diff --git a/.github/workflows/build-tree-sitter-prebuilds.yml b/.github/workflows/build-tree-sitter-prebuilds.yml index b592ebe16..d6e93536d 100644 --- a/.github/workflows/build-tree-sitter-prebuilds.yml +++ b/.github/workflows/build-tree-sitter-prebuilds.yml @@ -123,7 +123,7 @@ jobs: matrix: ${{ steps.decide.outputs.matrix }} release_app: ${{ steps.relapp.outputs.configured }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # need base history to diff recorded versions persist-credentials: false @@ -392,7 +392,7 @@ jobs: # and compiling them under emulation on the arm runners is slow. timeout-minutes: 45 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # this job uploads artifacts (artipacked) @@ -565,7 +565,7 @@ jobs: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ steps.app-token.outputs.token }} # On a (non-fork) PR, check out the PR's HEAD branch — not the merge ref — diff --git a/.github/workflows/ci-devcontainer.yml b/.github/workflows/ci-devcontainer.yml index 4a7eeb39a..73fd4e1ae 100644 --- a/.github/workflows/ci-devcontainer.yml +++ b/.github/workflows/ci-devcontainer.yml @@ -36,7 +36,7 @@ jobs: # persist-credentials: false — this job only reads (tests and syntax # checks) and never pushes. The setting keeps GITHUB_TOKEN out of # .git/config, which zizmor flags as the "artipacked" issue. - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -57,7 +57,7 @@ jobs: # persist-credentials: false — this is a read-only build smoke that # never pushes. The setting keeps GITHUB_TOKEN out of .git/config, # which zizmor flags as the "artipacked" issue. - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index a30371637..b8d813f9e 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -14,7 +14,7 @@ jobs: outputs: web_changed: ${{ steps.filter.outputs.web }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v3 @@ -31,7 +31,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index 1a2ac4728..dffb6550b 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -13,7 +13,7 @@ jobs: # canceled prettier at the 5-minute job cap; lint needed 7m41s the same run. timeout-minutes: 10 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -43,7 +43,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # tsc --noEmit reads source + gitnexus-shared/dist. Skip prepare/postinstall @@ -53,6 +53,9 @@ jobs: lifecycle-scripts: 'false' - run: npx tsc --noEmit working-directory: gitnexus + - name: Typecheck tests + run: npm run typecheck:tests + working-directory: gitnexus typecheck-web: runs-on: ubuntu-latest @@ -61,7 +64,7 @@ jobs: # run is cold again. timeout-minutes: 15 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: ./.github/actions/setup-gitnexus-web @@ -84,7 +87,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Validate workflow concurrency convention diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml index f1afd43b7..83a05c35e 100644 --- a/.github/workflows/ci-report.yml +++ b/.github/workflows/ci-report.yml @@ -125,7 +125,7 @@ jobs: - name: Checkout (for vitest config) if: steps.meta.outputs.skip != 'true' - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: sparse-checkout: gitnexus/vitest.config.ts sparse-checkout-cone-mode: false @@ -287,6 +287,7 @@ jobs: # ── Locate test results ── RESULTS_FILE=$(find_first "$DIR/test-reports" "test-results.json") WEB_RESULTS_FILE=$(find_first "$DIR/test-reports" "web-test-results.json") + PYTEST_RESULTS_FILE=$(find_first "$DIR/test-reports" "pytest-results.json") sum_results() { local file=$1 @@ -303,12 +304,21 @@ jobs: # framework, not as a top-line metric). read -r CLI_T CLI_P CLI_F CLI_S _ CLI_D <<< "$(sum_results "$RESULTS_FILE")" read -r WEB_T WEB_P WEB_F WEB_S _ WEB_D <<< "$(sum_results "$WEB_RESULTS_FILE")" + read -r PY_T PY_P PY_F PY_S _ PY_D <<< "$(sum_results "$PYTEST_RESULTS_FILE")" - TOTAL=$((CLI_T + WEB_T)) - PASSED=$((CLI_P + WEB_P)) - FAILED=$((CLI_F + WEB_F)) - SKIPPED=$((CLI_S + WEB_S)) + TOTAL=$((CLI_T + WEB_T + PY_T)) + PASSED=$((CLI_P + WEB_P + PY_P)) + FAILED=$((CLI_F + WEB_F + PY_F)) + SKIPPED=$((CLI_S + WEB_S + PY_S)) DURATION=$((CLI_D > WEB_D ? CLI_D : WEB_D)) + DURATION=$((DURATION > PY_D ? DURATION : PY_D)) + EXECUTION_ERRORS=0 + for rf in "$RESULTS_FILE" "$WEB_RESULTS_FILE" "$PYTEST_RESULTS_FILE"; do + if [ -n "$rf" ] && [ -f "$rf" ]; then + errors=$(jq -r '(.executionFailures // []) | length' "$rf") + EXECUTION_ERRORS=$((EXECUTION_ERRORS + errors)) + fi + done # ── Status helpers ── status_icon() { @@ -385,22 +395,22 @@ jobs: if [ "$TOTAL" -gt 0 ] 2>/dev/null; then echo "### Test Results" echo "" - echo "| Tests | Passed | Failed | Skipped | Duration |" + echo "| Tests | Passed | Failed | Unverified | Duration |" echo "|-------|--------|--------|---------|----------|" echo "| ${TOTAL} | ${PASSED} | ${FAILED} | ${SKIPPED} | ${DURATION}s |" echo "" - if [ "$FAILED" = "0" ]; then + if [[ "$FAILED" == "0" && "$SKIPPED" == "0" && "$EXECUTION_ERRORS" == "0" && "$TESTS" == "success" ]]; then echo "✅ All **${PASSED}** tests passed" else - echo "❌ **${FAILED}** failed / **${PASSED}** passed" + echo "❌ Test execution is incomplete or failed: **${FAILED}** failed, **${SKIPPED}** unverified, **${EXECUTION_ERRORS}** runner/suite errors." fi if [ "$SKIPPED" != "0" ]; then echo "" echo "
" - echo "${SKIPPED} test(s) skipped — expand for details" + echo "${SKIPPED} test(s) have no recorded pass — expand for details" echo "" - for rf in "$RESULTS_FILE" "$WEB_RESULTS_FILE"; do + for rf in "$RESULTS_FILE" "$WEB_RESULTS_FILE" "$PYTEST_RESULTS_FILE"; do if [ -n "$rf" ] && [ -f "$rf" ]; then jq -r ' .testResults[] diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index ee6f85511..6f5e3fb32 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -32,11 +32,12 @@ jobs: env: GITNEXUS_REQUIRE_FTS: '1' GITNEXUS_REQUIRE_ZIG: '1' + GITNEXUS_REQUIRE_VECTOR: '1' steps: # persist-credentials: false — runs tests + uploads a blob artifact; the # default-persisted token must not be capturable through it (zizmor # credential-persistence / artipacked audit). The job never pushes. - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: ./.github/actions/setup-gitnexus @@ -79,8 +80,8 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-blob-${{ matrix.shard }} - path: gitnexus/.vitest-reports/ - # .vitest-reports is a dotdir; upload-artifact excludes hidden files by + path: gitnexus/.vitest/blob/ + # .vitest is a dotdir; upload-artifact excludes hidden files by # default, which would upload an empty artifact and break the merge. include-hidden-files: true retention-days: 5 @@ -99,7 +100,7 @@ jobs: env: GITNEXUS_REQUIRE_FTS: '1' steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: ./.github/actions/setup-gitnexus @@ -109,19 +110,19 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: coverage-blob-* - path: gitnexus/.vitest-reports + path: gitnexus/.vitest/blob merge-multiple: true - name: Merge coverage + enforce thresholds run: >- - npx vitest --mergeReports + npx vitest --mergeReports=.vitest/blob --reporter=default - --reporter=json + --reporter=./scripts/execution-reporter.ts --outputFile=test-results.json --coverage --coverage.reporter=json-summary --coverage.reporter=json --coverage.reporter=text - --coverage.thresholdAutoUpdate=false + --coverage.thresholds.autoUpdate=false working-directory: gitnexus # gitnexus-shared already built by setup-gitnexus above - name: Install gitnexus-web dependencies @@ -131,7 +132,8 @@ jobs: run: >- npx vitest run --reporter=default - --reporter=json + --reporter=../gitnexus/scripts/execution-reporter.ts + --includeTaskLocation --outputFile=web-test-results.json working-directory: gitnexus-web - name: Run docker-server integration tests @@ -140,7 +142,7 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: test-reports + name: coverage-reports path: | gitnexus/coverage/coverage-summary.json gitnexus/coverage/coverage-final.json @@ -162,7 +164,7 @@ jobs: steps: - id: gen run: | - TOTAL=3 # cross-platform (windows/macOS) shards per OS + TOTAL=4 # cross-platform (windows/macOS) shards per OS COV_TOTAL=3 # ubuntu coverage shards (merged before thresholds) if [ "$TOTAL" -lt 1 ] || [ "$COV_TOTAL" -lt 1 ]; then echo "shard totals must be >= 1" >&2; exit 1 @@ -223,7 +225,7 @@ jobs: steps: # persist-credentials: false — runs tests only, never pushes (zizmor # credential-persistence / artipacked audit). - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: ./.github/actions/setup-gitnexus @@ -254,8 +256,48 @@ jobs: shell: bash env: SHARD: ${{ matrix.shard }}/${{ needs.shard-plan.outputs.total }} + GITNEXUS_TEST_REPORT: ${{ matrix.os }}-${{ matrix.shard }}.json run: npx tsx scripts/run-cross-platform.ts --shard="$SHARD" working-directory: gitnexus + - name: Upload platform execution report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: execution-${{ matrix.os }}-${{ matrix.shard }} + path: gitnexus/${{ matrix.os }}-${{ matrix.shard }}.json + if-no-files-found: error + retention-days: 5 + + # Branch protection still requires these six names from the three-shard + # matrix. Keep them as aggregate gates as the real matrix grows: every native + # shard and the complete execution audit must pass before any gate succeeds. + # These jobs only verify dependency results; tests run in cross-platform above. + protected-platform-checks: + name: ${{ matrix.os }} (platform-sensitive) ${{ matrix.check }}/3 + needs: [cross-platform, test-completeness] + if: always() + permissions: {} + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + check: [1, 2, 3] + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Require all native shards and the execution audit + shell: bash + env: + NATIVE_RESULT: ${{ needs.cross-platform.result }} + EXECUTION_RESULT: ${{ needs.test-completeness.result }} + run: | + echo "Compatibility gate for the former three-shard check name." + echo "All native shards: $NATIVE_RESULT" + echo "Complete execution audit: $EXECUTION_RESULT" + if [[ "$NATIVE_RESULT" != "success" || "$EXECUTION_RESULT" != "success" ]]; then + echo "::error::Every native shard and the execution audit must succeed." + exit 1 + fi # Tree-sitter ABI gate (#1922). Two halves, both blocking: # 1. Static, offline: assert every grammar's compiled ABI loads on the @@ -273,7 +315,7 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 20 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: ./.github/actions/setup-gitnexus @@ -317,7 +359,7 @@ jobs: # from a tarball and never pushes back; the token in .git/config would # be at risk of leaking through any future artifact-upload step # (zizmor artipacked audit). Disable upfront. - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # Skip prepare/postinstall/build here. `npm pack` runs prepack, which @@ -430,7 +472,7 @@ jobs: steps: # persist-credentials: false — builds and import-links only, never pushes # (zizmor credential-persistence / artipacked audit). - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -482,11 +524,8 @@ jobs: # heap, so parallel forks both skew the timings and OOM the worker pool — they # must run one file at a time. # - # go-pipeline-benchmark.test.ts is deliberately NOT included: its - # worker-pool (#1848) suite spins a real worker pool that exits unexpectedly - # under vitest's fork pool (reproduced in validation), which would make this - # gate flaky. Go is already guarded by its non-gated O(n^2) tripwire (runs in - # the main coverage job) plus its golden capture-parity test. + # Every opt-in suite is discovered by run-benchmarks.ts. A failing worker + # benchmark is a failure to investigate, never a reason to omit that suite. benchmarks: name: benchmarks (GITNEXUS_BENCH) runs-on: ubuntu-latest @@ -496,7 +535,7 @@ jobs: # and never pushes; the default-persisted token in .git/config would be at # risk of leaking through an artifact upload (zizmor credential-persistence # / artipacked audit). Mirrors the packaged-install-smoke job below. - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: ./.github/actions/setup-gitnexus @@ -876,28 +915,18 @@ jobs: - name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial) if: ${{ !cancelled() }} - # cpp-adl-benchmark.test.ts and csharp-razor-view-components-benchmark.test.ts - # are not `*-pipeline-benchmark.test.ts` files but belong here for the - # same reason: they are skipIf-gated on GITNEXUS_BENCH, so the scaling - # guards they hold never run in the main coverage job. env: - GITNEXUS_BENCH: '1' GITNEXUS_WORKER_READY_TIMEOUT_MS: '60000' - run: >- - npx vitest run --no-file-parallelism - test/integration/cobol-pipeline-benchmark.test.ts - test/integration/csharp-pipeline-benchmark.test.ts - test/integration/csharp-razor-view-components-benchmark.test.ts - test/integration/objective-c-pipeline-benchmark.test.ts - test/integration/cpp-adl-benchmark.test.ts - test/integration/data-route-table-benchmark.test.ts - test/integration/instance-ownership-pipeline-benchmark.test.ts - test/integration/spring-bean-resource-benchmark.test.ts - test/integration/spring-dynamic-lookup-benchmark.test.ts - test/integration/rust-pipeline-benchmark.test.ts - test/integration/php-pipeline-benchmark.test.ts - test/integration/ruby-pipeline-benchmark.test.ts + run: npm run test:benchmarks working-directory: gitnexus + - name: Upload benchmark execution report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: execution-benchmarks + path: gitnexus/benchmarks.json + if-no-files-found: error + retention-days: 5 # Locked eval suite. setup-uv and uv itself are immutable so CI exercises # exactly the dependency graph developers run from eval/uv.lock. @@ -907,17 +936,97 @@ jobs: timeout-minutes: 15 steps: # persist-credentials: false — runs tests only, never pushes. - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: version: '0.11.23' python-version: '3.13' enable-cache: true cache-dependency-glob: eval/uv.lock - - run: uv run --locked --extra dev python -m pytest tests -q + - run: uv run --locked --extra dev python -m pytest tests -q --junitxml=pytest-locked.xml working-directory: eval + - name: Upload pytest execution report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: execution-pytest-locked + path: eval/pytest-locked.xml + if-no-files-found: error + retention-days: 5 + - uses: ./.github/actions/setup-gitnexus + with: + build: 'true' + - name: Exercise real workflow evidence preflight + if: ${{ !cancelled() }} + run: >- + npx vitest run test/unit/skill-evolution-workflow.test.ts + --reporter=default --reporter=./scripts/execution-reporter.ts --outputFile=preflight.json + working-directory: gitnexus + - name: Upload preflight execution report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: execution-preflight + path: gitnexus/preflight.json + if-no-files-found: error + retention-days: 5 + + test-completeness: + name: every test executed + needs: + [ + shard-plan, + coverage-merge, + cross-platform, + benchmarks, + eval-tests, + eval-containment-linux, + eval-containment-windows, + ] + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup-gitnexus + - name: Download coverage and web reports + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: coverage-reports + - name: Download all execution receipts + if: ${{ !cancelled() }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: execution-* + path: execution-reports + merge-multiple: true + - name: Require a real pass for every collected test + env: + PLATFORM_SHARDS: ${{ needs.shard-plan.outputs.total }} + run: | + cp gitnexus/test-results.json execution-reports/coverage.json + cd gitnexus + node --import tsx scripts/test-completeness.ts ../execution-reports test-results.json "$PLATFORM_SHARDS" + - name: Require a real pass for every pytest case + if: ${{ !cancelled() }} + run: python3 eval/check_test_execution.py execution-reports eval/pytest-results.json + - name: Upload complete test reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-reports + path: | + gitnexus/coverage/coverage-summary.json + gitnexus/coverage/coverage-final.json + gitnexus/test-results.json + gitnexus-web/web-test-results.json + eval/pytest-results.json + if-no-files-found: error + retention-days: 5 # Native Linux ownership and Bubblewrap boundary. The environment flag makes # the real namespace test mandatory; a missing/blocked bwrap is a failure. @@ -934,7 +1043,7 @@ jobs: GITNEXUS_REQUIRE_FULL_SWEEP: '1' GITNEXUS_REQUIRE_CLAUDE_CANARY: '1' steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -944,7 +1053,7 @@ jobs: cache-dependency-path: | gitnexus/package-lock.json gitnexus-shared/package-lock.json - - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: version: '0.11.23' python-version: '3.13' @@ -992,8 +1101,19 @@ jobs: tests/test_workflow_bench_sessions.py tests/test_ce_plugin_runtime.py tests/test_offline_sweep_integration.py - tests/test_mock_provider.py -q + tests/test_mock_provider.py + tests/test_evolve.py::test_outer_runner_pid_namespace_kills_setsid_descendant + tests/test_oracle_assets.py::test_hidden_vitest_config_executes_sibling_oracle_against_candidate_checkout + -q --junitxml=pytest-ubuntu.xml working-directory: eval + - name: Upload pytest execution report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: execution-pytest-ubuntu + path: eval/pytest-ubuntu.xml + if-no-files-found: error + retention-days: 5 # Native Windows Job Object canary. POSIX-only tests skip by platform, while # the grandchild delayed-write test must execute and pass on this runner. @@ -1002,10 +1122,10 @@ jobs: runs-on: windows-latest timeout-minutes: 15 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: version: '0.11.23' python-version: '3.13' @@ -1015,5 +1135,13 @@ jobs: run: >- uv run --locked --extra dev python -m pytest tests/test_process_control.py tests/test_model_gateway.py - -k "not locked_litellm" -q + -k "not locked_litellm" -q --junitxml=pytest-windows.xml working-directory: eval + - name: Upload pytest execution report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: execution-pytest-windows + path: eval/pytest-windows.xml + if-no-files-found: error + retention-days: 5 diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 0856c132b..ffc6c77e7 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -129,7 +129,7 @@ jobs: core.setOutput('code_review', isCodeReview ? 'true' : 'false'); - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.repo || github.repository }} ref: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.sha || '' }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9075d8c90..b22dc7650 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -42,13 +42,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Don't leave GITHUB_TOKEN in .git/config for downstream steps to read. persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: languages: ${{ matrix.language }} queries: security-and-quality @@ -87,6 +87,6 @@ jobs: - '.github/scripts/fetch-lbug-fts-artifacts.mjs' - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/commit-fork-prebuilds.yml b/.github/workflows/commit-fork-prebuilds.yml index 3b6ef78d3..a45a3f5ba 100644 --- a/.github/workflows/commit-fork-prebuilds.yml +++ b/.github/workflows/commit-fork-prebuilds.yml @@ -145,7 +145,7 @@ jobs: # checkout (the same trust anchor as this workflow file). - name: Checkout identity verifier if: steps.meta.outputs.deliver == 'true' - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false sparse-checkout: .github/scripts/verify-workflow-run-pr-identity.cjs @@ -171,7 +171,7 @@ jobs: # never written to .git/config on disk. - name: Checkout fork PR head if: steps.meta.outputs.deliver == 'true' && steps.verify.outcome == 'success' - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ steps.verify.outputs.head_repo }} ref: ${{ steps.verify.outputs.head_sha }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 58740cd09..714627717 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 80e4588a8..0e43de45e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -103,7 +103,7 @@ jobs: # When triggered by workflow_call the caller passes the RC tag as an input; # we check out that tag so the Dockerfile and package.json match the built image. # For tag-push events github.ref is already the tag ref — no override needed. - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.tag || github.ref }} diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml index 3f0c50f4e..861b37ff2 100644 --- a/.github/workflows/gitleaks.yml +++ b/.github/workflows/gitleaks.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Full history needed for the on-push full-history scan; on PRs the # action diffs against the base ref so the cost is bounded by the PR. diff --git a/.github/workflows/gitnexus-review-agent.yml b/.github/workflows/gitnexus-review-agent.yml index 8ed65559e..5a4b4a2ea 100644 --- a/.github/workflows/gitnexus-review-agent.yml +++ b/.github/workflows/gitnexus-review-agent.yml @@ -303,7 +303,7 @@ jobs: - name: Checkout trusted workflow control plane id: checkout-control if: steps.context.outputs.ready == 'true' - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ github.repository }} ref: ${{ steps.context.outputs.control_sha }} @@ -315,7 +315,7 @@ jobs: - name: Checkout exact PR head as passive data id: checkout-head if: steps.context.outputs.ready == 'true' - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ steps.context.outputs.head_repo }} ref: ${{ steps.context.outputs.head_sha }} @@ -1294,7 +1294,7 @@ jobs: steps.claude-recheck.outcome == 'success' # Use the low-level base action: the high-level GitHub action can restore # project configuration from a moving base branch before invoking Claude. - uses: anthropics/claude-code-action/base-action@3553f84341b92da26052e28acf1aa898f9511f32 # v1 + uses: anthropics/claude-code-action/base-action@e0cf66d1d257526b5d07f141838c338921cb8455 # v1 env: CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1' CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '0' @@ -1447,7 +1447,7 @@ jobs: if: >- steps.precheck.outputs.repair_reason != '' && steps.repair-recheck.outcome == 'success' - uses: anthropics/claude-code-action/base-action@3553f84341b92da26052e28acf1aa898f9511f32 # v1 + uses: anthropics/claude-code-action/base-action@e0cf66d1d257526b5d07f141838c338921cb8455 # v1 env: CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1' CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '0' diff --git a/.github/workflows/gitnexus-skill-evolution.yml b/.github/workflows/gitnexus-skill-evolution.yml index 03b3655ca..867ce49a3 100644 --- a/.github/workflows/gitnexus-skill-evolution.yml +++ b/.github/workflows/gitnexus-skill-evolution.yml @@ -252,7 +252,7 @@ jobs: exit 1 fi - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 @@ -265,7 +265,7 @@ jobs: gitnexus/package-lock.json gitnexus-shared/package-lock.json - - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: version: '0.11.23' python-version: '3.13' diff --git a/.github/workflows/grammar-update-monitor.yml b/.github/workflows/grammar-update-monitor.yml index 7380af269..7cc8313a1 100644 --- a/.github/workflows/grammar-update-monitor.yml +++ b/.github/workflows/grammar-update-monitor.yml @@ -44,7 +44,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/impact-pdg-mutation-report.yml b/.github/workflows/impact-pdg-mutation-report.yml index 42903acb9..e09970da4 100644 --- a/.github/workflows/impact-pdg-mutation-report.yml +++ b/.github/workflows/impact-pdg-mutation-report.yml @@ -37,7 +37,7 @@ jobs: # artifact and never pushes; the default-persisted token in .git/config # must not be capturable through that upload (zizmor credential-persistence # / artipacked audit). Mirrors ci-tests.yml. - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/pr-autofix-apply.yml b/.github/workflows/pr-autofix-apply.yml index f612d3d31..78e90cced 100644 --- a/.github/workflows/pr-autofix-apply.yml +++ b/.github/workflows/pr-autofix-apply.yml @@ -336,7 +336,7 @@ jobs: # Push auth is provided inline at push time via the URL. - name: Checkout PR head if: steps.locate.outputs.found == 'true' - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v5.0.4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ steps.locate.outputs.head_repo }} ref: ${{ steps.locate.outputs.head_sha }} diff --git a/.github/workflows/pr-autofix-publish.yml b/.github/workflows/pr-autofix-publish.yml index 0fffad2ff..360b30986 100644 --- a/.github/workflows/pr-autofix-publish.yml +++ b/.github/workflows/pr-autofix-publish.yml @@ -131,7 +131,7 @@ jobs: # check-run SHA cannot be an unverified artifact field. # Mismatch => fail loud BEFORE any sticky/check-run side effect. - name: Checkout identity verifier - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false sparse-checkout: .github/scripts/verify-workflow-run-pr-identity.cjs diff --git a/.github/workflows/pr-autofix.yml b/.github/workflows/pr-autofix.yml index a74152049..2fec543aa 100644 --- a/.github/workflows/pr-autofix.yml +++ b/.github/workflows/pr-autofix.yml @@ -51,7 +51,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # PR head commit (not the synthetic merge ref) — we need the # exact tree the contributor pushed so suggestions line up. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 57f50c4fd..4cf9b7c55 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -162,7 +162,7 @@ jobs: should_run: ${{ steps.decide.outputs.should_run }} head_sha: ${{ steps.decide.outputs.head_sha }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 fetch-tags: true @@ -332,7 +332,7 @@ jobs: # on the RC path. - name: Checkout (RC) if: needs.route.outputs.mode == 'rc' - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 fetch-tags: true @@ -349,7 +349,7 @@ jobs: - name: Checkout (stable) if: needs.route.outputs.mode == 'stable' - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # No `token:` — actions/checkout uses GITHUB_TOKEN by default. Stable # path performs no git pushes; the default scope is sufficient. with: diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index d22add2ff..92e62f6ff 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -33,7 +33,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -53,6 +53,6 @@ jobs: retention-days: 5 - name: Upload to Security tab - uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: sarif_file: results.sarif diff --git a/.github/workflows/skill-sync.yml b/.github/workflows/skill-sync.yml index d029a79a8..db2a8befc 100644 --- a/.github/workflows/skill-sync.yml +++ b/.github/workflows/skill-sync.yml @@ -47,7 +47,7 @@ jobs: timeout-minutes: 15 steps: # persist-credentials: false — runs a read-only test, never pushes. - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/tree-sitter-upgrade-readiness.yml b/.github/workflows/tree-sitter-upgrade-readiness.yml index cddaebd8d..690d24479 100644 --- a/.github/workflows/tree-sitter-upgrade-readiness.yml +++ b/.github/workflows/tree-sitter-upgrade-readiness.yml @@ -52,7 +52,7 @@ jobs: report: ${{ steps.readiness.outputs.report }} exit_code: ${{ steps.readiness.outputs.exit_code }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/triage-sweep.yml b/.github/workflows/triage-sweep.yml index 4f39f94f2..b7c2e1d52 100644 --- a/.github/workflows/triage-sweep.yml +++ b/.github/workflows/triage-sweep.yml @@ -59,7 +59,7 @@ jobs: timeout-minutes: 30 steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: sparse-checkout: .github/scripts/triage sparse-checkout-cone-mode: false diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index ce1a1ca96..f8c52b104 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -45,7 +45,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -53,7 +53,7 @@ jobs: uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Build image (load locally for scan) - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: ${{ matrix.image.dockerfile }} @@ -76,7 +76,7 @@ jobs: exit-code: '0' - name: Upload to Security tab - uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: sarif_file: trivy-${{ matrix.image.name }}.sarif category: trivy-${{ matrix.image.name }} diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index c3aeb5fa8..03ce6b804 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -31,7 +31,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -53,7 +53,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -76,7 +76,7 @@ jobs: continue-on-error: true - name: Upload SARIF - uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: sarif_file: zizmor.sarif category: zizmor diff --git a/.gitignore b/.gitignore index 873f4082f..176a20e61 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ npm-debug.log* # Testing coverage/ +.vitest/ .tmp-test/ gitnexus/.tmp-test/ @@ -131,5 +132,13 @@ local_docs/ .context/ gitnexus/web/ +# Local copies of CI execution receipts. +gitnexus/benchmarks.json +gitnexus/preflight.json +gitnexus/test-results.json +gitnexus/windows-latest-*.json +gitnexus/macos-latest-*.json +gitnexus-web/web-test-results.json + # Machine-local skill-evolution evidence (consumed by eval/workflow_bench/evolve.py) eval/workflow_bench/learnings.jsonl diff --git a/README.md b/README.md index 4f4f5d4c7..e805a18be 100644 --- a/README.md +++ b/README.md @@ -611,11 +611,13 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max | Variable | Default | Effect | Tune when… | | ----------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GITNEXUS_TEST_REPORT` | unset | Write a JSON execution receipt from `scripts/run-cross-platform.ts`; accepts a basename ending in `.json`. | CI platform shards use distinct filenames so the completeness check can require every report. | | `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers `. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. | | `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. | | `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. | | `GITNEXUS_EMBEDDING_RETRY_TIMEOUTS` | unset | When truthy (`1`/`true`/`yes`), per-attempt HTTP embedding timeouts (`TimeoutError` on fetch or body read) go through the bounded `GITNEXUS_EMBEDDING_MAX_ATTEMPTS` retry loop instead of failing the job. Any other value leaves it off, so cloud/default timeouts remain terminal. | Local accelerators that drop a device lock when the client disconnects and succeed on the next request (observed with FastFlowLM on Ryzen AI). | | `GITNEXUS_EMBEDDING_SIDECAR_TIMEOUT_MS` | `180000` (3 minutes) | Per-request IPC timeout for local embedding sidecar embed batches. On overrun the parent SIGKILLs the sidecar child and rejects the batch. Init still uses the HF download budget (`HF_DOWNLOAD_TIMEOUT_MS` × attempts), not this knob. | Large embed batches or slow local ONNX inference cause sidecar request timeouts during `analyze --embeddings`, `embeddings sync`, serve, or MCP. | +| `GITNEXUS_VECTOR_MAX_DISTANCE` | `0.6` for CLI/MCP `query`; `0.5` for standalone semantic search | Maximum cosine distance for vector hits, applied to both indexed search and exact-scan fallback. Hits must have `distance < cutoff`. Unset/blank uses the default; non-numeric, non-finite, zero, or negative values warn and use the default; finite values above `2` warn and clamp to `2`. | Paraphrased queries have weak semantic recall with your embedding model. See [Vector cutoff tuning](#vector-cutoff-tuning). | | `GITNEXUS_ANALYZER_IDENTITY_IN_PROCESS_GUARDS` | unset | When truthy (`1`/`true`/`yes`), forces in-process cache-guard validation once a batch has ≥128 requests. In-process mode also auto-selects when `packageRoot`/`buildRoot` fail `W_OK` with `EACCES`/`EROFS`. Otherwise those large batches use a Node subprocess probe. Batches under 128 always stay in-process. | Trusted or read-only installs where two identity subprocess spawns per analyze dominate wall time; leave unset to keep the default isolation path on writable trees. | | `GITNEXUS_RESOLVE_DEF_GRAPH_ID_MEMO` | on (unset) | Memoizes `resolveDefGraphId` per `nodeLookup` instance (WeakMap). Enabled by default. Set to `0`/`false`/`off`/`no` to disable and recompute on every call (debug / bisect memo bugs). | Suspecting stale graph-id resolution after a lookup rebuild, or comparing memo vs uncached cost on a large index. | | `GITNEXUS_AUTH_TOKEN` | unset | Bearer token required when `eval-server` binds beyond loopback. May also be read from `.env.local` or `.env`; shell values take precedence. | Exposing the evaluation HTTP tools to a container, VM, or LAN. | @@ -655,6 +657,22 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max | `GITNEXUS_PUBLIC_ORIGIN` | unset | The single browser origin `serve` is reached through, added to the CORS allowlist and to the write-route origin guard. A wildcard bind (`0.0.0.0`) has no host identity, so without this the server's own UI is refused. **Setting it currently refuses to start:** `serve` has no authentication, requests carrying no `Origin` header already reach `POST /api/analyze` and `DELETE /api/repo`, and this is the setting that would admit browser writes on top of that. Matching rules for when the gate lifts: the hostname must match exactly, and so must the scheme. A value with no scheme (`app.example.com`) means `https`, since a bare host comes from platform service discovery and those terminate TLS; spell out `http://app.example.com` for plain HTTP. An explicit port must match; with no port, any port on that hostname is accepted. Anything that is not one reachable host (a list, `*`, a bare port number, a `:0` port, a trailing dot) warns at startup and allows nothing. | `gitnexus serve` runs behind a reverse proxy or on a wildcard bind, and the UI's index/delete requests return `origin_not_allowed`. | | `GITNEXUS_TRUST_PROXY` | `loopback, linklocal, uniquelocal` | Express `trust proxy` value — which upstream hops may set `X-Forwarded-*`, and so what the per-IP rate limiter reads as the client IP. Set it to the exact number of proxies you control. Every hop past that is one more entry of the chain the caller gets to write. `false`/`no`/`off` (and a `0` hop count) trust no hop; a proxy list Express can compile (`loopback`, `10.0.0.0/8, 127.0.0.1`) names them instead. `true`/`yes`/`on` is **rejected**: it reads the client-controlled leftmost `X-Forwarded-For` entry, so a spoofed chain earns a fresh rate-limit key per request, and express-rate-limit rejects it too (`ERR_ERL_PERMISSIVE_TRUST_PROXY`). Counts above `16` are rejected as well, as a sanity ceiling rather than a safety boundary. Any invalid value warns and falls back to the default. Bind non-loopback with this unset and `serve` warns: a load balancer outside the private ranges is untrusted, so every request keys to the balancer and the per-IP limit becomes one shared limit. | `serve` sits behind a load balancer outside the private ranges (AWS ALB, Cloudflare, CGNAT), where every request otherwise collapses to the proxy hop and rate limiting goes global. | +### Vector cutoff tuning + +`GITNEXUS_VECTOR_MAX_DISTANCE` controls which vector candidates enter hybrid search. Cosine distance is lower for more similar vectors; the appropriate cutoff depends on the embedding model and repository. A higher cutoff can recover useful semantic matches, but can also admit less relevant hits and change the fused ranking. The default is `0.6` for CLI and MCP `query`, and `0.5` for the standalone semantic-search function. + +For an index that already has embeddings, compare the default with `0.8` and `1.0` on representative queries, including paraphrases, exact symbol names, and queries that should have no relevant result: + +```bash +GITNEXUS_VECTOR_MAX_DISTANCE=0.8 gitnexus query "how are expired sessions removed" --limit 10 +``` + +Check both whether the expected symbols appear and where they rank. In [#3457](https://github.com/abhigyanpatwari/GitNexus/issues/3457), the reporter's `voyage-code-4` sample found 20 of 32 paraphrased targets at `0.6`, 25 at `0.8`, and 28 at `1.0`. Those values are a tuning starting point for that model, not universal recommendations: one paraphrased target fell from first to twelfth at `1.0`. The sample covered eight repositories, recorded no raw distances, used agent-written queries after reading the code, and did not send Voyage's `input_type`. + +Set the variable in the process that runs the search. For MCP, add it to the server's launch environment and restart the server; for `gitnexus serve`, set it in that process's environment and restart it. Setting it only during `analyze`, or exporting it in another shell, does not configure an existing server. Changing the cutoff requires no reindex. + +The cutoff only filters available vector candidates. It cannot add missing embeddings or repair a mismatch between the indexing and query-time embedding configuration; see the [embeddings runbook](RUNBOOK.md#embeddings). A cutoff of `2` is very permissive, but still rejects distance exactly `2` and retains the search candidate limits. +
diff --git a/RUNBOOK.md b/RUNBOOK.md index 2f2c06f14..e2e1de94c 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -87,6 +87,14 @@ npx gitnexus analyze --force If it recurs, the cause is almost always environmental rather than a code defect: check free disk space on the volume holding `.gitnexus/`, make sure no second `analyze` is running against the same repo (both use `.gitnexus/csv` for staging), then run `npx gitnexus doctor`. The check compares in-memory relationship totals (including streamed rows) against what the DB hands back, and is deliberately skipped on incremental runs, where the two counts are not comparable. +**Weak semantic recall despite existing embeddings:** If paraphrased queries look like keyword-only search, the distance cutoff may be too strict for the embedding model. CLI and MCP `query` default to `0.6`. From the indexed repository, try a broader cutoff: + +```bash +GITNEXUS_VECTOR_MAX_DISTANCE=0.8 npx gitnexus query "how are expired sessions removed" --limit 10 +``` + +Compare target inclusion and ranking against the default; a broader cutoff also admits less relevant hits. Set the variable in the MCP/serve launch environment and restart that process when tuning a server. A cutoff change needs no reindex and setting it only during `analyze` does not persist it. If the index has no vectors, generate embeddings first; if the embedding model or dimensions differ between indexing and querying, align that configuration and regenerate vectors. See [Vector cutoff tuning](README.md#vector-cutoff-tuning) for validation rules and the limited `voyage-code-4` evidence from #3457. + **Large repos:** Analyze may skip or limit embedding work when node counts are very high; watch CLI output. --- diff --git a/TESTING.md b/TESTING.md index 9febb8283..450a0c792 100644 --- a/TESTING.md +++ b/TESTING.md @@ -19,6 +19,7 @@ From `gitnexus/`: | Command | What it runs | When to use | | ----------------------------- | -------------------------------------------------- | ------------------------------------- | | `npm test` | Full suite (all 3 vitest projects) | Before opening a PR | +| `npm run typecheck:tests` | TypeScript checks for source, tests, and helpers | Before opening a PR | | `npm run test:unit` | Unit tests only (`test/unit/`) | Tight development loop | | `npm run test:integration` | Integration tests (`test/integration/`) | After changing pipelines, DB, workers | | `npm run test:coverage` | Full suite + v8 coverage with thresholds | Checking coverage impact | @@ -26,6 +27,11 @@ From `gitnexus/`: | `npm run test:cross-platform` | Platform-sensitive subset only | Debugging a Windows/macOS issue | | `npm run test:watch` | Vitest in watch mode | Active development | +Vitest transpiles TypeScript without checking types. Run `npm run typecheck:tests` +in `gitnexus/` to check test code and helpers against the production types. This +uses `tsc --noEmit -p tsconfig.test.json`; parser input files under `test/fixtures/` +are excluded because they are sample source code, not part of the test program. + ### `gitnexus-web/` commands From `gitnexus-web/`: @@ -41,7 +47,7 @@ From `gitnexus-web/`: ```bash # gitnexus-shared/dist must exist first. `npm install` / `npm run build` in # gitnexus/ compiles it via parent `lib/tsc.js` (do not npm ci gitnexus-shared). -cd gitnexus && npx tsc --noEmit && npm test +cd gitnexus && npx tsc --noEmit && npm run typecheck:tests && npm test cd ../gitnexus-web && npx tsc -b --noEmit && npm test ``` @@ -119,7 +125,45 @@ GitHub Actions (`.github/workflows/ci.yml`) orchestrate: | `ci-scope-parity.yml` | discover, parity | Scope-resolution parity for all migrated languages | | `ci-e2e.yml` | e2e (chromium) | Playwright E2E, gated on `gitnexus-web/**` changes | -The `CI Gate` job in `ci.yml` is the single required check for branch protection. It requires quality, tests, e2e, and scope-parity to all pass. +The `CI Gate` job in `ci.yml` requires the quality and test workflows to pass. +The browser E2E workflow must pass or be skipped because no web files changed. + +Branch protection also requires six platform check names from the former +three-shard matrix. These names remain as aggregate gates: all native shards +and the `every test executed` audit must succeed before any of them passes. +Failed, cancelled, skipped, or missing dependency results fail these gates. +The actual native tests run in the current Windows/macOS shard matrix. + +The `typecheck` job runs both the production compiler check and +`npm run typecheck:tests`. A type error in either check fails the job and the CI gate. + +### Complete execution, including platform and benchmark tests + +The required `every test executed` job reconciles execution receipts from Ubuntu +coverage, every Windows/macOS shard, the serial benchmark run, and the real Python +workflow preflight. It requires a recorded pass for every collected test. A skip +on Linux is satisfied only by a pass of that exact test in another required job. +Test identities include the file, suite/title and source location; ambiguous +parameterized cases must have unique titles. Missing receipts, missing test files, +unhandled runner errors, failed hooks, failed assertions, and tests with no pass +all fail the gate. Web tests are checked separately with the same rules. + +The locked pytest suite and Linux/Windows containment jobs also upload JUnit +receipts. The same gate checks every Python test file was collected and every +case passed in at least one job, while preserving failures from any job. The +Linux containment job supplies Bubblewrap, the pinned CLI, and built Vitest +dependencies for tests that cannot run in the basic Python job. Python results +are included in the combined PR report. + +The PR report shows the reconciled result as **Unverified**. Zero means every test +has execution evidence; individual OS logs still show tests that require another +OS as skipped. Failed executions remain failures even if another job passes. + +`npm run test:benchmarks` discovers all tests gated by `GITNEXUS_BENCH` and runs +them serially. Keep timing measurements out of parallel coverage workers. The +`eval-tests` job installs the locked Python dependencies and runs the workflow +preflight Vitest tests as well as pytest; those tests must exercise real Python +validation, never a stubbed success. ## Regression testing diff --git a/docker-server.test.mjs b/docker-server.test.mjs index 6d2a9f6c2..309edf3b5 100644 --- a/docker-server.test.mjs +++ b/docker-server.test.mjs @@ -331,8 +331,8 @@ const respondOk = (_req, res) => { // // upstream request handler, replaceable mid-test via `ctx.handler`; // null points the proxy at a port nothing ever listens on -// listenAfterMs bind the upstream this late, so the first attempt(s) hit -// ECONNREFUSED (a single-instance restart window) +// listenAfterMs bind the upstream this late after the first refused attempt +// (a single-instance restart window) // schemeless drop http:// from GITNEXUS_UPSTREAM_URL, the way Render's // `fromService: { property: hostport }` yields it // env extra environment for docker-server.mjs @@ -366,18 +366,15 @@ async function withProxy( }) : null; - // A late (or never) bind needs its port reserved up front; otherwise let the - // OS assign one at listen time. - const upstreamPort = - server && listenAfterMs === 0 - ? await new Promise((r) => server.listen(0, '127.0.0.1', () => r(server.address().port))) - : await getFreePort(); - const bindTimer = - server && listenAfterMs > 0 - ? setTimeout(() => server.listen(upstreamPort, '127.0.0.1'), listenAfterMs) - : null; - + // Keep the upstream port bound until the proxy port is chosen. Releasing it + // sooner lets the OS assign both services the same port and proxy to itself. + const reservation = server ?? createServer(); + const upstreamPort = await new Promise((r) => + reservation.listen(0, '127.0.0.1', () => r(reservation.address().port)), + ); const port = await getFreePort(); + let bindTimer = null; + const target = `127.0.0.1:${upstreamPort}`; const proc = spawnServerWithEnv(dir, port, { GITNEXUS_UPSTREAM_URL: schemeless ? target : `http://${target}`, @@ -390,18 +387,25 @@ async function withProxy( ...env, }); proc.stderr.setEncoding('utf8'); - proc.stderr.on('data', (chunk) => { + const collectStderr = (chunk) => { ctx.stderr += chunk; - }); + // Process startup must not consume the restart window or skip the retry. + if (server && listenAfterMs > 0 && !bindTimer && ctx.stderr.includes('ECONNREFUSED; retry')) { + bindTimer = setTimeout(() => server.listen(upstreamPort, '127.0.0.1'), listenAfterMs); + } + }; + proc.stderr.on('data', collectStderr); try { await waitForServer(port); + if (!server || listenAfterMs > 0) await new Promise((r) => reservation.close(r)); await fn(port, ctx); } finally { + proc.stderr.off('data', collectStderr); if (bindTimer) clearTimeout(bindTimer); await killAndWait(proc); - if (server?.listening) { - server.closeAllConnections?.(); - await new Promise((r) => server.close(r)); + if (reservation.listening) { + reservation.closeAllConnections?.(); + await new Promise((r) => reservation.close(r)); } await rm(dir, { recursive: true, force: true }); } @@ -661,8 +665,8 @@ it('returns 502 when the upstream is unreachable', async () => { // -- Connection-retry across an upstream restart window --------------------- // -// `listenAfterMs: 400` binds the upstream late, so the first attempt hits -// ECONNREFUSED and must be retried — a single-instance restart. The default 3 +// `listenAfterMs: 400` binds the upstream 400ms after the first ECONNREFUSED, +// so the request must be retried — a single-instance restart. The default 3 // attempts (backoff 250ms, 500ms) span ~750ms, so a retry lands after the bind. it('retries a connection-refused POST and succeeds once the upstream is up', async () => { @@ -676,6 +680,7 @@ it('retries a connection-refused POST and succeeds once the upstream is up', asy assert.match(res.body, /"ok":true/); assert.equal(ctx.calls, 1, 'upstream must run the job exactly once (no double-execute)'); assert.equal(ctx.body, '{"repo":"x"}', 'buffered body replayed intact'); + assert.match(ctx.stderr, /ECONNREFUSED; retry/, 'the restart gap must exercise a retry'); }); }); diff --git a/eval/check_test_execution.py b/eval/check_test_execution.py new file mode 100644 index 000000000..1d5e0f71c --- /dev/null +++ b/eval/check_test_execution.py @@ -0,0 +1,121 @@ +"""Require a real passing execution of every pytest case across the CI jobs.""" + +from __future__ import annotations + +import json +from pathlib import Path +import sys +import xml.etree.ElementTree as ET + + +RECEIPTS = ("pytest-locked.xml", "pytest-ubuntu.xml", "pytest-windows.xml") + + +def reconcile(reports: list[Path], expected_files: list[str]) -> dict: + if not reports: + raise ValueError("No pytest execution reports supplied") + tests: dict[tuple[str, str], set[str]] = {} + durations: list[float] = [] + for report in reports: + root = ET.parse(report).getroot() + suites = [root] if root.tag == "testsuite" else list(root.findall("testsuite")) + if root.tag not in {"testsuite", "testsuites"} or not suites: + raise ValueError(f"Invalid pytest report: {report}") + seen: set[tuple[str, str]] = set() + duration = 0.0 + for suite in suites: + cases = suite.findall("testcase") + if not cases: + raise ValueError(f"Empty pytest suite in {report}") + counts = {"tests": len(cases), "failures": 0, "errors": 0, "skipped": 0} + for case in cases: + key = (case.get("classname", ""), case.get("name", "")) + if not all(key) or key in seen: + raise ValueError(f"Missing or ambiguous pytest identity in {report}: {key}") + seen.add(key) + for status in ("failures", "errors", "skipped"): + tag = {"failures": "failure", "errors": "error", "skipped": "skipped"}[status] + counts[status] += len(case.findall(tag)) + status = ( + "failed" + if case.find("failure") is not None or case.find("error") is not None + else "pending" + if case.find("skipped") is not None + else "passed" + ) + tests.setdefault(key, set()).add(status) + for name, count in counts.items(): + if int(suite.get(name, "-1")) != count: + raise ValueError(f"Inconsistent pytest {name} count in {report}") + duration += float(suite.get("time", "0")) + durations.append(duration) + + modules = {module for module, _ in tests} + for file in expected_files: + module = file.removesuffix(".py").replace("/", ".") + if not any(name == module or name.startswith(module + ".") for name in modules): + raise ValueError(f"Pytest file was never collected: {file}") + + suites_by_module: dict[str, dict] = {} + failed: list[str] = [] + pending = 0 + for (module, name), statuses in sorted(tests.items()): + status = "failed" if "failed" in statuses else "passed" if "passed" in statuses else "pending" + full_name = f"{module}::{name}" + if status == "failed": + failed.append(full_name) + pending += status == "pending" + suite = suites_by_module.setdefault( + module, + { + "name": module, + "status": "passed", + "assertionResults": [], + "endTime": round(max(durations) * 1000), + }, + ) + suite["assertionResults"].append( + { + "fullName": full_name, + "ancestorTitles": [module], + "title": name, + "status": status, + } + ) + if status == "failed": + suite["status"] = "failed" + return { + "success": not failed and pending == 0, + "numTotalTests": len(tests), + "numPassedTests": len(tests) - len(failed) - pending, + "numFailedTests": len(failed), + "numPendingTests": pending, + "numTotalTestSuites": len(suites_by_module), + "numFailedTestSuites": sum(s["status"] == "failed" for s in suites_by_module.values()), + "executionFailures": failed, + "startTime": 0, + "testResults": list(suites_by_module.values()), + } + + +def main() -> int: + if len(sys.argv) != 3: + raise ValueError("Usage: check_test_execution.py ") + directory, output = map(Path, sys.argv[1:]) + root = Path(__file__).resolve().parent + expected = [p.relative_to(root).as_posix() for p in (root / "tests").rglob("test_*.py")] + report = reconcile([directory / name for name in RECEIPTS], expected) + output.write_text(json.dumps(report), encoding="utf-8") + print( + f"Eval execution: {report['numPassedTests']}/{report['numTotalTests']} passed; " + f"{report['numPendingTests']} unverified; {report['numFailedTests']} failures" + ) + for suite in report["testResults"]: + for case in suite["assertionResults"]: + if case["status"] != "passed": + print(f"{case['status']}: {case['fullName']}", file=sys.stderr) + return 0 if report["success"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/eval/tests/test_check_test_execution.py b/eval/tests/test_check_test_execution.py new file mode 100644 index 000000000..22950081f --- /dev/null +++ b/eval/tests/test_check_test_execution.py @@ -0,0 +1,142 @@ +"""The execution gate must reject absent, skipped, failed, or ambiguous evidence.""" + +from pathlib import Path +import json +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET + +import pytest + +from check_test_execution import reconcile + + +def receipt(tmp_path: Path, filename: str, cases: list[tuple[str, str]], module="tests.test_native") -> Path: + suite = ET.Element("testsuite", tests=str(len(cases)), failures="0", errors="0", skipped="0", time="1.5") + for name, status in cases: + case = ET.SubElement(suite, "testcase", classname=module, name=name) + if status != "passed": + ET.SubElement(case, status) + count = {"failure": "failures", "error": "errors", "skipped": "skipped"}[status] + suite.set(count, str(int(suite.get(count)) + 1)) + path = tmp_path / filename + ET.ElementTree(suite).write(path, encoding="utf-8") + return path + + +def test_platform_skip_requires_a_matching_real_pass(tmp_path): + linux = receipt(tmp_path, "linux.xml", [("test_windows", "skipped"), ("test_posix", "passed")]) + windows = receipt(tmp_path, "windows.xml", [("test_windows", "passed"), ("test_posix", "skipped")]) + report = reconcile([linux, windows], ["tests/test_native.py"]) + assert report["success"] is True + assert report["numPassedTests"] == report["numTotalTests"] == 2 + assert report["numPendingTests"] == report["numFailedTests"] == 0 + + +def test_unresolved_skip_is_not_a_pass(tmp_path): + path = receipt(tmp_path, "report.xml", [("test_missing_runtime", "skipped")]) + report = reconcile([path], []) + assert report["success"] is False + assert report["numPendingTests"] == 1 + assert report["numPassedTests"] == 0 + assert report["testResults"][0]["assertionResults"][0]["ancestorTitles"] == ["tests.test_native"] + + +@pytest.mark.parametrize("status", ["failure", "error"]) +def test_failure_is_not_hidden_by_another_job_passing(tmp_path, status): + failed = receipt(tmp_path, "failed.xml", [("test_shared", status)]) + passed = receipt(tmp_path, "passed.xml", [("test_shared", "passed")]) + report = reconcile([failed, passed], []) + assert report["success"] is False + assert report["numFailedTests"] == 1 + assert report["numPassedTests"] == 0 + assert report["executionFailures"] == ["tests.test_native::test_shared"] + + +def test_same_title_in_another_file_does_not_resolve_a_skip(tmp_path): + pending = receipt(tmp_path, "pending.xml", [("test_same", "skipped")]) + unrelated = receipt(tmp_path, "unrelated.xml", [("test_same", "passed")], module="tests.test_other") + report = reconcile([pending, unrelated], []) + assert report["numTotalTests"] == 2 + assert report["numPendingTests"] == 1 + + +def test_duplicate_identity_is_rejected(tmp_path): + path = receipt(tmp_path, "duplicate.xml", [("test_same", "passed"), ("test_same", "skipped")]) + with pytest.raises(ValueError, match="ambiguous pytest identity"): + reconcile([path], []) + + +def test_missing_receipt_is_rejected(tmp_path): + with pytest.raises(FileNotFoundError): + reconcile([tmp_path / "missing.xml"], []) + + +def test_empty_receipt_is_rejected(tmp_path): + path = receipt(tmp_path, "empty.xml", []) + with pytest.raises(ValueError, match="Empty pytest suite"): + reconcile([path], []) + + +def test_missing_file_is_rejected(tmp_path): + path = receipt(tmp_path, "report.xml", [("test_present", "passed")]) + with pytest.raises(ValueError, match="never collected: tests/test_missing.py"): + reconcile([path], ["tests/test_missing.py"]) + + +def test_inconsistent_counts_are_rejected(tmp_path): + path = receipt(tmp_path, "report.xml", [("test_present", "passed")]) + path.write_text(path.read_text().replace('tests="1"', 'tests="2"')) + with pytest.raises(ValueError, match="Inconsistent pytest tests count"): + reconcile([path], []) + + +def test_pytest_testsuites_wrapper_and_test_classes(tmp_path): + path = receipt(tmp_path, "report.xml", [("test_method", "passed")], module="tests.test_native.TestNative") + suite = ET.parse(path).getroot() + root = ET.Element("testsuites") + root.append(suite) + ET.ElementTree(root).write(path, encoding="utf-8") + assert reconcile([path], ["tests/test_native.py"])["success"] is True + + +def test_missing_report_list_is_rejected(): + with pytest.raises(ValueError, match="No pytest execution reports"): + reconcile([], []) + + +@pytest.mark.parametrize("scenario", ["passed", "pending", "failed", "missing-receipt", "missing-file"]) +def test_cli_requires_complete_execution_evidence(tmp_path, scenario): + script = tmp_path / "check_test_execution.py" + shutil.copyfile(Path(__file__).parents[1] / script.name, script) + tests = tmp_path / "tests" + tests.mkdir() + (tests / "test_native.py").write_text("# inventory fixture\n") + reports = tmp_path / "reports" + reports.mkdir() + receipt(reports, "pytest-locked.xml", [("test_native", "skipped" if scenario == "pending" else "passed")]) + receipt(reports, "pytest-ubuntu.xml", [("test_native", "error" if scenario == "failed" else "skipped")]) + if scenario != "missing-receipt": + receipt(reports, "pytest-windows.xml", [("test_native", "skipped")]) + if scenario == "missing-file": + (tests / "test_missing.py").write_text("# must be collected\n") + output = tmp_path / "pytest-results.json" + result = subprocess.run( + [sys.executable, str(script), str(reports), str(output)], + capture_output=True, + text=True, + timeout=10, + ) + assert result.returncode == (0 if scenario == "passed" else 1) + if scenario.startswith("missing-"): + assert not output.exists() + assert ("pytest-windows.xml" if scenario == "missing-receipt" else "never collected") in result.stderr + else: + report = json.loads(output.read_text()) + assert report["success"] is (scenario == "passed") + assert report["numTotalTests"] == 1 + assert report["numPassedTests"] == (scenario == "passed") + assert report["numPendingTests"] == (scenario == "pending") + assert report["numFailedTests"] == (scenario == "failed") + assert "Eval execution:" in result.stdout diff --git a/eval/tests/test_evolve.py b/eval/tests/test_evolve.py index 32d50fd85..9267a6c6b 100644 --- a/eval/tests/test_evolve.py +++ b/eval/tests/test_evolve.py @@ -5,6 +5,7 @@ import json import os import subprocess import sys +import threading import time from contextlib import contextmanager from datetime import UTC, datetime, timedelta @@ -1428,21 +1429,32 @@ def test_outer_runner_pid_namespace_kills_setsid_descendant(tmp_path): pytest.skip(str(exc)) raise AssertionError("pytest.skip() returned unexpectedly") sentinel = tmp_path / "escaped" + ready = tmp_path / "descendant-ready" + cancel = threading.Event() + descendant = ( + "import time,pathlib; " + f"pathlib.Path({str(ready)!r}).touch(); print('ready', flush=True); " + f"time.sleep(1); pathlib.Path({str(sentinel)!r}).touch()" + ) child = ( "import os,subprocess,sys,time; " - f"subprocess.Popen([sys.executable,'-c',\"import time,pathlib;time.sleep(1);pathlib.Path({str(sentinel)!r}).touch()\"],preexec_fn=os.setsid); " + f"subprocess.Popen([sys.executable,'-c',{descendant!r}],preexec_fn=os.setsid); " "time.sleep(10)" ) result = run_managed( pid_namespace_command([sys.executable, "-c", child], bwrap_bin=bwrap), - timeout=0.15, + # Cancel only after the escaped-session descendant has actually started. + # A timeout during bwrap/Python startup must not count as containment. + timeout=10, terminate_grace=0.1, require_pid_namespace=True, + cancel_event=cancel, + stdout_observer=lambda _chunk: cancel.set(), ) - time.sleep(1.1) - + assert ready.exists(), "the setsid descendant must start before it can be contained" assert not result.ok - assert result.state in {"timeout", "forced-kill"} + assert result.state == "cancelled" + time.sleep(1.1) assert not sentinel.exists() diff --git a/eval/tests/test_workflow_bench.py b/eval/tests/test_workflow_bench.py index 51212ceda..4a03ed243 100644 --- a/eval/tests/test_workflow_bench.py +++ b/eval/tests/test_workflow_bench.py @@ -185,7 +185,7 @@ def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs(): step for step in containment["steps"] if str(step.get("uses", "")).startswith("actions/setup-node@") ) claude_lock = json.loads((repo_root / ".github" / "claude-canary-runtime" / "package-lock.json").read_text()) - setup_uv = "astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990" + setup_uv = "astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9" assert workflow.count(setup_uv) >= 3 assert workflow.count("version: '0.11.23'") >= 3 assert workflow.count("uv run --locked --extra dev python -m pytest") >= 3 @@ -250,7 +250,11 @@ def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs(): # Carries the real-CLI identity probe, which needs CLAUDE_CANARY_BIN - # set only on this job. Omitted from this list it skipped everywhere. "tests/test_mock_provider.py", + # These also need runtime dependencies absent from the locked pytest job. + "tests/test_evolve.py::test_outer_runner_pid_namespace_kills_setsid_descendant", + "tests/test_oracle_assets.py::test_hidden_vitest_config_executes_sibling_oracle_against_candidate_checkout", "-q", + "--junitxml=pytest-ubuntu.xml", ] bwrap_canary_marker = re.compile( r'@pytest\.mark\.skipif\(\s*os\.environ\.get\("GITNEXUS_REQUIRE_BWRAP_CANARY"\)', @@ -266,6 +270,72 @@ def test_eval_ci_uses_locked_uv_and_blocking_native_containment_jobs(): assert "eval-containment-windows:" in workflow +@pytest.fixture(scope="module") +def ci_test_jobs(): + repo_root = Path(__file__).resolve().parents[2] + workflow = yaml.safe_load((repo_root / ".github" / "workflows" / "ci-tests.yml").read_text()) + return workflow["jobs"] + + +def test_platform_ci_preserves_required_check_names_with_complete_execution_gates(ci_test_jobs): + jobs = ci_test_jobs + gate = jobs["protected-platform-checks"] + matrix = gate["strategy"]["matrix"] + names = { + "tests / " + gate["name"].replace("${{ matrix.os }}", platform).replace("${{ matrix.check }}", str(check)) + for platform in matrix["os"] + for check in matrix["check"] + } + assert names == { + f"tests / {platform} (platform-sensitive) {check}/3" + for platform in ("windows-latest", "macos-latest") + for check in (1, 2, 3) + } + plan = jobs["shard-plan"]["steps"][0]["run"] + total = int(re.search(r"^TOTAL=(\d+)\b", plan, re.MULTILINE)[1]) + native_names = { + "tests / " + + jobs["cross-platform"]["name"] + .replace("${{ matrix.os }}", platform) + .replace("${{ matrix.shard }}", str(shard)) + .replace("${{ needs.shard-plan.outputs.total }}", str(total)) + for platform in jobs["cross-platform"]["strategy"]["matrix"]["os"] + for shard in range(1, total + 1) + } + assert names.isdisjoint(native_names) + assert set(gate["needs"]) == {"cross-platform", "test-completeness"} + assert gate["if"] == "always()" + assert not gate.get("continue-on-error", False) + assert not jobs["cross-platform"].get("continue-on-error", False) + assert not jobs["test-completeness"].get("continue-on-error", False) + assert gate["permissions"] == {} + assert len(gate["steps"]) == 1 + step = gate["steps"][0] + assert not step.get("continue-on-error", False) + assert "if" not in step + assert step["shell"] == "bash" + assert step["env"] == { + "NATIVE_RESULT": "${{ needs.cross-platform.result }}", + "EXECUTION_RESULT": "${{ needs.test-completeness.result }}", + } + + +@pytest.mark.parametrize("native_result", ["success", "failure", "cancelled", "skipped", ""]) +@pytest.mark.parametrize("execution_result", ["success", "failure", "cancelled", "skipped", ""]) +def test_required_platform_check_fails_unless_every_dependency_passed(ci_test_jobs, native_result, execution_result): + step = ci_test_jobs["protected-platform-checks"]["steps"][0] + result = subprocess.run( + ["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", step["run"]], + env={**os.environ, "NATIVE_RESULT": native_result, "EXECUTION_RESULT": execution_result}, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + expected = 0 if native_result == execution_result == "success" else 1 + assert result.returncode == expected, result.stdout + result.stderr + + def test_shipped_scenarios_opt_out_the_cross_module_cell_and_rebuild_graph_assets(): task_file = Path(__file__).resolve().parents[1] / "workflow_bench" / "tasks.scenarios.yaml" tasks = yaml.safe_load(task_file.read_text())["tasks"] @@ -1092,4 +1162,3 @@ def test_an_uninvoked_skill_still_counts_toward_the_arm_median(): # The invariant that makes the half-fix unsafe: the median and the run count # the gate reads must cover the same rows. assert agg["valid_runs"] == 2 - diff --git a/eval/uv.lock b/eval/uv.lock index ff06e6830..0f51f54a0 100644 --- a/eval/uv.lock +++ b/eval/uv.lock @@ -678,7 +678,7 @@ wheels = [ [[package]] name = "datasets" -version = "4.8.3" +version = "5.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dill" }, @@ -696,9 +696,9 @@ dependencies = [ { name = "tqdm" }, { name = "xxhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/74/0c5f29109e02c4c33e8634e31d5cd7d90ecc3ed7c95e1b377ece08354068/datasets-4.8.3.tar.gz", hash = "sha256:882fb1bb514772bec17fbcad2e32985893954d0a0ecf42266e5091386be1f3b7", size = 604294, upload-time = "2026-03-19T17:43:59.32Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/5b/836516269d4f618efe621661cfb6f9acc57e6f95265db3efaee48a5ffe04/datasets-5.0.1.tar.gz", hash = "sha256:ce22bb851efd7494f08aad33b940803784434f6e77763d00679a0dc45fcf686a", size = 641498, upload-time = "2026-07-28T11:09:12.016Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/c6/b8298394a8926f8af21c059f4ec794b61f9cf64ade561184027b94110639/datasets-4.8.3-py3-none-any.whl", hash = "sha256:a66cb506097bfce8461b076fb9e86ea216e00fd622387ba19909d9c1689ff8f9", size = 526834, upload-time = "2026-03-19T17:43:57.337Z" }, + { url = "https://files.pythonhosted.org/packages/44/0b/98fc6eb83333508ca5f44c52b3e287ea8137a0ad582714e2cbc67a02154b/datasets-5.0.1-py3-none-any.whl", hash = "sha256:9fbf73688f8c18f7529b4fe592abd04015f81d1e58001e4bac73ffb2b39d7cc4", size = 559079, upload-time = "2026-07-28T11:09:10.266Z" }, ] [[package]] @@ -953,11 +953,11 @@ wheels = [ [[package]] name = "fsspec" -version = "2026.2.0" +version = "2026.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, + { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, ] [package.optional-dependencies] @@ -1776,119 +1776,160 @@ wheels = [ [[package]] name = "multidict" -version = "6.7.1" +version = "6.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/99/1d4d69c3512d0ddbfa3a1b69cfd9a151012ab2eb4eabbb096201b1f0b7d8/multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2", size = 182404, upload-time = "2026-09-21T17:59:05.362Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, - { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, - { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, - { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, - { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, - { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, - { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, - { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, - { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, - { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, - { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, - { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, - { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, - { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, - { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, - { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, - { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, - { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, - { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, - { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, - { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, - { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, - { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, - { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/24/3823efc330630a1f132bcd0a9b182ddec3c53f452f551c8e8d3aaf5a2d20/multidict-6.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:910d4260512660484c0dc1588a316fbb35a40c081c36fc51d1225351af17cfe4", size = 96739, upload-time = "2026-09-21T17:54:42.815Z" }, + { url = "https://files.pythonhosted.org/packages/6a/69/36331fe1d3aeb0c525a9972d6cf82791075a395aa96f026f060009d691ae/multidict-6.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33fa55b990f81c2927e01399ace0d18926c69d69baa8cdaa819424132fb97987", size = 58514, upload-time = "2026-09-21T17:54:44.064Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/5860651f782078ef13f2761c2593f4ba066d76f626b2f30f23a7f838b775/multidict-6.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:369b5aa01b241cd3fea6890bdbb11a1425d87bf1515831500d518f4223e9d72c", size = 57303, upload-time = "2026-09-21T17:54:45.304Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9f/c56e2fa223cb5d182660e1a09eec38880f96b7cd22769582f74447c60418/multidict-6.9.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:803f8b575a71b1b299d677c28db0653459c79b5308874efec813f17b7457c7f1", size = 320076, upload-time = "2026-09-21T17:54:46.826Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/eda4fe0cad51f96096363ed32d3e0f8df28dab00beff4d92adc9db517726/multidict-6.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8a65621b98984a62e59403009591b8a5a7736273aefe1cab64cfb85b365cc07", size = 315489, upload-time = "2026-09-21T17:54:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/14/10b1ded0a4085a51c3054125f854da4f47ce4fec724a2d2503231b83f3db/multidict-6.9.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:13849a1d4f54c3809ae721e9e83ab28f5ea602f33660cb84eb6ef261eac706c1", size = 296716, upload-time = "2026-09-21T17:54:49.749Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/04d155dd18528443cb9009f3a9f35f7417773797d072c95104ff17b8b9b7/multidict-6.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd9a137a4a9becda3094f3831cd026380f75f6855e051eefe4c73ade524f1cc3", size = 330244, upload-time = "2026-09-21T17:54:51.26Z" }, + { url = "https://files.pythonhosted.org/packages/8b/90/284d10b3a9e5b312a8ec5b7a175fc7d560eefdc99886320449891a197992/multidict-6.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ae9614c317c50836689ce2dfde07c05fe0b16378562e2221746c3913ede3c80", size = 331288, upload-time = "2026-09-21T17:54:52.595Z" }, + { url = "https://files.pythonhosted.org/packages/27/50/f420de3683f9b047fc5588064d84043ef57a7152ef451b8373dfc7068d22/multidict-6.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f1e362c9352b50ed120f001046fdbb80810c9d56580f4c3fc13bbe30823387", size = 320663, upload-time = "2026-09-21T17:54:53.91Z" }, + { url = "https://files.pythonhosted.org/packages/01/ab/0120a650d7ce4fe167299c13a13cc4ee2dc72458a9c0e9e9330b824b73c9/multidict-6.9.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dd655518f136febd96c05131a76a863e32fc2a1d7acd4e3c959e3ceb77d8345", size = 291139, upload-time = "2026-09-21T17:54:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/c66342f73ee5cd2c9b1dda6cfce89b5f348c48921b8c8b6e59aafbbfe31a/multidict-6.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c5d675da8f1cb5650271c8ad5e95c0a3e5a183c105e72d953b12877b1c8d0fd", size = 310044, upload-time = "2026-09-21T17:54:56.745Z" }, + { url = "https://files.pythonhosted.org/packages/05/0d/e44b90d9e77e44ec3473c4c7ac7ea95764d7e8fbf6d5a085ef9bff7ce0c2/multidict-6.9.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:658f90f49cf5af2441cad0a2b801c3ef520471989a1ec55bcb25b255b2ca8d2f", size = 303279, upload-time = "2026-09-21T17:54:58.178Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/7741f7c23c211fae31ab546f0ba41569dae7671cf7ca30d5afb59e9d92aa/multidict-6.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:43124fe172ada86d03ac3c8dc8179091341f6724d5e5d5b160e1587e4cd3761b", size = 326177, upload-time = "2026-09-21T17:54:59.825Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/0cdbfbbe36316b2ef4b8e011afaca2cf291a73fe4fabf34dd0b22e3ee48a/multidict-6.9.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f0adc22a4eb31e545221d93fc73a0f6a8cc2379d0f4309f71d1d17ba938b82", size = 323526, upload-time = "2026-09-21T17:55:01.43Z" }, + { url = "https://files.pythonhosted.org/packages/98/05/0c86e9678e78f4596df0ed7f59d2aa755107da3f2a92f1b7fa150e94cbb8/multidict-6.9.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7fc59e9ba821b220944ccfe0f89c9dc4745f6d097569992356eb03869f21e953", size = 290278, upload-time = "2026-09-21T17:55:02.993Z" }, + { url = "https://files.pythonhosted.org/packages/95/8e/71bd7f43c4883cba6c5f96db88bd2311d52e74ad7680be5915938490d56f/multidict-6.9.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:87cc632c88ee5dc80e12681047839304d98ee5c9a708d686505767001c9b8b9a", size = 321142, upload-time = "2026-09-21T17:55:04.48Z" }, + { url = "https://files.pythonhosted.org/packages/2d/00/bf59d6bb22a4c152e4039574c29490caf3c65107715121807b4bd7cde244/multidict-6.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b828cf64d62dc09ac183f03c1aeedd164ade96a2ce4934109452edf29de1dd13", size = 319045, upload-time = "2026-09-21T17:55:06.119Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/f27f4f045198bf1c4bcd6eb3b6cf06be2dceaceb6e67222b903fce7a6749/multidict-6.9.1-cp311-cp311-win32.whl", hash = "sha256:2c1aeb92eea59d824f004341b26d5e4b47a8a441cf9726769b9a90abf9d0e08f", size = 51309, upload-time = "2026-09-21T17:55:07.531Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/c2064aef64efc007bc89a571f843e5772f27946eb9bc018731bf8016e319/multidict-6.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:5f89dad732280e7a10b74d40b91364f88e13c3f2c08c2ef83a8cd42f7a61af2e", size = 59148, upload-time = "2026-09-21T17:55:08.936Z" }, + { url = "https://files.pythonhosted.org/packages/45/a1/3bab1827edd813dfb90712af5cbb7fc1473ed4d9c871d103df4f4bb950c2/multidict-6.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:5800368526647146978389dfaa46da3356291e9f0fff9a4ef12e8c2bef964a0d", size = 54258, upload-time = "2026-09-21T17:55:10.191Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0d/4b5afb6d3e545c9af0cdfe2db8f6f4c6664568c23d863d888674e447e6a4/multidict-6.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe", size = 98360, upload-time = "2026-09-21T17:55:11.508Z" }, + { url = "https://files.pythonhosted.org/packages/89/ab/1b9ca66251899981b21138b87da9d5a9c2c81af12b1ea7d19466972f7fe2/multidict-6.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006", size = 59979, upload-time = "2026-09-21T17:55:12.866Z" }, + { url = "https://files.pythonhosted.org/packages/36/eb/6ae44062466c26c8469ef43f2481a6a48d8cea0587b2d54514ec92e2adfd/multidict-6.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42", size = 57736, upload-time = "2026-09-21T17:55:14.223Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5c/a67817593019257a4ac8b0d1b4c426030e637c047b0692ef405439ecea7a/multidict-6.9.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5", size = 333097, upload-time = "2026-09-21T17:55:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/90/bf/599ae2e6222822d88a247a8a7ae82fe6fd25d5700757b79603d5edafe6a0/multidict-6.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea", size = 334196, upload-time = "2026-09-21T17:55:17.015Z" }, + { url = "https://files.pythonhosted.org/packages/15/10/d8aac5acacbe7f5c117866c776ec26d5f15a868759b6f37ad8e7ed3b5b02/multidict-6.9.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24", size = 317465, upload-time = "2026-09-21T17:55:18.412Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/714f796f7293a8b1c5c3f465a26996231d5450c54e85d64ef1258c091134/multidict-6.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24", size = 345914, upload-time = "2026-09-21T17:55:20.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b1/e37fbf769c567be277bcf32df6234035a4384677fcc1bd852752be3d6b93/multidict-6.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a", size = 351163, upload-time = "2026-09-21T17:55:22.21Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d", size = 336881, upload-time = "2026-09-21T17:55:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/cc9bc8a62651d2d53ab93ee4993b3a71b7cb78eb8ebfc9c757a5b6698617/multidict-6.9.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805", size = 303405, upload-time = "2026-09-21T17:55:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0c/8e912afafa70e944dbb8bec4b66ca6e008511395278c0d3dd0e89567536a/multidict-6.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02", size = 324265, upload-time = "2026-09-21T17:55:26.989Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/62c2af80fb085e6234805017af857b8e913dfac7a55e9c1349c27768c58a/multidict-6.9.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8", size = 322085, upload-time = "2026-09-21T17:55:28.732Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6b/35bf801b336fd960811207203ffdcdc24acc3558b3e7ff2e1c914b141e70/multidict-6.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580", size = 338107, upload-time = "2026-09-21T17:55:30.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/495ef65bf5bba29d142d81b3fe1b8154b919bed70e96491b1e93c3c26f0f/multidict-6.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004", size = 339631, upload-time = "2026-09-21T17:55:32.012Z" }, + { url = "https://files.pythonhosted.org/packages/19/bd/057fdff5f4e04dcd40a960e38f77d19d3c4b67dd243ffa5f43718edb29fc/multidict-6.9.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60", size = 302080, upload-time = "2026-09-21T17:55:33.817Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/9ace933ee8dad808632523726f42255b09600087219e3d4ead7369820910/multidict-6.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a", size = 341107, upload-time = "2026-09-21T17:55:35.471Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ac/7c1204406097bfc5c283d4a3287d61166807d189917567df4c1318484fc4/multidict-6.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6", size = 333482, upload-time = "2026-09-21T17:55:37.002Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/a70c32ea3299ebe00f44533740cb46905717c51faf29bbd3ce8bb5886d9d/multidict-6.9.1-cp312-cp312-win32.whl", hash = "sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb", size = 52548, upload-time = "2026-09-21T17:55:38.529Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2e/8c9c2591df01e5692ad1bf92febb082a17b1c6c3a19aa8b7ff49988fd989/multidict-6.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203", size = 59622, upload-time = "2026-09-21T17:55:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/d0/95/59f4472ec512bc180fd207899594c7803ece96262ff9aaa3a4b6d7da6940/multidict-6.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc", size = 54930, upload-time = "2026-09-21T17:55:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/ddf7c76860f5f553a23ad3ca38463ccf5247081618977742c8dc4625355e/multidict-6.9.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3", size = 63266, upload-time = "2026-09-21T17:55:42.676Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d3/f4ae5945ea2de597eaeb4eca3a74c59783ace54482c9b49435442b63efa8/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382", size = 55771, upload-time = "2026-09-21T17:55:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/93/7d/15468239920040d01c686e5ae669e6382f7bf31fc3e5e0a6b8f1c3b32d3b/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a", size = 57116, upload-time = "2026-09-21T17:55:45.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/1b/b958f06aac2d8b1e485eb1c105b1b159cbf3868249e5142142451577dad2/multidict-6.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40", size = 97692, upload-time = "2026-09-21T17:55:46.674Z" }, + { url = "https://files.pythonhosted.org/packages/93/6c/d6cfe18e61010166d7237d7527c775eb9843e7078feadea45b7628751b60/multidict-6.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8", size = 59556, upload-time = "2026-09-21T17:55:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/46/46/9b4c1127cece0289fb207d04aae5116382ddfcb2a4dc8e0cb49a33f3c7b3/multidict-6.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c", size = 57357, upload-time = "2026-09-21T17:55:49.998Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fc/c18b07100a6064573e49e538d13d47eb2b5221f48080512df78aef54ab04/multidict-6.9.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a", size = 330433, upload-time = "2026-09-21T17:55:51.435Z" }, + { url = "https://files.pythonhosted.org/packages/62/ff/52a0082adeb69656b634609d4fb0ae65456deaae4cca3d5a12656626dc50/multidict-6.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8", size = 332487, upload-time = "2026-09-21T17:55:52.937Z" }, + { url = "https://files.pythonhosted.org/packages/39/3e/80bd4729635a7af371ff4dcc312594cf96e73f98854491be8740792de430/multidict-6.9.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94", size = 316911, upload-time = "2026-09-21T17:55:54.751Z" }, + { url = "https://files.pythonhosted.org/packages/15/f4/7ea4a907e053e924e2e1694d90560f79f72056504bfd5e8483695f9527d2/multidict-6.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa", size = 344950, upload-time = "2026-09-21T17:55:56.5Z" }, + { url = "https://files.pythonhosted.org/packages/0d/16/9642ae41fbfbdc546aa481c2b07ae1bc087a94ab5f5020b4d9ab77da401f/multidict-6.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5", size = 350264, upload-time = "2026-09-21T17:55:58.073Z" }, + { url = "https://files.pythonhosted.org/packages/93/f2/e06c8e42074d0a4b8419bfe92afbd1d262190b69549ecbcd2dffa4f103c2/multidict-6.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c", size = 336213, upload-time = "2026-09-21T17:55:59.723Z" }, + { url = "https://files.pythonhosted.org/packages/98/43/d7cf9ef4700e7c25244590d1b4f20cf12cbed86beba6c17be4d9e48c1099/multidict-6.9.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b", size = 302555, upload-time = "2026-09-21T17:56:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54209920324bc3928f5f2ba28b01a6dd957a9d48c3aaff77e38faaf5668d/multidict-6.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05", size = 322247, upload-time = "2026-09-21T17:56:03.265Z" }, + { url = "https://files.pythonhosted.org/packages/33/50/df96f961b178b621ab0adbf9221d4fd21534e1064fd9f5e85c1fa27fea55/multidict-6.9.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a", size = 321787, upload-time = "2026-09-21T17:56:04.894Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9b/37f354562a8f82f9c1f63d3a94300fc82126d50320175f0702bbd544f87c/multidict-6.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418", size = 335498, upload-time = "2026-09-21T17:56:06.783Z" }, + { url = "https://files.pythonhosted.org/packages/1e/44/78e366efc6c004185295cc9eb9711c10f13eadf3b547603b9c5526329976/multidict-6.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1", size = 338244, upload-time = "2026-09-21T17:56:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2b/ab5bd3964691abe4d14b43bdbdb8a621b77cea2ca352dc93159ec0a4575b/multidict-6.9.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721", size = 301641, upload-time = "2026-09-21T17:56:11.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/f6bc899f5aafaba755edc8e4bb934bc00b1e405f8d3fbc1dd10283738074/multidict-6.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771", size = 340162, upload-time = "2026-09-21T17:56:13.076Z" }, + { url = "https://files.pythonhosted.org/packages/eb/03/a8fc809ef364b8c231c065ea2ea2f97ae86d4f9a0e36b5721759fd606778/multidict-6.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14", size = 332826, upload-time = "2026-09-21T17:56:15.175Z" }, + { url = "https://files.pythonhosted.org/packages/f2/27/32dde80245e2024e9bb7a6ca3c2fb7c695dc74014cba557cc35be6f91e24/multidict-6.9.1-cp313-cp313-win32.whl", hash = "sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40", size = 52378, upload-time = "2026-09-21T17:56:16.959Z" }, + { url = "https://files.pythonhosted.org/packages/be/78/1bac2987edac273a6b27fa50e9204bc3466c94a7b4f6a44828d730f8810f/multidict-6.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d", size = 59449, upload-time = "2026-09-21T17:56:18.336Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/2a77ce19eea48cb9b3521a202b513ba3349aafa702c8317be0b645ffb74b/multidict-6.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4", size = 54713, upload-time = "2026-09-21T17:56:19.774Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/c6041015aa2cdc11e1dff0cae58898ae5525d19e35efd65a4ecf3c6ea235/multidict-6.9.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5", size = 62336, upload-time = "2026-09-21T17:56:21.181Z" }, + { url = "https://files.pythonhosted.org/packages/23/db/95bd0afb130a0149c955f1f066143528d60864e64f41ff5a1e1313609361/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9", size = 54892, upload-time = "2026-09-21T17:56:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/94/cc/8aa34ae8d09498e8da003f485c03371b55cf1bc6d56cff861b3a7de61251/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca", size = 56251, upload-time = "2026-09-21T17:56:24.36Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/22baa3b95375096369b04c4348af14139570f302f951773d2c68bf0ccf2e/multidict-6.9.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840", size = 96345, upload-time = "2026-09-21T17:56:25.777Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3e/b96779dcac28ec6d491fd821112a0156b519b6701bba186cdf6f6df737f4/multidict-6.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800", size = 58901, upload-time = "2026-09-21T17:56:27.364Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d", size = 56491, upload-time = "2026-09-21T17:56:28.901Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8f/212ed7e03282c7bc271e3796bf985b53d5727c2827361e3dd9c5ee6b093f/multidict-6.9.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201", size = 332297, upload-time = "2026-09-21T17:56:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/555aab000e03ecba85c0a1837fd5674d8bf10949490df7800da3a40660ce/multidict-6.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701", size = 329197, upload-time = "2026-09-21T17:56:32.652Z" }, + { url = "https://files.pythonhosted.org/packages/3d/25/f3539b5bdc147beba66015795dfe589eedcc580348fb1a8c69b1df4196b5/multidict-6.9.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610", size = 308456, upload-time = "2026-09-21T17:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7d/4665cad8fc326787d218879be879d86dfe32f006c1f88ae47b04d3b52fd5/multidict-6.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694", size = 343382, upload-time = "2026-09-21T17:56:36.606Z" }, + { url = "https://files.pythonhosted.org/packages/41/01/e1e9abe27f492b22dede492bc423015597412b14ce074f7475f4ecd80185/multidict-6.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a", size = 346848, upload-time = "2026-09-21T17:56:38.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/8d54118bc228e64e1087f1729647b93bbea225eda4a3f914663a6f410bca/multidict-6.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a", size = 333612, upload-time = "2026-09-21T17:56:40.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/e1/9509dafc1fc68948e75842a74533ddfa64af57f2b17810e9cacfa68a829b/multidict-6.9.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba", size = 304246, upload-time = "2026-09-21T17:56:42.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/96b93808e1bec0b51d49d89859e49b82a94f7b2e58eca0fc44ffcafd3ae5/multidict-6.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290", size = 321983, upload-time = "2026-09-21T17:56:44.399Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1c/08e5184df026b9d8e05a6dce5da22628e46bc4a7c7f03b8bf3c6939a8c46/multidict-6.9.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d", size = 315927, upload-time = "2026-09-21T17:56:46.208Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5d/9ff39e7387f6fcc9a81af5dbf055fde08149849a04f8df10ca5b086b0d39/multidict-6.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828", size = 336827, upload-time = "2026-09-21T17:56:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/5a/be/30fbb2cab22203128928066de94be247dc9a3230bf2bfc54437ac3b5f9f7/multidict-6.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132", size = 336317, upload-time = "2026-09-21T17:56:49.778Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/2480aef6b7d8e7ab89b064ae14f12ec069570dab3a5c13e0155912293247/multidict-6.9.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b", size = 303258, upload-time = "2026-09-21T17:56:51.829Z" }, + { url = "https://files.pythonhosted.org/packages/4c/04/dbe59cf4ea3778600e8e70e30b31aa9118ad6da2844bc9f717804cbaa000/multidict-6.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d", size = 337232, upload-time = "2026-09-21T17:56:53.537Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8c/abe55548f06f12878588f08eb803c5a391283f01243f784cb00adac79231/multidict-6.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194", size = 330261, upload-time = "2026-09-21T17:56:55.335Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f1/ba67f668478f152bdfa18abe72e7588bd078bf8b42c172fcf542fcf45826/multidict-6.9.1-cp314-cp314-win32.whl", hash = "sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841", size = 51715, upload-time = "2026-09-21T17:56:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/014bb2128aedc8157d2848f0ec8a0209c6b822fb88deea012834e1c4a4e8/multidict-6.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3", size = 58621, upload-time = "2026-09-21T17:56:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/66/34/8f43c03c2d8821a2320b8c560d8a49d0be2920627edfac3f546922f91969/multidict-6.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9", size = 53959, upload-time = "2026-09-21T17:57:00.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/4f2a5eeea10b6d42634ba168ec6efc732ef218772e57b8239cd485c202f6/multidict-6.9.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76", size = 107694, upload-time = "2026-09-21T17:57:01.785Z" }, + { url = "https://files.pythonhosted.org/packages/32/ce/c68d08ac2096c1528ff20d77fd10f7e401db3e534ac5a1c7726e36a77c2f/multidict-6.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b", size = 64766, upload-time = "2026-09-21T17:57:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9a/9f5c270c88fe289b4876fe28f9932a70e015a7916aff1e5b0325f7597b11/multidict-6.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485", size = 62723, upload-time = "2026-09-21T17:57:05.104Z" }, + { url = "https://files.pythonhosted.org/packages/7c/63/c9a0b131354dd949426166cef712d708f436a8cf261114f8d5d2aec440ca/multidict-6.9.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455", size = 290445, upload-time = "2026-09-21T17:57:06.687Z" }, + { url = "https://files.pythonhosted.org/packages/9d/41/ed27d1d20ba91f4e13e8f7dcfaa592614ae0e1d5f45e03171d23c3b098ac/multidict-6.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125", size = 304375, upload-time = "2026-09-21T17:57:08.717Z" }, + { url = "https://files.pythonhosted.org/packages/66/6f/a7b26167173c3b73d6e5ebb0856c5154ad005156c3a2fa4062329921819c/multidict-6.9.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390", size = 281746, upload-time = "2026-09-21T17:57:10.713Z" }, + { url = "https://files.pythonhosted.org/packages/34/1d/da27bdd49b0cb84263288f5f48dbd915f2d17fd228fee2078ca607e80277/multidict-6.9.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9", size = 311499, upload-time = "2026-09-21T17:57:12.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/36/ab0299a7abf53e38ff75d96c64480f1e2ff8edde985f93f7a1ad5e08bd94/multidict-6.9.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94", size = 313553, upload-time = "2026-09-21T17:57:14.548Z" }, + { url = "https://files.pythonhosted.org/packages/5a/44/e21be702efc9c9ba097c8be874c68860b1cb6cf9c85611263039bf95b37e/multidict-6.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45", size = 299898, upload-time = "2026-09-21T17:57:16.192Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e93bc0dc947c7f872e502cfaa7ad9d98f001e1c93cfa6f4940457a6e187a/multidict-6.9.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8", size = 273054, upload-time = "2026-09-21T17:57:17.793Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c9/945b44493fbe7af26ffe1e43c506b376daeab070fc1a0923bef9bbf572eb/multidict-6.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3", size = 294751, upload-time = "2026-09-21T17:57:19.631Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/0969a9b38b814e5fa3af1b0a57708c49b3d9b3d55c208bac6e98b87bb028/multidict-6.9.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f", size = 284523, upload-time = "2026-09-21T17:57:21.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1e/c32ddf53234c228b1a374ddabca19f542ac7f32752f5155982abe2304833/multidict-6.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709", size = 290275, upload-time = "2026-09-21T17:57:23.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/09/7a99a3daafd987330abb865a2d36e432fd8bf18e790ddda55c5de15eb98f/multidict-6.9.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983", size = 303350, upload-time = "2026-09-21T17:57:25.086Z" }, + { url = "https://files.pythonhosted.org/packages/2e/09/7e6ebaecc4e226d7fe27973c271d58b75c14c24e84c23736d21dc39997dd/multidict-6.9.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6", size = 272232, upload-time = "2026-09-21T17:57:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6e/420e9e879b21cbdb5036214ee238efabcfab7bf262d3fc124132050f79ff/multidict-6.9.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b", size = 300525, upload-time = "2026-09-21T17:57:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/c6b0896850b3ceb190ca302760d63148843371f027c5dde3d1d732343a08/multidict-6.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e", size = 295933, upload-time = "2026-09-21T17:57:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/f0ff3384a756227741c72f4ee94cadf1f9900df0ae961e48639f96115b1c/multidict-6.9.1-cp314-cp314t-win32.whl", hash = "sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd", size = 57776, upload-time = "2026-09-21T17:57:32.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/87/013e1eed30ad46bec1eb68947bc9caf55ba2fd0d6aed04fe964e3b44c23b/multidict-6.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9", size = 64881, upload-time = "2026-09-21T17:57:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/7f/95/64dd049bb4c53426e2fee7afb874daaa8b4426c41878785883a94511ecf8/multidict-6.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3", size = 58582, upload-time = "2026-09-21T17:57:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2c/26bc1723592cfe965b575652a7749026474b0625b04b04564e1fba265dc5/multidict-6.9.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1", size = 62339, upload-time = "2026-09-21T17:57:37.967Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c5/614d2eb9995232d066aae7f17d1d454060b6b5d882b22fbaf6150b05ef1d/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82", size = 55009, upload-time = "2026-09-21T17:57:39.746Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/f7179ecdf9cdd3581ace3292c3b883a1a500a374aa4e619bf6eb521c62e5/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea", size = 56273, upload-time = "2026-09-21T17:57:41.544Z" }, + { url = "https://files.pythonhosted.org/packages/ac/72/c9dffcecccd1f8b9a58f960ec4eaecab62c4edb08d5bb984d4de67fe3fdb/multidict-6.9.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c", size = 96390, upload-time = "2026-09-21T17:57:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3f/76542360e655cecdb7c158c7af88c69c4db0d2aac3421659d41214927229/multidict-6.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08", size = 58900, upload-time = "2026-09-21T17:57:45.105Z" }, + { url = "https://files.pythonhosted.org/packages/04/15/f28c71af461dd4ff15653a165cfd8ddaf01f3387ff80d2dd0de057d527d0/multidict-6.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b", size = 56540, upload-time = "2026-09-21T17:57:46.913Z" }, + { url = "https://files.pythonhosted.org/packages/19/de/5ee4050c3ddad88c605a89fc1cd18e89abd40a64f183e6fb5276b6eb7f52/multidict-6.9.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919", size = 331818, upload-time = "2026-09-21T17:57:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/70/e2/2d4e3c3ee70f583334ff236fe6a3babf7a05ce56169a15df65453af647c0/multidict-6.9.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519", size = 329800, upload-time = "2026-09-21T17:57:50.91Z" }, + { url = "https://files.pythonhosted.org/packages/d6/14/f64bd05667343a7250cdd99f81bda2270e3959f00832441f10c7f904e1c1/multidict-6.9.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9", size = 312048, upload-time = "2026-09-21T17:57:53.053Z" }, + { url = "https://files.pythonhosted.org/packages/64/bf/a91256ce00199e7544e3eda79c4e9544eb7388c38fa22f11638c2e357ccb/multidict-6.9.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d", size = 343676, upload-time = "2026-09-21T17:57:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/628b978413518159228c2b2ec38645362ceb6427e935d83d716e26490a49/multidict-6.9.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67", size = 347028, upload-time = "2026-09-21T17:57:57.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/5f9dc2b5ac7dd7a82458ae9cd283b99c185834f23442ace295362dcc5e45/multidict-6.9.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e", size = 335670, upload-time = "2026-09-21T17:57:59.126Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6f319ee000f5ab3fa0055b194ad706fddd552c60c76a21d581c87db1d46c/multidict-6.9.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca", size = 307804, upload-time = "2026-09-21T17:58:01.2Z" }, + { url = "https://files.pythonhosted.org/packages/3f/24/61be5fe1616d033532e8d63dc789c0f35b9793917834713eefe050589ba9/multidict-6.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac", size = 323409, upload-time = "2026-09-21T17:58:03.383Z" }, + { url = "https://files.pythonhosted.org/packages/22/a6/eab8aaf59892169a0f4e8e78a5516a6caa6abd8e96e6410111258f4ef0ef/multidict-6.9.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e", size = 315746, upload-time = "2026-09-21T17:58:05.372Z" }, + { url = "https://files.pythonhosted.org/packages/42/b1/d1e14bab80a74babf6e2ac971a878b84fdcf3a972f3ec3400ca12260fac5/multidict-6.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e", size = 334529, upload-time = "2026-09-21T17:58:07.35Z" }, + { url = "https://files.pythonhosted.org/packages/f5/50/a05b1d36f6d3363a5a334cebbf11624c9c7a69d66fd23345eb53b1d43b7e/multidict-6.9.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c", size = 336455, upload-time = "2026-09-21T17:58:09.112Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0d/bf33cb097380252b08e1177b5ae0cab822c47ce80d236f3f724953c76721/multidict-6.9.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f", size = 305865, upload-time = "2026-09-21T17:58:10.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/a2bd133391204b1cec5a5caf327dc9a06710439b7403220860a8fb164560/multidict-6.9.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4", size = 337531, upload-time = "2026-09-21T17:58:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/c0972486f81a06c3bbb23063c85afaac53c51608f7e35bcb101a1006408b/multidict-6.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d", size = 332464, upload-time = "2026-09-21T17:58:14.673Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/5cbbe852b45d8a7f0ab3ed042386e4894e463010eb230603596cb15d0fee/multidict-6.9.1-cp315-cp315-win32.whl", hash = "sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d", size = 51714, upload-time = "2026-09-21T17:58:16.409Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/1fa118386deeae4e7a48870fa5cb073e748b0ac5265a6990651cefc48068/multidict-6.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f", size = 58619, upload-time = "2026-09-21T17:58:18.369Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ee/51c45748acfce9cb69d89dcfcd09c874b385407fd6993e885d6905659865/multidict-6.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f", size = 53969, upload-time = "2026-09-21T17:58:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/89d2d3dedfae2558853c1770c7cbcdfd5211eb4c4090b1e8cb570544a490/multidict-6.9.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7", size = 107679, upload-time = "2026-09-21T17:58:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/c8/df/0c2a28870181c1762f90952b897f6db46c934ce073efe0442604cbc276db/multidict-6.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68", size = 64753, upload-time = "2026-09-21T17:58:23.622Z" }, + { url = "https://files.pythonhosted.org/packages/43/7e/9c6a3e7619459407d8958f7e56ab3dda7f0738f5aed9bb4ad48076e8de83/multidict-6.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64", size = 62732, upload-time = "2026-09-21T17:58:25.46Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/fd56c9d83ba0cdc3cf66f9acb278b24ef1f043b620b96d145685a3b57338/multidict-6.9.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce", size = 288648, upload-time = "2026-09-21T17:58:27.115Z" }, + { url = "https://files.pythonhosted.org/packages/df/ef/51e79c2442b0f56ac4ac580108d713078b31297e028d468fc220971a305d/multidict-6.9.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5", size = 304919, upload-time = "2026-09-21T17:58:29.143Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e4/774700cc5739353fe88a7204ed30e78a9403d98d4bb2496cc91f096d2e71/multidict-6.9.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2", size = 289094, upload-time = "2026-09-21T17:58:30.938Z" }, + { url = "https://files.pythonhosted.org/packages/ca/41/0bec327bc3a8271825376b6d8a469d1f80e3ba1e2e730e019836e29663c3/multidict-6.9.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1", size = 312007, upload-time = "2026-09-21T17:58:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/9906bf5f1fe20e8e755affccc1a69ecd8aebb079ac65386e5ffc8eefbe75/multidict-6.9.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659", size = 314657, upload-time = "2026-09-21T17:58:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/33/92/cf6d665a45663bbe216420fe632da10e9f4f842b07ce02bc3ddf8eb882b8/multidict-6.9.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20", size = 301563, upload-time = "2026-09-21T17:58:37.472Z" }, + { url = "https://files.pythonhosted.org/packages/97/4c/9f1b7945526905d58b4b2d422e2ea81326dda71f9bdc9c32c8002639234a/multidict-6.9.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471", size = 278540, upload-time = "2026-09-21T17:58:39.551Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d9/133aa472fc1be181de29a0c2e1c4cd778280882f532b404395f646742e46/multidict-6.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879", size = 294858, upload-time = "2026-09-21T17:58:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/bd/38/21f2305dd20ac5038fa581b5d7e0d8dbbd70dc6b50231c22ec173dacddda/multidict-6.9.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3", size = 285461, upload-time = "2026-09-21T17:58:43.844Z" }, + { url = "https://files.pythonhosted.org/packages/ad/21/465a43c214d1d7e2d0c974e727d4b5935f8a0868838b5df9d0984c83c1dc/multidict-6.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df", size = 288964, upload-time = "2026-09-21T17:58:46.16Z" }, + { url = "https://files.pythonhosted.org/packages/2d/58/72a3e8d56c1e05146d2d1841dbaacf8986375eded1e5a58d30eef191a580/multidict-6.9.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c", size = 303924, upload-time = "2026-09-21T17:58:48.395Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b9/911084c04530a682a54536b4f738d3992a593b5ff6fea1d6a1f02f3acc6b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c", size = 278479, upload-time = "2026-09-21T17:58:50.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/eb/9a333346c02c928a9e82534638bce85697c10c46b4901fcd70af19357356/multidict-6.9.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d", size = 301650, upload-time = "2026-09-21T17:58:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/1d936c4e45c8199db6082b6836e9182e31aa393de0de652637790831ca4b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1", size = 298024, upload-time = "2026-09-21T17:58:55.03Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0c/ca62ae2882b89a4cfeb0bbca873a4afdb5fc32994c4f8a37a7cb52ede7a0/multidict-6.9.1-cp315-cp315t-win32.whl", hash = "sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1", size = 57810, upload-time = "2026-09-21T17:58:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b2c2805c3eb6fa4cdf5512fb95ac6174dde71e34dd601c5be52f4df5dc82/multidict-6.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec", size = 64984, upload-time = "2026-09-21T17:58:59.446Z" }, + { url = "https://files.pythonhosted.org/packages/fd/34/03f1d204d698c408f4754c969882609b6b7a902c0e3d16a7033f219cdeda/multidict-6.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3", size = 58742, upload-time = "2026-09-21T17:59:01.726Z" }, + { url = "https://files.pythonhosted.org/packages/be/59/e26cb779be4c591d1a910f59d29aca9fba4de70349840a833beba2652371/multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b", size = 19176, upload-time = "2026-09-21T17:59:03.501Z" }, ] [[package]] @@ -1992,11 +2033,11 @@ wheels = [ [[package]] name = "oauthlib" -version = "3.3.1" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/d8/a1bcc8ba112a627f8ffbdc212a78ce18d3ac07e91a5ca65d27918eee25a1/oauthlib-4.0.0.tar.gz", hash = "sha256:efb274799819440f95b4ab3b818869f1ce9ae26c5beacba0201d1a1b76b54f86", size = 187232, upload-time = "2026-09-28T06:01:18.77Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f4/78229a1066068ca14fc60fb26cf7381cabe4382261392b90e5f9552722d4/oauthlib-4.0.0-py3-none-any.whl", hash = "sha256:624c28c13a0a59cabf9747dfa52af63be3e512a7f2714df16e91b5b3a145e6cd", size = 159715, upload-time = "2026-09-28T06:01:17.008Z" }, ] [[package]] @@ -2522,11 +2563,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/a5/5197bfd06417837ac079921c66fa6393f1dea3557272a263cebfef69e432/pyjwt-2.15.0.tar.gz", hash = "sha256:b11c5f9791d7bf51c2b39a81ed669f6b2dbbd669df2942f6c60167e9e3d1abe4", size = 120513, upload-time = "2026-09-23T16:56:00.689Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/e8/55/40e45bf052ee8ee12a4dfd785519660f8effa7b065442b91646ec6828619/pyjwt-2.15.0-py3-none-any.whl", hash = "sha256:7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8", size = 33680, upload-time = "2026-09-23T16:55:59.241Z" }, ] [package.optional-dependencies] @@ -3306,11 +3347,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]] diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index d904db1f6..08ab3b130 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -9,16 +9,16 @@ "version": "0.0.0", "dependencies": { "@langchain/anthropic": "^1.5.8", - "@langchain/core": "^1.2.8", + "@langchain/core": "^1.2.13", "@langchain/google-genai": "^2.3.1", - "@langchain/langgraph": "^1.4.14", + "@langchain/langgraph": "^1.4.18", "@langchain/ollama": "^1.3.0", "@langchain/openai": "^1.5.13", "@sigma/edge-curve": "^3.1.0", "@tailwindcss/vite": "^4.3.3", "axios": "^1.20.0", "d3": "^7.9.0", - "dompurify": "^3.4.15", + "dompurify": "^3.4.16", "gitnexus-shared": "file:../gitnexus-shared", "graphology": "^0.26.0", "graphology-indices": "^0.17.0", @@ -26,9 +26,9 @@ "graphology-layout-forceatlas2": "^0.10.1", "graphology-layout-noverlap": "^0.4.2", "graphology-utils": "^2.3.0", - "i18next": "^26.3.6", + "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", - "langchain": "^1.5.11", + "langchain": "^1.5.14", "lru-cache": "^11.5.3", "lucide-react": "^1.46.0", "mermaid": "^11.17.2", @@ -53,7 +53,7 @@ "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/dompurify": "^3.2.0", - "@types/node": "^26.5.1", + "@types/node": "^26.6.2", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@types/react-syntax-highlighter": "^15.5.13", @@ -1083,9 +1083,9 @@ } }, "node_modules/@langchain/core": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.11.tgz", - "integrity": "sha512-8yuWLLloTSYA453akm2JSadOVa8kGDY8v+kTzQ6kTY6aIETTEIxgysjZWyKrWQLo3UazctsSoGJ8JrdCGFL4/w==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.13.tgz", + "integrity": "sha512-ADGTxZ84n3civruUX1LlTOdua/PDGoni2U6TmKTX7hvRU2Jlepu8vLJI4Wnwj45KUKhUCrW94Il12Q2bxE3e8A==", "license": "MIT", "dependencies": { "@cfworker/json-schema": "^4.0.2", @@ -1116,13 +1116,13 @@ } }, "node_modules/@langchain/langgraph": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.14.tgz", - "integrity": "sha512-uWAdRYTllfKCnTrlyovExPJCHJwcf3Wl2LzUlnaqsT7Rmoo3aCeYtq/7MV/Pw4q11motG8pR8bjr6T6V8Pe1gQ==", + "version": "1.4.18", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.18.tgz", + "integrity": "sha512-yrMMJ9hk2NVMD2xU2WoVrgFawAU6s/RzEl/dX9BhlyxZHazo1wHY35z4K2BIBzTUh4z3giCzrUoqRAqW2CWpWg==", "license": "MIT", "dependencies": { "@langchain/langgraph-checkpoint": "^1.1.5", - "@langchain/langgraph-sdk": "~1.10.2", + "@langchain/langgraph-sdk": "~1.12.0", "@langchain/protocol": "^0.0.19", "@standard-schema/spec": "1.1.0" }, @@ -1147,9 +1147,9 @@ } }, "node_modules/@langchain/langgraph-sdk": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.10.2.tgz", - "integrity": "sha512-86qsfdBZWu1ZgywLN8AThU/jXi9rjPDZPWcTJp4SA1A/L62ypTNoSXbvtiwZt1odokXccYTxK1XWS8tmVdvEmw==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.12.0.tgz", + "integrity": "sha512-F3AOZjKZRUGmE3FzY+RaVZI6WzXOkwnuF7jqgto6rDPSnO9OdVdYGvwGDi3jjRGf5xLoDtX2dpsR9z5KptU+5A==", "license": "MIT", "dependencies": { "@langchain/protocol": "^0.0.19", @@ -1194,9 +1194,9 @@ } }, "node_modules/@langchain/langgraph-sdk/node_modules/p-timeout": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", - "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.2.tgz", + "integrity": "sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==", "license": "MIT", "engines": { "node": ">=20" @@ -2064,9 +2064,9 @@ "license": "MIT" }, "node_modules/@ts-morph/common/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -2438,9 +2438,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.5.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz", - "integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==", + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", + "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3410,9 +3410,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4291,9 +4291,9 @@ "peer": true }, "node_modules/dompurify": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", - "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -5108,9 +5108,9 @@ } }, "node_modules/i18next": { - "version": "26.3.6", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", - "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", + "version": "26.4.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz", + "integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==", "funding": [ { "type": "individual", @@ -5338,9 +5338,9 @@ } }, "node_modules/joi": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.8.tgz", - "integrity": "sha512-G2TX62h58ZHuwqetJgP2F4ualakqAmZtBYe3jWen7gxQRw5xApX6crnFtuB91WC0c3ESBnva+kGSnb3+6pIQDQ==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.9.tgz", + "integrity": "sha512-2mD929bUVKUhOLQQEVhlf6EZ0Mlo0DeRb5MO7cViR9AXLtBauuccEtB1py9Ocxpo/P7ucnh442iY/iOwrh3IQw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5513,9 +5513,9 @@ "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, "node_modules/langchain": { - "version": "1.5.11", - "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.5.11.tgz", - "integrity": "sha512-6Sx9N5ylAJ11WrP1QnJLSIo75UABZbshzTJgG28H4mXuGcDk+w+7ZaNLkmGIh9sy/3PZcYS8UrI2rvH6A8NYIg==", + "version": "1.5.14", + "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.5.14.tgz", + "integrity": "sha512-/orHDk5xbNSIJc9UhwmxFYHXbr/3RaBeQ3zX0xovEWfPg7Lmj8EJCMOXIUTCe2UtsFgfbyWtlwh51NM562LnZQ==", "license": "MIT", "dependencies": { "@langchain/langgraph": "^1.4.13", @@ -5527,7 +5527,7 @@ "node": ">=20" }, "peerDependencies": { - "@langchain/core": "^1.2.10" + "@langchain/core": "^1.2.13" } }, "node_modules/langsmith": { diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 787e24949..a45921117 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -19,16 +19,16 @@ }, "dependencies": { "@langchain/anthropic": "^1.5.8", - "@langchain/core": "^1.2.8", + "@langchain/core": "^1.2.13", "@langchain/google-genai": "^2.3.1", - "@langchain/langgraph": "^1.4.14", + "@langchain/langgraph": "^1.4.18", "@langchain/ollama": "^1.3.0", "@langchain/openai": "^1.5.13", "@sigma/edge-curve": "^3.1.0", "@tailwindcss/vite": "^4.3.3", "axios": "^1.20.0", "d3": "^7.9.0", - "dompurify": "^3.4.15", + "dompurify": "^3.4.16", "gitnexus-shared": "file:../gitnexus-shared", "graphology": "^0.26.0", "graphology-indices": "^0.17.0", @@ -36,9 +36,9 @@ "graphology-layout-forceatlas2": "^0.10.1", "graphology-layout-noverlap": "^0.4.2", "graphology-utils": "^2.3.0", - "i18next": "^26.3.6", + "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", - "langchain": "^1.5.11", + "langchain": "^1.5.14", "lru-cache": "^11.5.3", "lucide-react": "^1.46.0", "mermaid": "^11.17.2", @@ -63,7 +63,7 @@ "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/dompurify": "^3.2.0", - "@types/node": "^26.5.1", + "@types/node": "^26.6.2", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@types/react-syntax-highlighter": "^15.5.13", diff --git a/gitnexus/README.md b/gitnexus/README.md index bd825aeda..4b85e7e3e 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -36,6 +36,8 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up `gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list or repeat the option, for example `gitnexus setup -c cursor,codex`. +For Claude Code, Cursor, and Codex, setup keeps an existing URL-based `gitnexus` MCP entry and its authentication settings. Skills are still installed; setup installs Claude Code and Codex hooks, while Cursor hooks require [manual installation](../gitnexus-cursor-integration/README.md#hook-install). + ### Editor Support | Editor | MCP | Skills | Hooks (auto-augment) | Support | @@ -398,7 +400,7 @@ An operation must name a protocol, either through its own `bindings` or through Like Actuator snapshots, documents are external to git freshness — replacing one moves no commit and dirties no file — so an enabled run always rebuilds, and the first later run without the option rebuilds once to remove document-derived evidence. There is no glob-based auto-discovery, and the option is unsupported with `--watch`. -> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove. +> **`gitnexus uninstall`** removes the named GitNexus MCP entries, hooks, and skill directories from detected editors. This includes a URL-based MCP entry you configured yourself, even if setup preserved it. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove. ## Remote Embeddings diff --git a/gitnexus/bench/emit-persistence/baselines-streaming.json b/gitnexus/bench/emit-persistence/baselines-streaming.json index 0fabf5a02..837d78d1f 100644 --- a/gitnexus/bench/emit-persistence/baselines-streaming.json +++ b/gitnexus/bench/emit-persistence/baselines-streaming.json @@ -1,5 +1,6 @@ { - "fingerprint": "dadb6eb19a751c3d315b7c53effc6605f89e0a766b01cd5a2ae611b3720d0d55", + "fingerprint": "9408aad13feb6a65c6ce031a3264b72d1afdb1fdd1e984666fe30c3ecc24d66f", "_rebaselined_3161_static_gated_column": "Same change as baselines.json's `_rebaselined_3161_static_gated_column`: every relationship row now ends in a `staticGated` cell (`0` here, no PDG edge is a gated call), so each of the 36,000 `rel_BasicBlock_BasicBlock.csv` data rows grew by 2 bytes (`...,\"T\",0` -> `...,\"T\",0,0`) while the 7,200 BasicBlock rows are unchanged. Evidence is the inverse operation: stripping the trailing `,0` from the rel rows and re-hashing the sorted bb + rel rows gives EXACTLY the prior baseline 381de8dede253140953775c290bf9a25f82ebf3cc8ecb5250ae06a63f648b089. byte_identical_nodes / byte_identical_edges stayed true and resident_basic_blocks stayed 0 while the fingerprint gate was red, so the streamed sink still matches the whole-graph emit byte for byte and the RSS bound holds. Prior 381de8dede253140953775c290bf9a25f82ebf3cc8ecb5250ae06a63f648b089 -> dadb6eb19a751c3d315b7c53effc6605f89e0a766b01cd5a2ae611b3720d0d55.", - "_note": "Byte-identity + bounded-retention gate for streaming/chunked PDG emit (#2202). fingerprint = sha256 of the sorted, header-stripped BasicBlock + PDG-edge data rows of the canonical synthetic set. --check also asserts the streamed PdgEmitSink output is byte-identical to the whole-graph streamAllCSVsToDisk emit (byte_identical_nodes/edges) and that the in-memory graph retains 0 BasicBlocks (resident_basic_blocks === 0, the O(chunk) RSS bound). Regenerate via `node --import tsx bench/emit-persistence/measure-streaming.mjs`." + "_note": "Byte-identity + bounded-retention gate for streaming/chunked PDG emit (#2202). fingerprint = sha256 of the sorted, header-stripped BasicBlock + PDG-edge data rows of the canonical synthetic set. --check also asserts the streamed PdgEmitSink output is byte-identical to the whole-graph streamAllCSVsToDisk emit (byte_identical_nodes/edges) and that the in-memory graph retains 0 BasicBlocks (resident_basic_blocks === 0, the O(chunk) RSS bound). Regenerate via `node --import tsx bench/emit-persistence/measure-streaming.mjs`.", + "_rebaselined_3442_ladybug_null_cells": "Same NULL serialization change as baselines.json (#3442). Compared the whole-graph output with the parent writer: file set, row order and all other bytes match. The fingerprinted BasicBlock and PDG-edge files contain exactly 38,400 quoted empty cells changed to bare cells (14,400 BasicBlock cells and 24,000 edge cells); restoring their quotes reproduces the exact prior fingerprint dadb6eb19a751c3d315b7c53effc6605f89e0a766b01cd5a2ae611b3720d0d55. The new fingerprint is 9408aad13feb6a65c6ce031a3264b72d1afdb1fdd1e984666fe30c3ecc24d66f. Both streamed/whole byte-identity flags remain true and resident_basic_blocks remains 0." } diff --git a/gitnexus/bench/emit-persistence/baselines.json b/gitnexus/bench/emit-persistence/baselines.json index 9c04fb248..374ae2067 100644 --- a/gitnexus/bench/emit-persistence/baselines.json +++ b/gitnexus/bench/emit-persistence/baselines.json @@ -1,5 +1,5 @@ { - "fingerprint": "d3c3a53ead47663816344369dc0e501f31d60c462039de190ab6f5361037bd79", + "fingerprint": "2f5c46506cb8dfb79a65a276646ace766cc7645fabab8b383c89f5f0270cc30b", "_rebaselined_objective_c_node_tables": "Objective-C support adds Protocol and Category node tables to csv-generator.ts's MULTI_LANG_TYPES, so this deterministic 2,400-entity synthetic emit now creates 38 CSV files rather than 36. The two additions, category.csv and protocol.csv, are both expected 55-byte header-only files because the synthetic graph contains no Objective-C nodes. Re-emitting the graph and recomputing the fingerprint after excluding exactly those two files yields the exact prior baseline 011485e5180c005be9dfec7ac8dc4e3bb0fcfc4a680a16dcae2cf2d8af5ef7e2, proving all 36 pre-existing files retain their bytes, routing, and within-file order. Prior 011485e5180c005be9dfec7ac8dc4e3bb0fcfc4a680a16dcae2cf2d8af5ef7e2 -> d3c3a53ead47663816344369dc0e501f31d60c462039de190ab6f5361037bd79. The timing guard remains within budget: local scaling_ratio 0.965 against 1.8 and elapsed_ms_large 69.24ms against 1000ms.", "_rebaselined_3161_static_gated_column": "Every relationship row gained a trailing `staticGated` BOOLEAN column (RELATION_SCHEMA in src/core/lbug/schema.ts, REL_CSV_HEADER + buildRelRow in csv-generator.ts, the fallback CREATE in lbug-adapter.ts), written as `0` for every edge that does not carry the flag and `1` for a Zig call site inside a comptime-false branch (PR #3161). Unlike the earlier header-only rebaselines this one touches ROWS, so the evidence is the inverse operation rather than a file-set diff: re-emitting this bench's 2,400-entity graph on the branch produces the same 36 CSV files; exactly 3 of them differ from a copy with the new column stripped (`rel_File_Class.csv` 156228 -> 151416 bytes, `rel_File_Function.csv` 334008 -> 324396, `rel_Function_Function.csv` 185028 -> 180216: one header field plus `,0` per row, 4,800 / 9,600 / 4,800 rows, 2 bytes each), the other 33 files are byte-identical, and the per-file fingerprint over the stripped copies is EXACTLY the prior baseline 72096279092d4f118de7e179333705c19c9aff2664f77d71a7da48cd9f73fb5a. So no row moved between pair files and no row reordered; the only bytes that changed are the appended column. Prior 72096279092d4f118de7e179333705c19c9aff2664f77d71a7da48cd9f73fb5a -> 011485e5180c005be9dfec7ac8dc4e3bb0fcfc4a680a16dcae2cf2d8af5ef7e2. Timing gates passed while the guard was red: scaling_ratio 0.82 against the 1.8 budget, elapsed_ms_large 185.54ms against the 1000ms backstop, so no throughput claim is being rebaselined away.", "scaling_budget": 1.8, @@ -9,5 +9,6 @@ "_rebaselined_2856_property_is_detail": "Third and last of the bench guards this branch left red. The Property node table gained an `isDetail` BOOLEAN column (see PROPERTY_SCHEMA in src/core/lbug/schema.ts), so `streamAllCSVsToDisk` writes one more header field and one more cell per Property row \u2014 csv-generator.ts `propertyHeader` and the `node.label === 'Property'` tail. Verified to be header-only drift rather than a change in what is emitted: dumping every CSV this bench produces on `origin/main` and on this branch and diffing per-file (filename, byte length, sha256) shows the file SET is identical at 35 CSVs on both sides, 34 of the 35 are byte-identical, and the sole difference is `property.csv` growing 68 -> 77 bytes, `id,name,filePath,startLine,endLine,content,description,declaredType` -> `...,declaredType,isDetail`. The synthetic graph has no Property nodes, so no ROW moved at all. That is the check that matters here: a row routed to the wrong pair file, or a within-file reordering, is what this fingerprint exists to catch, and neither happened. Prior 69e9182ae205183ade24c3d8ad5d7292aea677144b1cbe443dd631bc25b0cafe -> 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. Both timing gates passed unchanged while this was red (scaling_ratio 0.783 vs budget 1.8, elapsed_ms_large 229ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.", "_rebaselined_3040_convex_endpoint_factory": "Const and Function gained a trailing convexEndpointFactory column. A deterministic 2,400-entity emit produced the same 35 CSV files and fingerprint c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e. Removing the new Const and Function header fields plus the new trailing empty Function cell from each of 4,800 Function rows restored the exact prior fingerprint 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. No file or row moved or reordered. The measured scaling ratio remained 0.826 against the 1.8 budget and elapsed_ms_large was 307.75ms against the 1000ms backstop.", "_rebaselined_3107_route_runtime_evidence": "Route gained trailing runtimeConfirmed BOOLEAN, runtimeSource STRING, and runtimeStatus STRING columns in its schema, CSV header/rows, COPY statement, and graph API projection. The deterministic emit still produces the same 35 CSV files; the synthetic benchmark graph has no Route rows, so the only byte drift is the Route CSV header and no row moved or reordered. Prior c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e -> 7b2ec01a110dcbc66868fba2c97714aaece8c3864c2eba00df68b5c027f034d2. While the guard was red, scaling_ratio was 1.044 against the 1.8 budget and elapsed_ms_large was 121.08ms against the 1000ms backstop.", - "_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted \u2014 binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then, and record WHY in a `_rebaselined_` key alongside \u2014 bench/scope-capture/baselines.json sets that convention and it is what makes a regenerated hash reviewable. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`." + "_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted \u2014 binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then, and record WHY in a `_rebaselined_` key alongside \u2014 bench/scope-capture/baselines.json sets that convention and it is what makes a regenerated hash reviewable. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`.", + "_rebaselined_3442_ladybug_null_cells": "LadybugDB 0.21.1 preserves quoted empty strings, so escapeCSVField now emits bare empty cells to retain the SQL NULL meaning of the 0.18.3 writer (#3442). Compared the canonical emit with the parent writer: all 38 file names and every row order match; exactly 31,200 quoted empty cells became bare cells in six files, with no other byte changes. Restoring quotes around those cells reproduces the exact prior fingerprint d3c3a53ead47663816344369dc0e501f31d60c462039de190ab6f5361037bd79. The new fingerprint is 2f5c46506cb8dfb79a65a276646ace766cc7645fabab8b383c89f5f0270cc30b. Timing budgets remain unchanged." } diff --git a/gitnexus/bench/incremental-write-integrity/README.md b/gitnexus/bench/incremental-write-integrity/README.md new file mode 100644 index 000000000..a75861500 --- /dev/null +++ b/gitnexus/bench/incremental-write-integrity/README.md @@ -0,0 +1,86 @@ +# Incremental node identity reconciliation + +For the native cause of #3421/#3423 and regressions covering both missing +context results and Unicode Property FTS, see [search-root-cause.md](search-root-cause.md). + +From `gitnexus/`: + +```sh +node --import tsx bench/incremental-write-integrity/measure.cjs --check +node bench/incremental-write-integrity/reproduce.cjs +``` + +The first command measures the production reconciliation, including every +single-label scan and comparison of ID, name, path and source range. Construction, +CSV loading, checkpoints and correctness controls are outside the timed region. +Two warm-ups precede seven measured samples. Exact counts and SHA-256 fixture +fingerprints reject empty or incomplete work. `--check` gates normalized scaling +against the committed budget, rather than a machine-specific time limit. + +On Node v24.19.0, Linux x64, Xeon Platinum 8370C, the initial measurement was +583 ms for 16,384 nodes and 2,810 ms for 65,536 nodes per reconciliation. The +normalized scaling was 1.206 (budget 2). Analyze performs two reconciliations: +after graph COPY/checkpoint and after FTS/embedding work plus a final checkpoint, +before registration, freshness metadata or staging publication. + +The verifier covers retained nodes as well as the write set. A path/range filter +would let a corrupted field hide its own row; a count would miss swapped fields +or blank IDs. Folder lifecycle, preserved Community/Process layers and streamed +BasicBlock rows require other oracles and are excluded. Relationships and source +content are outside this identity check. + +## Native-only reduction + +`reproduce.cjs` uses only `@ladybugdb/core`, its native schema/query/COPY API, and +synthetic ASCII CSV files. There is no parser, parse cache, graph adapter, FTS, +relationship table, periodic checkpoint driver or concurrent writer. Compression +is disabled and COPY is serial, matching the production settings. + +It loads 8,192 Function rows, checkpoints, deletes 64 rows belonging to the first +and last owners, then copies back those same 64 tuples. An independent tuple-set +oracle validates the complete single-label scan before and after each operation. +It also compares affected scan rows with primary-key lookups after reopening. +Use `--keep` to retain the synthetic database and CSV files; the JSON output gives +their directory. `--require-corruption` is a diagnostic assertion, deliberately +not a CI requirement: it should fail when the native defect is fixed. + +With the original `@ladybugdb/core` 0.18.3, this reduction produced: + +| Phase | Rows | Incorrect tuples | +| ------------------------- | ----: | ---------------: | +| Initial COPY + checkpoint | 8,192 | 0 | +| DELETE | 8,128 | 0 | +| Incremental COPY | 8,192 | 3,936 | +| Explicit checkpoint | 8,192 | 3,936 | +| Read-only reopen | 8,192 | 3,936 | + +With `@ladybugdb/core` 0.21.1, the same reduction returns zero incorrect +tuples in every phase. The production reconciliation and negative controls +remain required; this result covers the reduced fixture, not a replay of the +original incident. + +For example, the scan returned an empty `id` at native offset 64, while a +primary-key lookup at that same offset returned +`Function:src/owner2.ts:fn64` with the correct name/path/range. Smaller 1,024, +2,048 and 4,096-row fixtures remained healthy in the same three-cycle adapter +probe. The affected tuple count varies with fixture size and scan shape. + +This isolates a native scan/lookup discrepancy introduced by incremental COPY +into a previously populated table. It survives checkpoint and reopen, but does +not establish physical byte loss or the precise C++ defect. The reported Unicode +change and FTS build are unnecessary for this reduction; this does not prove +that the original incident has exactly the same native cause. + +The measurement harness runs selective native writes too. An independent oracle +requires the production verifier to reject any inconsistent scan, both before +and after checkpoint/reopen; healthy scans must certify their complete identity +set. It does not quietly treat the native discrepancy as a successful graph. + +Missing-row and swapped-range negative controls separately verify that the +check fails closed. Publication tests inject damage after COPY and after FTS, +assert unchanged registry/freshness, preserve the live staged baseline, and +exercise automatic dirty-index recovery followed by a no-op run. +An in-place verification failure keeps a non-FTS dirty phase: an FTS-only +repair cannot clear the marker and recertify the inconsistent graph. +The same protection applies when FTS returns but analyzer finalization fails +before the final identity scan: recovery still rebuilds the graph. diff --git a/gitnexus/bench/incremental-write-integrity/baseline.json b/gitnexus/bench/incremental-write-integrity/baseline.json new file mode 100644 index 000000000..988196191 --- /dev/null +++ b/gitnexus/bench/incremental-write-integrity/baseline.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "linear_scaling_budget": 2, + "sizes": [ + { + "nodes": 16384, + "fingerprint": "17aa2ded124af9cbd15a9fe13013410e92a478f88e63a2ad9859a05aacef8efe" + }, + { + "nodes": 65536, + "fingerprint": "a6ce82aa1d07804d16b916892dfb6ca0e6f5ce5ed606f47cb1321eb124c6ac44" + } + ] +} diff --git a/gitnexus/bench/incremental-write-integrity/measure.cjs b/gitnexus/bench/incremental-write-integrity/measure.cjs new file mode 100644 index 000000000..8db1d2d5b --- /dev/null +++ b/gitnexus/bench/incremental-write-integrity/measure.cjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node +/** + * Native identity reconciliation overhead and scaling. + * node --import tsx bench/incremental-write-integrity/measure.cjs [--check] + * + * COPY, graph construction, fingerprints and correctness controls are untimed. + * Each timed sample includes all single-label scans and exact tuple checks. + * Counts/fingerprints reject empty output; timing gates use normalized scaling. + */ +const assert = require('node:assert/strict'); +const { mkdtemp, rm, readFile } = require('node:fs/promises'); +const { tmpdir, cpus } = require('node:os'); +const { join, resolve } = require('node:path'); +const { pathToFileURL } = require('node:url'); +const { createHash } = require('node:crypto'); +const ROOT = resolve(__dirname, '../..'); +const source = (file) => import(pathToFileURL(join(ROOT, file)).href); + +(async () => { + const { createKnowledgeGraph } = await source('src/core/graph/graph.ts'); + const { reconcileGraphNodeIdentities } = await source( + 'src/core/incremental/write-reconciliation.ts', + ); + const adapter = await source('src/core/lbug/lbug-adapter.ts'); + const baseline = JSON.parse(await readFile(join(__dirname, 'baseline.json'), 'utf8')); + assert.equal(baseline.version, 1); + assert.ok(Number.isFinite(baseline.linear_scaling_budget) && baseline.linear_scaling_budget > 0); + const results = []; + for (const size of baseline.sizes) { + assert.ok(Number.isSafeInteger(size.nodes) && size.nodes > 0); + assert.match(size.fingerprint, /^[a-f0-9]{64}$/); + const graph = createKnowledgeGraph(); + const hash = createHash('sha256'); + for (let i = 0; i < size.nodes; i++) { + const id = `Function:src/owner${Math.floor(i / 32)}.ts:fn${i}`; + const properties = { + name: `fn${i}`, + filePath: `src/owner${Math.floor(i / 32)}.ts`, + startLine: (i % 32) * 4, + endLine: (i % 32) * 4 + 2, + }; + graph.addNode({ id, label: 'Function', properties }); + hash.update( + JSON.stringify([ + id, + properties.name, + properties.filePath, + properties.startLine, + properties.endLine, + ]) + '\n', + ); + } + assert.equal(hash.digest('hex'), size.fingerprint); + const directory = await mkdtemp(join(tmpdir(), 'gnx-reconciliation-bench-')); + try { + await adapter.initLbug(join(directory, 'lbug'), { skipFts: true }); + await adapter.loadGraphToLbug( + graph, + directory, + directory, + undefined, + undefined, + undefined, + 'none', + ); + await adapter.tryFlushWAL(); + const samples = []; + let calls = 0; + const query = async (sql) => { + calls++; + return adapter.executeQuery(sql); + }; + for (let run = 0; run < 9; run++) { + calls = 0; + const start = performance.now(); + const receipt = await reconcileGraphNodeIdentities(graph, query, 'benchmark'); + const elapsed = performance.now() - start; + assert.equal(receipt.nodes, size.nodes); + assert.equal(calls, receipt.tables); + if (run >= 2) samples.push(elapsed); + } + const first = graph.iterNodes().next().value; + const alteredQuery = async (sql) => { + const rows = await adapter.executeQuery(sql); + if (sql.includes('(n:`Function`)')) rows.find((r) => r.id === first.id).startLine++; + return rows; + }; + await assert.rejects( + reconcileGraphNodeIdentities(graph, alteredQuery, 'negative-control'), + /startLine/, + ); + await assert.rejects( + reconcileGraphNodeIdentities(graph, async () => [], 'negative-control'), + /missing ID/, + ); + + // Native 0.18.3 can corrupt scan projections after this real write, even + // without FTS. Use an independent tuple-set oracle to require rejection + // whenever the scan is wrong; a future native fix can pass normally. + const { extractChangedSubgraph } = await source('src/core/incremental/subgraph-extract.ts'); + const files = new Set(['src/owner0.ts', `src/owner${Math.floor((size.nodes - 1) / 32)}.ts`]); + const wanted = new Set( + [...graph.iterNodes()].map((node) => + JSON.stringify([ + node.id, + node.properties.name, + node.properties.filePath, + node.properties.startLine, + node.properties.endLine, + ]), + ), + ); + const nativePhases = []; + const audit = async (phase) => { + const rows = await adapter.executeQuery( + 'MATCH (n:Function) RETURN n.id AS id, n.name AS name, ' + + 'n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine', + ); + const actual = new Set( + rows.map((r) => JSON.stringify([r.id, r.name, r.filePath, r.startLine, r.endLine])), + ); + const mismatched = [...wanted].filter((tuple) => !actual.has(tuple)).length; + const rejected = + rows.length !== size.nodes || mismatched > 0 || actual.size !== wanted.size; + if (rejected) { + await assert.rejects( + reconcileGraphNodeIdentities(graph, adapter.executeQuery, phase), + /Graph identity reconciliation/, + ); + } else { + assert.equal( + (await reconcileGraphNodeIdentities(graph, adapter.executeQuery, phase)).nodes, + size.nodes, + ); + } + nativePhases.push({ + phase, + rows: rows.length, + missing_tuples: mismatched, + verdict: rejected ? 'rejected' : 'certified', + }); + }; + await adapter.deleteNodesForFiles([...files]); + await adapter.loadGraphToLbug( + extractChangedSubgraph(graph, files), + directory, + directory, + undefined, + undefined, + undefined, + 'none', + ); + await audit('post-COPY'); + await adapter.tryFlushWAL(); + await audit('post-checkpoint'); + await adapter.closeLbug(); + await adapter.initLbug(join(directory, 'lbug'), { readOnly: true, skipFts: true }); + await audit('reopened'); + samples.sort((a, b) => a - b); + results.push({ + nodes: size.nodes, + queries: calls, + min_ms: +samples[0].toFixed(3), + median_ms: +samples[Math.floor(samples.length / 2)].toFixed(3), + fingerprint: size.fingerprint, + native_phases: nativePhases, + }); + } finally { + await adapter.closeLbug(); + await rm(directory, { recursive: true, force: true }); + } + } + const scaling = + results[1].median_ms / results[0].median_ms / (results[1].nodes / results[0].nodes); + const report = { + node: process.version, + platform: process.platform, + cpu: cpus()[0].model, + results, + normalized_scaling: +scaling.toFixed(3), + negative_controls: 'missing/swapped fields rejected', + native_sequences: + 'selective delete/COPY, checkpoint and read-only reopen checked against independent tuple oracle', + }; + process.stdout.write(JSON.stringify(report, null, 2) + '\n'); + if (process.argv.includes('--check')) + assert.ok( + scaling <= baseline.linear_scaling_budget, + `normalized scaling ${scaling} exceeds ${baseline.linear_scaling_budget}`, + ); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/gitnexus/bench/incremental-write-integrity/reproduce-search.cjs b/gitnexus/bench/incremental-write-integrity/reproduce-search.cjs new file mode 100644 index 000000000..8e4c2d2e9 --- /dev/null +++ b/gitnexus/bench/incremental-write-integrity/reproduce-search.cjs @@ -0,0 +1,251 @@ +#!/usr/bin/env node +/** Native search contracts for #3421/#3423. See search-root-cause.md. */ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +// Diagnostic overrides allow the identical workload to test an older native engine. +const lbug = require(process.env.LBUG_MODULE || '@ladybugdb/core'); +const scenario = process.argv[2]; +assert.ok( + ['context', 'property'].includes(scenario), + 'Usage: reproduce-search.cjs context|property [--keep]', +); + +(async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ladybug-search-')); + const checks = []; + let database; + let connection; + const record = async (name, fn) => { + try { + checks.push({ name, ok: true, ...(await fn()) }); + } catch (error) { + checks.push({ name, ok: false, error: String(error) }); + } + }; + const close = async () => { + const activeConnection = connection; + const activeDatabase = database; + connection = undefined; + database = undefined; + const errors = []; + try { + await activeConnection?.close(); + } catch (error) { + errors.push(error); + } + try { + await activeDatabase?.close(); + } catch (error) { + errors.push(error); + } + if (errors.length) throw new AggregateError(errors, 'Native cleanup failed'); + }; + const query = async (sql, params) => { + const raw = params + ? await connection.execute(await connection.prepare(sql), params) + : await connection.query(sql); + const rows = []; + const errors = []; + // FTS expands into multiple statements: consume and close every result, + // including when an earlier statement reports a deferred native error. + for (const result of Array.isArray(raw) ? raw : [raw]) { + try { + rows.push(...(await result.getAll())); + } catch (error) { + errors.push(error); + } + try { + await result.close(); + } catch (error) { + errors.push(error); + } + } + if (errors.length) throw new AggregateError(errors, errors.map(String).join('; ')); + return rows; + }; + const table = scenario === 'context' ? 'Function' : 'Property'; + const columns = ['id', 'name', 'filePath', 'startLine', 'endLine']; + if (scenario === 'property') columns.push('content', 'description'); + const indices = Array.from({ length: 8192 }, (_, i) => i); + const changed = (i) => i < 32 || i >= 8192 - 32; + const tuple = (i) => + scenario === 'context' + ? [ + `Function:src/owner${Math.floor(i / 32)}.ts:unchangedSwiftTarget${i}`, + `unchangedSwiftTarget${i}`, // Out-of-line name, unlike the short fn64 fixture. + `src/owner${Math.floor(i / 32)}.ts`, + (i % 32) * 4, + (i % 32) * 4 + 2, + ] + : [ + `Property:src/owner${Math.floor(i / 32)}.swift:property${i}`, + `unchangedPropertyValue${i}${'王'.repeat(i % 17)}`, + `src/owner${Math.floor(i / 32)}.swift`, + i, + i + 1, + `var unchangedPropertyValue${i} = "Valid UTF8 Å王 café 東京 ${i} ${'Résumé Ελληνικά Привет 🙂 '.repeat(i % 53)}"${i === 64 ? ' retainedsearchsentinel' : ''}`, + `Documentation for property ${i} ${'正常な日本語 naïve façade Å王 '.repeat(i % 37)}`, + ]; + const csv = (selected) => + columns.join(',') + + '\n' + + selected + .map((i) => + tuple(i) + .map((value) => '"' + String(value).replaceAll('"', '""') + '"') + .join(','), + ) + .join('\n') + + '\n'; + const literal = (value) => "'" + value.replace(/\\/g, '/').replaceAll("'", "\\'") + "'"; + const copy = (file) => + query( + `COPY ${table} FROM ${literal(path.join(directory, file))} ` + + `(HEADER=true, ESCAPE='"', DELIM=',', QUOTE='"', PARALLEL=false, auto_detect=false)`, + ); + const extension = + process.env.FTS_LIBRARY || + path.resolve( + __dirname, + '../../vendor/lbug-fts/prebuilds', + `${process.platform}-${process.arch}`, + 'libfts.lbug_extension', + ); + const open = async (readOnly) => { + database = new lbug.Database( + path.join(directory, 'db'), + 256 * 1024 * 1024, + false, + readOnly, + 4 * 1024 ** 3, + true, + 64 * 1024 * 1024, + true, + true, + ); + connection = new lbug.Connection(database); + if (scenario === 'property') await query(`LOAD ${literal(extension)}`); + }; + const scan = async (stage, selected = indices) => + record(`${stage}:scan`, async () => { + const rows = await query( + `MATCH (n:${table}) RETURN ${columns.map((c) => `n.${c} AS ${c}`).join(', ')}`, + ); + const expected = new Set(selected.map((i) => JSON.stringify(tuple(i)))); + const actual = rows.map((row) => JSON.stringify(columns.map((c) => row[c]))); + const actualSet = new Set(actual); + const wrong = actual.filter((row) => !expected.has(row)).length; + const missing = [...expected].filter((row) => !actualSet.has(row)).length; + assert.deepEqual( + { rows: rows.length, wrong, missing }, + { rows: selected.length, wrong: 0, missing: 0 }, + ); + return { rows: rows.length, wrong, missing }; + }); + const lookup = async (stage) => { + const target = tuple(64); // Retained throughout DELETE/COPY. + const select = + 'n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine'; + for (const [kind, sql, params] of [ + ['primary-key', `MATCH (n:Function {id: $uid}) RETURN ${select} LIMIT 1`, { uid: target[0] }], + // Match LocalBackend.resolveContextSymbol's label-free UID/name queries. + ['uid', `MATCH (n {id: $uid}) RETURN ${select} LIMIT 1`, { uid: target[0] }], + [ + 'name', + `MATCH (n) WHERE n.name = $symName RETURN ${select} ORDER BY n.id LIMIT 20`, + { symName: target[1] }, + ], + ]) + await record(`${stage}:${kind}`, async () => { + const rows = await query(sql, params); + assert.deepEqual( + rows.map((row) => columns.map((column) => row[column])), + [target], + ); + return { ids: rows.map((row) => row.id) }; + }); + }; + const build = () => + query("CALL CREATE_FTS_INDEX('Property', 'property_fts', ['name', 'content', 'description'])"); + const drop = () => query("CALL DROP_FTS_INDEX('Property', 'property_fts')"); + const search = async (stage) => { + await record(`${stage}:catalog`, async () => { + const indexes = await query('CALL SHOW_INDEXES() RETURN *'); + const matches = indexes.filter( + (row) => row.table_name === 'Property' && row.index_name === 'property_fts', + ); + assert.equal(matches.length, 1); + assert.deepEqual(matches[0].property_names, ['name', 'content', 'description']); + return { indexes: matches.length }; + }); + await record(`${stage}:fts-query`, async () => { + const rows = await query( + "CALL QUERY_FTS_INDEX('Property', 'property_fts', 'retainedsearchsentinel') RETURN node, score ORDER BY score DESC, node.id LIMIT 20", + ); + const ids = rows.map((row) => row.node.id); + assert.deepEqual(ids, [tuple(64)[0]]); + return { ids }; + }); + for (const column of ['name', 'content', 'description']) + await record(`${stage}:lower-${column}`, async () => { + const rows = await query(`MATCH (n:Property) RETURN LOWER(n.${column}) AS value`); + assert.equal(rows.length, indices.length); + return { rows: rows.length }; + }); + }; + const check = async (stage) => { + await scan(stage); + if (scenario === 'context') await lookup(stage); + else await search(stage); + }; + try { + await fs.writeFile(path.join(directory, 'full.csv'), csv(indices)); + await fs.writeFile(path.join(directory, 'delta.csv'), csv(indices.filter(changed))); + await open(false); + const schema = columns.map((c) => `${c} ${c.endsWith('Line') ? 'INT64' : 'STRING'}`).join(', '); + await query(`CREATE NODE TABLE ${table}(${schema}, PRIMARY KEY(id))`); + // Multiple labels make the UID query use a filtered scan, as in GitNexus. + if (scenario === 'context') await query(`CREATE NODE TABLE Method(${schema}, PRIMARY KEY(id))`); + await copy('full.csv'); + if (scenario === 'property') await record('baseline:build', build); + await query('CHECKPOINT'); + await check('baseline'); + if (scenario === 'property') await drop(); + const suffix = scenario === 'context' ? 'ts' : 'swift'; + await query( + `MATCH (n:${table}) WHERE n.filePath IN ['src/owner0.${suffix}', 'src/owner255.${suffix}'] DETACH DELETE n`, + ); + await scan( + 'after-delete', + indices.filter((i) => !changed(i)), + ); + await copy('delta.csv'); + // Build first: scanning strings beforehand can change the old engine's error manifestation. + if (scenario === 'property') await record('after-copy:build', build); + await check('after-copy'); + if (scenario === 'property') { + await record('repair:drop', drop); + await record('repair:build', build); + await check('repair'); + } + await query('CHECKPOINT'); + await check('after-checkpoint'); + await close(); + await open(true); + await check('reopened'); + } catch (error) { + checks.push({ name: 'setup-or-write', ok: false, error: String(error) }); + } finally { + await record('close', close); + if (!process.argv.includes('--keep')) { + await record('remove-fixture', () => fs.rm(directory, { recursive: true, force: true })); + } + } + console.log(JSON.stringify({ scenario, native: lbug.VERSION, directory, checks })); + if (checks.some((check) => !check.ok)) process.exitCode = 1; +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/gitnexus/bench/incremental-write-integrity/reproduce.cjs b/gitnexus/bench/incremental-write-integrity/reproduce.cjs new file mode 100644 index 000000000..a270d3331 --- /dev/null +++ b/gitnexus/bench/incremental-write-integrity/reproduce.cjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/** Native-only selective COPY scan discrepancy; no parser, graph adapter or FTS. */ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const lbug = require('@ladybugdb/core'); + +(async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ladybug-copy-identity-')); + let database; + let connection; + const close = async () => { + // Retire this session before closing so failures or reopening cannot close it twice. + const activeConnection = connection; + const activeDatabase = database; + connection = undefined; + database = undefined; + const errors = []; + try { + await activeConnection?.close(); + } catch (error) { + errors.push(error); + } + try { + await activeDatabase?.close(); + } catch (error) { + errors.push(error); + } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new AggregateError(errors, 'Native resource cleanup failed'); + }; + const errors = []; + try { + const dbPath = path.join(directory, 'lbug'); + const count = 8192; + const tuple = (i) => [ + `Function:src/owner${Math.floor(i / 32)}.ts:fn${i}`, + `fn${i}`, + `src/owner${Math.floor(i / 32)}.ts`, + (i % 32) * 4, + (i % 32) * 4 + 2, + ]; + const indices = Array.from({ length: count }, (_, i) => i); + const expected = new Set(indices.map((i) => JSON.stringify(tuple(i)))); + const csv = (rows) => + 'id,name,filePath,startLine,endLine\n' + + rows + .map((i) => + tuple(i) + .map((value) => JSON.stringify(value)) + .join(','), + ) + .join('\n') + + '\n'; + await fs.writeFile(path.join(directory, 'full.csv'), csv(indices)); + await fs.writeFile( + path.join(directory, 'delta.csv'), + csv(indices.filter((i) => i < 32 || i >= count - 32)), + ); + + // Match GitNexus's uncompressed native constructor and serial COPY options. + const open = (readOnly) => + new lbug.Database( + dbPath, + 256 * 1024 * 1024, + false, + readOnly, + 4 * 1024 ** 3, + true, + 64 * 1024 * 1024, + true, + true, + ); + database = open(false); + connection = new lbug.Connection(database); + const query = async (cypher, params) => { + const result = params + ? await connection.execute(await connection.prepare(cypher), params) + : await connection.query(cypher); + try { + return await result.getAll(); + } finally { + await result.close(); + } + }; + const copy = (file) => + query( + `COPY Function FROM "${path.join(directory, file).replace(/\\/g, '/')}" ` + + `(HEADER=true, ESCAPE='"', DELIM=',', QUOTE='"', PARALLEL=false, auto_detect=false)`, + ); + const phases = []; + const scan = async (phase) => { + const rows = await query( + 'MATCH (n:Function) RETURN id(n) AS internalID, n.id AS id, n.name AS name, ' + + 'n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine', + ); + const key = (r) => JSON.stringify([r.id, r.name, r.filePath, r.startLine, r.endLine]); + const bad = rows.filter((r) => !expected.has(key(r))); + const actual = new Set(rows.map(key)); + const missing = [...expected].filter((value) => !actual.has(value)).length; + phases.push({ + phase, + rows: rows.length, + wrong_tuples: bad.length, + missing_tuples: missing, + examples: bad.slice(0, 2), + }); + return { rows, bad, missing }; + }; + await query( + 'CREATE NODE TABLE Function(id STRING, name STRING, filePath STRING, ' + + 'startLine INT64, endLine INT64, PRIMARY KEY(id))', + ); + await copy('full.csv'); + await query('CHECKPOINT'); + const before = await scan('baseline'); + assert.equal(before.rows.length, count); + assert.equal(before.missing, 0); + const baselineIdsByOffset = new Map(before.rows.map((r) => [r.internalID.offset, r.id])); + await query( + "MATCH (n:Function) WHERE n.filePath IN ['src/owner0.ts', 'src/owner255.ts'] DETACH DELETE n", + ); + const deleted = await scan('after-delete'); + assert.equal(deleted.rows.length, count - 64); + assert.equal(deleted.bad.length, 0); + await copy('delta.csv'); + const copied = await scan('after-copy'); + await query('CHECKPOINT'); + await scan('after-checkpoint'); + await close(); + database = open(true); + connection = new lbug.Connection(database); + const reopened = await scan('reopened'); + const pointLookups = []; + for (const row of reopened.bad.slice(0, 2)) { + const id = baselineIdsByOffset.get(row.internalID.offset); + if (!id) continue; + pointLookups.push({ + scan: row, + lookup: await query( + 'MATCH (n:Function {id: $id}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, ' + + 'n.startLine AS startLine, n.endLine AS endLine', + { id }, + ), + }); + } + process.stdout.write( + JSON.stringify( + { + native_version: lbug.VERSION, + node: process.version, + phases, + point_lookups: pointLookups, + ...(process.argv.includes('--keep') ? { directory } : {}), + }, + null, + 2, + ) + '\n', + ); + if (process.argv.includes('--require-corruption')) { + assert.ok( + copied.bad.length > 0 || copied.missing > 0 || copied.rows.length !== count, + 'Native failure did not reproduce; this is a diagnostic, not a passing CI invariant', + ); + } + } catch (error) { + errors.push(error); + } finally { + try { + await close(); + } catch (error) { + errors.push(error); + } + try { + if (!process.argv.includes('--keep')) + await fs.rm(directory, { recursive: true, force: true }); + } catch (error) { + errors.push(error); + } + } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new AggregateError(errors, 'Native benchmark failed'); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/gitnexus/bench/incremental-write-integrity/search-root-cause.md b/gitnexus/bench/incremental-write-integrity/search-root-cause.md new file mode 100644 index 000000000..fc09ee6dd --- /dev/null +++ b/gitnexus/bench/incremental-write-integrity/search-root-cause.md @@ -0,0 +1,100 @@ +# Incremental search failures: native cause and regression contracts + +Issues [#3421](https://github.com/abhigyanpatwari/GitNexus/issues/3421) and +[#3423](https://github.com/abhigyanpatwari/GitNexus/issues/3423) reported two +symptoms after a small incremental write: valid source strings caused Property +FTS to fail in `LOWER`, and unchanged symbols stopped resolving by both name and +UID. A full rebuild restored service in both reports. + +## Cause and effective fix + +LadybugDB 0.18.3's `StringColumn::scanFiltered` compared a position in the output +vector with a segment-local length. When a scan crossed segments after selective +DELETE/COPY, it could skip valid string positions. The native scan could return +blank or stale string values while a labeled primary-key lookup still returned +the correct tuple. This establishes an incorrect read, not physical byte loss. + +Upstream [LadybugDB #737](https://github.com/LadybugDB/ladybug/pull/737), commit +`a10cecbc76e05f993af6c6f4a57edbbf438bb376`, passes the current segment's scan length +into `scanFiltered` and bounds positions by +`offsetInResult <= pos < offsetInResult + numValuesToScan`. The local read offset +is `startOffsetInChunk + pos - offsetInResult`. The correction first shipped in +LadybugDB 0.19.0. + +GitNexus already includes it through the 0.21.1 dependency in +[#3442](https://github.com/abhigyanpatwari/GitNexus/pull/3442), commit +`412446408d0e3f286b9e461fc451c25bf6283b3c`. Keep that native correction and the +existing publication reconciliation. Changing only the UID query to a labeled +lookup would hide one symptom while leaving name scans and FTS vulnerable. + +## Controlled causal check + +On Linux x64, a clean build of upstream tag `v0.18.3` reproduced both symptoms via +the native C API, without GitNexus, its parser, or the Node binding. Applying +**only the two production-file changes from #737** to that same checkout and +rebuilding removed both failures using the same CSV input and FTS extension +(`v0.18.1/linux_amd64`, the extension ABI used by core 0.18.3). + +| Observation after selective COPY | Untouched 0.18.3 | 0.18.3 + #737 only | +| --- | --- | --- | +| Label-free retained UID lookup | 0 rows | Correct row | +| Label-free retained name lookup | 0 rows | Correct row | +| Labeled primary-key control | Correct row | Correct row | +| Function scan with blank ID/name | 1,984 rows | 0 rows | +| Property FTS creation | `LOWER: Invalid UTF-8` | Success | +| Property scan with blank ID/name | 1,984 rows | 0 rows | +| Property `LOWER(content)` | `Invalid UTF-8` | Success | +| Drop/recreate Property FTS | Missing index / orphan table | Success | + +The Node fixtures also fail on published core 0.18.3 and pass on 0.21.1. Incorrect +tuple counts vary with the fixture and scan shape (1,984 or 3,936 observed). +The UTF-8 exception itself varies between runs: a failed string scan can return +blank values without throwing. Therefore the regression oracle compares every +source field and checks lookup results, rather than requiring an exception or +treating a successful FTS build as sufficient. + +FTS creation expands into multiple statements in the extension's +`create_fts_index.cpp`: it creates internal tables, tokenizes property values +with `LOWER`, then registers the index. A failure during tokenization can leave +`0_property_fts_appears_info` without a catalog index. Dropping by index name then +fails, and creating again can encounter the orphan table. FTS-only repair cannot +correct the underlying string scan. The experiment reproduced this failure +chain, but not #3421's exact missing-index message without an appended native +error. Neither reporter's overwritten private database was available, so this +is a controlled reproduction of both reported symptoms, not a historical replay. + +## Permanent regressions + +From `gitnexus/`: + +```sh +node bench/incremental-write-integrity/reproduce-search.cjs context +node bench/incremental-write-integrity/reproduce-search.cjs property +npx vitest run test/integration/lbug-incremental-search.test.ts +``` + +Each workload loads 8,192 rows, checkpoints, deletes the first and last owners +(64 rows), then copies back exactly those rows. Row 64 remains untouched. + +- `context` uses out-of-line ASCII names and a second node table. Those details + matter: a single table can turn the UID query into a healthy primary-key scan, + and short names can stay readable while their IDs are blank. It runs the + label-free UID/name query shapes from `LocalBackend.resolveContextSymbol` plus + the labeled primary-key control. +- `property` uses valid, variable-length Unicode in name/content/description, + builds all three FTS columns, checks the catalog and a retained unique search + term, exercises `LOWER` on each column, and drops/recreates the index. It uses + the production FTS result shape (`RETURN node, score`). + +Both compare the complete expected tuple set before/after the write, after an +explicit checkpoint, and after read-only reopen. The Property fixture also +checks the repair cycle. The Vitest wrapper runs each native workload in a child +process, requires every expected phase, and rejects nonzero exits and signals. +It belongs to the serialized `lbug-db` project and the Windows/macOS native test list. + +The harness requires no network access. It defaults to the pinned core and +vendored platform FTS library. For a diagnostic comparison, set `LBUG_MODULE` +to an absolute path to another installed `@ladybugdb/core` and `FTS_LIBRARY` +to its matching native extension. Core 0.18.3 is expected to exit nonzero; do not +skip the test on that version. `--keep` retains only the synthetic fixture files; +otherwise cleanup errors are reported and fail the command. diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 4845f23fb..60ffca32d 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -119,7 +119,8 @@ "_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: ruby-callable-alternatives/app.rb (+1 file, +58 groups). Emitter delta: +5 / -0: seeds for single-statement `if` / `elsif` branches (run_then, run_else, run_sweep, run_a, run_b); the multi-statement branch contributes nothing. capture_groups_fp 1358 -> 1416, fixture_count 91 -> 92; synthetic counts unchanged; scaling 1.04 < 1.5. Prior 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57 -> 45e65d9fa8a9e5e905ddb596b179b77c86ed13ab5139c6b17ccaaf7315dfe46a." }, "swift": { - "fingerprint": "d56406c2645637042899cfcc8dc73f603d77caef0a9a2bac179ffbec848258a3", + "fingerprint": "2507ac75ba6fb47c272a5caa97b5e8b9f6ebc5098c15295d05c7bff3cabc321a", + "_rebaselined_3425_injected_closure": "Swift capture fixture growth: 83 to 85 fixtures, 1436 to 1553 capture groups; scaling budget unchanged.", "_rebaselined_3355_xcode_and_import_fixtures": "#3355 follow-up: new swift-xcode-targets fixture (four sources) and two sources added to swift-nested-packages (a Docs/Net folder decoy and an `import Net` caller). The capture query is unchanged; this is fixture-corpus growth only. capture_groups_fp 1393 -> 1436 and fixture_count 77 -> 83; synthetic scale counts remain 5012/16012. Prior aea33bf12f57561be7e3125929fb98cec7aed5a681e25aad435c7bb558747853 -> d56406c2645637042899cfcc8dc73f603d77caef0a9a2bac179ffbec848258a3; measured scaling 1.036 < 1.5.", "_rebaselined_3355_nested_packages": "#3355: new swift-nested-packages fixture (three nested Package.swift manifests, six sources) for nested-package module grouping. The capture query is unchanged; this is fixture-corpus growth only. capture_groups_fp 1333 -> 1393 and fixture_count 68 -> 77; synthetic scale counts remain 5012/16012. Prior c9fc553662f0db18027fba882e3ff730744f6153cc46eca7b00667fabd6a0df8 -> aea33bf12f57561be7e3125929fb98cec7aed5a681e25aad435c7bb558747853; measured scaling 1.023 < 1.5.", "scaling_budget": 1.5, @@ -187,7 +188,8 @@ "capture_groups_fp": 680 }, "typescript": { - "fingerprint": "77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467", + "fingerprint": "a7972d87abd253583dafa37443bee8ddf5c635d41b4dfbb0c831efda71d82b47", + "_rebaselined_3446_mcp_tools_fixture": "#3446 adds typescript-mcp-tools/src/{handlers,server,tools}.ts: corpus growth only, with fixture_count 171 -> 174 and capture_groups_fp 2753 -> 2894. Excluding only that fixture directory restores the prior fingerprint 77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467 exactly. The scope emitter, synthetic capture counts (4503/14403), scaling budget, and other language baselines are unchanged.", "_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.", "scaling_budget": 1.5, "_rebaselined_2934_import_type_only": "#2934: `import-decomposer.ts` attaches a presence-only `@import.type-only` synthetic capture to specifiers `tsc` erases, so `check --cycles` can stop counting type-only edges as initialization cycles. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE \u2014 the tag is added to import matches that already existed, never a new match, the same shape as the #2747 receiver-chain rebaseline. Every count is unchanged: capture_groups_fp 2414, fixture_count 155, capture_groups_small/large 4503/14403 (those measure the SYNTHETIC scaling source, which has no imports at all). The fingerprint moves because `canonicalizeMatch` in measure.mjs hashes every TAG on every match, synthetics included, so one extra presence-only tag on an existing match rewrites that match's canonical string. Attribution is exact, not inferred: neutralizing ONLY the `m['@import.type-only'] = \u2026` assignment in import-decomposer.ts and re-running returns the fingerprint to c2fbf8a89e5686dd\u2026 byte-for-byte, so nothing else in the TypeScript capture stream moved. All 14 other languages report ok. Scaling 0.997 < 1.5. NOTE ON THE CONTROL: javascript did not move (2026993b\u2026, 43 fixtures), but it is a WEAK control here \u2014 `import type` is TypeScript-only syntax, so a JS corpus cannot express the construct and could not have drifted either way. It evidences no collateral damage, not the correctness of the TS change; the exact-attribution check above is what does that. Prior c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8 -> f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f.", diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 81c235e00..a776592f2 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -11,7 +11,7 @@ "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@eagleoutice/tree-sitter-r": "^1.1.2", - "@ladybugdb/core": "0.18.3", + "@ladybugdb/core": "0.21.1", "@modelcontextprotocol/sdk": "^1.0.0", "@scarf/scarf": "^1.4.0", "busboy": "^1.6.0", @@ -66,11 +66,11 @@ "@types/express": "^5.0.6", "@types/node": "^26.0.0", "@types/proxy-addr": "^2.0.3", - "@vitest/coverage-v8": "^4.0.18", + "@vitest/coverage-v8": "^5.0.0", "gitnexus-shared": "file:../gitnexus-shared", "tsx": "^4.0.0", "typescript": "^7.0.2", - "vitest": "^4.0.18" + "vitest": "^5.0.0" }, "engines": { "node": "^22.18.0 || >=24.11.0" @@ -238,15 +238,6 @@ } } }, - "node_modules/@eagleoutice/tree-sitter-r/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -753,9 +744,9 @@ } }, "node_modules/@ladybugdb/core": { - "version": "0.18.3", - "resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.18.3.tgz", - "integrity": "sha512-XjpPKW4MrL28D2gYGTZuIjiEcPx12L21lx58QggrdrItw8o/e9Lmg/Ejoo4Kz08lZj+rIcC1Fu9thzIYOTUlJw==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.21.1.tgz", + "integrity": "sha512-G0LWyn/dIqX9yHlyjpR12Rvou2o1dvCB9EkuzGa5ykCYbzur8EQhRUbi4fw3FlbckWvR6rK/tVbnFpBcRLwiHg==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -764,17 +755,17 @@ "node-addon-api": "^6.0.0" }, "optionalDependencies": { - "@ladybugdb/core-darwin-arm64": "0.18.3", - "@ladybugdb/core-darwin-x64": "0.18.3", - "@ladybugdb/core-linux-arm64": "0.18.3", - "@ladybugdb/core-linux-x64": "0.18.3", - "@ladybugdb/core-win32-x64": "0.18.3" + "@ladybugdb/core-darwin-arm64": "0.21.1", + "@ladybugdb/core-darwin-x64": "0.21.1", + "@ladybugdb/core-linux-arm64": "0.21.1", + "@ladybugdb/core-linux-x64": "0.21.1", + "@ladybugdb/core-win32-x64": "0.21.1" } }, "node_modules/@ladybugdb/core-darwin-arm64": { - "version": "0.18.3", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.18.3.tgz", - "integrity": "sha512-DGZTOlvSS4esEb1vTekY5IDoAvZAeYzR5cXVkECtQj9BVkk05zsvCAdTPo1Rz1BuI0qvqUVF+2WlIerI67iA2g==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.21.1.tgz", + "integrity": "sha512-TEFYNqBdbIojf1t29p3esgRhMDm56lD5eK91dwWFZJTVWAZoGXn+yLKs0NUQgj3lW2ZquuRTLZGglPhFEsDL1Q==", "cpu": [ "arm64" ], @@ -785,9 +776,9 @@ ] }, "node_modules/@ladybugdb/core-darwin-x64": { - "version": "0.18.3", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.18.3.tgz", - "integrity": "sha512-Qp6j0CM/orBlK6KD0p/s4ofkIhNUwi1hdCgMw+fj81UHugWHkVLiYV4grRBdHhyplw+snchZpTxvfpxFbkG1Cw==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.21.1.tgz", + "integrity": "sha512-w9p3oKrqeMOeWpX3xdCc/hQJHfrZtpB9We+Ir+0qyls68dSQoeXXR1GZYzzTElOtxr4OM5USwYbVyxADx+CMZg==", "cpu": [ "x64" ], @@ -798,9 +789,9 @@ ] }, "node_modules/@ladybugdb/core-linux-arm64": { - "version": "0.18.3", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.18.3.tgz", - "integrity": "sha512-F9miYjBuS43I7uNG199FNMqwdHJ98WA6dU3v2SZCeLXmXCdRzmYcuHQWlbNr2Tba9CX58w2XvBZoUaXZKJ/yKQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.21.1.tgz", + "integrity": "sha512-TodWmzOHxmmKvXXo9buBXGeyolxf/NqBUbo/LcR/tuMViKhGH8TBzIXi8DfVVwmsJ+CQCU065TvXgv2JoHAlZg==", "cpu": [ "arm64" ], @@ -811,9 +802,9 @@ ] }, "node_modules/@ladybugdb/core-linux-x64": { - "version": "0.18.3", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.18.3.tgz", - "integrity": "sha512-AfG5RDp/f/IDctDMpTAT5+2MYNtlWT191xiQNjSaWD4X85DhY3Dzps8Qu5VteIAPih5d6mmoaKGs8q0XIjfkFA==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.21.1.tgz", + "integrity": "sha512-hCcLYv8ds0x4fkTaRkuc4f75KLdH9e0EV51ne6uBNdQSY7kyc0RgSoUG3utUGfFDMMkfgIJAeIxQY4ck9jQiXg==", "cpu": [ "x64" ], @@ -824,9 +815,9 @@ ] }, "node_modules/@ladybugdb/core-win32-x64": { - "version": "0.18.3", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.18.3.tgz", - "integrity": "sha512-bHuFk0m9cnq0WGd9I4D8or8g6cC/BS58iatMtilqM3JpDPIQIFk6MQl6exL7P4xyWbkLwQgsrv2ToDnyoQNKvg==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.21.1.tgz", + "integrity": "sha512-nBS1XSRJfpexdlhkezmEiRtkUt2qEDp4zBjIhH+oxgRdD7UsRz1Jyit8foJuZAmUsoQGukN5LO+YeyDU2WZTyA==", "cpu": [ "x64" ], @@ -843,9 +834,9 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.1.tgz", - "integrity": "sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA==", + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.31.0.tgz", + "integrity": "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", @@ -883,9 +874,9 @@ } }, "node_modules/@nodable/entities": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", - "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz", + "integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==", "funding": [ { "type": "github", @@ -895,13 +886,14 @@ "license": "MIT" }, "node_modules/@oxc-project/types": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", - "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", "dev": true, "license": "MIT", + "peer": true, "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@pinojs/redact": { @@ -910,10 +902,28 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", - "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", "cpu": [ "arm64" ], @@ -923,14 +933,15 @@ "os": [ "android" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", "cpu": [ "arm64" ], @@ -940,14 +951,15 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", - "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", "cpu": [ "x64" ], @@ -957,14 +969,15 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", - "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", "cpu": [ "x64" ], @@ -974,14 +987,15 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", - "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", "cpu": [ "arm" ], @@ -991,14 +1005,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", - "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", "cpu": [ "arm64" ], @@ -1008,14 +1023,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", - "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", "cpu": [ "arm64" ], @@ -1025,14 +1041,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", - "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", "cpu": [ "ppc64" ], @@ -1042,14 +1059,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", - "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", "cpu": [ "s390x" ], @@ -1059,14 +1077,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", - "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", "cpu": [ "x64" ], @@ -1076,14 +1095,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", - "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", "cpu": [ "x64" ], @@ -1093,14 +1113,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", - "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", "cpu": [ "arm64" ], @@ -1110,14 +1131,15 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", - "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", "cpu": [ "arm64" ], @@ -1127,14 +1149,15 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", - "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", "cpu": [ "x64" ], @@ -1144,6 +1167,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -1153,7 +1177,8 @@ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@scarf/scarf": { "version": "1.4.0", @@ -1162,13 +1187,6 @@ "hasInstallScript": true, "license": "Apache-2.0" }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -1306,9 +1324,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", - "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", + "version": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", "dev": true, "license": "MIT", "dependencies": { @@ -1701,29 +1719,27 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", - "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.0.tgz", + "integrity": "sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.11", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" + "@vitest/istanbul-lib-coverage": "^1.0.0", + "@vitest/istanbul-lib-report": "^1.0.0", + "ast-v8-to-istanbul": "^1.0.5", + "magicast": "^0.5.4", + "obug": "^2.1.4", + "std-env": "^4.2.0", + "tinyrainbow": "^3.1.1" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.11", - "vitest": "4.1.11" + "@vitest/browser": "5.0.0", + "vitest": "5.0.0" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1731,34 +1747,40 @@ } } }, - "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "node_modules/@vitest/istanbul-lib-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz", + "integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@vitest/istanbul-lib-report": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz", + "integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "@vitest/istanbul-lib-coverage": "1.0.2" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=22" } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "magic-string": "^1.2.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1776,74 +1798,16 @@ } } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.11", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", "dev": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1987,9 +1951,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", - "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", "dev": true, "license": "MIT", "dependencies": { @@ -2054,9 +2018,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -2321,13 +2285,6 @@ "node": ">= 0.6" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -2448,6 +2405,7 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8" } @@ -2515,9 +2473,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -2640,9 +2598,9 @@ } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2730,9 +2688,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -2762,9 +2720,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.11.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz", - "integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==", + "version": "5.11.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz", + "integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==", "funding": [ { "type": "github", @@ -2773,7 +2731,7 @@ ], "license": "MIT", "dependencies": { - "@nodable/entities": "^3.0.0", + "@nodable/entities": "^3.0.1", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", @@ -3094,13 +3052,6 @@ "node": ">=16.9.0" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -3138,9 +3089,9 @@ } }, "node_modules/ignore": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", - "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "license": "MIT", "engines": { "node": ">= 4" @@ -3159,9 +3110,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", "license": "MIT", "engines": { "node": ">= 12" @@ -3212,45 +3163,6 @@ "node": ">=20" } }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jose": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", @@ -3355,6 +3267,7 @@ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -3392,6 +3305,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3413,6 +3327,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3434,6 +3349,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3455,6 +3371,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3476,6 +3393,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3497,6 +3415,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3518,6 +3437,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3539,6 +3459,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3560,6 +3481,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3581,6 +3503,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3602,6 +3525,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3626,24 +3550,24 @@ } }, "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, @@ -3668,13 +3592,13 @@ } }, "node_modules/magicast/node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -3684,9 +3608,9 @@ } }, "node_modules/magicast/node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -3697,22 +3621,6 @@ "node": ">=6.9.0" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3829,9 +3737,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ { @@ -3840,6 +3748,7 @@ } ], "license": "MIT", + "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -3910,15 +3819,18 @@ "license": "MIT" }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/on-exit-leak-free": { "version": "2.1.2", @@ -4023,24 +3935,18 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -4133,9 +4039,9 @@ } }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -4152,8 +4058,9 @@ } ], "license": "MIT", + "peer": true, "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4306,13 +4213,14 @@ } }, "node_modules/rolldown": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", - "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@oxc-project/types": "=0.144.0", + "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -4322,20 +4230,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.4", - "@rolldown/binding-darwin-arm64": "1.2.4", - "@rolldown/binding-darwin-x64": "1.2.4", - "@rolldown/binding-freebsd-x64": "1.2.4", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", - "@rolldown/binding-linux-arm64-gnu": "1.2.4", - "@rolldown/binding-linux-arm64-musl": "1.2.4", - "@rolldown/binding-linux-ppc64-gnu": "1.2.4", - "@rolldown/binding-linux-s390x-gnu": "1.2.4", - "@rolldown/binding-linux-x64-gnu": "1.2.4", - "@rolldown/binding-linux-x64-musl": "1.2.4", - "@rolldown/binding-openharmony-arm64": "1.2.4", - "@rolldown/binding-win32-arm64-msvc": "1.2.4", - "@rolldown/binding-win32-x64-msvc": "1.2.4" + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, "node_modules/router": { @@ -4615,9 +4524,9 @@ } }, "node_modules/std-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -4733,16 +4642,19 @@ } }, "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, "license": "MIT", "engines": { @@ -4767,9 +4679,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -5141,16 +5053,17 @@ } }, "node_modules/vite": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", - "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.25", - "rolldown": "~1.2.1", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", "tinyglobby": "^0.2.17" }, "bin": { @@ -5167,7 +5080,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -5219,38 +5132,31 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -5258,16 +5164,16 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { diff --git a/gitnexus/package.json b/gitnexus/package.json index 5a2412b58..e398a578b 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -52,12 +52,14 @@ "serve": "tsx src/cli/index.ts serve", "dev": "tsx watch src/cli/index.ts", "test": "vitest run", + "typecheck:tests": "tsc --noEmit -p tsconfig.test.json", "test:unit": "vitest run test/unit", "pretest:integration": "node scripts/build.js", "test:integration": "vitest run test/integration", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:cross-platform": "tsx scripts/run-cross-platform.ts", + "test:benchmarks": "node --import tsx scripts/run-benchmarks.ts", "postinstall": "node scripts/build-tree-sitter-grammars.cjs", "assert-publish-coverage": "node scripts/assert-publish-grammar-coverage.cjs", "prepare": "node scripts/build.js", @@ -66,7 +68,7 @@ }, "dependencies": { "@eagleoutice/tree-sitter-r": "^1.1.2", - "@ladybugdb/core": "0.18.3", + "@ladybugdb/core": "0.21.1", "@modelcontextprotocol/sdk": "^1.0.0", "@scarf/scarf": "^1.4.0", "busboy": "^1.6.0", @@ -127,11 +129,11 @@ "@types/express": "^5.0.6", "@types/node": "^26.0.0", "@types/proxy-addr": "^2.0.3", - "@vitest/coverage-v8": "^4.0.18", + "@vitest/coverage-v8": "^5.0.0", "gitnexus-shared": "file:../gitnexus-shared", "tsx": "^4.0.0", "typescript": "^7.0.2", - "vitest": "^4.0.18" + "vitest": "^5.0.0" }, "overrides": { "adm-zip": ">=0.6.0", diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 037109640..74fb9623c 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -167,6 +167,7 @@ const LBUG_NATIVE = [ 'test/integration/lbug-close-handle-release.test.ts', 'test/integration/lbug-orphan-sidecar-recovery.test.ts', 'test/integration/lbug-interrupted-checkpoint-recovery.test.ts', + 'test/integration/lbug-incremental-search.test.ts', 'test/integration/lbug-readonly-init.test.ts', 'test/integration/lbug-non-ascii-path.test.ts', // Cross-repo trace e2e: builds two real lbug indexes + a real bridge and @@ -205,9 +206,8 @@ const LBUG_NATIVE = [ // (the same reason fts-extension-e2e.test.ts is registered below), so the // FTS-unavailable branch has to run on a real Windows/macOS runner rather // than only on Ubuntu where FTS always loads. And its both-blocked case is - // gated on GITNEXUS_REQUIRE_VECTOR=1, which ci-tests.yml sets ONLY on this - // job — everywhere else an unavailable VECTOR extension skips instead of - // failing. Budget: four real analyze runs, so expect it to sit alongside the + // gated on GITNEXUS_REQUIRE_VECTOR=1, which ci-tests.yml requires in both + // coverage and platform jobs so an unavailable VECTOR fails loudly. Budget: four real analyze runs, so expect it to sit alongside the // VECTOR sibling's ~87s Windows measurement. 'test/unit/incremental-index-extension-dml-gate.test.ts', ]; @@ -289,6 +289,7 @@ const SPAWN_CLI = [ // Worker threads tests — exercise real worker_threads which have // platform-specific behavior (thread spawning, IPC, exit handling) const WORKER_THREADS = [ + 'test/unit/community-processor.test.ts', 'test/integration/worker-pool.test.ts', 'test/integration/parse-impl-quarantine-cache-skip.test.ts', ]; @@ -307,6 +308,10 @@ const NATIVE_ADDON_SMOKE = [ // Filesystem behavior tests — exercise operations that vary across // platforms (CRLF, symlinks, permissions, temp dirs) const FILESYSTEM = [ + // The deletion-guard cases in this file require real Windows path semantics. + 'test/unit/canonicalize-path-long-path-prefix.test.ts', + 'test/unit/storage-resolver.test.ts', + 'test/unit/dart-package-imports.test.ts', // Cargo membership uses path normalization, descriptor validation, symlinks, // and Rust native parsing (including long Windows source strings). 'test/unit/scope-resolution/rust-cargo-targets.test.ts', diff --git a/gitnexus/scripts/execution-reporter.ts b/gitnexus/scripts/execution-reporter.ts new file mode 100644 index 000000000..bfb048c96 --- /dev/null +++ b/gitnexus/scripts/execution-reporter.ts @@ -0,0 +1,38 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { JsonReporter, type TestRunEndReason } from 'vitest/node'; + +/** Vitest's stock JSON omits unhandled errors, even when success is true. */ +export default class ExecutionReporter extends JsonReporter { + constructor() { + super({}); + } + + override async onTestRunEnd( + modules: Parameters[0], + errors: readonly unknown[] = [], + reason: TestRunEndReason = 'interrupted', + ): Promise { + const configuredOutput = this.ctx.config.outputFile; + const outputFile = resolve( + this.ctx.config.root, + (typeof configuredOutput === 'string' ? configuredOutput : configuredOutput?.json) ?? + '.vitest/json/output.json', + ); + // Vitest 5 writes JSON directly instead of calling writeReport. Use an + // explicit path so this reporter also works for the web suite on Vitest 4. + this.options.outputFile = outputFile; + await super.onTestRunEnd(modules); + const report = JSON.parse(await readFile(outputFile, 'utf8')); + await writeFile( + outputFile, + JSON.stringify({ + ...report, + success: report.success && errors.length === 0 && reason === 'passed', + executionErrors: errors.length, + executionReason: reason, + }), + 'utf8', + ); + } +} diff --git a/gitnexus/scripts/run-benchmarks.ts b/gitnexus/scripts/run-benchmarks.ts new file mode 100644 index 000000000..e2a58d5a3 --- /dev/null +++ b/gitnexus/scripts/run-benchmarks.ts @@ -0,0 +1,32 @@ +import { execFileSync } from 'node:child_process'; +import { globSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +// Discover the opt-in suites from source so a new benchmark cannot silently +// miss a hand-maintained workflow list. Keep wall-clock measurements serial. +const files = globSync('test/**/*.test.ts', { cwd: root }) + .filter((file) => + /process\.env(?:\.GITNEXUS_BENCH|\[['"]GITNEXUS_BENCH['"]\])/.test( + readFileSync(new URL(`../${file.replaceAll('\\', '/')}`, import.meta.url), 'utf8'), + ), + ) + .sort(); +if (!files.length) throw new Error('No benchmark test suites discovered'); +execFileSync( + process.execPath, + [ + fileURLToPath(new URL('../node_modules/vitest/vitest.mjs', import.meta.url)), + 'run', + '--no-file-parallelism', + ...files, + '--reporter=default', + '--reporter=./scripts/execution-reporter.ts', + '--outputFile=benchmarks.json', + ], + { + cwd: root, + stdio: 'inherit', + env: { ...process.env, GITNEXUS_BENCH: '1' }, + }, +); diff --git a/gitnexus/scripts/run-cross-platform.ts b/gitnexus/scripts/run-cross-platform.ts index 5404a87c3..630fd0b93 100644 --- a/gitnexus/scripts/run-cross-platform.ts +++ b/gitnexus/scripts/run-cross-platform.ts @@ -80,8 +80,19 @@ console.log( ); const startedAt = Date.now(); +const reportName = process.env.GITNEXUS_TEST_REPORT; +if (reportName && !/^[a-zA-Z0-9_-]+\.json$/.test(reportName)) { + throw new Error('GITNEXUS_TEST_REPORT must be a JSON basename'); +} +const reportArgs = reportName + ? [ + '--reporter=default', + '--reporter=./scripts/execution-reporter.ts', + `--outputFile=${reportName}`, + ] + : []; try { - execFileSync('npx', ['vitest', 'run', ...files], { + execFileSync('npx', ['vitest', 'run', ...files, ...reportArgs], { cwd: ROOT, stdio: 'inherit', timeout: timeoutMs, diff --git a/gitnexus/scripts/test-completeness.ts b/gitnexus/scripts/test-completeness.ts new file mode 100644 index 000000000..89ca86892 --- /dev/null +++ b/gitnexus/scripts/test-completeness.ts @@ -0,0 +1,203 @@ +import { globSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { TestRunEndReason } from 'vitest/node'; + +type Assertion = { + ancestorTitles: string[]; + title: string; + fullName: string; + status: string; + location?: { line: number; column: number }; +}; +type Suite = { + name: string; + status: string; + assertionResults: Assertion[]; + endTime?: number; + message?: string; +}; +type Report = { + success?: boolean; + numTotalTests?: number; + startTime?: number; + executionErrors: number; + executionReason: TestRunEndReason; + testResults: Suite[]; +}; + +/** Stable across GitHub's Linux, macOS and Windows checkout roots. */ +function testPath(name: string, packageName: string): string { + const normalized = name.replaceAll('\\', '/'); + const relative = + normalized.match(new RegExp(`(?:^|/)${packageName}/((?:test|src)/.+)$`))?.[1] ?? normalized; + if (!/^(?:test|src)\/.+\.test\.[jt]sx?$/.test(relative)) { + throw new Error(`Invalid test report path: ${name}`); + } + return relative; +} + +/** A skip is resolved only by a recorded pass of that same test in another job. */ +export function reconcileTestReports( + inputs: unknown[], + expectedFiles: string[] = [], + packageName = 'gitnexus', +) { + if (inputs.length === 0) throw new Error('No execution reports supplied'); + const tests = new Map< + string, + { file: string; assertion: Assertion; passed: boolean; failed: boolean } + >(); + const failures = new Set(); + const failedSuites = new Set(); + const startTimes: number[] = []; + const endTimes: number[] = []; + for (const [index, input] of inputs.entries()) { + const report = input as Report; + if (!report || !Array.isArray(report.testResults) || report.testResults.length === 0) { + throw new Error(`Missing or empty execution report ${index}`); + } + if (report.success === false) failures.add(`Report ${index}: unsuccessful runner result`); + if ( + !Number.isInteger(report.executionErrors) || + report.executionErrors < 0 || + !['passed', 'failed', 'interrupted'].includes(report.executionReason) + ) { + throw new Error(`Report ${index}: missing execution reporter evidence`); + } + if (report.executionErrors || report.executionReason !== 'passed') { + failures.add( + `Report ${index}: ${report.executionErrors} unhandled errors; ${report.executionReason}`, + ); + } + if (typeof report.startTime === 'number') startTimes.push(report.startTime); + let count = 0; + const seen = new Set(); + for (const suite of report.testResults) { + const file = testPath(suite.name, packageName); + if (!Array.isArray(suite.assertionResults) || suite.assertionResults.length === 0) { + throw new Error(`No collected tests in ${file}`); + } + if (suite.status === 'failed') { + failures.add(`${file}: failed suite`); + failedSuites.add(file); + } + if (typeof suite.endTime === 'number') endTimes.push(suite.endTime); + for (const assertion of suite.assertionResults) { + if ( + !['passed', 'failed', 'pending', 'skipped', 'todo', 'disabled'].includes( + assertion.status, + ) || + !Array.isArray(assertion.ancestorTitles) || + typeof assertion.title !== 'string' || + (assertion.location !== undefined && + (!Number.isInteger(assertion.location.line) || + !Number.isInteger(assertion.location.column))) + ) { + throw new Error(`Malformed assertion in ${file}`); + } + // Collection order differs across platforms. Source location, not an + // ordinal, distinguishes equal titles declared at different locations. + // Helper-generated tests may have no location; their complete title + // must then be unique within the file (the duplicate check still applies). + const title = JSON.stringify([...assertion.ancestorTitles, assertion.title]); + const key = JSON.stringify([ + file, + title, + assertion.location?.line ?? null, + assertion.location?.column ?? null, + ]); + if (seen.has(key)) + throw new Error( + `Ambiguous test identity in ${file}: ${title}; give parameterized cases unique titles`, + ); + seen.add(key); + const result = tests.get(key) ?? { file, assertion, passed: false, failed: false }; + result.passed ||= assertion.status === 'passed'; + result.failed ||= assertion.status === 'failed'; + tests.set(key, result); + if (result.failed) failures.add(`${file}: ${assertion.fullName ?? title}`); + count++; + } + } + if (typeof report.numTotalTests === 'number' && report.numTotalTests !== count) { + throw new Error(`Report ${index}: expected ${report.numTotalTests} tests, found ${count}`); + } + } + const unverified = [...tests.values()] + .filter((test) => !test.passed && !test.failed) + .map((test) => `${test.file}: ${test.assertion.fullName}`); + const suites = new Map(); + for (const test of tests.values()) { + const status = test.failed ? 'failed' : test.passed ? 'passed' : 'pending'; + const suite = suites.get(test.file) ?? { + name: test.file, + status: 'passed', + assertionResults: [], + }; + suite.assertionResults.push({ ...test.assertion, status }); + if (status === 'failed' || failedSuites.has(test.file)) suite.status = 'failed'; + suites.set(test.file, suite); + } + for (const file of expectedFiles) { + if (!suites.has(file.replaceAll('\\', '/'))) + throw new Error(`Test file was never collected: ${file}`); + } + const passed = [...tests.values()].filter((test) => test.passed && !test.failed).length; + const endTime = endTimes.length ? Math.max(...endTimes) : 0; + return { + total: tests.size, + passed, + unverified, + failures: [...failures], + report: { + success: failures.size === 0 && unverified.length === 0, + numTotalTests: tests.size, + numPassedTests: passed, + numFailedTests: [...tests.values()].filter((test) => test.failed).length, + numPendingTests: unverified.length, + numTotalTestSuites: suites.size, + numFailedTestSuites: failedSuites.size, + executionFailures: [...failures], + startTime: startTimes.length ? Math.min(...startTimes) : 0, + testResults: [...suites.values()].map((suite) => ({ ...suite, endTime })), + }, + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const [directory, output, shardsText] = process.argv.slice(2); + const shards = Number(shardsText); + if (!directory || !output || !Number.isInteger(shards) || shards < 1) { + throw new Error('Usage: test-completeness.ts '); + } + const names = ['coverage.json', 'benchmarks.json', 'preflight.json']; + for (const os of ['windows-latest', 'macos-latest']) { + for (let shard = 1; shard <= shards; shard++) names.push(`${os}-${shard}.json`); + } + // Exact expected receipts: an absent/cancelled job is never an empty success. + const root = fileURLToPath(new URL('../', import.meta.url)); + const expected = globSync('test/**/*.test.ts', { cwd: root }); + const result = reconcileTestReports( + names.map((name) => JSON.parse(readFileSync(path.join(directory, name), 'utf8'))), + expected, + ); + writeFileSync(output, JSON.stringify(result.report)); + console.log( + `Test execution: ${result.passed}/${result.total} passed; ${result.unverified.length} unverified; ${result.failures.length} failures`, + ); + for (const message of [...result.unverified, ...result.failures]) console.error(message); + if (!result.report.success) process.exitCode = 1; + const webPath = path.join(root, '../gitnexus-web/web-test-results.json'); + const web = reconcileTestReports( + [JSON.parse(readFileSync(webPath, 'utf8'))], + globSync('{test,src}/**/*.test.{ts,tsx}', { cwd: path.join(root, '../gitnexus-web') }), + 'gitnexus-web', + ); + writeFileSync(webPath, JSON.stringify(web.report)); + console.log( + `Web execution: ${web.passed}/${web.total} passed; ${web.unverified.length} unverified; ${web.failures.length} failures`, + ); + for (const message of [...web.unverified, ...web.failures]) console.error(message); + if (!web.report.success) process.exitCode = 1; +} diff --git a/gitnexus/src/cli/detect-changes-format.ts b/gitnexus/src/cli/detect-changes-format.ts index 9e91cc330..9eece8e43 100644 --- a/gitnexus/src/cli/detect-changes-format.ts +++ b/gitnexus/src/cli/detect-changes-format.ts @@ -1,5 +1,6 @@ import { t } from './i18n/index.js'; import { formatSymbolLine } from './format-symbol.js'; +import { formatPathForTerminal } from './format-path.js'; type DetectChangesSummary = { changed_files?: number; @@ -32,6 +33,7 @@ type DetectChangesResult = { summary?: DetectChangesSummary; changed_symbols?: ChangedSymbol[]; affected_processes?: AffectedProcess[]; + unmapped_files?: string[]; }; export function formatDetectChangesResult(result: unknown): string { @@ -47,6 +49,13 @@ export function formatDetectChangesResult(result: unknown): string { // Both lead the output — a caveat printed after the summary is read too late. const notes: string[] = []; if (payload.partial) notes.push(t('tool.detectChanges.partial')); + if (payload.unmapped_files?.length) { + notes.push( + t('tool.detectChanges.unmappedSource', { + files: payload.unmapped_files.map(formatPathForTerminal).join(', '), + }), + ); + } // The plain truncation note reassures that the counts are whole. That is only // true when the run did NOT also degrade — `changed_count` sums the batches // that succeeded — so the two flags together get a different sentence. diff --git a/gitnexus/src/cli/editor-targets.ts b/gitnexus/src/cli/editor-targets.ts index 212a4c400..f3267aa4b 100644 --- a/gitnexus/src/cli/editor-targets.ts +++ b/gitnexus/src/cli/editor-targets.ts @@ -56,7 +56,7 @@ export interface McpJsoncTarget { export interface CodexMcpTarget { id: 'codex'; label: string; - /** Absolute path to ~/.codex/config.toml. */ + /** Absolute path to CODEX_HOME/config.toml (default ~/.codex/config.toml). */ configFile: string; /** The TOML table header (without brackets) setup writes / uninstall strips. */ tomlSection: string; @@ -100,6 +100,9 @@ export interface EditorTargets { * `process.env.HOME` before invoking sees the right locations. */ export function getEditorTargets(home: string = os.homedir()): EditorTargets { + const codexHome = process.env.CODEX_HOME + ? path.resolve(process.env.CODEX_HOME) + : path.join(home, '.codex'); const mcpJsonc: McpJsoncTarget[] = [ { id: 'cursor', @@ -166,7 +169,7 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets { const codex: CodexMcpTarget = { id: 'codex', label: 'Codex', - configFile: path.join(home, '.codex', 'config.toml'), + configFile: path.join(codexHome, 'config.toml'), tomlSection: 'mcp_servers.gitnexus', }; @@ -205,10 +208,10 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets { // Codex hooks use Claude Code's exact {hooks: {Event: [...]}} JSON shape // and hookSpecificOutput response contract, in a dedicated hooks.json // (https://developers.openai.com/codex/hooks). - settingsFile: path.join(home, '.codex', 'hooks.json'), + settingsFile: path.join(codexHome, 'hooks.json'), events: ['PreToolUse', 'PostToolUse'], needle: 'gitnexus-hook', - scriptDir: path.join(home, '.codex', 'hooks', 'gitnexus'), + scriptDir: path.join(codexHome, 'hooks', 'gitnexus'), }, { id: 'antigravity', diff --git a/gitnexus/src/cli/embeddings-sync.ts b/gitnexus/src/cli/embeddings-sync.ts index 9848ef7c4..59cf9270a 100644 --- a/gitnexus/src/cli/embeddings-sync.ts +++ b/gitnexus/src/cli/embeddings-sync.ts @@ -4,7 +4,11 @@ import { LBUG_DIRECTORY } from '../storage/storage-constants.js'; import path from 'node:path'; import { cliInfo } from './cli-message.js'; import { getGitRoot } from '../storage/git.js'; -import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js'; +import { + acquireIndexLock, + requireExclusiveIndexLock, + sweepStagingArtifacts, +} from '../storage/index-lock.js'; import { getStoragePaths, loadMeta, saveMeta } from '../storage/repo-manager.js'; import { closeLbug, @@ -50,12 +54,22 @@ export const embeddingsSyncCommand = async (inputPath?: string): Promise = // Writes go to the slot's own graph. A shared-store checkout that reads an // immutable commit graph (#3352) takes a private copy first. const lbugPath = path.join(metaDir, LBUG_DIRECTORY); - const lock = await acquireIndexLock(metaDir); + const lock = await acquireIndexLock(metaDir, { sweep: false }); try { requireExclusiveIndexLock( lock, `Cannot acquire the index lock at ${metaDir}; refusing an unlocked embeddings sync.`, ); + // Sync writes the published graph and cannot recover a staged generation. + // Reject even malformed receipts before sweeping staging files or writing. + const recoveryCheckpoint = (await loadMeta(metaDir))?.embeddingCheckpoint; + if (recoveryCheckpoint && Object.hasOwn(recoveryCheckpoint, 'recovery')) { + throw new Error( + 'Cannot sync embeddings: the index checkpoint references staged embeddings. ' + + 'Run `gitnexus analyze` to recover them first.', + ); + } + sweepStagingArtifacts(metaDir); if (!(await ensurePrivateSharedGraph(metaDir, (m) => console.log(` ${m}`)))) { throw new Error('The shared graph this checkout reads is gone. Run gitnexus analyze first.'); } diff --git a/gitnexus/src/cli/format-path.ts b/gitnexus/src/cli/format-path.ts new file mode 100644 index 000000000..e7eab1f47 --- /dev/null +++ b/gitnexus/src/cli/format-path.ts @@ -0,0 +1,8 @@ +/** Quote control-bearing paths for terminal output; structured results retain raw paths. */ +export const formatPathForTerminal = (filePath: string): string => + /[\u0000-\u001f\u007f-\u009f]/.test(filePath) + ? JSON.stringify(filePath).replace( + /[\u007f-\u009f]/g, + (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`, + ) + : filePath; diff --git a/gitnexus/src/cli/group-status-format.ts b/gitnexus/src/cli/group-status-format.ts index f5d09c47e..bd94ccc9a 100644 --- a/gitnexus/src/cli/group-status-format.ts +++ b/gitnexus/src/cli/group-status-format.ts @@ -4,16 +4,18 @@ * against a real group, which is how `STALE (-1 commits behind)` went unnoticed * (#3256). */ +import type { StalenessStatus } from '../core/staleness-status.js'; /** The fields of a `groupStatus` repo row the index column reads. */ export interface GroupRepoIndexRow { indexStale: boolean; commitsBehind?: number; + status?: StalenessStatus; } /** - * The index column of a `group status` row. The output is unchanged except - * for one case: a count that is not a real count renders as `?`. + * The index column of a `group status` row. Diverged indexes differ from HEAD + * without a forward commit count; an unavailable count renders as `?`. * * `group/service.ts` has always reported a repo with no recorded commit as * `{ indexStale: true, commitsBehind: -1 }`. The previous `?? '?'` fallback @@ -21,6 +23,7 @@ export interface GroupRepoIndexRow { */ export const formatIndexStatusCell = (row: GroupRepoIndexRow): string => { if (!row.indexStale) return 'OK '; + if (row.status === 'diverged') return 'STALE (index differs from HEAD)'; const n = row.commitsBehind; const count = typeof n === 'number' && n >= 0 ? String(n) : '?'; return `STALE (${count} commits behind)`; diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 981071f64..8cedc843f 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -137,7 +137,9 @@ export const en = { 'tool.detectChanges.noOverlappingSymbols': 'Diff touched {{files}} file(s) but no indexed symbols overlap those hunks — not a clean tree.', 'tool.detectChanges.partial': - 'PARTIAL RESULT: a graph query failed, so changed symbols may be missing. Do not read this as a clean pre-commit check.', + 'PARTIAL RESULT: changed-symbol or process mapping is incomplete. Do not read this as a clean pre-commit check.', + 'tool.detectChanges.unmappedSource': + 'No symbols mapped for changed source files: {{files}}. Rebuild the index and inspect the diff; retry alone may not resolve missing or out-of-range symbols.', 'tool.detectChanges.truncated': 'LISTING CAPPED: the changed-symbol list was capped, so it does not name every changed symbol. The counts and risk level still cover all of them.', // The reassurance above is only true on its own. When the run also degraded, @@ -428,5 +430,5 @@ export const en = { 'help.identityCache.environment': '\nAnalyzer identity cache:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n Operator-trusted persistent cache for warm cross-process status. The directory must pre-exist, be outside the GitNexus package/build roots, and contain no symlink or junction components. Defaults remain fail-closed on platforms without POSIX ownership APIs.', 'help.analyze.environment': - '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_STORAGE_PATH=/absolute/index Complete external index directory. Preserves the existing configuration semantics and overrides GITNEXUS_STORAGE_ROOT when both are set.\n GITNEXUS_STORAGE_ROOT=/absolute/root External index root; each repository uses an isolated -/ slot.\n GITNEXUS_CONTENT_RETENTION=full Source-text retention profile: full, symbol, or none. Default full.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n GITNEXUS_MAX_PROCESSES=N Process-detection process cap (positive integer). Replaces the dynamic max(20, round(symbols/10)) formula. Distinct from query-time IMPACT_MAX_CHUNKS.\n GITNEXUS_MAX_PROCESS_BRANCHING=N Process-detection per-node branching cap. Default 4.\n GITNEXUS_MAX_PROCESS_TRACE_DEPTH=N Process-detection DFS depth cap. Default 10.\n GITNEXUS_MAX_ENTRY_POINT_CANDIDATES=N Ranked entry-point candidate pool. Default 200. Raise when the warning names this knob; doubling is the usual first raise.\n\nCLI flags take precedence over `.gitnexusrc`, which takes precedence over env vars, which take precedence over built-in defaults.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).', + '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_STORAGE_PATH=/absolute/index Complete external index directory. Preserves the existing configuration semantics and overrides GITNEXUS_STORAGE_ROOT when both are set.\n GITNEXUS_STORAGE_ROOT=/absolute/root External index root; each repository uses an isolated -/ slot.\n GITNEXUS_CONTENT_RETENTION=full Source-text retention profile: full, symbol, or none. Default full.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for CLI/MCP query, 0.5 for standalone semantic search. Tune for your embedding model: higher values can improve recall but may reduce relevance. Set in the query/server environment; no reindex needed.\n GITNEXUS_MAX_PROCESSES=N Process-detection process cap (positive integer). Replaces the dynamic max(20, round(symbols/10)) formula. Distinct from query-time IMPACT_MAX_CHUNKS.\n GITNEXUS_MAX_PROCESS_BRANCHING=N Process-detection per-node branching cap. Default 4.\n GITNEXUS_MAX_PROCESS_TRACE_DEPTH=N Process-detection DFS depth cap. Default 10.\n GITNEXUS_MAX_ENTRY_POINT_CANDIDATES=N Ranked entry-point candidate pool. Default 200. Raise when the warning names this knob; doubling is the usual first raise.\n\nCLI flags take precedence over `.gitnexusrc`, which takes precedence over env vars, which take precedence over built-in defaults.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).', } as const; diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 606a9e4e6..f333188b7 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -128,7 +128,9 @@ export const zhCN = { 'tool.detectChanges.noOverlappingSymbols': 'diff 触及 {{files}} 个文件,但没有索引符号与这些 hunk 重叠 — 并非干净工作区。', 'tool.detectChanges.partial': - '结果不完整:图查询失败,可能遗漏已变更符号。请勿将其视为通过的提交前检查。', + '结果不完整:变更符号或流程映射不完整。请勿将其视为通过的提交前检查。', + 'tool.detectChanges.unmappedSource': + '变更源码文件未映射到符号:{{files}}。请重建索引并检查 diff;仅重试可能无法解决符号缺失或源码范围不匹配。', 'tool.detectChanges.truncated': '列表已截断:已变更符号列表被截断,未列出全部变更符号。计数与风险等级仍涵盖全部符号。', 'tool.detectChanges.truncatedDegraded': @@ -390,5 +392,5 @@ export const zhCN = { 'help.identityCache.environment': '\n分析器身份缓存:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n 由操作员明确信任的持久缓存,用于跨进程快速查询状态。目录必须预先存在、位于 GitNexus 包/构建根目录之外,且路径中不得包含符号链接或 junction。缺少 POSIX 所有权 API 的平台默认保持故障关闭。', 'help.analyze.environment': - '\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_STORAGE_PATH=/absolute/index 完整外部索引目录。保留既有配置语义;与 GITNEXUS_STORAGE_ROOT 同时设置时优先使用。\n GITNEXUS_STORAGE_ROOT=/absolute/root 外部索引根目录;每个仓库使用独立的 <仓库名>-<规范路径哈希>/ 子目录。\n GITNEXUS_CONTENT_RETENTION=full 源码文本保留策略:full、symbol 或 none。默认 full。\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir 由操作员明确信任的持久分析器身份缓存;目录必须预先存在、位于包/构建根目录之外,且路径中不得包含符号链接或 junction。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N 线程池关闭时等待仍在原生代码中的已退役 worker 的最长时间(到达安全点后再终止,避免进程级 abort)。默认 30000。\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N C++ 捕获提取的每文件墙钟预算;超出后该文件保留部分捕获并输出警告。默认 20000。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 将单次 HTTP 嵌入超时纳入 GITNEXUS_EMBEDDING_MAX_ATTEMPTS 重试(默认关闭,超时仍为终止错误)。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。MCP 默认 0.6,其他路径默认 0.5。\n GITNEXUS_MAX_PROCESSES=N 流程检测的流程数量上限(正整数)。覆盖动态的 max(20, round(symbols/10)) 公式。与查询时的 IMPACT_MAX_CHUNKS 无关。\n GITNEXUS_MAX_PROCESS_BRANCHING=N 流程检测的单节点分支上限。默认 4。\n GITNEXUS_MAX_PROCESS_TRACE_DEPTH=N 流程检测的 DFS 深度上限。默认 10。\n GITNEXUS_MAX_ENTRY_POINT_CANDIDATES=N 排序后的入口点候选池。默认 200。仅在警告点名该上限时提高;那时通常先翻倍。\n\nCLI 参数优先于 `.gitnexusrc`,后者优先于环境变量,环境变量优先于内置默认值。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。', + '\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_STORAGE_PATH=/absolute/index 完整外部索引目录。保留既有配置语义;与 GITNEXUS_STORAGE_ROOT 同时设置时优先使用。\n GITNEXUS_STORAGE_ROOT=/absolute/root 外部索引根目录;每个仓库使用独立的 <仓库名>-<规范路径哈希>/ 子目录。\n GITNEXUS_CONTENT_RETENTION=full 源码文本保留策略:full、symbol 或 none。默认 full。\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir 由操作员明确信任的持久分析器身份缓存;目录必须预先存在、位于包/构建根目录之外,且路径中不得包含符号链接或 junction。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N 线程池关闭时等待仍在原生代码中的已退役 worker 的最长时间(到达安全点后再终止,避免进程级 abort)。默认 30000。\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N C++ 捕获提取的每文件墙钟预算;超出后该文件保留部分捕获并输出警告。默认 20000。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 将单次 HTTP 嵌入超时纳入 GITNEXUS_EMBEDDING_MAX_ATTEMPTS 重试(默认关闭,超时仍为终止错误)。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。CLI/MCP query 默认 0.6,独立语义搜索默认 0.5。应根据嵌入模型调节:提高阈值可增加召回,但可能降低相关性。在查询/服务器进程的环境中设置,无需重新索引。\n GITNEXUS_MAX_PROCESSES=N 流程检测的流程数量上限(正整数)。覆盖动态的 max(20, round(symbols/10)) 公式。与查询时的 IMPACT_MAX_CHUNKS 无关。\n GITNEXUS_MAX_PROCESS_BRANCHING=N 流程检测的单节点分支上限。默认 4。\n GITNEXUS_MAX_PROCESS_TRACE_DEPTH=N 流程检测的 DFS 深度上限。默认 10。\n GITNEXUS_MAX_ENTRY_POINT_CANDIDATES=N 排序后的入口点候选池。默认 200。仅在警告点名该上限时提高;那时通常先翻倍。\n\nCLI 参数优先于 `.gitnexusrc`,后者优先于环境变量,环境变量优先于内置默认值。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。', } satisfies EnglishMessages; diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index f03c58aa3..33de0ad6b 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -12,7 +12,16 @@ import os from 'os'; import { execFile, execFileSync } from 'child_process'; import { promisify } from 'util'; import { fileURLToPath } from 'url'; -import { parseTree, modify, applyEdits, ParseError, parse as parseJsonc } from 'jsonc-parser'; +import { + parseTree, + findNodeAtLocation, + getNodeValue, + modify, + applyEdits, + ParseError, + parse as parseJsonc, +} from 'jsonc-parser'; +import { parse as parseToml, TomlError } from 'smol-toml'; import { packageVersion } from '../core/package-version.js'; import { getGlobalDir } from '../storage/repo-manager.js'; import { @@ -214,7 +223,8 @@ async function mergeJsoncFile( filePath: string, keyPath: string[], value: unknown, -): Promise { + options?: { preserveHttpEntry?: boolean }, +): Promise { let raw: string; try { raw = await fs.readFile(filePath, 'utf-8'); @@ -240,6 +250,15 @@ async function mergeJsoncFile( const tree = parseTree(raw, parseErrors); if (tree && tree.type === 'object' && parseErrors.length === 0) { + const existingNode = options?.preserveHttpEntry ? findNodeAtLocation(tree, keyPath) : undefined; + const existingEntry = existingNode === undefined ? undefined : getNodeValue(existingNode); + if ( + existingEntry !== null && + typeof existingEntry === 'object' && + typeof (existingEntry as Record).url === 'string' + ) { + return 'preserved-http'; + } const formattingOptions = detectIndentation(raw); const edits = modify(raw, keyPath, value, { formattingOptions }); const result = applyEdits(raw, edits); @@ -306,8 +325,12 @@ async function setupCursor(result: SetupResult): Promise { const { file: mcpPath, keyPath } = mcpTarget('cursor'); try { - const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry()); - if (ok) { + const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry(), { + preserveHttpEntry: true, + }); + if (ok === 'preserved-http') { + result.configured.push('Cursor (existing HTTP MCP entry kept)'); + } else if (ok) { result.configured.push('Cursor'); } else { result.errors.push('Cursor: mcp.json is corrupt — skipping to preserve existing content'); @@ -327,8 +350,12 @@ async function setupClaudeCode(result: SetupResult): Promise { // Claude Code stores MCP config in ~/.claude.json const { file: mcpPath, keyPath } = mcpTarget('claude'); try { - const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry()); - if (ok) { + const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry(), { + preserveHttpEntry: true, + }); + if (ok === 'preserved-http') { + result.configured.push('Claude Code (existing HTTP MCP entry kept)'); + } else if (ok) { result.configured.push('Claude Code'); } else { result.errors.push( @@ -1044,13 +1071,47 @@ async function upsertCodexConfigToml(configPath: string): Promise { await fs.writeFile(configPath, `${nextContent.trimEnd()}\n`, 'utf-8'); } +async function codexHasHttpMcpEntry(configPath: string): Promise { + let raw: string; + try { + raw = await fs.readFile(configPath, 'utf-8'); + } catch (err) { + if (isEnoent(err)) return false; + throw err; + } + const config = parseToml(raw); + const servers = config.mcp_servers; + if (servers === null || typeof servers !== 'object') return false; + const entry = (servers as Record).gitnexus; + return ( + entry !== null && + typeof entry === 'object' && + typeof (entry as Record).url === 'string' + ); +} + async function setupCodex(result: SetupResult): Promise { - const codexDir = path.join(os.homedir(), '.codex'); - if (!(await dirExists(codexDir))) { + const configPath = getEditorTargets().codex.configFile; + const codexDir = path.dirname(configPath); + if (!process.env.CODEX_HOME && !(await dirExists(codexDir))) { result.skipped.push('Codex (not installed)'); return; } + try { + if (await codexHasHttpMcpEntry(configPath)) { + result.configured.push('Codex (existing HTTP MCP entry kept)'); + return; + } + } catch (err) { + result.errors.push( + err instanceof TomlError + ? `Codex: invalid config.toml (line ${err.line}, column ${err.column}); MCP entry unchanged` + : `Codex: ${err instanceof Error ? err.message : String(err)}`, + ); + return; + } + try { const entry = getMcpEntry(); await execFileAsync('codex', ['mcp', 'add', 'gitnexus', '--', entry.command, ...entry.args], { @@ -1064,9 +1125,8 @@ async function setupCodex(result: SetupResult): Promise { } try { - const configPath = getEditorTargets().codex.configFile; await upsertCodexConfigToml(configPath); - result.configured.push('Codex (MCP added to ~/.codex/config.toml)'); + result.configured.push('Codex (MCP added to active config.toml)'); } catch (err: any) { result.errors.push(`Codex: ${err.message}`); } @@ -1265,7 +1325,7 @@ async function installOpenCodeSkills(result: SetupResult): Promise { * Install global Codex skills to ~/.agents/skills/gitnexus/ */ async function installCodexSkills(result: SetupResult): Promise { - const codexDir = path.join(os.homedir(), '.codex'); + const codexDir = path.dirname(getEditorTargets().codex.configFile); if (!(await dirExists(codexDir))) return; const skillsDir = skillTarget('codex').dir; diff --git a/gitnexus/src/cli/skill-gen.ts b/gitnexus/src/cli/skill-gen.ts index f1dd45c3b..ed7f58232 100644 --- a/gitnexus/src/cli/skill-gen.ts +++ b/gitnexus/src/cli/skill-gen.ts @@ -114,21 +114,21 @@ export const generateSkillFiles = async ( } } - if (!communityResult || !communityResult.memberships.length) { + const memberships = communityResult?.rawMemberships ?? communityResult?.memberships ?? []; + if (!communityResult || !memberships.length) { console.log('\n Skills: no communities detected, skipping skill generation'); return { skills: [], outputPath: outputDir }; } console.log('\n Generating repo-specific skills...'); - // Step 1: Build communities from memberships (not the filtered communities array). - // The community processor skips singletons from its communities array but memberships - // include ALL assignments. For repos with sparse CALLS edges, the communities array - // can be empty while memberships still has useful groupings. + // Step 1: Use raw assignments for the fallback when all communities were + // filtered as singletons. Same-folder aggregation can still produce skills + // for these sparse graphs without emitting dangling MEMBER_OF edges. const communities = communityResult.communities.length > 0 ? communityResult.communities - : buildCommunitiesFromMemberships(communityResult.memberships, graph, repoPath); + : buildCommunitiesFromMemberships(memberships, graph, repoPath); const aggregated = aggregateCommunities(communities); @@ -145,11 +145,8 @@ export const generateSkillFiles = async ( } // Step 3: Build lookup maps - const membershipsByComm = buildMembershipMap(communityResult.memberships); - const nodeIdToCommunityLabel = buildNodeCommunityLabelMap( - communityResult.memberships, - communities, - ); + const membershipsByComm = buildMembershipMap(memberships); + const nodeIdToCommunityLabel = buildNodeCommunityLabelMap(memberships, communities); // Step 4: Ensure the shared project-skill root exists. Never clear it: it // also contains user-authored and standard GitNexus skills. @@ -185,7 +182,7 @@ export const generateSkillFiles = async ( const entryPoints = gatherEntryPoints(members); // Gather execution flows - const flows = gatherFlows(community.rawIds, processResult?.processes || []); + const flows = gatherFlows(community.rawIds, members, processResult?.processes || []); // Gather cross-community connections const connections = gatherCrossConnections( @@ -529,14 +526,26 @@ const gatherEntryPoints = (members: MemberSymbol[]): MemberSymbol[] => { /** * @brief Gather execution flows touching this community * @param {string[]} rawIds - Raw community IDs for this aggregated community + * @param {MemberSymbol[]} members - Member symbols, including raw singleton assignments * @param {ProcessNode[]} processes - All detected processes - * @returns {ProcessNode[]} Processes whose communities intersect rawIds, sorted by stepCount + * @returns {ProcessNode[]} Processes matching the community IDs or member symbols, sorted by stepCount */ -const gatherFlows = (rawIds: string[], processes: ProcessNode[]): ProcessNode[] => { +const gatherFlows = ( + rawIds: string[], + members: MemberSymbol[], + processes: ProcessNode[], +): ProcessNode[] => { const rawIdSet = new Set(rawIds); + const memberIds = new Set(members.map((member) => member.id)); return processes - .filter((proc) => proc.communities.some((cid) => rawIdSet.has(cid))) + .filter( + (proc) => + proc.communities.some((cid) => rawIdSet.has(cid)) || + // Filtered singleton communities are absent from process metadata, + // but their symbols still participate in detected execution traces. + proc.trace.some((nodeId) => memberIds.has(nodeId)), + ) .sort((a, b) => b.stepCount - a.stepCount); }; diff --git a/gitnexus/src/cli/status.ts b/gitnexus/src/cli/status.ts index 26dd780b4..8316fc3ae 100644 --- a/gitnexus/src/cli/status.ts +++ b/gitnexus/src/cli/status.ts @@ -48,6 +48,7 @@ import { isFullSourceAvailable, } from '../core/content-retention.js'; import { t } from './i18n/index.js'; +import { formatPathForTerminal as formatDriftPath } from './format-path.js'; /** How many drifted paths the report names before summarizing the rest. */ const DRIFT_SAMPLE_LIMIT = 10; @@ -84,9 +85,6 @@ const describeContentDrift = (drift: IndexContentDrift | undefined) => { }; }; -/** Escape control characters in repo-relative paths before printing. */ -const formatDriftPath = (rel: string): string => - /[\u0000-\u001f\u007f]/.test(rel) ? JSON.stringify(rel) : rel; const printDriftDetail = (drift: Extract): void => { console.log( t('status.indexContentDrifted', { diff --git a/gitnexus/src/config/ignore-service.ts b/gitnexus/src/config/ignore-service.ts index ac2dc7704..6af0e479a 100644 --- a/gitnexus/src/config/ignore-service.ts +++ b/gitnexus/src/config/ignore-service.ts @@ -1,5 +1,13 @@ import ignore, { type Ignore } from 'ignore'; -import { existsSync } from 'fs'; +import { + closeSync, + constants as fsConstants, + existsSync, + fstatSync, + lstatSync, + openSync, + readFileSync, +} from 'fs'; import fs from 'fs/promises'; import nodePath from 'path'; import type { Path } from 'path-scurry'; @@ -531,9 +539,163 @@ const hasExplicitUnignore = (ig: Ignore, rel: string): boolean => { return false; }; +/** + * Read a nested `.gitignore` only if it is a regular file, not a symlink. + * + * git does not follow a symlinked `.gitignore` in the working tree, and + * reading one could pull rules from outside the repository. Where the + * platform supports it, the file is opened with O_NOFOLLOW (a symlink fails + * with ELOOP). Windows has no O_NOFOLLOW, so there the path is lstat'ed after + * opening and must be the same regular file as the open descriptor. Either + * way the content is read through the descriptor that was checked, never by + * path, so the file cannot be swapped between the check and the read. + * + * The open also passes O_NONBLOCK where it exists. Opening a FIFO for reading + * blocks in open(2) until a writer appears, so a `.gitignore` that is a FIFO + * would hang the scan before the isFile() check could reject it (glob's + * ignore callback is synchronous). The flag makes that open return at once + * and changes nothing for a regular file. Same reasoning as readBoundedFile + * in src/core/ingestion/asyncapi/document.ts. + */ +const readNestedGitignore = (filePath: string): string | null => { + const noFollow = fsConstants.O_NOFOLLOW; + const nonBlock = fsConstants.O_NONBLOCK; + const fd = openSync(filePath, fsConstants.O_RDONLY | (noFollow ?? 0) | (nonBlock ?? 0)); + try { + const stat = fstatSync(fd); + if (!stat.isFile()) return null; + if (noFollow === undefined) { + const link = lstatSync(filePath); + if (!link.isFile() || link.ino !== stat.ino || link.dev !== stat.dev) return null; + } + return readFileSync(fd, 'utf-8'); + } finally { + closeSync(fd); + } +}; + +/** + * Resolve `.gitignore` files below the repository root (#2675). + * + * `loadIgnoreRules` only reads the root `.gitignore`, so a monorepo package + * or checked-out submodule with its own `.gitignore` had its generated + * output indexed anyway. Each nested file is read lazily (glob's filter is + * synchronous) and cached per directory, and its patterns are matched + * against the path relative to that directory, like git does. + * + * Returns the effective decision when nested rules affect the path or an + * ancestor, and `undefined` otherwise. Root rules participate so a directory + * negation does not erase independent root exclusions for its children. + * The caller still gives `.gitnexusignore` its higher precedence. + */ +const createNestedGitignoreMatcher = ( + repoPath: string, + rootRules: Ignore | null, +): ((rel: string, isDirectory: boolean) => boolean | undefined) => { + const rulesFor = (dirRel: string): Ignore | null => { + let rules: Ignore | null = null; + const filePath = nodePath.join(repoPath, dirRel, '.gitignore'); + try { + const content = readNestedGitignore(filePath); + if (content !== null) rules = ignore().add(content); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'ELOOP') { + logger.warn(` Warning: could not read ${filePath}: ${(err as Error).message}`); + } + } + return rules; + }; + + interface Scope { + base: string; + rules: Ignore; + } + interface DirectoryContext { + scopes: Scope[]; + ignored: boolean; + nested: boolean; + } + + const relativeTo = (base: string, rel: string): string => + base ? rel.slice(base.length + 1) : rel; + const match = (scopes: Scope[], rel: string, isDirectory: boolean) => { + for (let i = scopes.length - 1; i >= 0; i--) { + const { base, rules } = scopes[i]; + const sub = relativeTo(base, rel); + const result = rules.test(isDirectory ? `${sub}/` : sub); + if (result.ignored || result.unignored) { + return { ignored: result.ignored, nested: base !== '' }; + } + } + return undefined; + }; + + const contexts = new Map([ + [ + '', + { + scopes: rootRules ? [{ base: '', rules: rootRules }] : [], + ignored: false, + nested: false, + }, + ], + ]); + + const contextFor = (dir: string): DirectoryContext => { + const cached = contexts.get(dir); + if (cached) return cached; + const parentDir = nodePath.posix.dirname(dir); + const parent = contextFor(parentDir === '.' ? '' : parentDir); + // A .gitignore inside an excluded directory cannot bring that directory + // back. Do not read rules below a parent that traversal would prune. + if (parent.ignored) { + contexts.set(dir, parent); + return parent; + } + + const result = match(parent.scopes, dir, true); + const context: DirectoryContext = { + scopes: parent.scopes, + ignored: result?.ignored ?? false, + nested: parent.nested || (result?.nested ?? false), + }; + if (!context.ignored) { + context.scopes = parent.scopes.map(({ base, rules }) => { + const sub = relativeTo(base, dir); + if (!rules.test(`${sub}/`).ignored) return { base, rules }; + // A deeper rule let us enter this directory. Clear only its inherited + // exclusion in the shallower layer; child rules must still be tested. + // Keep patterns in their original scope, and escape this literal path. + const literal = sub.replace(/[\\*?\[\]]/g, '\\$&'); + return { + base, + rules: ignore() + .add(rules) + .add({ pattern: `!/${literal}/` }), + }; + }); + const rules = rulesFor(dir); + if (rules) context.scopes.push({ base: dir, rules }); + } + contexts.set(dir, context); + return context; + }; + + return (rel: string, isDirectory: boolean): boolean | undefined => { + const parentDir = nodePath.posix.dirname(rel); + const parent = contextFor(parentDir === '.' ? '' : parentDir); + if (parent.ignored) return parent.nested ? true : undefined; + const result = match(parent.scopes, rel, isDirectory); + if (parent.nested || result?.nested) return result?.ignored ?? false; + return undefined; + }; +}; + /** * Create a glob-compatible ignore filter combining: * - .gitignore / .gitnexusignore patterns (via `ignore` package) + * - nested .gitignore files, scoped to their own directory (#2675) * - Hardcoded DEFAULT_IGNORE_LIST, IGNORED_EXTENSIONS, IGNORED_FILES * * Returns an IgnoreLike object for glob's `ignore` option, @@ -550,6 +712,13 @@ const hasExplicitUnignore = (ig: Ignore, rel: string): boolean => { */ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptions) => { const ig = await loadIgnoreRules(repoPath, options); + const skipGitignore = options?.noGitignore ?? !!process.env.GITNEXUS_NO_GITIGNORE; + const nestedIgnores = skipGitignore ? null : createNestedGitignoreMatcher(repoPath, ig); + // A nested negation outranks the root .gitignore, as in git, but not the + // user's .gitnexusignore, so keep a matcher for that file on its own. + const nexusIgnore = nestedIgnores + ? await loadIgnoreRules(repoPath, { ...options, noGitignore: true, noGlobalIgnore: true }) + : null; return { ignored(p: Path): boolean { @@ -557,6 +726,23 @@ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptio // native separators on Windows when called through glob. const rel = p.relative().replace(/\\/g, '/'); if (!rel) return false; + // Nested .gitignore files below the root (#2675). .gitnexusignore + // comes first, then the deepest nested .gitignore, which outranks the + // root .gitignore as in git. The nested matcher preserves independent + // root exclusions; a nested negation never rescues a hardcoded default. + // With no nested opinion the original order below applies unchanged. + if (nestedIgnores) { + if (nexusIgnore) { + if (hasExplicitUnignore(nexusIgnore, rel) && !ig?.ignores(rel)) return false; + if (nexusIgnore.ignores(rel)) return true; + } + const nested = nestedIgnores(rel, false); + if (nested === true) return true; + if (nested === false) { + if (ig && hasExplicitUnignore(ig, rel) && !ig.ignores(rel)) return false; + return shouldIgnorePath(rel); + } + } // User's .gitnexusignore negation takes precedence over hardcoded // rules (#771). If any ancestor or the path itself was explicitly // unignored AND no more-specific rule re-ignores this exact path, @@ -576,6 +762,22 @@ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptio // list check below is defense-in-depth — do not remove `dot: false` // assuming this covers it. const rel = p.relative().replace(/\\/g, '/'); + // Nested .gitignore files below the root (#2675), same precedence as in + // `ignored` above. + if (nestedIgnores && rel) { + if (nexusIgnore) { + if (hasExplicitUnignore(nexusIgnore, rel) && !ig?.ignores(rel + '/')) { + return false; + } + if (nexusIgnore.ignores(rel + '/')) return true; + } + const nested = nestedIgnores(rel, true); + if (nested === true) return true; + if (nested === false) { + if (ig && hasExplicitUnignore(ig, rel) && !ig.ignores(rel + '/')) return false; + return isHardcodedIgnoredDirectoryAtPath(repoPath, nodePath.join(repoPath, rel)); + } + } // User's .gitnexusignore negation takes precedence (#771) — if the // user explicitly unignored this directory or any ancestor via a // !pattern rule, allow descent even if the directory name is in diff --git a/gitnexus/src/core/augmentation/engine.ts b/gitnexus/src/core/augmentation/engine.ts index f72223b5c..ef6fe0c8c 100644 --- a/gitnexus/src/core/augmentation/engine.ts +++ b/gitnexus/src/core/augmentation/engine.ts @@ -129,7 +129,7 @@ export async function augment(pattern: string, cwd?: string): Promise { } // Step 1: BM25 search (fast, no embeddings) - const { results: bm25Results, ftsAvailable } = await searchFTSFromLbug(pattern, 10, repoId); + const { results: bm25Results } = await searchFTSFromLbug(pattern, 10, repoId); // Step 2: Map BM25 file results to symbols const symbolMatches: Array<{ @@ -167,16 +167,18 @@ export async function augment(pattern: string, cwd?: string): Promise { } } - // When FTS indexes are unavailable (read-only DB, first run before indexes are built), - // fall back to a direct name CONTAINS query so enrichment still works. - if (symbolMatches.length === 0 && !ftsAvailable) { + // FTS ranks files by mentions, so a widely referenced symbol's definition + // may not be present in the top file results. Fall back to graph names + // whenever those files produce no symbol match, regardless of FTS health. + if (symbolMatches.length === 0) { const fallbackRows = await executeQuery( repoId, ` MATCH (n) WHERE n.name CONTAINS '${patternFirstWord}' - RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath - ORDER BY id + RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, + CASE WHEN n.name = '${patternFirstWord}' THEN 0 ELSE 1 END AS exactRank + ORDER BY exactRank, id LIMIT 5 `, ).catch(() => []); diff --git a/gitnexus/src/core/embeddings/staged-embedding-recovery-child.ts b/gitnexus/src/core/embeddings/staged-embedding-recovery-child.ts new file mode 100644 index 000000000..b1fb2cb73 --- /dev/null +++ b/gitnexus/src/core/embeddings/staged-embedding-recovery-child.ts @@ -0,0 +1,181 @@ +/** Isolated strict native reader. Never import this entrypoint into analyze. */ +import fs from 'node:fs'; +import path from 'node:path'; +import lbug from '@ladybugdb/core'; +import { createLbugDatabase, toNativeSafePath } from '../lbug/lbug-config.js'; +import { FAMILY_SUFFIXES } from '../../storage/embedding-recovery.js'; +import { + abortCachedEmbeddingsBuilder, + createCachedEmbeddingsBuilder, + finalizeCachedEmbeddingsSnapshot, + ingestCachedEmbeddingRow, +} from './embedding-restore-spill.js'; +import type { StagedEmbeddingExport } from './staged-embedding-recovery.js'; + +/** Native replay and close may checkpoint; only give them disposable copies. */ +function copyRecoveryFamily(dbPath: string, exportDir: string): string { + const replayDir = fs.mkdtempSync(path.join(exportDir, 'replay-')); + const replayPath = path.join(replayDir, path.basename(dbPath)); + const noFollow = fs.constants.O_NOFOLLOW ?? 0; + const flags = fs.constants.O_RDONLY | noFollow | (fs.constants.O_NONBLOCK ?? 0); + const buffer = Buffer.allocUnsafe(1024 * 1024); + for (const suffix of FAMILY_SUFFIXES) { + const sourcePath = dbPath + suffix; + let entry: fs.BigIntStats; + try { + entry = fs.lstatSync(sourcePath, { bigint: true }); + } catch (error) { + if (suffix && (error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw error; + } + if (!entry.isFile()) throw new Error('staged embedding family is not a regular file'); + const source = fs.openSync(sourcePath, flags); + let destination: number | undefined; + try { + const opened = fs.fstatSync(source, { bigint: true }); + const current = fs.lstatSync(sourcePath, { bigint: true }); + if ( + !opened.isFile() || + !current.isFile() || + (noFollow === 0 && opened.ino === 0n) || + opened.dev !== entry.dev || + opened.ino !== entry.ino || + opened.dev !== current.dev || + opened.ino !== current.ino + ) { + throw new Error('staged embedding family changed while opening'); + } + destination = fs.openSync(replayPath + suffix, 'wx', 0o600); + let copied = 0n; + for (;;) { + const bytesRead = fs.readSync(source, buffer, 0, buffer.length, null); + if (bytesRead === 0) break; + fs.writeFileSync(destination, buffer.subarray(0, bytesRead)); + copied += BigInt(bytesRead); + } + const after = fs.fstatSync(source, { bigint: true }); + if ( + copied !== opened.size || + after.size !== opened.size || + after.mtimeNs !== opened.mtimeNs || + after.ctimeNs !== opened.ctimeNs + ) { + throw new Error('staged embedding family changed while copying'); + } + } finally { + try { + if (destination !== undefined) fs.closeSync(destination); + } finally { + fs.closeSync(source); + } + } + } + return replayPath; +} + +async function extract(): Promise { + const [dbPath, exportDir, dimensionsArg] = process.argv.slice(2); + const dimensions = Number(dimensionsArg); + if (!dbPath || !exportDir || !Number.isInteger(dimensions) || dimensions <= 0) { + throw new Error('invalid staged embedding extraction arguments'); + } + // The parent owns exportDir and reclaims it even after killing this child. + const replayPath = copyRecoveryFamily(dbPath, exportDir); + const builder = createCachedEmbeddingsBuilder({ inMemoryRowLimit: 0, spillDir: exportDir }); + const rejectedNodeIds = new Set(); + let db: lbug.Database | undefined; + let conn: lbug.Connection | undefined; + try { + // Avoid openLbugConnection's test-fixture lock sweep: a recovery source must + // never have its WAL removed, even when an external slot resembles a fixture. + db = createLbugDatabase(lbug, toNativeSafePath(replayPath), { throwOnWalReplayFailure: true }); + conn = new lbug.Connection(db); + const queried = await conn.query( + 'MATCH (e:CodeEmbedding) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding, e.contentHash AS contentHash', + ); + const results = Array.isArray(queried) ? queried : [queried]; + try { + if (results.length !== 1) throw new Error('unexpected staged embedding query result'); + const result = results[0]; + while (await result.hasNext()) { + const raw = await result.getNext(); + const rec = raw as Record & unknown[]; + const nodeId = rec.nodeId ?? rec[0]; + if (typeof nodeId !== 'string' || !nodeId) + throw new Error('invalid staged embedding node id'); + const chunkIndex = rec.chunkIndex ?? rec[1]; + const startLine = rec.startLine ?? rec[2]; + const endLine = rec.endLine ?? rec[3]; + const embedding = rec.embedding ?? rec[4]; + const contentHash = rec.contentHash ?? rec[5]; + const vector = + Array.isArray(embedding) || + (ArrayBuffer.isView(embedding) && !(embedding instanceof DataView)) + ? Array.from(embedding as ArrayLike) + : undefined; + if ( + !Number.isInteger(chunkIndex) || + Number(chunkIndex) < 0 || + !Number.isInteger(startLine) || + Number(startLine) < 0 || + !Number.isInteger(endLine) || + Number(endLine) < Number(startLine) || + typeof contentHash !== 'string' || + !contentHash || + !vector || + vector.length !== dimensions || + vector.some( + (value) => + typeof value !== 'number' || + !Number.isFinite(value) || + !Number.isFinite(Math.fround(value)), + ) + ) { + rejectedNodeIds.add(nodeId); + continue; + } + ingestCachedEmbeddingRow( + builder, + { nodeId, chunkIndex, startLine, endLine, embedding: vector, contentHash }, + true, + ); + } + } finally { + for (const result of results) await result.close(); + } + // Both closes must succeed. Suppressed native teardown errors are unsafe. + await conn.close(); + await db.close(); + const snapshot = finalizeCachedEmbeddingsSnapshot(builder); + if (snapshot.spill) fs.renameSync(snapshot.spill.path, path.join(exportDir, 'vectors.bin')); + const manifest: StagedEmbeddingExport = { + version: 1, + dimensions, + rows: snapshot.rows, + rejectedNodeIds: [...rejectedNodeIds], + }; + fs.writeFileSync(path.join(exportDir, 'manifest.json'), JSON.stringify(manifest), { + flag: 'wx', + mode: 0o600, + }); + } catch (err) { + abortCachedEmbeddingsBuilder(builder); + // Cleanup is best effort on a rejected source, never used to approve output. + try { + await conn?.close(); + } catch { + /* rejected */ + } + try { + await db?.close(); + } catch { + /* rejected */ + } + throw err; + } +} + +extract().catch((err: unknown) => { + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); + process.exitCode = 1; +}); diff --git a/gitnexus/src/core/embeddings/staged-embedding-recovery.ts b/gitnexus/src/core/embeddings/staged-embedding-recovery.ts new file mode 100644 index 000000000..b295f4beb --- /dev/null +++ b/gitnexus/src/core/embeddings/staged-embedding-recovery.ts @@ -0,0 +1,244 @@ +/** Recover paid embedding rows without opening an interrupted native DB in analyze. */ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + abortCachedEmbeddingsBuilder, + createCachedEmbeddingsBuilder, + EmbeddingSpillReader, + emptyCachedEmbeddingsSnapshot, + finalizeCachedEmbeddingsSnapshot, + ingestCachedEmbeddingRow, + materializeCachedEmbeddings, + type CachedEmbeddingMeta, + type CachedEmbeddingsSnapshot, +} from './embedding-restore-spill.js'; + +export interface StagedEmbeddingRecoveryOptions { + dimensions: number; + /** Active checkpoint window and any incomplete inherited restore groups. */ + excludedNodeIds?: Iterable; + timeoutMs?: number; +} + +export interface StagedEmbeddingExport { + version: 1; + dimensions: number; + rows: CachedEmbeddingMeta[]; + rejectedNodeIds: string[]; +} + +const RESTORE_BATCH_SIZE = 200; +const DEFAULT_EXTRACTION_TIMEOUT_MS = 120_000; + +/** A contiguous prefix is safe only when its node is outside every unsafe window. */ +export function validateRecoveredNodeGroups( + rows: readonly CachedEmbeddingMeta[], + excludedNodeIds: ReadonlySet = new Set(), +): Set { + const groups = new Map; invalid: boolean }>(); + for (const row of rows) { + let group = groups.get(row.nodeId); + if (!group) { + group = { hash: row.contentHash, ordinals: new Set(), invalid: false }; + groups.set(row.nodeId, group); + } + if ( + typeof row.nodeId !== 'string' || + !row.nodeId || + typeof row.contentHash !== 'string' || + !row.contentHash || + row.contentHash !== group.hash || + !Number.isInteger(row.chunkIndex) || + row.chunkIndex < 0 || + group.ordinals.has(row.chunkIndex) || + !Number.isInteger(row.startLine) || + row.startLine < 0 || + !Number.isInteger(row.endLine) || + row.endLine < row.startLine + ) + group.invalid = true; + group.ordinals.add(row.chunkIndex); + } + const accepted = new Set(); + for (const [nodeId, group] of groups) { + if (group.invalid || excludedNodeIds.has(nodeId)) continue; + // Unique ordinals with max n-1 and zero present have no holes. + if (!group.ordinals.has(0)) continue; + if ([...group.ordinals].some((ordinal) => ordinal >= group.ordinals.size)) continue; + accepted.add(nodeId); + } + return accepted; +} + +/** Whole recovered nodes replace whole published groups; vectors stay in bounded batches. */ +export function mergeRecoveredEmbeddings( + live: CachedEmbeddingsSnapshot, + recovered: CachedEmbeddingsSnapshot, +): CachedEmbeddingsSnapshot { + const builder = createCachedEmbeddingsBuilder({ inMemoryRowLimit: 0 }); + try { + appendSnapshotRows( + live, + live.rows.filter((row) => !recovered.embeddingNodeIds.has(row.nodeId)), + builder, + ); + appendSnapshotRows(recovered, recovered.rows, builder); + return finalizeCachedEmbeddingsSnapshot(builder); + } catch (err) { + abortCachedEmbeddingsBuilder(builder); + throw err; + } +} + +function appendSnapshotRows( + snapshot: CachedEmbeddingsSnapshot, + rows: readonly CachedEmbeddingMeta[], + builder: ReturnType, +): void { + const reader = snapshot.spill ? new EmbeddingSpillReader(snapshot.spill) : undefined; + try { + for (let i = 0; i < rows.length; i += RESTORE_BATCH_SIZE) { + const batch = materializeCachedEmbeddings( + snapshot, + rows.slice(i, i + RESTORE_BATCH_SIZE), + reader, + ); + for (const row of batch) + ingestCachedEmbeddingRow(builder, row as unknown as Record, true); + } + } finally { + reader?.close(); + } +} + +/** + * Caller must validate checkpoint identity, schema, exact generation, and hold the + * index lock. A subprocess contains native WAL replay/query/destructor failures. + * A failed strict open is never retried with WAL removed or validation disabled. + */ +export async function recoverStagedEmbeddings( + dbPath: string, + options: StagedEmbeddingRecoveryOptions, +): Promise { + if (!Number.isInteger(options.dimensions) || options.dimensions <= 0) { + throw new Error('invalid staged embedding dimensions'); + } + const dbStat = fs.lstatSync(dbPath); + if (!dbStat.isFile() || dbStat.isSymbolicLink()) + throw new Error('staged embedding DB is not a regular file'); + const exportDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-stage-export-')); + try { + await runExtractionChild(dbPath, exportDir, options); + const manifestPath = path.join(exportDir, 'manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as StagedEmbeddingExport; + if ( + manifest.version !== 1 || + manifest.dimensions !== options.dimensions || + !Array.isArray(manifest.rows) || + !Array.isArray(manifest.rejectedNodeIds) || + manifest.rejectedNodeIds.some((id) => typeof id !== 'string') || + manifest.rows.some((row, i) => !row || row.vectorIndex !== i) + ) + throw new Error('invalid staged embedding export manifest'); + if (manifest.rows.length === 0) return emptyCachedEmbeddingsSnapshot(); + const spill = { + path: path.join(exportDir, 'vectors.bin'), + dims: options.dimensions, + rowCount: manifest.rows.length, + }; + const vectorStat = fs.lstatSync(spill.path); + if ( + !vectorStat.isFile() || + vectorStat.isSymbolicLink() || + vectorStat.size !== 12 + spill.rowCount * spill.dims * 4 + ) { + throw new Error('invalid staged embedding export size'); + } + const excluded = new Set(options.excludedNodeIds ?? []); + for (const nodeId of manifest.rejectedNodeIds) excluded.add(nodeId); + const accepted = validateRecoveredNodeGroups(manifest.rows, excluded); + const exported: CachedEmbeddingsSnapshot = { + rows: manifest.rows, + embeddings: [], + embeddingNodeIds: accepted, + spill, + }; + const builder = createCachedEmbeddingsBuilder({ inMemoryRowLimit: 0 }); + const reader = new EmbeddingSpillReader(spill); + try { + const rows = manifest.rows.filter((row) => accepted.has(row.nodeId)); + for (let i = 0; i < rows.length; i += RESTORE_BATCH_SIZE) { + for (const row of materializeCachedEmbeddings( + exported, + rows.slice(i, i + RESTORE_BATCH_SIZE), + reader, + )) { + if ( + row.embedding.length !== options.dimensions || + row.embedding.some((value) => !Number.isFinite(value)) + ) { + throw new Error('invalid staged embedding vector'); + } + ingestCachedEmbeddingRow(builder, row as unknown as Record, true); + } + } + return finalizeCachedEmbeddingsSnapshot(builder); + } catch (err) { + abortCachedEmbeddingsBuilder(builder); + throw err; + } finally { + reader.close(); + } + } finally { + fs.rmSync(exportDir, { recursive: true, force: true }); + } +} + +function runExtractionChild( + dbPath: string, + exportDir: string, + options: StagedEmbeddingRecoveryOptions, +): Promise { + const compiledPath = fileURLToPath( + new URL('./staged-embedding-recovery-child.js', import.meta.url), + ); + const sourcePath = compiledPath.replace(/\.js$/, '.ts'); + const childPath = fs.existsSync(compiledPath) ? compiledPath : sourcePath; + const args = childPath.endsWith('.ts') + ? ['--import', import.meta.resolve('tsx'), childPath] + : [childPath]; + args.push(dbPath, exportDir, String(options.dimensions)); + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + stdio: ['ignore', 'ignore', 'pipe'], + windowsHide: true, + }); + let stderr = ''; + let timedOut = false; + child.stderr.on('data', (chunk: Buffer) => { + if (stderr.length < 4096) stderr += chunk.toString().slice(0, 4096 - stderr.length); + }); + const timer = setTimeout(() => { + timedOut = true; + // A process boundary is safe to kill even while native code is executing. + child.kill('SIGKILL'); + }, options.timeoutMs ?? DEFAULT_EXTRACTION_TIMEOUT_MS); + child.once('error', (err) => { + clearTimeout(timer); + reject(err); + }); + child.once('close', (code, signal) => { + clearTimeout(timer); + if (code === 0 && !signal && !timedOut) resolve(); + else + reject( + new Error( + `staged embedding extraction failed${timedOut ? ' (timeout)' : ` (${signal ?? code})`}${stderr ? `: ${stderr.trim()}` : ''}`, + ), + ); + }); + }); +} diff --git a/gitnexus/src/core/git-staleness.ts b/gitnexus/src/core/git-staleness.ts index ece63a87c..e224b2319 100644 --- a/gitnexus/src/core/git-staleness.ts +++ b/gitnexus/src/core/git-staleness.ts @@ -17,8 +17,8 @@ export type { StalenessInfo, StalenessStatus } from './staleness-status.js'; const execFileAsync = promisify(execFile); /** - * Ceiling for one `git rev-list` staleness probe. Generous for the local - * history walk this is, and short enough that an unresponsive working tree + * Per-command ceiling for async `git rev-list` and both HEAD probes. + * Generous for local Git queries, and short enough that an unresponsive working tree * degrades to "not stale" quickly rather than holding a request open. */ const STALENESS_TIMEOUT_MS = 5_000; @@ -33,12 +33,23 @@ const behindHint = (n: number): string => const DIVERGED_HINT = "⚠️ Index is not at HEAD and the commit gap could not be counted — the recorded commit may no longer be in this clone's history. Run analyze tool to update."; +// `rev-list --count lastCommit..HEAD` answering 0 does NOT mean "HEAD is the +// indexed commit" — it means "HEAD has no commits lastCommit lacks", which is +// also true when HEAD is an *ancestor* of lastCommit (the working tree checked +// out an older commit than the one indexed, or switched to a line of history +// behind it). That read a rollback as `current` until this hint existed. +const REGRESSED_HINT = + '⚠️ Index is not at HEAD — the indexed commit is not reachable from the checked-out commit (the working tree may have checked out an older commit, or a different line of history). Run analyze tool to update.'; + const unknown = (): StalenessInfo => ({ isStale: false, commitsBehind: 0, status: 'unknown' }); -const fromCount = (commitsBehind: number): StalenessInfo => - commitsBehind > 0 - ? { isStale: true, commitsBehind, hint: behindHint(commitsBehind), status: 'behind' } - : { isStale: false, commitsBehind: 0, status: 'current' }; +// Called only once a positive HEAD-only count is in hand. +const behind = (commitsBehind: number): StalenessInfo => ({ + isStale: true, + commitsBehind, + hint: behindHint(commitsBehind), + status: 'behind', +}); /** * `rev-list` could not answer. Asking for HEAD alone needs no history walk and @@ -53,6 +64,26 @@ const fromHead = (head: string | null, lastCommit: string): StalenessInfo => { return { isStale: false, commitsBehind: 0, hint: DIVERGED_HINT, status: 'diverged' }; }; +/** + * `rev-list --left-right --count lastCommit...HEAD` measures both sides in + * one process, so a later HEAD change cannot mix two snapshots. The left + * count identifies a rollback even when the HEAD-only (right) count is 0. + * Positive right counts keep the existing `behind` behavior, including when + * both sides have commits (divergent branches or a re-shallowed clone). + */ +const fromCounts = (output: string): StalenessInfo => { + const counts = /^(\d+)\s+(\d+)$/.exec(output.trim()); + if (!counts) return unknown(); + const indexedOnly = Number(counts[1]); + const headOnly = Number(counts[2]); + if (!Number.isSafeInteger(indexedOnly) || !Number.isSafeInteger(headOnly)) return unknown(); + if (headOnly > 0) return behind(headOnly); + if (indexedOnly > 0) { + return { isStale: true, commitsBehind: 0, hint: REGRESSED_HINT, status: 'diverged' }; + } + return { isStale: false, commitsBehind: 0, status: 'current' }; +}; + const readHeadSync = (repoPath: string): string | null => { try { return ( @@ -61,6 +92,7 @@ const readHeadSync = (repoPath: string): string | null => { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, + timeout: STALENESS_TIMEOUT_MS, }).trim() || null ); } catch { @@ -89,14 +121,18 @@ export function checkStaleness(repoPath: string, lastCommit: string): StalenessI // No recorded commit is not "at HEAD": there is nothing to measure against. if (!lastCommit) return unknown(); try { - const result = execFileSync('git', ['rev-list', '--count', `${lastCommit}..HEAD`], { - cwd: repoPath, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }).trim(); + const result = execFileSync( + 'git', + ['rev-list', '--left-right', '--count', `${lastCommit}...HEAD`], + { + cwd: repoPath, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }, + ); - return fromCount(parseInt(result, 10) || 0); + return fromCounts(result); } catch { return fromHead(readHeadSync(repoPath), lastCommit); } @@ -115,21 +151,25 @@ export async function checkStalenessAsync( try { // Note: promisified execFile captures stdout/stderr by default (no stdio option needed, // unlike the sync variant which requires explicit stdio: ['pipe','pipe','pipe']). - const { stdout } = await execFileAsync('git', ['rev-list', '--count', `${lastCommit}..HEAD`], { - cwd: repoPath, - encoding: 'utf-8', - windowsHide: true, - // The catch below fails closed on every git ERROR, but a hang is not an - // error — it is silence, and without a bound this await never settles. - // A working tree on a disconnected network mount or behind a stuck lock - // does exactly that, and `/api/repos` fans this out once per registered - // repo, so one unreachable mount could hold the whole listing open - // (#3232 review). The timeout kills the child and rejects, and the catch - // below reports it as `unknown` — still the fail-closed `isStale: false`. - timeout: STALENESS_TIMEOUT_MS, - }); + const { stdout } = await execFileAsync( + 'git', + ['rev-list', '--left-right', '--count', `${lastCommit}...HEAD`], + { + cwd: repoPath, + encoding: 'utf-8', + windowsHide: true, + // The catch below fails closed on every git ERROR, but a hang is not an + // error — it is silence, and without a bound this await never settles. + // A working tree on a disconnected network mount or behind a stuck lock + // does exactly that, and `/api/repos` fans this out once per registered + // repo, so one unreachable mount could hold the whole listing open + // (#3232 review). The timeout kills the child and rejects, and the catch + // below reports it as `unknown` — still the fail-closed `isStale: false`. + timeout: STALENESS_TIMEOUT_MS, + }, + ); - return fromCount(parseInt(stdout.trim(), 10) || 0); + return fromCounts(stdout); } catch (err) { // A rev-list that timed out means the working tree is not answering. Asking // it again for HEAD would only double the bound #3232 put on a hung mount. diff --git a/gitnexus/src/core/group/extractors/http-patterns/go.ts b/gitnexus/src/core/group/extractors/http-patterns/go.ts index ebf87f0e1..5b1557831 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/go.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/go.ts @@ -1,27 +1,42 @@ +import type Parser from 'tree-sitter'; import Go from 'tree-sitter-go'; +import { goImportPackageName } from '../../../ingestion/languages/go/import-package-name.js'; +import { + isEchoImportPath, + isGinImportPath, + stringLiteral, +} from '../../../ingestion/route-extractors/go-shared.js'; import { compilePatterns, runCompiledPatterns, - unquoteLiteral, type LanguagePatterns, } from '../tree-sitter-scanner.js'; import type { HttpDetection, HttpLanguagePlugin } from './types.js'; /** * Go HTTP plugin. Handles: - * - gin / echo / chi framework routing — `r.GET("/path", handler)` + * - gin / echo framework routing — `r.GET("/path", handler)`, including + * prefixes from route groups bound in the same function (`r.Group("/api")`) * - net/http stdlib — `http.HandleFunc("/path", handler)` * - net/http consumer — `http.Get(...)`, `http.NewRequest("METHOD", ...)` * - resty consumer — `client.R().Delete("/path")` */ // ─── Provider: framework routing ────────────────────────────────────── -// Matches `\w+\.GET(...)` etc. (gin, echo, chi all share this shape). -// Captures the HTTP method (field name), path literal, and the handler — -// anchored to the LAST argument (`@handler .`) so a variadic middleware -// chain (`r.GET("/x", mw, handler)`, gin/echo/chi style) binds the real -// handler, not a middleware identifier (which would otherwise over-match -// and attach the route to the wrong symbol — see #2276 review). +// Matches `\w+\.GET(...)` etc. (gin and echo share this shape). +// Captures the receiver, the HTTP method (field name), and the path literal +// (either Go string form; stringLiteral decodes both, as ingestion does). The +// query does not anchor the path with `.`: tree-sitter counts comments as +// named children, so `GET(/* c */ "/p", h)` would fail the anchor. scan instead +// requires the path to be the first argument in code (comments skipped) and +// picks the handler out of the remaining code arguments. Which argument that is depends on the framework: +// gin is `GET(path, middleware..., handler)` (last), echo is +// `GET(path, handler, middleware...)` (first) — see readFrameworkImports and +// the per-call choice in scan below. +// The handler must be an identifier, an inline func literal, or a method +// value / package-qualified function (`h.ListUsers`, `handlers.ListUsers`); +// anything else there means the call cannot be attributed to a symbol, so it +// is dropped rather than guessed (variadic-middleware over-match, #2276). const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({ name: 'go-framework-route', language: Go, @@ -31,16 +46,633 @@ const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({ query: ` (call_expression function: (selector_expression + operand: (_) @receiver field: (field_identifier) @http_method (#match? @http_method "^(GET|POST|PUT|DELETE|PATCH)$")) arguments: (argument_list - (interpreted_string_literal) @path - [(identifier) (func_literal)] @handler - .)) + [(interpreted_string_literal) (raw_string_literal)] @path)) `, }, ], } satisfies LanguagePatterns>); +/** Named children that are code, not comments (tree-sitter names comments). */ +function codeChildren(node: Parser.SyntaxNode | null | undefined): Parser.SyntaxNode[] { + return node ? node.namedChildren.filter((c) => c.type !== 'comment') : []; +} + +/** Argument forms a route handler may take. */ +const HANDLER_ARG_TYPES: ReadonlySet = new Set([ + 'identifier', + 'func_literal', + 'selector_expression', +]); + +/** + * The file's framework import aliases: which local qualifiers resolve to + * echo and to gin. Matched on the EXACT import path (shared with ingestion via + * go-shared.ts) rather than the local name, so + * an aliased import still counts; an unaliased import is keyed by its + * conventional package name (`goImportPackageName`). `_` and `.` imports bind + * no qualifier this file can route through. An empty set means the file + * proves nothing about that framework. Echo's verb calls take the handler as + * the FIRST argument after the path (`GET(path, handler, middleware...)`), + * gin's as the LAST (`GET(path, middleware..., handler)`) — `scan` picks the + * rule per call from these sets. + */ +function readFrameworkImports(root: Parser.SyntaxNode): { + echo: Set; + gin: Set; +} { + // Imports sit only at file scope; skip bodies. + const specs = root.namedChildren + .filter((node) => node.type === 'import_declaration') + .flatMap((decl) => decl.descendantsOfType('import_spec')); + const echo = new Set(); + const gin = new Set(); + for (const spec of specs) { + const importPath = stringLiteral(spec.childForFieldName('path')); + if (importPath === null) continue; + const local = spec.childForFieldName('name')?.text ?? goImportPackageName(importPath); + if (local === '_' || local === '.') continue; + if (isEchoImportPath(importPath)) echo.add(local); + else if (isGinImportPath(importPath)) gin.add(local); + } + return { echo, gin }; +} + +// ─── Route groups: `v1 := r.Group("/api/v1")` ───────────────────────── +// gin (`*gin.RouterGroup`) and echo (`*echo.Group`) routes registered on a +// group inherit every enclosing `Group(prefix)`. The prefix is recovered by +// walking the route's receiver back through its bindings, lexically, inside +// the enclosing function declaration only: a group handed to another +// function (`registerAdmin(v1)`) arrives as a parameter and contributes no +// prefix there, and a receiver bound to anything but a literal-prefix +// `Group(...)` call contributes none either — the route keeps its literal path. +// Statement-scoped bindings count too: an `if`/`switch` initializer, a `for` +// clause (including `range`), a type-switch guard, and declarations inside a +// switch case all scope over their statement the same way Go scopes them, so +// they shadow an outer group of the same name instead of being skipped. A +// select case's receive binding (`case g := <-ch:`) stops the walk entirely: +// what arrives from the channel is statically unknown, so the route keeps its +// literal path rather than inheriting an outer group. + +const MAX_GROUP_DEPTH = 32; + +function joinRoutePath(prefix: string, relative: string): string { + let joined = relative; + if (prefix && relative) { + joined = `${prefix.replace(/\/+$/, '')}/${relative.replace(/^\/+/, '')}`; + } else if (prefix) { + joined = prefix; + } + // Collapse duplicate slashes on the FINAL result — every return branch, not + // just the join — because ingestion's normalizeExtractedRoutePath collapses + // all "//" while the downstream contract-id normalizer does not: a path + // that keeps "//" would split into two contract ids across the strategies. + const collapsed = joined.replace(/\/+/g, '/'); + // Force a leading "/" for the same reason: ingestion's + // normalizeExtractedRoutePath always adds one, while normalizeHttpPath (the + // shared contract-id normalizer) does not — a literal "x" or a slashless + // Group("api") prefix would emit `...::x` here and `...::/x` there. Gin + // also refuses a registration path that does not start with "/". + return collapsed.startsWith('/') ? collapsed : `/${collapsed}`; +} + +/** `parent.Group("/p", mw...)` → its receiver and literal prefix; null otherwise. */ +function asGroupCall( + node: Parser.SyntaxNode, +): { parent: Parser.SyntaxNode; prefix: string } | null { + if (node.type !== 'call_expression') return null; + const fn = node.childForFieldName('function'); + if (fn?.type !== 'selector_expression' || fn.childForFieldName('field')?.text !== 'Group') { + return null; + } + const parent = fn.childForFieldName('operand'); + const first = codeChildren(node.childForFieldName('arguments'))[0]; + // Only a string literal carries a prefix, and it must decode to the text + // the runtime registers: stringLiteral applies Go unescaping (both `"…"` + // with escapes and raw `` `…` `` strings). A non-literal argument (a + // variable, concatenation) or an undecodable string contributes no prefix. + const prefix = first ? stringLiteral(first) : null; + if (!parent || prefix === null) return null; + return { parent, prefix }; +} + +/** The `var_spec`s of a `var_declaration`, flattened out of a grouped `var (…)`. */ +function varSpecs(decl: Parser.SyntaxNode): Parser.SyntaxNode[] { + return decl.namedChildren + .flatMap((c) => (c.type === 'var_spec_list' ? c.namedChildren : [c])) + .filter((spec) => spec.type === 'var_spec'); +} + +/** The expression `name` is assigned by `stmt` (`:=`, `=`, or `var`), if any. */ +function boundValue(stmt: Parser.SyntaxNode, name: string): Parser.SyntaxNode | null | undefined { + const pick = ( + names: Parser.SyntaxNode[], + values: Parser.SyntaxNode | null, + ): Parser.SyntaxNode | null | undefined => { + // Go carries an assignment out left to right, so a repeated target leaves + // the LAST one in place (`g, g = a, b` ends with g == b). + let i = -1; + for (let k = 0; k < names.length; k++) { + if (names[k].type === 'identifier' && names[k].text === name) i = k; + } + if (i < 0) return undefined; + return codeChildren(values)[i] ?? null; + }; + switch (stmt.type) { + case 'short_var_declaration': + case 'assignment_statement': + return pick(codeChildren(stmt.childForFieldName('left')), stmt.childForFieldName('right')); + case 'var_spec': + return pick(stmt.childrenForFieldName('name'), stmt.childForFieldName('value')); + case 'var_declaration': { + for (const spec of varSpecs(stmt)) { + const value = pick(spec.childrenForFieldName('name'), spec.childForFieldName('value')); + if (value !== undefined) return value; + } + return undefined; + } + default: + return undefined; + } +} + +/** + * A binding whose value cannot be established statically: an earlier + * statement writes the name inside a nested scope (`{ g = r.Group("/new") }`, + * a branch or loop body), so which value reaches the use depends on control + * flow. Callers decline the route instead of picking the older binding. + */ +const CONFLICT = Symbol('conflicting-binding'); +/** The name is a parameter (or method receiver): its declaration carries a static type. */ +interface ParamBinding { + param: Parser.SyntaxNode; +} +type Binding = Parser.SyntaxNode | null | undefined | typeof CONFLICT | ParamBinding; + +function isParamBinding(b: Binding): b is ParamBinding { + return typeof b === 'object' && b !== null && 'param' in b; +} + +/** The parameter_declaration of `fn` (parameters or method receiver) declaring `name`. */ +function paramDeclaring(fn: Parser.SyntaxNode, name: string): Parser.SyntaxNode | null { + for (const list of [fn.childForFieldName('parameters'), fn.childForFieldName('receiver')]) { + for (const decl of codeChildren(list)) { + if (decl.type !== 'parameter_declaration' && decl.type !== 'variadic_parameter_declaration') { + continue; + } + if (decl.childrenForFieldName('name').some((n) => n.text === name)) return decl; + } + } + return null; +} + +/** Whether `inner` lies within `outer`'s source span. */ +function within(outer: Parser.SyntaxNode | null, inner: Parser.SyntaxNode): boolean { + return !!outer && outer.startIndex <= inner.startIndex && inner.endIndex <= outer.endIndex; +} + +/** + * Whether `node` is itself a write to `name`: a plain `assignment_statement` + * (`g = …`) or the `=` form of a range clause (`for _, g = range xs`) or a + * select receive (`case g = <-ch`), which tree-sitter parses as their own node + * types. A `:=` declares a new variable — loop- or case-local — and is not a + * write to the outer one. + */ +function isWriteTo(node: Parser.SyntaxNode, name: string): boolean { + if (node.type === 'assignment_statement') { + return declaresName(node.childForFieldName('left'), name); + } + return ( + (node.type === 'range_clause' || node.type === 'receive_statement') && + assignsExistingName(node) && + declaresName(node.childForFieldName('left'), name) + ); +} + +/** + * Every write to `name` inside `scope` (a statement or a function literal). + * With `executedOnly`, a nested function literal is entered only through a + * call: one merely stored in a value does not run when its enclosing body + * runs. + */ +function writesInScope( + scope: Parser.SyntaxNode, + name: string, + executedOnly: boolean, +): Parser.SyntaxNode[] { + const out: Parser.SyntaxNode[] = []; + const walk = (node: Parser.SyntaxNode): void => { + if (node.type === 'func_literal') { + if (executedOnly && !isInvokedLiteral(node)) return; + const body = node.childForFieldName('body'); + if (body) walk(body); + return; + } + if (isWriteTo(node, name)) out.push(node); + for (const child of node.namedChildren) walk(child); + }; + walk(scope); + return out; +} + +/** + * Whether `scope` writes `name` through a binding declared OUTSIDE it: an + * assignment (or `=` range/receive) whose target resolves to a name the scope + * itself does not declare — a captured outer group rather than a local `:=`, + * `var`, or parameter. An unresolvable or conflicting write reaches out + * conservatively. With `executedOnly`, writes that only run if a nested + * function literal is later called are ignored. + */ +function writesOuterName(scope: Parser.SyntaxNode, name: string, executedOnly = false): boolean { + for (const write of writesInScope(scope, name, executedOnly)) { + const ident = identifierNamed(write.childForFieldName('left'), name); + if (!ident) continue; + // A range/receive `=` target resolves from OUTSIDE its clause: the + // clause's own binding logic describes the loop/case variable, not the + // existing name the `=` writes. + const from = + write.type === 'range_clause' || write.type === 'receive_statement' + ? write.parent + : undefined; + const binding = lookupBinding(ident, from); + // null is a declaration without a traceable value; it carries no position + // to place, so a write through it reaches out conservatively. + if (binding === undefined || binding === null || binding === CONFLICT) return true; + const declared = isParamBinding(binding) ? binding.param : binding; + if (!within(scope, declared)) return true; + } + return false; +} + +/** + * Whether `stmt` writes `name` through a binding declared outside it — a + * nested assignment, branch, loop, or closure body. A declaration that + * shadows `name` inside the statement (a nested `:=`, a parameter) writes the + * local, not the target the use would see, so it does not count. + */ +function writesNameInside(stmt: Parser.SyntaxNode, name: string): boolean { + return writesOuterName(stmt, name); +} + +/** A range clause or receive that assigns (`=`) rather than declares (`:=`). */ +function assignsExistingName(clause: Parser.SyntaxNode): boolean { + return !clause.children.some((c) => c.type === ':='); +} + +/** + * Whether `fn` is the callee of a call (`func(){ … }()`), ignoring + * parentheses, so its body is known to run. + */ +function isInvokedLiteral(fn: Parser.SyntaxNode): boolean { + let callee: Parser.SyntaxNode = fn; + while (callee.parent?.type === 'parenthesized_expression') callee = callee.parent; + const parent = callee.parent; + return ( + !!parent && + parent.type === 'call_expression' && + parent.childForFieldName('function')?.id === callee.id + ); +} + +/** + * Whether evaluating `node` executes a write to `name`. A function literal is + * only entered when it is the callee of a call: `g := func(){ g = … }` merely + * stores the literal, so the outer `g` is untouched, while `func(){ … }()` + * runs its body (writes that resolve to the literal's own locals excepted). + */ +function executedWritesName(node: Parser.SyntaxNode, name: string): boolean { + if (node.type === 'func_literal') { + return isInvokedLiteral(node) && writesOuterName(node, name, true); + } + if (isWriteTo(node, name)) return true; + return node.namedChildren.some((child) => executedWritesName(child, name)); +} + +/** + * Whether the initializer of the declaration that brings `name` into scope in + * a block writes the OUTER `name`. The right-hand side runs before the new + * variable exists, so `g := func(){ g = … }()` still writes the binding the + * use outside the block would inherit — but a literal only stored in a value + * never runs its body. Grouped `var` specs up to and including the declaring + * one are scanned; later specs read the new local. + */ +function initializerWritesName(decl: Parser.SyntaxNode, name: string): boolean { + if (decl.type === 'short_var_declaration') { + const values = decl.childForFieldName('right'); + return !!values && executedWritesName(values, name); + } + if (decl.type === 'var_declaration') { + for (const spec of varSpecs(decl)) { + const value = spec.childForFieldName('value'); + if (value && executedWritesName(value, name)) return true; + if (boundValue(spec, name) !== undefined) return false; + } + } + return false; +} + +/** + * Whether `stmt` holds a function literal that writes the outer `name`. Such + * a literal can run after any later direct assignment (`reset := func(){ g = … }; + * g = …; reset()`), so captured writes are found independently of statement + * order instead of being lost behind the last-assignment return. Writes that + * resolve to a declaration inside the literal (a parameter, a nested `:=`) are + * local and do not count. + */ +function closureWritesName(stmt: Parser.SyntaxNode, name: string): boolean { + const literals = + stmt.type === 'func_literal' + ? [stmt, ...stmt.descendantsOfType('func_literal')] + : stmt.descendantsOfType('func_literal'); + return literals.some((fn) => writesOuterName(fn, name)); +} + +/** + * The effect a bare `{ … }` block placed before the use has on the outer + * `name`. A bare block always runs, so its last top-level `name = v` is the + * value that reaches the use (`{ g = r.Group("/new") }` → `/new`); a write + * nested in a branch, loop, or closure inside it is CONFLICT. A `:=` or `var` + * of `name` in the block starts a new variable, so only the statements before + * it touch the outer one — but the declaring statement's initializer still + * does, because it evaluates before the new variable is in scope. undefined + * when the block leaves `name` alone. + */ +function bareBlockWrite(block: Parser.SyntaxNode, name: string): Binding { + const stmts = codeChildren(block); + const declIndex = stmts.findIndex( + (s) => + (s.type === 'short_var_declaration' || s.type === 'var_declaration') && + boundValue(s, name) !== undefined, + ); + const outerRegion = declIndex < 0 ? stmts : stmts.slice(0, declIndex); + if (declIndex >= 0 && initializerWritesName(stmts[declIndex], name)) return CONFLICT; + if (outerRegion.some((stmt) => closureWritesName(stmt, name))) return CONFLICT; + for (const stmt of outerRegion.reverse()) { + if (stmt.type === 'assignment_statement') { + const value = boundValue(stmt, name); + if (value !== undefined) return value; + } + if (stmt.type === 'block') { + const inner = bareBlockWrite(stmt, name); + if (inner !== undefined) return inner; + continue; + } + if (writesNameInside(stmt, name)) return CONFLICT; + } + return undefined; +} + +/** The identifier named `name` on an assignment side (or null). */ +function identifierNamed(node: Parser.SyntaxNode | null, name: string): Parser.SyntaxNode | null { + if (!node) return null; + if (node.type === 'identifier') return node.text === name ? node : null; + return node.namedChildren.find((n) => n.type === 'identifier' && n.text === name) ?? null; +} + +/** Whether an identifier or expression_list (e.g. a range left side) declares `name`. */ +function declaresName(node: Parser.SyntaxNode | null, name: string): boolean { + return identifierNamed(node, name) !== null; +} + +/** + * The value last bound to identifier `ident` before its use: the nearest + * binding site in the enclosing scopes, walking outward — preceding statements + * in blocks and switch/select cases (`expression_case`/`type_case`/ + * `communication_case`/`default_case` act as statement containers), then + * statement-scoped bindings (`if`/`switch` initializers, `for` clauses + * including `range`, type-switch guards), up to the enclosing function + * declaration. Returns null when the name is a parameter, is bound without a + * value, is received from a channel by a select case head (the received value + * is statically unknown, so the walk stops instead of escaping to an outer + * group), or is not bound in scope. + */ +function findBinding(ident: Parser.SyntaxNode): Parser.SyntaxNode | null | typeof CONFLICT { + const binding = lookupBinding(ident); + return isParamBinding(binding) ? null : (binding ?? null); +} + +/** + * findBinding's walk, keeping "declared without a traceable value" (null: a + * `func` literal parameter, `var x T`, a select receive) apart from "not + * declared before reaching the enclosing function declaration" (undefined). + * CONFLICT means a preceding statement writes the name in a nested scope, so + * the value at the use is control-flow dependent. A parameter or method + * receiver of the enclosing function returns its declaration (ParamBinding): + * no value, but a static type. `from` starts the walk at that node's parent + * instead of the identifier's, so an `=` range/receive target can be resolved + * outside its own clause. + */ +function lookupBinding(ident: Parser.SyntaxNode, from?: Parser.SyntaxNode | null): Binding { + const name = ident.text; + let child: Parser.SyntaxNode = from ?? ident; + for (let node = child.parent; node; child = node, node = node.parent) { + if ( + node.type === 'function_declaration' || + node.type === 'method_declaration' || + node.type === 'func_literal' + ) { + const param = paramDeclaring(node, name); + if (param) return { param }; + if (node.type === 'func_literal') continue; + return undefined; + } + // Inside a grouped `var ( a = …; b = a.Group(…) )`, the specs before the + // one holding the use are already in scope; the current and later specs + // are not (`var g = g.Group(…)` reads the outer g). + if (node.type === 'var_spec_list') { + const specs = codeChildren(node); + const useIndex = specs.findIndex((s) => s.id === child.id); + for (const spec of specs.slice(0, useIndex).reverse()) { + const value = boundValue(spec, name); + if (value !== undefined) return value; + } + continue; + } + if ( + node.type === 'block' || + node.type === 'expression_case' || + node.type === 'type_case' || + node.type === 'communication_case' || + node.type === 'default_case' + ) { + // A select case head can rebind the name (`case g := <-ch:` or + // `case g = <-ch:`); the received value is statically unknown, so the + // walk must STOP with no traceable value — the same decline the + // ingestion-side route bindings record (value null) — instead of + // escaping to an outer group of the same name. + if (node.type === 'communication_case') { + for (const head of node.children) { + if ( + head.type === 'receive_statement' && + declaresName(head.childForFieldName('left'), name) + ) { + return null; + } + } + } + const stmts = codeChildren(node); + const useIndex = stmts.findIndex((s) => s.id === child.id); + for (const stmt of stmts.slice(0, useIndex).reverse()) { + const value = boundValue(stmt, name); + if (value !== undefined) return value; + // A bare block always runs: its unconditional writes are bindings. + if (stmt.type === 'block') { + const written = bareBlockWrite(stmt, name); + if (written !== undefined) return written; + continue; + } + // A write nested in an earlier branch, loop, or closure may or may + // not run before the use: the reaching value is unprovable. + if (writesNameInside(stmt, name)) return CONFLICT; + } + continue; + } + // Statement-scoped bindings enclose the use the same way Go scopes them. + if (node.type === 'if_statement' || node.type === 'expression_switch_statement') { + // A use inside the initializer itself (`if g := g.Group(…); …`) reads + // the OUTER binding: the new one only scopes over what follows it. + const init = node.childForFieldName('initializer'); + if (init && !within(init, ident)) { + const value = boundValue(init, name); + if (value !== undefined) return value; + } + continue; + } + if (node.type === 'for_statement') { + const clause = codeChildren(node)[0]; + // A write in the loop's post statement, condition, or body runs between + // iterations, so from the second pass on the body sees that value + // instead of the one it entered with (`for ; c; g = r.Group("/post")`): + // the prefix is control-flow dependent, so decline it. + if (within(node.childForFieldName('body'), ident)) { + const loopParts = [ + node.childForFieldName('body'), + clause?.type === 'for_clause' ? clause.childForFieldName('update') : null, + clause?.type === 'for_clause' ? clause.childForFieldName('condition') : null, + ]; + if (loopParts.some((part) => part && writesNameInside(part, name))) return CONFLICT; + } + if (clause?.type === 'for_clause') { + // Only the initializer binds before the body; an absent initializer + // (`for ; c; i++`) binds nothing. + const init = clause.childForFieldName('initializer'); + if (init && !within(init, ident)) { + const value = boundValue(init, name); + if (value !== undefined) return value; + } + } else if (clause?.type === 'range_clause') { + if ( + declaresName(clause.childForFieldName('left'), name) && + !within(clause.childForFieldName('right'), ident) + ) { + return clause.childForFieldName('right') ?? null; + } + } + continue; + } + if (node.type === 'type_switch_statement') { + // `switch g := x.(type)` — the guard list is the `alias` field, which + // only the `:=` form has (bare `switch x.(type)` parses with none), + // matching how the ingestion-side route-bindings read it. The switched + // value is the operand right after the guard list. + const guard = node.childForFieldName('alias'); + const switched = codeChildren(node)[1] ?? null; + if ( + guard?.type === 'expression_list' && + declaresName(guard, name) && + !within(switched, ident) + ) { + return switched; + } + continue; + } + } + return undefined; +} + +/** + * Whether a mixed-import file's route receiver traces back to echo's + * constructor — `e := echo.New()` / `echo.Default()` with `echo` resolving to + * one of the file's verified echo import aliases — either directly or through + * enclosing `Group(...)` calls (`users := api.Group(…)` ← `api := e.Group(…)` + * ← `echo.New()`), the normal shape of grouped routes (review #7, #10). + * A parameter or method receiver counts by its declared type instead: + * `e *echo.Echo` / `g *echo.Group` (with `echo` one of those aliases) proves + * echo; any other type — gin's, or one the file cannot tie to echo — does not. + * Unrelated packages' `New()`, a local that shadows the echo import name, and + * anything else return false so the caller keeps the conservative + * last-argument fallback instead of guessing. Returns null when the Group chain exceeds MAX_GROUP_DEPTH or a + * binding on it is control-flow dependent (CONFLICT): the framework is then + * unprovable either way, so the caller declines the route. + */ +function receiverBindsToEchoConstructor( + receiver: Parser.SyntaxNode, + echoAliases: ReadonlySet, + depth = 0, +): boolean | null { + if (depth > MAX_GROUP_DEPTH) return null; + // An identifier resolves through its binding; a chained `X.Group(…).Group(…)` + // operand is already a call and is inspected as-is. + const value = receiver.type === 'identifier' ? lookupBinding(receiver) : receiver; + if (value === CONFLICT) return null; + if (isParamBinding(value)) + return isEchoRouterType(value.param.childForFieldName('type'), echoAliases); + if (value?.type !== 'call_expression') return false; + const fn = value.childForFieldName('function'); + if (fn?.type !== 'selector_expression') return false; + const field = fn.childForFieldName('field')?.text; + const operand = fn.childForFieldName('operand'); + if (!operand) return false; + if (field === 'New' || field === 'Default') { + return operand.type === 'identifier' && echoAliases.has(operand.text) && !isLocalName(operand); + } + if (field === 'Group') { + return receiverBindsToEchoConstructor(operand, echoAliases, depth + 1); + } + return false; +} + +/** `*echo.Echo` / `echo.Echo` / `*echo.Group` with `echo` a verified echo import alias. */ +function isEchoRouterType( + type: Parser.SyntaxNode | null, + echoAliases: ReadonlySet, +): boolean { + const named = type?.type === 'pointer_type' ? codeChildren(type)[0] : type; + if (named?.type !== 'qualified_type') return false; + const pkg = named.childForFieldName('package')?.text; + const typeName = named.childForFieldName('name')?.text; + return !!pkg && echoAliases.has(pkg) && (typeName === 'Echo' || typeName === 'Group'); +} + +/** + * Whether `ident` names a local value rather than an imported package: a + * declaration in scope (with or without a value — `var echo Factory` shadows + * too) or a parameter/receiver of an enclosing function. Go lets either + * shadow a package qualifier (`func f(echo *Factory) { echo.New() }`). + */ +function isLocalName(ident: Parser.SyntaxNode): boolean { + // lookupBinding covers parameters and receivers too (ParamBinding). + return lookupBinding(ident) !== undefined; +} + +/** + * Joined `Group(...)` prefix of a route receiver; '' when it cannot be traced. + * Returns null when the chain exceeds MAX_GROUP_DEPTH, or when a binding on it + * is control-flow dependent (CONFLICT): a partial or stale prefix would emit + * a wrong path, so the caller declines the route instead. + */ +function groupPrefix(receiver: Parser.SyntaxNode, depth = 0): string | null { + if (depth > MAX_GROUP_DEPTH) return null; + const value = receiver.type === 'identifier' ? findBinding(receiver) : receiver; + if (value === CONFLICT) return null; + const group = value ? asGroupCall(value) : null; + if (!group) return ''; + const outer = groupPrefix(group.parent, depth + 1); + return outer === null ? null : joinRoutePath(outer, group.prefix); +} + // ─── Provider: net/http `http.HandleFunc("/p", handler)` ───────────── const HANDLE_FUNC_PATTERNS = compilePatterns({ name: 'go-handle-func', @@ -135,27 +767,87 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { scan(tree) { const out: HttpDetection[] = []; - // Framework providers: r.GET/POST/... with handler identifier + // Framework providers: r.GET/POST/... on an engine or (nested) route group + const imports = readFrameworkImports(tree.rootNode); + const echoOnly = imports.echo.size > 0 && imports.gin.size === 0; + const mixed = imports.echo.size > 0 && imports.gin.size > 0; + // Handler names declared more than once in this file (`(h *A) List` and + // `(o *B) List`): the emitted name is field-only, so these must resolve + // only when unique in the file instead of taking the first same-named row. + const declaredNames = new Map(); + for (const decl of tree.rootNode.descendantsOfType([ + 'function_declaration', + 'method_declaration', + ])) { + const declName = decl.childForFieldName('name')?.text; + if (declName) declaredNames.set(declName, (declaredNames.get(declName) ?? 0) + 1); + } for (const match of runCompiledPatterns(FRAMEWORK_ROUTE_PATTERNS, tree)) { const methodNode = match.captures.http_method; const pathNode = match.captures.path; - const handlerNode = match.captures.handler; + const receiverNode = match.captures.receiver; if (!methodNode || !pathNode) continue; - const path = unquoteLiteral(pathNode.text); - if (path === null) continue; + const literalPath = stringLiteral(pathNode); + if (literalPath === null) continue; + const argList = pathNode.parent; + if (argList?.type !== 'argument_list') continue; + const args = codeChildren(argList); + if (args[0]?.id !== pathNode.id) continue; + // The path is the first code argument, so everything after it is a handler or + // middleware candidate: echo's verb calls take the FIRST of those, gin's + // the LAST (see FRAMEWORK_ROUTE_PATTERNS / readFrameworkImports). The + // rule is chosen per call: an echo-only file is unambiguous; a + // mixed-import file takes the first argument only when the receiver + // provably traces to echo's constructor (directly or through enclosing + // Group() calls) — everything else keeps the last-argument anchor, + // gin's order and the safer default when the file proves nothing. + const rest = args.slice(1); + if (rest.length === 0) continue; + const echoOrder = mixed + ? receiverNode + ? receiverBindsToEchoConstructor(receiverNode, imports.echo) + : false + : echoOnly; + // A Group chain deeper than MAX_GROUP_DEPTH proves neither the full + // prefix nor the framework order: decline rather than emit a guess. + if (echoOrder === null) continue; + const prefix = receiverNode ? groupPrefix(receiverNode) : ''; + if (prefix === null) continue; + const handlerNode = echoOrder ? rest[0] : rest[rest.length - 1]; + if (!HANDLER_ARG_TYPES.has(handlerNode.type)) continue; + const path = receiverNode ? joinRoutePath(prefix, literalPath) : literalPath; // An inline `func(){…}` handler has no name → emit `name: null` and a // `line` so it resolves to its containing/closure symbol by line-span - // containment (like a consumer). A named identifier handler keeps its - // name and resolves by name; `line` is harmless there. + // containment (like a consumer). A named handler keeps its name and + // resolves by name; `line` is harmless there. For a method value or a + // package-qualified function (`h.List`, `pkg.List`) that name is the + // field, and the operand (usually a local variable, not the receiver + // type) does not prove where `List` is declared — so the detection is + // marked qualifiedHandler: resolve only to a repo-wide unique `List`, + // never to a same-named local method that merely shares the name. const isInlineHandler = handlerNode?.type === 'func_literal'; + const isQualified = handlerNode?.type === 'selector_expression'; + const handlerName = isQualified + ? (handlerNode.childForFieldName('field')?.text ?? null) + : (handlerNode?.text ?? null); out.push({ role: 'provider', framework: 'go-framework', method: methodNode.text.toUpperCase(), path, - name: isInlineHandler ? null : (handlerNode?.text ?? null), + name: isInlineHandler ? null : handlerName, line: (handlerNode ?? pathNode).startPosition.row + 1, confidence: 0.8, + ...(isQualified ? { qualifiedHandler: true } : {}), + // A bare name declared more than once in this file (a function and a + // method of the same name) resolves only when the file holds exactly + // one match, rather than binding to whichever row the graph lists first. + ...(!isQualified && + !isInlineHandler && + handlerName && + (declaredNames.get(handlerName) ?? 0) > 1 + ? { strictHandlerResolution: true } + : {}), }); } @@ -164,7 +856,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { const pathNode = match.captures.path; const handlerNode = match.captures.handler; if (!pathNode) continue; - const path = unquoteLiteral(pathNode.text); + const path = stringLiteral(pathNode); if (path === null) continue; // Inline `func(){…}` handler → resolve by containment (see go-framework // note above); a named handler resolves by name. @@ -187,7 +879,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { if (!fnNode || !pathNode) continue; const httpMethod = HTTP_CLIENT_METHOD_TO_HTTP[fnNode.text]; if (!httpMethod) continue; - const path = unquoteLiteral(pathNode.text); + const path = stringLiteral(pathNode); if (path === null) continue; out.push({ role: 'consumer', @@ -205,8 +897,8 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { const methodNode = match.captures.http_method; const pathNode = match.captures.path; if (!methodNode || !pathNode) continue; - const method = unquoteLiteral(methodNode.text); - const path = unquoteLiteral(pathNode.text); + const method = stringLiteral(methodNode); + const path = stringLiteral(pathNode); if (method === null || path === null) continue; out.push({ role: 'consumer', @@ -224,7 +916,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { const methodNode = match.captures.http_method; const pathNode = match.captures.path; if (!methodNode || !pathNode) continue; - const path = unquoteLiteral(pathNode.text); + const path = stringLiteral(pathNode); if (path === null) continue; out.push({ role: 'consumer', diff --git a/gitnexus/src/core/group/extractors/http-patterns/types.ts b/gitnexus/src/core/group/extractors/http-patterns/types.ts index 38c197704..bbd3fe1a2 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/types.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/types.ts @@ -58,6 +58,14 @@ export interface HttpDetection { handlerImport?: { name: string; module: string }; /** Resolve only from the registration file or exact import target; never guess repo-wide. */ strictHandlerResolution?: boolean; + /** + * The handler was designated through a qualifier the plugin cannot tie to a + * declaration (`recv.name`, `pkg.name`): `name` alone does not prove the + * handler lives in the registration file. Skip the file-scoped name lookup + * — a same-named but unrelated local symbol would win it — and accept only + * a repo-wide unique match, else keep the file-level fallback. + */ + qualifiedHandler?: boolean; /** * The plugin saw a provider handler designator but could not prove its owner. * Prevents the orchestrator from treating it as an anonymous inline handler diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index d116bc046..707ccdff7 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -629,11 +629,17 @@ export class HttpRouteExtractor implements ContractExtractor { if (d.strictHandlerResolution) return null; return resolveSymbolByNameUnique(d.handlerImport.name); } - const byName = d.strictHandlerResolution - ? resolveFileSymbolByNameUnique(syms, d.name) - : resolveSymbolByName(syms, d.name); - if (byName) return byName; - if (d.strictHandlerResolution) return null; + // A qualified designator (`recv.name`) does not prove the handler is + // declared in this file, so the file-first rung could bind an + // unrelated same-named local symbol: go straight to the unique + // repo-wide match. + if (!d.qualifiedHandler) { + const byName = d.strictHandlerResolution + ? resolveFileSymbolByNameUnique(syms, d.name) + : resolveSymbolByName(syms, d.name); + if (byName) return byName; + if (d.strictHandlerResolution) return null; + } const byGlobal = await resolveSymbolByNameUnique(d.name); if (byGlobal) return byGlobal; // A NAMED handler we could not resolve by name (neither file-scoped nor diff --git a/gitnexus/src/core/incremental/write-reconciliation.ts b/gitnexus/src/core/incremental/write-reconciliation.ts new file mode 100644 index 000000000..bbd717e0f --- /dev/null +++ b/gitnexus/src/core/incremental/write-reconciliation.ts @@ -0,0 +1,107 @@ +import type { GraphNode } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../graph/types.js'; +import { NODE_TABLES, type NodeTableName } from '../lbug/schema.js'; +import { sanitizeUTF8 } from '../lbug/csv-generator.js'; + +// These layers need a different oracle: folders can outlive their last file, +// derived nodes may be preserved without recomputation, and PDG can be streamed. +const EXCLUDED = new Set(['Folder', 'Community', 'Process', 'BasicBlock']); +const WITHOUT_RANGE = new Set(['File', 'Route', 'Tool']); + +interface IdentityRow { + id: string; + name: string | null; + filePath: string | null; + startLine?: number | null; + endLine?: number | null; +} + +const storedString = (value: string | undefined): string => sanitizeUTF8(value || ''); + +const storedIdentityField = (node: GraphNode, field: string): unknown => { + const value = node.properties[field]; + if (field === 'name' || field === 'filePath') { + return storedString(value as string | undefined); + } + if (node.label === 'Destination') { + return typeof value === 'number' && Number.isFinite(value) ? value : null; + } + return value ?? -1; +}; + +/** + * Certify the identity fields used by context/impact/detect_changes, including + * retained rows. A write-set-only probe misses corruption of an unchanged row. + * Scan one label at a time, without path/range predicates: a corrupt field must + * not be able to hide its own row. No multi-label scans (#3139), N+1 ID lookups, + * source-content copies, or whole-graph row materialization. + * + * This certifies node identity, not relationships or native storage internals. + * Call after COPY/checkpoint and after FTS/embedding/checkpoint, before metadata. + */ +export async function reconcileGraphNodeIdentities( + graph: KnowledgeGraph, + query: (cypher: string) => Promise, + phase: string, +): Promise<{ nodes: number; tables: number }> { + const expected = new Map>(); + for (const table of NODE_TABLES) { + if (!EXCLUDED.has(table)) expected.set(table, new Map()); + } + for (const node of graph.iterNodes()) { + const table = expected.get(node.label as NodeTableName); + if (!table) continue; + const id = storedString(node.id); + if (!id || table.has(id)) { + throw new Error(`Graph identity reconciliation (${phase}): invalid or duplicate ID ${id}`); + } + table.set(id, node); + } + + let nodes = 0; + for (const [table, remaining] of expected) { + const fields = WITHOUT_RANGE.has(table) + ? ['id', 'name', 'filePath'] + : ['id', 'name', 'filePath', 'startLine', 'endLine']; + const identityFields = fields.slice(1); + const fail: (detail: string, cause?: unknown) => never = (detail, cause) => { + throw new Error( + `Graph identity reconciliation failed (${phase}, ${table}): ${detail}. ` + + 'Freshness was not advanced; run `gitnexus analyze --force` to rebuild the graph.', + { cause }, + ); + }; + let rows: IdentityRow[]; + try { + rows = await query( + `MATCH (n:\`${table}\`) RETURN ${fields.map((f) => `n.${f} AS ${f}`).join(', ')}`, + ); + } catch (error) { + fail( + `could not read identity fields: ${error instanceof Error ? error.message : String(error)}`, + error, + ); + } + for (const row of rows) { + const node = remaining.get(row.id); + if (!node) fail(`unexpected or duplicate ID ${JSON.stringify(row.id)}`); + for (const field of identityFields) { + const wanted = storedIdentityField(node, field); + const actual = field === 'name' || field === 'filePath' ? (row[field] ?? '') : row[field]; + if (actual !== wanted) { + fail( + `${row.id}.${field}: expected ${JSON.stringify(wanted)}, read ${JSON.stringify(actual)}`, + ); + } + } + remaining.delete(row.id); + nodes++; + } + if (remaining.size) { + fail( + `${remaining.size} missing ID(s), including ${JSON.stringify(remaining.keys().next().value)}`, + ); + } + } + return { nodes, tables: expected.size }; +} diff --git a/gitnexus/src/core/ingestion/cobol-processor.ts b/gitnexus/src/core/ingestion/cobol-processor.ts index 3fac5072d..6fae16805 100644 --- a/gitnexus/src/core/ingestion/cobol-processor.ts +++ b/gitnexus/src/core/ingestion/cobol-processor.ts @@ -26,6 +26,8 @@ import { import { expandCopies } from './cobol/cobol-copy-expander.js'; import { processJclFiles } from './cobol/jcl-processor.js'; import { resolveCobolCopyTarget } from './languages/cobol/copy-target.js'; +import { COBOL_EXTENSIONS, JCL_EXTENSIONS } from './cobol/file-types.js'; +export { isCobolFile, isJclFile } from './cobol/file-types.js'; import { logger } from '../logger.js'; @@ -33,10 +35,6 @@ import { logger } from '../logger.js'; // File detection // --------------------------------------------------------------------------- -const COBOL_EXTENSIONS = new Set(['.cob', '.cbl', '.cobol', '.cpy', '.copybook']); - -const JCL_EXTENSIONS = new Set(['.jcl', '.job', '.proc']); - const COPYBOOK_EXTENSIONS = new Set(['.cpy', '.copybook']); interface CobolFile { @@ -67,16 +65,6 @@ export interface CobolProcessResult { arithmeticOps: number; } -/** Returns true if the file is a COBOL or copybook file. */ -export function isCobolFile(filePath: string): boolean { - return COBOL_EXTENSIONS.has(path.extname(filePath).toLowerCase()); -} - -/** Returns true if the file is a JCL file. */ -export function isJclFile(filePath: string): boolean { - return JCL_EXTENSIONS.has(path.extname(filePath).toLowerCase()); -} - /** Returns true if the file is a COBOL copybook. */ function isCopybook(filePath: string): boolean { return COPYBOOK_EXTENSIONS.has(path.extname(filePath).toLowerCase()); diff --git a/gitnexus/src/core/ingestion/cobol/file-types.ts b/gitnexus/src/core/ingestion/cobol/file-types.ts new file mode 100644 index 000000000..a41d622ad --- /dev/null +++ b/gitnexus/src/core/ingestion/cobol/file-types.ts @@ -0,0 +1,15 @@ +import path from 'node:path'; + +// Keep detection shared without loading the analyze-only language providers. +export const COBOL_EXTENSIONS = new Set(['.cob', '.cbl', '.cobol', '.cpy', '.copybook']); +export const JCL_EXTENSIONS = new Set(['.jcl', '.job', '.proc']); + +/** Includes COBOL programs and copybooks accepted by ingestion. */ +export function isCobolFile(filePath: string): boolean { + return COBOL_EXTENSIONS.has(path.extname(filePath).toLowerCase()); +} + +/** Includes job and procedure aliases accepted by JCL ingestion. */ +export function isJclFile(filePath: string): boolean { + return JCL_EXTENSIONS.has(path.extname(filePath).toLowerCase()); +} diff --git a/gitnexus/src/core/ingestion/community-processor.ts b/gitnexus/src/core/ingestion/community-processor.ts index 77b66d73b..17b90d03a 100644 --- a/gitnexus/src/core/ingestion/community-processor.ts +++ b/gitnexus/src/core/ingestion/community-processor.ts @@ -29,12 +29,9 @@ const _require = createRequire(import.meta.url); /** Graphology Graph instance type (AbstractGraph from graphology-types avoids CJS/ESM interop namespace issue) */ type GraphInstance = AbstractGraph; -const leiden: LeidenModule = _require(leidenPath); - -/** Vendored Leiden algorithm module shape */ -interface LeidenModule { - detailed: (graph: GraphInstance, options: Record) => LeidenDetailedResult; -} +// The Leiden worker loads its own copy; this load keeps a missing vendor/ asset +// failing at import time and keeps it visible to dockerfile-runtime-asset-parity. +_require(leidenPath); /** Result returned by leiden.detailed() */ interface LeidenDetailedResult { @@ -110,15 +107,6 @@ interface IcebugWorkerFailure { * incremental-indexing equivalence test (incremental ≡ full rebuild). */ const LEIDEN_SEED = 0xc0de; -function createSeededRng(seed: number): () => number { - let s = seed >>> 0; - return () => { - s = (s + 0x6d2b79f5) >>> 0; - let t = Math.imul(s ^ (s >>> 15), 1 | s); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} const COMMUNITY_ENGINE_ENV = 'GITNEXUS_COMMUNITY_ENGINE'; /** @@ -176,7 +164,10 @@ export interface CommunityMembership { export interface CommunityDetectionResult { communities: CommunityNode[]; + /** Assignments to retained communities, safe to emit as MEMBER_OF edges. */ memberships: CommunityMembership[]; + /** All Leiden assignments, including filtered singletons. Optional for legacy producers. */ + rawMemberships?: CommunityMembership[]; stats: { totalCommunities: number; modularity: number; @@ -235,6 +226,7 @@ export const processCommunities = async ( return { communities: [], memberships: [], + rawMemberships: [], stats: { totalCommunities: 0, modularity: 0, @@ -267,13 +259,16 @@ export const processCommunities = async ( onProgress?.('Creating membership edges...', 80); - // Step 4: Create membership mappings + // Step 4: Preserve all assignments for skill generation, but only emit + // memberships for retained communities with a corresponding graph node. + const retainedCommunityIds = new Set(communityNodes.map((community) => community.id)); const memberships: CommunityMembership[] = []; + const rawMemberships: CommunityMembership[] = []; Object.entries(details.communities).forEach(([nodeId, communityNum]) => { - memberships.push({ - nodeId, - communityId: `comm_${communityNum}`, - }); + const communityId = `comm_${communityNum}`; + const membership = { nodeId, communityId }; + rawMemberships.push(membership); + if (retainedCommunityIds.has(communityId)) memberships.push(membership); }); onProgress?.('Community detection complete!', 100); @@ -281,6 +276,7 @@ export const processCommunities = async ( return { communities: communityNodes, memberships, + rawMemberships, stats: { totalCommunities: details.count, modularity: details.modularity, @@ -466,28 +462,101 @@ const runCommunityEngine = async ( 35, ); const fallback = await runGraphologyLeiden(graph, projection.isLarge, engineRequested); - return { ...fallback, fallbackReason }; + return { + ...fallback, + fallbackReason: fallback.fallbackReason + ? `${fallbackReason}; ${fallback.fallbackReason}` + : fallbackReason, + }; } }; -const runGraphologyLeiden = async ( +/** + * Runs the vendored Leiden in a worker so LEIDEN_TIMEOUT_MS can actually fire. + * leiden.detailed is synchronous and, on some graphs, never returns + * (graphology/graphology#557); a timer on the main thread cannot interrupt it. + * The worker is pure JS, so terminate() is safe (unlike the icebug N-API worker). + */ +const GRAPHOLOGY_WORKER_SOURCE = ` +const { parentPort, workerData } = require('node:worker_threads'); +try { + const Graph = require(workerData.graphologyPath); + const leiden = require(workerData.leidenPath); + const graph = Graph.from(workerData.graph); + const rng = (() => { + let s = workerData.seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + })(); + const d = leiden.detailed(graph, { + resolution: workerData.resolution, + maxIterations: workerData.maxIterations, + rng, + }); + parentPort.postMessage({ + ok: true, + communities: d.communities, + count: d.count, + modularity: d.modularity, + }); +} catch (error) { + parentPort.postMessage({ ok: false, error: error instanceof Error ? error.message : String(error) }); +} +`; + +export const runGraphologyLeiden = async ( graph: GraphInstance, isLarge: boolean, engineRequested: CommunityDetectionEngine, + timeoutMs: number = LEIDEN_TIMEOUT_MS, ): Promise => { try { - const details = await Promise.race([ - Promise.resolve( - leiden.detailed(graph, { + const details = await new Promise((resolve, reject) => { + const worker = new Worker(GRAPHOLOGY_WORKER_SOURCE, { + eval: true, + workerData: { + graphologyPath: _require.resolve('graphology'), + leidenPath, + graph: graph.export(), + seed: LEIDEN_SEED, resolution: isLarge ? 2.0 : 1.0, maxIterations: isLarge ? 3 : 0, - rng: createSeededRng(LEIDEN_SEED), - }), - ), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Leiden timeout')), LEIDEN_TIMEOUT_MS), - ), - ]); + }, + }); + let settled = false; + const timeout = setTimeout(() => { + settled = true; + void worker.terminate(); + reject(new Error('Leiden timeout')); + }, timeoutMs); + worker.once('message', (message) => { + settled = true; + clearTimeout(timeout); + if (message.ok === true) { + resolve({ + communities: message.communities, + count: message.count, + modularity: message.modularity, + }); + } else { + reject(new Error(message.error)); + } + }); + worker.once('error', (error) => { + settled = true; + clearTimeout(timeout); + reject(error); + }); + worker.once('exit', (code) => { + if (settled) return; + clearTimeout(timeout); + reject(new Error(`Graphology Leiden worker exited with code ${code}`)); + }); + }); return { ...details, engine: 'graphology', engineRequested }; } catch (e: any) { if (e.message !== 'Leiden timeout') { diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 152957ff0..4ff29a453 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -48,7 +48,7 @@ import type { RepoConstants, } from './route-extractors/constant-resolver.js'; import type Parser from 'tree-sitter'; -import type { ExtractedDecoratorRoute } from './workers/parse-worker.js'; +import type { ExtractedDecoratorRoute, ExtractedToolDef } from './workers/parse-worker.js'; import type { SemanticModel } from './model/semantic-model.js'; /** What a provider's {@link LanguageProviderConfig.resolveRouteHandler} can see. */ @@ -568,6 +568,15 @@ interface LanguageProviderConfig { */ readonly extractTextRoutes?: (filePath: string, content: string) => ExtractedRoute[]; + /** Extract tool registrations after captures, using only emitted callable identities. + * The map keys are declaration-name AST node IDs, local to this parsed tree. */ + readonly extractToolDefinitions?: ( + tree: Parser.Tree, + filePath: string, + lineOffset: number, + callableBindings: ReadonlyMap, + ) => ExtractedToolDef[]; + /** * Extract routes that a parsed file declares in its own AST. * diff --git a/gitnexus/src/core/ingestion/languages/dart/package-config.ts b/gitnexus/src/core/ingestion/languages/dart/package-config.ts index 4503a57bd..2040d6d94 100644 --- a/gitnexus/src/core/ingestion/languages/dart/package-config.ts +++ b/gitnexus/src/core/ingestion/languages/dart/package-config.ts @@ -39,6 +39,11 @@ export interface DartPackageConfigOptions { * is listed and before its entries are opened. */ readonly beforeEntryOpen?: (relativePath: string) => void | Promise; + /** + * Test seam. Production calls omit it. Invoked after the directory is + * opened and before it is listed. + */ + readonly beforeDirectoryList?: (relativePath: string) => void | Promise; } type ManifestRead = @@ -123,12 +128,13 @@ function directoryIdentity(stat: BigIntStats): string { /** * Path that lists the directory inode already open on `fd`. - * Linux uses `/proc/self/fd/N` and macOS uses `/dev/fd/N`. - * Other platforms have no such path; callers refuse instead of listing by name. + * Only Linux has one: `/proc/self/fd/N`. macOS refuses `opendir` on + * `/dev/fd/N` for a directory (ENOTDIR) and Node has no `fdopendir`, so the + * walker lists the lexical path and verifies the pinned chain around it. + * Every other platform returns null and is not walked. */ export function descriptorDirectoryPath(fd: number): string | null { if (process.platform === 'linux') return `/proc/self/fd/${fd}`; - if (process.platform === 'darwin') return `/dev/fd/${fd}`; return null; } @@ -264,11 +270,33 @@ async function openChildFile( throw Object.assign(new Error('no descriptor anchor'), { code: 'ENOTSUP' }); } -async function listOpenedDirectory(handle: FileHandle, entryLimit: number): Promise { - const listing = descriptorDirectoryPath(handle.fd); - if (listing === null) { +/** + * List the directory open on the last frame of `frames`. Linux lists the + * pinned inode through its descriptor. macOS re-checks the pinned chain, lists + * the lexical path, then re-checks the chain, so a path replaced around the + * listing fails the walk instead of being listed. + */ +async function listOpenedDirectory( + frames: readonly WalkFrame[], + entryLimit: number, + beforeList?: () => void | Promise, +): Promise { + const frame = frames[frames.length - 1]; + if (frame === undefined) { + throw Object.assign(new Error('no directory to list'), { code: 'EINVAL' }); + } + const anchored = descriptorDirectoryPath(frame.handle.fd); + if (anchored === null && process.platform !== 'darwin') { throw Object.assign(new Error('no descriptor listing'), { code: 'ENOTSUP' }); } + if (anchored === null) await assertPinnedChain(frames); + if (beforeList) await beforeList(); + const entries = await readDirectoryBounded(anchored ?? frame.absolute, entryLimit); + if (anchored === null) await assertPinnedChain(frames); + return entries; +} + +async function readDirectoryBounded(listing: string, entryLimit: number): Promise { const dir = await opendir(listing); const entries: Dirent[] = []; try { @@ -295,8 +323,16 @@ async function listOpenedDirectory(handle: FileHandle, entryLimit: number): Prom */ export async function readDirectoryNoFollow(directory: string): Promise { const opened = await openVerifiedDirectory(directory); + const frame: WalkFrame = { + relative: '', + absolute: directory, + handle: opened.handle, + identity: opened.identity, + entries: [], + next: 0, + }; try { - return await listOpenedDirectory(opened.handle, DART_PUBSPEC_DIRECTORY_ENTRY_LIMIT); + return await listOpenedDirectory([frame], DART_PUBSPEC_DIRECTORY_ENTRY_LIMIT); } finally { await opened.handle.close(); } @@ -345,7 +381,12 @@ export async function loadDartPackageConfig( const fillFrame = async (frame: WalkFrame): Promise => { if (++visited > directoryLimit) return incomplete('directory-limit', frame.relative || '.'); try { - frame.entries = await listOpenedDirectory(frame.handle, directoryEntryLimit); + const beforeList = options?.beforeDirectoryList; + frame.entries = await listOpenedDirectory( + stack, + directoryEntryLimit, + beforeList ? () => beforeList(frame.relative) : undefined, + ); } catch (error) { const reason = (error as NodeJS.ErrnoException).code === 'E2BIG' ? 'directory-entries' : 'read-directory'; diff --git a/gitnexus/src/core/ingestion/languages/swift/callable-visibility.ts b/gitnexus/src/core/ingestion/languages/swift/callable-visibility.ts new file mode 100644 index 000000000..f89b2b639 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/swift/callable-visibility.ts @@ -0,0 +1,78 @@ +import type { ParsedFile, ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { findClassBindingInScope } from '../../scope-resolution/scope/walkers.js'; + +export function swiftIsCallableVisibleFromCaller(ctx: { + readonly candidate: SymbolDefinition; + readonly callerParsed?: ParsedFile; + readonly callArity?: number; + readonly callerScope?: ScopeId; + readonly scopes?: ScopeResolutionIndexes; +}): boolean { + const indexes = ctx.scopes; + if (ctx.callerScope === undefined || indexes === undefined || ctx.callArity !== 0) return true; + + const name = ctx.candidate.qualifiedName?.split('.').at(-1); + if (name === undefined) return true; + + let scopeId: ScopeId | null = ctx.callerScope; + let selfType: TypeRef | undefined; + while (scopeId !== null) { + const scope = indexes.scopeTree.getScope(scopeId); + if (scope === undefined) break; + // A local function selected inside the caller wins before member lookup. + if ( + scope.kind !== 'Class' && + scope.ownedDefs.some((def) => def.nodeId === ctx.candidate.nodeId) + ) + return true; + selfType ??= scope.typeBindings.get('self'); + if (scope.kind === 'Class') { + const classDef = + scope.ownedDefs.find((def) => def.type === 'Class') ?? + (selfType === undefined + ? undefined + : findClassBindingInScope(ctx.callerScope, selfType.rawName, indexes, undefined, { + uniqueQualifiedNameFallback: false, + })); + const propertyOnOwner = (ownerId: string, ownerName: string): boolean => + indexes.qualifiedNames.get(`${ownerName}.${name}`).some((defId) => { + const def = indexes.defs.get(defId); + return def?.type === 'Property' && def.ownerId === ownerId; + }); + const ownProperty = + scope.ownedDefs.some( + (def) => def.type === 'Property' && def.qualifiedName?.split('.').at(-1) === name, + ) || + (classDef !== undefined && + classDef.qualifiedName !== undefined && + propertyOnOwner(classDef.nodeId, classDef.qualifiedName)); + const inheritedProperty = + classDef !== undefined && + indexes.methodDispatch.mroFor(classDef.nodeId).some((ownerId) => { + const ownerName = indexes.defs.get(ownerId)?.qualifiedName; + return ownerName !== undefined && propertyOnOwner(ownerId, ownerName); + }); + if (!ownProperty && !inheritedProperty) return true; + + // Swift's nested function is owned by its own Function scope. A + // same-file sibling must not count as a nearer lexical binding. + const declarationScope = ctx.callerParsed?.scopes.find((candidateScope) => + candidateScope.ownedDefs.some((def) => def.nodeId === ctx.candidate.nodeId), + ); + if ( + declarationScope !== undefined && + declarationScope.kind === 'Function' && + (declarationScope.id === ctx.callerScope || + indexes.scopeTree.getAncestors(declarationScope.id).includes(ctx.callerScope)) + ) + return true; + + // Scope defs do not carry Swift access modifiers. A method positively + // selected on this type must not be vetoed by an uncertain ancestor. + return classDef !== undefined && ctx.candidate.ownerId === classDef.nodeId; + } + scopeId = scope.parent; + } + return true; +} diff --git a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts index 0822b8cf8..7fe075abf 100644 --- a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts @@ -70,6 +70,7 @@ import { import { stripSwiftTypePreservingDecoration } from './interpret.js'; import { groupSwiftFilesByModule } from './target-grouping.js'; import { swiftIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js'; +import { swiftIsCallableVisibleFromCaller } from './callable-visibility.js'; const ZERO_RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const; @@ -155,6 +156,7 @@ const swiftScopeResolver: ScopeResolver = { // no-`new` constructor + cross-file free-call shape). allowGlobalFreeCallFallback: true, isGlobalNameFallbackPlausible: swiftIsGlobalNameFallbackPlausible, + isCallableVisibleFromCaller: swiftIsCallableVisibleFromCaller, // Swift's call graph models `Type(...)` as a reference to the type // itself, not its `init` — both the legacy DAG and this test suite link diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 5251910cb..c3509ea5e 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -130,6 +130,7 @@ import { extractDataRouteTableRoutes } from '../route-extractors/data-route-tabl import { extractNestRoutes } from '../route-extractors/nest.js'; import { extractTrpcRoutes, shouldScanForTrpcRoutes } from '../route-extractors/trpc.js'; import { extractConvexEndpointProperties } from './typescript/convex-endpoint-metadata.js'; +import { extractToolDefinitions } from './typescript/tool-definitions.js'; const extractJsTsRoutes = (...args: Parameters) => [ ...extractDispatchGuardRoutes(...args), @@ -494,6 +495,7 @@ export const typescriptProvider = defineLanguage({ // Content-based (not AST): tRPC procedure routers are scanned from source text. // Path-gate lives here (language provider), not in the shared parse worker. extractTextRoutes: extractJsTsTextRoutes, + extractToolDefinitions, }); export const javascriptProvider = defineLanguage({ @@ -577,4 +579,5 @@ export const javascriptProvider = defineLanguage({ extractDecoratorRoutes: extractJsTsRoutes, // Content-based (not AST): tRPC procedure routers are scanned from source text. extractTextRoutes: extractJsTsTextRoutes, + extractToolDefinitions, }); diff --git a/gitnexus/src/core/ingestion/languages/typescript/tool-definitions.ts b/gitnexus/src/core/ingestion/languages/typescript/tool-definitions.ts new file mode 100644 index 000000000..307998c56 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/tool-definitions.ts @@ -0,0 +1,365 @@ +import type Parser from 'tree-sitter'; +import type { SyntaxNode } from 'tree-sitter'; +import type { ExtractedToolDef } from '../../workers/parse-worker.js'; +import { plainString, propertyName } from '../../route-extractors/data-route-table.js'; + +interface Scope { + parent?: Scope; + functionScope: boolean; + bindings: Map; +} + +interface Binding { + name: SyntaxNode; + scope: Scope; + kind: 'unknown' | 'sdk' | 'sdk-namespace' | 'variable' | 'parameter' | 'function'; + value?: SyntaxNode; + type?: SyntaxNode; + typeOnly?: boolean; + immutable?: boolean; + invalid?: boolean; +} + +const FUNCTIONS = new Set([ + 'function_declaration', + 'generator_function_declaration', + 'function_expression', + 'generator_function', + 'arrow_function', + 'method_definition', +]); +const BLOCKS = new Set([ + 'statement_block', + 'for_statement', + 'for_in_statement', + 'switch_body', + 'catch_clause', + 'class_body', +]); +const SDK_MODULES = new Set([ + '@modelcontextprotocol/sdk/server/mcp.js', + '@modelcontextprotocol/sdk/server/mcp', +]); + +function lookup(scope: Scope, name: string): Binding | undefined { + for (let current: Scope | undefined = scope; current; current = current.parent) { + const binding = current.bindings.get(name); + if (binding) return binding; + } +} + +/** Only binding/assignment patterns: never descend into keys, types or defaults. + * Member assignment targets are reported separately from binding names. */ +function patternNames(pattern: SyntaxNode, onMember?: (member: SyntaxNode) => void): SyntaxNode[] { + const names: SyntaxNode[] = []; + const pending = [pattern]; + while (pending.length) { + const node = pending.pop()!; + if (node.type === 'identifier' || node.type === 'shorthand_property_identifier_pattern') { + names.push(node); + } else if (node.type === 'member_expression' || node.type === 'subscript_expression') { + onMember?.(node); + } else if (node.type === 'pair_pattern') { + const value = node.childForFieldName('value'); + if (value) pending.push(value); + } else if (node.type === 'assignment_pattern' || node.type === 'object_assignment_pattern') { + const left = node.childForFieldName('left'); + if (left) pending.push(left); + } else if ( + node.type === 'array_pattern' || + node.type === 'object_pattern' || + node.type === 'rest_pattern' + ) { + pending.push(...node.namedChildren); + } + } + return names; +} + +function declare(scope: Scope, name: SyntaxNode, details: Partial = {}): void { + const previous = scope.bindings.get(name.text); + if (previous) { + previous.invalid = true; + } else { + scope.bindings.set(name.text, { name, scope, kind: 'unknown', ...details }); + } +} + +function variableScope(scope: Scope): Scope { + while (!scope.functionScope && scope.parent) scope = scope.parent; + return scope; +} + +function collectBindings(root: SyntaxNode) { + const moduleScope: Scope = { functionScope: true, bindings: new Map() }; + const scopes = new Map(); + const calls: SyntaxNode[] = []; + const writes: SyntaxNode[] = []; + const pending = [{ node: root, scope: moduleScope }]; + while (pending.length) { + const entry = pending.pop()!; + const node = entry.node; + let scope = entry.scope; + const isFunction = FUNCTIONS.has(node.type); + const name = node.childForFieldName('name'); + if (node.type === 'function_declaration' || node.type === 'generator_function_declaration') { + if (name) declare(scope, name, { kind: 'function', value: node, immutable: true }); + } else if ( + [ + 'class_declaration', + 'interface_declaration', + 'type_alias_declaration', + 'enum_declaration', + ].includes(node.type) + ) { + if (name) declare(scope, name); + } + if ( + isFunction || + BLOCKS.has(node.type) || + node.type === 'class' || + node.type === 'class_declaration' + ) { + scope = { parent: scope, functionScope: isFunction, bindings: new Map() }; + } + scopes.set(node.id, scope); + + if (node.type === 'class' && name) declare(scope, name); + + if (isFunction) { + if (name && (node.type === 'function_expression' || node.type === 'generator_function')) { + declare(scope, name, { kind: 'function', value: node, immutable: true }); + } + const parameters = node.childForFieldName('parameters'); + const single = node.childForFieldName('parameter'); + for (const parameter of parameters?.namedChildren ?? (single ? [single] : [])) { + const pattern = parameter.childForFieldName('pattern') ?? parameter; + const type = parameter.childForFieldName('type')?.namedChildren[0]; + for (const bindingName of patternNames(pattern)) { + declare(scope, bindingName, { + kind: 'parameter', + ...(pattern.type === 'identifier' && type ? { type } : {}), + }); + } + } + } else if (node.type === 'import_statement') { + const source = node.childForFieldName('source'); + const sdk = source !== null && SDK_MODULES.has(plainString(source) ?? ''); + const typeOnly = node.children.some((child) => child.type === 'type'); + const clause = node.namedChildren.find((child) => child.type === 'import_clause'); + for (const child of clause?.namedChildren ?? []) { + if (child.type === 'identifier') declare(scope, child); + else if (child.type === 'namespace_import') { + const local = child.namedChildren[0]; + if (local) declare(scope, local, { kind: sdk ? 'sdk-namespace' : 'unknown', typeOnly }); + } else if (child.type === 'named_imports') { + for (const specifier of child.namedChildren) { + const imported = specifier.childForFieldName('name'); + const local = specifier.childForFieldName('alias') ?? imported; + if (local) + declare(scope, local, { + kind: sdk && imported?.text === 'McpServer' ? 'sdk' : 'unknown', + typeOnly: typeOnly || specifier.children.some((part) => part.type === 'type'), + }); + } + } + } + } else if (node.type === 'variable_declarator') { + if (name) { + const target = node.parent?.type === 'variable_declaration' ? variableScope(scope) : scope; + for (const bindingName of patternNames(name)) { + declare(target, bindingName, { + kind: 'variable', + immutable: node.parent?.childForFieldName('kind')?.type === 'const', + ...(name.type === 'identifier' + ? { value: node.childForFieldName('value') ?? undefined } + : {}), + }); + } + } + } else if (node.type === 'catch_clause') { + const parameter = node.childForFieldName('parameter'); + if (parameter) for (const bindingName of patternNames(parameter)) declare(scope, bindingName); + } else if (node.type === 'for_in_statement') { + const left = node.childForFieldName('left'); + const kind = node.childForFieldName('kind'); + if (left && kind) { + const target = kind.type === 'var' ? variableScope(scope) : scope; + for (const bindingName of patternNames(left)) declare(target, bindingName); + } else if (left) writes.push(left); + } else if (node.type === 'type_parameter') { + if (name) declare(scope, name); + } + if (node.type === 'call_expression') calls.push(node); + if (node.type === 'assignment_expression' || node.type === 'augmented_assignment_expression') { + const left = node.childForFieldName('left'); + if (left) writes.push(left); + } else if ( + node.type === 'update_expression' || + (node.type === 'unary_expression' && node.children.some((child) => child.type === 'delete')) + ) { + const argument = node.childForFieldName('argument'); + if (argument) writes.push(argument); + } + for (let index = node.namedChildCount - 1; index >= 0; index--) { + pending.push({ node: node.namedChild(index)!, scope }); + } + } + // Resolve writes after declarations so later declarations also shadow outer names. + while (writes.length) { + let target = writes.pop()!; + const scope = scopes.get(target.id)!; + const members: Array = []; + while (target.type === 'member_expression' || target.type === 'subscript_expression') { + const object = target.childForFieldName('object'); + if (!object) break; + const property = target.childForFieldName('property'); + const index = target.childForFieldName('index'); + members.push(property ? propertyName(property) : index ? plainString(index) : null); + target = object; + } + // Nested member targets must pass the same guard as direct property writes. + for (const name of patternNames(target, (member) => writes.push(member))) { + const binding = lookup(scope, name.text); + // Lifecycle callbacks and other known properties do not replace the receiver + // or its registration methods. Unknown keys and constructor mutations remain unsafe. + if ( + binding?.kind !== 'sdk' && + binding?.kind !== 'sdk-namespace' && + members.length > 0 && + members.every((member) => member !== null) && + !['tool', 'registerTool', '__proto__'].includes(members[members.length - 1]!) + ) + continue; + if (binding) binding.invalid = true; + } + } + return { scopes, calls }; +} + +function sdkBinding(node: SyntaxNode, scope: Scope, forType = false): boolean { + let kind: Binding['kind'] = 'sdk'; + if (node.type === (forType ? 'nested_type_identifier' : 'member_expression')) { + const namespace = node.childForFieldName(forType ? 'module' : 'object'); + const member = node.childForFieldName(forType ? 'name' : 'property'); + if (namespace?.type !== 'identifier' || member?.text !== 'McpServer') return false; + node = namespace; + kind = 'sdk-namespace'; + } + if (node.type !== 'identifier' && node.type !== 'type_identifier') return false; + const binding = lookup(scope, node.text); + return binding?.kind === kind && !binding.invalid && (forType || !binding.typeOnly); +} + +function sdkReceiver(node: SyntaxNode, scope: Scope, scopes: ReadonlyMap): boolean { + if (node.type !== 'identifier') return false; + const binding = lookup(scope, node.text); + if (!binding || binding.invalid) return false; + if (binding.kind === 'parameter' && binding.type) { + return sdkBinding(binding.type, binding.scope, true); + } + const value = binding.value; + if (binding.kind !== 'variable' || value?.type !== 'new_expression') return false; + if (value.endIndex > node.startIndex && variableScope(binding.scope) === variableScope(scope)) + return false; + const constructor = value.childForFieldName('constructor'); + return constructor !== null && sdkBinding(constructor, scopes.get(value.id)!); +} + +/** An unknown later property can replace description; a later explicit property restores proof. */ +function descriptionFromConfig(config: SyntaxNode): string { + let description = ''; + if (config.type !== 'object') return description; + for (const child of config.namedChildren) { + if (child.type === 'comment') continue; + const key = child.childForFieldName('key') ?? child.childForFieldName('name'); + const name = + child.type === 'shorthand_property_identifier' ? child.text : key && propertyName(key); + if (child.type === 'pair' && name === 'description') { + const value = child.childForFieldName('value'); + description = value ? (plainString(value) ?? '') : ''; + } else if (!name || name === 'description') { + description = ''; + } + } + return description; +} + +function handlerNodeId( + node: SyntaxNode, + scope: Scope, + callableBindings: ReadonlyMap | undefined, +): string | undefined { + if (node.type !== 'identifier') return undefined; + const binding = lookup(scope, node.text); + if (!binding || binding.invalid) return undefined; + if (binding.kind === 'variable') { + const value = binding.value; + if ( + !binding.immutable || + !value || + (value.type !== 'arrow_function' && value.type !== 'function_expression') + ) + return undefined; + if (value.endIndex > node.startIndex && variableScope(binding.scope) === variableScope(scope)) + return undefined; + } else if (binding.kind !== 'function') return undefined; + return callableBindings?.get(binding.name.id); +} + +/** Direct SDK registrations only; no wrapper, alias-chain or runtime-value inference. */ +export function extractToolDefinitions( + tree: Parser.Tree, + filePath: string, + lineOffset = 0, + callableBindings?: ReadonlyMap, +): ExtractedToolDef[] { + // Ordinary files need no lexical walk; every supported receiver originates here. + const importsSdk = tree.rootNode.namedChildren.some((node) => { + if (node.type !== 'import_statement') return false; + const source = node.childForFieldName('source'); + return source !== null && SDK_MODULES.has(plainString(source) ?? ''); + }); + if (!importsSdk) return []; + + const { scopes, calls } = collectBindings(tree.rootNode); + const definitions: ExtractedToolDef[] = []; + for (const call of calls) { + const callee = call.childForFieldName('function'); + if (call.hasError || callee?.type !== 'member_expression') continue; + const receiver = callee.childForFieldName('object'); + const method = callee.childForFieldName('property'); + if ( + !receiver || + method?.type !== 'property_identifier' || + (method.text !== 'registerTool' && method.text !== 'tool') || + !sdkReceiver(receiver, scopes.get(call.id)!, scopes) + ) + continue; + const args = + call + .childForFieldName('arguments') + ?.namedChildren.filter((child) => child.type !== 'comment') ?? []; + if (args.some((arg) => arg.type === 'spread_element')) continue; + if (method.text === 'registerTool' ? args.length !== 3 : args.length < 2 || args.length > 5) + continue; + const toolName = plainString(args[0]); + if (toolName === null) continue; + const description = + method.text === 'registerTool' + ? descriptionFromConfig(args[1]) + : args.length > 2 + ? (plainString(args[1]) ?? '') + : ''; + const handler = handlerNodeId(args[args.length - 1], scopes.get(call.id)!, callableBindings); + definitions.push({ + filePath, + toolName, + description, + lineNumber: call.startPosition.row + 1 + lineOffset, + ...(handler !== undefined ? { handlerNodeId: handler } : {}), + allowFileFallback: false, + }); + } + return definitions; +} diff --git a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts index 6be8839c3..75f8a0b48 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts @@ -323,6 +323,7 @@ export const processesPhase: PipelinePhase = { const toolsByHandlerId = new Map(); const toolsWithoutHandlerByFile = new Map(); for (const td of toolDefs) { + if (!td.handlerNodeId && td.allowFileFallback === false) continue; const key = td.handlerNodeId ?? td.filePath; const targetMap = td.handlerNodeId ? toolsByHandlerId : toolsWithoutHandlerByFile; let list = targetMap.get(key); diff --git a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts index 42bf639d2..cc1c48488 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts @@ -14,7 +14,7 @@ import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; import { getPhaseOutput } from './types.js'; import type { ParseOutput } from './parse.js'; -import { isBladeTemplateFilename } from 'gitnexus-shared'; +import { isTemplateRouteCandidate } from '../utils/template-file.js'; import { nextjsFileToRouteURL, normalizeFetchURL } from '../route-extractors/nextjs.js'; import { expoFileToRouteURL } from '../route-extractors/expo.js'; import { phpFileToRouteURL } from '../route-extractors/php.js'; @@ -41,6 +41,8 @@ import { readFileContents } from '../filesystem-walker.js'; import { isDev } from '../utils/env.js'; import { logger } from '../../logger.js'; +export { isTemplateRouteCandidate } from '../utils/template-file.js'; + const EXPO_NAV_PATTERNS = [ /router\.(push|replace|navigate)\(\s*['"`]([^'"`]+)['"`]/g, /]*href=\s*['"`]([^'"`]+)['"`]/g, @@ -121,17 +123,6 @@ function hasRouteParameters(routeUrl: string): boolean { return /\{[^}]+\}/.test(routeUrl); } -export const isTemplateRouteCandidate = (filePath: string): boolean => { - const normalized = filePath.replace(/\\/g, '/').toLowerCase(); - return ( - normalized.endsWith('.html') || - normalized.endsWith('.htm') || - normalized.endsWith('.ejs') || - normalized.endsWith('.hbs') || - isBladeTemplateFilename(normalized) - ); -}; - export function extractTemplateStaticFetchCalls( filePath: string, content: string, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/tools.ts b/gitnexus/src/core/ingestion/pipeline-phases/tools.ts index 32a0ae71f..296d0a42a 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/tools.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/tools.ts @@ -22,6 +22,7 @@ export interface ToolDef { filePath: string; description: string; handlerNodeId?: string; + allowFileFallback?: false; } export interface ToolsOutput { @@ -51,6 +52,7 @@ export const toolsPhase: PipelinePhase = { filePath: td.filePath, description: td.description, ...(handlerNodeId !== undefined ? { handlerNodeId } : {}), + ...(td.allowFileFallback === false ? { allowFileFallback: false as const } : {}), }); } diff --git a/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts b/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts index f7cdd1c64..125f02c72 100644 --- a/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts +++ b/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts @@ -26,6 +26,7 @@ import type Parser from 'tree-sitter'; import { goImportPackageName } from '../languages/go/import-package-name.js'; import { GoRouteBindings, type GoRouteBinding } from '../languages/go/route-bindings.js'; +import { isEchoImportPath, isGinImportPath, stringLiteral } from './go-shared.js'; import { normalizeExtractedRoutePath } from './route-path.js'; import type { SyntaxNode } from 'tree-sitter'; import type { ExtractedDecoratorRoute, RouteHandlerReceiver } from '../workers/parse-worker.js'; @@ -58,59 +59,6 @@ interface Framework { const FUNCTION_TYPE_LIST = ['function_declaration', 'method_declaration', 'func_literal']; const FUNCTION_TYPES: ReadonlySet = new Set(FUNCTION_TYPE_LIST); -function stringLiteral(node: SyntaxNode | null | undefined): string | null { - if (!node || node.hasError) return null; - const body = node.text.slice(1, -1); - // Go discards carriage returns in raw strings, including CRLF source files. - if (node.type === 'raw_string_literal') return body.replace(/\r/g, ''); - if (node.type !== 'interpreted_string_literal') return null; - if (!body.includes('\\')) return body; - - const simple: Readonly> = { - a: '\x07', - b: '\b', - f: '\f', - n: '\n', - r: '\r', - t: '\t', - v: '\v', - '\\': '\\', - '"': '"', - }; - const chunks: Buffer[] = []; - const tokens = - /\\(?:[abfnrtv\\"]|[0-7]{3}|x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8})|[^\\"\n]+/g; - let consumed = 0; - for (const match of body.matchAll(tokens)) { - if (match.index !== consumed) return null; - const token = match[0]; - consumed += token.length; - if (!token.startsWith('\\')) { - chunks.push(Buffer.from(token)); - } else if (simple[token[1]] !== undefined) { - chunks.push(Buffer.from(simple[token[1]])); - } else { - const octal = /[0-7]/.test(token[1]); - const value = Number.parseInt(token.slice(octal ? 1 : 2), octal ? 8 : 16); - if (octal || token[1] === 'x') { - // Octal and hex escapes encode bytes, not Unicode code points. - if (value > 255) return null; - chunks.push(Buffer.from([value])); - } else { - if (value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) return null; - chunks.push(Buffer.from(String.fromCodePoint(value))); - } - } - } - if (consumed !== body.length) return null; - try { - // Arbitrary non-UTF-8 Go byte strings cannot be represented losslessly in a URL. - return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks)); - } catch { - return null; - } -} - /** The framework this file routes with, when exactly one is imported. */ function readImports(root: SyntaxNode): { readonly framework: Framework | null; @@ -129,8 +77,8 @@ function readImports(root: SyntaxNode): { if (explicit === '_' || explicit === '.') continue; const local = explicit ?? goImportPackageName(importPath); if (!local) continue; - if (importPath === 'github.com/gin-gonic/gin') gin = local; - if (/^github\.com\/labstack\/echo(\/v\d+)?$/.test(importPath)) echo = local; + if (isGinImportPath(importPath)) gin = local; + if (isEchoImportPath(importPath)) echo = local; } // Both, or neither: no way to tell which argument is the handler. if (gin !== null && echo === null) { diff --git a/gitnexus/src/core/ingestion/route-extractors/go-shared.ts b/gitnexus/src/core/ingestion/route-extractors/go-shared.ts new file mode 100644 index 000000000..c2fba1932 --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/go-shared.ts @@ -0,0 +1,89 @@ +import type { SyntaxNode } from 'tree-sitter'; + +/** + * Framework import paths, matched EXACTLY on both layers (ingestion's + * `go-gin-echo.ts` and the group-mode `http-patterns/go.ts`): a substring + * test would classify `example.com/labstack/echo-wrapper` as echo on one + * side only, flipping the handler-argument order and splitting contracts. + */ +export function isGinImportPath(importPath: string): boolean { + return importPath === 'github.com/gin-gonic/gin'; +} + +export function isEchoImportPath(importPath: string): boolean { + return /^github\.com\/labstack\/echo(\/v\d+)?$/.test(importPath); +} + +/** + * Go string-literal decoding shared by the Go route extractors on both + * layers: the ingestion extractor (`go-gin-echo.ts`, Strategy A) and the + * group-mode HTTP plugin (`group/extractors/http-patterns/go.ts`, + * Strategy B). Both sides must produce the SAME text for a route literal, + * or the same route lands under two different contract ids that never + * collide in the merge — a wrong-path duplicate that survives alongside + * the graph's correct entry. + * + * Go's semantics, per `strconv.Unquote`: + * - interpreted (`"…"`) strings decode escapes (`\n`, `\x2f`, `ሴ`, + * `\101`, …); hex and octal escapes encode BYTES, not code points; + * - raw (`` `…` ``) strings have no escapes (a backslash is literal), + * and Go discards carriage returns in them, including CRLF source files; + * - a string that cannot be decoded to valid UTF-8 text (invalid escape, + * non-UTF-8 bytes) returns null — callers decline instead of guessing, + * since a URL cannot carry those bytes losslessly. + * + * Nodes that are not string literals (an identifier prefix, a rune + * literal, an errored subtree) also return null. + */ +export function stringLiteral(node: SyntaxNode | null | undefined): string | null { + if (!node || node.hasError) return null; + const body = node.text.slice(1, -1); + // Go discards carriage returns in raw strings, including CRLF source files. + if (node.type === 'raw_string_literal') return body.replace(/\r/g, ''); + if (node.type !== 'interpreted_string_literal') return null; + if (!body.includes('\\')) return body; + + const simple: Readonly> = { + a: '\x07', + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', + v: '\v', + '\\': '\\', + '"': '"', + }; + const chunks: Buffer[] = []; + const tokens = + /\\(?:[abfnrtv\\"]|[0-7]{3}|x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8})|[^\\"\n]+/g; + let consumed = 0; + for (const match of body.matchAll(tokens)) { + if (match.index !== consumed) return null; + const token = match[0]; + consumed += token.length; + if (!token.startsWith('\\')) { + chunks.push(Buffer.from(token)); + } else if (simple[token[1]] !== undefined) { + chunks.push(Buffer.from(simple[token[1]])); + } else { + const octal = /[0-7]/.test(token[1]); + const value = Number.parseInt(token.slice(octal ? 1 : 2), octal ? 8 : 16); + if (octal || token[1] === 'x') { + // Octal and hex escapes encode bytes, not Unicode code points. + if (value > 255) return null; + chunks.push(Buffer.from([value])); + } else { + if (value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) return null; + chunks.push(Buffer.from(String.fromCodePoint(value))); + } + } + } + if (consumed !== body.length) return null; + try { + // Arbitrary non-UTF-8 Go byte strings cannot be represented losslessly in a URL. + return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks)); + } catch { + return null; + } +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 46a80060a..22a7a2d41 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -1235,6 +1235,9 @@ export interface ScopeResolver { readonly isCallableVisibleFromCaller?: (ctx: { readonly callerParsed: ParsedFile; readonly candidate: SymbolDefinition; + /** Arity of the actual call, when known. A visibility veto must not + * infer applicability from a name match alone. */ + readonly callArity?: number; /** Caller's enclosing scope id. Languages that gate visibility on * caller scope (e.g. C++ two-phase template lookup) consult it; * others ignore. Optional so existing implementations stay valid. */ diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index 6693a2bc6..af556d0d3 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -84,12 +84,7 @@ export function emitFreeCallFallback( * appended to its reason. See `ScopeResolver.markConstructionSites`. */ readonly markConstructionSites?: boolean; readonly isFileLocalDef?: (def: SymbolDefinition) => boolean; - readonly isCallableVisibleFromCaller?: (ctx: { - readonly callerParsed: ParsedFile; - readonly candidate: SymbolDefinition; - readonly callerScope?: ScopeId; - readonly scopes?: ScopeResolutionIndexes; - }) => boolean; + readonly isCallableVisibleFromCaller?: ScopeResolver['isCallableVisibleFromCaller']; readonly resolveAdlCandidates?: ( site: { readonly name: string; @@ -620,6 +615,7 @@ export function emitFreeCallFallback( options.isCallableVisibleFromCaller!({ callerParsed: parsed, candidate, + callArity: site.arity, callerScope: site.inScope, scopes, }) @@ -698,6 +694,7 @@ export function emitFreeCallFallback( !options.isCallableVisibleFromCaller({ callerParsed: parsed, candidate: fnDef, + callArity: site.arity, callerScope: site.inScope, scopes, }) diff --git a/gitnexus/src/core/ingestion/utils/template-file.ts b/gitnexus/src/core/ingestion/utils/template-file.ts new file mode 100644 index 000000000..1024de74e --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/template-file.ts @@ -0,0 +1,13 @@ +import { isBladeTemplateFilename } from 'gitnexus-shared'; + +/** Templates whose URLs and fetches can contribute route relationships to the graph. */ +export const isTemplateRouteCandidate = (filePath: string): boolean => { + const normalized = filePath.replace(/\\/g, '/').toLowerCase(); + return ( + normalized.endsWith('.html') || + normalized.endsWith('.htm') || + normalized.endsWith('.ejs') || + normalized.endsWith('.hbs') || + isBladeTemplateFilename(normalized) + ); +}; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 24116ecd6..e3e06db7c 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -440,6 +440,8 @@ export interface ExtractedToolDef { description: string; lineNumber: number; handlerNodeId?: string; + /** Unresolved registrations must not inherit unrelated same-file flows. */ + allowFileFallback?: false; } export interface ExtractedORMQuery { @@ -1691,6 +1693,7 @@ const processFileGroup = ( // node id → graph node id for classes THIS file's capture loop materialized. // Keyed by in-memory AST identity (never persisted); filled below. const classOwnersByNodeId = new Map(); + const callableBindings = new Map(); // #2687: ONE pass over `matches` yields both suppression sets — the // definition-name claims by rank (callable > Property > value), so the dedup @@ -3129,6 +3132,11 @@ const processFileGroup = ( }), }); + // Keep actual emitted identities; providers must not reconstruct graph IDs. + if (nameNode && (nodeLabel === 'Function' || nodeLabel === 'Method')) { + callableBindings.set(nameNode.id, nodeId); + } + // enclosingClassId already computed above (before nodeId generation) const ownerId = enclosingClassId ?? objectLiteralOwnerInfo?.ownerId; @@ -3231,6 +3239,23 @@ const processFileGroup = ( } } + if (provider.extractToolDefinitions) { + // Distinct lexical declarations can share a graph ID (for example, sibling + // block-scoped functions). Such IDs cannot prove which handler owns a tool. + const seenCallableIds = new Set(); + const ambiguousCallableIds = new Set(); + for (const nodeId of callableBindings.values()) { + if (seenCallableIds.has(nodeId)) ambiguousCallableIds.add(nodeId); + seenCallableIds.add(nodeId); + } + for (const [bindingId, nodeId] of callableBindings) { + if (ambiguousCallableIds.has(nodeId)) callableBindings.delete(bindingId); + } + result.toolDefs.push( + ...provider.extractToolDefinitions(tree, file.path, lineOffset, callableBindings), + ); + } + // Extract framework routes via provider detection (e.g., Laravel routes.php) if (provider.isRouteFile?.(file.path)) { const extractedRoutes = extractLaravelRoutes(tree, file.path); diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index aafb556f6..5230ec713 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -110,9 +110,13 @@ export const sanitizeUTF8 = (str: string): string => { }; export const escapeCSVField = (value: string | number | undefined | null): string => { - if (value === undefined || value === null) return '""'; + if (value === undefined || value === null) return ''; let str = String(value); str = sanitizeUTF8(str); + // Preserve the NULL meaning of absent strings from the 0.18.3 writer. + // Newer LadybugDB readers retain a quoted empty field as a STRING value; + // it must stay unquoted or unresolved destination addresses can join. + if (str.length === 0) return ''; return `"${str.replace(/"/g, '""')}"`; }; diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 970832995..92adff18a 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -1101,28 +1101,78 @@ export type LbugProgressCallback = (message: string) => void; /** * Run a COPY, retrying once with IGNORE_ERRORS=true (which skips row-level - * errors) on first failure. On a second failure, hand the RAW retry error to - * `onError` — each call site formats + slices its own message (#2226 F5: node - * COPY slices to 200 chars and throws; relationship COPY slices to 80 and warns, - * so the helper must not pre-format and lose that distinction). `onError` may - * throw to propagate the failure. + * errors) on first failure. Log the original failure and native COPY/warning + * receipts even when the retry succeeds. Node COPY requires every row; a + * skipped-node receipt is a failure. Call sites retain their own message + * limits and relationship fallback policy. */ const copyCsvWithRetry = async ( targetConn: lbug.Connection, copyQuery: string, onError: (retryErr: unknown) => void, + expectedRows?: number, ): Promise => { try { await queryAndDrain(targetConn, copyQuery); - } catch { + } catch (firstError) { + logger.warn( + { err: firstError, copyQuery }, + 'First COPY failure; retrying with IGNORE_ERRORS=true', + ); try { const retryQuery = copyQuery.replace( 'auto_detect=false)', 'auto_detect=false, IGNORE_ERRORS=true)', ); - await queryAndDrain(targetConn, retryQuery); + // Keep COPY and its connection-local warning receipt in one critical + // section. Only project diagnostics, never skipped_line_or_record: that + // column contains source text. Warnings are retained at a bounded native + // limit, so their count is a lower bound, not the exact skipped total. + const retry = async () => { + await drainQueryResult(await targetConn.query('CALL CLEAR_WARNINGS()')); + const result = await readQueryRows(await targetConn.query(retryQuery)); + const warnings = await readQueryRows( + await targetConn.query('CALL SHOW_WARNINGS() RETURN message, file_path, line_number'), + ); + // The native warning limit can be zero; warning rows alone cannot + // prove that every CSV row landed. Node COPY exposes its copied count + // in the result receipt even when warning retention is disabled. + const countReceipt = result + .map((row) => String(row.result ?? '')) + .map((message) => /^(\d+) tuples have been copied/.exec(message)) + .find(Boolean); + const copiedRows = countReceipt ? Number(countReceipt[1]) : undefined; + logger.warn( + { + copyQuery, + copyResult: result, + expectedRows, + copiedRows, + skippedRows: + expectedRows !== undefined && copiedRows !== undefined + ? Math.max(0, expectedRows - copiedRows) + : undefined, + retainedWarnings: warnings.length, + warningSamples: warnings.slice(0, 5), + }, + 'COPY retry completed; retained warnings describe skipped rows (a lower bound)', + ); + if (expectedRows !== undefined && (copiedRows !== expectedRows || warnings.length > 0)) { + throw new Error( + `COPY retry skipped rows or could not verify a complete node load ` + + `(copied ${copiedRows ?? 'unknown'} of ${expectedRows}; ${warnings.length} retained warning(s))`, + ); + } + }; + await (isSharedSingletonConn(targetConn) ? withConnLock(retry) : retry()); } catch (retryErr) { - onError(retryErr); + const firstMessage = firstError instanceof Error ? firstError.message : String(firstError); + const retryMessage = retryErr instanceof Error ? retryErr.message : String(retryErr); + onError( + new Error(`COPY retry failed: ${retryMessage}; first failure: ${firstMessage}`, { + cause: retryErr, + }), + ); } } }; @@ -1192,17 +1242,22 @@ const copyNodeCSVs = async ( if (!(await stagingCsvExists(csvPath))) throw missingStagingCsvError(table, csvPath, rows); const copyQuery = getCopyQuery(table, normalizeCopyPath(csvPath)); - await copyCsvWithRetry(targetConn, copyQuery, (retryErr) => { - const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr); - // Pool exhaustion gets a remedy (#2631): the raw binder text gives the - // operator nothing to act on, and on non-4K-page hosts (Ascend aarch64, - // Apple Silicon) the pool bills up to pageSize/4KiB x faster than the - // sizing was calibrated for — name the knob and the mechanism. - const remedy = bufferPoolExhaustionRemedy(retryMsg); - throw new Error( - `COPY failed for ${table}: ${retryMsg.slice(0, 200)}${remedy ? ` ${remedy}` : ''}`, - ); - }); + await copyCsvWithRetry( + targetConn, + copyQuery, + (retryErr) => { + const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr); + // Pool exhaustion gets a remedy (#2631): the raw binder text gives the + // operator nothing to act on, and on non-4K-page hosts (Ascend aarch64, + // Apple Silicon) the pool bills up to pageSize/4KiB x faster than the + // sizing was calibrated for — name the knob and the mechanism. + const remedy = bufferPoolExhaustionRemedy(retryMsg); + throw new Error( + `COPY failed for ${table}: ${retryMsg.slice(0, 200)}${remedy ? ` ${remedy}` : ''}`, + ); + }, + rows, + ); } }; @@ -3989,6 +4044,7 @@ export const buildFtsQueryCypher = ( * @param query - Search query string * @param limit - Maximum results * @param conjunctive - If true, all terms must match (AND); if false, any term matches (OR) + * @param missingIndex - Preserve the empty-result default, or propagate missing indexes for diagnostics * @returns Array of { node properties, score } */ export const queryFTS = async ( @@ -3997,6 +4053,7 @@ export const queryFTS = async ( query: string, limit: number = 20, conjunctive: boolean = false, + missingIndex: 'empty' | 'throw' = 'empty', ): Promise< Array<{ nodeId: string; name: string; filePath: string; score: number; [key: string]: any }> > => { @@ -4027,7 +4084,7 @@ export const queryFTS = async ( // NEW-6 — this used to be a bare `.includes('does not exist')` check // that could not tell the two apart). const message = e instanceof Error ? e.message : String(e); - if (classifyFtsQueryError(message) === 'missing-index') { + if (missingIndex === 'empty' && classifyFtsQueryError(message) === 'missing-index') { return []; } throw e; diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 8d302c913..6c6b79b51 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -36,7 +36,12 @@ import fs from 'fs/promises'; import { constants as fsConstants, existsSync } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { retryRename } from '../storage/fs-atomic.js'; -import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js'; +import { + acquireIndexLock, + requireExclusiveIndexLock, + sweepStagingArtifacts, +} from '../storage/index-lock.js'; +import { resolveEmbeddingRecovery } from '../storage/embedding-recovery.js'; import { invalidateNodeWorkspacePackages } from './ingestion/import-resolvers/node-workspace-packages.js'; import { logNameFallbackSummary, @@ -51,6 +56,7 @@ import { import { summarizeUndecidedSatisfaction } from './ingestion/scope-resolution/undecided-satisfaction.js'; import { summarizeScopeExtractionFailures } from './ingestion/scope-resolution/scope-extraction-failures.js'; import type { KnowledgeGraph } from './graph/types.js'; +import { reconcileGraphNodeIdentities } from './incremental/write-reconciliation.js'; import { resetDegradedParseCounter } from './tree-sitter/safe-parse.js'; import { initLbug, @@ -128,6 +134,7 @@ import { resolveFtsVersionPair } from './lbug/vendored-extension-path.js'; import { startWalCheckpointDriver, checkpointOnce, + isManualCheckpointEnabled, type WalCheckpointDriver, } from './lbug/wal-checkpoint-driver.js'; import { @@ -1285,6 +1292,8 @@ export async function runFullAnalysis( const log = (msg: string) => callbacks.onLog?.(stripControlCharacters(msg)); const acquireOpts = { log, + // Resolve and validate the canonical slot under the lock before cleanup. + sweep: false, onWaitStart: () => callbacks.onProgress('lock', 0, 'Waiting for another analyze to finish on this index…'), }; @@ -1343,6 +1352,7 @@ export async function runFullAnalysis( } const flatShared = writeTarget.placement.branch ? undefined : writeTarget.sharedStore; if (flatShared) await seedSharedSlot(flatShared, repoPath, log); + sweepStagingArtifacts(writeTarget.metaDir, log); const slotToLeave = options.noShare ? await optedInSlotToLeave(repoPath) : undefined; const result = await runFullAnalysisInner( repoPath, @@ -1718,20 +1728,33 @@ async function runFullAnalysisInner( ? (table, indexName) => log(`FTS: ready ${table}.${indexName}`) : undefined, }); - const missing = await verifySearchFTSIndexes(executeQuery, ftsIndexes); - if (missing.length > 0) { - // #2889: name WHY each index is missing when the build itself said so. - // Repair now rebuilds every table it can before reporting, so the tables - // absent from this list were genuinely repaired even on a failed run — - // previously the first failure aborted the sweep and the message could - // only ever list "missing", never a reason. Same sentence the analyze - // degrade path prints, so one failure does not read two ways. - const reasons = + const catalogDiagnostics: string[] = []; + let missing: string[] = []; + let catalogError: string | undefined; + try { + missing = await verifySearchFTSIndexes(executeQuery, ftsIndexes, (diagnostic) => { + catalogDiagnostics.push(diagnostic.message); + }); + } catch (error) { + // An unreadable catalog is unknown, not proof of missing indexes. + // Keep earlier native build errors when this read also fails. + catalogError = error instanceof Error ? error.message : String(error); + } + if (repairFailures.length > 0 || missing.length > 0 || catalogError !== undefined) { + // A failed DROP may leave a valid old catalog entry. Its presence must + // not certify that this repair succeeded or clear the recovery marker. + const reasons = [ + missing.length > 0 ? `missing indexes after rebuild: ${missing.join(', ')}` : '', repairFailures.length > 0 - ? ` ${summarizeFtsIndexBuildFailures(repairFailures, ftsIndexes)}.` - : ''; + ? summarizeFtsIndexBuildFailures(repairFailures, ftsIndexes) + : missing.length > 0 + ? 'no build error was returned' + : '', + ...catalogDiagnostics, + catalogError !== undefined ? `catalog verification failed: ${catalogError}` : '', + ].filter(Boolean); throw new Error( - `FTS repair failed - missing indexes after rebuild: ${missing.join(', ')}.${reasons} ` + + `FTS repair failed - ${reasons.join('. ')}. ` + 'Run `gitnexus analyze --force` to perform a full graph+FTS rebuild; ' + 'if that also fails, verify FTS extension availability via `gitnexus doctor`.', ); @@ -1840,7 +1863,13 @@ async function runFullAnalysisInner( decision = decideEmbeddingResume(checkpoint, embeddingIdentityForRun, resumeOptions); } if (decision.action === 'abort') throw new Error(decision.error); - log(decision.log); + log( + decision.action === 'resume' && checkpoint.recovery + ? `Previous analyze recorded an embedding checkpoint (${checkpoint.nodesProcessed}/` + + `${checkpoint.totalNodes} nodes); validating staged vectors before retrying ` + + `${decision.pendingNodeIds.size} pending node(s).` + : decision.log, + ); if (options.dropEmbeddings) { // --drop-embeddings has always implied a rebuild here; the decision only // covers the marker. @@ -2669,6 +2698,74 @@ async function runFullAnalysisInner( } } + // A checkpoint's pending decision and its paid, complete vectors are + // independent: --force discards the former but can still reuse the latter. + // Select only the explicitly referenced generation, never an orphan by age. + const stagedRecovery = resolveEmbeddingRecovery(metaDir, existingMeta?.embeddingCheckpoint); + const stagedCheckpoint = existingMeta?.embeddingCheckpoint; + const inheritedUnsafeNodeIds = new Set([ + ...pendingEmbeddingNodeIds, + ...(stagedRecovery?.unsafeNodeIds ?? []), + ]); + if (shouldLoadCache && !options.dropEmbeddings && stagedRecovery && stagedCheckpoint) { + if (!embeddingIdentityForRun) { + const { resolveEmbeddingIdentity } = await import('./embeddings/embedding-identity.js'); + embeddingIdentityForRun = resolveEmbeddingIdentity(); + } + const marker = stagedCheckpoint; + const matchesIdentity = + marker.model === embeddingIdentityForRun.model && + marker.dimensions === embeddingIdentityForRun.dimensions && + marker.provider === embeddingIdentityForRun.provider; + if (matchesIdentity && stagedRecovery.schemaFingerprint === SCHEMA_FINGERPRINT) { + // A force-discarded pending decision must not turn a known incomplete + // inherited group into a reusable cache merely because its hash matches. + if (inheritedUnsafeNodeIds.size > 0) { + const rows = cachedSnapshot.rows.filter((row) => !inheritedUnsafeNodeIds.has(row.nodeId)); + cachedSnapshot = { + ...cachedSnapshot, + rows, + embeddingNodeIds: new Set(rows.map((row) => row.nodeId)), + }; + } + let recovered: CachedEmbeddingsSnapshot | undefined; + try { + const { recoverStagedEmbeddings, mergeRecoveredEmbeddings } = + await import('./embeddings/staged-embedding-recovery.js'); + recovered = await recoverStagedEmbeddings(stagedRecovery.dbPath, { + dimensions: embeddingIdentityForRun.dimensions, + excludedNodeIds: stagedRecovery.unsafeNodeIds, + }); + const liveCacheDims = snapshotEmbeddingDims(cachedSnapshot); + if (liveCacheDims !== undefined && liveCacheDims !== embeddingIdentityForRun.dimensions) { + log( + `Embedding dimensions changed (${liveCacheDims}d -> ` + + `${embeddingIdentityForRun.dimensions}d), discarding published cache`, + ); + discardCachedEmbeddings(); + } + if (recovered.rows.length > 0) { + const merged = mergeRecoveredEmbeddings(cachedSnapshot, recovered); + disposeEmbeddingSpill(cachedSnapshot.spill); + adoptCachedEmbeddings(merged); + } + log( + `Recovered ${recovered.rows.length} complete staged embedding chunk(s) ` + + `for ${recovered.embeddingNodeIds.size} node(s); unchanged content can reuse them.`, + ); + } catch (err) { + log( + `Warning: could not recover staged embeddings (${(err as Error).message}); ` + + 'the retry will regenerate missing chunks.', + ); + } finally { + disposeEmbeddingSpill(recovered?.spill); + } + } else { + log('Staged embedding identity or schema changed; its vectors will not be reused.'); + } + } + // ── Load incremental parse cache ────────────────────────────────── // Content-addressed: `--force` reuses parser shards; `useParseCache: false` // stages a new generation under a run-unique parse-rebuild.* dir and publishes @@ -2854,7 +2951,7 @@ async function runFullAnalysisInner( // (Bugbot review on PR #1479: a prediction that flipped post-pipeline // could skip the embedding cache load and then take the full-rebuild // path, silently losing embeddings). - const isIncremental = + const incrementalEligible = !options.force && !!existingMeta && // Belt and braces, not a second gate: the guard above already set `force` @@ -2867,6 +2964,13 @@ async function runFullAnalysisInner( repoHasGit && allFilePaths.length > 0; + // Select a full build before any selective mutation when the operator has + // disabled the checkpoint needed to certify an incremental publication. + const isIncremental = incrementalEligible && isManualCheckpointEnabled(); + if (incrementalEligible && !isIncremental) { + log('Manual WAL checkpoints are disabled; switching to a full DB write before mutation.'); + } + const hashDiff = isIncremental ? diffFileHashes(newFileHashes, existingMeta!.fileHashes) : undefined; @@ -3112,6 +3216,24 @@ async function runFullAnalysisInner( // collapse check compares the whole in-memory graph against the whole DB, // which is only a like-for-like comparison on a full rebuild. let wroteChangedSubgraphOnly = false; + const markIncrementalGraphVerification = async (): Promise => { + if (buildPath !== lbugPath) return; + const latest = await loadMeta(metaDir); + if (!latest?.incrementalInProgress) { + throw new Error('Cannot certify incremental graph without its dirty metadata marker.'); + } + // A failed or aborted identity scan is a graph failure. FTS-only repair + // and FTS crash recovery must not clear it while retaining these rows. + await saveMeta(metaDir, { + ...latest, + incrementalInProgress: { + ...latest.incrementalInProgress, + phase: 'graph-reconciliation', + updatedAt: Date.now(), + checkpointSucceeded: false, + }, + }); + }; let incrementalFtsRebuildTables: Set | undefined; if (isIncremental && hashDiff) { // ── Incremental DB writeback ─────────────────────────────────── @@ -3163,6 +3285,8 @@ async function runFullAnalysisInner( phase: string, extra: Partial> = {}, ): Promise => { + // Do not stamp live metadata while writing a staging database. + if (buildPath !== lbugPath) return; await saveMeta(metaDir, { ...existingMeta!, incrementalInProgress: { @@ -3906,6 +4030,14 @@ async function runFullAnalysisInner( 'Continuing; recovery will treat the graph-boundary checkpoint as unsuccessful.', ); } + if (wroteChangedSubgraphOnly) { + await markIncrementalGraphVerification(); + await reconcileGraphNodeIdentities( + pipelineResult.graph, + executeQuery, + 'post-COPY/checkpoint', + ); + } if (shouldStampFtsDirtyPhase(ftsWritePlan)) { // Lift the prior-meta precondition: a first-ever in-place run (Windows // full rebuild, or any in-place incremental) must stamp too. Staging @@ -4002,6 +4134,7 @@ async function runFullAnalysisInner( ? (table, indexName) => log(`FTS: ready ${table}.${indexName}`) : undefined, }); + if (wroteChangedSubgraphOnly) await markIncrementalGraphVerification(); if (ftsResult.ok) { progress('fts', 90, 'Search indexes ready'); } else if (ftsFailureIsFatal(ftsResult.failureClass, useAtomicSwap)) { @@ -4054,6 +4187,12 @@ async function runFullAnalysisInner( progress('fts', 90, 'Search indexes skipped (FTS unavailable)'); } + if (wroteChangedSubgraphOnly) { + // FTS has returned. Later embedding/finalization failures must require + // graph recovery, since post-FTS node identities are not yet certified. + await markIncrementalGraphVerification(); + } + // ── Phase 3.5: Re-insert cached embeddings ──────────────────────── // Runs on BOTH the full-rebuild path and the incremental path: // - Full rebuild / escalated write: DB was wiped, every cached row @@ -4428,6 +4567,7 @@ async function runFullAnalysisInner( semanticMode = vectorIndexReady ? 'vector-index' : 'exact-scan'; } + let stagedCheckpointEmbeddingCount: number | undefined; if (!embeddingSkipped) { const { isHttpMode } = await import('./embeddings/http-client.js'); const httpMode = isHttpMode(); @@ -4442,6 +4582,16 @@ async function runFullAnalysisInner( embeddingIdentityForRun = resolveEmbeddingIdentity(); } const embeddingIdentity = embeddingIdentityForRun; + const stagedRecoveryEnabled = useAtomicSwap && isManualCheckpointEnabled(); + if (useAtomicSwap && !stagedRecoveryEnabled) { + log( + 'Manual WAL checkpoints are disabled; new staged work cannot be recovered after interruption. ' + + 'Any previous durable recovery source is retained until publication.', + ); + } + const unsafeRecoveryNodeIds = new Set([...inheritedUnsafeNodeIds, ...restoreFailedNodeIds]); + let activeWindowNodeIds: string[] = []; + let recoveryGenerationDurable = false; // Build a Map from cached embeddings for incremental mode let existingEmbeddings: Map | undefined; if (cachedSnapshot.embeddingNodeIds.size > 0) { @@ -4478,11 +4628,10 @@ async function runFullAnalysisInner( // /api/embed checkpoint writer in server/api.ts already uses, which also // keeps a concurrent writer's update from being reverted by a stale // snapshot) and replace ONLY `embeddingCheckpoint` — plus - // `stats.embeddings` when the caller actually MEASURED the live count - // (the post-window `onCheckpoint`). The window-start callback passes - // nothing: restating the previous run's count there both re-published a - // stale number and clobbered the live count a preceding `onCheckpoint` - // had just written. + // `stats.embeddings` when the caller actually MEASURED the published + // index (the post-window `onCheckpoint` on an in-place build). A staging + // build's count is not published until the atomic swap succeeds. The + // window-start callback passes nothing, preserving the latest count. const saveEmbeddingCheckpoint = async ( checkpoint: { nodesProcessed: number; @@ -4492,7 +4641,18 @@ async function runFullAnalysisInner( pendingNodeIds: string[], embeddings?: number, ): Promise => { + if (embeddings !== undefined && buildPath !== lbugPath) { + stagedCheckpointEmbeddingCount = embeddings; + } const latestMeta = (await loadMeta(metaDir)) ?? existingMeta; + // An in-place write or manual-checkpoint opt-out cannot create a new + // recoverable staged generation. Keep the complete previous receipt: + // updated progress or unsafe nodes would describe different source bytes. + const preservedRecoveryCheckpoint = + !stagedRecoveryEnabled && + resolveEmbeddingRecovery(metaDir, latestMeta?.embeddingCheckpoint) + ? latestMeta?.embeddingCheckpoint + : undefined; // First-ever analyze of this repo: no meta exists on disk yet (the // pre-wipe dirty stamp only fires when one does). Mint the minimum // RepoMeta requires, with `lastCommit: ''` — never `currentCommit` — @@ -4503,16 +4663,30 @@ async function runFullAnalysisInner( lastCommit: '', indexedAt: new Date().toISOString(), }; + const interrupted = mintInterruptedCheckpoint( + embeddingIdentity, + checkpoint, + pendingNodeIds, + ); await saveMeta(metaDir, { ...base, - ...(embeddings === undefined ? {} : { stats: { ...base.stats, embeddings } }), + ...(embeddings === undefined || buildPath !== lbugPath + ? {} + : { stats: { ...base.stats, embeddings } }), // Written by a run that is still IN FLIGHT — see the `kind` doc in // repo-manager.ts. - embeddingCheckpoint: mintInterruptedCheckpoint( - embeddingIdentity, - checkpoint, - pendingNodeIds, - ), + embeddingCheckpoint: preservedRecoveryCheckpoint ?? { + ...interrupted, + ...(stagedRecoveryEnabled + ? { + recovery: { + stagingFile: path.basename(buildPath), + schemaFingerprint: SCHEMA_FINGERPRINT, + unsafeNodeIds: [...unsafeRecoveryNodeIds], + }, + } + : {}), + }, }); }; @@ -4535,7 +4709,21 @@ async function runFullAnalysisInner( { forceReembedNodeIds: pendingEmbeddingNodeIds, onCheckpointWindowStart: async ({ nodeIds, ...checkpoint }) => { + const handoff = stagedRecoveryEnabled && !recoveryGenerationDurable; + if (handoff) { + if (!(await checkpointOnce())) { + throw new Error( + 'Could not checkpoint restored embeddings before recovery handoff.', + ); + } + recoveryGenerationDurable = true; + } + activeWindowNodeIds = nodeIds; + for (const id of nodeIds) unsafeRecoveryNodeIds.add(id); await saveEmbeddingCheckpoint(checkpoint, nodeIds); + // Reclaim the old source only after the new durable generation's + // reference is saved. Later windows retain this same generation. + if (handoff) sweepStagingArtifacts(metaDir, log); }, // ── The mid-run count is a DIAGNOSTIC, not a gate (#2790) ────── // This used to run the count query bare. THIS callback's rejection @@ -4549,7 +4737,12 @@ async function runFullAnalysisInner( // touch stats.embeddings" signal — so the checkpoint still lands, // with whatever count is already on disk left alone. onCheckpoint: async (checkpoint) => { - await checkpointOnce(); + const durable = await checkpointOnce(); + if (stagedRecoveryEnabled && !durable) { + throw new Error('Could not checkpoint the completed embedding window for recovery.'); + } + for (const id of activeWindowNodeIds) unsafeRecoveryNodeIds.delete(id); + activeWindowNodeIds = []; const measured = await measurePersistedEmbeddingCount(executeQuery); if (measured.kind === 'unknown') { log( @@ -4708,14 +4901,13 @@ async function runFullAnalysisInner( // already written to disk: prior meta says 0, a clean run inserts // embeddings and checkpoints the real count, the final probe is // unavailable, and finalization carries the stale 0 forward while reporting - // success. `loadMeta` never throws (it returns null), and the checkpoint - // writer already re-reads the same way, so this is the same freshness - // discipline applied to the same field. + // success. For a staged build, use its last measured count only in the + // final meta, written after the swap; never publish it at a checkpoint. const latestMetaForCount = embeddingCount === undefined ? ((await loadMeta(metaDir)) ?? existingMeta) : undefined; const persistedEmbeddingCount = resolvePersistedEmbeddingCount( measuredEmbeddingCount, - latestMetaForCount?.stats?.embeddings, + stagedCheckpointEmbeddingCount ?? latestMetaForCount?.stats?.embeddings, ); const { getRuntimeCapabilities } = await import('./platform/capabilities.js'); @@ -4956,96 +5148,25 @@ async function runFullAnalysisInner( // Parse-cache publish waits until after that swap + saveMeta so a failed // registerRepo / close / swap cannot replace live shards (#3153). - // Forward the --name alias and the registry-collision bypass bit. - // `allowDuplicateName` is its own concern — independent from the - // pipeline `force` above. The CLI maps it from - // `--allow-duplicate-name` only; `--force` and `--skills` both - // trigger pipeline re-run but never bypass the registry guard. - // The returned name is the one actually written to the registry - // (after applying the precedence chain in registerRepo) — reuse it - // so AGENTS.md / skill files reference the same name MCP clients - // will look up (#979). - const projectName = await registerRepo(repoPath, meta, { - name: options.registryName, - onRename: (previousName, nextName) => - log(`Registry name changed: "${previousName}" -> "${nextName}".`), - allowDuplicateName: options.allowDuplicateName, - // Non-primary branch runs upsert into the entry's branches[]; the - // primary/flat run (placement.branch === undefined) refreshes the - // top-level fields (#2106). - branch: placement.branch, - storagePath, - }); - - // ── #2354: the flat workspace slot has adopted this run's branch ────── - // Drop a now-shadowed `branches//` sub-index for the same label - // (unreachable once the flat slot serves it) and align the registry's - // top-level branch label. Best-effort (#2364 review F5): the index is - // complete and registered, and a failure - // here leaves only a stale registry label / undeleted shadowed dir — - // never wrong routing, because the flat meta this run already stamped is - // what applyBranchScope trusts. Retried by the next content-changing run - // (same-commit fast-path runs skip it: their guard compares the - // already-stamped meta label). - if (!placement.branch && branchLabel) { - try { - await adoptFlatBranchLabel(repoPath, branchLabel, storagePath); - } catch (e) { - log( - `Warning: could not sync the workspace branch label (${(e as Error).message}); continuing.`, + if (wroteChangedSubgraphOnly) { + // Registry freshness must not advance either. Include FTS, embedding + // restoration and the final WAL drain in the certified boundary. + await markIncrementalGraphVerification(); + await walCheckpointDriver.stop(); + if (!(await checkpointOnce())) { + throw new Error( + 'Graph identity reconciliation failed: final checkpoint could not be verified; run `gitnexus analyze --force`.', ); } + await reconcileGraphNodeIdentities( + pipelineResult.graph, + executeQuery, + 'pre-publish/checkpoint', + ); } - // Keep generated .gitnexus contents ignored without editing the user's root .gitignore. await ensureGitNexusIgnored(repoPath, storagePath); - // ── Generate AI context files (best-effort) ─────────────────────── - let aggregatedClusterCount = 0; - if (pipelineResult.communityResult?.communities) { - const groups = new Map(); - for (const c of pipelineResult.communityResult.communities) { - const label = c.heuristicLabel || c.label || 'Unknown'; - groups.set(label, (groups.get(label) || 0) + c.symbolCount); - } - aggregatedClusterCount = Array.from(groups.values()).filter((count) => count >= 5).length; - } - - // Only (re)generate the repo-root AI context files (AGENTS.md / CLAUDE.md / - // skills) for the primary/flat index (#2106). A non-primary branch analyze - // must not churn the repo's committed AGENTS.md with branch-specific stats. - if (!placement.branch) { - try { - await generateAIContextFiles( - repoPath, - storagePath, - projectName, - { - files: pipelineResult.totalFileCount, - nodes: stats.nodes, - edges: stats.edges, - communities: - pipelineResult.communityResult?.stats.totalCommunities ?? - existingMeta?.stats?.communities, - clusters: aggregatedClusterCount, - processes: - pipelineResult.processResult?.stats.totalProcesses ?? existingMeta?.stats?.processes, - }, - undefined, - { - skipAgentsMd: options.skipAgentsMd, - skipSkills: options.skipSkills, - noStats: options.noStats, - defaultBranch: options.defaultBranch, - hasPdg: options.pdg === true, - hasSpringActuator: options.springActuatorPath !== undefined, - }, - ); - } catch { - // Best-effort — don't fail the entire analysis for context file issues - } - } - // ── Close LadybugDB ────────────────────────────────────────────── // Stop the manual checkpoint driver before closeLbug so its // in-flight CHECKPOINT cannot race the `safeClose` CHECKPOINT. @@ -5108,6 +5229,97 @@ async function runFullAnalysisInner( // is a crash-safety improvement: a failed swap leaves the previous index // live and the next run recovers via the full-rebuild path. await saveMeta(metaDir, meta); + sweepStagingArtifacts(metaDir, log); + + // Registry freshness is published only after the graph and its metadata. + // A failed close, swap, or metadata save must leave the previous registry + // receipt intact, just as it leaves the cache unpublished. + // Forward the --name alias and the registry-collision bypass bit. + // `allowDuplicateName` is its own concern — independent from the + // pipeline `force` above. The CLI maps it from + // `--allow-duplicate-name` only; `--force` and `--skills` both + // trigger pipeline re-run but never bypass the registry guard. + // The returned name is the one actually written to the registry + // (after applying the precedence chain in registerRepo) — reuse it + // so AGENTS.md / skill files reference the same name MCP clients + // will look up (#979). + const projectName = await registerRepo(repoPath, meta, { + name: options.registryName, + onRename: (previousName, nextName) => + log(`Registry name changed: "${previousName}" -> "${nextName}".`), + allowDuplicateName: options.allowDuplicateName, + // Non-primary branch runs upsert into the entry's branches[]; the + // primary/flat run (placement.branch === undefined) refreshes the + // top-level fields (#2106). + branch: placement.branch, + storagePath, + }); + + // ── #2354: the flat workspace slot has adopted this run's branch ────── + // Drop a now-shadowed `branches//` sub-index for the same label + // (unreachable once the flat slot serves it) and align the registry's + // top-level branch label. Best-effort (#2364 review F5): the index is + // complete and registered, and a failure + // here leaves only a stale registry label / undeleted shadowed dir — + // never wrong routing, because the flat meta this run already stamped is + // what applyBranchScope trusts. Retried by the next content-changing run + // (same-commit fast-path runs skip it: their guard compares the + // already-stamped meta label). + if (!placement.branch && branchLabel) { + try { + await adoptFlatBranchLabel(repoPath, branchLabel, storagePath); + } catch (e) { + log( + `Warning: could not sync the workspace branch label (${(e as Error).message}); continuing.`, + ); + } + } + + // ── Generate AI context files (best-effort) ─────────────────────── + let aggregatedClusterCount = 0; + if (pipelineResult.communityResult?.communities) { + const groups = new Map(); + for (const c of pipelineResult.communityResult.communities) { + const label = c.heuristicLabel || c.label || 'Unknown'; + groups.set(label, (groups.get(label) || 0) + c.symbolCount); + } + aggregatedClusterCount = Array.from(groups.values()).filter((count) => count >= 5).length; + } + + // Only (re)generate the repo-root AI context files (AGENTS.md / CLAUDE.md / + // skills) for the primary/flat index (#2106). A non-primary branch analyze + // must not churn the repo's committed AGENTS.md with branch-specific stats. + if (!placement.branch) { + try { + await generateAIContextFiles( + repoPath, + storagePath, + projectName, + { + files: pipelineResult.totalFileCount, + nodes: stats.nodes, + edges: stats.edges, + communities: + pipelineResult.communityResult?.stats.totalCommunities ?? + existingMeta?.stats?.communities, + clusters: aggregatedClusterCount, + processes: + pipelineResult.processResult?.stats.totalProcesses ?? existingMeta?.stats?.processes, + }, + undefined, + { + skipAgentsMd: options.skipAgentsMd, + skipSkills: options.skipSkills, + noStats: options.noStats, + defaultBranch: options.defaultBranch, + hasPdg: options.pdg === true, + hasSpringActuator: options.springActuatorPath !== undefined, + }, + ); + } catch { + // Best-effort — don't fail the entire analysis for context file issues + } + } // Persist the incremental parse cache only after a successful graph // publish (#3153). try/catch so a cache-write failure never breaks an @@ -5227,7 +5439,13 @@ async function runFullAnalysisInner( // rethrow below is the surface, and the lock's sweep remains the backstop. if (useAtomicSwap && buildPath !== lbugPath) { try { - await wipeLbugDbFiles(buildPath); + const recovery = resolveEmbeddingRecovery( + metaDir, + (await loadMeta(metaDir))?.embeddingCheckpoint, + ); + // Both paths belong to this locked slot. The validated generation + // basename identifies the same file even through a directory alias. + if (recovery?.stagingFile !== path.basename(buildPath)) await wipeLbugDbFiles(buildPath); } catch { /* swallow — orphan reclamation must never mask the real failure */ } @@ -5239,6 +5457,12 @@ async function runFullAnalysisInner( // IndexLockTimeoutError and other domain failures with `instanceof`. recordLiveIndexMutationRisk(err); } + if (/max(?:imum)?(?: database| db)? size|database size limit|maxDBSize/i.test(String(err))) { + log( + 'The database size limit was reached. Set GITNEXUS_LBUG_MAX_DB_SIZE to a larger ' + + 'byte limit before retrying analyze; retained complete embeddings can be reused.', + ); + } throw err; } } diff --git a/gitnexus/src/core/search/bm25-index.ts b/gitnexus/src/core/search/bm25-index.ts index 8aaa279ef..c105a62a3 100644 --- a/gitnexus/src/core/search/bm25-index.ts +++ b/gitnexus/src/core/search/bm25-index.ts @@ -47,6 +47,8 @@ export interface FTSSearchResponse { * which only does so when every table failed). */ nonBenignErrors?: string[]; + /** Configured table.index names that could not be queried because their index is missing. */ + missingIndexes?: string[]; } /** @@ -142,6 +144,7 @@ export const searchFTSFromLbug = async ( const resultsByIndex: any[][] = []; let queriesSucceeded = 0; const nonBenignErrors: string[] = []; + const missingIndexes: string[] = []; const ftsExtension = getExtensionCapabilities().find((c) => c.name === 'fts'); if (ftsExtension && !ftsExtension.loaded) { @@ -171,24 +174,28 @@ export const searchFTSFromLbug = async ( if (outcome.rows) { queriesSucceeded++; resultsByIndex.push(outcome.rows); - } else if (!outcome.benign) { + } else if (outcome.benign) { + missingIndexes.push(`${table}.${indexName}`); + } else { nonBenignErrors.push(redactPaths(outcome.message ?? 'Unknown FTS query error')); } } } else { // Use core lbug adapter (CLI / pipeline context) — also sequential for safety. - // tri-review Residual-1: `queryFTS` itself only swallows a genuinely-missing - // index (via the SAME classifyFtsQueryError this module re-exports); a - // missing-table or real query error rethrows here — track it the same way - // the MCP pool path does instead of a bare `catch {}` that dropped it. + // Opt into missing-index propagation so absent indexes cannot masquerade + // as successful zero-match queries. Keep core/pool classification identical. for (const { table, indexName } of FTS_INDEXES) { try { - const result = await queryFTS(table, indexName, searchQuery, limit, false); + const result = await queryFTS(table, indexName, searchQuery, limit, false, 'throw'); queriesSucceeded++; resultsByIndex.push(result); } catch (e) { const message = e instanceof Error ? e.message : String(e); - nonBenignErrors.push(redactPaths(message)); + if (classifyFtsQueryError(message) === 'missing-index') { + missingIndexes.push(`${table}.${indexName}`); + } else { + nonBenignErrors.push(redactPaths(message)); + } } } } @@ -234,5 +241,6 @@ export const searchFTSFromLbug = async ( })), ftsAvailable, ...(nonBenignErrors.length > 0 && { nonBenignErrors }), + ...(missingIndexes.length > 0 && { missingIndexes }), }; }; diff --git a/gitnexus/src/core/search/fts-indexes.ts b/gitnexus/src/core/search/fts-indexes.ts index 2070fadf3..00d08f23a 100644 --- a/gitnexus/src/core/search/fts-indexes.ts +++ b/gitnexus/src/core/search/fts-indexes.ts @@ -3,6 +3,7 @@ import { dropFTSIndex, indexRowName, indexRowTable, + indexRowType, resolveGateRows, DEFAULT_FTS_STEMMER, type IndexCatalogSnapshot, @@ -110,19 +111,21 @@ export const ftsDegradedWarning = ( }; /** - * Warning for when the FTS extension is loaded and indexes exist, but every - * configured table's query failed for a real, non-benign reason (timeout, - * connection reset, native fault) — as opposed to `ftsDegradedWarning`'s - * missing-index case. `--repair-fts` will not fix a query/connection error, - * so this deliberately does NOT suggest it: reusing the missing-index - * message here would reproduce, for this cause, the exact misleading - * "run --repair-fts" guidance #2767 itself was about (tri-review NEW-1). + * Warning when no FTS query succeeded and at least one failed for a real, + * non-benign reason (timeout, connection reset, native fault). `--repair-fts` + * will not fix those errors. If indexes are also missing, the caller composes + * their repair guidance separately; do not deny that additional failure cause. */ -export const ftsQueryFailedWarning = (context: FtsWarningContext): string => +export const ftsQueryFailedWarning = ( + context: FtsWarningContext, + hasMissingIndexes = false, +): string => 'FTS keyword search failed — every configured index query returned an error' + (context.lastErrorRedacted ? ` (${context.lastErrorRedacted})` : '') + - '; results do not include keyword matches. This is not a missing-index ' + - 'condition — see server logs for details.' + + '; results do not include keyword matches. ' + + (hasMissingIndexes + ? 'See server logs for query error details.' + : 'This is not a missing-index condition — see server logs for details.') + ` (resolved: ${formatResolvedSuffix(context)})`; // Stemmers shipped by the LadybugDB FTS extension. Mirrors the lowercase token @@ -392,9 +395,40 @@ export const summarizeFtsIndexBuildFailures = ( `FTS index build failed for ${failures.length} of ${indexes.length} tables: ` + failures.map((f) => `${f.table}.${f.indexName} (${f.error})`).join(', '); +/** Bounded catalog evidence for CLI/log diagnostics, never source or index definitions. */ +export interface FtsIndexVerificationDiagnostic { + table: string; + indexName: string; + message: string; +} + +const boundCatalogText = (text: string, limit: number): string => + text.length > limit ? `${text.slice(0, limit - 14)}… [truncated]` : text; + +const catalogFieldEvidence = (value: unknown): string => + typeof value === 'string' + ? JSON.stringify(boundCatalogText(value, 80)) + : value === undefined + ? '' + : ``; + +const catalogPropertiesEvidence = (value: unknown): string => { + if (!Array.isArray(value)) return ``; + return ( + `[${value.slice(0, 8).map(catalogFieldEvidence).join(', ')}]` + + (value.length > 8 ? ` (${value.length - 8} properties truncated)` : '') + ); +}; + +/** + * Return missing or invalid configured indexes. An optional callback receives + * bounded evidence from the same catalog read; a failed read still throws and + * proves neither presence nor absence. + */ export async function verifySearchFTSIndexes( executeQuery: (cypher: string) => Promise, indexes: readonly FTSIndexDefinition[] = FTS_INDEXES, + onMismatch?: (diagnostic: FtsIndexVerificationDiagnostic) => void, ): Promise { // Read the catalog once and check each configured index both EXISTS and // covers its expected columns. A queryability-only probe (CALL QUERY_FTS_INDEX @@ -402,34 +436,51 @@ export async function verifySearchFTSIndexes( // pre-#2299 DB stays queryable yet silently misses `description`, so the probe // would pass while doc-comment search is still broken (#2299). SHOW_INDEXES // exposes `property_names` (STRING[]) per index, so we assert coverage directly. - const rows = await executeQuery('CALL SHOW_INDEXES() RETURN *'); - - const propsByIndex = new Map(); - for (const row of rows) { - if (typeof row !== 'object' || row === null) continue; - const record = row as Record; - const indexName = indexRowName(record); - // LADYBUGDB-CONTRACT: `property_names` is the one SHOW_INDEXES column with a - // single reader, so it has no shared accessor — see {@link IndexCatalogRow} - // in lbug-adapter.ts for the full column list and the re-validation rule. - // Unlike the gates, an unreadable shape here is safe: it reports the index as - // not covering its columns, i.e. "missing", which degrades keyword search - // loudly rather than passing a broken index off as verified. - const propertyNames = record.property_names; - if (typeof indexName !== 'string' || !Array.isArray(propertyNames)) continue; - propsByIndex.set( - indexName, - propertyNames.filter((p): p is string => typeof p === 'string'), - ); - } + const rows = (await executeQuery('CALL SHOW_INDEXES() RETURN *')).filter( + (row): row is Record => typeof row === 'object' && row !== null, + ); const missing: string[] = []; for (const { table, indexName, properties } of indexes) { - const actual = propsByIndex.get(indexName); - // Absent from the catalog, or present but not covering every expected column. - if (!actual || !properties.every((p) => actual.includes(p))) { - missing.push(`${table}.${indexName}`); - } + const valid = rows.some((row) => { + // LADYBUGDB-CONTRACT: `property_names` is a named STRING[] column; + // identity/type use the shared catalog readers in lbug-adapter.ts. + const actual = row.property_names; + return ( + indexRowTable(row) === table && + indexRowName(row) === indexName && + indexRowType(row) === 'FTS' && + Array.isArray(actual) && + actual.every((property) => typeof property === 'string') && + properties.every((property) => actual.includes(property)) + ); + }); + if (valid) continue; + missing.push(`${table}.${indexName}`); + if (!onMismatch) continue; + + // Show only rows that could explain this identity mismatch, and only + // catalog identity/type/property fields. Malformed objects are represented + // by their type rather than serialized (they can carry arbitrary content). + const relevant = rows.filter( + (row) => indexRowTable(row) === table || indexRowName(row) === indexName, + ); + const observed = relevant + .slice(0, 3) + .map( + (row) => + `{table=${catalogFieldEvidence(indexRowTable(row))}, ` + + `index=${catalogFieldEvidence(indexRowName(row))}, ` + + `type=${catalogFieldEvidence(indexRowType(row))}, ` + + `properties=${catalogPropertiesEvidence(row.property_names)}}`, + ); + const message = + `${table}.${indexName}: expected table=${catalogFieldEvidence(table)}, ` + + `index=${catalogFieldEvidence(indexName)}, type=FTS, ` + + `properties=${catalogPropertiesEvidence(properties)}; observed ` + + (observed.length > 0 ? observed.join(', ') : 'no matching table or index name') + + (relevant.length > 3 ? ` (${relevant.length - 3} catalog rows truncated)` : ''); + onMismatch({ table, indexName, message: boundCatalogText(message, 2048) }); } return missing; } @@ -533,19 +584,39 @@ export async function buildSearchIndexesOrDegrade( // means description search is broken (#2299). const failures = await createSearchFTSIndexes(options); const indexes = options?.indexes ?? FTS_INDEXES; - const missing = await verifySearchFTSIndexes(executeQuery, indexes); - if (failures.length === 0 && missing.length === 0) return { ok: true }; + const diagnostics: FtsIndexVerificationDiagnostic[] = []; + let missing: string[] = []; + let verificationError: string | undefined; + try { + missing = await verifySearchFTSIndexes(executeQuery, indexes, (diagnostic) => + diagnostics.push(diagnostic), + ); + } catch (e) { + // A failed catalog read is unknown, not absence. Keep the native build + // failures already collected rather than replacing them with this error. + verificationError = e instanceof Error ? e.message : String(e); + } + if (failures.length === 0 && missing.length === 0 && verificationError === undefined) { + return { ok: true }; + } - // A table that failed to build is necessarily missing too — report it once, - // with its reason, and keep `missing` for indexes nothing explains. + // A failed DROP may leave an old catalog row present. Build failures stand + // on their own; avoid naming the same table again as an unexplained failure. const named = new Set(failures.map((f) => `${f.table}.${f.indexName}`)); - const unexplained = missing.filter((name) => !named.has(name)); + const unexplained = diagnostics.filter((d) => !named.has(`${d.table}.${d.indexName}`)); const error = [ failures.length > 0 ? summarizeFtsIndexBuildFailures(failures, indexes) : '', // Structural incompleteness with no thrown error — classified capability // (degrade) below, matching prior behavior; a broken *write* surfaces as // a thrown IO/checkpoint error and is classified integrity there. - unexplained.length > 0 ? `missing indexes after build: ${unexplained.join(', ')}` : '', + unexplained.length > 0 + ? 'missing indexes or mismatched catalog rows after build ' + + '(no build error was returned for these indexes): ' + + unexplained.map((d) => d.message).join('; ') + : '', + verificationError !== undefined + ? `FTS catalog verification failed: ${verificationError}` + : '', ] .filter((part) => part.length > 0) .join('; '); @@ -553,9 +624,11 @@ export async function buildSearchIndexesOrDegrade( // Classify per failure, not over the joined text: capability signatures are // checked first, so folding the messages together would let an untokenizable // row mask a genuinely broken write and downgrade an abort into a degrade. - const failureClass = failures.some((f) => classifyFtsBuildError(f.error) === 'integrity') - ? 'integrity' - : classifyFtsBuildError(error); + const failureClass = + failures.some((f) => classifyFtsBuildError(f.error) === 'integrity') || + (verificationError !== undefined && classifyFtsBuildError(verificationError) === 'integrity') + ? 'integrity' + : 'capability'; return { ok: false, error, failureClass }; } catch (e) { const error = e instanceof Error ? e.message : String(e); diff --git a/gitnexus/src/core/staleness-status.ts b/gitnexus/src/core/staleness-status.ts index 97a8cce94..40f9d3b51 100644 --- a/gitnexus/src/core/staleness-status.ts +++ b/gitnexus/src/core/staleness-status.ts @@ -18,21 +18,35 @@ * provably stale index indistinguishable from a fresh one. `status` is the * additive channel that separates them for a caller that wants to act on it: * - * - `current` — the index is at HEAD: `rev-list` answered 0, or it could not - * answer but HEAD alone resolved to the indexed commit. - * - `behind` — `rev-list` answered N > 0; `commitsBehind` is N. - * - `diverged` — `rev-list` could not answer, but HEAD resolved and is not the - * indexed commit. The index is provably not at HEAD; only the count is - * unknown. A branch-pinned `serve` clone reaches this once git prunes the - * commit a failed re-index left behind — the pinned update is a - * `fetch --depth 1`, which orphans it — and a rewritten history reaches it - * directly. It is the rule the Claude hook already applies: - * HEAD !== lastCommit. - * - `unknown` — HEAD could not be resolved at all: not a git repository, git - * timed out, or no commit was recorded. + * The successful probe is `rev-list --left-right --count lastCommit...HEAD`: + * the left count is indexed-only commits, and the right is HEAD-only commits. + * Both counts come from one HEAD snapshot, without a follow-up process. * - * `isStale` and `commitsBehind` keep their historical values in every case, so - * no existing consumer changes behaviour unless it reads `status`. + * - `current` — both counts are 0, or `rev-list` could not answer but a + * fallback `rev-parse HEAD` resolved to the indexed commit. + * - `behind` — the right count is N > 0; `commitsBehind` is N, including + * when the left count is positive too (divergent or shallow history). + * - `diverged` — the index is provably not at HEAD, reached two different ways: + * - The left count is positive and the right is 0: HEAD is an ancestor + * of the indexed commit (#3127). The working tree checked out an older + * commit than the one indexed, or a release branch behind the indexed + * tip. The mismatch is established: `isStale` is `true` and + * `commitsBehind` stays 0 (there is no forward count to report). + * - `rev-list` could not answer at all, but HEAD resolved and is not the + * indexed commit: only the count is unknown. A branch-pinned `serve` + * clone reaches this once git prunes the commit a failed re-index left + * behind — the pinned update is a `fetch --depth 1`, which orphans it — + * and a rewritten history reaches it directly. This arm keeps the + * historical fail-open `isStale: false` (see below). + * - `unknown` — the probe could not establish the relationship: no readable + * HEAD, a timeout, no recorded commit, or malformed count output. + * + * `isStale` and `commitsBehind` keep their historical fail-open values + * (`false` / `0`) whenever the check could not fully answer — every `unknown`, + * and the `rev-list`-failure arm of `diverged` — so no existing consumer + * changes behaviour there unless it reads `status`. The other arm of + * `diverged` (the confirmed rollback) is a successful, computed + * answer rather than a failure, so `isStale` reflects it (`true`) instead. */ export type StalenessStatus = 'current' | 'behind' | 'diverged' | 'unknown'; diff --git a/gitnexus/src/mcp/local/aop-metadata.ts b/gitnexus/src/mcp/local/aop-metadata.ts index a3506d9c5..d5d41ab02 100644 --- a/gitnexus/src/mcp/local/aop-metadata.ts +++ b/gitnexus/src/mcp/local/aop-metadata.ts @@ -1,4 +1,9 @@ import { executeParameterized } from '../../core/lbug/pool-adapter.js'; +import { + assertSymbolIdentity, + assertIdentityFields, + rethrowSymbolIdentityError, +} from './query-result-integrity.js'; import { decodeSpringAopReason, type SpringAopReason, @@ -151,6 +156,7 @@ const DETERMINISTIC_RELATIONSHIP_ORDER = 'ORDER BY sourceId, targetId, reason, s * shared decoder. Other DECLARES edges (for example Spring Bean factories) * and malformed/forward-version evidence are ignored. Query failures are * fail-soft because older or partially upgraded indexes must remain readable. + * Corrupt identities propagate to the context/impact integrity error boundary. */ export async function querySpringAopMetadata( lbugPath: string, @@ -204,6 +210,24 @@ export async function querySpringAopMetadata( ), ]); + for (const rows of [ + outgoingAdviceRows, + incomingAdviceRows, + outgoingPointcutRows, + incomingPointcutRows, + ]) { + for (const row of rows) { + assertSymbolIdentity(readRowValue(row, 'sourceId', 0)); + assertSymbolIdentity(readRowValue(row, 'targetId', 3)); + assertIdentityFields( + readRowValue(row, 'sourceName', 1), + readRowValue(row, 'sourceFilePath', 2), + readRowValue(row, 'targetName', 4), + readRowValue(row, 'targetFilePath', 5), + ); + } + } + const behaviors: SpringAopBehaviorMetadata[] = []; const advices: SpringAopAdviceMetadata[] = []; const resolvedPointcuts: SpringAopResolvedPointcutMetadata[] = []; @@ -346,7 +370,8 @@ export async function querySpringAopMetadata( resolvedPointcuts: dedupedResolvedPointcuts, unresolvedPointcuts: dedupedPointcuts, }; - } catch { + } catch (error) { + rethrowSymbolIdentityError(error); return undefined; } } diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 71864e674..e5dc607f1 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -10,7 +10,12 @@ import { resolveGraphPath } from '../../storage/shared-store.js'; import fs from 'fs/promises'; import path from 'path'; import { createHash } from 'crypto'; -import { scoreImpactRisk, unusedAxesForImpactWalk, type ImpactRiskResult } from 'gitnexus-shared'; +import { + scoreImpactRisk, + unusedAxesForImpactWalk, + getLanguageFromFilename, + type ImpactRiskResult, +} from 'gitnexus-shared'; import { initLbug, executeQuery, @@ -23,6 +28,15 @@ import { } from '../../core/lbug/pool-adapter.js'; import { queryClassBeanMetadata } from './bean-metadata.js'; import { querySpringAopMetadata } from './aop-metadata.js'; +import { + SYMBOL_IDENTITY_RECOVERY_SUGGESTION, + SymbolIdentityError, + assertSymbolIdentity, + queryRowValue, + assertIdentityFields, + assertQueryIdentity, + rethrowSymbolIdentityError, +} from './query-result-integrity.js'; import { queryConvexDispatchMetadata } from './convex-metadata.js'; import { isValidQueryParams } from '../../core/lbug/query-params.js'; import { toDisplayLine } from './line-display.js'; @@ -30,8 +44,10 @@ import { shapeQueryProcessAttaches } from './query-process-attaches.js'; import { LBUG_ID_PROBE_BATCH_SIZE, LBUG_QUERY_BATCH_SIZE } from '../../core/lbug/query-batch.js'; import { chunk, mapConcurrent } from '../../lib/utils.js'; import { pathSuffixOf } from './path-predicate.js'; +import { isCobolFile, isJclFile } from '../../core/ingestion/cobol/file-types.js'; import { toOneBasedLine } from '../../core/ingestion/utils/line-base.js'; import { isTestFilePath } from '../../core/ingestion/utils/test-file-path.js'; +import { isTemplateRouteCandidate } from '../../core/ingestion/utils/template-file.js'; import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/lbug-config.js'; // Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node // at MCP server startup — crashes on unsupported Node ABI versions (#89) @@ -317,6 +333,70 @@ function nonBlankUid(value: unknown): string | undefined { return typeof value === 'string' ? value.trim() || undefined : undefined; } +function assertSymbolRowIdentity(row: unknown): void { + assertQueryIdentity(row, 'id', 0, [ + ['name', 1], + ['type', 2], + ['filePath', 3], + ]); +} + +function assertContextRefs(rows: unknown[]): void { + for (const row of rows) { + assertQueryIdentity(row, 'uid', 1, [ + ['name', 2], + ['filePath', 3], + ['kind', 4], + ]); + assertSymbolIdentity(queryRowValue(row, 'relType', 0)); + } +} + +const RESPONSE_IDENTITY_FIELDS = new Set([ + 'id', + 'uid', + 'name', + 'filePath', + 'label', + 'kind', + 'type', + 'relationType', + 'processType', + 'url', + 'method', + 'sourceId', + 'targetId', + 'symbolId', + 'symbolName', + 'symbolFilePath', + 'adviceId', + 'adviceName', + 'adviceFilePath', + 'advisedId', + 'advisedName', + 'advisedFilePath', + 'evidenceId', +]); + +/** Cover nested additive identity fields while leaving source/metadata text alone. */ +function assertResponseIdentities(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) assertResponseIdentities(item); + } else if (value !== null && typeof value === 'object') { + for (const [key, field] of Object.entries(value)) { + if (key === 'seedBlocks' || key === 'reachableBlocks' || key === 'intraReachableBlocks') { + if (!Array.isArray(field)) throw new SymbolIdentityError(); + for (const id of field) assertSymbolIdentity(id); + continue; + } + if (RESPONSE_IDENTITY_FIELDS.has(key)) assertIdentityFields(field); + if (key !== 'content' && key !== 'methodMetadata' && key !== 'bean') { + assertResponseIdentities(field); + } + } + } +} + interface StringAliasDefinition { canonical: string; aliases: readonly string[]; @@ -3143,6 +3223,7 @@ export class LocalBackend { // regardless of whether OTHER tables succeeded — previously a real error // on N-1 of N tables while one succeeded left zero diagnostic trail. const ftsQueryErrors = bm25SearchResult?.nonBenignErrors; + const ftsMissingIndexes = bm25SearchResult?.missingIndexes; if (ftsQueryErrors) { // tri-review NEW-5: these strings are already classified non-benign by // classifyFtsQueryError — do NOT route them through logQueryError, @@ -3632,13 +3713,15 @@ export class LocalBackend { branch: repo.branch, indexedAt: this.lastObservedPoolState.get(repo.lbugPath)?.indexedAt ?? repo.indexedAt, }; - // tri-review NEW-1: every table failing for a REAL error (timeout, - // connection reset) is not a missing-index condition — `ftsDegradedWarning`'s - // "run --repair-fts" headline won't fix it. Route to a dedicated message - // instead of burying the real cause as a trailing suffix on bad advice. + // Real errors (timeout, connection reset) need their own diagnosis. + // When some indexes are also missing, preserve both causes and append + // their repair guidance below even though no FTS query succeeded. warnings.push( ftsQueryErrors - ? ftsQueryFailedWarning({ ...warningContext, lastErrorRedacted: ftsQueryErrors[0] }) + ? ftsQueryFailedWarning( + { ...warningContext, lastErrorRedacted: ftsQueryErrors[0] }, + !!ftsMissingIndexes?.length, + ) : ftsDegradedWarning(warningContext, ftsDisabledReason), ); } else if (ftsQueryErrors) { @@ -3651,6 +3734,12 @@ export class LocalBackend { `FTS keyword search partially failed — ${ftsQueryErrors.length} of the configured indexes hit a query error and were skipped; results may be missing matches from those node types (see server logs).`, ); } + if (ftsMissingIndexes?.length && (ftsUsed || ftsQueryErrors)) { + warnings.push( + `FTS keyword search is incomplete: missing configured indexes (${ftsMissingIndexes.join(', ')}). ` + + 'Results may be missing matches from those node types. Run `gitnexus analyze --repair-fts`.', + ); + } // #2331: a CJK query against a server process resolving // GITNEXUS_FTS_CJK_SEGMENTATION to 'none' silently misses sub-phrase // matches with no other signal — this is the only place an agent driving @@ -3782,7 +3871,7 @@ export class LocalBackend { // #2767: a partial FTS failure (some tables ok, one or more real errors) // is as much a "results may be incomplete" signal as enrichmentDegraded — // flag it the same way rather than only via the warning string. - const ftsPartial = ftsUsed && !!ftsQueryErrors; + const ftsPartial = ftsUsed && (!!ftsQueryErrors || !!ftsMissingIndexes?.length); return { processes, @@ -3803,7 +3892,12 @@ export class LocalBackend { query: string, limit: number, disabledReason?: FtsDisabledReason, - ): Promise<{ results: any[]; ftsUsed: boolean; nonBenignErrors?: string[] }> { + ): Promise<{ + results: any[]; + ftsUsed: boolean; + nonBenignErrors?: string[]; + missingIndexes?: string[]; + }> { if (disabledReason) return { results: [], ftsUsed: false }; let searchFTSFromLbug; try { @@ -3837,6 +3931,7 @@ export class LocalBackend { const bm25Results = ftsResponse?.results ?? []; const ftsUsed = ftsResponse?.ftsAvailable ?? false; const nonBenignErrors = ftsResponse?.nonBenignErrors; + const missingIndexes = ftsResponse?.missingIndexes; const results: any[] = []; @@ -3910,7 +4005,12 @@ export class LocalBackend { } } - return { results, ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) }; + return { + results, + ftsUsed, + ...(nonBenignErrors && { nonBenignErrors }), + ...(missingIndexes && { missingIndexes }), + }; } /** @@ -4344,9 +4444,10 @@ export class LocalBackend { * "unknown kind" and, worse, makes the `kind` disambiguation hint unable to * filter it out (#2687). * - * Failures are swallowed: label enrichment is an optimisation for + * Ordinary query failures are swallowed: label enrichment is an optimisation for * downstream scoring and #480 Class/Interface BFS seeding; if it fails * the symbol still resolves, just without the kind-priority bonus. + * Corrupt identities propagate to the context/impact error envelope. */ private async enrichCandidateLabels( repo: RepoHandle, @@ -4380,6 +4481,7 @@ export class LocalBackend { ); const labelById = new Map(); for (const r of rows as any[]) { + assertQueryIdentity(r, 'id', 0, [['label', 1]]); const id = (r.id ?? r[0]) as string; const label = (r.label ?? r[1]) as string; if (id && label && !labelById.has(id)) labelById.set(id, label); @@ -4387,7 +4489,8 @@ export class LocalBackend { for (const c of candidates) { if (c.type === '' && labelById.has(c.id)) c.type = labelById.get(c.id) as string; } - } catch { + } catch (error) { + rethrowSymbolIdentityError(error); /* best-effort — downstream resolvers still work without the label */ } } @@ -4512,6 +4615,7 @@ export class LocalBackend { { uid }, ); if (rows.length === 0) return { kind: 'not_found' }; + assertSymbolRowIdentity(rows[0]); const r = rows[0] as any; const symbol = { id: (r.id ?? r[0]) as string, @@ -4522,6 +4626,7 @@ export class LocalBackend { endLine: (r.endLine ?? r[5]) as number, ...(include_content ? { content: (r.content ?? r[6]) as string | undefined } : {}), }; + assertSymbolIdentity(symbol.id, uid); // Same LadybugDB label-enrichment as the name-based path: a UID // pointing at a Class must still surface `type: 'Class'` so impact's // Class/Interface BFS seed fires. No-op when type is already set. @@ -4645,6 +4750,10 @@ export class LocalBackend { if (rows.length === 0) return { kind: 'not_found' }; + // Reject every raw candidate before narrowing/scoring can hide a corrupt row. + for (const row of rows) { + assertSymbolRowIdentity(row); + } // Normalise row shape across object / tuple returns from LadybugDB. let normalized = rows.map((r: any) => ({ id: (r.id ?? r[0]) as string, @@ -4655,7 +4764,6 @@ export class LocalBackend { endLine: (r.endLine ?? r[5]) as number, ...(include_content ? { content: (r.content ?? r[6]) as string | undefined } : {}), })); - // An exact File path wins over anchored suffix candidates. Without this, // `lib/a.ts` and `src/lib/a.ts` both score as File candidates and turn an // otherwise unambiguous exact target into `ambiguous` (#3084 review P2). @@ -4805,9 +4913,14 @@ export class LocalBackend { }, ): Promise { try { - return await this._contextImpl(repo, params); + const result = await this._contextImpl(repo, params); + if (!result.error) assertResponseIdentities(result); + return result; } catch (err: any) { const msg = (err instanceof Error ? err.message : String(err)) || 'Context query failed'; + if (err instanceof SymbolIdentityError) { + return { error: msg, recoverySuggestion: SYMBOL_IDENTITY_RECOVERY_SUGGESTION }; + } if (isWalCorruptionError(err)) { return { error: msg, @@ -4922,6 +5035,8 @@ export class LocalBackend { { symId }, ), ]); + assertContextRefs(incomingRows); + assertContextRefs(incomingAdvisedRows); incomingRows.push(...incomingAdvisedRows); let typedPropertyRows: any[] = []; @@ -5026,6 +5141,16 @@ export class LocalBackend { }, ), ]); + assertContextRefs(ctorIncoming); + assertContextRefs(fileIncoming); + assertContextRefs(typedPropertyIncoming); + for (const row of typedProperties) { + assertQueryIdentity(row, 'uid', 0, [ + ['name', 1], + ['filePath', 2], + ['kind', 3], + ]); + } typedPropertyRows = typedProperties; // Deduplicate by (relType, uid) — a caller can have multiple relation @@ -5042,6 +5167,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('context:class-incoming-expansion', e); } } @@ -5072,6 +5198,8 @@ export class LocalBackend { { symId }, ), ]); + assertContextRefs(outgoingRows); + assertContextRefs(outgoingAdvisedRows); outgoingRows.push(...outgoingAdvisedRows); // Process participation. @@ -5095,7 +5223,14 @@ export class LocalBackend { `, { symId }, ); + for (const row of processRows) { + assertQueryIdentity(row, 'pid', 0, [ + ['label', 1], + ['entryPointId', 4], + ]); + } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('context:process-participation', e); } @@ -5132,6 +5267,7 @@ export class LocalBackend { // (GET/POST pair). URL-only dedup would drop the second endpoint. const seenRoutes = new Set(); for (const r of routeRows) { + assertIdentityFields(queryRowValue(r, 'url', 0), queryRowValue(r, 'method', 1)); const url = r.url ?? r[0]; const method = r.method ?? r[1]; const dedupKey = routeEnrichmentKey(method ? String(method) : undefined, url); @@ -5141,7 +5277,8 @@ export class LocalBackend { } } } catch (e) { - // Best-effort enrichment — never fail the context call. + rethrowSymbolIdentityError(e); + // Ordinary query failures leave this best-effort enrichment unavailable. logQueryError('context:route-lookup', e); } @@ -5191,6 +5328,7 @@ export class LocalBackend { ); const beanMetadataPromise = queryClassBeanMetadata(repo.lbugPath, symId, epistemicSymType); const aopMetadataPromise = querySpringAopMetadata(repo.lbugPath, symId, epistemicSymType); + void aopMetadataPromise.catch(() => undefined); // R3-1. A `Property` whose name the analyzer declined to link — because // every definition of it lives in another language — otherwise returns an @@ -5285,6 +5423,7 @@ export class LocalBackend { try { chain = await this._computeContextChain(repo, symId, requestedDepth); } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('context:chain-bfs', e); } } @@ -5325,8 +5464,8 @@ export class LocalBackend { processes: processRows.map((r: any) => ({ id: r.pid || r[0], name: r.label || r[1], - step_index: r.step || r[2], - step_count: r.stepCount || r[3], + step_index: r.step ?? r[2], + step_count: r.stepCount ?? r[3], })), }; } @@ -5407,6 +5546,13 @@ export class LocalBackend { visited: Array.from(visited), }); if (rows.length === 0) return { nextFrontier: [] }; + for (const row of rows) { + assertQueryIdentity(row, 'uid', 0, [ + ['name', 1], + ['filePath', 2], + ['kind', 3], + ]); + } // MATCH is one row per CALLS edge. Cypher `WITH DISTINCT` applies // LIMIT 50 to unique neighbors; this second pass still collapses // twins if a driver/engine ever returns duplicate rows. @@ -5426,6 +5572,7 @@ export class LocalBackend { for (const r of fresh) visited.add(r.uid); return { nodes, nextFrontier: fresh.map((r: any) => r.uid) }; } catch (e) { + rethrowSymbolIdentityError(e); logQueryError(logLabel, e); return { nextFrontier: [] }; } @@ -6445,6 +6592,7 @@ export class LocalBackend { // throwaway arrays the size of the row set (40k rows 11.4ms → 4.5ms, 200k // rows 71.3ms → 26.6ms). const exactlyMatchedPaths = new Set(); + const mappedPaths = new Set(); for (const row of symbolRows) { if (row.filePath === row.diffPath) exactlyMatchedPaths.add(row.diffPath); } @@ -6454,6 +6602,8 @@ export class LocalBackend { if (sym.filePath !== sym.diffPath && exactlyMatchedPaths.has(diffPath)) continue; const hunks = hunksByPath.get(diffPath) ?? []; if (!hunksOverlapRange(hunks, sym.startLine, sym.endLine)) continue; + // A suffix fallback is a hint, not proof that this is the changed file. + if (sym.filePath === diffPath) mappedPaths.add(diffPath); if (changedSymbols.has(sym.id)) continue; changedSymbols.set(sym.id, { @@ -6465,6 +6615,27 @@ export class LocalBackend { }); } + // An empty successful query cannot prove a source diff is safe: its rows + // may be missing, outside indexed spans, or not yet indexed. Keep ordinary + // docs/config diffs measurable, but withhold a ranked source-risk verdict. + const isSourceFile = (file: string): boolean => + getLanguageFromFilename(file) !== null || + isCobolFile(file) || + isJclFile(file) || + isTemplateRouteCandidate(file); + const unmappedFiles = [ + ...new Set( + fileDiffs + .filter( + ({ filePath, oldFilePath }) => + !mappedPaths.has(filePath) && + (isSourceFile(filePath) || (oldFilePath !== undefined && isSourceFile(oldFilePath))), + ) + .map(({ filePath }) => filePath), + ), + ]; + if (unmappedFiles.length > 0) queryDegraded = true; + // Find affected processes -- batched queries instead of N+1 const affectedProcesses = new Map(); if (changedSymbols.size > 0) { @@ -6565,8 +6736,9 @@ export class LocalBackend { }, changed_symbols: listedSymbols, affected_processes: Array.from(affectedProcesses.values()), - // A swallowed query failure makes the counts/risk above incomplete — tell - // the caller so the safety gate isn't trusted as a clean result (#2283). + ...(unmappedFiles.length > 0 && { unmapped_files: unmappedFiles }), + // Failed queries or unmapped source files leave counts/risk incomplete; + // the safety gate must not treat that as a clean result (#2283). ...(queryDegraded && { partial: true }), ...(listedSymbols.length < changedSymbols.size && { truncated: true }), }; @@ -7082,13 +7254,23 @@ export class LocalBackend { private async impact(repo: RepoHandle, params: ImpactParams): Promise { try { - return await this._impactImpl(repo, params); + const result = await this._impactImpl(repo, params); + if (!result.error) assertResponseIdentities(result); + return result; } catch (err: any) { // Return structured error instead of crashing (#321) const message = (err instanceof Error ? err.message : String(err)) || 'Impact analysis failed'; - const suggestion = 'The graph query failed — try gitnexus context as a fallback'; - const recoverySuggestion = isWalCorruptionError(err) ? WAL_RECOVERY_SUGGESTION : undefined; + const recoverySuggestion = + err instanceof SymbolIdentityError + ? SYMBOL_IDENTITY_RECOVERY_SUGGESTION + : isWalCorruptionError(err) + ? WAL_RECOVERY_SUGGESTION + : undefined; + const suggestion = + err instanceof SymbolIdentityError + ? SYMBOL_IDENTITY_RECOVERY_SUGGESTION + : 'The graph query failed — try gitnexus context as a fallback'; if (params.mode === 'pdg') { // Symbol resolution never reached the catch with a resolved symbol (the // throw can originate before/within resolution), so the envelope carries @@ -7392,6 +7574,7 @@ export class LocalBackend { } catch (e) { probeFailed = true; candidateProbeFailed = true; + rethrowSymbolIdentityError(e); logQueryError('impact:ambiguous-candidate', e); } return { @@ -7649,6 +7832,7 @@ export class LocalBackend { }); return composeUnifiedPdgImpactResult(pdgResult, interproceduralResult); } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:pdg-interprocedural-reach', e); return composeUnifiedPdgImpactResult(pdgResult, null, e); } @@ -7686,10 +7870,12 @@ export class LocalBackend { { ids: blockIds }, ); for (const r of rows as any[]) { + assertIdentityFields(r.callees ?? r[0]); const raw = String(r.callees ?? r[0] ?? ''); for (const n of raw.split(' ')) if (n) names.add(n); } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:pdg-slice-callees', e); } return names; @@ -7725,6 +7911,7 @@ export class LocalBackend { for (const id of splitCalleeIds(r.calleeIds ?? r[0])) ids.add(id); } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:pdg-slice-callee-ids', e); } return ids; @@ -7878,7 +8065,10 @@ export class LocalBackend { ORDER BY id LIMIT 25`, { symId, heritage: HERITAGE_TYPES }, - ).catch(() => []); + ).catch((error) => { + rethrowSymbolIdentityError(error); + return []; + }); const undecidedSummary = meta?.undecidedInterfaceSatisfaction; const undecidedDrops = undecidedSummary === undefined @@ -7917,6 +8107,10 @@ export class LocalBackend { } const ifaceRows = await interfaceRowsPromise; for (const r of ifaceRows) { + assertQueryIdentity(r, 'id', 0, [ + ['name', 1], + ['label', 2], + ]); const id = (r.id ?? r[0]) as string; if (id && !boundary.has(id)) { boundary.set(id, { @@ -7942,7 +8136,10 @@ export class LocalBackend { WHERE iface.id = $ifaceId AND r.type IN $types RETURN COUNT(DISTINCT other.id) AS cnt`, { ifaceId, types }, - ).catch(() => []); + ).catch((error) => { + rethrowSymbolIdentityError(error); + return []; + }); const cnt = rows.length > 0 ? Number((rows[0] as any).cnt ?? (rows[0] as any)[0] ?? 0) : 0; m.set(ifaceId, cnt); @@ -8001,7 +8198,8 @@ export class LocalBackend { callableValueReferences: droppedBoundaries.callableValueReferences, }, }; - } catch { + } catch (error) { + rethrowSymbolIdentityError(error); // Never let the heritage probe's failure suppress a drop we already know // about — the whole point is that silence must not read as certainty. return epistemicFrom(droppedBoundaries); @@ -8093,6 +8291,7 @@ export class LocalBackend { `Impact target '${sym.name || sym[1] || '?'}' resolved without a node id; refusing to report a blast radius`, ); } + assertSymbolRowIdentity(sym); // #1858 — kick off the epistemic boundary probe concurrently with the BFS. // It depends only on symId/symType/symName (all known now) and touches no @@ -8128,6 +8327,7 @@ export class LocalBackend { opts.skipEpistemic || summaryOnly ? Promise.resolve(undefined) : querySpringAopMetadata(repo.lbugPath, symId, symType); + void aopMetadataPromise.catch(() => undefined); const impacted: any[] = []; const visited = new Set([symId]); const pdgBridgeEvidenceById = new Map(); @@ -8172,6 +8372,7 @@ export class LocalBackend { ]); for (const r of ctorRows) { + assertSymbolRowIdentity(r); const rid = r.id || r[0]; if (rid && !visited.has(rid)) { visited.add(rid); @@ -8179,6 +8380,7 @@ export class LocalBackend { } } for (const r of fileRows) { + assertSymbolRowIdentity(r); const rid = r.id || r[0]; if (rid && !visited.has(rid)) { visited.add(rid); @@ -8203,6 +8405,7 @@ export class LocalBackend { ); for (const r of typedPropertyRows) { + assertSymbolRowIdentity(r); const rid = r.id || r[0]; if (rid && !visited.has(rid)) { visited.add(rid); @@ -8210,6 +8413,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:class-node-expansion', e); traversalComplete = false; } @@ -8247,6 +8451,9 @@ export class LocalBackend { `, { symId }, ); + for (const row of memberRows) { + assertSymbolRowIdentity(row); + } memberRows.sort((a, b) => compareCodeUnits(String(a.id ?? a[0]), String(b.id ?? b[0]))); if (memberRows.length > OBJECT_CALLABLE_MEMBER_CAP) traversalComplete = false; for (const row of memberRows.slice(0, OBJECT_CALLABLE_MEMBER_CAP)) { @@ -8266,6 +8473,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:object-callable-expansion', e); traversalComplete = false; } @@ -8322,6 +8530,17 @@ export class LocalBackend { relTypes: relationTypes, ...(safeMinConfidence > 0 ? { minConfidence: safeMinConfidence } : {}), }); + // Validate before filtering/deduplication; a discarded corrupt edge is + // still evidence that this result's counts cannot be trusted. + for (const row of related) { + assertQueryIdentity(row, 'id', 1, [ + ['sourceId', 0], + ['name', 2], + ['type', 3], + ['filePath', 4], + ]); + assertSymbolIdentity(queryRowValue(row, 'relType', 5)); + } const edges: ImpactFrontierEdge[] = related.map((rel) => ({ id: rel.id || rel[1], @@ -8421,6 +8640,7 @@ export class LocalBackend { }); } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:depth-traversal', e); // Break out of depth loop on query failure but return partial results // collected so far, rather than silently swallowing the error (#321) @@ -8546,6 +8766,7 @@ export class LocalBackend { `, { ids }, ).catch((err) => { + rethrowSymbolIdentityError(err); processQueryFailed = true; enrichmentDegraded = true; logQueryError('impact:process-chunk', err); @@ -8553,6 +8774,14 @@ export class LocalBackend { }); for (const row of rows) { + assertQueryIdentity(row, 'pId', 0, [ + ['name', 1], + ['processType', 2], + ['entryPointId', 3], + ['epName', 7], + ['epType', 8], + ['epFilePath', 9], + ]); const pId = row.pId ?? row[0]; const epId = row.entryPointId ?? row[3] ?? row.pId ?? row[0]; // Track mapping from process -> entryPoint so we can backfill missing minStep @@ -8601,6 +8830,7 @@ export class LocalBackend { ep.earliest_broken_step = Math.min(ep.earliest_broken_step, minStep ?? Infinity); } } catch (e) { + rethrowSymbolIdentityError(e); processQueryFailed = true; enrichmentDegraded = true; logQueryError('impact:process-chunk', e); @@ -8623,12 +8853,14 @@ export class LocalBackend { `, { pIds, ids: allImpactedIds }, ).catch((err) => { + rethrowSymbolIdentityError(err); enrichmentDegraded = true; logQueryError('impact:process-chunk-backfill', err); return []; }); for (const mr of missingRows) { + assertQueryIdentity(mr, 'pid', 0, []); const pid = mr.pid ?? mr[0]; const minStep = mr.minStep ?? mr[1]; const epId = processToEntryPoint.get(String(pid)); @@ -8640,6 +8872,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); enrichmentDegraded = true; logQueryError('impact:process-chunk-backfill', e); } @@ -8702,6 +8935,7 @@ export class LocalBackend { `, { ids: idsChunk }, ).catch((err) => { + rethrowSymbolIdentityError(err); moduleQueryFailed = true; enrichmentDegraded = true; logQueryError('impact:module-chunk', err); @@ -8709,12 +8943,14 @@ export class LocalBackend { }); for (const r of rows) { + assertIdentityFields(queryRowValue(r, 'name', 0)); const name = r.name ?? r[0] ?? null; const hits = (r.hits ?? r[1]) || 0; if (!name) continue; moduleHitsMap.set(name, (moduleHitsMap.get(name) || 0) + hits); } } catch (e) { + rethrowSymbolIdentityError(e); moduleQueryFailed = true; enrichmentDegraded = true; logQueryError('impact:module-chunk', e); @@ -8743,16 +8979,19 @@ export class LocalBackend { `, { ids: idsChunk }, ).catch((err) => { + rethrowSymbolIdentityError(err); enrichmentDegraded = true; moduleClassificationFailed = true; logQueryError('impact:direct-module-chunk', err); return []; }); for (const r of rows) { + assertIdentityFields(queryRowValue(r, 'name', 0)); const name = r.name ?? r[0] ?? null; if (name) directModuleSet.add(name); } } catch (e) { + rethrowSymbolIdentityError(e); enrichmentDegraded = true; moduleClassificationFailed = true; logQueryError('impact:direct-module-chunk', e); @@ -8814,11 +9053,14 @@ export class LocalBackend { `, { ids: chunkIds }, ).catch((err) => { + rethrowSymbolIdentityError(err); enrichmentDegraded = true; logQueryError('impact:route-chunk', err); return []; }); for (const row of rows) { + assertSymbolIdentity(queryRowValue(row, 'hid', 0)); + assertIdentityFields(queryRowValue(row, 'url', 1), queryRowValue(row, 'method', 2)); const hid = String(row.hid ?? row[0] ?? ''); const url = row.url ?? row[1]; if (!hid || typeof url !== 'string') continue; @@ -8975,8 +9217,16 @@ export class LocalBackend { p.processType AS pType, MIN(r.step) AS step `, { ids: chunkIds }, - ).catch(() => []); + ).catch((err) => { + rethrowSymbolIdentityError(err); + return []; + }); for (const row of rows) { + assertQueryIdentity(row, 'sid', 0, []); + assertQueryIdentity(row, 'pid', 1, [ + ['pName', 2], + ['pType', 3], + ]); const sid = row.sid ?? row[0]; if (!sid) continue; const procEntry = { @@ -8990,6 +9240,7 @@ export class LocalBackend { else perSymbolProcesses.set(String(sid), [procEntry]); } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:per-symbol-process-chunk', e); } } @@ -9106,6 +9357,7 @@ export class LocalBackend { ]; try { + assertSymbolIdentity(sym.id ?? sym[0], uid); // skipPerSymbolEnrichment suppresses ONLY the per-symbol STEP_IN_PROCESS // enrichment pass while preserving byDepth. Group-mode cross-repo fan-out // may fan across many repos; the per-symbol pass adds up to MAX_CHUNKS diff --git a/gitnexus/src/mcp/local/pdg-impact.ts b/gitnexus/src/mcp/local/pdg-impact.ts index ae4e9836f..4b7321925 100644 --- a/gitnexus/src/mcp/local/pdg-impact.ts +++ b/gitnexus/src/mcp/local/pdg-impact.ts @@ -27,6 +27,11 @@ import { toDisplayLine } from './line-display.js'; import { toOneBasedLine } from '../../core/ingestion/utils/line-base.js'; import { decodeCallSummary } from '../../core/ingestion/taint/call-summary-codec.js'; import { decodeReachingDefReason } from '../../core/ingestion/cfg/reaching-def-reason-codec.js'; +import { + assertSymbolIdentity, + assertIdentityFields, + assertQueryIdentity, +} from './query-result-integrity.js'; /** * Parse the `` segment out of a `BasicBlock` id (1-based function start @@ -90,14 +95,19 @@ const INTERPROC_NODE_BUDGET = 5000; * `classifyPdgBridgeEvidence`); this is the same fact, read at the descent side. */ function parseCalleeIdsCell(raw: unknown): { ids: string[]; truncated: boolean } { + assertIdentityFields(raw); const ids: string[] = []; let truncated = false; + if (!String(raw ?? '').trim()) return { ids, truncated }; // Split on the SHARED CALLEE_ID_SEP (tab) — ids embed file paths / multi-word // C++ type tokens that can contain a space, so a space split would fragment // them. Producer (calleeIdsOfBlock) joins with the same constant. for (const id of String(raw ?? '').split(CALLEE_ID_SEP)) { if (id === CALLEES_TRUNCATED_SENTINEL) truncated = true; - else if (id) ids.push(id); + else { + assertSymbolIdentity(id); + ids.push(id); + } } return { ids, truncated }; } @@ -218,6 +228,7 @@ async function selfReachingDefEdgesByBlock( { ids: blockIds }, ); for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0); const id = String(r['id'] ?? ''); if (!id) continue; const decoded = decodeReachingDefReason(r['reason']); @@ -297,6 +308,7 @@ async function pdgStatementsForBlocks( // Narrow the awaited rows ONCE at the boundary to a typed record shape; read // the aliased cells via bracket access with String()/Number() coercion. for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0); const id = String(r['id'] ?? ''); const line = Number(r['line'] ?? 0); if (!id || !Number.isFinite(line) || line <= 0) continue; @@ -501,6 +513,10 @@ async function projectBlocksToSymbols(deps: { // non-aliased row shape) — no per-field `as any`, matching the typed-row // pattern used elsewhere in this file (e.g. lines ~264, ~1309, ~1386). for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0, [ + ['name', 1], + ['label', 2], + ]); resolved.push({ id: String(r['id'] ?? r['0'] ?? ''), name: String(r['name'] ?? r['1'] ?? ''), @@ -1718,6 +1734,7 @@ async function bfsReachableBlocks(input: { // Narrow the awaited rows ONCE at the boundary (executeParameterized returns // any[]) to a typed record shape, then read the aliased `id` via bracket // access — no `as any` sprayed per field. + for (const row of rawRows) assertQueryIdentity(row, 'id', 0); const rows = rawRows.slice(0, stepLimit) as Array>; depthReached = depth + 1; if (rawRows.length > stepLimit) truncatedByLimit = true; @@ -1796,6 +1813,10 @@ async function calleeIdsByBlock( // Narrow the awaited rows ONCE at the boundary to a typed record shape; read // the aliased cells via bracket access — no per-field `as any`. for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0, [ + ['calleeIds', 1], + ['callees', 2], + ]); const blockId = String(r['id'] ?? ''); if (!blockId) continue; // ONE pass over the cell classifies BOTH facts — a second full split just to @@ -1877,6 +1898,7 @@ async function calleesWithReturnFlow( { ids: calleeIds }, ); for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0); const id = String(r['id'] ?? ''); if (!id) continue; const decoded = decodeCallSummary(r['reason']); @@ -1931,6 +1953,7 @@ async function resolveCalleeSpans( // the aliased columns via bracket access with Number()/String() coercion — // no per-field `as any` (the same boundary-narrowing the typed helpers use). for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0, [['filePath', 1]]); const id = String(r['id'] ?? ''); const filePath = String(r['filePath'] ?? ''); const startLine = Number(r['startLine']); @@ -2168,6 +2191,7 @@ async function interproceduralDescent(input: { seedBlockQuery(anchorClause, probeLimit), queryParams, ); + for (const row of rawSeedRows) assertQueryIdentity(row, 'id', 0); const exceeded = rawSeedRows.length > stepLimit; const seeds = rawSeedRows .slice(0, stepLimit) @@ -2353,6 +2377,7 @@ export async function runImpactPDG(deps: RunPdgImpactDeps): Promise>; let seedBlocks: string[] = seedRows .map((r) => String(r['id'] ?? '')) diff --git a/gitnexus/src/mcp/local/query-result-integrity.ts b/gitnexus/src/mcp/local/query-result-integrity.ts new file mode 100644 index 000000000..da92cca55 --- /dev/null +++ b/gitnexus/src/mcp/local/query-result-integrity.ts @@ -0,0 +1,57 @@ +/** Shared integrity boundary for identities returned by impact/context queries. */ +export const SYMBOL_IDENTITY_RECOVERY_SUGGESTION = + 'Run gitnexus analyze --force from the affected repository root to rebuild the index.'; + +export class SymbolIdentityError extends Error { + constructor() { + super('The index returned an invalid symbol identity. ' + SYMBOL_IDENTITY_RECOVERY_SUGGESTION); + this.name = 'SymbolIdentityError'; + } +} + +/** Validate database identities before using them as graph traversal anchors. */ +export function assertSymbolIdentity(id: unknown, expectedUid?: string): asserts id is string { + if ( + typeof id !== 'string' || + !id.trim() || + id.includes('\0') || + (expectedUid !== undefined && id !== expectedUid) + ) { + throw new SymbolIdentityError(); + } +} + +/** Read either native row shape without turning an absent row into a TypeError. */ +export function queryRowValue(row: unknown, key: string, index: number): unknown { + if (typeof row !== 'object' || row === null) return undefined; + const value = row as Record; + return value[key] ?? value[index]; +} + +/** Optional labels/paths may be empty or NULL; NUL is never a usable identity. */ +export function assertIdentityFields(...values: unknown[]): void { + for (const value of values) { + if ( + value !== null && + value !== undefined && + (typeof value !== 'string' || value.includes('\0')) + ) { + throw new SymbolIdentityError(); + } + } +} + +export function assertQueryIdentity( + row: unknown, + idKey: string, + idIndex: number, + fields: ReadonlyArray = [], +): void { + assertSymbolIdentity(queryRowValue(row, idKey, idIndex)); + for (const [key, index] of fields) assertIdentityFields(queryRowValue(row, key, index)); +} + +/** Ordinary query failures may degrade; corrupt identities must reach the outer error envelope. */ +export function rethrowSymbolIdentityError(error: unknown): void { + if (error instanceof SymbolIdentityError) throw error; +} diff --git a/gitnexus/src/mcp/resources.ts b/gitnexus/src/mcp/resources.ts index 8436629e8..49687fa9b 100644 --- a/gitnexus/src/mcp/resources.ts +++ b/gitnexus/src/mcp/resources.ts @@ -351,7 +351,7 @@ async function getContextResource(backend: LocalBackend, repoName?: string): Pro // Check staleness using the current on-disk lastCommit (not the cached handle) const repoPath = repo.repoPath; - const lastCommit = freshMeta?.lastCommit ?? repo.lastCommit ?? 'HEAD'; + const lastCommit = freshMeta?.lastCommit ?? repo.lastCommit ?? ''; const staleness = repoPath ? checkStaleness(repoPath, lastCommit) : { isStale: false, commitsBehind: 0 }; diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 2965fcd6f..77b527e00 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -400,7 +400,7 @@ AFTER THIS: Review affected processes. Use context() on high-risk symbols. READ GIT WORKTREE SUPPORT: GitNexus automatically detects when the MCP server was launched from inside a linked git worktree and runs git diff against that worktree — no extra parameters needed in the common case. Pass "worktree" explicitly only when the server was started from a different directory than the worktree you are editing (e.g., the server runs from the canonical root but your changes are in a linked worktree at a different path). Returns: changed symbols, affected processes, and a risk summary. -- partial: true — a step failed and was swallowed, so the result is incomplete and risk_level is "unknown" instead of a ranked level. Two causes, with different blast radii: the symbol query (or an unparseable diff) degrades everything — changed_symbols, both counts, and the processes derived from them — while a failed process lookup degrades only affected_processes and the risk read off it, leaving the changed-symbol counts sound. changed_count:0 with partial:true is NOT a clean pre-commit check; re-run before treating the diff as safe. +- partial: true — mapping is incomplete, so risk_level is "unknown" instead of a ranked level. A failed symbol query (or an unparseable diff) degrades changed_symbols, both counts, and derived processes; a failed process lookup degrades only affected_processes and risk, leaving changed-symbol counts sound. unmapped_files lists changed supported source files with no mapped symbols, including source renames without hunks: their symbols may be missing, unindexed, or outside indexed ranges even when every query succeeded. Rebuild the index and inspect those diffs; retry alone may not resolve this state. changed_count:0 with partial:true is NOT a clean pre-commit check. - truncated: true — the changed_symbols LISTING was capped for this response. summary.changed_count counts every symbol the run observed: the true total normally, a LOWER BOUND when partial:true. Compare it with the array length rather than trusting the array.`, annotations: READ_ONLY_TOOL_ANNOTATIONS, inputSchema: { diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 0d554125a..f4cb39c66 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -12,6 +12,7 @@ import { acquireIndexLock, IndexLockTimeoutError, requireExclusiveIndexLock, + sweepStagingArtifacts, type IndexLockHandle, } from '../storage/index-lock.js'; import { ensurePrivateSharedGraph } from '../core/shared-store-analyze.js'; @@ -550,7 +551,7 @@ const mapGraphRelationshipRow = (row: any): GraphRelationship => ({ sourceId: row.sourceId, targetId: row.targetId, confidence: row.confidence, - reason: row.reason, + reason: row.reason ?? '', step: row.step, }); @@ -2152,11 +2153,21 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // for the whole embedding write, released in the finally below. let slotLock: IndexLockHandle | undefined; try { - slotLock = await acquireIndexLock(storagePath); + slotLock = await acquireIndexLock(storagePath, { sweep: false }); requireExclusiveIndexLock( slotLock, `Cannot acquire the index lock at ${storagePath}; refusing an unlocked embedding run.`, ); + // This writer cannot recover staged generations. Preserve their + // receipts, including malformed ones, before sweeping or writing. + const recoveryCheckpoint = (await loadMeta(storagePath))?.embeddingCheckpoint; + if (recoveryCheckpoint && Object.hasOwn(recoveryCheckpoint, 'recovery')) { + throw new Error( + 'Cannot generate embeddings: the index checkpoint references staged embeddings. ' + + 'Run `gitnexus analyze` to recover them first.', + ); + } + sweepStagingArtifacts(storagePath); // Writes go to the slot's own graph; a shared-store checkout // reading an immutable commit graph (#3352) takes a private copy. if (!(await ensurePrivateSharedGraph(storagePath, () => {}))) { diff --git a/gitnexus/src/storage/embedding-recovery.ts b/gitnexus/src/storage/embedding-recovery.ts new file mode 100644 index 000000000..54821bcc7 --- /dev/null +++ b/gitnexus/src/storage/embedding-recovery.ts @@ -0,0 +1,167 @@ +/** + * Filesystem-only staged embedding provenance. Keep this independent of native + * and model imports: every index-lock caller needs the retention decision. + */ +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readFileSync, + realpathSync, +} from 'node:fs'; +import path from 'node:path'; +import type { EmbeddingRecoveryReference } from './repo-meta.js'; +import { INDEX_METADATA_FILE, LEGACY_METADATA_FILE } from './storage-constants.js'; + +const STAGING_FILENAME = + /^lbug\.staging\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +export const FAMILY_SUFFIXES = [ + '', + '.wal', + '.shadow', + '.wal.checkpoint', + '.lock', + '.checkpoint.intent.lock', + '.checkpoint.apply.lock', +] as const; + +export interface ResolvedEmbeddingRecovery extends EmbeddingRecoveryReference { + dbPath: string; + /** Exact basenames, never a prefix match that can preserve another generation. */ + familyFiles: string[]; +} + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); +const isNonemptyString = (value: unknown): value is string => + typeof value === 'string' && value.length > 0; +const isNodeIds = (value: unknown): value is string[] => + Array.isArray(value) && value.every(isNonemptyString); +const isCount = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; + +/** + * Resolve only an explicit interrupted-generation receipt in this canonical + * slot. This validates provenance, not native contents or current identity; + * those checks must pass separately before any rows can be reused. + */ +export const resolveEmbeddingRecovery = ( + lockDir: string, + checkpoint: unknown, +): ResolvedEmbeddingRecovery | undefined => { + if (!isRecord(checkpoint) || !isRecord(checkpoint.recovery)) return undefined; + if (checkpoint.kind !== undefined && checkpoint.kind !== 'interrupted') return undefined; + if ( + !isNonemptyString(checkpoint.at) || + !Number.isFinite(Date.parse(checkpoint.at)) || + !isCount(checkpoint.nodesProcessed) || + !isCount(checkpoint.totalNodes) || + checkpoint.nodesProcessed > checkpoint.totalNodes || + !isCount(checkpoint.chunksProcessed) || + !isNonemptyString(checkpoint.model) || + !isCount(checkpoint.dimensions) || + checkpoint.dimensions === 0 || + !isNonemptyString(checkpoint.provider) || + (checkpoint.pendingNodeIds !== undefined && !isNodeIds(checkpoint.pendingNodeIds)) + ) { + return undefined; + } + const recovery = checkpoint.recovery; + if ( + !isNonemptyString(recovery.stagingFile) || + !STAGING_FILENAME.test(recovery.stagingFile) || + !isNonemptyString(recovery.schemaFingerprint) || + !isNodeIds(recovery.unsafeNodeIds) + ) { + return undefined; + } + const unsafeNodeIds = new Set(recovery.unsafeNodeIds); + if ( + isNodeIds(checkpoint.pendingNodeIds) && + checkpoint.pendingNodeIds.some((nodeId) => !unsafeNodeIds.has(nodeId)) + ) { + return undefined; + } + + try { + const canonicalDir = realpathSync(lockDir); + if (!lstatSync(canonicalDir).isDirectory()) return undefined; + const familyFiles = FAMILY_SUFFIXES.map((suffix) => recovery.stagingFile + suffix); + for (const [index, filename] of familyFiles.entries()) { + try { + // lstat refuses both live and dangling symlinks, without following one + // to a database outside the slot. The base file must exist. + if (!lstatSync(path.join(canonicalDir, filename)).isFile()) return undefined; + } catch (error) { + if (index > 0 && (error as NodeJS.ErrnoException).code === 'ENOENT') continue; + return undefined; + } + } + return { + dbPath: path.join(canonicalDir, recovery.stagingFile), + stagingFile: recovery.stagingFile, + schemaFingerprint: recovery.schemaFingerprint, + unsafeNodeIds: [...unsafeNodeIds], + familyFiles, + }; + } catch { + return undefined; + } +}; + +/** Synchronous mirror of loadMeta's primary-first, absent-only fallback rule. */ +export const readEmbeddingRecovery = (lockDir: string): ResolvedEmbeddingRecovery | undefined => { + let metadataPath = path.join(lockDir, INDEX_METADATA_FILE); + let descriptor: number | undefined; + const noFollow = constants.O_NOFOLLOW ?? 0; + const flags = constants.O_RDONLY | noFollow | (constants.O_NONBLOCK ?? 0); + try { + try { + descriptor = openSync(metadataPath, flags); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') return undefined; + // Windows cannot open with O_NOFOLLOW: an open of a dangling symlink + // reports ENOENT, but that existing primary entry must prevent fallback. + try { + lstatSync(metadataPath); + return undefined; + } catch (statError) { + const statCode = (statError as NodeJS.ErrnoException).code; + if (statCode !== 'ENOENT' && statCode !== 'ENOTDIR') return undefined; + } + metadataPath = path.join(lockDir, LEGACY_METADATA_FILE); + descriptor = openSync(metadataPath, flags); + } + const opened = fstatSync(descriptor, { bigint: true }); + const entry = lstatSync(metadataPath, { bigint: true }); + // Check the opened file itself and match the current non-symlink entry. + // This also refuses replacement on platforms without O_NOFOLLOW. + if ( + !opened.isFile() || + !entry.isFile() || + (noFollow === 0 && opened.ino === 0n) || + opened.dev !== entry.dev || + opened.ino !== entry.ino + ) { + return undefined; + } + const meta: unknown = JSON.parse(readFileSync(descriptor, 'utf8')); + if (!isRecord(meta)) return undefined; + // storagePath describes the flat/cache root, including in branch-slot + // metadata. The current locked directory is the generation boundary. + return resolveEmbeddingRecovery(lockDir, meta.embeddingCheckpoint); + } catch { + return undefined; + } finally { + if (descriptor !== undefined) { + try { + closeSync(descriptor); + } catch { + /* best-effort */ + } + } + } +}; diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index 0d0e138e4..f4e26b99c 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -827,6 +827,8 @@ export interface DiffHunk { export interface FileDiff { filePath: string; + /** Decoded pre-rename path; either side can identify a source-file change. */ + oldFilePath?: string; hunks: DiffHunk[]; } @@ -1061,7 +1063,10 @@ export function parseDiffHunksResult(diffOutput: string): DiffHunkParseResult { } else { unparsedGitHeaders++; } - } else if (line.startsWith('rename to ')) { + } else if (!inHunk && line.startsWith('rename from ')) { + const oldFilePath = decodeGitPathToken(line.slice('rename from '.length)); + if (current && oldFilePath) current.oldFilePath = oldFilePath; + } else if (!inHunk && line.startsWith('rename to ')) { const filePath = pathFromRenameTo(line); if (!filePath) continue; if (current) current.filePath = filePath; diff --git a/gitnexus/src/storage/index-lock.ts b/gitnexus/src/storage/index-lock.ts index 5588127e6..5d3b6cc47 100644 --- a/gitnexus/src/storage/index-lock.ts +++ b/gitnexus/src/storage/index-lock.ts @@ -62,6 +62,7 @@ import path from 'node:path'; import os from 'node:os'; import { randomBytes, randomUUID, createHash } from 'node:crypto'; import { isProcessAlive } from '../utils/process-identity.js'; +import { readEmbeddingRecovery } from './embedding-recovery.js'; const LOCK_FILENAME = 'analyze.lock'; const LOCK_RECORD_VERSION = 1 as const; @@ -433,8 +434,9 @@ const deniedCreateHandle = ( /** * Delete orphaned build/staging artifacts left in the lock directory by a * crashed prior writer. Safe precisely because we hold the exclusive lock: no - * other writer can be creating these here right now, so anything present is a - * crash orphan. Matches this slot's staging files ONLY — never `lbug` itself, + * other writer can be creating these here right now. A checkpoint-referenced + * generation is retained for embedding recovery; all other stages are orphans. + * Matches this slot's staging files ONLY — never `lbug` itself, * never `lbug.wal`/`lbug.shadow` (the LIVE index's own sidecars), and never a * `branches//` sub-slot (which owns its own lock + sweep). Non-recursive. */ @@ -442,6 +444,7 @@ export const sweepStagingArtifacts = (lockDir: string, log?: (msg: string) => vo // Matches `lbug.new`, `lbug.new.wal`, `lbug.staging.`, `lbug.staging..wal`, … // Does NOT match `lbug`, `lbug.wal`, `lbug.shadow`. const stagingRe = /^lbug\.(staging\..+|new(\..+)?)$/; + const retained = new Set(readEmbeddingRecovery(lockDir)?.familyFiles ?? []); let removed = 0; let entries: string[]; try { @@ -450,7 +453,7 @@ export const sweepStagingArtifacts = (lockDir: string, log?: (msg: string) => vo return; } for (const name of entries) { - if (!stagingRe.test(name)) continue; + if (!stagingRe.test(name) || retained.has(name)) continue; try { unlinkSync(path.join(lockDir, name)); removed++; diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index f01bf80b0..fcce2f7e6 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -822,28 +822,32 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // `handlerReceiver` hint. Warm v123 Go worker results carry no routes. // v125 (#3402): Go route hints now honor lexical declarations and captured writes; // namespace imports retain whether their local name comes from the package clause. -// v126: R scope queries now anchor a `@scope.function` -// on every named-function assignment and named function-valued argument (the -// same nodes the `@declaration.function`/`@declaration.method` anchors use), -// so each def is owned by its own Function scope instead of the module/Class -// scope. Warm ParsedFiles and durable stores hold the pre-fix scope tree, where -// every R call is credited to the first callable in its file; both stores must -// re-extract. -// v127: R scope queries now capture the `pkg::`/`pkg:::` -// qualifier of namespaced calls as `@reference.qualified-name`, so it reaches -// `site.rawQualifiedName`. Warm ParsedFiles hold the pre-capture site shape without the -// qualifier, so both stores must re-extract. -// v128: the R provider keeps only the argument that names an S4 definition +// v126 (#3446): SDK positional tool registrations now emit tool definitions, +// exact handler identities, and an opt-out from unrelated file-level flows. +// Warm v125 worker results omit these definitions and must be re-extracted. +// v127 (#3450): Destructured member writes invalidate SDK registration evidence. +// Warm v126 worker results can retain false tools after a method replacement. +// v128 (#3450): SDK namespace imports now prove positional tool receivers. +// Warm v127 worker results omit these definitions and must be re-extracted. +// v129: R provider fixes, collapsed into one bump because none of them has +// shipped. R scope queries anchor a `@scope.function` on every named-function +// assignment and named function-valued argument (the same nodes the +// `@declaration.function`/`@declaration.method` anchors use), so each def is +// owned by its own Function scope instead of the module/Class scope, and +// capture the `pkg::`/`pkg:::` qualifier of namespaced calls as +// `@reference.qualified-name` so it reaches `site.rawQualifiedName`. The R +// provider also keeps only the argument that names an S4 definition // (shouldSkipDefinitionCapture), so `setClass("A", contains = "VIRTUAL")` no // longer defines a Class `VIRTUAL`, `setGeneric(..., valueClass = "numeric")` a -// Function `numeric`, nor `setMethod("show", "Foo", ...)` a Method `Foo`. The R +// Function `numeric`, nor `setMethod("show", "Foo", ...)` a Method `Foo`; the R // scope emitter likewise keeps only the argument that names a `setClass` class // or a `library`/`require`/`source` import, so `library(lib.loc = libO, pkgP)` // no longer imports `libO` and a comment before the first argument no longer // hides the class or import. Definitions and scope captures are parse-time -// facts replayed verbatim from the warm cache, so stale ones would persist on -// unchanged files. (One bump covers both: v128 has not shipped.) -const SCHEMA_BUMP = 128; +// facts replayed verbatim from the warm cache, so stale ones (the pre-fix scope +// tree, the unqualified call-site shape, the mis-selected arguments) would +// persist on unchanged files; both stores must re-extract. +const SCHEMA_BUMP = 129; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/src/storage/repo-meta.ts b/gitnexus/src/storage/repo-meta.ts index 0c412dd7c..1562ee8c7 100644 --- a/gitnexus/src/storage/repo-meta.ts +++ b/gitnexus/src/storage/repo-meta.ts @@ -43,6 +43,15 @@ export type ContentRetention = 'full' | 'symbol' | 'none'; export type FtsProfile = 'full' | 'symbol-no-file-content' | 'name-only'; export const CONTENT_RETENTION_SCHEMA_VERSION = 1; +/** Exact staged generation whose completed embedding groups can survive a retry. */ +export interface EmbeddingRecoveryReference { + /** A run-minted basename within this metadata file's index slot. */ + stagingFile: string; + schemaFingerprint: string; + /** Active-window and inherited incomplete groups: never reusable until completed. */ + unsafeNodeIds: string[]; +} + /** * Versioned receipt for the analyzer process that produced an index. * @@ -521,6 +530,11 @@ export interface RepoMeta { * subset of their chunks; for `'partial'` they hold none. */ pendingNodeIds?: string[]; + /** + * Interrupted atomic builds only. This does not publish the staged graph + * or advance live embedding statistics; it identifies a recovery source. + */ + recovery?: EmbeddingRecoveryReference; }; /** * Name of the git branch this index represents (#2106). Absent for the diff --git a/gitnexus/test/fixtures/lang-resolution/swift-injected-closure-call/Caller.swift b/gitnexus/test/fixtures/lang-resolution/swift-injected-closure-call/Caller.swift new file mode 100644 index 000000000..dfea6fe9a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-injected-closure-call/Caller.swift @@ -0,0 +1,54 @@ +import Foundation + +enum Example { + static func runScenario() -> Int { + makeValue(input: 1) + } +} + +final class Service { + private let clock: () -> Date + + init(clock: @escaping () -> Date) { + self.clock = clock + } + + func refreshValue() -> Date { + clock() + } + + func refreshWithLocalClock() -> Int { + func clock() -> Int { 2 } + return clock() + } +} + +class BaseService { + let clock: () -> Date + + init(clock: @escaping () -> Date) { + self.clock = clock + } +} + +final class DerivedService: BaseService { + func refreshInheritedValue() -> Date { + clock() + } +} + +final class LabeledService { + let first: Int = 1 + + func first(where value: Bool) -> Int { + value ? 2 : 0 + } + + func refreshLabeled() -> Int { + first(where: true) + } +} + +class PrivateBase { + private let clock: () -> Int = { 1 } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-injected-closure-call/Helpers.swift b/gitnexus/test/fixtures/lang-resolution/swift-injected-closure-call/Helpers.swift new file mode 100644 index 000000000..64c6b6a10 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-injected-closure-call/Helpers.swift @@ -0,0 +1,39 @@ +import Foundation + +extension Example { + static func makeValue(input: Int) -> Int { + input + 1 + } +} + +enum Other { + private static func makeValue(other: String) -> Int { + -1 + } + + static func clock() -> Date { + Date.distantPast + } +} + +extension DerivedService { + func refreshInheritedFromExtension() -> Date { + clock() + } +} + +extension BaseService { + func refreshOwnFromExtension() -> Date { + clock() + } +} + +final class PrivateDerived: PrivateBase { + func clock() -> Int { + 2 + } + + func refreshPrivateAncestor() -> Int { + clock() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/handlers.ts b/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/handlers.ts new file mode 100644 index 000000000..e30cb154d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/handlers.ts @@ -0,0 +1 @@ +export function importedHandler() { return 'imported'; } diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/server.js b/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/server.js new file mode 100644 index 000000000..eedf3488f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/server.js @@ -0,0 +1,5 @@ +import { McpServer as Server } from '@modelcontextprotocol/sdk/server/mcp.js'; + +const server = new Server({ name: 'javascript', version: '1' }); +function jsPing() { return 'pong'; } +server.registerTool('js_ping', { description: 'Ping JavaScript' }, jsPing); diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/server.ts b/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/server.ts new file mode 100644 index 000000000..4a9c954f8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/server.ts @@ -0,0 +1,52 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { importedHandler } from './handlers.js'; + +const server = new McpServer({ name: 'fixture', version: '1' }); + +function formatSearch(query: string) { return query; } +function lookupSearch(query: string) { return formatSearch(query); } +export function searchFiles(query: string) { return lookupSearch(query); } + +function formatFile(file: string) { return file; } +function lookupFile(file: string) { return formatFile(file); } +export const readFile = (file: string) => lookupFile(file); + +function formatOther() { return 'other'; } +function lookupOther() { return formatOther(); } +export function unrelatedEntry() { return lookupOther(); } + +server.registerTool('search-files', { description: 'Search files' }, searchFiles); +server.tool('read_file', 'Read a file', {}, readFile); +server.registerTool('inline_callback', {}, async () => 'inline'); +server.tool('imported_callback', importedHandler); + +function install(server: McpServer, searchFiles: () => string) { + server.registerTool('parameter_callback', {}, searchFiles); +} + +let mutable = () => 'mutable'; +server.tool('mutable_callback', mutable); + +function replaced() { return 'before'; } +replaced = () => 'after'; +server.tool('reassigned_callback', replaced); + +{ + const searchFiles = 'not callable'; + server.tool('shadowed_callback', searchFiles); +} + +const alias = readFile; +server.tool('alias_callback', alias); + +const expressionHandler = function () { return 'expression'; }; +server.registerTool('function_expression_tool', {}, expressionHandler); + +{ + const handler = () => lookupSearch('first'); + server.tool('first_block_callback', handler); +} +{ + const handler = () => lookupFile('second'); + server.tool('second_block_callback', handler); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/tools.ts b/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/tools.ts new file mode 100644 index 000000000..0013ccfab --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/tools.ts @@ -0,0 +1,4 @@ +export const tools = [ + { name: 'manifest_tool', description: 'Existing object manifest', inputSchema: {} }, + { name: 'read_file', description: 'Duplicate manifest entry', inputSchema: {} }, +]; diff --git a/gitnexus/test/fixtures/local-backend-seed.ts b/gitnexus/test/fixtures/local-backend-seed.ts index 4878348b2..1f7fd02c9 100644 --- a/gitnexus/test/fixtures/local-backend-seed.ts +++ b/gitnexus/test/fixtures/local-backend-seed.ts @@ -1,4 +1,5 @@ import type { FTSIndexDef } from '../helpers/test-indexed-db.js'; +import { FTS_INDEXES } from '../../src/core/search/fts-schema.js'; export const LOCAL_BACKEND_SEED_DATA = [ // Files @@ -60,9 +61,8 @@ export const LOCAL_BACKEND_SEED_DATA = [ CREATE (c)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'class-method', step: 0}]->(m)`, ]; -export const LOCAL_BACKEND_FTS_INDEXES: FTSIndexDef[] = [ - { table: 'Function', indexName: 'function_fts', columns: ['name', 'content', 'description'] }, - { table: 'Class', indexName: 'class_fts', columns: ['name', 'content', 'description'] }, - { table: 'Method', indexName: 'method_fts', columns: ['name', 'content', 'description'] }, - { table: 'File', indexName: 'file_fts', columns: ['name', 'content'] }, -]; +// Healthy-backend fixtures must mirror the complete configured search index set. +// The old four-table subset is now correctly reported as partial FTS coverage. +export const LOCAL_BACKEND_FTS_INDEXES: FTSIndexDef[] = FTS_INDEXES.map( + ({ table, indexName, properties }) => ({ table, indexName, columns: [...properties] }), +); diff --git a/gitnexus/test/fixtures/staged-embedding-recovery/seed.mjs b/gitnexus/test/fixtures/staged-embedding-recovery/seed.mjs new file mode 100644 index 000000000..68401a4b8 --- /dev/null +++ b/gitnexus/test/fixtures/staged-embedding-recovery/seed.mjs @@ -0,0 +1,52 @@ +import lbug from '@ladybugdb/core'; +import fs from 'node:fs'; +import { createLbugDatabase } from '../../../src/core/lbug/lbug-config.ts'; + +const [dbPath, mode] = process.argv.slice(2); +const db = createLbugDatabase(lbug, dbPath); +const conn = new lbug.Connection(db); +async function query(cypher) { + const queried = await conn.query(cypher); + for (const result of Array.isArray(queried) ? queried : [queried]) { + await result.getAll(); + await result.close(); + } +} +await query('CREATE NODE TABLE CodeEmbedding (id STRING, nodeId STRING, chunkIndex INT32, startLine INT64, endLine INT64, embedding FLOAT[2], contentHash STRING, PRIMARY KEY(id))'); +async function row(id, nodeId, chunkIndex, hash = 'same', startLine = 1, endLine = 3) { + await query(`CREATE (:CodeEmbedding {id: '${id}', nodeId: '${nodeId}', chunkIndex: ${chunkIndex}, startLine: ${startLine}, endLine: ${endLine}, embedding: [1.0, 2.0], contentHash: ${hash === null ? 'NULL' : `'${hash}'`}})`); +} +await row('complete-0', 'complete', 0); +await row('complete-1', 'complete', 1); +await row('other', 'other', 0); +await query('CHECKPOINT'); +if (mode === 'interrupted-checkpoint') { + await row('checkpoint-only', 'checkpoint-only', 0); + const main = fs.readFileSync(dbPath); + const wal = fs.readFileSync(`${dbPath}.wal`); + await conn.close(); + await db.close(); + // Restore the pre-close bytes: writable close has already checkpointed them. + fs.writeFileSync(dbPath, main); + fs.writeFileSync(`${dbPath}.wal.checkpoint`, wal); + fs.writeFileSync(`${dbPath}.wal`, ''); + fs.writeFileSync(`${dbPath}.shadow`, ''); + fs.writeFileSync(`${dbPath}.checkpoint.intent.lock`, ''); + fs.writeFileSync(`${dbPath}.checkpoint.apply.lock`, ''); +} else if (mode === 'hard-kill') { + await row('unsafe', 'unsafe-prefix', 0); + process.kill(process.pid, 'SIGKILL'); +} else { + await row('gap', 'gap', 1); + await row('duplicate-0', 'duplicate', 0); + await row('duplicate-1', 'duplicate', 0); + await row('mixed-0', 'mixed', 0, 'old'); + await row('mixed-1', 'mixed', 1, 'new'); + await row('missing-hash-0', 'missing-hash', 0); + await row('missing-hash-1', 'missing-hash', 1, null); + await row('bad-line', 'bad-line', 0, 'same', -1, 3); + await row('nan-0', 'nan', 0); + await query("CREATE (:CodeEmbedding {id: 'nan-1', nodeId: 'nan', chunkIndex: 1, startLine: 1, endLine: 3, embedding: [CAST('NaN', 'FLOAT'), 2.0], contentHash: 'same'})"); + await conn.close(); + await db.close(); +} diff --git a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json index b6c40d1b1..037d5d01c 100644 --- a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json @@ -127,6 +127,14 @@ "captureGroups": 9, "digest": "c4d442b67247406de3b1158b958f6f3e294f11c0bbb360ed32489582769a1187" }, + "swift-injected-closure-call/Caller.swift": { + "captureGroups": 69, + "digest": "6768d62722a6b173dbe953507fec53661ea6c9786a49d8edf7caedb0a3cef0e4" + }, + "swift-injected-closure-call/Helpers.swift": { + "captureGroups": 48, + "digest": "6e6bfe83084bf189a7789702cfccb5fcc5753986b0de3c0d7f81fc3ae59270b2" + }, "swift-member-write-access/App.swift": { "captureGroups": 14, "digest": "7be68b7e510fefac99c94a4ebab3eb96556a0e587155b6c82b7602705e270987" diff --git a/gitnexus/test/helpers/cfg-harness.ts b/gitnexus/test/helpers/cfg-harness.ts index e5d787836..eb7b5c04c 100644 --- a/gitnexus/test/helpers/cfg-harness.ts +++ b/gitnexus/test/helpers/cfg-harness.ts @@ -29,7 +29,7 @@ export interface CfgHarness { * CFG shape). The parser is created once and reused across parses. */ export function makeCfgHarness( - grammar: Parser.Language, + grammar: Parameters[0], visitor: CfgVisitor, filePath = 'fixture', ): CfgHarness { diff --git a/gitnexus/test/helpers/leiden-timeout-child.ts b/gitnexus/test/helpers/leiden-timeout-child.ts new file mode 100644 index 000000000..eecebbf84 --- /dev/null +++ b/gitnexus/test/helpers/leiden-timeout-child.ts @@ -0,0 +1,38 @@ +import { + buildGraphologyGraph, + runGraphologyLeiden, +} from '../../src/core/ingestion/community-processor.js'; + +// 105-node / 128-edge graph from #3476 on which the vendored Leiden never +// terminates (graphology/graphology#557). Preserve the original insertion order: +// sorting either the nodes or edges changes the path through the algorithm. +const EDGES = + '39-26 65-66 72-52 10-38 98-68 37-10 26-81 37-38 30-66 18-66 30-97 84-81 50-96 36-76 ' + + '33-29 54-87 4-75 44-31 29-31 60-44 91-10 48-41 68-15 83-90 82-57 50-71 28-55 100-11 ' + + '42-19 28-42 74-92 55-5 7-65 96-41 61-27 60-85 89-67 97-34 94-64 89-6 61-51 12-13 ' + + '35-9 91-20 94-23 91-7 43-99 80-85 80-101 75-50 38-57 4-104 35-6 65-103 83-45 62-91 ' + + '16-37 94-33 103-17 67-66 39-73 68-2 26-31 48-19 43-88 88-47 54-42 26-66 100-65 7-30 ' + + '32-13 56-2 22-24 78-24 84-53 90-71 60-12 76-49 86-35 55-68 96-46 33-30 4-1 24-68 ' + + '23-14 93-3 58-56 64-27 85-87 61-101 36-37 37-85 60-63 37-26 41-74 103-87 78-36 69-97 ' + + '77-42 97-52 57-25 27-34 68-28 95-104 18-96 4-15 60-48 17-14 51-31 83-40 48-75 16-99 ' + + '79-65 70-14 3-27 0-62 21-101 44-59 4-24 34-52 65-8 102-66 67-24 11-26 39-78 6-103 ' + + '4-71 70-4'; + +const graph = buildGraphologyGraph({ + nodes: Array.from({ length: 105 }, (_, i) => ({ + id: String(i), + name: String(i), + filePath: `/src/${i}.ts`, + type: 'Function', + })), + edges: EDGES.split(/\s+/).map((edge) => { + const [source, target] = edge.split('-').map(Number); + return [source, target]; + }), + symbolCount: 105, + isLarge: false, +}); + +const result = await runGraphologyLeiden(graph, false, 'graphology', 500); +console.log(JSON.stringify(result)); +// No explicit exit: a leaked worker must keep this process alive for the parent to detect. diff --git a/gitnexus/test/helpers/parse-source-safe-mock.ts b/gitnexus/test/helpers/parse-source-safe-mock.ts index 8cc0646a4..78a6df6d2 100644 --- a/gitnexus/test/helpers/parse-source-safe-mock.ts +++ b/gitnexus/test/helpers/parse-source-safe-mock.ts @@ -1,4 +1,4 @@ -import { vi } from 'vitest'; +import { vi, type Mock } from 'vitest'; import type * as SafeParseModule from '../../src/core/tree-sitter/safe-parse.js'; /** @@ -43,7 +43,7 @@ import type * as SafeParseModule from '../../src/core/tree-sitter/safe-parse.js' * }); */ export async function buildSafeParseMock( - spy: ReturnType, + spy: Mock, ): Promise { const actual = await vi.importActual( '../../src/core/tree-sitter/safe-parse.js', diff --git a/gitnexus/test/helpers/parse-typescript-source.ts b/gitnexus/test/helpers/parse-typescript-source.ts index d324d84d8..0935a240d 100644 --- a/gitnexus/test/helpers/parse-typescript-source.ts +++ b/gitnexus/test/helpers/parse-typescript-source.ts @@ -30,10 +30,8 @@ export interface ParsedSource { const PARSE_PLUGINS: NonNullable[1]>['plugins'] = [ 'typescript', - 'explicitResourceManagement', - 'importAttributes', 'decoratorAutoAccessors', - ['decorators', { decoratorsBeforeExport: true }], + 'decorators', ]; export function forEachChild(node: Node, visit: (child: AstNode) => void): void { diff --git a/gitnexus/test/helpers/perf-sequencer.ts b/gitnexus/test/helpers/perf-sequencer.ts index d5f8604a3..9dd6d1fb3 100644 --- a/gitnexus/test/helpers/perf-sequencer.ts +++ b/gitnexus/test/helpers/perf-sequencer.ts @@ -17,7 +17,19 @@ export default class PerfSequencer extends BaseSequencer { override async shard(specs: TestSpecification[]): Promise { const shard = this.ctx.config.shard; if (!shard) return specs; - const groups = assignShards(specs, shard.count, specWeight, (spec) => spec.moduleId); + const groups = assignShards( + specs, + shard.count, + (spec) => { + const config = spec.project.config; + const fileParallelism = + 'fileParallelism' in config && typeof config.fileParallelism === 'boolean' + ? config.fileParallelism + : undefined; + return specWeight({ moduleId: spec.moduleId, project: { config: { fileParallelism } } }); + }, + (spec) => spec.moduleId, + ); return groups[shard.index - 1] ?? []; } } diff --git a/gitnexus/test/helpers/test-graph.ts b/gitnexus/test/helpers/test-graph.ts index 834d0df9b..a0a589e97 100644 --- a/gitnexus/test/helpers/test-graph.ts +++ b/gitnexus/test/helpers/test-graph.ts @@ -5,7 +5,8 @@ * without touching the filesystem or LadybugDB. */ import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; -import type { KnowledgeGraph, NodeLabel, RelationshipType } from '../../src/core/graph/types.js'; +import type { KnowledgeGraph } from '../../src/core/graph/types.js'; +import type { NodeLabel, RelationshipType } from 'gitnexus-shared'; export interface TestNodeInput { id: string; diff --git a/gitnexus/test/integration/analyze-atomic-swap.test.ts b/gitnexus/test/integration/analyze-atomic-swap.test.ts index f664a6bb6..9731bf51b 100644 --- a/gitnexus/test/integration/analyze-atomic-swap.test.ts +++ b/gitnexus/test/integration/analyze-atomic-swap.test.ts @@ -19,32 +19,55 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; type LbugAdapter = typeof import('../../src/core/lbug/lbug-adapter.js'); +type RepoManager = typeof import('../../src/storage/repo-manager.js'); +type FsAtomic = typeof import('../../src/storage/fs-atomic.js'); const ctx = vi.hoisted(() => ({ loadMock: vi.fn(), realLoad: null as LbugAdapter['loadGraphToLbug'] | null, deleteMock: vi.fn(), realDelete: null as LbugAdapter['deleteNodesForFiles'] | null, + closeMock: vi.fn(), + realClose: null as LbugAdapter['closeLbug'] | null, + saveMetaMock: vi.fn(), + realSaveMeta: null as RepoManager['saveMeta'] | null, + renameMock: vi.fn(), + realRename: null as FsAtomic['retryRename'] | null, })); -// Delegating mock: overrides only loadGraphToLbug so a rebuild can be made to -// fail on demand (mirrors run-analyze-adopt-failure.test.ts). +// Delegating mocks keep real graph, metadata, and registry writes while +// allowing failures at the write and publication boundaries. vi.mock('../../src/core/lbug/lbug-adapter.js', async (importOriginal) => { const actual = await importOriginal(); ctx.realLoad = actual.loadGraphToLbug; ctx.realDelete = actual.deleteNodesForFiles; + ctx.realClose = actual.closeLbug; ctx.loadMock.mockImplementation(actual.loadGraphToLbug); ctx.deleteMock.mockImplementation(actual.deleteNodesForFiles); + ctx.closeMock.mockImplementation(actual.closeLbug); return { ...actual, loadGraphToLbug: ctx.loadMock, deleteNodesForFiles: ctx.deleteMock, + closeLbug: ctx.closeMock, }; }); +vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => { + const actual = await importOriginal(); + ctx.realSaveMeta = actual.saveMeta; + ctx.saveMetaMock.mockImplementation(actual.saveMeta); + return { ...actual, saveMeta: ctx.saveMetaMock }; +}); +vi.mock('../../src/storage/fs-atomic.js', async (importOriginal) => { + const actual = await importOriginal(); + ctx.realRename = actual.retryRename; + ctx.renameMock.mockImplementation(actual.retryRename); + return { ...actual, retryRename: ctx.renameMock }; +}); import { analyzeFailureMayHaveMutatedLiveIndex, runFullAnalysis, } from '../../src/core/run-analyze.js'; -import { getStoragePaths } from '../../src/storage/repo-manager.js'; +import { getStoragePaths, loadMeta, readRegistry } from '../../src/storage/repo-manager.js'; import { initLbug as poolInit, executeQuery as poolQuery, @@ -83,6 +106,16 @@ describe.skipIf(isWin)('atomic full-rebuild swap (#2)', () => { ctx.deleteMock.mockImplementation((...a: Parameters) => ctx.realDelete!(...a), ); + ctx.closeMock.mockReset(); + ctx.closeMock.mockImplementation(() => ctx.realClose!()); + ctx.saveMetaMock.mockReset(); + ctx.saveMetaMock.mockImplementation((...a: Parameters) => + ctx.realSaveMeta!(...a), + ); + ctx.renameMock.mockReset(); + ctx.renameMock.mockImplementation((...a: Parameters) => + ctx.realRename!(...a), + ); }); afterEach(async () => { @@ -144,6 +177,102 @@ describe.skipIf(isWin)('atomic full-rebuild swap (#2)', () => { } }, 180_000); + it.each( + (['full', 'incremental'] as const).flatMap((writeMode) => + (['close', 'swap', 'metadata'] as const).map((failurePoint) => ({ + writeMode, + failurePoint, + })), + ), + )( + 'keeps registry freshness unchanged when $writeMode publication fails at $failurePoint', + async ({ writeMode, failurePoint }) => { + const { repo, cleanup } = await makeRepo(); + const repoId = `atomic-publication-${writeMode}-${failurePoint}`; + try { + await runFullAnalysis(repo, {}, { onProgress: () => {} }); + const { lbugPath, storagePath } = getStoragePaths(repo); + const oldGraph = await fs.readFile(lbugPath); + const oldMeta = await loadMeta(storagePath); + const oldRegistry = await readRegistry(); + expect(oldRegistry).toHaveLength(1); + expect(oldRegistry[0]).toMatchObject({ + lastCommit: oldMeta!.lastCommit, + indexedAt: oldMeta!.indexedAt, + }); + + await fs.writeFile( + path.join(repo, 'a.ts'), + 'export function replacement() { return "new graph"; }\n', + ); + execSync('git -c user.name=t -c user.email=t@t commit -am replacement', { + cwd: repo, + stdio: 'pipe', + }); + const nextCommit = execSync('git rev-parse HEAD', { + cwd: repo, + encoding: 'utf8', + }).trim(); + expect(nextCommit).not.toBe(oldMeta!.lastCommit); + + const injected = new Error(`injected final ${failurePoint} failure`); + ctx.loadMock.mockClear(); + ctx.deleteMock.mockClear(); + if (failurePoint === 'close') { + ctx.closeMock.mockImplementation(async () => { + // Actually release native handles, then inject the rejection only + // after loading the new graph, not at the pre-rebuild close. + await ctx.realClose!(); + if (ctx.loadMock.mock.calls.length > 0) throw injected; + }); + } else if (failurePoint === 'swap') { + ctx.renameMock.mockImplementation( + async (...args: Parameters) => { + const [from, to] = args; + if (from.startsWith(`${lbugPath}.staging.`) && to === lbugPath) throw injected; + await ctx.realRename!(...args); + }, + ); + } else { + ctx.saveMetaMock.mockImplementation( + async (...args: Parameters) => { + const [, meta] = args; + if (meta.lastCommit === nextCommit && !meta.incrementalInProgress) throw injected; + await ctx.realSaveMeta!(...args); + }, + ); + } + + const failure = await runFullAnalysis( + repo, + writeMode === 'full' ? { force: true } : { atomicIncremental: true }, + { onProgress: () => {} }, + ).catch((error: unknown) => error); + expect(failure).toBe(injected); + expect(ctx.loadMock).toHaveBeenCalled(); + expect(ctx.deleteMock.mock.calls.length > 0).toBe(writeMode === 'incremental'); + expect(await readRegistry()).toEqual(oldRegistry); + expect(await loadMeta(storagePath)).toMatchObject({ lastCommit: oldMeta!.lastCommit }); + expect(await lingeringTemp(lbugPath)).toEqual([]); + + // A metadata failure occurs after the swap; close/swap failures keep + // the old bytes. Both cases must retain the old registry receipt. + const published = failurePoint === 'metadata'; + expect(analyzeFailureMayHaveMutatedLiveIndex(failure)).toBe(published); + expect((await fs.readFile(lbugPath)).equals(oldGraph)).toBe(!published); + await poolInit(repoId, lbugPath); + const names = (await poolQuery(repoId, 'MATCH (f:Function) RETURN f.name AS n')).flatMap( + (row) => Object.values(row as Record).map(String), + ); + expect(names.sort()).toEqual(published ? ['replacement'] : ['caller', 'greet']); + } finally { + await poolClose(repoId); + await cleanup(); + } + }, + 180_000, + ); + it('marks a failure after an atomic publish as potentially live-mutating', async () => { const { repo, cleanup } = await makeRepo(); try { diff --git a/gitnexus/test/integration/analyze-staged-embedding-recovery.test.ts b/gitnexus/test/integration/analyze-staged-embedding-recovery.test.ts new file mode 100644 index 000000000..33db6b36b --- /dev/null +++ b/gitnexus/test/integration/analyze-staged-embedding-recovery.test.ts @@ -0,0 +1,451 @@ +/** + * Exercise the CLI -> native staged DB -> durable checkpoint -> SIGKILL -> + * isolated recovery -> graph rebuild -> publication chain. The endpoint is + * local and deterministic; request text is the billing/reuse oracle. + */ +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { CLI_SPAWN_PREFIX, tsxLoaderUrl } from '../helpers/cli-entry.js'; + +const DIMS = 8; +const NODE_COUNT = 5_128; +const DEADLINE = process.env.CI ? 180_000 : 120_000; +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const adapterUrl = pathToFileURL(path.join(packageRoot, 'src/core/lbug/lbug-adapter.ts')).href; + +interface CheckpointMeta { + repoPath: string; + stats?: { embeddings?: number }; + embeddingCheckpoint?: { + nodesProcessed: number; + chunksProcessed: number; + pendingNodeIds?: string[]; + recovery?: { stagingFile: string; schemaFingerprint: string; unsafeNodeIds: string[] }; + }; +} + +interface Run { + child: ChildProcess; + output: () => string; + done: Promise<{ code: number | null; signal: NodeJS.Signals | null; output: string }>; +} + +let root: string; +let stoppedRepo: string; +let server: http.Server; +let endpoint: string; +let submitted: string[] = []; +let completed: string[] = []; +let durableTexts: string[] = []; +const activeWindowCompleted: string[] = []; +let held: string[] = []; +let stopAfterCheckpoint = false; +let currentRepo: string; +let gateResolve: (() => void) | undefined; +const running = new Set(); + +function readMeta(repo: string): CheckpointMeta { + return JSON.parse(fs.readFileSync(path.join(repo, '.gitnexus', 'gitnexus.json'), 'utf8')); +} + +function cliEnv(repo: string): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (key.startsWith('GITNEXUS_EMBEDDING_') || key.startsWith('GITNEXUS_STORAGE_')) + delete env[key]; + } + return { + ...env, + GITNEXUS_HOME: path.join(root, `home-${path.basename(repo)}`), + GITNEXUS_SHARED_STORE: 'off', + GITNEXUS_LBUG_EXTENSION_INSTALL: 'never', + GITNEXUS_LBUG_BUFFER_POOL_SIZE: String(256 * 1024 * 1024), + GITNEXUS_EMBEDDING_URL: endpoint, + GITNEXUS_EMBEDDING_MODEL: 'staged-recovery-fixture', + GITNEXUS_EMBEDDING_DIMS: String(DIMS), + GITNEXUS_EMBEDDING_BATCH_SIZE: '5000', + GITNEXUS_EMBEDDING_SUB_BATCH_SIZE: '64', + GITNEXUS_EMBEDDING_MAX_ATTEMPTS: '1', + GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT: '0', + GITNEXUS_MEMORY: 'off', + // SIGKILL skips process exit hooks; keep orphaned cache/export spills in + // this suite's owned directory so afterAll can remove them as well. + TMPDIR: path.join(root, 'tmp'), + NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=2048`.trim(), + CI: '1', + }; +} + +function runAnalyze(repo: string, flags: string[] = [], onOutput?: (output: string) => void): Run { + let output = ''; + const child = spawn( + process.execPath, + [ + ...CLI_SPAWN_PREFIX, + 'analyze', + repo, + '--no-share', + '--skip-skills', + '--skip-fts', + '--workers', + '1', + ...flags, + ], + { cwd: repo, env: cliEnv(repo), stdio: ['ignore', 'pipe', 'pipe'] }, + ); + running.add(child); + const timeout = setTimeout(() => child.kill('SIGKILL'), DEADLINE); + const collect = (chunk: Buffer) => { + output += chunk.toString(); + onOutput?.(output); + }; + child.stdout?.on('data', collect); + child.stderr?.on('data', collect); + const done = new Promise<{ code: number | null; signal: NodeJS.Signals | null; output: string }>( + (resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => { + clearTimeout(timeout); + running.delete(child); + resolve({ code, signal, output }); + }); + }, + ); + return { child, done, output: () => output }; +} + +async function successfulAnalyze(repo: string, flags: string[] = []): Promise { + submitted = []; + stopAfterCheckpoint = false; + currentRepo = repo; + const result = await runAnalyze(repo, flags).done; + expect(result.output, `CLI exited ${result.code}, signal ${result.signal}`).not.toContain( + 'SIGABRT', + ); + expect(result.code, result.output).toBe(0); + return [...submitted]; +} + +function cloneStoppedRepo(name: string): string { + const repo = path.join(root, name); + fs.cpSync(stoppedRepo, repo, { recursive: true }); + for (const filename of ['gitnexus.json', 'meta.json']) { + const target = path.join(repo, '.gitnexus', filename); + const meta = JSON.parse(fs.readFileSync(target, 'utf8')); + meta.repoPath = repo; + meta.storagePath = path.join(repo, '.gitnexus'); + fs.writeFileSync(target, JSON.stringify(meta)); + } + return repo; +} + +function readPublishedRows( + repo: string, +): Array<{ nodeId: string; chunkIndex: number; embedding: number[] }> { + // Keep native handles out of the vitest fork, and wait for clean teardown + // before accepting the receipt. Every read opens only a published DB. + const receiptPath = path.join(root, `rows-${path.basename(repo)}.json`); + const script = ` + const adapter = await import(${JSON.stringify(adapterUrl)}); + const fs = await import('node:fs'); + await adapter.initLbug(${JSON.stringify(path.join(repo, '.gitnexus', 'lbug'))}); + try { + const rows = await adapter.executeQuery('MATCH (e:CodeEmbedding) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.embedding AS embedding'); + fs.writeFileSync(${JSON.stringify(receiptPath)}, JSON.stringify(rows)); + console.log('ROWS_RECEIPT:' + rows.length); + } finally { await adapter.closeLbug(); } + `; + const result = spawnSync( + process.execPath, + ['--import', tsxLoaderUrl(), '--input-type=module', '-e', script], + { + cwd: packageRoot, + env: cliEnv(repo), + encoding: 'utf8', + timeout: 30_000, + }, + ); + try { + const diagnostic = + `${result.error ?? ''} ${result.signal ?? ''}\n${result.stderr}\n${result.stdout}`.slice( + 0, + 4096, + ); + expect(result.status, diagnostic).toBe(0); + expect(result.stdout).toMatch(/ROWS_RECEIPT:\d+/); + return JSON.parse(fs.readFileSync(receiptPath, 'utf8')); + } finally { + fs.rmSync(receiptPath, { force: true }); + } +} + +function expectPublishedComplete(repo: string, expectedNodes: number): void { + const meta = readMeta(repo); + expect(meta.embeddingCheckpoint).toBeUndefined(); + const rows = readPublishedRows(repo); + expect(new Set(rows.map((row) => row.nodeId)).size).toBe(expectedNodes); + expect(meta.stats?.embeddings).toBe(rows.length); + const byNode = new Map(); + for (const row of rows) { + expect(row.embedding).toHaveLength(DIMS); + expect(row.embedding.every(Number.isFinite)).toBe(true); + const indices = byNode.get(row.nodeId) ?? []; + indices.push(row.chunkIndex); + byNode.set(row.nodeId, indices); + } + for (const indices of byNode.values()) { + expect(indices.sort((a, b) => a - b)).toEqual( + Array.from({ length: indices.length }, (_, index) => index), + ); + } + expect( + fs.readdirSync(path.join(repo, '.gitnexus')).filter((name) => name.startsWith('lbug.staging.')), + ).toEqual([]); +} + +beforeAll(async () => { + if (process.platform === 'win32') return; + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-staged-recovery-e2e-')); + fs.mkdirSync(path.join(root, 'tmp')); + stoppedRepo = path.join(root, 'interrupted'); + currentRepo = stoppedRepo; + fs.mkdirSync(stoppedRepo); + const shortFunctions = Array.from( + { length: NODE_COUNT - 1 }, + (_, index) => `export function recoverable${index}() { return ${index}; }`, + ); + // Multi-chunk nodes must be reused as a complete group, including their tail. + const longFunction = `export function longRecoverable() {\n${Array.from({ length: 80 }, (_, index) => ` // retained chunk marker ${index} ${'x'.repeat(80)}`).join('\n')}\n return 42;\n}`; + fs.writeFileSync( + path.join(stoppedRepo, 'functions.ts'), + `${longFunction}\n${shortFunctions.join('\n')}\n`, + ); + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: 'test', + GIT_AUTHOR_EMAIL: 'test@test', + GIT_COMMITTER_NAME: 'test', + GIT_COMMITTER_EMAIL: 'test@test', + }; + for (const args of [['init'], ['add', 'functions.ts'], ['commit', '-m', 'recovery fixture']]) { + const result = spawnSync('git', args, { cwd: stoppedRepo, env: gitEnv, encoding: 'utf8' }); + expect(result.status, result.stderr).toBe(0); + } + server = http.createServer((request, response) => { + let body = ''; + request.on('data', (chunk) => { + body += chunk; + }); + request.on('end', () => { + const { input } = JSON.parse(body) as { input: string[] }; + submitted.push(...input); + if ( + stopAfterCheckpoint && + (readMeta(currentRepo).embeddingCheckpoint?.nodesProcessed ?? 0) >= 5000 + ) { + if (activeWindowCompleted.length > 0) { + held = [...input]; + gateResolve?.(); + // One sub-batch has already inserted rows inside the unsafe window. + // Awaiting the next real request gates a crash before it completes. + return; + } + durableTexts = [...completed]; + activeWindowCompleted.push(...input); + } + completed.push(...input); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + data: input.map((text, index) => ({ + index, + embedding: Array.from( + { length: DIMS }, + (_, dimension) => ((text.length + dimension * 17) % 101) / 101, + ), + })), + }), + ); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address() as { port: number }; + endpoint = `http://127.0.0.1:${address.port}/v1`; + await successfulAnalyze(stoppedRepo, ['--index-only']); + expect(readMeta(stoppedRepo).stats?.embeddings ?? 0).toBe(0); + completed = []; + held = []; + stopAfterCheckpoint = true; + const gate = new Promise((resolve) => { + gateResolve = resolve; + }); + const first = runAnalyze(stoppedRepo, ['--force', '--embeddings']); + await Promise.race([ + gate, + first.done.then((result) => { + throw new Error(`CLI exited before recovery gate: ${result.output}`); + }), + ]); + first.child.kill('SIGKILL'); + expect((await first.done).signal).toBe('SIGKILL'); + const interrupted = readMeta(stoppedRepo); + expect(interrupted.embeddingCheckpoint?.nodesProcessed).toBe(5000); + expect(interrupted.embeddingCheckpoint?.recovery?.stagingFile).toMatch(/^lbug\.staging\./); + expect(interrupted.stats?.embeddings ?? 0).toBe(0); + expect(completed.length).toBeGreaterThanOrEqual(5000); + expect(completed.filter((text) => text.includes('longRecoverable')).length).toBeGreaterThan(1); + expect(activeWindowCompleted).toHaveLength(64); + expect(held.length).toBeGreaterThan(0); + gateResolve = undefined; + stopAfterCheckpoint = false; +}, DEADLINE * 2); + +afterAll(async () => { + for (const child of running) child.kill('SIGKILL'); + await Promise.all( + [...running].map( + (child) => new Promise((resolve) => child.once('close', () => resolve())), + ), + ); + if (server) { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +// Atomic publication is POSIX-specific; Windows uses the in-place path. +describe.skipIf(process.platform === 'win32')( + 'interrupted staged embedding recovery (real CLI and native DB)', + { concurrent: false }, + () => { + it.each([ + ['plain', []], + ['forced', ['--force', '--embeddings']], + ] as const)( + '%s retry bills only unfinished groups and publishes an honest count', + async (name, flags) => { + const repo = cloneStoppedRepo(name); + const texts = await successfulAnalyze(repo, [...flags]); + const durable = new Set(durableTexts); + expect(texts.filter((text) => durable.has(text))).toEqual([]); + for (const text of held) expect(texts).toContain(text); + for (const text of activeWindowCompleted) expect(texts).toContain(text); + expect(texts.length).toBe(128); + expectPublishedComplete(repo, NODE_COUNT); + }, + DEADLINE, + ); + + it( + 'manual checkpoint opt-out completes a staged retry without resubmitting durable chunks', + async () => { + const repo = cloneStoppedRepo('manual-checkpoint-opt-out'); + const previous = process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT; + process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = '0'; + try { + const texts = await successfulAnalyze(repo, ['--force', '--embeddings']); + expect(texts.filter((text) => new Set(durableTexts).has(text))).toEqual([]); + expect(texts.length).toBe(128); + expectPublishedComplete(repo, NODE_COUNT); + } finally { + if (previous === undefined) delete process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT; + else process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = previous; + } + }, + DEADLINE, + ); + + it( + 'survives another crash after harvesting but before the replacement is durable', + async () => { + const repo = cloneStoppedRepo('crash-again'); + const source = readMeta(repo).embeddingCheckpoint?.recovery?.stagingFile; + expect(source).toBeDefined(); + if (!source) throw new Error('fixture has no retained generation'); + submitted = []; + currentRepo = repo; + let killed = false; + const retry = runAnalyze(repo, [], (output) => { + if (!killed && /Recovered \d+ complete staged embedding chunk/.test(output)) { + killed = true; + retry.child.kill('SIGKILL'); + } + }); + const result = await retry.done; + expect(killed, result.output).toBe(true); + expect(result.signal).toBe('SIGKILL'); + expect(submitted).toEqual([]); + expect(readMeta(repo).embeddingCheckpoint?.recovery?.stagingFile).toBe(source); + expect(fs.existsSync(path.join(repo, '.gitnexus', source))).toBe(true); + const finalTexts = await successfulAnalyze(repo); + expect(finalTexts.filter((text) => new Set(durableTexts).has(text))).toEqual([]); + expect(finalTexts.length).toBe(128); + expectPublishedComplete(repo, NODE_COUNT); + }, + DEADLINE * 2, + ); + + it( + 'regenerates changed content and removes deleted nodes while reusing the other complete groups', + async () => { + const repo = cloneStoppedRepo('changed'); + const source = path.join(repo, 'functions.ts'); + const content = fs + .readFileSync(source, 'utf8') + .replace( + 'export function recoverable5() { return 5; }', + 'export function recoverable5() { return 999999; }', + ) + .replace( + 'export function recoverable6() { return 6; }', + '// deleted function retains line offsets', + ); + fs.writeFileSync(source, content); + const texts = await successfulAnalyze(repo); + expect(texts.some((text) => text.includes('recoverable5') && text.includes('999999'))).toBe( + true, + ); + expect(texts.some((text) => text.includes('recoverable6'))).toBe(false); + expect(texts.filter((text) => new Set(durableTexts).has(text))).toEqual([]); + expect(texts.length).toBe(129); + expectPublishedComplete(repo, NODE_COUNT - 1); + }, + DEADLINE, + ); + + it( + 'a forced retry with a different model does not import the staged cache', + async () => { + const repo = cloneStoppedRepo('different-model'); + const texts = await successfulAnalyze(repo, [ + '--force', + '--embeddings', + '--embedding-model', + 'different-model', + ]); + for (const text of durableTexts) expect(texts).toContain(text); + expect(texts.length).toBeGreaterThanOrEqual(NODE_COUNT); + expectPublishedComplete(repo, NODE_COUNT); + }, + DEADLINE, + ); + + it( + 'explicit drop abandons staged vectors without contacting the provider', + async () => { + const repo = cloneStoppedRepo('drop'); + expect(await successfulAnalyze(repo, ['--force', '--drop-embeddings'])).toEqual([]); + expect(readMeta(repo).embeddingCheckpoint).toBeUndefined(); + expect(readMeta(repo).stats?.embeddings ?? 0).toBe(0); + expect(readPublishedRows(repo)).toEqual([]); + }, + DEADLINE, + ); + }, +); diff --git a/gitnexus/test/integration/api-query.test.ts b/gitnexus/test/integration/api-query.test.ts index 70cc81287..caac05dcd 100644 --- a/gitnexus/test/integration/api-query.test.ts +++ b/gitnexus/test/integration/api-query.test.ts @@ -51,8 +51,8 @@ withTestLbugDB( }); expect(response.status).toBe(200); const body = await response.json(); - expect(Array.isArray(body.result)).toBe(true); - expect(body.result[0].one).toBe(1); + expect(body).toHaveProperty('result', expect.any(Array)); + expect(body).toHaveProperty('result.0.one', 1); }); it('returns 403 for a write query on read-only HTTP path', async () => { @@ -65,7 +65,10 @@ withTestLbugDB( }); expect(response.status).toBe(403); const body = await response.json(); - expect(body.error).toContain('Write queries are not allowed'); + expect(body).toHaveProperty( + 'error', + expect.stringContaining('Write queries are not allowed'), + ); }); it('returns 400 for invalid params payload', async () => { @@ -76,7 +79,7 @@ withTestLbugDB( }); expect(response.status).toBe(400); const body = await response.json(); - expect(body.error).toContain('"params"'); + expect(body).toHaveProperty('error', expect.stringContaining('"params"')); }); it('returns 400 when cypher is missing', async () => { @@ -87,7 +90,7 @@ withTestLbugDB( }); expect(response.status).toBe(400); const body = await response.json(); - expect(body.error).toContain('Missing "cypher"'); + expect(body).toHaveProperty('error', expect.stringContaining('Missing "cypher"')); }); }); }, diff --git a/gitnexus/test/integration/augmentation.test.ts b/gitnexus/test/integration/augmentation.test.ts index 460df12a6..479fe393f 100644 --- a/gitnexus/test/integration/augmentation.test.ts +++ b/gitnexus/test/integration/augmentation.test.ts @@ -21,6 +21,11 @@ const AUGMENT_SEED_DATA = [ `CREATE (n:Function {id: 'func:login', name: 'login', filePath: 'src/auth.ts', startLine: 1, endLine: 15, isExported: true, content: 'function login authenticates user credentials', description: 'user login'})`, `CREATE (n:Function {id: 'func:validate', name: 'validate', filePath: 'src/auth.ts', startLine: 17, endLine: 25, isExported: true, content: 'function validate checks user input', description: 'input validation'})`, `CREATE (n:Function {id: 'func:hash', name: 'hash', filePath: 'src/utils.ts', startLine: 1, endLine: 8, isExported: true, content: 'function hash computes bcrypt hash', description: 'password hashing'})`, + ...Array.from( + { length: 5 }, + (_, i) => + `CREATE (n:Function {id: 'a:login-${i}', name: 'loginPartial${i}', filePath: 'src/login-partial-${i}.ts', startLine: 1, endLine: 2, isExported: true, content: 'partial login match ${i}', description: 'login partial'})`, + ), // Class / Method / Interface nodes `CREATE (n:Class {id: 'class:AuthService', name: 'AuthService', filePath: 'src/auth.ts', startLine: 30, endLine: 60, isExported: true, content: 'class AuthService handles authentication', description: 'auth service'})`, @@ -130,23 +135,40 @@ withTestLbugDB( expect(result).toBe(''); }); - // ─── Negative-safety: fallback must stay gated on !ftsAvailable ─── - // - // When FTS is available but happens to return zero BM25 hits, the - // CONTAINS fallback must NOT fire — preserving the original early-return - // semantics. If anyone later loosens the gate to `symbolMatches.length - // === 0` alone, this test fails. - - it('does NOT fire CONTAINS fallback when FTS is available but BM25 returns empty', async () => { + it('falls back to graph names when FTS is available but BM25 returns empty', async () => { const bm25 = await import('../../src/core/search/bm25-index.js'); const spy = vi .spyOn(bm25, 'searchFTSFromLbug') .mockResolvedValue({ results: [], ftsAvailable: true }); try { - // 'login' WOULD match a graph node via CONTAINS, but FTS is available - // and empty → fallback gate must hold → result must be ''. + // FTS health does not imply its top file results contain the symbol's + // definition. The graph-name fallback must recover the exact node. const result = await augment('login', handle.dbPath); - expect(result).toBe(''); + expect(result).toContain('[GitNexus]'); + expect(result).toMatch(/^login \(src\/auth\.ts\)$/m); + } finally { + spy.mockRestore(); + } + }); + + it('finds an exact-name symbol when FTS ranks only mentioning files', async () => { + const bm25 = await import('../../src/core/search/bm25-index.js'); + const spy = vi.spyOn(bm25, 'searchFTSFromLbug').mockResolvedValue({ + results: Array.from({ length: 5 }, (_, i) => ({ + filePath: `src/caller-${i}.ts`, + score: 100 - i, + rank: i + 1, + nodeIds: [`file:caller-${i}`], + })), + ftsAvailable: true, + }); + try { + // Five partial login matches have IDs that sort before func:login, so + // the five-row fallback only retains the exact node when exact names + // rank ahead of partial matches. + const result = await augment('login', handle.dbPath); + expect(result).toContain('[GitNexus]'); + expect(result).toMatch(/^login \(src\/auth\.ts\)$/m); } finally { spy.mockRestore(); } diff --git a/gitnexus/test/integration/cfg/cfg-emit.test.ts b/gitnexus/test/integration/cfg/cfg-emit.test.ts index 66f9a35fc..32ed86652 100644 --- a/gitnexus/test/integration/cfg/cfg-emit.test.ts +++ b/gitnexus/test/integration/cfg/cfg-emit.test.ts @@ -490,6 +490,7 @@ describe('U4 (#2085 M5) — emitFileCdg', () => { const unsound: FunctionCfg = { filePath: 'spin.ts', functionStartLine: 1, + functionEndLine: 4, functionStartColumn: 0, entryIndex: 0, exitIndex: 3, diff --git a/gitnexus/test/integration/clean-stale-branch-slots.test.ts b/gitnexus/test/integration/clean-stale-branch-slots.test.ts index b287da7e6..4cdf4605b 100644 --- a/gitnexus/test/integration/clean-stale-branch-slots.test.ts +++ b/gitnexus/test/integration/clean-stale-branch-slots.test.ts @@ -68,7 +68,7 @@ describe('clean --stale leftover branch slots (#3331)', () => { } vi.spyOn(process, 'cwd').mockReturnValue(repo); vi.spyOn(console, 'log').mockImplementation(() => {}); - return { dir, storagePath }; + return { repo, dir, storagePath }; } it('reclaims a slot after the git branch is deleted', async () => { diff --git a/gitnexus/test/integration/context-resource-staleness.test.ts b/gitnexus/test/integration/context-resource-staleness.test.ts index ab94fcab8..c5f58454d 100644 --- a/gitnexus/test/integration/context-resource-staleness.test.ts +++ b/gitnexus/test/integration/context-resource-staleness.test.ts @@ -207,4 +207,26 @@ describe('context resource freshness — out-of-process analyze (#2438)', () => expect(result).toContain('symbols: 333'); expect(result).toContain('processes: 4'); }); + + it('does not claim divergence when disk metadata and the cached handle have no indexed commit', async function missingIndexedCommitDoesNotClaimDivergence() { + writeFileSync(path.join(repoPath, 'a.ts'), 'export const a = 1;\n'); + runGit(repoPath, 'add', 'a.ts'); + runGit(repoPath, 'commit', '-m', 'c1'); + + // A legacy metadata/registry entry can omit lastCommit despite RepoMeta's + // required field; seed that persisted shape rather than a symbolic ref. + await seedIndexedRepo(repoPath, storagePath, { + repoPath, + indexedAt: '2024-01-01T00:00:00Z', + stats: { files: 1, nodes: 1, processes: 0 }, + } as RepoMeta); + + const backend = new LocalBackend(); + await backend.init(); + expect((await backend.resolveRepo('test-repo')).lastCommit).toBeUndefined(); + + const result = await readResource('gitnexus://repo/test-repo/context', backend); + expect(result).not.toContain('staleness:'); + expect(result).toContain(' commit: ""'); + }); }); diff --git a/gitnexus/test/integration/data-route-table-pipeline.test.ts b/gitnexus/test/integration/data-route-table-pipeline.test.ts index 0965a80b3..7bcaa18ec 100644 --- a/gitnexus/test/integration/data-route-table-pipeline.test.ts +++ b/gitnexus/test/integration/data-route-table-pipeline.test.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; import { DATA_ROUTE_TABLE_SOURCE } from '../../src/core/ingestion/route-extractors/data-route-table.js'; import { loadParseCache, diff --git a/gitnexus/test/integration/detect-changes-path-anchoring.test.ts b/gitnexus/test/integration/detect-changes-path-anchoring.test.ts index f4bd381d8..f65cdec6b 100644 --- a/gitnexus/test/integration/detect-changes-path-anchoring.test.ts +++ b/gitnexus/test/integration/detect-changes-path-anchoring.test.ts @@ -14,6 +14,7 @@ * 0-based line space (#2377, #2915). */ import { it, expect, beforeAll, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; import { mkdirSync, writeFileSync } from 'fs'; import path from 'path'; import { LocalBackend } from '../../src/mcp/local/local-backend.js'; @@ -58,8 +59,10 @@ function makeWorkingCopy(): string { /** The fields these tests read off one `detect_changes` run. */ type DetectChangesResult = { error?: unknown; - summary: { changed_count: number }; + summary: { changed_count: number; risk_level: string }; changed_symbols: { name: string; filePath: string }[]; + partial?: boolean; + unmapped_files?: string[]; }; withTestLbugDB( @@ -118,3 +121,51 @@ withTestLbugDB( }, }, ); + +withTestLbugDB( + 'detect-changes-unindexed-sibling', + (handle) => { + it('does not certify a new root index.ts from an indexed pkg/index.ts suffix match', async () => { + const backend = (handle as typeof handle & { _backend: LocalBackend })._backend; + const result = (await backend.callTool('detect_changes', { + scope: 'staged', + })) as DetectChangesResult; + expect(result.error).toBeUndefined(); + // The fallback remains visible, but cannot certify the identity of the new file. + expect(result.changed_symbols).toEqual([ + expect.objectContaining({ name: 'helper', filePath: 'pkg/index.ts' }), + ]); + expect(result.summary.risk_level).toBe('unknown'); + expect(result.partial).toBe(true); + expect(result.unmapped_files).toEqual(['index.ts']); + }); + }, + { + seed: [ + "CREATE (fn:Function {id: 'Function:pkg/index.ts:helper', name: 'helper', filePath: 'pkg/index.ts', startLine: 0, endLine: 1, isExported: true})", + ], + poolAdapter: true, + afterSetup: async (handle) => { + const repoDir = tempDirs.dir(); + mkdirSync(path.join(repoDir, 'pkg'), { recursive: true }); + writeFileSync(path.join(repoDir, 'pkg/index.ts'), 'export function helper() {\n}\n'); + initGitRepo(repoDir); + commitAll(repoDir, 'indexed sibling'); + writeFileSync(path.join(repoDir, 'index.ts'), 'export function added() {\n}\n'); + execFileSync('git', ['add', 'index.ts'], { cwd: repoDir }); + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'sibling-repo', + path: repoDir, + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'abc1234', + stats: { files: 1, nodes: 1, edges: 0, communities: 0, processes: 0 }, + }, + ]); + const backend = new LocalBackend(); + await backend.init(); + (handle as typeof handle & { _backend: LocalBackend })._backend = backend; + }, + }, +); diff --git a/gitnexus/test/integration/dispatch-guard-route-pipeline.test.ts b/gitnexus/test/integration/dispatch-guard-route-pipeline.test.ts index a8fb9153a..1d693b33b 100644 --- a/gitnexus/test/integration/dispatch-guard-route-pipeline.test.ts +++ b/gitnexus/test/integration/dispatch-guard-route-pipeline.test.ts @@ -20,7 +20,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'node:path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; import { DISPATCH_GUARD_SOURCE } from '../../src/core/ingestion/route-extractors/dispatch-guard.js'; const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'dispatch-guard-app'); diff --git a/gitnexus/test/integration/django-route-extraction-e2e.test.ts b/gitnexus/test/integration/django-route-extraction-e2e.test.ts index 3b11cc1bf..0ef6a13e6 100644 --- a/gitnexus/test/integration/django-route-extraction-e2e.test.ts +++ b/gitnexus/test/integration/django-route-extraction-e2e.test.ts @@ -13,7 +13,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'node:path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'django-subdir-app'); diff --git a/gitnexus/test/integration/doc-comment-description-e2e.test.ts b/gitnexus/test/integration/doc-comment-description-e2e.test.ts index 9424ae32e..dcdb5057e 100644 --- a/gitnexus/test/integration/doc-comment-description-e2e.test.ts +++ b/gitnexus/test/integration/doc-comment-description-e2e.test.ts @@ -59,7 +59,6 @@ describe('doc-comment description end-to-end (issue #2270)', () => { it('surfaces an exported function JSDoc as its node description through the pipeline', async () => { const result: PipelineResult = await runPipelineFromRepo(createTsRepo(), () => {}, { skipGraphPhases: true, - workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, workerPoolSize: 2, }); @@ -79,7 +78,6 @@ describe('doc-comment description end-to-end (issue #2270)', () => { async () => { const result: PipelineResult = await runPipelineFromRepo(createSwiftRepo(), () => {}, { skipGraphPhases: true, - workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, workerPoolSize: 2, }); diff --git a/gitnexus/test/integration/expo-routes.test.ts b/gitnexus/test/integration/expo-routes.test.ts index 74c63dc2a..066c4bcf9 100644 --- a/gitnexus/test/integration/expo-routes.test.ts +++ b/gitnexus/test/integration/expo-routes.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; const EXPO_APP = path.resolve(__dirname, '..', 'fixtures', 'expo-app'); diff --git a/gitnexus/test/integration/fastapi-composed-route-constants.test.ts b/gitnexus/test/integration/fastapi-composed-route-constants.test.ts index 89e6bcb45..1c066154e 100644 --- a/gitnexus/test/integration/fastapi-composed-route-constants.test.ts +++ b/gitnexus/test/integration/fastapi-composed-route-constants.test.ts @@ -21,7 +21,7 @@ import * as os from 'node:os'; import Parser from 'tree-sitter'; import Python from 'tree-sitter-python'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; import { PYTHON_HTTP_PLUGIN } from '../../src/core/group/extractors/http-patterns/python.js'; import { loadParseCache, @@ -141,6 +141,7 @@ describe('FastAPI composed route constants — ingestion↔group parity (#2391 R return p.parse(src); }; const ctx = PYTHON_HTTP_PLUGIN.prepareRepo?.({ + repoPath: '/test-repo', files: Object.keys(files), parser, readFile: (r) => files[r] ?? null, diff --git a/gitnexus/test/integration/fastapi-prefix-pipeline.test.ts b/gitnexus/test/integration/fastapi-prefix-pipeline.test.ts index 0450f123b..07b269847 100644 --- a/gitnexus/test/integration/fastapi-prefix-pipeline.test.ts +++ b/gitnexus/test/integration/fastapi-prefix-pipeline.test.ts @@ -46,7 +46,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'node:path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'fastapi-prefix-app'); diff --git a/gitnexus/test/integration/fts-extension-e2e.test.ts b/gitnexus/test/integration/fts-extension-e2e.test.ts index 47f79d137..9bc6ad544 100644 --- a/gitnexus/test/integration/fts-extension-e2e.test.ts +++ b/gitnexus/test/integration/fts-extension-e2e.test.ts @@ -25,6 +25,7 @@ import { spawnSync } from 'child_process'; import path from 'path'; import fs from 'fs'; import os from 'os'; +import { inspect } from 'node:util'; import { getExtensionInstallChildProcessArgs } from '../../src/core/lbug/extension-loader.js'; import { @@ -144,6 +145,8 @@ const makeFixtureRepo = (label: string): string => { interface CliResult { status: number | null; + /** Keep native termination and spawn failures visible in assertion messages. */ + diagnostics: string; /** stdout + stderr combined — warn lines and progress renderer interleave streams. */ output: string; } @@ -172,7 +175,20 @@ const runCli = ( NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), }, }); - return { status: result.status, output: `${result.stdout ?? ''}\n${result.stderr ?? ''}` }; + return { + status: result.status, + diagnostics: inspect( + { + status: result.status, + signal: result.signal, + error: result.error, + stdout: result.stdout, + stderr: result.stderr, + }, + { depth: null, maxStringLength: null }, + ), + output: `${result.stdout ?? ''}\n${result.stderr ?? ''}`, + }; }; beforeAll(() => { @@ -208,7 +224,7 @@ describe('happy path — extension pre-installed, fully offline (load-only)', () it('analyze builds the index with FTS and emits no degradation warning', () => { const result = runCli(['analyze'], repo, home, 'load-only'); - expect(result.status).toBe(0); + expect(result.status, result.diagnostics).toBe(0); expect(result.output).toContain('indexed successfully'); expect(result.output).not.toContain('FTS extension unavailable'); expect(result.output).not.toContain('search is disabled'); @@ -216,14 +232,14 @@ describe('happy path — extension pre-installed, fully offline (load-only)', () it('query finds the symbol via BM25 with no degradation warning', () => { const result = runCli(['query', 'greetE2eSymbol'], repo, home, 'load-only'); - expect(result.status).toBe(0); + expect(result.status, result.diagnostics).toBe(0); expect(result.output).toContain('greetE2eSymbol'); expect(result.output).not.toContain('keyword search degraded'); }, 60_000); it('doctor reports a live-probed available FTS and a resolved LadybugDB version', () => { const result = runCli(['doctor'], repo, home, 'load-only'); - expect(result.status).toBe(0); + expect(result.status, result.diagnostics).toBe(0); expect(result.output).toContain('Full-text search: available'); // #2374: version used to print as "unknown" on every platform. expect(result.output).toMatch(/LadybugDB:\s*\d+\.\d+\.\d+/); @@ -231,7 +247,7 @@ describe('happy path — extension pre-installed, fully offline (load-only)', () it('analyze --repair-fts rebuilds the search indexes offline', () => { const result = runCli(['analyze', '--repair-fts'], repo, home, 'load-only'); - expect(result.status).toBe(0); + expect(result.status, result.diagnostics).toBe(0); expect(result.output).toContain('FTS indexes repaired successfully'); }, 180_000); }); @@ -250,7 +266,7 @@ describe('packaged vendor survives a broken or missing home copy', () => { it('analyze stays FTS-available when ~/.lbdb is broken', () => { const result = runCli(['analyze'], repo, home, 'load-only'); - expect(result.status).toBe(0); + expect(result.status, result.diagnostics).toBe(0); expect(result.output).toContain('indexed successfully'); expect(result.output).not.toContain('FTS extension unavailable'); expect(result.output).not.toContain('search is disabled'); @@ -258,13 +274,13 @@ describe('packaged vendor survives a broken or missing home copy', () => { it('analyze --repair-fts succeeds from the packaged artifact', () => { const result = runCli(['analyze', '--repair-fts'], repo, home, 'load-only'); - expect(result.status).toBe(0); + expect(result.status, result.diagnostics).toBe(0); expect(result.output).toContain('FTS indexes repaired successfully'); }, 180_000); it('query finds the symbol with no HOME-copy degradation warning', () => { const result = runCli(['query', 'greetE2eSymbol'], repo, home, 'load-only'); - expect(result.status).toBe(0); + expect(result.status, result.diagnostics).toBe(0); expect(result.output).toContain('greetE2eSymbol'); expect(result.output).not.toContain('keyword search degraded'); expect(result.output).not.toContain('FTS extension failed to load'); @@ -272,7 +288,7 @@ describe('packaged vendor survives a broken or missing home copy', () => { it('doctor reports a live-probed available FTS despite a broken HOME copy', () => { const result = runCli(['doctor'], repo, home, 'load-only'); - expect(result.status).toBe(0); + expect(result.status, result.diagnostics).toBe(0); expect(result.output).toContain('Full-text search: available'); }, 60_000); @@ -280,7 +296,7 @@ describe('packaged vendor survives a broken or missing home copy', () => { const missing = makeHome('missing'); const missingRepo = makeFixtureRepo('missing'); const result = runCli(['analyze'], missingRepo, missing.home, 'load-only'); - expect(result.status).toBe(0); + expect(result.status, result.diagnostics).toBe(0); expect(result.output).toContain('indexed successfully'); expect(result.output).not.toContain('FTS extension unavailable'); expect(result.output).not.toContain('has not been installed'); @@ -295,7 +311,7 @@ describe('regression — the home copy disappears between analyze runs (#2841)', // 1. First analyze with the extension in place: the index ends up carrying // an FTS index on every searchable table. const first = runCli(['analyze'], repo, home, 'load-only'); - expect(first.status).toBe(0); + expect(first.status, first.diagnostics).toBe(0); // This case needs run 1 to actually BUILD the indexes — without them there // is nothing for the gate to trip on and the assertions below would be // vacuous. When the seeded extension cannot load on this host (the same @@ -332,7 +348,7 @@ describe('regression — the home copy disappears between analyze runs (#2841)', // table File but its extension is not loaded" and no mention of FTS at all. // Packaged vendor still loads after HOME vanishes, so incremental stays // incremental (no Binder, no full-DB escalation). - expect(second.status).toBe(0); + expect(second.status, second.diagnostics).toBe(0); expect(second.output).not.toContain('its extension is not loaded'); expect(second.output).not.toContain('full DB write'); expect(second.output).not.toContain('forcing full rebuild'); @@ -346,15 +362,15 @@ describe('auto policy — packaged vendor does not need a HOME reinstall', () => const repo = makeFixtureRepo('heal'); const first = runCli(['analyze'], repo, home, 'load-only'); - expect(first.status).toBe(0); + expect(first.status, first.diagnostics).toBe(0); expect(first.output).not.toContain('FTS extension unavailable'); const repair = runCli(['analyze', '--repair-fts'], repo, home, 'auto'); - expect(repair.status).toBe(0); + expect(repair.status, repair.diagnostics).toBe(0); expect(repair.output).toContain('FTS indexes repaired successfully'); const query = runCli(['query', 'greetE2eSymbol'], repo, home, 'load-only'); - expect(query.status).toBe(0); + expect(query.status, query.diagnostics).toBe(0); expect(query.output).toContain('greetE2eSymbol'); expect(query.output).not.toContain('keyword search degraded'); }, 600_000); @@ -363,7 +379,7 @@ describe('auto policy — packaged vendor does not need a HOME reinstall', () => const { home } = makeHome('missing'); const repo = makeFixtureRepo('fresh'); const result = runCli(['analyze'], repo, home, 'load-only'); - expect(result.status).toBe(0); + expect(result.status, result.diagnostics).toBe(0); expect(result.output).toContain('indexed successfully'); expect(result.output).not.toContain('FTS extension unavailable'); }, 600_000); diff --git a/gitnexus/test/integration/fts-repair-warm-session.test.ts b/gitnexus/test/integration/fts-repair-warm-session.test.ts index 2550ba874..d6e5a3ed6 100644 --- a/gitnexus/test/integration/fts-repair-warm-session.test.ts +++ b/gitnexus/test/integration/fts-repair-warm-session.test.ts @@ -32,6 +32,7 @@ const REQUIRE_FTS = process.env.GITNEXUS_REQUIRE_FTS === '1'; type QueryResult = { error?: unknown; warning?: string; + partial?: boolean; definitions?: Array<{ id: string }>; process_symbols?: Array<{ id: string }>; }; @@ -40,7 +41,8 @@ const matchedIds = (r: QueryResult): string[] => [...(r.process_symbols ?? []), ...(r.definitions ?? [])].map((s) => s.id); const ftsMissing = (r: QueryResult): boolean => - typeof r.warning === 'string' && /FTS indexes missing/i.test(r.warning); + typeof r.warning === 'string' && + /FTS indexes missing|missing configured indexes/i.test(r.warning); /** * Poll the SAME warm `LocalBackend` until it stops reporting FTS-missing, or @@ -90,14 +92,14 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', ( await tmpHandle.cleanup(); }); - it( - 'a warm session transitions from FTS-unavailable to FTS-available without restarting, after an out-of-band --repair-fts', + it.for(['all', 'Function'] as const)( + 'a warm session repairs missing %s indexes without restarting', { timeout: 60_000 }, - async (ctx) => { + async (missing, ctx) => { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { createSearchFTSIndexes } = await import('../../src/core/search/fts-indexes.js'); - // ── Step 1: build the index WITHOUT FTS (analyzed before repair) ──── + // ── Step 1: build an index with all or just one FTS index missing ── await adapter.initLbug(lbugPath); const ftsAvailable = await adapter.loadFTSExtension(undefined, { @@ -118,6 +120,10 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', ( await adapter.executeQuery( `CREATE (n:Function {id: 'func:login', name: 'login', filePath: 'src/auth.ts', startLine: 1, endLine: 3, content: 'function login() { return true; }'})`, ); + if (missing === 'Function') { + await createSearchFTSIndexes(); + await adapter.dropFTSIndex('Function', 'function_fts'); + } await adapter.flushWAL(); await adapter.closeLbug(); @@ -129,7 +135,10 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', ( stats: { files: 1, nodes: 1 }, capabilities: { graph: { provider: 'ladybugdb', status: 'available' }, - fts: { provider: 'ladybugdb-fts', status: 'unavailable' }, + fts: { + provider: 'ladybugdb-fts', + status: missing === 'all' ? 'unavailable' : 'available', + }, vectorSearch: { provider: 'exact-scan', status: 'unavailable', exactScanLimit: 0 }, }, }; @@ -142,6 +151,10 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', ( const before = await backend.callTool('query', { query: 'login' }); expect(before.error).toBeUndefined(); expect(ftsMissing(before)).toBe(true); + if (missing === 'Function') { + expect(before.partial).toBe(true); + expect(before.warning).toContain('Function.function_fts'); + } // ── Step 3: out-of-band --repair-fts (separate writable session) ──── // Same production functions the repair-fts branch of runFullAnalysis @@ -163,6 +176,7 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', ( const after = await waitForFtsRecognized(backend, 'login'); expect(after.error).toBeUndefined(); expect(ftsMissing(after)).toBe(false); + expect(after.partial).toBeUndefined(); expect(matchedIds(after)).toContain('func:login'); }, ); diff --git a/gitnexus/test/integration/go-multi-name-worker-metadata.test.ts b/gitnexus/test/integration/go-multi-name-worker-metadata.test.ts index fbf97646f..4065772d4 100644 --- a/gitnexus/test/integration/go-multi-name-worker-metadata.test.ts +++ b/gitnexus/test/integration/go-multi-name-worker-metadata.test.ts @@ -32,7 +32,6 @@ function createGoRepo(): string { async function runWorker(): Promise { return runPipelineFromRepo(createGoRepo(), () => {}, { skipGraphPhases: true, - workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, workerPoolSize: 2, }); } diff --git a/gitnexus/test/integration/go-pipeline-benchmark.test.ts b/gitnexus/test/integration/go-pipeline-benchmark.test.ts index ac3c0c4cf..04af7af9b 100644 --- a/gitnexus/test/integration/go-pipeline-benchmark.test.ts +++ b/gitnexus/test/integration/go-pipeline-benchmark.test.ts @@ -32,7 +32,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import { makeScopeId, type ParsedFile, type SymbolDefinition } from 'gitnexus-shared'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; import { emitGoScopeCaptures, @@ -473,13 +473,10 @@ describe('Go scope-capture O(n^2) regression tripwire', () => { */ function generateSyntheticInterfaceData(interfaceCount: number, structCount: number): ParsedFile[] { const defs: SymbolDefinition[] = []; - const ifaceIds: string[] = []; - const structIds: string[] = []; // Interfaces for (let i = 0; i < interfaceCount; i++) { const ifaceId = `iface:Repo${i}`; - ifaceIds.push(ifaceId); defs.push({ nodeId: ifaceId, filePath: 'repo.go', @@ -515,37 +512,34 @@ function generateSyntheticInterfaceData(interfaceCount: number, structCount: num // Structs — each implements all interfaces for (let s = 0; s < structCount; s++) { const structId = `struct:Impl${s}`; - structIds.push(structId); defs.push({ nodeId: structId, filePath: 'repo.go', type: 'Struct', qualifiedName: `Impl${s}`, }); - for (let i = 0; i < interfaceCount; i++) { - defs.push({ - nodeId: `struct:Impl${s}.Repo${i}.Find`, - filePath: 'repo.go', - type: 'Method', - qualifiedName: `Impl${s}.Find`, - ownerId: structId, - parameterCount: 1, - requiredParameterCount: 1, - parameterTypes: ['string'], - returnType: 'User', - }); - defs.push({ - nodeId: `struct:Impl${s}.Repo${i}.Save`, - filePath: 'repo.go', - type: 'Method', - qualifiedName: `Impl${s}.Save`, - ownerId: structId, - parameterCount: 1, - requiredParameterCount: 1, - parameterTypes: ['User'], - returnType: 'error', - }); - } + defs.push({ + nodeId: `struct:Impl${s}.Find`, + filePath: 'repo.go', + type: 'Method', + qualifiedName: `Impl${s}.Find`, + ownerId: structId, + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['string'], + returnType: 'User', + }); + defs.push({ + nodeId: `struct:Impl${s}.Save`, + filePath: 'repo.go', + type: 'Method', + qualifiedName: `Impl${s}.Save`, + ownerId: structId, + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['User'], + returnType: 'error', + }); } // BadStructs — wrong Save signature (string instead of User), should NOT match @@ -557,44 +551,45 @@ function generateSyntheticInterfaceData(interfaceCount: number, structCount: num type: 'Struct', qualifiedName: `Bad${b}`, }); - for (let i = 0; i < interfaceCount; i++) { - defs.push({ - nodeId: `struct:Bad${b}.Repo${i}.Find`, - filePath: 'repo.go', - type: 'Method', - qualifiedName: `Bad${b}.Find`, - ownerId: badId, - parameterCount: 1, - requiredParameterCount: 1, - parameterTypes: ['string'], - returnType: 'User', - }); - // Mismatched Save: string param instead of User - defs.push({ - nodeId: `struct:Bad${b}.Repo${i}.Save`, - filePath: 'repo.go', - type: 'Method', - qualifiedName: `Bad${b}.Save`, - ownerId: badId, - parameterCount: 1, - requiredParameterCount: 1, - parameterTypes: ['string'], - returnType: 'error', - }); - } + defs.push({ + nodeId: `struct:Bad${b}.Find`, + filePath: 'repo.go', + type: 'Method', + qualifiedName: `Bad${b}.Find`, + ownerId: badId, + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['string'], + returnType: 'User', + }); + // Mismatched Save: string param instead of User + defs.push({ + nodeId: `struct:Bad${b}.Save`, + filePath: 'repo.go', + type: 'Method', + qualifiedName: `Bad${b}.Save`, + ownerId: badId, + parameterCount: 1, + requiredParameterCount: 1, + parameterTypes: ['string'], + returnType: 'error', + }); } return [ { filePath: 'repo.go', - language: 'go', + moduleScope: makeScopeId({ + filePath: 'repo.go', + range: { startLine: 0, startCol: 0, endLine: 0, endCol: 0 }, + kind: 'Module', + }), scopes: [], - imports: [], parsedImports: [], localDefs: defs, referenceSites: [], }, - ] as ParsedFile[]; + ]; } /** @@ -603,6 +598,15 @@ function generateSyntheticInterfaceData(interfaceCount: number, structCount: num * path (structIdsByMethodName intersection) keeps this well under budget. */ describe('Go structural interface detection O(n²) regression tripwire', () => { + it('uses legal Go method sets with one declaration per receiver and method name', () => { + const [parsed] = generateSyntheticInterfaceData(3, 3); + const methods = parsed.localDefs.filter((def) => def.type === 'Method'); + const identities = methods.map((def) => `${def.ownerId}:${def.qualifiedName}`); + expect(new Set(identities).size).toBe(methods.length); + // Three interfaces, three matching structs and three negative controls. + expect(methods).toHaveLength(18); + }); + it('detects implementations for 100 interfaces × 100 structs within budget', () => { const IFACE_COUNT = 100; const STRUCT_COUNT = 100; @@ -776,7 +780,7 @@ describe.skipIf(!BENCH_ENABLED)('Go structural interface detection split-phase b console.log('\nScaling analysis (total time ratio / pair-count ratio):'); // We can't split phases without exporting internals, but we can report // how total time scales relative to pair count. - // The detection loop is O(I×C×M×O_a) where O_a grows with I in this - // synthetic case, so pair-count ratio alone underestimates expected growth. + // Each receiver declares Find and Save once, as real Go requires. + // Required signatures stay constant while interface/struct pairs grow. }, 300_000); }); diff --git a/gitnexus/test/integration/impact-context-integrity.test.ts b/gitnexus/test/integration/impact-context-integrity.test.ts new file mode 100644 index 000000000..f26151faf --- /dev/null +++ b/gitnexus/test/integration/impact-context-integrity.test.ts @@ -0,0 +1,765 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import lbug from '@ladybugdb/core'; +import * as adapter from '../../src/core/lbug/lbug-adapter.js'; +import { executeParameterized } from '../../src/core/lbug/pool-adapter.js'; +import { closeQueryResults } from '../../src/core/lbug/query-result-utils.js'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { retryRename } from '../../src/storage/fs-atomic.js'; +import { getStoragePaths, registerRepo, saveMeta } from '../../src/storage/repo-manager.js'; +import { createTempDir } from '../helpers/test-db.js'; + +const REPO = 'query-integrity'; +const nodes = { + alpha: { id: 'Function:src/alpha.ts:runSweep', name: 'runSweep', filePath: 'src/alpha.ts' }, + alphaCaller: { + id: 'Function:src/α/caller.ts:appelÉ', + name: 'appelÉ', + filePath: 'src/α/caller.ts', + }, + alphaOtherCaller: { + id: 'Function:src/other.ts:runOther', + name: 'runOther', + filePath: 'src/other.ts', + }, + alphaRoot: { id: 'Function:src/root.ts:startSweep', name: 'startSweep', filePath: 'src/root.ts' }, + alphaReader: { + id: 'Function:src/reader.ts:readSweep', + name: 'readSweep', + filePath: 'src/reader.ts', + }, + beta: { + id: 'Function:src/beta.ts:extractLeadingNumber', + name: 'extractLeadingNumber', + filePath: 'src/beta.ts', + }, + betaCaller: { + id: 'Function:src/number.ts:parseNumber', + name: 'parseNumber', + filePath: 'src/number.ts', + }, + betaReader: { + id: 'Function:src/number-view.ts:readNumber', + name: 'readNumber', + filePath: 'src/number-view.ts', + }, +} as const; + +const processes = { + alpha: { + id: 'process:alpha', + label: 'Sweep flow', + entry: nodes.alphaRoot, + terminal: nodes.alpha, + stepCount: 3, + }, + alphaOther: { + id: 'process:alpha-other', + label: 'Other sweep flow', + entry: nodes.alphaOtherCaller, + terminal: nodes.alpha, + stepCount: 2, + }, + beta: { + id: 'process:beta', + label: 'Number flow', + entry: nodes.betaCaller, + terminal: nodes.beta, + stepCount: 2, + }, +} as const; + +type NodeIdentity = { id: string; name: string; filePath: string }; +type Membership = { id: string; label: string; processType: string; step: number }; +type ImpactRow = NodeIdentity & { + relationType: string; + confidence: number; + processes: Membership[]; +}; +type ContextRef = { uid: string; name: string; filePath: string }; +type Target = 'alpha' | 'beta'; + +const membership = ( + process: (typeof processes)[keyof typeof processes], + step: number, +): Membership => ({ + id: process.id, + label: process.label, + processType: 'intra_community', + step, +}); + +const affectedProcess = (process: (typeof processes)[keyof typeof processes], hits: number) => ({ + name: process.entry.name, + type: 'Function', + filePath: process.entry.filePath, + affected_process_count: 1, + total_hits: hits, + earliest_broken_step: 0, +}); + +const oracle = { + alpha: { + count: 3, + direct: 2, + byDepth: { + 1: [ + { + ...nodes.alphaOtherCaller, + relationType: 'CALLS', + confidence: 1, + processes: [membership(processes.alphaOther, 0)], + }, + { + ...nodes.alphaCaller, + relationType: 'CALLS', + confidence: 1, + processes: [membership(processes.alpha, 1)], + }, + ], + 2: [ + { + ...nodes.alphaRoot, + relationType: 'CALLS', + confidence: 1, + processes: [membership(processes.alpha, 0)], + }, + ], + }, + callers: [nodes.alphaOtherCaller, nodes.alphaCaller], + accesses: [nodes.alphaReader], + processes: [ + { id: processes.alpha.id, name: processes.alpha.label, step_index: 2, step_count: 3 }, + { + id: processes.alphaOther.id, + name: processes.alphaOther.label, + step_index: 1, + step_count: 2, + }, + ], + affectedProcesses: [ + affectedProcess(processes.alpha, 2), + affectedProcess(processes.alphaOther, 1), + ], + }, + beta: { + count: 1, + direct: 1, + byDepth: { + 1: [ + { + ...nodes.betaCaller, + relationType: 'CALLS', + confidence: 1, + processes: [membership(processes.beta, 0)], + }, + ], + }, + callers: [nodes.betaCaller], + accesses: [nodes.betaReader], + processes: [ + { id: processes.beta.id, name: processes.beta.label, step_index: 1, step_count: 2 }, + ], + affectedProcesses: [affectedProcess(processes.beta, 1)], + }, +} as const; + +// Compare the values returned by the native engine with a hand-written graph +// oracle, rather than accepting a repeated (and potentially wrong) first result. +function expectImpact( + result: Awaited>, + target: Target, + summaryOnly: boolean, +): void { + const expected = oracle[target]; + expect(result).not.toHaveProperty('error'); + expect(result).not.toHaveProperty('partial'); + expect(result.target).toMatchObject(nodes[target]); + expect(result.direction).toBe('upstream'); + expect(result.impactedCount).toBe(expected.count); + expect(result.risk).toBe('LOW'); + expect(result.epistemic).toBe('exact'); + expect(result.summary).toEqual({ + direct: expected.direct, + processes_affected: expected.affectedProcesses.length, + modules_affected: 0, + }); + expect(result.byDepthCounts).toEqual(target === 'alpha' ? { 1: 2, 2: 1 } : { 1: 1 }); + expect(result.affected_processes).toEqual(expected.affectedProcesses); + expect(result.affected_modules).toEqual([]); + expect(result.affected_routes).toEqual([]); + if (summaryOnly) { + expect(result).not.toHaveProperty('byDepth'); + } else { + const byDepth = Object.fromEntries( + Object.entries(result.byDepth).map(([depth, rows]) => [ + depth, + (rows as ImpactRow[]).map( + ({ id, name, filePath, relationType, confidence, processes: memberships }) => ({ + id, + name, + filePath, + relationType, + confidence, + processes: memberships, + }), + ), + ]), + ); + expect(byDepth).toEqual(expected.byDepth); + } +} + +function expectContext( + result: Awaited>, + target: Target, +): void { + const expected = oracle[target]; + expect(result).not.toHaveProperty('error'); + expect(result.status).toBe('found'); + expect(result.symbol).toMatchObject({ + uid: nodes[target].id, + name: nodes[target].name, + filePath: nodes[target].filePath, + }); + expect(result.epistemic).toBe('exact'); + const incoming = Object.fromEntries( + Object.entries(result.incoming).map(([type, refs]) => [ + type, + (refs as ContextRef[]).map(({ uid, name, filePath }) => ({ id: uid, name, filePath })), + ]), + ); + expect(incoming).toEqual({ calls: expected.callers, accesses: expected.accesses }); + expect(result.outgoing).toEqual({}); + expect(result.processes).toEqual(expected.processes); +} + +function edge(source: NodeIdentity, target: NodeIdentity, type: 'CALLS' | 'ACCESSES'): string { + return `MATCH (a:Function {id: '${source.id}'}), (b:Function {id: '${target.id}'}) CREATE (a)-[:CodeRelation {type: '${type}', confidence: 1.0, reason: 'direct', step: 0}]->(b)`; +} + +function processStep( + node: NodeIdentity, + process: (typeof processes)[keyof typeof processes], + step: number, +): string { + return `MATCH (n:Function {id: '${node.id}'}), (p:Process {id: '${process.id}'}) CREATE (n)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: 'trace-detection', step: ${step}}]->(p)`; +} + +describe('native impact/context result integrity (#3354)', () => { + let temp: Awaited>; + let backend: LocalBackend; + let lbugPath: string; + + beforeAll(async () => { + temp = await createTempDir(); + vi.stubEnv('GITNEXUS_HOME', path.join(temp.dbPath, 'home')); + vi.stubEnv('GITNEXUS_STORAGE_PATH', path.join(temp.dbPath, 'index')); + vi.stubEnv('GITNEXUS_SHARED_STORE', 'off'); + const paths = getStoragePaths(temp.dbPath); + lbugPath = paths.lbugPath; + + // Close the writer before LocalBackend opens its ordinary read pool. No + // mocked registry or injected writable Database bypasses the read path. + await adapter.initLbug(lbugPath); + try { + const seed = [ + ...Object.values(nodes).map( + (node) => + `CREATE (:Function {id: '${node.id}', name: '${node.name}', filePath: '${node.filePath}', startLine: 1, endLine: 3})`, + ), + ...Object.values(processes).map( + (process) => + `CREATE (:Process {id: '${process.id}', label: '${process.label}', heuristicLabel: '${process.label}', processType: 'intra_community', stepCount: ${process.stepCount}, communities: [], entryPointId: '${process.entry.id}', terminalId: '${process.terminal.id}'})`, + ), + edge(nodes.alphaCaller, nodes.alpha, 'CALLS'), + edge(nodes.alphaOtherCaller, nodes.alpha, 'CALLS'), + edge(nodes.alphaRoot, nodes.alphaCaller, 'CALLS'), + edge(nodes.alphaReader, nodes.alpha, 'ACCESSES'), + edge(nodes.betaCaller, nodes.beta, 'CALLS'), + edge(nodes.betaReader, nodes.beta, 'ACCESSES'), + processStep(nodes.alphaRoot, processes.alpha, 0), + processStep(nodes.alphaCaller, processes.alpha, 1), + processStep(nodes.alpha, processes.alpha, 2), + processStep(nodes.alphaOtherCaller, processes.alphaOther, 0), + processStep(nodes.alpha, processes.alphaOther, 1), + processStep(nodes.betaCaller, processes.beta, 0), + processStep(nodes.beta, processes.beta, 1), + ]; + for (const query of seed) await adapter.executeQuery(query); + await adapter.flushWAL(); + } finally { + await adapter.closeLbug(); + } + const meta = { + repoPath: temp.dbPath, + storagePath: paths.storagePath, + lastCommit: 'integrity-fixture', + indexedAt: new Date().toISOString(), + scopeExtractionReceipt: 1 as const, + stats: { files: 8, nodes: 11, processes: 3, communities: 0 }, + }; + await saveMeta(paths.storagePath, meta); + await registerRepo(temp.dbPath, meta, { name: REPO }); + backend = new LocalBackend(); + expect(await backend.init()).toBe(true); + }); + + afterAll(async () => { + try { + await backend?.dispose(); + } finally { + await adapter.closeLbug(); + vi.unstubAllEnvs(); + await temp?.cleanup(); + } + }); + + const impact = (target: Target, summaryOnly = false) => + backend.callTool('impact', { + repo: REPO, + target: nodes[target].name, + direction: 'upstream', + summaryOnly, + }); + const context = (target: Target) => + backend.callTool('context', { repo: REPO, uid: nodes[target].id }); + + it('returns exact values across identical sequential requests', async () => { + for (let repeat = 0; repeat < 6; repeat++) { + expectImpact(await impact('alpha', true), 'alpha', true); + expectContext(await context('alpha'), 'alpha'); + expectImpact(await impact('alpha'), 'alpha', false); + } + }); + + it('keeps unrelated targets isolated across mixed requests', async () => { + for (const target of ['alpha', 'beta', 'beta', 'alpha'] as const) { + expectImpact(await impact(target, true), target, true); + expectContext(await context(target), target); + expectImpact(await impact(target), target, false); + } + }); + + it('keeps mixed concurrent prepared reads symbol-specific', async () => { + for (let repeat = 0; repeat < 3; repeat++) { + const results = await Promise.all([ + impact('alpha', true), + context('beta'), + impact('beta'), + impact('beta', true), + context('alpha'), + impact('alpha'), + ]); + expectImpact(results[0], 'alpha', true); + expectContext(results[1], 'beta'); + expectImpact(results[2], 'beta', false); + expectImpact(results[3], 'beta', true); + expectContext(results[4], 'alpha'); + expectImpact(results[5], 'alpha', false); + } + }); + + it('returns exact relation rows directly from the pooled prepared adapter', async () => { + // Warm the pool through the same backend, then check the native row + // boundary independently of the tool's normalization and aggregation. + expectContext(await context('alpha'), 'alpha'); + const read = (target: Target) => + executeParameterized( + lbugPath, + ` + MATCH (caller:Function)-[r:CodeRelation]->(target:Function {id: $id}) + WHERE r.type IN ['CALLS', 'ACCESSES'] + RETURN caller.id AS id, caller.name AS name, caller.filePath AS filePath, r.type AS relationType + ORDER BY id + `, + { id: nodes[target].id }, + ); + const expectedRows = (target: Target) => + [ + ...oracle[target].callers.map((caller) => ({ ...caller, relationType: 'CALLS' })), + ...oracle[target].accesses.map((reader) => ({ ...reader, relationType: 'ACCESSES' })), + ].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + for (let repeat = 0; repeat < 4; repeat++) { + expect(await read('alpha')).toEqual(expectedRows('alpha')); + const [beta, alpha] = await Promise.all([read('beta'), read('alpha')]); + expect(beta).toEqual(expectedRows('beta')); + expect(alpha).toEqual(expectedRows('alpha')); + } + }); +}); + +describe('native string projections after checkpointed deletion (#3354)', () => { + it('keeps long symbol identities associated with their source rows across segments', async () => { + const temp = await createTempDir(); + const db = new lbug.Database(path.join(temp.dbPath, 'scan.lbug'), 128 * 1024 * 1024); + const conn = new lbug.Connection(db, 4); + const source = Array.from({ length: 10_000 }, (_, startLine) => ({ + id: `Function:src/generated/rené-${String(startLine).padStart(5, '0')}.ts:fn${startLine}`, + name: `generated_function_${startLine}_é`, + filePath: `src/generated/rené-${String(startLine).padStart(5, '0')}.ts`, + startLine, + })); + const projection = + 'RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine'; + const read = async (query: string) => { + const result = await conn.query(query); + try { + const cursor = Array.isArray(result) ? result[0] : result; + return await cursor.getAll(); + } finally { + await closeQueryResults(result); + } + }; + + try { + await read( + 'CREATE NODE TABLE Function(id STRING, name STRING, filePath STRING, startLine INT64, PRIMARY KEY(id))', + ); + // Separate checkpoints create segment boundaries inside scan vectors. + // LadybugDB 0.18.3's filtered STRING scan could retain another row's + // printable identities here (LadybugDB/ladybug#678, fixed by #737). + for (let batch = 0; batch < 4; batch++) { + const csvPath = path.join(temp.dbPath, `rows-${batch}.csv`); + const csv = source + .slice(batch * 2500, (batch + 1) * 2500) + .map((row) => + Object.values(row) + .map((value) => JSON.stringify(value)) + .join(','), + ) + .join('\n'); + await fs.writeFile(csvPath, `${csv}\n`); + await read( + `COPY Function FROM ${JSON.stringify(csvPath.replaceAll('\\', '/'))} (HEADER=false)`, + ); + await read('CHECKPOINT'); + } + expect(await read(`MATCH (n:Function) ${projection} ORDER BY n.startLine`)).toEqual(source); + + await read( + 'MATCH (n:Function) WHERE n.startLine >= 3000 AND n.startLine < 3400 DETACH DELETE n', + ); + await read('CHECKPOINT'); + const surviving = source.filter((row) => row.startLine < 3000 || row.startLine >= 3400); + for (let repeat = 0; repeat < 3; repeat++) { + for (const order of ['', ' ORDER BY n.startLine']) { + const rows = await read(`MATCH (n:Function) ${projection}${order}`); + expect(new Set(rows.map((row) => row.id)).size).toBe(surviving.length); + expect( + rows.sort((a, b) => { + if (typeof a.startLine !== 'number' || typeof b.startLine !== 'number') { + throw new Error('Expected numeric startLine values from the database'); + } + return a.startLine - b.startLine; + }), + ).toEqual(surviving); + } + } + // Point lookups independently verify values in the affected segments; + // a repeatably wrong scan must never become the test's reference answer. + for (const startLine of [1600, 7486]) { + expect( + await read(`MATCH (n:Function {id: '${source[startLine].id}'}) ${projection}`), + ).toEqual([source[startLine]]); + } + expect(await read(`MATCH (n:Function {id: '${source[3000].id}'}) ${projection}`)).toEqual([]); + } finally { + try { + await conn.close(); + } finally { + try { + await db.close(); + } finally { + await temp.cleanup(); + } + } + } + }); +}); + +// Windows graph replacement is opt-in in production. The repeated-read +// characterization above remains enabled there; only this POSIX swap is skipped. +describe.skipIf(process.platform === 'win32')('warm backend index replacement (#3354)', () => { + it('reads changed callers, processes and a new symbol through the real freshness window', async () => { + const temp = await createTempDir(); + let backend: LocalBackend | undefined; + vi.stubEnv('GITNEXUS_HOME', path.join(temp.dbPath, 'home')); + vi.stubEnv('GITNEXUS_STORAGE_PATH', path.join(temp.dbPath, 'index')); + vi.stubEnv('GITNEXUS_SHARED_STORE', 'off'); + const paths = getStoragePaths(temp.dbPath); + const stagedPath = `${paths.lbugPath}.replacement`; + const replacement = { + entry: { + id: 'Function:src/replacement-entry.ts:startReplacement', + name: 'startReplacement', + filePath: 'src/replacement-entry.ts', + }, + caller: { + id: 'Function:src/replacement-caller.ts:callReplacement', + name: 'callReplacement', + filePath: 'src/replacement-caller.ts', + }, + reader: { + id: 'Function:src/replacement-reader.ts:readReplacement', + name: 'readReplacement', + filePath: 'src/replacement-reader.ts', + }, + }; + const nextProcess = { id: 'process:replacement', label: 'Replacement flow' }; + const oldProcess = processes.alphaOther; + + const seed = async (dbPath: string, next: boolean) => { + await adapter.initLbug(dbPath); + try { + const caller = next ? replacement.caller : nodes.alphaOtherCaller; + const reader = next ? replacement.reader : nodes.alphaReader; + const process = next ? nextProcess : oldProcess; + const entry = next ? replacement.entry : caller; + for (const node of [nodes.alpha, caller, reader, ...(next ? [entry] : [])]) { + await adapter.executeQuery( + `CREATE (:Function {id: '${node.id}', name: '${node.name}', filePath: '${node.filePath}', startLine: 1, endLine: 3})`, + ); + } + await adapter.executeQuery( + `CREATE (:Process {id: '${process.id}', label: '${process.label}', heuristicLabel: '${process.label}', processType: 'intra_community', stepCount: ${next ? 3 : 2}, communities: [], entryPointId: '${entry.id}', terminalId: '${nodes.alpha.id}'})`, + ); + await adapter.executeQuery(edge(caller, nodes.alpha, 'CALLS')); + await adapter.executeQuery(edge(reader, nodes.alpha, 'ACCESSES')); + if (next) await adapter.executeQuery(edge(entry, caller, 'CALLS')); + const steps = next ? [entry, caller, nodes.alpha] : [caller, nodes.alpha]; + for (const [step, node] of steps.entries()) { + await adapter.executeQuery( + `MATCH (n:Function {id: '${node.id}'}), (p:Process {id: '${process.id}'}) CREATE (n)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: 'trace-detection', step: ${step}}]->(p)`, + ); + } + await adapter.flushWAL(); + } finally { + await adapter.closeLbug(); + } + }; + const processMembership = (next: boolean, step: number) => ({ + ...(next ? nextProcess : { id: oldProcess.id, label: oldProcess.label }), + processType: 'intra_community', + step, + }); + const expectedCaller = (node: NodeIdentity, next: boolean, step: number) => ({ + ...node, + relationType: 'CALLS', + confidence: 1, + processes: [processMembership(next, step)], + }); + const expectGeneration = ( + impact: Awaited>, + context: Awaited>, + next: boolean, + ) => { + const caller = next ? replacement.caller : nodes.alphaOtherCaller; + const reader = next ? replacement.reader : nodes.alphaReader; + const entry = next ? replacement.entry : caller; + const process = next ? nextProcess : oldProcess; + expect(impact).not.toHaveProperty('error'); + expect(impact).not.toHaveProperty('partial'); + expect(impact.target).toMatchObject(nodes.alpha); + expect(impact.risk).toBe('LOW'); + expect(impact.epistemic).toBe('exact'); + expect(impact.impactedCount).toBe(next ? 2 : 1); + expect(impact.summary).toEqual({ direct: 1, processes_affected: 1, modules_affected: 0 }); + expect(impact.byDepthCounts).toEqual(next ? { 1: 1, 2: 1 } : { 1: 1 }); + const byDepth = Object.fromEntries( + Object.entries(impact.byDepth).map(([depth, rows]) => [ + depth, + (rows as ImpactRow[]).map( + ({ id, name, filePath, relationType, confidence, processes: memberships }) => ({ + id, + name, + filePath, + relationType, + confidence, + processes: memberships, + }), + ), + ]), + ); + expect(byDepth).toEqual({ + 1: [expectedCaller(caller, next, next ? 1 : 0)], + ...(next ? { 2: [expectedCaller(entry, true, 0)] } : {}), + }); + expect(impact.affected_processes).toEqual([ + { + name: entry.name, + type: 'Function', + filePath: entry.filePath, + affected_process_count: 1, + total_hits: next ? 2 : 1, + earliest_broken_step: 0, + }, + ]); + expect(impact.affected_modules).toEqual([]); + expect(impact.affected_routes).toEqual([]); + expect(context).not.toHaveProperty('error'); + expect(context.status).toBe('found'); + expect(context.epistemic).toBe('exact'); + expect(context.symbol).toMatchObject({ + uid: nodes.alpha.id, + name: nodes.alpha.name, + filePath: nodes.alpha.filePath, + }); + expect( + Object.fromEntries( + Object.entries(context.incoming).map(([type, refs]) => [ + type, + (refs as ContextRef[]).map(({ uid, name, filePath }) => ({ id: uid, name, filePath })), + ]), + ), + ).toEqual({ calls: [caller], accesses: [reader] }); + expect(context.outgoing).toEqual({}); + expect(context.processes).toEqual([ + { + id: process.id, + name: process.label, + step_index: next ? 2 : 1, + step_count: next ? 3 : 2, + }, + ]); + }; + + try { + await seed(paths.lbugPath, false); + const meta = { + repoPath: temp.dbPath, + storagePath: paths.storagePath, + lastCommit: 'graph-a', + indexedAt: new Date().toISOString(), + scopeExtractionReceipt: 1 as const, + stats: { files: 3, nodes: 4, processes: 1, communities: 0 }, + }; + await saveMeta(paths.storagePath, meta); + await registerRepo(temp.dbPath, meta, { name: REPO }); + const heldBackend = new LocalBackend(); + backend = heldBackend; + expect(await heldBackend.init()).toBe(true); + const impact = () => + heldBackend.callTool('impact', { + repo: REPO, + target: nodes.alpha.name, + direction: 'upstream', + }); + const context = () => heldBackend.callTool('context', { repo: REPO, uid: nodes.alpha.id }); + expectGeneration(await impact(), await context(), false); + expect( + await heldBackend.callTool('context', { repo: REPO, uid: replacement.entry.id }), + ).toHaveProperty('error'); + + // Keep this backend and its read pool alive. Publish only after the + // separate staged writer has closed, exactly as run-analyze does. + await seed(stagedPath, true); + for (const suffix of ['.wal', '.shadow', '.wal.checkpoint']) { + await expect(fs.stat(`${stagedPath}${suffix}`)).rejects.toMatchObject({ code: 'ENOENT' }); + } + await retryRename(stagedPath, paths.lbugPath); + const nextMeta = { + ...meta, + lastCommit: 'graph-b', + indexedAt: new Date(Date.now() + 1).toISOString(), + stats: { files: 4, nodes: 5, processes: 1, communities: 0 }, + }; + await saveMeta(paths.storagePath, nextMeta); + await registerRepo(temp.dbPath, nextMeta, { name: REPO }); + + // An independent native read-only Database opens the published path. + // It does not share LocalBackend's pool or trigger its reinitialization. + const freshDb = new lbug.Database(paths.lbugPath, 128 * 1024 * 1024, true, true); + const freshConn = new lbug.Connection(freshDb); + try { + const read = async (query: string) => { + const result = await freshConn.query(query); + try { + const cursor = Array.isArray(result) ? result[0] : result; + return await cursor.getAll(); + } finally { + await closeQueryResults(result); + } + }; + expect( + await read(` + MATCH (n:Function) + RETURN n.id AS id, n.name AS name, n.filePath AS filePath + ORDER BY id + `), + ).toEqual( + [nodes.alpha, ...Object.values(replacement)].sort((a, b) => + a.id < b.id ? -1 : a.id > b.id ? 1 : 0, + ), + ); + expect( + await read(` + MATCH (n:Function)-[r:CodeRelation]->(target:Function {id: '${nodes.alpha.id}'}) + WHERE r.type IN ['CALLS', 'ACCESSES'] + RETURN n.id AS id, n.name AS name, n.filePath AS filePath, r.type AS relationType + ORDER BY id + `), + ).toEqual([ + { ...replacement.caller, relationType: 'CALLS' }, + { ...replacement.reader, relationType: 'ACCESSES' }, + ]); + expect( + await read(` + MATCH (n:Function)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) + RETURN n.id AS id, p.id AS processId, p.heuristicLabel AS label, + r.step AS step, p.stepCount AS stepCount, p.entryPointId AS entryPointId + ORDER BY step + `), + ).toEqual( + [replacement.entry, replacement.caller, nodes.alpha].map((node, step) => ({ + id: node.id, + processId: nextProcess.id, + label: nextProcess.label, + step, + stepCount: 3, + entryPointId: replacement.entry.id, + })), + ); + } finally { + await freshConn.close(); + await freshDb.close(); + } + + // Poll the SAME backend through its unchanged five-second throttle. + // No private watermark override, poolInit, reset or restart is used. + const deadline = Date.now() + 15_000; + let refreshed = await context(); + while (refreshed.processes?.[0]?.id !== nextProcess.id && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 300)); + refreshed = await context(); + } + expectGeneration(await impact(), refreshed, true); + for (let repeat = 0; repeat < 3; repeat++) { + expectGeneration(await impact(), await context(), true); + const introduced = await heldBackend.callTool('context', { + repo: REPO, + name: replacement.entry.name, + }); + expect(introduced).not.toHaveProperty('error'); + expect(introduced.status).toBe('found'); + expect(introduced.symbol).toMatchObject({ + uid: replacement.entry.id, + name: replacement.entry.name, + filePath: replacement.entry.filePath, + }); + expect(introduced.processes).toEqual([ + { id: nextProcess.id, name: nextProcess.label, step_index: 0, step_count: 3 }, + ]); + } + } finally { + try { + await backend?.dispose(); + } finally { + await adapter.closeLbug(); + vi.unstubAllEnvs(); + await temp.cleanup(); + } + } + }); +}); diff --git a/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts b/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts index 2ef3e6c78..458a19155 100644 --- a/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts +++ b/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts @@ -124,12 +124,15 @@ withTestLbugDB( it('marks impact as a lower bound when scope extraction omitted files', async () => { vi.mocked(loadMeta).mockResolvedValueOnce({ + repoPath: '/test/repo', + lastCommit: 'abc123', + indexedAt: new Date().toISOString(), scopeExtractionReceipt: 1, scopeExtractionFailures: { total: 2, paths: ['src/broken-a.ts', 'src/broken-b.ts'], }, - } as Awaited>); + }); const result = await backend.callTool('impact', { target: 'formatDate', @@ -143,12 +146,15 @@ withTestLbugDB( it('never renders repository-controlled failure paths in boundary prose', async () => { vi.mocked(loadMeta).mockResolvedValueOnce({ + repoPath: '/test/repo', + lastCommit: 'abc123', + indexedAt: new Date().toISOString(), scopeExtractionReceipt: 1, scopeExtractionFailures: { total: 1, paths: ['src/`break`\n\u001b[31m\u202e\u200binject.ts'], }, - } as Awaited>); + }); const result = await backend.callTool('impact', { target: 'formatDate', @@ -239,12 +245,15 @@ withTestLbugDB( it('context() reports persisted scope extraction omissions as a lower bound', async () => { vi.mocked(loadMeta).mockResolvedValueOnce({ + repoPath: '/test/repo', + lastCommit: 'abc123', + indexedAt: new Date().toISOString(), scopeExtractionReceipt: 1, scopeExtractionFailures: { total: 2, paths: ['src/broken-a.ts', 'src/broken-b.ts'], }, - } as Awaited>); + }); const result = await backend.callTool('context', { name: 'formatDate', diff --git a/gitnexus/test/integration/impact-pdg-shape.test.ts b/gitnexus/test/integration/impact-pdg-shape.test.ts index 0458ab03d..3d83a6fbf 100644 --- a/gitnexus/test/integration/impact-pdg-shape.test.ts +++ b/gitnexus/test/integration/impact-pdg-shape.test.ts @@ -279,6 +279,7 @@ withTestLbugDB( const result = await downstream(); const localRisk = String(result.risk); expect(localRisk).toBe('UNKNOWN'); + if (localRisk !== 'UNKNOWN') throw new Error(`Expected UNKNOWN risk, got ${localRisk}`); // No cross-repo hits → mergeRisk returns localRisk verbatim: 'UNKNOWN', // NEVER coerced to a confident 'LOW' (the false-safe this guards). expect(mergeRisk(localRisk, [])).toBe('UNKNOWN'); diff --git a/gitnexus/test/integration/incremental-worktree-context.test.ts b/gitnexus/test/integration/incremental-worktree-context.test.ts new file mode 100644 index 000000000..d8292813b --- /dev/null +++ b/gitnexus/test/integration/incremental-worktree-context.test.ts @@ -0,0 +1,288 @@ +/** + * Characterize #3423's public workflow against the current analyzer. The + * original private database is unavailable; this fixture does not reproduce + * or establish the cause of that historical incident. + */ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, vi } from 'vitest'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; +import * as adapter from '../../src/core/lbug/lbug-adapter.js'; +import { runFullAnalysis } from '../../src/core/run-analyze.js'; +import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { getStoragePaths, loadMeta } from '../../src/storage/repo-manager.js'; +import { CLI_SPAWN_PREFIX, tsxLoaderUrl } from '../helpers/cli-entry.js'; +import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js'; +import { createTempDir } from '../helpers/test-db.js'; + +const SWIFT_PATH = 'Sources/Numbers.swift'; +const TEST_PATH = 'checks/test_numbers.py'; +const IMPORTER_PATH = 'checks/runner.py'; +const TARGET_NAME = 'extractLeadingNumber'; +const PROPERTY_NAME = 'preservedSearchNeedle'; +const CALLER_NAMES = ['parsePrimaryNumber', 'parseSecondaryNumber']; +const options = { skipAgentsMd: true, skipSkills: true }; +const callbacks = { onProgress: () => {} }; + +const SWIFT_SOURCE = `func extractLeadingNumber(_ raw: String) -> Int { + return 7 +} + +func parsePrimaryNumber() -> Int { + return extractLeadingNumber("17") +} + +func parseSecondaryNumber() -> Int { + return extractLeadingNumber("23") +} + +class NumberSettings { + var preservedSearchNeedle: String = "retained property" +} +`; + +type SymbolRef = { uid: string; name: string; filePath: string }; +type ContextResult = { + status: string; + symbol: SymbolRef; + incoming: { calls?: SymbolRef[] }; +}; +type StatusResult = { + repository: string; + status: string; + contentDrift: { status: string; coveredFiles?: number }; + index: { incompleteReasons: string[]; runnerIdentityStatus: string }; +}; + +const git = (cwd: string, ...args: string[]): string => + execFileSync('git', args, { cwd, encoding: 'utf8', stdio: 'pipe' }).trim(); + +function runCli(cwd: string, args: string[], prefix = CLI_SPAWN_PREFIX): T { + const stdout = execFileSync(process.execPath, [...prefix, ...args], { + cwd, + env: process.env, + encoding: 'utf8', + timeout: 60_000, + maxBuffer: 4 * 1024 * 1024, + windowsHide: true, + }); + // Context prints a human-readable banner before its JSON response. + const start = stdout.indexOf('{'); + expect(start, stdout).toBeGreaterThanOrEqual(0); + return JSON.parse(stdout.slice(start)) as T; +} + +function expectContext(result: ContextResult, target: SymbolRef, callers: SymbolRef[]): void { + expect(result).not.toHaveProperty('error'); + expect(result.status).toBe('found'); + expect(result.symbol).toMatchObject(target); + const actual = (result.incoming.calls ?? []).map(({ uid, name, filePath }) => ({ + uid, + name, + filePath, + })); + expect(actual.sort((a, b) => a.uid.localeCompare(b.uid))).toEqual( + [...callers].sort((a, b) => a.uid.localeCompare(b.uid)), + ); +} + +describe.skipIf(!isLanguageAvailable(SupportedLanguages.Swift))( + 'unchanged Swift context across a selective linked-worktree refresh (#3423)', + () => { + it('retains CLI/MCP name and UID context, both callers, and native Property FTS', async (ctx) => { + const temp = await createTempDir(); + let backend: LocalBackend | undefined; + try { + const root = await fs.realpath(temp.dbPath); + const main = path.join(root, 'main'); + const worktree = path.join(root, 'linked'); + const nativeHome = path.join(root, 'user-home'); + await fs.mkdir(nativeHome); + vi.stubEnv('HOME', nativeHome); + vi.stubEnv('USERPROFILE', nativeHome); + vi.stubEnv('GITNEXUS_HOME', path.join(root, 'home')); + vi.stubEnv('GITNEXUS_STORAGE_PATH', undefined); + vi.stubEnv('GITNEXUS_STORAGE_ROOT', undefined); + // Keep a real linked checkout, but avoid the shared-store seed/full + // rebuild path: the behavior under test is selective row replacement. + vi.stubEnv('GITNEXUS_SHARED_STORE', 'off'); + vi.stubEnv('GITNEXUS_CONTENT_RETENTION', 'full'); + vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', '1'); + vi.stubEnv('GITNEXUS_LBUG_EXTENSION_INSTALL', 'load-only'); + + await adapter.initLbug(path.join(root, 'fts-probe')); + // Require an existing extension; a network install would make this + // isolated fixture depend on the developer's machine or connectivity. + if (!(await adapter.loadFTSExtension(undefined, { policy: 'load-only' }))) { + if (process.env.GITNEXUS_REQUIRE_FTS === '1') { + throw new Error( + 'FTS is required (GITNEXUS_REQUIRE_FTS=1) but could not load in the isolated fixture.', + ); + } + ctx.skip('FTS extension unavailable under the load-only policy'); + } + await adapter.closeLbug(); + + await fs.mkdir(path.join(main, 'Sources'), { recursive: true }); + await fs.mkdir(path.join(main, 'checks'), { recursive: true }); + await fs.mkdir(path.join(main, 'retained'), { recursive: true }); + await fs.writeFile(path.join(main, '.gitignore'), '.gitnexus/\n'); + await fs.writeFile(path.join(main, SWIFT_PATH), SWIFT_SOURCE); + await fs.writeFile(path.join(main, 'checks/__init__.py'), ''); + await fs.writeFile(path.join(main, TEST_PATH), 'def fixture_number():\n return 7\n'); + await fs.writeFile( + path.join(main, IMPORTER_PATH), + 'from checks.test_numbers import fixture_number\n\ndef run_checks():\n return fixture_number()\n', + ); + // Keep the two-file write set below the 50-file / 50% escalation gate. + for (let i = 0; i < 55; i++) { + await fs.writeFile( + path.join(main, `retained/value_${i}.py`), + `def retained_value_${i}():\n return ${i}\n`, + ); + } + initGitRepo(main); + git(main, 'config', 'commit.gpgsign', 'false'); + commitAll(main, 'baseline mixed-language fixture'); + git(main, 'worktree', 'add', '-q', '-b', 'lookup-refresh', worktree); + expect((await fs.stat(path.join(worktree, '.git'))).isFile()).toBe(true); + expect(git(worktree, 'rev-parse', 'HEAD')).toBe(git(main, 'rev-parse', 'HEAD')); + + const { storagePath, lbugPath } = getStoragePaths(worktree); + expect(storagePath).toBe(path.join(worktree, '.gitnexus')); + const baseline = await runFullAnalysis(worktree, options, callbacks); + const baselineMeta = await loadMeta(storagePath); + expect(baselineMeta?.runnerIdentity).toBeDefined(); + const swiftFunctions = [...baseline.pipelineResult.graph.iterNodes()].filter( + (node) => node.label === 'Function' && node.properties.filePath === SWIFT_PATH, + ); + function symbol(name: string): SymbolRef { + const matches = swiftFunctions.filter((node) => node.properties.name === name); + expect(matches, name).toHaveLength(1); + return { uid: matches[0].id, name, filePath: SWIFT_PATH }; + } + const target = symbol(TARGET_NAME); + const callers = CALLER_NAMES.map(symbol); + expect(new Set(callers.map(({ uid }) => uid)).size).toBe(2); + + // runFullAnalysis is imported from source. Use the same source build + // for status so it actually measures content drift instead of stopping + // at a source/dist runner-identity mismatch. Context may use either. + const statusPrefix = [ + '--import', + tsxLoaderUrl(), + fileURLToPath(new URL('../../src/cli/index.ts', import.meta.url)), + ]; + const readStatus = (): StatusResult => + runCli(worktree, ['status', '--json'], statusPrefix); + const expectCurrentStatus = (): void => { + const status = readStatus(); + expect(status.repository).toBe(worktree); + expect(status.index.runnerIdentityStatus).toBe('current'); + expect(status.contentDrift.status).toBe('current'); + expect(status.contentDrift.coveredFiles).toBeGreaterThan(50); + expect(status.index.incompleteReasons).toEqual([]); + expect(status.status).toBe('up-to-date'); + }; + const readPublicContexts = async (): Promise => { + // CLI and MCP own separate native readers, released before writes. + expectContext(runCli(worktree, ['context', TARGET_NAME]), target, callers); + expectContext(runCli(worktree, ['context', '--uid', target.uid]), target, callers); + backend = new LocalBackend(); + try { + expect(await backend.init()).toBe(true); + for (let repeat = 0; repeat < 2; repeat++) { + expectContext( + await backend.callTool('context', { name: TARGET_NAME }), + target, + callers, + ); + expectContext( + await backend.callTool('context', { uid: target.uid }), + target, + callers, + ); + } + } finally { + await backend.dispose(); + backend = undefined; + } + }; + const readPropertyFts = async (): Promise => { + await adapter.initLbug(lbugPath); + try { + const rows = await adapter.executeQuery( + `CALL QUERY_FTS_INDEX('Property', 'property_fts', '${PROPERTY_NAME}') + RETURN node.id AS uid, node.name AS name, node.filePath AS filePath`, + ); + expect(rows).toEqual([ + { uid: expect.any(String), name: PROPERTY_NAME, filePath: SWIFT_PATH }, + ]); + return rows; + } finally { + await adapter.closeLbug(); + } + }; + + await readPublicContexts(); + expectCurrentStatus(); + const propertyBefore = await readPropertyFts(); + + await fs.writeFile(path.join(worktree, TEST_PATH), 'def fixture_number():\n return 8\n'); + expect(git(worktree, 'diff', '--name-only')).toBe(TEST_PATH); + const dirtyStatus = readStatus(); + expect(dirtyStatus.contentDrift.status).toBe('drifted'); + expect(dirtyStatus.index.incompleteReasons).toEqual([]); + + // A call-through spy observes the exact native deletion boundary; it + // neither supplies graph rows nor substitutes for the incremental write. + const deleteNodes = vi.spyOn(adapter, 'deleteNodesForFiles'); + const refreshed = await runFullAnalysis(worktree, options, callbacks); + expect(refreshed.incrementalStats).toMatchObject({ + changedFiles: 1, + writeMode: 'incremental', + }); + expect(refreshed.incrementalStats?.affectedDependents).toBeGreaterThan(0); + expect(deleteNodes).toHaveBeenCalledTimes(1); + const writePaths = [...deleteNodes.mock.calls[0][0]].sort(); + // Effective write-set expansion also refreshes the containing Folder + // row through graph edges; no other source file enters the write set. + expect(writePaths).toEqual(['checks', TEST_PATH, IMPORTER_PATH].sort()); + expect(writePaths.filter((filePath) => filePath.endsWith('.py'))).toEqual( + [TEST_PATH, IMPORTER_PATH].sort(), + ); + expect(writePaths).not.toContain(SWIFT_PATH); + expect((await loadMeta(storagePath))?.runnerIdentity).toEqual(baselineMeta?.runnerIdentity); + expect(await fs.readFile(path.join(worktree, SWIFT_PATH), 'utf8')).toBe(SWIFT_SOURCE); + await readPublicContexts(); + expectCurrentStatus(); + expect(await readPropertyFts()).toEqual(propertyBefore); + + deleteNodes.mockClear(); + const noop = await runFullAnalysis(worktree, options, callbacks); + // The edit remains uncommitted, so clean-porcelain early return is + // unavailable. Identical content instead takes a zero-file refresh. + expect(noop.incrementalStats).toMatchObject({ + changedFiles: 0, + affectedDependents: 0, + deletedFiles: 0, + writeMode: 'incremental', + }); + expect(deleteNodes.mock.calls.flatMap(([filePaths]) => filePaths)).toEqual([]); + expect((await loadMeta(storagePath))?.runnerIdentity).toEqual(baselineMeta?.runnerIdentity); + await readPublicContexts(); + expectCurrentStatus(); + expect(await readPropertyFts()).toEqual(propertyBefore); + } finally { + await backend?.dispose(); + await adapter.closeLbug(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + await temp.cleanup(); + } + }, 180_000); + }, +); diff --git a/gitnexus/test/integration/kotlin-spring-route-pipeline.test.ts b/gitnexus/test/integration/kotlin-spring-route-pipeline.test.ts index c7f29f912..6a931cd4e 100644 --- a/gitnexus/test/integration/kotlin-spring-route-pipeline.test.ts +++ b/gitnexus/test/integration/kotlin-spring-route-pipeline.test.ts @@ -10,7 +10,7 @@ import os from 'node:os'; import path from 'node:path'; import { requireVendoredGrammar } from '../../src/core/tree-sitter/vendored-grammars.js'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; import { loadParseCache, PARSE_CACHE_VERSION, diff --git a/gitnexus/test/integration/lbug-core-adapter.test.ts b/gitnexus/test/integration/lbug-core-adapter.test.ts index 814fd60a4..89dd1c8a8 100644 --- a/gitnexus/test/integration/lbug-core-adapter.test.ts +++ b/gitnexus/test/integration/lbug-core-adapter.test.ts @@ -11,6 +11,8 @@ import { describe, it, expect } from 'vitest'; import fs from 'fs/promises'; import path from 'path'; +import { PassThrough } from 'node:stream'; +import type { Response } from 'express'; import type { GraphRelationship } from 'gitnexus-shared'; import { withTestLbugDB } from '../helpers/test-indexed-db.js'; import { skipUnlessFtsAvailable } from '../helpers/fts-availability.js'; @@ -61,6 +63,39 @@ withTestLbugDB( expect(folderRows).toHaveLength(1); }); + it('serializes CSV-loaded empty relationship reasons as strings in both graph APIs', async () => { + const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js'); + const { buildGraph, streamGraphNdjson } = await import('../../src/server/api.js'); + const stored = await executeQuery( + 'MATCH ()-[r:CodeRelation]->() RETURN r.reason AS reason', + ); + expect(stored).toEqual(Array.from({ length: 4 }, () => ({ reason: null }))); + + const buffered = JSON.parse(JSON.stringify(await buildGraph())); + const response = new PassThrough(); + let ndjson = ''; + response.on('data', (chunk: Buffer) => { + ndjson += chunk.toString(); + }); + try { + await streamGraphNdjson(response as unknown as Response); + } finally { + response.destroy(); + } + const streamed = ndjson + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + .filter((record) => record.type === 'relationship') + .map((record) => record.data); + expect(buffered.relationships).toHaveLength(4); + expect({ + buffered: buffered.relationships.map((rel: GraphRelationship) => rel.reason), + streamed: streamed.map((rel: GraphRelationship) => rel.reason), + }).toEqual({ buffered: ['', '', '', ''], streamed: ['', '', '', ''] }); + expect(streamed).toEqual(expect.arrayContaining(buffered.relationships)); + }); + it('createFTSIndex: creates FTS index on Function table without error', async (ctx) => { await skipUnlessFtsAvailable(ctx); const { createFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js'); diff --git a/gitnexus/test/integration/lbug-incremental-search.test.ts b/gitnexus/test/integration/lbug-incremental-search.test.ts new file mode 100644 index 000000000..ac94170be --- /dev/null +++ b/gitnexus/test/integration/lbug-incremental-search.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const reproducer = fileURLToPath( + new URL('../../bench/incremental-write-integrity/reproduce-search.cjs', import.meta.url), +); +interface Check { + name: string; + ok: boolean; + error?: string; + rows?: number; + wrong?: number; + missing?: number; + ids?: string[]; + indexes?: number; +} + +describe('native incremental search (#3421, #3423)', () => { + for (const scenario of ['context', 'property'] as const) { + it(`preserves retained ${scenario} strings and search through COPY, checkpoint and reopen`, () => { + // Own process: isolate native lifetimes and allow old-engine diagnostic runs. + const result = spawnSync(process.execPath, [reproducer, scenario], { + encoding: 'utf8', + timeout: 120_000, + maxBuffer: 2 * 1024 * 1024, + }); + expect(result.error, result.stderr).toBeUndefined(); + expect(result.signal, result.stderr).toBeNull(); + expect(result.status, result.stdout + result.stderr).toBe(0); + const report = JSON.parse(result.stdout.trim()) as { checks: Check[] }; + expect(report.checks.filter((check) => !check.ok)).toEqual([]); + const check = (name: string) => report.checks.find((entry) => entry.name === name); + expect(check('after-delete:scan')).toMatchObject({ rows: 8128, wrong: 0, missing: 0 }); + const stages = ['baseline', 'after-copy', 'after-checkpoint', 'reopened']; + if (scenario === 'property') stages.push('repair'); + for (const stage of stages) { + expect(check(`${stage}:scan`)).toMatchObject({ rows: 8192, wrong: 0, missing: 0 }); + if (scenario === 'context') { + for (const lookup of ['primary-key', 'uid', 'name']) { + expect(check(`${stage}:${lookup}`)?.ids).toEqual([ + 'Function:src/owner2.ts:unchangedSwiftTarget64', + ]); + } + } else { + expect(check(`${stage}:catalog`)?.indexes).toBe(1); + expect(check(`${stage}:fts-query`)?.ids).toEqual([ + 'Property:src/owner2.swift:property64', + ]); + for (const column of ['name', 'content', 'description']) { + expect(check(`${stage}:lower-${column}`)?.rows).toBe(8192); + } + } + } + if (scenario === 'property') { + for (const name of ['baseline:build', 'after-copy:build', 'repair:drop', 'repair:build']) { + expect(check(name)?.ok).toBe(true); + } + } + }, 150_000); + } +}); diff --git a/gitnexus/test/integration/lbug-interrupted-checkpoint-recovery.test.ts b/gitnexus/test/integration/lbug-interrupted-checkpoint-recovery.test.ts index d2dea28cf..287f2333b 100644 --- a/gitnexus/test/integration/lbug-interrupted-checkpoint-recovery.test.ts +++ b/gitnexus/test/integration/lbug-interrupted-checkpoint-recovery.test.ts @@ -20,58 +20,28 @@ * and the behavioral contract is held on every pin by the mocked * forced-refusal suites instead. */ -import { afterAll, describe, expect, it } from 'vitest'; +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { spawnSync } from 'node:child_process'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { closeLbug, executeQuery, initLbug } from '../../src/core/lbug/pool-adapter.js'; import lbug from '@ladybugdb/core'; +import { closeQueryResults } from '../../src/core/lbug/query-result-utils.js'; const REPO = 'test-interrupted-checkpoint'; const ROWS = 300; /** - * Windows: the native close() resolves before the kernel releases the file's - * handles and byte-range locks — the next open then dies with Win32 Error 33 - * ("another process has locked a portion of the file"), which is exactly how - * this fixture failed its first hosted run. Probe-read both the db and its - * residual WAL until the engine's locks are gone. Bounded, so a real handle - * leak fails loudly instead of hanging; a pass-through on POSIX (first probe - * always succeeds). - */ -async function waitForFixtureRelease(dbPath: string): Promise { - for (const target of [dbPath, `${dbPath}.wal`]) { - for (let attempt = 0; ; attempt++) { - try { - const fh = await fs.open(target, 'r'); - try { - await fh.read(Buffer.alloc(1), 0, 1, 0); - } finally { - await fh.close(); - } - break; - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') break; // nothing planted there - if (attempt >= 40) throw err; // ~6s of retries: report the leak - await new Promise((resolve) => setTimeout(resolve, 150)); - } - } - } -} - -/** - * Deterministic interrupted-checkpoint signature: build rows on a writable - * session with AUTO-CHECKPOINT DISABLED and close WITHOUT checkpointing, so — - * exactly like a CHECKPOINT killed mid-flight — the main file is stale and - * every row lives only in the WAL. Then rename that WAL to the - * `lbug.wal.checkpoint` name the engine gives it during checkpoint, and plant - * the shadow + intent/apply lock files it leaves behind. + * Preserve the main file and WAL before native close forces a checkpoint. + * Restoring both snapshots models a killed checkpoint with every row still + * WAL-only. All query results must be closed before releasing the database: + * native results retain the database and its writer lock until closed or GC'd. */ async function plantInterruptedCheckpoint(dbPath: string): Promise { - // Raw constructor (positional args mirror createLbugDatabase) because the - // autoCheckpoint toggle is not exposed through the config helpers — and - // auto-checkpoint-on-close is precisely what must NOT happen here. + // Disable automatic checkpoints while building the WAL. Native close still + // forces a checkpoint, so the pre-close main file must also be preserved. const db = new lbug.Database( dbPath, 128 * 1024 * 1024, // bufferManagerSize @@ -83,40 +53,61 @@ async function plantInterruptedCheckpoint(dbPath: string): Promise { false, // throwOnWalReplayFailure true, // enableChecksums ); - await db.init(); - const conn = new lbug.Connection(db); + let conn: lbug.Connection | undefined; + let mainBuffer: Buffer; + let walBuffer: Buffer; try { - await conn.query('CREATE NODE TABLE Person (name STRING, PRIMARY KEY(name))'); + await db.init(); + conn = new lbug.Connection(db); + const statements = ['CREATE NODE TABLE Person (name STRING, PRIMARY KEY(name))']; for (let i = 0; i < ROWS; i += 100) { const batch = Array.from({ length: 100 }, (_, j) => `{name: 'p${i + j}'}`).join(', '); - await conn.query(`UNWIND [${batch}] AS r CREATE (:Person {name: r.name})`); + statements.push(`UNWIND [${batch}] AS r CREATE (:Person {name: r.name})`); } - const walBuffer = await fs.readFile(`${dbPath}.wal`); - // Honesty check: the rows must actually LIVE in the WAL — on an engine - // that tolerates the planted state this is the only proof the plant is - // not an empty shell (review finding: unused walBuffer). + for (const statement of statements) { + const result = await conn.query(statement); + try { + for (const cursor of Array.isArray(result) ? result : [result]) await cursor.getAll(); + } finally { + await closeQueryResults(result); + } + } + [mainBuffer, walBuffer] = await Promise.all([ + fs.readFile(dbPath), + fs.readFile(`${dbPath}.wal`), + ]); expect(walBuffer.byteLength).toBeGreaterThan(0); - // Close WITHOUT checkpoint: rows stay WAL-only, main file stays stale. - // Explicitly awaited release BEFORE the rename/reopen — on Windows the - // kernel releases the engine's handles/locks asynchronously and the WAL - // rename + pooled reopen race them (Win32 Error 33, seen in CI). - await conn.close().catch(() => {}); + } finally { + await conn?.close().catch(() => {}); await db.close().catch(() => {}); - await waitForFixtureRelease(dbPath); - // Re-plant the captured WAL bytes rather than renaming the original: a - // close-time auto-checkpoint can consume the live .wal file out from - // under the rename (ENOENT — the fixture's other CI flake), while the - // captured buffer is what a killed checkpoint would have left behind. - await fs.writeFile(`${dbPath}.wal.checkpoint`, walBuffer); - await fs.writeFile(`${dbPath}.wal`, ''); - await fs.writeFile(`${dbPath}.shadow`, ''); - await fs.writeFile(`${dbPath}.checkpoint.intent.lock`, ''); - await fs.writeFile(`${dbPath}.checkpoint.apply.lock`, ''); - } catch (err) { - await conn.close().catch(() => {}); - await db.close().catch(() => {}); - throw err; } + // A separate process must acquire the native writer lock before the + // fixture files are restored. A byte-zero file read cannot test that lock. + const probe = spawnSync( + process.execPath, + [ + '-e', + `const lbug = require(process.argv[1]); + const db = new lbug.Database(process.argv[2], 128 * 1024 * 1024, false, + false, 16 * 1024 * 1024 * 1024); + db.init().then(() => db.close()).catch((error) => { + console.error(error); + process.exitCode = 1; + });`, + fileURLToPath(new URL('../../node_modules/@ladybugdb/core', import.meta.url)), + dbPath, + ], + { encoding: 'utf8', timeout: 15_000 }, + ); + expect(probe.error).toBeUndefined(); + expect(probe.status, probe.stderr).toBe(0); + + await fs.writeFile(dbPath, mainBuffer); + await fs.writeFile(`${dbPath}.wal.checkpoint`, walBuffer); + await fs.writeFile(`${dbPath}.wal`, ''); + await fs.writeFile(`${dbPath}.shadow`, ''); + await fs.writeFile(`${dbPath}.checkpoint.intent.lock`, ''); + await fs.writeFile(`${dbPath}.checkpoint.apply.lock`, ''); } describe('interrupted-checkpoint recovery (pooled read path self-heal)', () => { @@ -128,6 +119,38 @@ describe('interrupted-checkpoint recovery (pooled read path self-heal)', () => { if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true }); }); + it('preserves the setup error when both native closes reject', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-lbug-cp-cleanup-')); + const setupError = new Error('injected query failure'); + const closeConnection = lbug.Connection.prototype.close; + const closeDatabase = lbug.Database.prototype.close; + const query = vi.spyOn(lbug.Connection.prototype, 'query').mockRejectedValueOnce(setupError); + const connectionClose = vi + .spyOn(lbug.Connection.prototype, 'close') + .mockImplementation(async function (this: lbug.Connection) { + await closeConnection.call(this); + throw new Error('injected connection close failure'); + }); + const databaseClose = vi + .spyOn(lbug.Database.prototype, 'close') + .mockImplementation(async function (this: lbug.Database) { + await closeDatabase.call(this); + throw new Error('injected database close failure'); + }); + try { + await expect(plantInterruptedCheckpoint(path.join(directory, 'lbug'))).rejects.toBe( + setupError, + ); + expect(connectionClose).toHaveBeenCalledOnce(); + expect(databaseClose).toHaveBeenCalledOnce(); + } finally { + query.mockRestore(); + connectionClose.mockRestore(); + databaseClose.mockRestore(); + await fs.rm(directory, { recursive: true, force: true }); + } + }); + it('opens read-only through the pool refusal and answers queries', async (ctx) => { // Engine-version honesty: only 0.19+ treats the planted signature as an // interrupted checkpoint ("Cannot open database in read-only mode while @@ -180,9 +203,6 @@ describe('interrupted-checkpoint recovery (pooled read path self-heal)', () => { } })(), ).rejects.toThrow(/checkpoint is in progress/i); - // The probe's native close is best-effort; its handles must be gone - // before the pooled open below (Windows Error 33 otherwise). - await waitForFixtureRelease(dbPath); // The wiki path: pooled READ-ONLY open. Before the fix this refused with // "Cannot open database in read-only mode while checkpoint is in @@ -212,7 +232,6 @@ describe('interrupted-checkpoint recovery (pooled read path self-heal)', () => { // A second open must answer without needing recovery again. await closeLbug(REPO); - await waitForFixtureRelease(dbPath); await initLbug(REPO, dbPath); const again = await executeQuery(REPO, 'MATCH (n:Person) RETURN count(n) AS c'); expect(again.length).toBe(1); diff --git a/gitnexus/test/integration/lbug-load-overlap-errors.test.ts b/gitnexus/test/integration/lbug-load-overlap-errors.test.ts index d72477a4e..f75c87e3a 100644 --- a/gitnexus/test/integration/lbug-load-overlap-errors.test.ts +++ b/gitnexus/test/integration/lbug-load-overlap-errors.test.ts @@ -81,6 +81,52 @@ afterAll(async () => { }); describe('loadGraphToLbug overlap error paths (#2226 F1)', () => { + it.each([1000, 0])( + 'retains COPY failures and rejects skipped rows with warning_limit=%i', + async (warningLimit) => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const { _captureLogger } = await import('../../src/core/logger.js'); + const graph = buildTestGraph([], []); + emitMock.mockImplementation( + async (_g: unknown, _r: unknown, dir: string, onNodes?: (n: NodeFiles) => void) => { + await fs.mkdir(dir, { recursive: true }); + const csvPath = path.join(dir, 'function.csv'); + await fs.writeFile( + csvPath, + 'id,name,filePath,startLine,endLine,isExported,content,description,convexEndpointFactory\n' + + '"Function:bad.ts:copy","copy","bad.ts",bad,2,false,"","",""\n', + ); + const nodeFiles = new Map([['Function', { csvPath, rows: 1 }]]) as NodeFiles; + onNodes?.(nodeFiles); + return { ...emptyResult(), nodeFiles }; + }, + ); + const capture = _captureLogger(); + try { + await adapter.executeQuery(`CALL warning_limit=${warningLimit}`); + await expect(adapter.loadGraphToLbug(graph, tmpBase, storagePath)).rejects.toThrow( + /skipped/i, + ); + const records = capture.records(); + expect(records.some((r) => /first COPY failure/i.test(String(r.msg)))).toBe(true); + expect(records).toContainEqual( + expect.objectContaining({ + expectedRows: 1, + copiedRows: 0, + skippedRows: 1, + retainedWarnings: warningLimit === 0 ? 0 : 1, + }), + ); + } finally { + try { + await adapter.executeQuery('CALL warning_limit=1000'); + } finally { + capture.restore(); + } + } + }, + ); + it('relationship-emit failure with node COPY in flight surfaces the emit error and leaks no unhandled rejection', async () => { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const graph = buildTestGraph( diff --git a/gitnexus/test/integration/local-backend-calltool.test.ts b/gitnexus/test/integration/local-backend-calltool.test.ts index f618a3c5b..6477a5fce 100644 --- a/gitnexus/test/integration/local-backend-calltool.test.ts +++ b/gitnexus/test/integration/local-backend-calltool.test.ts @@ -356,7 +356,9 @@ withTestLbugDB( const result = await backend.callTool('tool_map', {}); expect(result).not.toHaveProperty('error'); - const tools = new Map(result.tools.map((tool: any) => [tool.name, tool])); + const toolEntries: Array<{ name: string; description: string; flows: string[] }> = + result.tools; + const tools = new Map(toolEntries.map((tool) => [tool.name, tool])); expect(tools.get('alpha')?.description).toBe('Calls chain A.'); expect(tools.get('beta')?.description).toBe('Calls chain B.'); expect(tools.get('alpha')?.flows).toEqual(['AlphaFlow']); @@ -956,3 +958,98 @@ withTestLbugDB( }, }, ); + +const PYTHON_METHOD_ID = 'Method:tests/test_supervisor.py:Supervisor.run'; +const PYTHON_CALLER_ID = 'Function:tests/test_supervisor.py:test_run'; +const SWIFT_METHOD_ID = 'Method:Sources/Supervisor.swift:Supervisor.run'; +const SWIFT_CALLER_ID = 'Constructor:Sources/Supervisor.swift:Supervisor.init'; + +withTestLbugDB( + 'symbol-identity-isolation-3424', + (handle) => { + describe('mixed Python/Swift symbol identity isolation (#3424)', () => { + let backend: LocalBackend; + + beforeAll(() => { + backend = (handle as typeof handle & { _backend: LocalBackend })._backend; + }); + + it.each(['name and file', 'UID'])('keeps Python context isolated by %s', async (lookup) => { + const params = + lookup === 'UID' + ? { uid: PYTHON_METHOD_ID } + : { name: 'run', file_path: 'tests/test_supervisor.py' }; + const result = await backend.callTool('context', params); + expect(result).not.toHaveProperty('error'); + expect(result.symbol.uid).toBe(PYTHON_METHOD_ID); + expect(result.incoming.calls.map((caller: { uid: string }) => caller.uid)).toEqual([ + PYTHON_CALLER_ID, + ]); + }); + + it.each(['name and file', 'UID'])('keeps Python impact isolated by %s', async (lookup) => { + const params = + lookup === 'UID' + ? { target_uid: PYTHON_METHOD_ID } + : { target: 'run', file_path: 'tests/test_supervisor.py' }; + const result = await backend.callTool('impact', { + ...params, + direction: 'upstream', + includeTests: true, + }); + expect(result).not.toHaveProperty('error'); + expect(result.target.id).toBe(PYTHON_METHOD_ID); + expect(result.impactedCount).toBe(1); + expect(result.byDepth[1].map((caller: { id: string }) => caller.id)).toEqual([ + PYTHON_CALLER_ID, + ]); + }); + + it('keeps the unrelated Swift constructor queryable', async () => { + const context = await backend.callTool('context', { uid: SWIFT_METHOD_ID }); + expect(context).not.toHaveProperty('error'); + expect(context.symbol.uid).toBe(SWIFT_METHOD_ID); + expect(context.incoming.calls.map((caller: { uid: string }) => caller.uid)).toEqual([ + SWIFT_CALLER_ID, + ]); + const impact = await backend.callTool('impact', { + target_uid: SWIFT_METHOD_ID, + direction: 'upstream', + includeTests: true, + }); + expect(impact).not.toHaveProperty('error'); + expect(impact.target.id).toBe(SWIFT_METHOD_ID); + expect(impact.impactedCount).toBe(1); + expect(impact.byDepth[1].map((caller: { id: string }) => caller.id)).toEqual([ + SWIFT_CALLER_ID, + ]); + }); + }); + }, + { + seed: [ + `CREATE (:Method {id: '${PYTHON_METHOD_ID}', name: 'run', filePath: 'tests/test_supervisor.py', startLine: 3, endLine: 5})`, + `CREATE (:Function {id: '${PYTHON_CALLER_ID}', name: 'test_run', filePath: 'tests/test_supervisor.py', startLine: 7, endLine: 9})`, + `CREATE (:Method {id: '${SWIFT_METHOD_ID}', name: 'run', filePath: 'Sources/Supervisor.swift', startLine: 3, endLine: 5})`, + `CREATE (:Constructor {id: '${SWIFT_CALLER_ID}', name: 'init', filePath: 'Sources/Supervisor.swift', startLine: 7, endLine: 9})`, + `MATCH (a:Function), (b:Method) WHERE a.id = '${PYTHON_CALLER_ID}' AND b.id = '${PYTHON_METHOD_ID}' CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'direct', step: 0}]->(b)`, + `MATCH (a:Constructor), (b:Method) WHERE a.id = '${SWIFT_CALLER_ID}' AND b.id = '${SWIFT_METHOD_ID}' CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'direct', step: 0}]->(b)`, + ], + poolAdapter: true, + afterSetup: async (handle) => { + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'mixed-language-repo', + path: '/mixed-language/repo', + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'abc123', + stats: { files: 2, nodes: 4, communities: 0, processes: 0 }, + }, + ]); + const backend = new LocalBackend(); + await backend.init(); + (handle as typeof handle & { _backend: LocalBackend })._backend = backend; + }, + }, +); diff --git a/gitnexus/test/integration/mcp/update-notice.test.ts b/gitnexus/test/integration/mcp/update-notice.test.ts index a1380af39..654d97524 100644 --- a/gitnexus/test/integration/mcp/update-notice.test.ts +++ b/gitnexus/test/integration/mcp/update-notice.test.ts @@ -18,7 +18,7 @@ interface FakeChecker { } interface FakeLogger { - info: ReturnType; + info: ReturnType, message: string) => unknown>>; } function checker(initial: UpdateState | null): { diff --git a/gitnexus/test/integration/multi-verb-route-identity.test.ts b/gitnexus/test/integration/multi-verb-route-identity.test.ts index d488c2522..f0d270358 100644 --- a/gitnexus/test/integration/multi-verb-route-identity.test.ts +++ b/gitnexus/test/integration/multi-verb-route-identity.test.ts @@ -21,7 +21,7 @@ import path from 'node:path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; import { generateId } from '../../src/lib/utils.js'; import { routeNodeKey } from '../../src/core/ingestion/route-extractors/route-path.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'multi-verb-route-app'); diff --git a/gitnexus/test/integration/pdg-query.test.ts b/gitnexus/test/integration/pdg-query.test.ts index de5477d8a..a138d6766 100644 --- a/gitnexus/test/integration/pdg-query.test.ts +++ b/gitnexus/test/integration/pdg-query.test.ts @@ -134,7 +134,7 @@ withTestLbugDB( // An MCP client may send {"name":"pdg_query"} with no `arguments` field; // the dispatch then hands `params: undefined` to the impl. It must // default to {} and surface the mode-validation error, not a TypeError. - const result = await backend.callTool('pdg_query'); + const result = await backend.callTool('pdg_query', undefined); expect(result).toHaveProperty('error'); expect(result.error).toMatch(/mode/i); }); diff --git a/gitnexus/test/integration/resolvers/callable-value-flow.test.ts b/gitnexus/test/integration/resolvers/callable-value-flow.test.ts index a2222fb4b..b7b7ed7b0 100644 --- a/gitnexus/test/integration/resolvers/callable-value-flow.test.ts +++ b/gitnexus/test/integration/resolvers/callable-value-flow.test.ts @@ -326,7 +326,12 @@ function callableTargetQualifiedNames( ): string[] { return getRelationships(result, 'CALLS') .filter((edge) => edge.source === source && edge.rel.reason === 'callable-value-flow') - .map((edge) => result.graph.getNode(edge.rel.targetId)?.properties.qualifiedName ?? edge.target) + .map((edge) => { + const qualifiedName = result.graph.getNode(edge.rel.targetId)?.properties.qualifiedName; + if (qualifiedName == null) return edge.target; + if (typeof qualifiedName !== 'string') throw new Error('Expected a string qualified name'); + return qualifiedName; + }) .sort(); } @@ -350,7 +355,10 @@ describe('callable value flow', () => { it('does not overstate Objective-C callable-value-flow coverage', () => { expect(CALLABLE_FLOW_PROVIDER_COVERAGE[SupportedLanguages.ObjectiveC]).toBe('not-applicable'); expect( - PROVIDER_FLOW_CASES.some(({ language }) => language === SupportedLanguages.ObjectiveC), + PROVIDER_FLOW_CASES.some( + ({ language }: { language: SupportedLanguages }) => + language === SupportedLanguages.ObjectiveC, + ), ).toBe(false); }); diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 4bcb4e0bd..47899b348 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -1757,7 +1757,11 @@ describe('C++ template specialization disambiguation across files', () => { expect(targetOwnerEdge).toBeDefined(); expect(sourceOwnerEdge!.rel.sourceId).toBe(targetOwnerEdge!.rel.sourceId); const ownerNode = result.graph.getNode(sourceOwnerEdge!.rel.sourceId); - const fp = ownerNode?.properties.templateArguments?.join(','); + const templateArguments = ownerNode?.properties.templateArguments; + if (templateArguments != null && !Array.isArray(templateArguments)) { + throw new Error('Expected template arguments to be an array'); + } + const fp = Array.isArray(templateArguments) ? templateArguments.join(',') : undefined; if (fp) ownerFingerprints.add(fp); } expect(ownerFingerprints).toEqual(new Set(['User', 'Order'])); @@ -2125,14 +2129,15 @@ describe('C++ ambiguous integer-width overloads', () => { }); it('records a structured suppression reason for normalization ambiguity', () => { - const outcomes = getResolutionOutcomes(result).filter( - (o) => - o.kind === 'suppressed' && - o.name === 'process' && - o.phase === 'receiver-bound-calls' && - o.filePath.endsWith('caller.cpp') && - o.reason === 'overload-ambiguous-normalization', - ); + const outcomes = getResolutionOutcomes(result) + .filter((o) => o.kind === 'suppressed') + .filter( + (o) => + o.name === 'process' && + o.phase === 'receiver-bound-calls' && + o.filePath.endsWith('caller.cpp') && + o.reason === 'overload-ambiguous-normalization', + ); expect(outcomes.length).toBeGreaterThan(0); expect(outcomes[0]?.candidateIds.length).toBe(2); @@ -2214,13 +2219,12 @@ describe('C++ overload resolution — conversion-rank disambiguation (#1578)', ( }); it('records a structured suppression reason for conversion-rank ties', () => { - const outcomes = getResolutionOutcomes(result).filter( - (o) => - o.kind === 'suppressed' && - o.name === 'h' && - o.phase === 'free-call-fallback' && - o.reason === 'conversion-rank-tied', - ); + const outcomes = getResolutionOutcomes(result) + .filter((o) => o.kind === 'suppressed') + .filter( + (o) => + o.name === 'h' && o.phase === 'free-call-fallback' && o.reason === 'conversion-rank-tied', + ); expect(outcomes.length).toBeGreaterThan(0); expect(outcomes[0]?.candidateIds.length).toBe(2); @@ -3256,13 +3260,14 @@ describe('C++ ADL — non-function ordinary lookup suppresses ADL', () => { }); it('records a structured suppression reason for ADL blocker lookup', () => { - const outcomes = getResolutionOutcomes(result).filter( - (o) => - o.kind === 'suppressed' && - o.name === 'record' && - o.phase === 'free-call-fallback' && - o.reason === 'adl-ordinary-lookup-blocked', - ); + const outcomes = getResolutionOutcomes(result) + .filter((o) => o.kind === 'suppressed') + .filter( + (o) => + o.name === 'record' && + o.phase === 'free-call-fallback' && + o.reason === 'adl-ordinary-lookup-blocked', + ); expect(outcomes.length).toBeGreaterThan(0); expect(outcomes[0]?.candidateIds.length).toBe(0); @@ -3552,13 +3557,14 @@ describe('C++ inline namespace — ambiguous same-name across inline children (# }); it('records a structured suppression reason for inline namespace ambiguity', () => { - const outcomes = getResolutionOutcomes(result).filter( - (o) => - o.kind === 'suppressed' && - o.name === 'foo' && - o.phase === 'receiver-bound-calls' && - o.reason === 'inline-ns-ambiguous', - ); + const outcomes = getResolutionOutcomes(result) + .filter((o) => o.kind === 'suppressed') + .filter( + (o) => + o.name === 'foo' && + o.phase === 'receiver-bound-calls' && + o.reason === 'inline-ns-ambiguous', + ); expect(outcomes.length).toBeGreaterThan(0); expect(outcomes[0]?.candidateIds.length).toBe(0); diff --git a/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts b/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts index 159c0d694..6df379155 100644 --- a/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts +++ b/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts @@ -413,6 +413,7 @@ describe('JavaScript plain-object property access (A1/A5)', () => { }); interface PropNode { + readonly id: string; readonly label: string; readonly properties: Record; } diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index 646fef3b0..57dc088d0 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -1146,6 +1146,7 @@ describe('Python mixin self-dispatch', () => { expect( getResolutionOutcomes(result).some( (outcome) => + outcome.kind === 'suppressed' && outcome.filePath === 'mixins.py' && outcome.name === 'class_only' && outcome.reason === 'receiver-unresolved', @@ -1202,11 +1203,9 @@ describe('Python mixin self-dispatch', () => { it('records only the expected mixin dispatch gaps and partial coverage', () => { const unresolvedSites = getResolutionOutcomes(result) + .filter((outcome) => outcome.kind === 'suppressed') .filter( - (outcome) => - outcome.kind === 'suppressed' && - outcome.reason === 'receiver-unresolved' && - outcome.filePath === 'mixins.py', + (outcome) => outcome.reason === 'receiver-unresolved' && outcome.filePath === 'mixins.py', ) .map((outcome) => `${outcome.range.startLine}:${outcome.name}`) .sort(); @@ -1325,6 +1324,7 @@ describe('Python mixin self-dispatch', () => { expect( getResolutionOutcomes(result).some( (outcome) => + outcome.kind === 'suppressed' && outcome.filePath === 'mixins.py' && outcome.name === 'duplicate_hook' && outcome.reason === 'member-lookup-ambiguous', @@ -1378,12 +1378,9 @@ describe('Python unproven subtype methods', () => { expect(calls.map((call) => call.rel.targetId)).toEqual([ expect.stringContaining('Concrete.hook'), ]); - const unresolved = getResolutionOutcomes(result).filter( - (outcome) => - outcome.kind === 'suppressed' && - outcome.name === 'hook' && - outcome.reason === 'receiver-unresolved', - ); + const unresolved = getResolutionOutcomes(result) + .filter((outcome) => outcome.kind === 'suppressed') + .filter((outcome) => outcome.name === 'hook' && outcome.reason === 'receiver-unresolved'); expect(unresolved.flatMap((outcome) => outcome.candidateIds).sort()).toEqual([ // AbstractWorker.hook (line 10) and Receiverless.hook (line 13). 'def:worker.py#10:4:Method:hook', @@ -1419,6 +1416,7 @@ describe('Python unproven subtype methods', () => { ]); expect( getResolutionOutcomes(result) + .filter((outcome) => outcome.kind === 'suppressed') .filter((outcome) => outcome.name === 'hook' && outcome.reason === 'receiver-unresolved') .flatMap((outcome) => outcome.candidateIds), ).toEqual([expect.stringMatching(/:Class:Base$/)]); @@ -1459,6 +1457,7 @@ class MissingHook(Mixin): expect( getResolutionOutcomes(result).some( (outcome) => + outcome.kind === 'suppressed' && outcome.name === 'hook' && outcome.reason === 'receiver-unresolved' && outcome.candidateIds.some((id) => id.endsWith(':Class:MissingHook')), @@ -1547,7 +1546,10 @@ class Mixin: ); expect( getResolutionOutcomes(result).some( - (outcome) => outcome.name === target && outcome.reason === 'receiver-unresolved', + (outcome) => + outcome.kind === 'suppressed' && + outcome.name === target && + outcome.reason === 'receiver-unresolved', ), ).toBe(true); } @@ -1647,7 +1649,10 @@ class ReboundProperty: ); expect( getResolutionOutcomes(result).some( - (outcome) => outcome.name === target && outcome.reason === 'receiver-unresolved', + (outcome) => + outcome.kind === 'suppressed' && + outcome.name === target && + outcome.reason === 'receiver-unresolved', ), ).toBe(true); } @@ -1660,13 +1665,17 @@ class ReboundProperty: expect( getResolutionOutcomes(result).some( (outcome) => - outcome.name === 'wrapped_helper' && outcome.reason === 'receiver-unresolved', + outcome.kind === 'suppressed' && + outcome.name === 'wrapped_helper' && + outcome.reason === 'receiver-unresolved', ), ).toBe(true); expect( getResolutionOutcomes(result).some( (outcome) => - outcome.name === 'rebound_helper' && outcome.reason === 'receiver-unresolved', + outcome.kind === 'suppressed' && + outcome.name === 'rebound_helper' && + outcome.reason === 'receiver-unresolved', ), ).toBe(true); } finally { @@ -1752,6 +1761,7 @@ class Worker(Mixin): expect( getResolutionOutcomes(result).some( (outcome) => + outcome.kind === 'suppressed' && outcome.filePath === 'case.py' && outcome.name === 'hook' && outcome.reason === 'receiver-unresolved', @@ -1760,6 +1770,7 @@ class Worker(Mixin): expect( getResolutionOutcomes(result).some( (outcome) => + outcome.kind === 'suppressed' && outcome.filePath === 'case.py' && outcome.name === 'own_hook' && outcome.reason === 'receiver-unresolved', @@ -1768,6 +1779,7 @@ class Worker(Mixin): expect( getResolutionOutcomes(result).some( (outcome) => + outcome.kind === 'suppressed' && outcome.filePath === 'case.py' && outcome.name === 'unicode_helper' && outcome.reason === 'receiver-unresolved', @@ -1776,6 +1788,7 @@ class Worker(Mixin): expect( getResolutionOutcomes(result).some( (outcome) => + outcome.kind === 'suppressed' && outcome.filePath === 'case.py' && outcome.name === 'nested_hook' && outcome.reason === 'receiver-unresolved', @@ -1855,6 +1868,7 @@ class DirectWorker(HookMixin, external.Parent, First, Second): expect( getResolutionOutcomes(result).some( (outcome) => + outcome.kind === 'suppressed' && outcome.name === 'hook' && outcome.reason === 'receiver-unresolved' && outcome.candidateIds.some((id) => id.endsWith(':Class:Worker')), @@ -1896,6 +1910,7 @@ class DirectWorker(HookMixin): expect( getResolutionOutcomes(result).some( (outcome) => + outcome.kind === 'suppressed' && outcome.name === 'hook' && outcome.reason === 'receiver-unresolved' && outcome.candidateIds.some((id) => id.endsWith(':Class:Worker')), diff --git a/gitnexus/test/integration/resolvers/swift.test.ts b/gitnexus/test/integration/resolvers/swift.test.ts index 5d3e0bd85..76b25a565 100644 --- a/gitnexus/test/integration/resolvers/swift.test.ts +++ b/gitnexus/test/integration/resolvers/swift.test.ts @@ -359,6 +359,95 @@ describe.skipIf(!swiftAvailable)('Swift protocol-extension implicit self (#3273) }); }); +describe.skipIf(!swiftAvailable)('Swift injected closure property call (#3425)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-injected-closure-call'), + () => {}, + ); + }, 60000); + + it('does not resolve an injected closure call to an unrelated method', () => { + expect( + getNodesByLabelFull(result, 'Property').some( + (node) => node.name === 'clock' && node.properties.filePath === 'Caller.swift', + ), + ).toBe(true); + expect( + getNodesByLabelFull(result, 'Function').some( + (node) => node.name === 'clock' && node.properties.filePath === 'Helpers.swift', + ), + ).toBe(true); + const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'refreshValue'); + expect(calls.filter((c) => c.target === 'clock')).toEqual([]); + }); + + it('does not resolve an inherited closure property call to the unrelated method', () => { + const extendsEdges = getRelationships(result, 'EXTENDS'); + expect( + extendsEdges.some( + (edge) => edge.source === 'DerivedService' && edge.target === 'BaseService', + ), + ).toBe(true); + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'refreshInheritedValue', + ); + expect(calls.filter((c) => c.target === 'clock')).toEqual([]); + }); + + it('keeps inherited closure calls in extensions unlinked', () => { + expect( + getNodesByLabelFull(result, 'Function').some( + (node) => + node.name === 'refreshInheritedFromExtension' && + node.properties.filePath === 'Helpers.swift', + ), + ).toBe(true); + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'refreshInheritedFromExtension', + ); + expect(calls.filter((c) => c.target === 'clock')).toEqual([]); + }); + + it('keeps same-type closure calls in extensions unlinked', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'refreshOwnFromExtension', + ); + expect(calls.filter((c) => c.target === 'clock')).toEqual([]); + }); + + it('still resolves the concrete-type extension call', () => { + const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'runScenario'); + expect(calls.map((c) => c.rel.targetId)).toEqual([ + 'Function:Helpers.swift:Example.makeValue#1', + ]); + }); + + it('keeps a nested function that shadows the stored closure', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'refreshWithLocalClock', + ); + expect(calls.filter((c) => c.target === 'clock')).toHaveLength(1); + expect(calls.find((c) => c.target === 'clock')?.targetFilePath).toBe('Caller.swift'); + }); + + it('keeps a labeled method selected alongside a same-name property', () => { + const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'refreshLabeled'); + expect(calls.map((c) => c.target)).toContain('first'); + expect(calls.find((c) => c.target === 'first')?.targetFilePath).toBe('Caller.swift'); + }); + + it('keeps a derived method despite an inaccessible ancestor property', () => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'refreshPrivateAncestor', + ); + expect(calls.map((c) => c.target)).toContain('clock'); + expect(calls.find((c) => c.target === 'clock')?.targetFilePath).toBe('Helpers.swift'); + }); +}); + // --------------------------------------------------------------------------- // Constructor fallback: Swift constructors look like free function calls // (no `new` keyword). The resolver retries with constructor form when diff --git a/gitnexus/test/integration/resolvers/typescript-mcp-tools.test.ts b/gitnexus/test/integration/resolvers/typescript-mcp-tools.test.ts new file mode 100644 index 000000000..fba040759 --- /dev/null +++ b/gitnexus/test/integration/resolvers/typescript-mcp-tools.test.ts @@ -0,0 +1,283 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { + loadParseCache, + PARSE_CACHE_VERSION, + pruneCache, + saveParseCache, + type ParseCache, +} from '../../../src/storage/parse-cache.js'; +import { + getDurableParsedFileDir, + pruneAndSaveDurableParsedFileStore, +} from '../../../src/storage/parsedfile-store.js'; +import { + FIXTURES, + findDanglingEdges, + getNodesByLabel, + getNodesByLabelFull, + getRelationships, + runPipelineFromRepo, + type PipelineResult, +} from './helpers.js'; + +describe('JavaScript and TypeScript SDK tool registrations', () => { + let result: PipelineResult; + const unresolved = [ + 'inline_callback', + 'imported_callback', + 'parameter_callback', + 'mutable_callback', + 'reassigned_callback', + 'shadowed_callback', + 'alias_callback', + 'first_block_callback', + 'second_block_callback', + ]; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'typescript-mcp-tools'), () => {}); + }, 60000); + + it.each(['ts', 'js'])( + 'does not emit tools for a destructured method replacement in %s', + async (extension) => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-tool-write-')); + try { + fs.writeFileSync( + path.join(repo, `server.${extension}`), + ` + import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + const replaced = new McpServer({ name: 'replaced', version: '1' }); + ({ registerTool: replaced.registerTool } = { registerTool: () => undefined }); + replaced.registerTool('fake', {}, () => ({ content: [] })); + const actual = new McpServer({ name: 'actual', version: '1' }); + actual.registerTool('real', {}, () => ({ content: [] })); + `, + ); + const pipeline = await runPipelineFromRepo(repo, () => {}, { workerPoolSize: 1 }); + expect(getNodesByLabel(pipeline, 'Tool')).toEqual(['real']); + expect(findDanglingEdges(pipeline, ['HANDLES_TOOL', 'ENTRY_POINT_OF'])).toEqual([]); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }, + ); + + it.each(['ts', 'js'])( + 'preserves namespace registrations through cold/warm %s parsing', + async (extension) => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-namespace-')); + const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-namespace-cache-')); + try { + fs.writeFileSync( + path.join(repo, `server.${extension}`), + ` + import * as SDK from '@modelcontextprotocol/sdk/server/mcp.js'; + const server = new SDK.McpServer({}); + function handleNamespace() { return { content: [] }; } + server.registerTool('namespace', { description: 'Namespace tool' }, handleNamespace); + const replaced = new SDK.McpServer({}); + ({ method: replaced.registerTool } = other); + replaced.registerTool('fake', {}, handleNamespace); + ${extension === 'ts' ? "function install(typed: SDK.McpServer) { typed.tool('typed_namespace', handleNamespace); }" : ''} + `, + ); + const cache: ParseCache = { + version: PARSE_CACHE_VERSION, + entries: new Map(), + usedKeys: new Set(), + storagePath: storageDir, + onDiskKeys: new Set(), + }; + const cold = await runPipelineFromRepo(repo, () => {}, { + parseCache: cache, + workerPoolSize: 1, + }); + expect(cold.usedWorkerPool).toBe(true); + pruneCache(cache, cache.usedKeys); + const keys = await saveParseCache(storageDir, cache); + await pruneAndSaveDurableParsedFileStore( + getDurableParsedFileDir(storageDir), + PARSE_CACHE_VERSION, + new Set(keys), + ); + const warmCache = await loadParseCache(storageDir); + expect(warmCache).not.toBeNull(); + const warm = await runPipelineFromRepo(repo, () => {}, { + parseCache: warmCache!, + workerPoolSize: 1, + }); + expect(warm.usedWorkerPool).toBe(false); + for (const pipeline of [cold, warm]) { + expect(getNodesByLabel(pipeline, 'Tool')).toEqual( + extension === 'ts' ? ['namespace', 'typed_namespace'] : ['namespace'], + ); + expect( + getNodesByLabelFull(pipeline, 'Tool').find((tool) => tool.name === 'namespace') + ?.properties.description, + ).toBe('Namespace tool'); + expect( + getRelationships(pipeline, 'HANDLES_TOOL').filter( + (edge) => edge.target === 'namespace', + ), + ).toMatchObject([{ source: 'handleNamespace', sourceLabel: 'Function' }]); + expect(findDanglingEdges(pipeline, ['HANDLES_TOOL', 'ENTRY_POINT_OF'])).toEqual([]); + } + expect(getNodesByLabelFull(warm, 'Tool')).toEqual(getNodesByLabelFull(cold, 'Tool')); + expect(getRelationships(warm, 'HANDLES_TOOL')).toEqual( + getRelationships(cold, 'HANDLES_TOOL'), + ); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + fs.rmSync(storageDir, { recursive: true, force: true }); + } + }, + 120_000, + ); + + it('discovers ordinary server files alongside deduplicated object manifests', () => { + expect(getNodesByLabel(result, 'Tool')).toEqual( + [ + ...unresolved, + 'search-files', + 'read_file', + 'function_expression_tool', + 'js_ping', + 'manifest_tool', + ].sort(), + ); + const tools = new Map( + getNodesByLabelFull(result, 'Tool').map((tool) => [tool.name, tool.properties]), + ); + expect(tools.get('search-files')).toMatchObject({ + filePath: 'src/server.ts', + description: 'Search files', + }); + expect(tools.get('read_file')).toMatchObject({ + filePath: 'src/server.ts', + description: 'Read a file', + }); + expect(tools.get('js_ping')).toMatchObject({ + filePath: 'src/server.js', + description: 'Ping JavaScript', + }); + expect(tools.get('manifest_tool')).toMatchObject({ + filePath: 'src/tools.ts', + description: 'Existing object manifest', + }); + expect(tools.get('inline_callback')?.description).toBe(''); + }); + + it('uses actual emitted callable nodes for supported local handlers', () => { + const edges = getRelationships(result, 'HANDLES_TOOL'); + for (const [tool, handler] of [ + ['search-files', 'searchFiles'], + ['read_file', 'readFile'], + ['function_expression_tool', 'expressionHandler'], + ['js_ping', 'jsPing'], + ]) { + expect(edges.filter((edge) => edge.target === tool)).toMatchObject([ + { source: handler, sourceLabel: 'Function' }, + ]); + } + expect(findDanglingEdges(result, ['HANDLES_TOOL', 'ENTRY_POINT_OF'])).toEqual([]); + }); + + it('links each same-file named handler only to its own execution flow', () => { + const edges = getRelationships(result, 'ENTRY_POINT_OF').filter( + (edge) => edge.sourceLabel === 'Tool', + ); + for (const [tool, handler] of [ + ['search-files', 'searchFiles'], + ['read_file', 'readFile'], + ]) { + const flows = edges.filter((edge) => edge.source === tool); + expect(flows).toHaveLength(1); + const process = result.graph.getNode(flows[0].rel.targetId)!; + const entry = result.graph.getNode(process.properties.entryPointId as string)!; + expect(entry.properties.name).toBe(handler); + } + }); + + it('keeps unresolved callbacks at file attribution without unrelated same-file flows', () => { + const handles = getRelationships(result, 'HANDLES_TOOL'); + const flows = getRelationships(result, 'ENTRY_POINT_OF'); + expect( + getNodesByLabelFull(result, 'Process').some((process) => { + const entry = result.graph.getNode(process.properties.entryPointId as string); + return entry?.properties.name === 'unrelatedEntry'; + }), + ).toBe(true); + for (const name of unresolved) { + expect(handles.filter((edge) => edge.target === name)).toMatchObject([ + { sourceLabel: 'File', sourceFilePath: 'src/server.ts' }, + ]); + expect(flows.filter((edge) => edge.sourceLabel === 'Tool' && edge.source === name)).toEqual( + [], + ); + } + }); + + it('preserves tool metadata, handler identities, and flow attribution on warm replay', async () => { + const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-tools-cache-')); + try { + const cold: ParseCache = { + version: PARSE_CACHE_VERSION, + entries: new Map(), + usedKeys: new Set(), + storagePath: storageDir, + onDiskKeys: new Set(), + }; + const fixture = path.join(FIXTURES, 'typescript-mcp-tools'); + const initial = await runPipelineFromRepo(fixture, () => {}, { + parseCache: cold, + workerPoolSize: 1, + }); + expect(initial.usedWorkerPool).toBe(true); + pruneCache(cold, cold.usedKeys); + const savedKeys = await saveParseCache(storageDir, cold); + await pruneAndSaveDurableParsedFileStore( + getDurableParsedFileDir(storageDir), + PARSE_CACHE_VERSION, + new Set(savedKeys), + ); + const warm = await loadParseCache(storageDir); + expect(warm).not.toBeNull(); + const replay = await runPipelineFromRepo(fixture, () => {}, { + parseCache: warm!, + workerPoolSize: 1, + }); + expect(replay.usedWorkerPool).toBe(false); + + const project = (pipeline: PipelineResult) => ({ + tools: getNodesByLabelFull(pipeline, 'Tool'), + edges: [ + ...getRelationships(pipeline, 'HANDLES_TOOL'), + ...getRelationships(pipeline, 'ENTRY_POINT_OF').filter( + (edge) => edge.sourceLabel === 'Tool', + ), + ] + .map(({ rel, source }) => ({ + type: rel.type, + source, + sourceId: rel.sourceId, + targetId: rel.targetId, + })) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))), + }); + const expected = project(initial); + expect(expected.tools).toHaveLength(unresolved.length + 5); + expect(project(replay)).toEqual(expected); + for (const name of unresolved) { + expect( + expected.edges.filter((edge) => edge.type === 'ENTRY_POINT_OF' && edge.source === name), + ).toEqual([]); + } + } finally { + fs.rmSync(storageDir, { recursive: true, force: true }); + } + }, 120_000); +}); diff --git a/gitnexus/test/integration/resolvers/value-ref-locality.test.ts b/gitnexus/test/integration/resolvers/value-ref-locality.test.ts index 9a1d6ee45..cfa1c931a 100644 --- a/gitnexus/test/integration/resolvers/value-ref-locality.test.ts +++ b/gitnexus/test/integration/resolvers/value-ref-locality.test.ts @@ -47,7 +47,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'node:path'; import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../../types/pipeline.js'; +import type { PipelineResult } from '../../../src/types/pipeline.js'; const LANG_FIXTURES = path.resolve(__dirname, '..', '..', 'fixtures', 'lang-resolution'); diff --git a/gitnexus/test/integration/shared-store-analyze.test.ts b/gitnexus/test/integration/shared-store-analyze.test.ts index 2c08b47a3..55062266a 100644 --- a/gitnexus/test/integration/shared-store-analyze.test.ts +++ b/gitnexus/test/integration/shared-store-analyze.test.ts @@ -186,9 +186,9 @@ describe('shared sibling store analyze (#3352)', () => { const conn = new lbug.Connection(db); let rows: { p: string }[]; try { - rows = (await ( - await conn.query('MATCH (f:File) RETURN f.filePath AS p ORDER BY p') - ).getAll()) as { p: string }[]; + const result = await conn.query('MATCH (f:File) RETURN f.filePath AS p ORDER BY p'); + if (Array.isArray(result)) throw new Error('Expected a single query result'); + rows = (await result.getAll()) as { p: string }[]; } finally { await conn.close(); await db.close(); diff --git a/gitnexus/test/integration/shared-store-seed.test.ts b/gitnexus/test/integration/shared-store-seed.test.ts index 2b50704b1..951a9f9c8 100644 --- a/gitnexus/test/integration/shared-store-seed.test.ts +++ b/gitnexus/test/integration/shared-store-seed.test.ts @@ -41,9 +41,9 @@ const queryNames = async (graph: string): Promise => { const db = new lbug.Database(graph, 0, true, true); const conn = new lbug.Connection(db); try { - const rows = (await ( - await conn.query('MATCH (f:Function) RETURN f.name AS n ORDER BY n') - ).getAll()) as { n: string }[]; + const result = await conn.query('MATCH (f:Function) RETURN f.name AS n ORDER BY n'); + if (Array.isArray(result)) throw new Error('Expected a single query result'); + const rows = (await result.getAll()) as { n: string }[]; return rows.map((r) => r.n); } finally { await conn.close(); diff --git a/gitnexus/test/integration/skip-fts.test.ts b/gitnexus/test/integration/skip-fts.test.ts index 896287ee9..4192316bb 100644 --- a/gitnexus/test/integration/skip-fts.test.ts +++ b/gitnexus/test/integration/skip-fts.test.ts @@ -367,8 +367,14 @@ describe('FTS opt-out analysis lifecycle (#3091)', () => { }); expect(response.status).toBe(200); const body = await response.json(); - expect(body.warning).toContain('FTS disabled for this index'); - expect(body.warning).not.toMatch(/failed to load|indexes missing|repair-fts/); + expect(body).toHaveProperty( + 'warning', + expect.stringContaining('FTS disabled for this index'), + ); + expect(body).toHaveProperty( + 'warning', + expect.not.stringMatching(/failed to load|indexes missing|repair-fts/), + ); } } finally { if (server.exitCode === null) server.kill('SIGTERM'); diff --git a/gitnexus/test/integration/spring-bean-pipeline.test.ts b/gitnexus/test/integration/spring-bean-pipeline.test.ts index c01cd9915..d010663bb 100644 --- a/gitnexus/test/integration/spring-bean-pipeline.test.ts +++ b/gitnexus/test/integration/spring-bean-pipeline.test.ts @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import path from 'node:path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'spring-bean-app'); diff --git a/gitnexus/test/integration/spring-config-pipeline.test.ts b/gitnexus/test/integration/spring-config-pipeline.test.ts index 65adf802a..111f5f0e0 100644 --- a/gitnexus/test/integration/spring-config-pipeline.test.ts +++ b/gitnexus/test/integration/spring-config-pipeline.test.ts @@ -4,7 +4,7 @@ import { beforeAll, describe, expect, it, vi } from 'vitest'; import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; import { SPRING_CONFIG_DESCRIPTION } from '../../src/core/ingestion/frameworks/spring/config-bindings.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; import { createTempDir } from '../helpers/test-db.js'; const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'spring-config-app'); diff --git a/gitnexus/test/integration/spring-interface-inheritance-pipeline.test.ts b/gitnexus/test/integration/spring-interface-inheritance-pipeline.test.ts index d759e8348..dd0368ebf 100644 --- a/gitnexus/test/integration/spring-interface-inheritance-pipeline.test.ts +++ b/gitnexus/test/integration/spring-interface-inheritance-pipeline.test.ts @@ -10,7 +10,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; const ORDER_API = `package com.example; import org.springframework.web.bind.annotation.*; diff --git a/gitnexus/test/integration/spring-route-pipeline.test.ts b/gitnexus/test/integration/spring-route-pipeline.test.ts index e6fd739af..1683cd027 100644 --- a/gitnexus/test/integration/spring-route-pipeline.test.ts +++ b/gitnexus/test/integration/spring-route-pipeline.test.ts @@ -13,7 +13,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'node:path'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; -import type { PipelineResult } from '../../types/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'spring-route-app'); diff --git a/gitnexus/test/integration/staged-embedding-recovery.test.ts b/gitnexus/test/integration/staged-embedding-recovery.test.ts new file mode 100644 index 000000000..0a52c67ab --- /dev/null +++ b/gitnexus/test/integration/staged-embedding-recovery.test.ts @@ -0,0 +1,208 @@ +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + disposeEmbeddingSpill, + materializeCachedEmbeddings, + type CachedEmbeddingsSnapshot, +} from '../../src/core/embeddings/embedding-restore-spill.js'; +import { recoverStagedEmbeddings } from '../../src/core/embeddings/staged-embedding-recovery.js'; + +describe('isolated native staged embedding recovery', () => { + let tmp: string | undefined; + let recovered: CachedEmbeddingsSnapshot | undefined; + afterEach(() => { + disposeEmbeddingSpill(recovered?.spill); + recovered = undefined; + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); + tmp = undefined; + }); + function stagePath() { + tmp ??= fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-stage-native-')); + return path.join(tmp, `lbug.staging.${randomUUID()}`); + } + function seed(dbPath: string, mode = 'clean') { + const result = spawnSync( + process.execPath, + [ + '--import', + 'tsx', + fileURLToPath(new URL('../fixtures/staged-embedding-recovery/seed.mjs', import.meta.url)), + dbPath, + mode, + ], + { + encoding: 'utf8', + timeout: 20_000, + env: { ...process.env, GITNEXUS_LBUG_BUFFER_POOL_SIZE: String(128 * 1024 * 1024) }, + }, + ); + expect(result.error, result.stderr).toBeUndefined(); + if (mode === 'hard-kill') expect(result.signal, result.stderr).toBe('SIGKILL'); + else expect(result.status, result.stderr).toBe(0); + } + + function snapshotSourceFamily(dbPath: string) { + const basename = path.basename(dbPath); + return Object.fromEntries( + fs + .readdirSync(path.dirname(dbPath)) + .filter((name) => name === basename || name.startsWith(`${basename}.`)) + .sort() + .map((name) => [name, fs.readFileSync(path.join(path.dirname(dbPath), name))]), + ); + } + + it('streams complete same-hash groups and rejects malformed whole nodes', async () => { + const dbPath = stagePath(); + seed(dbPath); + recovered = await recoverStagedEmbeddings(dbPath, { dimensions: 2 }); + expect([...recovered.embeddingNodeIds].sort()).toEqual(['complete', 'other']); + expect(recovered.rows).toHaveLength(3); + expect(recovered.embeddings).toEqual([]); + expect(materializeCachedEmbeddings(recovered, recovered.rows)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + nodeId: 'complete', + chunkIndex: 0, + embedding: [1, 2], + contentHash: 'same', + }), + expect.objectContaining({ + nodeId: 'complete', + chunkIndex: 1, + embedding: [1, 2], + contentHash: 'same', + }), + ]), + ); + expect(fs.existsSync(dbPath)).toBe(true); + }, 30_000); + + it('replays a hard-killed native writer strictly and excludes the incomplete active window', async () => { + const dbPath = stagePath(); + seed(dbPath, 'hard-kill'); + const sourceBefore = snapshotSourceFamily(dbPath); + recovered = await recoverStagedEmbeddings(dbPath, { + dimensions: 2, + excludedNodeIds: ['unsafe-prefix', 'other'], + }); + expect([...recovered.embeddingNodeIds]).toEqual(['complete']); + expect(recovered.rows).toHaveLength(2); + expect( + materializeCachedEmbeddings(recovered, recovered.rows) + .map((row) => row.chunkIndex) + .sort(), + ).toEqual([0, 1]); + expect(snapshotSourceFamily(dbPath)).toEqual(sourceBefore); + }, 30_000); + + it('rejects vectors from a different dimension instead of coercing them', async () => { + const dbPath = stagePath(); + seed(dbPath); + recovered = await recoverStagedEmbeddings(dbPath, { dimensions: 3 }); + expect(recovered.rows).toEqual([]); + }, 30_000); + + it('strictly replays an interrupted checkpoint copy with both checkpoint locks retained', async (ctx) => { + const version = JSON.parse( + fs.readFileSync( + new URL('../../node_modules/@ladybugdb/core/package.json', import.meta.url), + 'utf8', + ), + ).version as string; + const [major, minor] = version.split('.').map(Number); + // Older pins do not support the deterministic interrupted-checkpoint plant. + if (Number.isFinite(major) && Number.isFinite(minor) && major === 0 && minor < 19) ctx.skip(); + const dbPath = stagePath(); + seed(dbPath, 'interrupted-checkpoint'); + const sourceBefore = snapshotSourceFamily(dbPath); + expect(fs.statSync(`${dbPath}.wal.checkpoint`).size).toBeGreaterThan(0); + expect(fs.existsSync(`${dbPath}.checkpoint.intent.lock`)).toBe(true); + expect(fs.existsSync(`${dbPath}.checkpoint.apply.lock`)).toBe(true); + + recovered = await recoverStagedEmbeddings(dbPath, { dimensions: 2 }); + + expect([...recovered.embeddingNodeIds].sort()).toEqual([ + 'checkpoint-only', + 'complete', + 'other', + ]); + expect(recovered.rows).toHaveLength(4); + expect(snapshotSourceFamily(dbPath)).toEqual(sourceBefore); + }, 30_000); + + it('contains a malformed native source in a subprocess and preserves it', async () => { + const dbPath = stagePath(); + fs.writeFileSync(dbPath, 'not a ladybug database'); + await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2 })).rejects.toThrow( + /extraction failed/, + ); + expect(fs.readFileSync(dbPath, 'utf8')).toBe('not a ladybug database'); + }, 30_000); + + it('rejects a malformed WAL without deleting or quarantining it to reopen the source', async () => { + const dbPath = stagePath(); + seed(dbPath, 'hard-kill'); + const walPath = `${dbPath}.wal`; + fs.writeFileSync(walPath, Buffer.alloc(128, 0xff)); + const sourceBefore = snapshotSourceFamily(dbPath); + await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2 })).rejects.toThrow( + /extraction failed/, + ); + expect(fs.existsSync(walPath)).toBe(true); + expect(fs.readdirSync(path.dirname(dbPath)).some((name) => /bad|quarantine/i.test(name))).toBe( + false, + ); + expect(snapshotSourceFamily(dbPath)).toEqual(sourceBefore); + }, 30_000); + + it('does not create a missing source and refuses a symlink', async () => { + const dbPath = stagePath(); + await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2 })).rejects.toThrow(/ENOENT/); + expect(fs.existsSync(dbPath)).toBe(false); + const realPath = path.join(path.dirname(dbPath), 'real'); + fs.writeFileSync(realPath, 'fixture'); + fs.symlinkSync(realPath, dbPath); + await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2 })).rejects.toThrow( + /regular file/, + ); + }); + + it('can kill a timed out native subprocess without aborting analyze', async () => { + const dbPath = stagePath(); + seed(dbPath); + const sourceBefore = snapshotSourceFamily(dbPath); + await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2, timeoutMs: 1 })).rejects.toThrow( + /timeout/, + ); + expect(fs.existsSync(dbPath)).toBe(true); + expect(snapshotSourceFamily(dbPath)).toEqual(sourceBefore); + }, 30_000); + + it('loads the source child when analyze runs in another repository directory', () => { + const dbPath = stagePath(); + seed(dbPath); + const result = spawnSync( + process.execPath, + [ + '--import', + import.meta.resolve('tsx'), + '--input-type=module', + '-e', + 'const { recoverStagedEmbeddings } = await import(process.argv[1]); const { disposeEmbeddingSpill } = await import(process.argv[2]); const recovered = await recoverStagedEmbeddings(process.argv[3], {dimensions: 2}); process.stdout.write(String(recovered.rows.length)); disposeEmbeddingSpill(recovered.spill);', + new URL('../../src/core/embeddings/staged-embedding-recovery.ts', import.meta.url).href, + new URL('../../src/core/embeddings/embedding-restore-spill.ts', import.meta.url).href, + dbPath, + ], + { cwd: path.dirname(dbPath), encoding: 'utf8', timeout: 20_000 }, + ); + expect(result.error, result.stderr).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe('3'); + }, 30_000); +}); diff --git a/gitnexus/test/integration/taint-explain.test.ts b/gitnexus/test/integration/taint-explain.test.ts index 28478051e..09867bb6d 100644 --- a/gitnexus/test/integration/taint-explain.test.ts +++ b/gitnexus/test/integration/taint-explain.test.ts @@ -76,7 +76,7 @@ export function handle(req: any, db: any, map: Map, task: any, r .map((rel) => { const sink = result.graph.getNode(rel.targetId); const decoded = decodeTaintPath(rel.reason); - if (!decoded.ok) { + if (decoded.ok === false) { throw new Error(`invalid TAINTED reason for ${rel.id}: ${decoded.error}`); } return { diff --git a/gitnexus/test/integration/watch-filesystem.test.ts b/gitnexus/test/integration/watch-filesystem.test.ts index f1e9aae8b..646d95e2a 100644 --- a/gitnexus/test/integration/watch-filesystem.test.ts +++ b/gitnexus/test/integration/watch-filesystem.test.ts @@ -81,7 +81,9 @@ describe('watch filesystem integration', () => { const loop = await startWatchFileLoop( repo, 30, - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, (error) => { throw error; }, @@ -166,7 +168,9 @@ describe('watch filesystem integration', () => { const loop = await startWatchFileLoop( repo, 25, - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, (error) => { throw error; }, @@ -224,7 +228,9 @@ describe('watch filesystem integration', () => { const loop = await startWatchFileLoop( repo, 25, - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, (_error, paths) => errors.push([...paths]), ); loops.push(loop); @@ -252,7 +258,9 @@ describe('watch filesystem integration', () => { const loop = await startWatchFileLoop( repo, 25, - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, (error) => { throw error; }, diff --git a/gitnexus/test/integration/worker-pool.test.ts b/gitnexus/test/integration/worker-pool.test.ts index ac6fe0076..b995873a5 100644 --- a/gitnexus/test/integration/worker-pool.test.ts +++ b/gitnexus/test/integration/worker-pool.test.ts @@ -1367,7 +1367,7 @@ describe('worker pool integration', () => { ]); // Deterministically: a.ts crashes round 1, b.ts crashes round 2. - const quarantine = (pool.getQuarantinedPaths?.() ?? []).sort(); + const quarantine = [...(pool.getQuarantinedPaths?.() ?? [])].sort(); expect(quarantine).toEqual(['a.ts', 'b.ts']); // All non-quarantined files eventually parsed by the survivor slot. const allPaths = results.flatMap((r) => r.paths).sort(); diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index a7174e674..c411d7ec5 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -1667,7 +1667,7 @@ describe('count-only reindex does not churn the committed block (#2907)', () => describe('--no-stats drops the per-cluster symbol counts too (#2907)', () => { const stats = { nodes: 10, edges: 20, processes: 3 }; - const skills = [{ label: 'ingestion', name: 'p-ingestion', symbolCount: 120 }]; + const skills = [{ label: 'ingestion', name: 'p-ingestion', symbolCount: 120, fileCount: 10 }]; it('omits the count under --no-stats and keeps it otherwise', () => { const lean = generateGitNexusContent('P', stats, { generatedSkills: skills, noStats: true }); diff --git a/gitnexus/test/unit/analyze-delete-api.test.ts b/gitnexus/test/unit/analyze-delete-api.test.ts index e40ff53cc..74b01e733 100644 --- a/gitnexus/test/unit/analyze-delete-api.test.ts +++ b/gitnexus/test/unit/analyze-delete-api.test.ts @@ -152,7 +152,11 @@ const invokeDelete = (route: string, jobId: string): { statusCode: number; body: return this; }, }; - handler({ params: { jobId } }, res); + handler( + { params: { jobId } } as unknown as express.Request, + res as unknown as express.Response, + vi.fn(), + ); return res; }; diff --git a/gitnexus/test/unit/analyze-gitnexusrc.test.ts b/gitnexus/test/unit/analyze-gitnexusrc.test.ts index 081b3664c..e7fe6224c 100644 --- a/gitnexus/test/unit/analyze-gitnexusrc.test.ts +++ b/gitnexus/test/unit/analyze-gitnexusrc.test.ts @@ -22,8 +22,12 @@ const { getDefaultBranchMock, } = vi.hoisted(() => ({ runFullAnalysisMock: vi.fn(), - generateAIContextFilesMock: vi.fn(async () => ({ files: [] as string[] })), - refreshBaseRefLineMock: vi.fn(async () => ({ files: [] as string[] })), + generateAIContextFilesMock: vi.fn< + typeof import('../../src/cli/ai-context.js').generateAIContextFiles + >(async () => ({ files: [] as string[] })), + refreshBaseRefLineMock: vi.fn( + async () => ({ files: [] as string[] }), + ), generateSkillFilesMock: vi.fn(async () => ({ skills: [{ name: 'c', label: 'Community', symbolCount: 1, fileCount: 1 }], outputPath: '/repo/.claude/skills', diff --git a/gitnexus/test/unit/analyze-http-endpoint-error.test.ts b/gitnexus/test/unit/analyze-http-endpoint-error.test.ts index ffb7d2b91..718a9fa16 100644 --- a/gitnexus/test/unit/analyze-http-endpoint-error.test.ts +++ b/gitnexus/test/unit/analyze-http-endpoint-error.test.ts @@ -30,12 +30,15 @@ const resolveEmbeddingRuntimeMock = vi.fn<() => { source: string } | null>(() => source: 'package', })); const isPrefixRuntimeLoadableMock = vi.fn(() => true); -const installEmbeddingRuntimeMock = vi.fn(async () => undefined); +const installEmbeddingRuntimeMock = vi.fn< + typeof import('../../src/core/embeddings/runtime-install.js').installEmbeddingRuntime +>(async () => undefined); vi.mock('../../src/core/embeddings/runtime-install.js', async (importOriginal) => ({ ...(await importOriginal()), resolveEmbeddingRuntime: () => resolveEmbeddingRuntimeMock(), isPrefixRuntimeLoadable: () => isPrefixRuntimeLoadableMock(), - installEmbeddingRuntime: (...args: unknown[]) => installEmbeddingRuntimeMock(...args), + installEmbeddingRuntime: (...args: Parameters) => + installEmbeddingRuntimeMock(...args), getEmbeddingRuntimeDir: () => '/fake/embedding-runtime', })); diff --git a/gitnexus/test/unit/analyze-local-embedding-error.test.ts b/gitnexus/test/unit/analyze-local-embedding-error.test.ts index e67f43fe5..161f3b77b 100644 --- a/gitnexus/test/unit/analyze-local-embedding-error.test.ts +++ b/gitnexus/test/unit/analyze-local-embedding-error.test.ts @@ -38,12 +38,15 @@ const resolveEmbeddingRuntimeMock = vi.fn<() => { source: string } | null>(() => source: 'package', })); const isPrefixRuntimeLoadableMock = vi.fn(() => true); -const installEmbeddingRuntimeMock = vi.fn(async () => undefined); +const installEmbeddingRuntimeMock = vi.fn< + typeof import('../../src/core/embeddings/runtime-install.js').installEmbeddingRuntime +>(async () => undefined); vi.mock('../../src/core/embeddings/runtime-install.js', async (importOriginal) => ({ ...(await importOriginal()), resolveEmbeddingRuntime: () => resolveEmbeddingRuntimeMock(), isPrefixRuntimeLoadable: () => isPrefixRuntimeLoadableMock(), - installEmbeddingRuntime: (...args: unknown[]) => installEmbeddingRuntimeMock(...args), + installEmbeddingRuntime: (...args: Parameters) => + installEmbeddingRuntimeMock(...args), getEmbeddingRuntimeDir: () => '/fake/embedding-runtime', })); diff --git a/gitnexus/test/unit/analyze-no-stats-bridge.test.ts b/gitnexus/test/unit/analyze-no-stats-bridge.test.ts index a63a6fa5a..63a417b2e 100644 --- a/gitnexus/test/unit/analyze-no-stats-bridge.test.ts +++ b/gitnexus/test/unit/analyze-no-stats-bridge.test.ts @@ -3,7 +3,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { runFullAnalysisMock, generateAIContextFilesMock, generateSkillFilesMock, cliErrorMock } = vi.hoisted(() => { const runFullAnalysisMock = vi.fn(); - const generateAIContextFilesMock = vi.fn(async () => ({ files: [] as string[] })); + const generateAIContextFilesMock = vi.fn< + typeof import('../../src/cli/ai-context.js').generateAIContextFiles + >(async () => ({ files: [] as string[] })); const generateSkillFilesMock = vi.fn(async () => ({ skills: [{ name: 'c', label: 'Community', symbolCount: 1, fileCount: 1 }], outputPath: '/repo/.claude/skills', diff --git a/gitnexus/test/unit/analyze-worker-core.test.ts b/gitnexus/test/unit/analyze-worker-core.test.ts index 2b09108b5..2c0323220 100644 --- a/gitnexus/test/unit/analyze-worker-core.test.ts +++ b/gitnexus/test/unit/analyze-worker-core.test.ts @@ -23,6 +23,7 @@ import type { AnalyzerRunnerIdentity } from '../../src/storage/repo-manager.js'; import { IndexLockTimeoutError, type LockRecord } from '../../src/storage/index-lock.js'; const baseResult: AnalyzeResult = { + rebuildReasons: [], repoName: 'repo', repoPath: '/repo', storagePath: '/repo/.gitnexus', diff --git a/gitnexus/test/unit/analyze-worker-ipc.test.ts b/gitnexus/test/unit/analyze-worker-ipc.test.ts index d6dde3fcc..e05d8c180 100644 --- a/gitnexus/test/unit/analyze-worker-ipc.test.ts +++ b/gitnexus/test/unit/analyze-worker-ipc.test.ts @@ -29,6 +29,7 @@ function hostileResult(): AnalyzeResult { }; graph.self = graph; // circular — JSON.stringify throws return { + rebuildReasons: [], repoName: 'demo', repoPath: '/repos/demo', storagePath: '/repos/demo/.gitnexus', @@ -83,8 +84,16 @@ describe('#2112: analyze-worker IPC projection', () => { properties: { name: `n${i}`, filePath: 'x.ts' }, }); } - graph.addRelationship({ id: 'n0->n1', source: 'n0', target: 'n1', type: 'CALLS' }); + graph.addRelationship({ + id: 'n0->n1', + sourceId: 'n0', + targetId: 'n1', + type: 'CALLS', + confidence: 1, + reason: '', + }); const result: AnalyzeResult = { + rebuildReasons: [], repoName: 'demo', repoPath: '/r', storagePath: '/r/.gitnexus', diff --git a/gitnexus/test/unit/api-analyze-upload.test.ts b/gitnexus/test/unit/api-analyze-upload.test.ts index 8843231ff..bc8d7370e 100644 --- a/gitnexus/test/unit/api-analyze-upload.test.ts +++ b/gitnexus/test/unit/api-analyze-upload.test.ts @@ -1,3 +1,4 @@ +import type { AnalyzeUploadDeps } from '../../src/server/analyze-upload.js'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import path from 'node:path'; import fs from 'node:fs/promises'; @@ -88,8 +89,13 @@ function uniqueTop(): string { describe('createAnalyzeUploadHandler', () => { it('ingests, promotes the inner folder, and launches analysis (202)', async () => { const top = uniqueTop(); - const createJob = vi.fn(() => ({ id: 'job-1', status: 'queued' })); - const launch = vi.fn((_j, dir: string) => promoted.push(dir)); + const createJob = vi.fn(() => ({ + id: 'job-1', + status: 'queued', + })); + const launch = vi.fn((_j, dir) => { + promoted.push(dir); + }); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); @@ -118,10 +124,12 @@ describe('createAnalyzeUploadHandler', () => { it('maps a busy job (createJob throws "already in progress") to 409 and promotes nothing', async () => { const top = uniqueTop(); - const createJob = vi.fn(() => { + const createJob = vi.fn(() => { throw new Error('Analysis already in progress for another repository'); }); - const launch = vi.fn((_j, dir: string) => promoted.push(dir)); + const launch = vi.fn((_j, dir) => { + promoted.push(dir); + }); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); @@ -142,8 +150,8 @@ describe('createAnalyzeUploadHandler', () => { }); it('rejects a traversal path in the manifest (400) without launching', async () => { - const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); - const launch = vi.fn(); + const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); + const launch = vi.fn(); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); @@ -162,8 +170,8 @@ describe('createAnalyzeUploadHandler', () => { }); it('rejects an un-nameable top folder (Windows-reserved → 400)', async () => { - const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); - const launch = vi.fn(); + const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); + const launch = vi.fn(); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); @@ -182,8 +190,13 @@ describe('createAnalyzeUploadHandler', () => { it('strips a crafted .gitnexus index from the promoted upload', async () => { const top = uniqueTop(); - const createJob = vi.fn(() => ({ id: 'job-x', status: 'queued' })); - const launch = vi.fn((_j, dir: string) => promoted.push(dir)); + const createJob = vi.fn(() => ({ + id: 'job-x', + status: 'queued', + })); + const launch = vi.fn((_j, dir) => { + promoted.push(dir); + }); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); @@ -204,8 +217,8 @@ describe('createAnalyzeUploadHandler', () => { }); it('rejects a single-segment manifest before creating a job (no slot taken)', async () => { - const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); - const launch = vi.fn(); + const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); + const launch = vi.fn(); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); @@ -224,8 +237,8 @@ describe('createAnalyzeUploadHandler', () => { }); it('rejects a multi-top-folder manifest (would silently drop folders)', async () => { - const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); - const launch = vi.fn(); + const createJob = vi.fn(() => ({ id: 'j', status: 'queued' })); + const launch = vi.fn(); const failJob = vi.fn(); const handler = createAnalyzeUploadHandler({ createJob, launch, failJob }); @@ -245,9 +258,12 @@ describe('createAnalyzeUploadHandler', () => { it('releases the single slot (failJob) when a step fails after createJob', async () => { const top = uniqueTop(); - const createJob = vi.fn(() => ({ id: 'job-fail', status: 'queued' })); + const createJob = vi.fn(() => ({ + id: 'job-fail', + status: 'queued', + })); // launch throws AFTER createJob + promote — the slot must be released. - const launch = vi.fn((_j, dir: string) => { + const launch = vi.fn((_j, dir: string) => { promoted.push(dir); throw new Error('worker fork blew up'); }); diff --git a/gitnexus/test/unit/api-fts-mode.test.ts b/gitnexus/test/unit/api-fts-mode.test.ts index 632bd37d3..a78a2525a 100644 --- a/gitnexus/test/unit/api-fts-mode.test.ts +++ b/gitnexus/test/unit/api-fts-mode.test.ts @@ -7,7 +7,12 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } const mocks = vi.hoisted(() => ({ loadMeta: vi.fn(), + saveMeta: vi.fn(), listRegisteredRepos: vi.fn(), + acquireIndexLock: vi.fn(), + releaseIndexLock: vi.fn(), + ensurePrivateSharedGraph: vi.fn(), + runEmbeddingPipeline: vi.fn(), withLbugDb: vi.fn(), search: vi.fn(), updateJob: vi.fn(), @@ -16,8 +21,19 @@ const mocks = vi.hoisted(() => ({ vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => ({ ...(await importOriginal()), loadMeta: mocks.loadMeta, + saveMeta: mocks.saveMeta, listRegisteredRepos: mocks.listRegisteredRepos, })); +vi.mock('../../src/storage/index-lock.js', async (importOriginal) => ({ + ...(await importOriginal()), + acquireIndexLock: mocks.acquireIndexLock, +})); +vi.mock('../../src/core/shared-store-analyze.js', () => ({ + ensurePrivateSharedGraph: mocks.ensurePrivateSharedGraph, +})); +vi.mock('../../src/core/embeddings/embedding-pipeline.js', () => ({ + runEmbeddingPipeline: mocks.runEmbeddingPipeline, +})); vi.mock('../../src/storage/storage-resolver.js', async (importOriginal) => ({ ...(await importOriginal()), requireRegisteredStoragePath: vi.fn(async (entry: { storagePath: string }) => entry.storagePath), @@ -116,6 +132,19 @@ afterAll(() => { beforeEach(() => { vi.clearAllMocks(); + mocks.acquireIndexLock.mockResolvedValue({ + release: mocks.releaseIndexLock, + record: { + v: 1, + pid: process.pid, + hostname: 'test-host', + startTime: null, + token: 'test-lock', + invocationId: 'test-run', + acquiredAt: '', + }, + }); + mocks.ensurePrivateSharedGraph.mockResolvedValue(true); mocks.listRegisteredRepos.mockResolvedValue([entry]); mocks.withLbugDb.mockImplementation(async (_path, callback) => callback()); mocks.search.mockImplementation(async (_query, _limit, _exec, reason) => ({ @@ -124,6 +153,145 @@ beforeEach(() => { })); }); +describe('POST /api/embed staged recovery preflight', () => { + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(entry.storagePath, { recursive: true, force: true }); + fs.mkdirSync(entry.storagePath, { recursive: true }); + }); + + async function useRealIndexLock() { + vi.stubEnv('GITNEXUS_INDEX_LOCK_BACKEND', 'file'); + const actual = await vi.importActual( + '../../src/storage/index-lock.js', + ); + mocks.acquireIndexLock.mockImplementation( + async (...args: Parameters) => { + const lock = await actual.acquireIndexLock(...args); + return { + ...lock, + release: () => { + lock.release(); + mocks.releaseIndexLock(); + }, + }; + }, + ); + } + + it.each([ + { + name: 'valid staged receipt', + recovery: { + stagingFile: 'lbug.staging.12345678-1234-4123-8123-123456789abc', + schemaFingerprint: 'test-schema', + unsafeNodeIds: ['n2', 'inherited-window-node'], + }, + }, + { name: 'null receipt', recovery: null }, + { name: 'malformed receipt', recovery: { stagingFile: 'invalid' } }, + { name: 'false receipt', recovery: false }, + ])('preserves a $name and releases the job locks', async ({ recovery }) => { + await useRealIndexLock(); + const lockPath = path.join(entry.storagePath, 'analyze.lock'); + const lbugPath = path.join(entry.storagePath, 'lbug'); + const metaPath = path.join(entry.storagePath, 'gitnexus.json'); + const sourcePath = path.join( + entry.storagePath, + 'lbug.staging.12345678-1234-4123-8123-123456789abc', + ); + fs.writeFileSync(lbugPath, 'published graph'); + fs.writeFileSync(sourcePath, 'completed paid vectors'); + fs.writeFileSync(`${sourcePath}.wal`, 'unfinished window'); + const metadataBytes = JSON.stringify({ + repoPath: entry.path, + lastCommit: 'abc123', + indexedAt: '2026-01-01T00:00:00.000Z', + stats: { embeddings: 7 }, + embeddingCheckpoint: { + at: '2026-01-01T00:00:00.000Z', + nodesProcessed: 1, + totalNodes: 2, + chunksProcessed: 1, + model: 'test-model', + dimensions: 768, + provider: 'local', + kind: 'interrupted', + pendingNodeIds: ['n2'], + recovery, + }, + }); + fs.writeFileSync(metaPath, metadataBytes); + mocks.loadMeta.mockImplementation(async () => { + expect(fs.existsSync(lockPath)).toBe(true); + return JSON.parse(fs.readFileSync(metaPath, 'utf8')); + }); + mocks.withLbugDb.mockResolvedValue(undefined); + + await invoke('/api/embed'); + await vi.waitFor(() => expect(mocks.releaseIndexLock).toHaveBeenCalledTimes(1)); + + expect(mocks.updateJob).toHaveBeenCalledWith( + 'embed-job', + expect.objectContaining({ + status: 'failed', + error: expect.stringMatching( + /staged embeddings.*Run `gitnexus analyze` to recover them first/, + ), + }), + ); + expect(mocks.acquireIndexLock).toHaveBeenCalledWith(entry.storagePath, { sweep: false }); + expect(mocks.loadMeta.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.acquireIndexLock.mock.invocationCallOrder[0]!, + ); + expect(mocks.ensurePrivateSharedGraph).not.toHaveBeenCalled(); + expect(mocks.withLbugDb).not.toHaveBeenCalled(); + expect(mocks.runEmbeddingPipeline).not.toHaveBeenCalled(); + expect(mocks.saveMeta).not.toHaveBeenCalled(); + expect(fs.existsSync(lockPath)).toBe(false); + expect(fs.readFileSync(metaPath, 'utf8')).toBe(metadataBytes); + expect(fs.readFileSync(lbugPath, 'utf8')).toBe('published graph'); + expect(fs.readFileSync(sourcePath, 'utf8')).toBe('completed paid vectors'); + expect(fs.readFileSync(`${sourcePath}.wal`, 'utf8')).toBe('unfinished window'); + + // A second accepted job proves the in-memory repo lock was also released. + await invoke('/api/embed'); + await vi.waitFor(() => expect(mocks.releaseIndexLock).toHaveBeenCalledTimes(2)); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('sweeps orphaned staging files before a writable job without a recovery receipt', async () => { + await useRealIndexLock(); + const metaPath = path.join(entry.storagePath, 'gitnexus.json'); + fs.writeFileSync(metaPath, JSON.stringify({ repoPath: entry.path })); + const sourcePath = path.join( + entry.storagePath, + 'lbug.staging.12345678-1234-4123-8123-123456789abc', + ); + fs.writeFileSync(sourcePath, 'orphaned database'); + fs.writeFileSync(`${sourcePath}.wal`, 'orphaned WAL'); + mocks.loadMeta.mockImplementation(async () => JSON.parse(fs.readFileSync(metaPath, 'utf8'))); + mocks.ensurePrivateSharedGraph.mockImplementation(async () => { + expect(fs.existsSync(path.join(entry.storagePath, 'analyze.lock'))).toBe(true); + expect(fs.existsSync(sourcePath)).toBe(false); + expect(fs.existsSync(`${sourcePath}.wal`)).toBe(false); + return true; + }); + mocks.withLbugDb.mockResolvedValue(undefined); + + await invoke('/api/embed'); + await vi.waitFor(() => expect(mocks.releaseIndexLock).toHaveBeenCalledTimes(1)); + + expect(mocks.updateJob).toHaveBeenCalledWith( + 'embed-job', + expect.objectContaining({ status: 'complete' }), + ); + expect(mocks.ensurePrivateSharedGraph).toHaveBeenCalledTimes(1); + expect(mocks.withLbugDb).toHaveBeenCalledTimes(1); + expect(fs.existsSync(path.join(entry.storagePath, 'analyze.lock'))).toBe(false); + }); +}); + async function invoke(route: string, query: Record = {}) { const layer = app.router.stack.find((item: any) => item.route?.path === route); expect(layer, route).toBeDefined(); @@ -160,7 +328,7 @@ async function invoke(route: string, query: Record = {}) { this.emit('finish'); }, }); - await handler(req, res); + await handler(req as unknown as express.Request, res as unknown as express.Response, vi.fn()); expect(res.statusCode, JSON.stringify(res.body)).toBe(route === '/api/embed' ? 202 : 200); return res; } @@ -257,7 +425,9 @@ describe('serve uses one metadata-derived FTS mode on every DB-open path', () => expect.any(Function), skip ? { skipFts: true } : {}, ); - expect(mocks.loadMeta).toHaveBeenCalledExactlyOnceWith(entry.storagePath); + expect(mocks.loadMeta).toHaveBeenCalledTimes(2); + expect(mocks.loadMeta).toHaveBeenNthCalledWith(1, entry.storagePath); + expect(mocks.loadMeta).toHaveBeenNthCalledWith(2, entry.storagePath); }, ); }); diff --git a/gitnexus/test/unit/api-graph-streaming.test.ts b/gitnexus/test/unit/api-graph-streaming.test.ts index 58de01f84..4aa6ccdb8 100644 --- a/gitnexus/test/unit/api-graph-streaming.test.ts +++ b/gitnexus/test/unit/api-graph-streaming.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'node:events'; +import type { GraphRelationship } from 'gitnexus-shared'; import { describe, expect, it, vi, beforeEach } from 'vitest'; const { lbugMocks } = vi.hoisted(() => ({ @@ -9,7 +10,7 @@ const { lbugMocks } = vi.hoisted(() => ({ })); vi.mock('../../src/core/lbug/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); @@ -23,6 +24,60 @@ const createMockResponse = (writeImpl?: (chunk: string) => boolean) => { return response; }; +describe('graph relationship reason contract', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + { stored: null, expected: '' }, + { stored: undefined, expected: '' }, + { stored: '', expected: '' }, + { stored: 'branch → next', expected: 'branch → next' }, + ])( + 'returns reason=$expected for stored $stored in JSON and NDJSON', + async ({ stored, expected }) => { + const row = { + sourceId: 'BasicBlock:src/app.ts:1', + targetId: 'BasicBlock:src/app.ts:2', + type: 'CFG' as const, + confidence: 1, + reason: stored, + step: 0, + }; + lbugMocks.executeQuery.mockImplementation(async (query: string) => + query.includes('CodeRelation') ? [row] : [], + ); + lbugMocks.streamQuery.mockImplementation( + async (query: string, onRow: (row: unknown) => Promise) => { + if (!query.includes('CodeRelation')) return 0; + await onRow(row); + return 1; + }, + ); + + const buffered = JSON.parse(JSON.stringify(await buildGraph())); + const writes: string[] = []; + await streamGraphNdjson( + createMockResponse((chunk) => { + writes.push(chunk); + return true; + }), + ); + const streamed = writes.map((chunk) => JSON.parse(chunk)); + const relationship: GraphRelationship = { + ...row, + id: `${row.sourceId}_${row.type}_${row.targetId}`, + reason: expected, + }; + expect({ buffered: buffered.relationships, streamed }).toEqual({ + buffered: [relationship], + streamed: [{ type: 'relationship', data: relationship }], + }); + }, + ); +}); + describe('streamGraphNdjson', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/gitnexus/test/unit/auto-sync-runner.test.ts b/gitnexus/test/unit/auto-sync-runner.test.ts index a16f0fdac..e7c8761e3 100644 --- a/gitnexus/test/unit/auto-sync-runner.test.ts +++ b/gitnexus/test/unit/auto-sync-runner.test.ts @@ -98,7 +98,7 @@ async function runWithOnDiskPdgState(options: { getCurrentCommit: vi.fn(async () => 'commit-1'), runAnalysis, registerRepo: vi.fn(async () => 'repo'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -145,11 +145,11 @@ describe('auto-sync runner', () => { it('runs clone, analyzes changed commits, registers the repo, and syncs changed groups', async () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({ + loadState: vi.fn(async () => ({ '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', @@ -229,11 +229,11 @@ describe('auto-sync runner', () => { await fs.writeFile(path.join(targetDir, '.gitnexusrc'), '{"embeddings": true}'); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => targetDir), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -268,12 +268,12 @@ describe('auto-sync runner', () => { const stateKey = '/tmp/repos/gitee.com/qts_server/qts_account|master'; const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-1'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-1'), getExistingPdgMode: vi.fn(async () => false), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({ + loadState: vi.fn(async () => ({ [stateKey]: { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', @@ -318,12 +318,12 @@ describe('auto-sync runner', () => { }; const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), getExistingPdgMode: vi.fn(async () => true), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -411,12 +411,12 @@ describe('auto-sync runner', () => { const warn = vi.fn(); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-1'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-1'), getExistingPdgMode: vi.fn(async () => true), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -452,7 +452,7 @@ describe('auto-sync runner', () => { runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), resolveBranchPlacement: vi.fn(async () => ({ branch: 'master' })), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => true), @@ -480,11 +480,11 @@ describe('auto-sync runner', () => { it('syncs a group when a repo is newly added to the group', async () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => true), @@ -508,11 +508,11 @@ describe('auto-sync runner', () => { it('syncs a group after successful re-analysis even when membership already exists', async () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-3'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-3'), runAnalysis: vi.fn(async () => ({ stats: { files: 2 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({ + loadState: vi.fn(async () => ({ '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-2', analyzedCommitId: 'commit-2', @@ -547,12 +547,14 @@ describe('auto-sync runner', () => { projects: [ { localPath: '/tmp/repos-a', + overwriteLocalChanges: false, groupName: 'back_end', branches: ['main'], remoteUrls: ['git@github.com:team-a/service.git'], }, { localPath: '/tmp/repos-b', + overwriteLocalChanges: false, groupName: 'back_end', branches: ['main'], remoteUrls: ['git@gitlab.com:team-b/service.git'], @@ -565,11 +567,11 @@ describe('auto-sync runner', () => { root: localPath, })), cloneOrPull: vi.fn(async (_url, targetDir) => targetDir), - getCurrentBranch: vi.fn(() => 'main'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'main'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'service'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => true), @@ -618,11 +620,11 @@ describe('auto-sync runner', () => { it('skips analysis when commit id has not changed', async () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-1'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-1'), runAnalysis: vi.fn(), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({ + loadState: vi.fn(async () => ({ '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', @@ -664,11 +666,11 @@ describe('auto-sync runner', () => { .mockResolvedValueOnce(undefined); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-1'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-1'), runAnalysis: vi.fn(), registerRepo: vi.fn(), - loadState: vi.fn(async () => structuredClone(persistedState)), + loadState: vi.fn(async () => structuredClone(persistedState)), saveState: vi.fn(async (state) => { persistedState = structuredClone(state); }), @@ -705,11 +707,11 @@ describe('auto-sync runner', () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -763,11 +765,11 @@ describe('auto-sync runner', () => { const cloneOrPull = vi.fn(async () => '/tmp/repos/gitlab.mycompany.com/group/repo'); const deps: Partial = withCloneRoot({ cloneOrPull, - getCurrentBranch: vi.fn(() => 'main'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'main'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'repo'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -826,11 +828,11 @@ describe('auto-sync runner', () => { const runAnalysis = vi.fn(async () => ({ stats: { files: 1 } }) as any); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis, registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -873,11 +875,11 @@ describe('auto-sync runner', () => { if (options?.branch === 'missing') throw new Error('remote branch not found'); return '/tmp/repos/gitee.com/qts_server/qts_account'; }), - getCurrentBranch: vi.fn(() => 'develop'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'develop'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -940,7 +942,7 @@ describe('auto-sync runner', () => { getCurrentCommit: vi.fn(), runAnalysis: vi.fn(), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -980,11 +982,11 @@ describe('auto-sync runner', () => { const warnLogger = vi.fn(); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'develop'), + getCurrentBranch: vi.fn(async () => 'develop'), getCurrentCommit: vi.fn(), runAnalysis: vi.fn(), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => true), @@ -1010,11 +1012,11 @@ describe('auto-sync runner', () => { const warnLogger = vi.fn(); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => undefined), + getCurrentBranch: vi.fn(async () => undefined), getCurrentCommit: vi.fn(), runAnalysis: vi.fn(), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => true), @@ -1055,13 +1057,13 @@ describe('auto-sync runner', () => { if (remoteUrl.includes('failing_sync')) throw new Error('sync failed'); return '/tmp/repos/gitee.com/qts_server/qts_account'; }), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => { throw new Error('analysis failed'); }), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({ + loadState: vi.fn(async () => ({ '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', @@ -1113,11 +1115,11 @@ describe('auto-sync runner', () => { it('records the resolved target directory when a post-sync operation fails', async () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async (_url, targetDir) => targetDir), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => { + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => { throw new Error('git log failed'); }), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), getAvailableMemoryGB: vi.fn(() => 8), @@ -1153,11 +1155,11 @@ describe('auto-sync runner', () => { return cloneRoot; }), cloneOrPull: vi.fn(async (_url, targetDir) => targetDir), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'repo'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -1180,13 +1182,13 @@ describe('auto-sync runner', () => { const errorLogger = vi.fn(); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async (_url, targetDir) => targetDir), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => { throw new Error('registry busy'); }), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -1220,11 +1222,11 @@ describe('auto-sync runner', () => { const errorLogger = vi.fn(); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -1279,13 +1281,13 @@ describe('auto-sync runner', () => { events.push(`clone-end:${remoteUrl}`); return remoteUrl.includes('/one.git') ? '/tmp/repos/one' : '/tmp/repos/two'; }), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn((repoPath) => + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async (repoPath: string) => repoPath.endsWith('/one') ? 'one-commit' : 'two-commit', ), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'repo'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -1323,11 +1325,11 @@ describe('auto-sync runner', () => { }; const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async (_url, targetDir) => targetDir), - getCurrentBranch: vi.fn(() => 'main'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'main'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async (_path, _meta, options) => options?.name ?? 'repo'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -1367,14 +1369,14 @@ describe('auto-sync runner', () => { }; const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/github.com/owner/repo'), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), syncGroupByName: vi.fn(async () => {}), getAvailableMemoryGB: vi.fn(() => 8), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'abc'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'abc'), runAnalysis: vi.fn(async () => ({ stats: {} })), registerRepo: vi.fn(async () => 'repo'), }); @@ -1399,13 +1401,13 @@ describe('auto-sync runner', () => { const errorLogger = vi.fn(); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => { throw new Error('parser crashed\nwith stack'); }), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({ + loadState: vi.fn(async () => ({ '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', @@ -1460,13 +1462,13 @@ describe('auto-sync runner', () => { ); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-1'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-1'), runAnalysis: vi.fn(async () => { throw leftover; }), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({ + loadState: vi.fn(async () => ({ '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', @@ -1513,8 +1515,8 @@ describe('auto-sync runner', () => { it('does not count a retryable live-holder index-lock timeout toward the analyze failure threshold', async () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-1'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-1'), runAnalysis: vi.fn(async () => { throw new AutoSyncAnalysisError('waited for the index lock', { code: 'index-lock-timeout', @@ -1522,7 +1524,7 @@ describe('auto-sync runner', () => { }); }), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -1550,8 +1552,8 @@ describe('auto-sync runner', () => { it('still counts a non-retryable index-lock guard timeout toward the analyze failure threshold', async () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-1'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-1'), runAnalysis: vi.fn(async () => { throw new AutoSyncAnalysisError('index lock guard timeout', { code: 'index-lock-timeout', @@ -1559,7 +1561,7 @@ describe('auto-sync runner', () => { }); }), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -1586,13 +1588,13 @@ describe('auto-sync runner', () => { it('records a null analysis failure without masking it with a TypeError', async () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => { throw null; }), registerRepo: vi.fn(), - loadState: vi.fn(async () => ({})), + loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), addRepoToGroup: vi.fn(async () => false), @@ -1621,11 +1623,11 @@ describe('auto-sync runner', () => { const errorLogger = vi.fn(); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({ + loadState: vi.fn(async () => ({ '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', @@ -1673,11 +1675,11 @@ describe('auto-sync runner', () => { it('clears prior analyze failure count after a successful analyze', async () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), - getCurrentBranch: vi.fn(() => 'master'), - getCurrentCommit: vi.fn(() => 'commit-2'), + getCurrentBranch: vi.fn(async () => 'master'), + getCurrentCommit: vi.fn(async () => 'commit-2'), runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), - loadState: vi.fn(async () => ({ + loadState: vi.fn(async () => ({ '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', diff --git a/gitnexus/test/unit/auto-sync.test.ts b/gitnexus/test/unit/auto-sync.test.ts index ac974817a..ebf3ba866 100644 --- a/gitnexus/test/unit/auto-sync.test.ts +++ b/gitnexus/test/unit/auto-sync.test.ts @@ -287,7 +287,7 @@ describe('auto-sync', () => { const loaded = await loadAutoSyncConfig(); expect(loaded.ok).toBe(false); - if (loaded.ok) throw new Error('expected invalid config'); + if (loaded.ok !== false) throw new Error('expected invalid config'); expect(loaded.message).toContain('analyze_failure_threshold must be an integer >= 2'); }); @@ -307,7 +307,7 @@ describe('auto-sync', () => { const loaded = await loadAutoSyncConfig(); expect(loaded.ok).toBe(false); - if (loaded.ok) throw new Error('expected invalid config'); + if (loaded.ok !== false) throw new Error('expected invalid config'); expect(loaded.reason).toBe('invalid'); expect(loaded.message).toContain('[auto-sync] Invalid watch_config.yml:'); expect(loaded.message).toContain('sync_interval_minutes must be a positive integer'); @@ -330,7 +330,7 @@ describe('auto-sync', () => { const loaded = await loadAutoSyncConfig(); expect(loaded.ok).toBe(false); - if (loaded.ok) throw new Error('expected invalid config'); + if (loaded.ok !== false) throw new Error('expected invalid config'); expect(loaded.message).toContain('local_path must be an absolute path'); }); @@ -740,7 +740,7 @@ describe('auto-sync', () => { const loaded = await loadAutoSyncConfig(); expect(loaded.ok).toBe(false); - if (loaded.ok) throw new Error('expected invalid config'); + if (loaded.ok !== false) throw new Error('expected invalid config'); expect(loaded.message).toContain('must not set both branch and branches'); }); diff --git a/gitnexus/test/unit/basicblock-callee-ids-schema.test.ts b/gitnexus/test/unit/basicblock-callee-ids-schema.test.ts index a9e7ab1ad..d6dbb8154 100644 --- a/gitnexus/test/unit/basicblock-callee-ids-schema.test.ts +++ b/gitnexus/test/unit/basicblock-callee-ids-schema.test.ts @@ -74,7 +74,7 @@ describe('BasicBlock calleeIds — CSV header + row builder', () => { expect(rowCells).toHaveLength(headerCols.length); const calleeIdsIdx = headerCols.indexOf('calleeIds'); const calleesIdx = headerCols.indexOf('callees'); - // escapeCSVField always wraps the cell in double quotes; the space-joined + // Non-empty string fields are quoted; the space-joined // id list contains no comma, so the cell is a single CSV column. expect(rowCells[calleeIdsIdx]).toBe('"id1 id2"'); expect(rowCells[calleesIdx]).toBe('"foo bar"'); @@ -85,7 +85,8 @@ describe('BasicBlock calleeIds — CSV header + row builder', () => { const headerCols = BASICBLOCK_CSV_HEADER.split(','); const rowCells = buildBasicBlockRow(node).split(','); const calleeIdsIdx = headerCols.indexOf('calleeIds'); - expect(rowCells[calleeIdsIdx]).toBe('""'); + // An absent string must stay unquoted so native COPY loads SQL NULL. + expect(rowCells[calleeIdsIdx]).toBe(''); expect(rowCells[calleeIdsIdx]).not.toContain('undefined'); }); }); diff --git a/gitnexus/test/unit/bm25-search.test.ts b/gitnexus/test/unit/bm25-search.test.ts index 793cf7aae..87b750fb7 100644 --- a/gitnexus/test/unit/bm25-search.test.ts +++ b/gitnexus/test/unit/bm25-search.test.ts @@ -45,7 +45,9 @@ describe('BM25 search', () => { // One SHOW_INDEXES call returns a catalog row per configured index, each // covering exactly its expected properties. const showIndexesRows = FTS_INDEXES.map((i) => ({ + table_name: i.table, index_name: i.indexName, + index_type: 'FTS', property_names: [...i.properties], })); const executeQuery = vi.fn().mockResolvedValue(showIndexesRows); @@ -62,7 +64,9 @@ describe('BM25 search', () => { // missing `description`. Every other index covers its columns. const staleIndex = 'function_fts'; const showIndexesRows = FTS_INDEXES.map((i) => ({ + table_name: i.table, index_name: i.indexName, + index_type: 'FTS', property_names: i.indexName === staleIndex ? ['name', 'content'] : [...i.properties], })); const executeQuery = vi.fn().mockResolvedValue(showIndexesRows); @@ -77,7 +81,9 @@ describe('BM25 search', () => { // Every configured index present and covering, except const_fts is missing. const absentIndex = 'const_fts'; const showIndexesRows = FTS_INDEXES.filter((i) => i.indexName !== absentIndex).map((i) => ({ + table_name: i.table, index_name: i.indexName, + index_type: 'FTS', property_names: [...i.properties], })); const executeQuery = vi.fn().mockResolvedValue(showIndexesRows); @@ -647,3 +653,125 @@ describe('BM25 search', () => { }); }); }); + +describe('FTS index completeness', () => { + beforeEach(async () => { + resetExtensionState(); + mockExecuteParameterized.mockReset(); + const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(queryFTS).mockReset(); + }); + + const missing = (table: string, indexName: string) => + new Error(`Binder exception: Table ${table} doesn't have an index with name ${indexName}.`); + + for (const pooled of [false, true]) { + const mode = pooled ? 'pool' : 'core'; + const repo = pooled ? 'completeness-test' : undefined; + + async function useOutcome(outcome: (table: string, indexName: string) => Promise) { + const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(queryFTS).mockImplementation((table, indexName) => outcome(table, indexName)); + mockExecuteParameterized.mockImplementation(async (_repo: string, cypher: string) => { + const match = cypher.match(/QUERY_FTS_INDEX\('([^']+)', '([^']+)'/); + return outcome(match![1], match![2]); + }); + } + + it(`${mode}: distinguishes healthy zero matches from missing indexes`, async () => { + await useOutcome(async () => []); + const result = await searchFTSFromLbug('no matches', 5, repo); + expect(result).toEqual({ results: [], ftsAvailable: true }); + }); + + it(`${mode}: reports one missing index while retaining successful matches`, async () => { + await useOutcome(async (table, indexName) => { + if (table === 'Function') throw missing(table, indexName); + if (table !== 'File') return []; + return pooled + ? [{ node: { filePath: 'src/auth.ts', id: 'file:auth' }, score: 3 }] + : [{ filePath: 'src/auth.ts', nodeId: 'file:auth', score: 3 }]; + }); + const result = await searchFTSFromLbug('auth', 5, repo); + expect(result.ftsAvailable).toBe(true); + expect(result.results[0]).toMatchObject({ filePath: 'src/auth.ts', score: 3 }); + expect(result.missingIndexes).toEqual(['Function.function_fts']); + expect(result.nonBenignErrors).toBeUndefined(); + }); + + it(`${mode}: reports all missing indexes without claiming FTS is available`, async () => { + await useOutcome(async (table, indexName) => { + throw missing(table, indexName); + }); + const result = await searchFTSFromLbug('auth', 5, repo); + expect(result.ftsAvailable).toBe(false); + expect(result.results).toEqual([]); + if (!pooled) { + const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js'); + for (const { table, indexName } of FTS_INDEXES) { + expect(queryFTS).toHaveBeenCalledWith(table, indexName, 'auth', 5, false, 'throw'); + } + } + expect(result.missingIndexes).toEqual( + FTS_INDEXES.map(({ table, indexName }) => `${table}.${indexName}`), + ); + expect(result.nonBenignErrors).toBeUndefined(); + }); + + it(`${mode}: preserves redacted real errors alongside missing-index diagnostics`, async () => { + await useOutcome(async (table, indexName) => { + if (table === 'Function') throw missing(table, indexName); + if (table === 'Class') + throw new Error('connection reset at /home/alice/private/index.lbug'); + return []; + }); + const result = await searchFTSFromLbug('auth', 5, repo); + expect(result.ftsAvailable).toBe(true); + expect(result.missingIndexes).toEqual(['Function.function_fts']); + expect(result.nonBenignErrors).toHaveLength(1); + expect(result.nonBenignErrors![0]).toContain('connection reset'); + expect(JSON.stringify(result)).not.toContain('/home/alice'); + }); + + it(`${mode}: retains both failure causes when no configured index query succeeds`, async () => { + await useOutcome(async (table, indexName) => { + if (table === 'Function') throw missing(table, indexName); + throw new Error('connection reset at /home/alice/private/index.lbug'); + }); + + const result = await searchFTSFromLbug('auth', 5, repo); + + expect(result.ftsAvailable).toBe(false); + expect(result.results).toEqual([]); + expect(result.missingIndexes).toEqual(['Function.function_fts']); + expect(result.nonBenignErrors).toEqual( + Array(FTS_INDEXES.length - 1).fill('connection reset at '), + ); + }); + + it(`${mode}: clears missing-index diagnostics after a repaired query`, async () => { + await useOutcome(async (table, indexName) => { + if (table === 'Function') throw missing(table, indexName); + return []; + }); + expect((await searchFTSFromLbug('auth', 5, repo)).missingIndexes).toEqual([ + 'Function.function_fts', + ]); + await useOutcome(async () => []); + expect(await searchFTSFromLbug('auth', 5, repo)).toEqual({ results: [], ftsAvailable: true }); + }); + + it(`${mode}: explicit opt-out makes no queries and emits no missing-index diagnosis`, async () => { + await useOutcome(async (table, indexName) => { + throw missing(table, indexName); + }); + expect(await searchFTSFromLbug('auth', 5, repo, 'disabled-by-flag')).toEqual({ + results: [], + ftsAvailable: false, + }); + const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js'); + expect(queryFTS).not.toHaveBeenCalled(); + expect(mockExecuteParameterized).not.toHaveBeenCalled(); + }); + } +}); diff --git a/gitnexus/test/unit/call-routing/ruby.test.ts b/gitnexus/test/unit/call-routing/ruby.test.ts index 69c6405c3..a5ea5a6f7 100644 --- a/gitnexus/test/unit/call-routing/ruby.test.ts +++ b/gitnexus/test/unit/call-routing/ruby.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import type { SyntaxNode } from 'tree-sitter'; import { routeRubyCall } from '../../../src/core/ingestion/call-routing.js'; // ── Mock AST node helpers ──────────────────────────────────────────────────── @@ -164,67 +165,73 @@ function makeNamedSibling(type = 'expression_statement'): MockNode { return { type, text: '', isNamed: true }; } +// These routing fixtures intentionally provide only the traversed AST fields. +// Keep the native SyntaxNode assertion at the test boundary. +function syntaxNode(node: MockNode): SyntaxNode { + return node as unknown as SyntaxNode; +} + // ── require / require_relative ─────────────────────────────────────────────── describe('routeRubyCall — require / require_relative', () => { it('require with a valid string path returns import with isRelative=false', () => { const node = makeRequireCallNode('net/http'); - const result = routeRubyCall('require', node); + const result = routeRubyCall('require', syntaxNode(node)); expect(result).toEqual({ kind: 'import', importPath: 'net/http', isRelative: false }); }); it('require_relative without leading dot prepends "./"', () => { const node = makeRequireCallNode('models/user'); - const result = routeRubyCall('require_relative', node); + const result = routeRubyCall('require_relative', syntaxNode(node)); expect(result).toEqual({ kind: 'import', importPath: './models/user', isRelative: true }); }); it('require_relative with path already starting with "." does not double-prepend', () => { const node = makeRequireCallNode('./helpers/formatter'); - const result = routeRubyCall('require_relative', node); + const result = routeRubyCall('require_relative', syntaxNode(node)); expect(result).toEqual({ kind: 'import', importPath: './helpers/formatter', isRelative: true }); }); it('require_relative with "../" prefix is left unchanged', () => { const node = makeRequireCallNode('../shared/utils'); - const result = routeRubyCall('require_relative', node); + const result = routeRubyCall('require_relative', syntaxNode(node)); expect(result).toEqual({ kind: 'import', importPath: '../shared/utils', isRelative: true }); }); it('returns skip when there is no string_content node (non-literal argument)', () => { const node = makeRequireCallNodeNoContent(); - expect(routeRubyCall('require', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('require', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('returns skip when import path is an empty string', () => { const node = makeRequireCallNode(''); - expect(routeRubyCall('require', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('require', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('returns skip when import path contains a control character (\\x00)', () => { const node = makeRequireCallNode('some\x00path'); - expect(routeRubyCall('require', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('require', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('returns skip when import path contains a newline control character (\\n)', () => { const node = makeRequireCallNode('path\ninjection'); - expect(routeRubyCall('require', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('require', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('returns skip when import path exceeds 1024 characters', () => { const longPath = 'a'.repeat(1025); const node = makeRequireCallNode(longPath); - expect(routeRubyCall('require', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('require', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('accepts import path of exactly 1024 characters', () => { const maxPath = 'a'.repeat(1024); const node = makeRequireCallNode(maxPath); - const result = routeRubyCall('require', node); + const result = routeRubyCall('require', syntaxNode(node)); expect(result).toEqual({ kind: 'import', importPath: maxPath, isRelative: false }); }); @@ -240,13 +247,13 @@ describe('routeRubyCall — require / require_relative', () => { text: '', childForFieldName: (name: string) => (name === 'arguments' ? argList : undefined), }; - expect(routeRubyCall('require', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('require', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('returns skip when childForFieldName is absent (undefined callNode fields)', () => { // callNode has no childForFieldName method at all const node: MockNode = { type: 'call', text: '' }; - expect(routeRubyCall('require', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('require', syntaxNode(node))).toEqual({ kind: 'skip' }); }); }); @@ -259,7 +266,7 @@ describe('routeRubyCall — require / require_relative', () => { describe('routeRubyCall — include / extend / prepend (heritage owned by scope-resolution)', () => { it('include returns skip (heritage emitted by scope-resolution, not a call edge)', () => { const node = makeHeritageCallNode([makeConstantArg('Serializable')], 'class', 'User'); - expect(routeRubyCall('include', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('include', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('extend returns skip (heritage emitted by scope-resolution, not a call edge)', () => { @@ -268,12 +275,12 @@ describe('routeRubyCall — include / extend / prepend (heritage owned by scope- 'class', 'Post', ); - expect(routeRubyCall('extend', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('extend', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('prepend returns skip (heritage emitted by scope-resolution, not a call edge)', () => { const node = makeHeritageCallNode([makeConstantArg('Instrumented')], 'class', 'Service'); - expect(routeRubyCall('prepend', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('prepend', syntaxNode(node))).toEqual({ kind: 'skip' }); }); }); @@ -282,7 +289,7 @@ describe('routeRubyCall — include / extend / prepend (heritage owned by scope- describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { it('attr_accessor with a single symbol returns a property item', () => { const node = makeAccessorCallNode([makeSimpleSymbol('name', 5)]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toEqual({ kind: 'properties', @@ -292,7 +299,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { it('attr_reader sets accessorType to "attr_reader"', () => { const node = makeAccessorCallNode([makeSimpleSymbol('age', 3)]); - const result = routeRubyCall('attr_reader', node); + const result = routeRubyCall('attr_reader', syntaxNode(node)); expect(result).toEqual({ kind: 'properties', @@ -302,7 +309,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { it('attr_writer sets accessorType to "attr_writer"', () => { const node = makeAccessorCallNode([makeSimpleSymbol('email', 7)]); - const result = routeRubyCall('attr_writer', node); + const result = routeRubyCall('attr_writer', syntaxNode(node)); expect(result).toEqual({ kind: 'properties', @@ -319,7 +326,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { endPosition: { row: 2 }, }; const node = makeAccessorCallNode([symNode]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toMatchObject({ kind: 'properties', items: [{ propName: 'title' }] }); }); @@ -333,7 +340,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { endPosition: { row: 1 }, }; const node = makeAccessorCallNode([symNode]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toMatchObject({ kind: 'properties', items: [{ propName: 'status' }] }); }); @@ -345,7 +352,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { makeSimpleSymbol('dob', 10), ]; const node = makeAccessorCallNode(args); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toEqual({ kind: 'properties', @@ -360,7 +367,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { it('extracts simple YARD @return [Type] from preceding comment', () => { const comment = makeCommentNode('# @return [Address]'); const node = makeAccessorCallNode([makeSimpleSymbol('address', 20)], [comment]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toMatchObject({ kind: 'properties', @@ -372,7 +379,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { // The regex captures "Array"; the simple match grabs the first uppercase word "Array" const comment = makeCommentNode('# @return [Array]'); const node = makeAccessorCallNode([makeSimpleSymbol('users', 15)], [comment]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toMatchObject({ kind: 'properties', @@ -383,7 +390,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { it('extracts type from YARD comment with extra whitespace inside brackets', () => { const comment = makeCommentNode('# @return [ Integer ]'); const node = makeAccessorCallNode([makeSimpleSymbol('count', 8)], [comment]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toMatchObject({ kind: 'properties', @@ -393,7 +400,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { it('does not set declaredType when no YARD comment precedes the call', () => { const node = makeAccessorCallNode([makeSimpleSymbol('score', 12)]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toMatchObject({ kind: 'properties', items: [{ propName: 'score' }] }); const item = (result as any).items[0]; @@ -403,7 +410,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { it('does not set declaredType when comment has no @return annotation', () => { const comment = makeCommentNode('# This accessor stores the user name'); const node = makeAccessorCallNode([makeSimpleSymbol('user_name', 9)], [comment]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); const item = (result as any).items[0]; expect(item.declaredType).toBeUndefined(); @@ -413,7 +420,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { // e.g. "@return [string]" — lowercase first char fails the simple = raw.match(/^([A-Z]\w*)/) const comment = makeCommentNode('# @return [string]'); const node = makeAccessorCallNode([makeSimpleSymbol('label', 4)], [comment]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); const item = (result as any).items[0]; expect(item.declaredType).toBeUndefined(); @@ -426,7 +433,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { const named = makeNamedSibling(); const node = makeAccessorCallNode([makeSimpleSymbol('owner', 6)], [yardComment, named]); // named is last in the array → becomes direct previousSibling - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); const item = (result as any).items[0]; expect(item.declaredType).toBeUndefined(); @@ -438,7 +445,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { const comment = makeCommentNode('# @return [Order]'); // siblings in order oldest→newest; the last becomes the direct previousSibling const node = makeAccessorCallNode([makeSimpleSymbol('order', 30)], [comment, unnamedNode]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toMatchObject({ kind: 'properties', @@ -450,12 +457,12 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { // Only an identifier node — not a symbol const identArg: MockNode = { type: 'identifier', text: 'some_var' }; const node = makeAccessorCallNode([identArg]); - expect(routeRubyCall('attr_accessor', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('attr_accessor', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('returns skip when arg list is empty', () => { const node = makeAccessorCallNode([]); - expect(routeRubyCall('attr_accessor', node)).toEqual({ kind: 'skip' }); + expect(routeRubyCall('attr_accessor', syntaxNode(node))).toEqual({ kind: 'skip' }); }); it('records correct startLine and endLine from symbol node positions', () => { @@ -466,7 +473,7 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { endPosition: { row: 42 }, }; const node = makeAccessorCallNode([sym]); - const result = routeRubyCall('attr_accessor', node); + const result = routeRubyCall('attr_accessor', syntaxNode(node)); expect(result).toMatchObject({ kind: 'properties', @@ -480,19 +487,19 @@ describe('routeRubyCall — attr_accessor / attr_reader / attr_writer', () => { describe('routeRubyCall — default (unknown method name)', () => { it('returns {kind: "call"} for an arbitrary method name', () => { const node: MockNode = { type: 'call', text: '' }; - expect(routeRubyCall('some_method', node)).toEqual({ kind: 'call' }); + expect(routeRubyCall('some_method', syntaxNode(node))).toEqual({ kind: 'call' }); }); it('returns {kind: "call"} for an empty method name string', () => { const node: MockNode = { type: 'call', text: '' }; - expect(routeRubyCall('', node)).toEqual({ kind: 'call' }); + expect(routeRubyCall('', syntaxNode(node))).toEqual({ kind: 'call' }); }); it('returns {kind: "call"} for a realistic method name (save, render, etc.)', () => { const node: MockNode = { type: 'call', text: '' }; - expect(routeRubyCall('render', node)).toEqual({ kind: 'call' }); - expect(routeRubyCall('save', node)).toEqual({ kind: 'call' }); - expect(routeRubyCall('destroy', node)).toEqual({ kind: 'call' }); + expect(routeRubyCall('render', syntaxNode(node))).toEqual({ kind: 'call' }); + expect(routeRubyCall('save', syntaxNode(node))).toEqual({ kind: 'call' }); + expect(routeRubyCall('destroy', syntaxNode(node))).toEqual({ kind: 'call' }); }); }); @@ -501,13 +508,13 @@ describe('routeRubyCall — default (unknown method name)', () => { describe('routeRubyCall passthrough', () => { it('routeRubyCall delegates correctly for require', () => { const node = makeRequireCallNode('json'); - const result = routeRubyCall('require', node); + const result = routeRubyCall('require', syntaxNode(node)); expect(result).toEqual({ kind: 'import', importPath: 'json', isRelative: false }); }); it('routeRubyCall returns {kind: "call"} for an unknown method name', () => { const node: MockNode = { type: 'call', text: '' }; - const result = routeRubyCall('render', node); + const result = routeRubyCall('render', syntaxNode(node)); expect(result).toEqual({ kind: 'call' }); }); }); diff --git a/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts b/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts index 0390caab6..ac832db95 100644 --- a/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts +++ b/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts @@ -29,12 +29,12 @@ const { lbugMocks } = vi.hoisted(() => ({ })); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); @@ -159,9 +159,8 @@ describe('LocalBackend PDG impact — resolved-callee-id bridge (U6)', () => { if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) { return [frontierRow('func:callee-A', 'callee')]; } - if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return []; - // Target resolution (WHERE n.name = $symName) and any other read. - return [TARGET_ROW]; + if (query.includes('WHERE n.name = $symName')) return [TARGET_ROW]; + return []; }); const result = await backend.callTool('impact', { @@ -195,8 +194,8 @@ describe('LocalBackend PDG impact — resolved-callee-id bridge (U6)', () => { if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) { return [frontierRow('func:callee-A', 'callee')]; } - if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return []; - return [TARGET_ROW]; + if (query.includes('WHERE n.name = $symName')) return [TARGET_ROW]; + return []; }); const result = await backend.callTool('impact', { @@ -230,8 +229,8 @@ describe('LocalBackend PDG impact — resolved-callee-id bridge (U6)', () => { if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) { return [frontierRow('func:callee-A', 'callee'), frontierRow('func:callee-B', 'callee')]; } - if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return []; - return [TARGET_ROW]; + if (query.includes('WHERE n.name = $symName')) return [TARGET_ROW]; + return []; }); const result = await backend.callTool('impact', { @@ -266,8 +265,8 @@ describe('LocalBackend PDG impact — resolved-callee-id bridge (U6)', () => { if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) { return [frontierRow('func:callee-A', 'callee'), frontierRow('*', 'callee')]; } - if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return []; - return [TARGET_ROW]; + if (query.includes('WHERE n.name = $symName')) return [TARGET_ROW]; + return []; }); const result = await backend.callTool('impact', { diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 6a0a98d4a..aa4bc9d82 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -30,13 +30,13 @@ const { lbugMocks } = vi.hoisted(() => ({ })); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); // Re-export shim must resolve to the same mocks vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); @@ -183,6 +183,16 @@ function setupNoRepos() { (listRegisteredRepos as any).mockResolvedValue([]); } +/** Seed resolver rows without inventing relationship/process rows for other projections. */ +function mockSymbolRows(rows: Record[]) { + (executeParameterized as any).mockImplementation( + async (_repo: string, query: string, params: Record) => { + if (query.includes('COUNT(*) AS total')) return [{ total: rows.length }]; + return params?.symName || params?.uid ? rows : []; + }, + ); +} + const duplicateFixtureDirs: string[] = []; function makeDuplicateNameFixture() { @@ -565,14 +575,17 @@ describe('LocalBackend.callTool', () => { ['impact', { name: 'validate', symbol: 'login', direction: 'upstream' }], ['impact', { target: 'validate', direction: 'upstream', maxDepth: 3, depth: 1 }], ['context', { name: 'validate', file_path: 'src/auth.ts', file: 'src/login.ts' }], - ])('rejects conflicting %s aliases before repository resolution', async (method, params) => { - const resolveSpy = vi.spyOn(backend, 'selectToolRepository'); + ])( + 'rejects conflicting %s aliases before repository resolution (case %#)', + async (method, params) => { + const resolveSpy = vi.spyOn(backend, 'selectToolRepository'); - const result = await backend.callTool(method, params); + const result = await backend.callTool(method, params); - expect(result.error).toMatch(/conflicting mcp parameters/i); - expect(resolveSpy).not.toHaveBeenCalled(); - }); + expect(result.error).toMatch(/conflicting mcp parameters/i); + expect(resolveSpy).not.toHaveBeenCalled(); + }, + ); it.each([ ['impact', { name: 42, direction: 'upstream' }], @@ -624,8 +637,8 @@ describe('LocalBackend.callTool', () => { }); it('reports UNKNOWN instead of a blast radius when the target resolves without a node id (#3354)', async () => { - // Every query returns the same id-less row: the resolver picks it as the - // single match, and the frontier query would answer for no symbol at all. + // Every query returns the same id-less row: resolution must reject it + // before a frontier query could answer for no symbol at all. (executeParameterized as any).mockResolvedValue([{ name: 'runSweep', type: 'Function' }]); const result = await backend.callTool('impact', { target: 'runSweep', direction: 'upstream' }); @@ -635,7 +648,8 @@ describe('LocalBackend.callTool', () => { impactedCount: null, risk: 'UNKNOWN', }); - expect(result.error).toMatch(/without a node id/); + expect(result.error).toMatch(/invalid symbol identity/); + expect(result.recoverySuggestion).toContain('gitnexus analyze --force'); expect(result).not.toHaveProperty('byDepthCounts'); }); @@ -1320,7 +1334,7 @@ describe('LocalBackend.callTool', () => { }); it('dispatches context tool', async () => { - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'func:main', name: 'main', @@ -1662,27 +1676,22 @@ describe('LocalBackend.callTool', () => { }); it('exact File path wins over suffixed matches during qualified resolution (#3084 review P2)', async () => { - (executeParameterized as any).mockImplementation(async (_repo: string, query: string) => { - if (query.startsWith('MATCH (n)')) { - return [ - { - id: 'File:src/lib/a.ts', - name: 'a.ts', - filePath: 'src/lib/a.ts', - kind: 'File', - total_hits: 1, - }, - { - id: 'File:lib/a.ts', - name: 'a.ts', - filePath: 'lib/a.ts', - kind: 'File', - total_hits: 1, - }, - ]; - } - return [{ total: 2 }]; - }); + mockSymbolRows([ + { + id: 'File:src/lib/a.ts', + name: 'a.ts', + filePath: 'src/lib/a.ts', + kind: 'File', + total_hits: 1, + }, + { + id: 'File:lib/a.ts', + name: 'a.ts', + filePath: 'lib/a.ts', + kind: 'File', + total_hits: 1, + }, + ]); const result = await backend.callTool('context', { name: 'lib/a.ts' }); expect(result).toMatchObject({ @@ -2004,7 +2013,7 @@ describe('LocalBackend.callTool', () => { }); it('context tool ranks file_path match higher than non-match (#470)', async () => { - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'func:handleConnect:1', name: 'handleConnect', @@ -2043,7 +2052,7 @@ describe('LocalBackend.callTool', () => { // review): both candidates satisfy the file_path hint (so DB // pre-filter would return both in production), and promotion is // determined purely by the combined file_path + kind score. - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'fn:App:1', name: 'render', @@ -2134,7 +2143,7 @@ describe('LocalBackend.callTool', () => { it('impact tool returns ambiguous shape with ranked candidates when target has multiple matches (#470)', async () => { // resolveSymbolCandidates issues a single name query; mock it to return // two Function rows in different files with no hints. - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'func:login:1', name: 'login', @@ -2221,7 +2230,7 @@ describe('LocalBackend.callTool', () => { // Resolver returns target; BFS returns one frontier caller; no STEP_IN_PROCESS rows. (executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => { // BFS frontier query is now parameterized (#1907 U3). - if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) { + if (cypher.includes('$frontierIds')) { return Promise.resolve([ { id: 'func:caller', @@ -2233,10 +2242,12 @@ describe('LocalBackend.callTool', () => { }, ]); } - // Symbol resolution. - return Promise.resolve([ - { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, - ]); + // Symbol resolution; unseeded enrichment queries return no rows. + return Promise.resolve( + cypher.includes('$symName') + ? [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }] + : [], + ); }); (executeQuery as any).mockResolvedValue([]); @@ -2466,7 +2477,7 @@ describe('LocalBackend.callTool', () => { }, ]); - const result = await backend.impactByUid('test-project', 'uid:main', 'upstream', { + const result = await backend.impactByUid('test-project', 'func:main', 'upstream', { maxDepth: 5, relationTypes: ['CALLS'], minConfidence: 0, @@ -2552,9 +2563,7 @@ describe('LocalBackend.callTool', () => { // `oldName` must sit on the file's 0-based line 1 for the definition edit to // fire. (#2380: the mock previously put it on line 0, which stopped matching // once context() went 1-based.) - const readSpy = vi - .spyOn(fsPromises, 'readFile') - .mockResolvedValue('\nfunction oldName() {}\n' as unknown as Buffer); + const readSpy = vi.spyOn(fsPromises, 'readFile').mockResolvedValue('\nfunction oldName() {}\n'); const writeSpy = vi .spyOn(fsPromises, 'writeFile') .mockRejectedValue(new Error('EACCES: permission denied')); @@ -2973,7 +2982,7 @@ describe('LocalBackend.callTool', () => { }); it('dispatches "explore" as alias for context', async () => { - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'func:main', name: 'main', @@ -3006,9 +3015,7 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { // dispatch (callgraph BFS or the PDG traversal). The callgraph BFS then issues // executeQuery for its frontier; the PDG path delegates to runImpactPDG. function resolveSingleTarget() { - (executeParameterized as any).mockResolvedValue([ - { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, - ]); + mockSymbolRows([{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }]); (executeQuery as any).mockResolvedValue([]); } @@ -3028,7 +3035,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { it('mode absent → callgraph result (target populated, no mode-error, BFS runs)', async () => { resolveSingleTarget(); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const result = await backend.callTool('impact', { target: 'main', direction: 'upstream' }); // A clean callgraph result carries no mode error and runs the BFS. expect(result.error ?? '').not.toMatch(/Invalid "mode"/); @@ -3056,7 +3066,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { it("mode:'pdg' routes to the PDG traversal and attaches interprocedural symbol reach", async () => { resolveSingleTarget(); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const result = await backend.callTool('impact', { target: 'main', direction: 'upstream', @@ -3073,7 +3086,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { it("mode:'pdg' labels interprocedural symbols as a callgraph bridge", async () => { resolveSingleTarget(); - vi.spyOn(backend as any, '_runImpactBFS').mockResolvedValueOnce({ + vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ).mockResolvedValueOnce({ target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, direction: 'downstream', impactedCount: 1, @@ -3112,7 +3128,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { it("mode:'pdg' preserves unproven bridge evidence when call-site proof is unavailable", async () => { resolveSingleTarget(); - vi.spyOn(backend as any, '_runImpactBFS').mockResolvedValueOnce({ + vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ).mockResolvedValueOnce({ target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, direction: 'downstream', impactedCount: 1, @@ -3153,7 +3172,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { 'invalid mode %j → structured {error}, never a callgraph result (KTD5 anti-silent-fallback)', async (bad) => { resolveSingleTarget(); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const result = await backend.callTool('impact', { target: 'main', direction: 'upstream', @@ -3170,7 +3192,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { 'line param with mode:%j → structured {error} (line is PDG-only), never a callgraph result', async (mode) => { resolveSingleTarget(); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const result = await backend.callTool('impact', { target: 'main', direction: 'upstream', @@ -3193,7 +3218,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { 'mode:%j + adapter-materialized line:0 is treated as omitted and runs the BFS (#2279)', async (mode) => { resolveSingleTarget(); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const result = await backend.callTool('impact', { target: 'main', direction: 'upstream', @@ -3265,18 +3293,13 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { it("mode:'pdg' + downstream line:8 routes to the PDG traversal and seeds bridge evidence", async () => { resolveSingleTarget(); - // The target-resolution row doubles as the calleesOfBlocks row: `callees` - // ('callee') is the leaf name persisted on the slice's BasicBlock, the - // statement-precise substrate the bridge keys on. - (executeParameterized as any).mockResolvedValue([ - { - id: 'func:main', - name: 'main', - type: 'Function', - filePath: 'src/index.ts', - callees: 'callee', - }, - ]); + // BasicBlock callees and symbol lookup use distinct native projections. + vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => { + if (query.includes('RETURN b.callees')) return [{ callees: 'callee' }]; + return query.includes('$symName') + ? [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }] + : []; + }); // A line-seeded downstream slice with one reachable block → the dispatch // queries that block's callees and seeds the bridge with them. const pdgSpy = vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({ @@ -3295,7 +3318,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { affectedStatementCount: 1, criterionLine: 8, }); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const result = await backend.callTool('impact', { target: 'main', direction: 'downstream', @@ -3348,7 +3374,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { affectedStatementCount: 0, criterionLine: 8, }); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); await backend.callTool('impact', { target: 'main', direction: 'downstream', @@ -3401,11 +3430,13 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { // is not built and the inter-procedural reach falls back to callgraph-equal — // never surfacing the error or producing a partial proven/unproven labeling. resolveSingleTarget(); - // The slice-callees query (RETURN b.callees) throws; every other query (target - // resolution) returns the resolved symbol row. + // The slice-callees query throws; lookup returns the target and unseeded + // relationship/process projections return no rows. vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => { if (query.includes('RETURN b.callees')) throw new Error('slice-callees query failed'); - return [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }]; + return query.includes('$symName') + ? [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }] + : []; }); // A line-seeded downstream slice so calleesOfBlocks is attempted. vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({ @@ -3423,7 +3454,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { affectedStatementCount: 1, criterionLine: 8, }); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const cap = _captureLogger(); try { const result = await backend.callTool('impact', { @@ -3460,7 +3494,9 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { resolveSingleTarget(); vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => { if (query.includes('RETURN b.callees')) throw new Error('Table BasicBlock does not exist'); - return [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }]; + return query.includes('$symName') + ? [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }] + : []; }); vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({ mode: 'pdg', @@ -3477,7 +3513,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { affectedStatementCount: 1, criterionLine: 8, }); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const cap = _captureLogger('debug'); try { const result = await backend.callTool('impact', { @@ -3524,7 +3563,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { affectedStatementCount: 1, criterionLine: 8, }); - vi.spyOn(backend as any, '_runImpactBFS'); + vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const cap = _captureLogger(); try { await backend.callTool('impact', { @@ -3543,7 +3585,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { it("mode:'pdg' + crossDepth → hard {error} (single-repo PDG impact)", async () => { resolveSingleTarget(); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const result = await backend.callTool('impact', { target: 'main', direction: 'upstream', @@ -3560,17 +3605,22 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { ['minConfidence', { minConfidence: 0.5 }, (opts: any) => opts.minConfidence], ])("mode:'pdg' + %s feeds the interprocedural symbol reach", async (_label, extra, readOpt) => { resolveSingleTarget(); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS').mockResolvedValueOnce({ - target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, - direction: 'upstream', - impactedCount: 0, - risk: 'LOW', - summary: { direct: 0, processes_affected: 0, modules_affected: 0 }, - byDepthCounts: {}, - affected_processes: [], - affected_modules: [], - byDepth: {}, - }); + const bfsSpy = vi + .spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ) + .mockResolvedValueOnce({ + target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, + direction: 'upstream', + impactedCount: 0, + risk: 'LOW', + summary: { direct: 0, processes_affected: 0, modules_affected: 0 }, + byDepthCounts: {}, + affected_processes: [], + affected_modules: [], + byDepth: {}, + }); const result = await backend.callTool('impact', { target: 'main', direction: 'upstream', @@ -3601,7 +3651,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { startLine: 8, }, ]); - const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const bfsSpy = vi.spyOn( + backend as unknown as { _runImpactBFS: LocalBackend['_runImpactBFS'] }, + '_runImpactBFS', + ); const result = await backend.callTool('impact', { target: 'login', direction: 'upstream', diff --git a/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts b/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts index 0e4730a00..9783d9553 100644 --- a/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts +++ b/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts @@ -3,7 +3,7 @@ * * This is the Linux-runnable guard for the `canonicalizePath` wiring. The * companion assertions in `repo-manager.test.ts` exercise the real - * `realpathSync.native` and are therefore `it.skipIf(win32)`, so they only run on + * `realpathSync.native` and are therefore Windows-only, so they only run on * the windows-latest matrix leg — leaving the Ubuntu gate with no coverage of the * behaviour at all. This file closes that hole by injecting the two platform * primitives and nothing else: @@ -26,8 +26,8 @@ * and the realpath case passes, which is exactly the asymmetry the fix targets: * the realpath branch never leaked, because libuv strips the prefix itself. * - * Deliberately NOT registered in `scripts/cross-platform-tests.ts`: it simulates - * Windows rather than needing it, so its home is the Ubuntu suite. + * Registered in `scripts/cross-platform-tests.ts` as well: the injected Windows + * path rules must agree with the native Windows run. */ import { describe, it, expect, vi } from 'vitest'; @@ -139,13 +139,10 @@ describe('canonicalizePath vs the `\\\\?\\` long-path prefix (#2667)', () => { }); }); -// The guard in front of `fs.rm(recursive)` in remove.ts / clean.ts. It compares -// `path.resolve` forms on both sides and deliberately does NOT canonicalize, so a -// prefixed entry stays self-consistent while a mixed-form entry fails closed. -// Pinned here because "complete the fix by stripping here too" is the tempting -// follow-up refactor, and it would widen what the recursive delete accepts. +// The storage resolver now compares canonical paths for repository-local slots. +// Equivalent prefix spellings name the same .gitnexus directory; normalization +// must still reject the repository itself, its parents, and unowned external paths. describe('assertSafeStoragePath vs the `\\\\?\\` prefix (#2667)', () => { - const itOnWindows = process.platform === 'win32' ? it : it.skip; const base: Omit = { name: 'repo', path: '\\\\?\\D:\\Projects\\repo', @@ -153,16 +150,29 @@ describe('assertSafeStoragePath vs the `\\\\?\\` prefix (#2667)', () => { lastCommit: 'deadbee', }; - itOnWindows('accepts an entry whose path and storagePath share the prefix', async () => { + it('accepts an entry whose path and storagePath share the prefix', async () => { await expect( assertSafeStoragePath({ ...base, storagePath: '\\\\?\\D:\\Projects\\repo\\.gitnexus' }), ).resolves.toBeUndefined(); }); - itOnWindows('rejects a mixed-form entry instead of deleting through it', async () => { + it('accepts mixed prefix spellings of the same repository-local slot', async () => { await expect( assertSafeStoragePath({ ...base, storagePath: 'D:\\Projects\\repo\\.gitnexus' }), - ).rejects.toThrow(); + ).resolves.toBeUndefined(); + }); + + it.each([ + 'D:\\Projects\\repo', + '\\\\?\\D:\\Projects\\repo', + 'D:\\Projects', + 'D:\\', + 'D:\\Projects\\other\\.gitnexus', + '\\\\?\\D:\\Projects\\other\\.gitnexus', + ])('rejects an unsafe deletion target despite prefix normalization: %s', async (storagePath) => { + await expect(assertSafeStoragePath({ ...base, storagePath })).rejects.toThrow( + /Refusing to remove storage path for safety/, + ); }); }); diff --git a/gitnexus/test/unit/cfg/c-cpp-visitor.test.ts b/gitnexus/test/unit/cfg/c-cpp-visitor.test.ts index a9263e37f..99f8a0043 100644 --- a/gitnexus/test/unit/cfg/c-cpp-visitor.test.ts +++ b/gitnexus/test/unit/cfg/c-cpp-visitor.test.ts @@ -1,3 +1,4 @@ +import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js'; import { describe, it, expect, vi } from 'vitest'; import { createRequire } from 'node:module'; import { requireVendoredGrammar } from '../../../src/core/tree-sitter/vendored-grammars.js'; diff --git a/gitnexus/test/unit/cfg/cfg-builder.test.ts b/gitnexus/test/unit/cfg/cfg-builder.test.ts index 1bef8cc41..6effd0dc4 100644 --- a/gitnexus/test/unit/cfg/cfg-builder.test.ts +++ b/gitnexus/test/unit/cfg/cfg-builder.test.ts @@ -116,7 +116,7 @@ describe('ControlFlowContext', () => { it('labeled break/continue resolve to the labeled loop, not the nearest', () => { const ctx = new ControlFlowContext(); - ctx.pushLoop(/*outer*/ 100, 200, 'outer'); + ctx.pushLoop(/*outer*/ 100, 200, ['outer']); ctx.pushLoop(/*inner*/ 110, 210); expect(ctx.breakTarget('outer')).toBe(200); expect(ctx.continueTarget('outer')).toBe(100); diff --git a/gitnexus/test/unit/cfg/control-dependence.test.ts b/gitnexus/test/unit/cfg/control-dependence.test.ts index e131a946e..04f96fc18 100644 --- a/gitnexus/test/unit/cfg/control-dependence.test.ts +++ b/gitnexus/test/unit/cfg/control-dependence.test.ts @@ -49,6 +49,7 @@ function mkCfg( return { filePath: 't.ts', functionStartLine: 1, + functionEndLine: blockCount, functionStartColumn: 0, entryIndex: entry, exitIndex: exit, diff --git a/gitnexus/test/unit/cfg/post-dominators.test.ts b/gitnexus/test/unit/cfg/post-dominators.test.ts index abc257b41..4f665b7b4 100644 --- a/gitnexus/test/unit/cfg/post-dominators.test.ts +++ b/gitnexus/test/unit/cfg/post-dominators.test.ts @@ -36,6 +36,7 @@ function mkCfg( return { filePath: 't.ts', functionStartLine: 1, + functionEndLine: blockCount, functionStartColumn: 0, entryIndex: entry, exitIndex: exit, diff --git a/gitnexus/test/unit/cfg/synthetic-escape.test.ts b/gitnexus/test/unit/cfg/synthetic-escape.test.ts index ee02e01c0..5f62a7227 100644 --- a/gitnexus/test/unit/cfg/synthetic-escape.test.ts +++ b/gitnexus/test/unit/cfg/synthetic-escape.test.ts @@ -51,6 +51,7 @@ function mkCfg( return { filePath: 't.ts', functionStartLine: 1, + functionEndLine: blockCount, functionStartColumn: 0, entryIndex: entry, exitIndex: exit, diff --git a/gitnexus/test/unit/cli-commands.test.ts b/gitnexus/test/unit/cli-commands.test.ts index ade958fe2..ed8031aef 100644 --- a/gitnexus/test/unit/cli-commands.test.ts +++ b/gitnexus/test/unit/cli-commands.test.ts @@ -96,7 +96,9 @@ describe('CLI commands', () => { describe('optional parser dependencies', () => { it('loads vendored grammars from vendor/ — never file: optionalDependencies (#1728) nor a node_modules copy (#2111)', async () => { const pkg = await import('../../package.json', { with: { type: 'json' } }); - const optional = pkg.default.optionalDependencies ?? {}; + const optional = + (pkg.default as { optionalDependencies?: Record }).optionalDependencies ?? + {}; expect(optional['tree-sitter-dart']).toBeUndefined(); expect(optional['tree-sitter-proto']).toBeUndefined(); expect(optional['tree-sitter-swift']).toBeUndefined(); @@ -142,8 +144,12 @@ describe('CLI commands', () => { expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-grammars.cjs'); expect(swiftPkg.default.version).toBe('0.7.1'); // No scripts.install / dependencies inside vendor/ (#836 / #1728 hygiene). - expect(swiftPkg.default.scripts?.install).toBeUndefined(); - expect(swiftPkg.default.dependencies).toBeUndefined(); + expect( + (swiftPkg.default as { scripts?: Record }).scripts?.install, + ).toBeUndefined(); + expect( + (swiftPkg.default as { dependencies?: Record }).dependencies, + ).toBeUndefined(); expect(swiftPkg.default.peerDependencies['tree-sitter']).toContain('^0.21.1'); // Swift is now unified with Dart/Proto/Kotlin/C: the grammar SOURCE is // vendored so build-tree-sitter-grammars.cjs can source-build the binding @@ -164,7 +170,9 @@ describe('CLI commands', () => { const kotlinPkg = await import('../../vendor/tree-sitter-kotlin/package.json', { with: { type: 'json' }, }); - const optional = pkg.default.optionalDependencies ?? {}; + const optional = + (pkg.default as { optionalDependencies?: Record }).optionalDependencies ?? + {}; // Kotlin is now VENDORED (like Swift/Dart/Proto), not a third-party npm // optionalDependency. Its prebuilds are GitNexus-cross-built (upstream // ships source only) and loaded from vendor/ by absolute path (#2111). @@ -172,8 +180,12 @@ describe('CLI commands', () => { expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-grammars.cjs'); expect(kotlinPkg.default.version).toBe('0.4.0'); // No scripts.install / dependencies inside vendor/ (#836 / #1728 hygiene). - expect(kotlinPkg.default.scripts?.install).toBeUndefined(); - expect(kotlinPkg.default.dependencies).toBeUndefined(); + expect( + (kotlinPkg.default as { scripts?: Record }).scripts?.install, + ).toBeUndefined(); + expect( + (kotlinPkg.default as { dependencies?: Record }).dependencies, + ).toBeUndefined(); expect(kotlinPkg.default.peerDependencies['tree-sitter']).toContain('^0.21'); }); @@ -182,15 +194,21 @@ describe('CLI commands', () => { const zigPkg = await import('../../vendor/tree-sitter-zig/package.json', { with: { type: 'json' }, }); - const optional = pkg.default.optionalDependencies ?? {}; + const optional = + (pkg.default as { optionalDependencies?: Record }).optionalDependencies ?? + {}; expect(optional['@tree-sitter-grammars/tree-sitter-zig']).toBeUndefined(); expect(Object.keys(pkg.default.overrides ?? {})).not.toContain( '@tree-sitter-grammars/tree-sitter-zig', ); expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-grammars.cjs'); expect(zigPkg.default.version).toBe('1.1.2'); - expect(zigPkg.default.scripts?.install).toBeUndefined(); - expect(zigPkg.default.dependencies).toBeUndefined(); + expect( + (zigPkg.default as { scripts?: Record }).scripts?.install, + ).toBeUndefined(); + expect( + (zigPkg.default as { dependencies?: Record }).dependencies, + ).toBeUndefined(); expect(zigPkg.default.peerDependencies['tree-sitter']).toContain('^0.21'); }); @@ -205,8 +223,12 @@ describe('CLI commands', () => { expect(pkg.default.dependencies['tree-sitter-c']).toBeUndefined(); expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-grammars.cjs'); expect(cPkg.default.version).toBe('0.21.4'); - expect(cPkg.default.scripts?.install).toBeUndefined(); - expect(cPkg.default.dependencies).toBeUndefined(); + expect( + (cPkg.default as { scripts?: Record }).scripts?.install, + ).toBeUndefined(); + expect( + (cPkg.default as { dependencies?: Record }).dependencies, + ).toBeUndefined(); }); }); diff --git a/gitnexus/test/unit/cli-group-status-format.test.ts b/gitnexus/test/unit/cli-group-status-format.test.ts index 32278dd04..962efd536 100644 --- a/gitnexus/test/unit/cli-group-status-format.test.ts +++ b/gitnexus/test/unit/cli-group-status-format.test.ts @@ -27,6 +27,29 @@ describe('formatIndexStatusCell', () => { ); }); + it.each([0, 3])( + 'renders a stale diverged row without a commits-behind count of %i', + (commitsBehind) => { + const row = { indexStale: true, commitsBehind, status: 'diverged' as const }; + expect(formatIndexStatusCell(row)).toBe('STALE (index differs from HEAD)'); + }, + ); + + it('renders an explicit behind status with its counted gap', () => { + const row = { indexStale: true, commitsBehind: 3, status: 'behind' as const }; + expect(formatIndexStatusCell(row)).toBe('STALE (3 commits behind)'); + }); + + it('renders an explicit current status as OK', () => { + const row = { indexStale: false, commitsBehind: 0, status: 'current' as const }; + expect(formatIndexStatusCell(row)).toBe('OK '); + }); + + it('preserves the fail-open cell when a diverged probe did not confirm staleness', () => { + const row = { indexStale: false, commitsBehind: 0, status: 'diverged' as const }; + expect(formatIndexStatusCell(row)).toBe('OK '); + }); + it('keeps the OK cell byte-identical, padding included', () => { expect(formatIndexStatusCell({ indexStale: false, commitsBehind: 0 })).toBe('OK '); }); diff --git a/gitnexus/test/unit/cohesion-consistency.test.ts b/gitnexus/test/unit/cohesion-consistency.test.ts index 327f0cd65..0301264e7 100644 --- a/gitnexus/test/unit/cohesion-consistency.test.ts +++ b/gitnexus/test/unit/cohesion-consistency.test.ts @@ -10,7 +10,7 @@ */ import { describe, it, expect } from 'vitest'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; -import type { GraphNode, GraphRelationship } from '../../src/core/graph/types.js'; +import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; import { processCommunities } from '../../src/core/ingestion/community-processor.js'; // ============================================================================ diff --git a/gitnexus/test/unit/community-processor.test.ts b/gitnexus/test/unit/community-processor.test.ts index 2e207a22a..2e8acb9f8 100644 --- a/gitnexus/test/unit/community-processor.test.ts +++ b/gitnexus/test/unit/community-processor.test.ts @@ -1,11 +1,14 @@ +import { spawnSync } from 'node:child_process'; import { EventEmitter } from 'node:events'; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { Worker } from 'node:worker_threads'; import { describe, it, expect, vi } from 'vitest'; +import { tsxLoaderUrl } from '../helpers/cli-entry.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; -import type { GraphNode, GraphRelationship } from '../../src/core/graph/types.js'; +import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; import { getCommunityColor, COMMUNITY_COLORS, @@ -197,9 +200,19 @@ describe('community-processor', () => { vi.resetModules(); vi.doMock('node:worker_threads', () => { class MockWorker extends EventEmitter { - constructor() { + constructor(_source?: string, options?: { workerData?: { graphologyPath?: string } }) { super(); queueMicrotask(() => { + if (options?.workerData?.graphologyPath) { + // The Graphology Leiden worker: answer as the real one would. + this.emit('message', { + ok: true, + communities: { 'fn:a': 0, 'fn:b': 0 }, + count: 1, + modularity: 0, + }); + return; + } this.emit('message', { ok: true, partition: [0, 0], modularity: Number.NaN }); }); } @@ -232,7 +245,7 @@ describe('community-processor', () => { expect(result.stats.engineRequested).toBe('icebug'); expect(result.stats.engine).toBe('graphology'); - expect(result.stats.fallbackReason).toContain('modularity'); + expect(result.stats.fallbackReason).toBe('optional icebug modularity was not finite'); expect(progress.some((message) => message.includes('falling back to Graphology'))).toBe( true, ); @@ -246,6 +259,64 @@ describe('community-processor', () => { } }); + it.each(['icebug', 'auto'] as const)( + 'preserves both fallback reasons when %s fails and Graphology times out', + async (engine) => { + vi.resetModules(); + vi.useFakeTimers(); + const icebugError = 'optional icebug could not load'; + vi.doMock('node:worker_threads', () => { + class MockWorker extends EventEmitter { + constructor(_source?: string, options?: { workerData?: { graphologyPath?: string } }) { + super(); + if (!options?.workerData?.graphologyPath) { + queueMicrotask(() => this.emit('message', { ok: false, error: icebugError })); + } + // The Graphology worker stays silent until its real deadline fires. + } + + terminate(): Promise { + return Promise.resolve(0); + } + + unref(): void {} + } + + return { Worker: MockWorker }; + }); + + try { + const { processCommunities: processCommunitiesWithMockWorker } = + await import('../../src/core/ingestion/community-processor.js'); + const graph = createKnowledgeGraph(); + graph.addNode(makeNode('fn:a', 'a')); + graph.addNode(makeNode('fn:b', 'b')); + graph.addRelationship(makeRel('rel:ab', 'fn:a', 'fn:b')); + + const pending = processCommunitiesWithMockWorker(graph, undefined, { engine }); + await vi.advanceTimersByTimeAsync(60_000); + const result = await pending; + + expect(result.stats).toMatchObject({ + engineRequested: engine, + engine: 'graphology', + fallbackReason: `${icebugError}; Graphology Leiden timeout`, + totalCommunities: 1, + modularity: 0, + nodesProcessed: 2, + }); + expect(result.memberships).toEqual([ + { nodeId: 'fn:a', communityId: 'comm_0' }, + { nodeId: 'fn:b', communityId: 'comm_0' }, + ]); + } finally { + vi.useRealTimers(); + vi.doUnmock('node:worker_threads'); + vi.resetModules(); + } + }, + ); + it('falls back before icebug worker launch for nondeterministic options', async () => { const graph = createKnowledgeGraph(); graph.addNode(makeNode('fn:a', 'a', 'Function', '/src/group/a.ts')); @@ -372,6 +443,31 @@ module.exports = { }); }); + describe('graphology Leiden timeout', () => { + it('returns the fallback and exits naturally after terminating the hanging worker', () => { + const childPath = fileURLToPath( + new URL('../helpers/leiden-timeout-child.ts', import.meta.url), + ); + // A fallback alone is not enough: the child must exit without process.exit(). + // SIGKILL bounds cleanup if a regression leaves its worker running forever. + const child = spawnSync(process.execPath, ['--import', tsxLoaderUrl(), childPath], { + encoding: 'utf8', + timeout: 10_000, + killSignal: 'SIGKILL', + }); + + expect(child.error).toBeUndefined(); + expect(child.signal).toBeNull(); + expect(child.status, child.stderr).toBe(0); + const result = JSON.parse(child.stdout); + + expect(result.fallbackReason).toBe('Graphology Leiden timeout'); + expect(result.count).toBe(1); + expect(new Set(Object.values(result.communities))).toEqual(new Set([0])); + expect(Object.keys(result.communities)).toHaveLength(105); + }, 15_000); + }); + describe('vendored Leiden partitioning', () => { // Golden values for the seeded graph below, captured from the vendored // implementation. They pin the partition, not just its shape. @@ -448,7 +544,76 @@ module.exports = { const second = await processCommunities(graph); expect(second.memberships).toEqual(first.memberships); + expect(second.rawMemberships).toEqual(first.rawMemberships); expect(second.stats.modularity).toBe(first.stats.modularity); }); }); }); + +describe('community membership integrity', () => { + it('returns empty raw and retained memberships for an empty graph', async () => { + const result = await processCommunities(createKnowledgeGraph()); + + expect(result.communities).toEqual([]); + expect(result.memberships).toEqual([]); + expect(result.rawMemberships).toEqual([]); + expect(result.stats).toMatchObject({ totalCommunities: 0, modularity: 0, nodesProcessed: 0 }); + }); + + it('retains connected members without emitting memberships for a filtered singleton', async () => { + const graph = createKnowledgeGraph(); + for (const id of ['fn:a', 'fn:b', 'fn:c', 'fn:singleton']) { + graph.addNode(makeNode(id, id.slice(3))); + } + graph.addNode(makeNode('file:target', 'target', 'File')); + graph.addRelationship(makeRel('rel:ab', 'fn:a', 'fn:b')); + graph.addRelationship(makeRel('rel:bc', 'fn:b', 'fn:c')); + graph.addRelationship(makeRel('rel:ca', 'fn:c', 'fn:a')); + // The symbol enters the projection, but its non-symbol target does not. + // Leiden therefore partitions it into a singleton, which is not emitted. + graph.addRelationship(makeRel('rel:singleton', 'fn:singleton', 'file:target')); + + const result = await processCommunities(graph, undefined, { engine: 'graphology' }); + const communityIds = new Set(result.communities.map((community) => community.id)); + + expect(result.communities).toHaveLength(1); + expect(result.communities[0].symbolCount).toBe(3); + expect(result.stats).toMatchObject({ totalCommunities: 2, nodesProcessed: 4 }); + expect(result.memberships.map((membership) => membership.nodeId).sort()).toEqual([ + 'fn:a', + 'fn:b', + 'fn:c', + ]); + expect(result.memberships.every((membership) => communityIds.has(membership.communityId))).toBe( + true, + ); + expect(result.rawMemberships?.map((membership) => membership.nodeId)).toEqual([ + 'fn:a', + 'fn:b', + 'fn:c', + 'fn:singleton', + ]); + expect( + result.rawMemberships?.filter((membership) => communityIds.has(membership.communityId)), + ).toEqual(result.memberships); + }); + + it('returns no memberships when every detected community is a filtered singleton', async () => { + const graph = createKnowledgeGraph(); + graph.addNode(makeNode('file:target', 'target', 'File')); + for (const id of ['fn:a', 'fn:b']) { + graph.addNode(makeNode(id, id.slice(3))); + graph.addRelationship(makeRel(`rel:${id}`, id, 'file:target')); + } + + const result = await processCommunities(graph, undefined, { engine: 'graphology' }); + + expect(result.communities).toEqual([]); + expect(result.memberships).toEqual([]); + expect(result.rawMemberships).toEqual([ + { nodeId: 'fn:a', communityId: 'comm_0' }, + { nodeId: 'fn:b', communityId: 'comm_1' }, + ]); + expect(result.stats).toMatchObject({ totalCommunities: 2, nodesProcessed: 2 }); + }); +}); diff --git a/gitnexus/test/unit/convex-metadata.test.ts b/gitnexus/test/unit/convex-metadata.test.ts index e486f6bc2..45bb603b3 100644 --- a/gitnexus/test/unit/convex-metadata.test.ts +++ b/gitnexus/test/unit/convex-metadata.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import Parser from 'tree-sitter'; import TypeScript from 'tree-sitter-typescript'; -import type { ParsedImport } from 'gitnexus-shared'; +import type { NodeLabel, ParsedImport } from 'gitnexus-shared'; import { extractConvexEndpointProperties } from '../../src/core/ingestion/languages/typescript/convex-endpoint-metadata.js'; import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js'; @@ -27,18 +27,19 @@ const namedImport = ( importedName: string, localName = importedName, ): ParsedImport => ({ - kind: localName === importedName ? 'named' : 'alias', + ...(localName === importedName + ? { kind: 'named' as const } + : { kind: 'alias' as const, alias: localName }), targetRaw, importedName, localName, - ...(localName === importedName ? {} : { alias: localName }), }); function extract( source: string, imports: readonly ParsedImport[], isExported = true, - nodeLabel = 'Const', + nodeLabel: NodeLabel = 'Const', definitionType = 'export_statement', ) { return extractConvexEndpointProperties({ diff --git a/gitnexus/test/unit/cpp-ue-preprocessor.test.ts b/gitnexus/test/unit/cpp-ue-preprocessor.test.ts index 087c5ba2a..dbbc0dcfb 100644 --- a/gitnexus/test/unit/cpp-ue-preprocessor.test.ts +++ b/gitnexus/test/unit/cpp-ue-preprocessor.test.ts @@ -211,7 +211,7 @@ describe('stripUeMacros — tree-sitter extraction (end-to-end)', () => { */ function extractClassNames(source: string): string[] { const parser = new Parser(); - parser.setLanguage(CPP as unknown as Parser.Language); + parser.setLanguage(CPP); const tree = parser.parse(source); const names: string[] = []; const stack: Parser.SyntaxNode[] = [tree.rootNode]; @@ -249,7 +249,7 @@ describe('stripUeMacros — tree-sitter extraction (end-to-end)', () => { const src = `UCLASS()\nclass BRAWLUI_API UMyClass : public UObject\n{\n GENERATED_BODY()\n public:\n void Run();\n};`; const out = stripUeMacros(src); const parser = new Parser(); - parser.setLanguage(CPP as unknown as Parser.Language); + parser.setLanguage(CPP); const tree = parser.parse(out); const stack: Parser.SyntaxNode[] = [tree.rootNode]; let runLine: number | undefined; diff --git a/gitnexus/test/unit/cross-platform-shard.test.ts b/gitnexus/test/unit/cross-platform-shard.test.ts index 66ea6a430..81722c941 100644 --- a/gitnexus/test/unit/cross-platform-shard.test.ts +++ b/gitnexus/test/unit/cross-platform-shard.test.ts @@ -13,6 +13,7 @@ * reproduce the outage exactly. */ +import { readFileSync } from 'node:fs'; import { describe, it, expect } from 'vitest'; import { shardFiles, @@ -22,7 +23,12 @@ import { } from '../../scripts/cross-platform-shard.js'; import { ALL_CROSS_PLATFORM } from '../../scripts/cross-platform-tests.js'; -const SHARD_TOTAL = 3; +// Replay the actual CI partition, including shard-count changes as the suite grows. +const workflow = readFileSync( + new URL('../../../.github/workflows/ci-tests.yml', import.meta.url), + 'utf8', +); +const SHARD_TOTAL = Number(workflow.match(/^\s+TOTAL=(\d+)\b/m)?.[1]); /** Every shard of a split, as file lists. */ const allShards = (files: readonly string[], total: number): readonly (readonly string[])[] => @@ -54,7 +60,7 @@ describe('cross-platform shard partition', () => { 'test/unit/incremental-index-extension-dml-gate.test.ts', ].map((file) => shards.findIndex((files) => files.includes(file))); expect(heavyweightLocations).not.toContain(-1); - expect(new Set(heavyweightLocations).size).toBe(SHARD_TOTAL); + expect(new Set(heavyweightLocations).size).toBe(heavyweightLocations.length); expect( shards.filter( (s) => diff --git a/gitnexus/test/unit/csv-escaping.test.ts b/gitnexus/test/unit/csv-escaping.test.ts index 6abb31511..2e8d8a36a 100644 --- a/gitnexus/test/unit/csv-escaping.test.ts +++ b/gitnexus/test/unit/csv-escaping.test.ts @@ -15,16 +15,25 @@ import { // ─── escapeCSVField ────────────────────────────────────────────────── describe('escapeCSVField', () => { - it('returns empty quoted string for null', () => { - expect(escapeCSVField(null)).toBe('""'); + it('returns an unquoted NULL field for null', () => { + expect(escapeCSVField(null)).toBe(''); }); - it('returns empty quoted string for undefined', () => { - expect(escapeCSVField(undefined)).toBe('""'); + it('returns an unquoted NULL field for undefined', () => { + expect(escapeCSVField(undefined)).toBe(''); }); - it('returns quoted empty string for empty input', () => { - expect(escapeCSVField('')).toBe('""'); + it('preserves the legacy NULL meaning of empty input', () => { + expect(escapeCSVField('')).toBe(''); + }); + + it('returns a NULL field when sanitization removes the entire value', () => { + expect(escapeCSVField('\x00\x01')).toBe(''); + }); + + it('keeps whitespace and zero as quoted non-null values', () => { + expect(escapeCSVField(' ')).toBe('" "'); + expect(escapeCSVField(0)).toBe('"0"'); }); it('wraps simple string in quotes', () => { diff --git a/gitnexus/test/unit/dart-package-imports.test.ts b/gitnexus/test/unit/dart-package-imports.test.ts index 0e33bc463..c127838d1 100644 --- a/gitnexus/test/unit/dart-package-imports.test.ts +++ b/gitnexus/test/unit/dart-package-imports.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { constants } from 'node:fs'; import { mkdtemp, @@ -463,47 +463,52 @@ describe.skipIf(!pubspecWalkAnchored())('Dart pubspec package discovery', () => }, ); - it('does not follow a listed directory replaced by a symlink on macOS', async () => { - if (process.platform !== 'darwin') return; - const outside = await fixture({ - 'pubspec.yaml': 'name: foreign', - 'nested/pubspec.yaml': 'name: foreign', - }); - const root = await fixture({ - 'pkg/pubspec.yaml': 'name: data', - 'pkg/nested/pubspec.yaml': 'name: nested_data', - }); - const pkg = path.join(root, 'pkg'); - await expect( - loadDartPackageConfig(root, { - beforeEntryOpen: async (relative) => { - if (relative !== 'pkg') return; - await rename(pkg, path.join(root, 'pkg-moved')); - await symlink(outside, pkg, 'dir'); - }, - }), - ).rejects.toThrow(/Dart pubspec discovery failed \((read-directory|read-pubspec)\)/); - }); + it.skipIf(process.platform !== 'darwin')( + 'does not follow a listed directory replaced by a symlink on macOS', + async () => { + const outside = await fixture({ + 'pubspec.yaml': 'name: foreign', + 'nested/pubspec.yaml': 'name: foreign', + }); + const root = await fixture({ + 'pkg/pubspec.yaml': 'name: data', + 'pkg/nested/pubspec.yaml': 'name: nested_data', + }); + const pkg = path.join(root, 'pkg'); + await expect( + loadDartPackageConfig(root, { + beforeEntryOpen: async (relative) => { + if (relative !== 'pkg') return; + await rename(pkg, path.join(root, 'pkg-moved')); + await symlink(outside, pkg, 'dir'); + }, + }), + ).rejects.toThrow(/Dart pubspec discovery failed \((read-directory|read-pubspec)\)/); + }, + ); - it('reads listed manifests from the opened directory inode after that path is replaced', async () => { - if (descriptorEntryPath(0, 'pubspec.yaml') === null) return; - const outside = await fixture({ - 'pubspec.yaml': 'name: foreign', - 'nested/pubspec.yaml': 'name: foreign', - }); + // Linux lists the opened inode through /proc/self/fd/N. macOS cannot list a + // descriptor (opendir on /dev/fd/N is ENOTDIR), so it lists the path and + // re-checks the pinned chain afterwards; the swap must fail that check. + it('lists the opened directory, or refuses, when its path is replaced before listing', async () => { + const outside = await fixture({ 'pubspec.yaml': 'name: foreign' }); const root = await fixture({ 'pkg/pubspec.yaml': 'name: data', 'pkg/nested/pubspec.yaml': 'name: nested_data', }); const pkg = path.join(root, 'pkg'); - const loaded = await loadDartPackageConfig(root, { - beforeEntryOpen: async (relative) => { + const load = loadDartPackageConfig(root, { + beforeDirectoryList: async (relative) => { if (relative !== 'pkg') return; await rename(pkg, path.join(root, 'pkg-moved')); await symlink(outside, pkg, 'dir'); }, }); - expect(loaded.packages).toEqual( + if (process.platform === 'darwin') { + await expect(load).rejects.toThrow('Dart pubspec discovery failed (read-directory): pkg'); + return; + } + expect((await load).packages).toEqual( new Map([ ['data', 'pkg/lib'], ['nested_data', 'pkg/nested/lib'], @@ -511,26 +516,55 @@ describe.skipIf(!pubspecWalkAnchored())('Dart pubspec package discovery', () => ); }); - it('lists the opened directory inode after its path becomes a symlink', async () => { - const listingRoot = descriptorDirectoryPath(0); - if (listingRoot === null) return; - const outside = await fixture({ 'outside.txt': 'out' }); - const root = await fixture({}); - const real = path.join(root, 'real'); - await mkdir(real); - await writeFile(path.join(real, 'inside.txt'), 'in'); - const handle = await open(real, directoryOpenFlags()); - try { - const listed = descriptorDirectoryPath(handle.fd); - if (listed === null) throw new Error('descriptor listing path missing'); - await rename(real, path.join(root, 'real-moved')); - await symlink(outside, real, process.platform === 'win32' ? 'junction' : 'dir'); - await expect(readDirectoryNoFollow(real)).rejects.toThrow(); - expect(await readdir(listed)).toEqual(['inside.txt']); - } finally { - await handle.close(); - } - }); + it.skipIf(descriptorEntryPath(0, 'pubspec.yaml') === null)( + 'reads listed manifests from the opened directory inode after that path is replaced', + async () => { + const outside = await fixture({ + 'pubspec.yaml': 'name: foreign', + 'nested/pubspec.yaml': 'name: foreign', + }); + const root = await fixture({ + 'pkg/pubspec.yaml': 'name: data', + 'pkg/nested/pubspec.yaml': 'name: nested_data', + }); + const pkg = path.join(root, 'pkg'); + const loaded = await loadDartPackageConfig(root, { + beforeEntryOpen: async (relative) => { + if (relative !== 'pkg') return; + await rename(pkg, path.join(root, 'pkg-moved')); + await symlink(outside, pkg, 'dir'); + }, + }); + expect(loaded.packages).toEqual( + new Map([ + ['data', 'pkg/lib'], + ['nested_data', 'pkg/nested/lib'], + ]), + ); + }, + ); + + it.skipIf(descriptorDirectoryPath(0) === null)( + 'lists the opened directory inode after its path becomes a symlink', + async () => { + const outside = await fixture({ 'outside.txt': 'out' }); + const root = await fixture({}); + const real = path.join(root, 'real'); + await mkdir(real); + await writeFile(path.join(real, 'inside.txt'), 'in'); + const handle = await open(real, directoryOpenFlags()); + try { + const listed = descriptorDirectoryPath(handle.fd); + if (listed === null) throw new Error('descriptor listing path missing'); + await rename(real, path.join(root, 'real-moved')); + await symlink(outside, real, process.platform === 'win32' ? 'junction' : 'dir'); + await expect(readDirectoryNoFollow(real)).rejects.toThrow(); + expect(await readdir(listed)).toEqual(['inside.txt']); + } finally { + await handle.close(); + } + }, + ); }); describe('directory symlink refusal', () => { @@ -549,13 +583,18 @@ describe('directory symlink refusal', () => { await expect(readDirectoryNoFollow(link)).rejects.toThrow(); }); - it.skipIf(pubspecWalkAnchored())( - 'does not discover packages when the walk cannot honor no-follow', - async () => { - const root = await mkdtemp(path.join(os.tmpdir(), 'gitnexus-dart-pubspec-')); - roots.push(root); - await writeFile(path.join(root, 'pubspec.yaml'), 'name: app\n'); + it('does not discover packages when the walk cannot honor no-follow', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'gitnexus-dart-pubspec-')); + roots.push(root); + await writeFile(path.join(root, 'pubspec.yaml'), 'name: app\n'); + // Exercise the unsupported-platform branch on every host. The real + // capability check must refuse before attempting any directory walk. + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + try { + expect(pubspecWalkAnchored()).toBe(false); expect((await loadDartPackageConfig(root)).packages.size).toBe(0); - }, - ); + } finally { + platform.mockRestore(); + } + }); }); diff --git a/gitnexus/test/unit/detect-changes-format.test.ts b/gitnexus/test/unit/detect-changes-format.test.ts index 18f21eeeb..32e3a57f4 100644 --- a/gitnexus/test/unit/detect-changes-format.test.ts +++ b/gitnexus/test/unit/detect-changes-format.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { formatDetectChangesResult } from '../../src/cli/detect-changes-format.js'; import { setCliLanguage } from '../../src/cli/i18n/index.js'; +import { parseDiffHunks } from '../../src/storage/git.js'; describe('formatDetectChangesResult — zero-symbol honesty (#3131)', () => { beforeEach(() => { @@ -62,6 +63,49 @@ describe('formatDetectChangesResult — zero-symbol honesty (#3131)', () => { expect(text).toBe('No changes detected.'); }); + it('explains unmapped source files without claiming a query failed or retry will repair the index', () => { + const text = formatDetectChangesResult({ + partial: true, + unmapped_files: ['src/index-lock.ts'], + summary: { changed_count: 0, affected_count: 0, changed_files: 1, risk_level: 'unknown' }, + }); + expect(text).toContain('PARTIAL RESULT'); + expect(text).toContain('src/index-lock.ts'); + expect(text).toMatch(/rebuild/i); + expect(text).not.toMatch(/queries failed|No changes detected|no indexed symbols overlap/i); + }); + + it('escapes Git-decoded terminal controls while leaving structured paths intact', () => { + const diff = [ + 'diff --git "a/evil\\033]52;c;VEVTVA==\\007.ts" "b/evil\\033]52;c;VEVTVA==\\007.ts"', + 'old mode 100644', + 'new mode 100755', + ].join('\n'); + const paths = parseDiffHunks(diff).map((file) => file.filePath); + expect(paths[0]).toContain('\u001b'); + const text = formatDetectChangesResult({ + partial: true, + unmapped_files: paths, + summary: { changed_count: 0, changed_files: 1, risk_level: 'unknown' }, + }); + expect(text).toContain('\\u001b]52;c;VEVTVA==\\u0007.ts'); + expect(text).not.toContain('\u001b'); + expect(text).not.toContain('\u0007'); + expect(paths[0]).toContain('\u0007'); + }); + + it('leads a populated summary with the incomplete source-mapping explanation', () => { + const text = formatDetectChangesResult({ + partial: true, + unmapped_files: ['other.ts'], + summary: { changed_count: 1, changed_files: 2, affected_count: 0, risk_level: 'unknown' }, + changed_symbols: [{ type: 'Function', name: 'known', filePath: 'code.py' }], + }); + expect(text.indexOf('other.ts')).toBeLessThan(text.indexOf('Changes:')); + expect(text).toContain('known'); + expect(text).toContain('unknown'); + }); + it('localizes the production clean-tree payload that carries English summary.message', () => { setCliLanguage('zh-CN'); const text = formatDetectChangesResult({ diff --git a/gitnexus/test/unit/detect-changes-hunk-scale.test.ts b/gitnexus/test/unit/detect-changes-hunk-scale.test.ts index a9e1035fc..b7f472dea 100644 --- a/gitnexus/test/unit/detect-changes-hunk-scale.test.ts +++ b/gitnexus/test/unit/detect-changes-hunk-scale.test.ts @@ -133,11 +133,11 @@ interface DetectChangesResult { partial?: boolean; } -async function runDetectChanges(): Promise { +async function runDetectChanges(scope = 'unstaged'): Promise { const backend = new LocalBackend(); await backend.init(); return (await backend.callTool('detect_changes', { - scope: 'unstaged', + scope, repo: 'hunk-scale-repo', })) as DetectChangesResult; } @@ -238,6 +238,135 @@ beforeEach(() => { }); describe('#2915 detect_changes hunk scaling', () => { + it('withholds a low-risk verdict when a changed source file maps to no symbols', async () => { + const result = await detectChangesForCodePy('def vanished():\n return 2\n'); + expect(result.summary.changed_files).toBe(1); + expect(result.summary.changed_count).toBe(0); + expect(result.summary.risk_level).toBe('unknown'); + expect(result.partial).toBe(true); + expect(result).toHaveProperty('unmapped_files', ['code.py']); + }); + + it.each(['notes.txt', 'README.md', 'config.json', 'config.yaml'])( + 'keeps ordinary non-source changes in %s measurable with zero symbols', + async (filePath) => { + const repoDir = makeRepo([filePath], 2); + writeFileSync(path.join(repoDir, filePath), 'changed\nline 2\n'); + registerRepo(repoDir); + const result = await runDetectChanges(); + expect(result.summary.risk_level).toBe('low'); + expect(result.partial).toBeUndefined(); + }, + ); + + it.each(['.html', '.htm', '.ejs', '.hbs', '.blade.php'])( + 'withholds ranked risk when a changed %s template maps to no symbols', + async (extension) => { + const filePath = `views/orders${extension}`; + const repoDir = makeRepo([filePath], 2); + writeFileSync(path.join(repoDir, filePath), '
\n'); + registerRepo(repoDir); + const result = await runDetectChanges(); + expect(result.summary).toMatchObject({ + changed_files: 1, + changed_count: 0, + risk_level: 'unknown', + }); + expect(result.partial).toBe(true); + expect(result).toHaveProperty('unmapped_files', [filePath]); + }, + ); + + it.each(['.html', '.htm', '.ejs', '.hbs', '.blade.php'])( + 'recognizes the template side of a pure %s-to-text rename', + async (extension) => { + const filePath = `orders${extension}`; + const repoDir = makeRepo([filePath], 2); + execFileSync('git', ['mv', filePath, 'orders.txt'], { cwd: repoDir }); + registerRepo(repoDir); + const result = await runDetectChanges('staged'); + expect(result.summary).toMatchObject({ + changed_files: 1, + changed_count: 0, + risk_level: 'unknown', + }); + expect(result.partial).toBe(true); + expect(result).toHaveProperty('unmapped_files', ['orders.txt']); + }, + ); + + it('keeps mapped template changes measurable', async () => { + const filePath = 'views/orders.blade.php'; + const repoDir = makeRepo([filePath], 2); + writeFileSync(path.join(repoDir, filePath), '
\nline 2\n'); + registerRepo(repoDir); + mockSymbolRows([{ name: 'orders', filePath, startLine: 0, endLine: 1 }]); + const result = await runDetectChanges(); + expect(result.summary).toMatchObject({ + changed_files: 1, + changed_count: 1, + risk_level: 'low', + }); + expect(result.changed_symbols.map((symbol) => symbol.name)).toEqual(['orders']); + expect(result.partial).toBeUndefined(); + expect(result).not.toHaveProperty('unmapped_files'); + }); + + it('withholds a low-risk verdict for an unmapped source rename without hunks', async () => { + const repoDir = makeRepo(['code.py'], 2); + execFileSync('git', ['mv', 'code.py', 'renamed.py'], { cwd: repoDir }); + registerRepo(repoDir); + const result = await runDetectChanges('staged'); + expect(result.summary.changed_files).toBe(1); + expect(result.summary.risk_level).toBe('unknown'); + expect(result.partial).toBe(true); + expect(result).toHaveProperty('unmapped_files', ['renamed.py']); + }); + + it('recognizes the source side of a pure source-to-text rename', async () => { + const repoDir = makeRepo(['code.py'], 2); + execFileSync('git', ['mv', 'code.py', 'code.txt'], { cwd: repoDir }); + registerRepo(repoDir); + const result = await runDetectChanges('staged'); + expect(result.summary).toMatchObject({ + changed_files: 1, + changed_count: 0, + risk_level: 'unknown', + }); + expect(result.partial).toBe(true); + expect(result).toHaveProperty('unmapped_files', ['code.txt']); + }); + + it.each(['.jcl', '.job', '.proc', '.copybook', '.JCL', '.COPYBOOK'])( + 'withholds ranked risk for an unmapped ingestion-supported %s rename', + async (extension) => { + const repoDir = makeRepo([`source${extension}`], 2); + execFileSync('git', ['mv', `source${extension}`, `renamed${extension}`], { cwd: repoDir }); + registerRepo(repoDir); + const result = await runDetectChanges('staged'); + expect(result.summary.risk_level).toBe('unknown'); + expect(result.partial).toBe(true); + expect(result).toHaveProperty('unmapped_files', [`renamed${extension}`]); + }, + ); + + it('retains mapped symbols while withholding ranked risk for an unmapped source', async () => { + const repoDir = makeRepo(['code.py', 'other.ts'], 2); + writeFileSync(path.join(repoDir, 'code.py'), 'changed\nline 2\n'); + writeFileSync(path.join(repoDir, 'other.ts'), 'changed\nline 2\n'); + registerRepo(repoDir); + mockSymbolRows([{ name: 'known', startLine: 0, endLine: 1 }]); + const result = await runDetectChanges(); + expect(result.summary).toMatchObject({ + changed_files: 2, + changed_count: 1, + risk_level: 'unknown', + }); + expect(result.changed_symbols.map((symbol) => symbol.name)).toEqual(['known']); + expect(result.partial).toBe(true); + expect(result).toHaveProperty('unmapped_files', ['other.ts']); + }); + it('sends the same query for a 3,000-hunk diff as for a 1-hunk diff', async () => { const oneHunkRepo = makeRepo(['big.txt'], 12000); editEveryNthLine(oneHunkRepo, 'big.txt', 12000, 12000); diff --git a/gitnexus/test/unit/embedding-recovery-race.test.ts b/gitnexus/test/unit/embedding-recovery-race.test.ts new file mode 100644 index 000000000..145f860ec --- /dev/null +++ b/gitnexus/test/unit/embedding-recovery-race.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { readEmbeddingRecovery } from '../../src/storage/embedding-recovery.js'; + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + constants: { ...actual.constants }, + lstatSync: vi.fn(actual.lstatSync), + openSync: vi.fn(actual.openSync), + fstatSync: vi.fn(actual.fstatSync), + readFileSync: vi.fn(actual.readFileSync), + closeSync: vi.fn(actual.closeSync), + }; +}); + +const actual = await vi.importActual('node:fs'); +const stagingFile = 'lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46'; +const receipt = { + embeddingCheckpoint: { + kind: 'interrupted', + at: '2026-10-03T12:00:00.000Z', + nodesProcessed: 1, + totalNodes: 2, + chunksProcessed: 1, + model: 'test-model', + dimensions: 2, + provider: 'local', + recovery: { stagingFile, schemaFingerprint: 'test-schema', unsafeNodeIds: [] }, + }, +}; + +let dir: string; +let metadataPath: string; +beforeEach(() => { + vi.mocked(fs.lstatSync).mockImplementation(actual.lstatSync); + vi.mocked(fs.openSync).mockImplementation(actual.openSync); + vi.mocked(fs.fstatSync).mockImplementation(actual.fstatSync); + vi.mocked(fs.readFileSync).mockImplementation(actual.readFileSync); + vi.mocked(fs.closeSync).mockImplementation(actual.closeSync); + Object.assign(fs.constants, actual.constants); + vi.clearAllMocks(); + dir = actual.mkdtempSync(path.join(os.tmpdir(), 'gnx-recovery-metadata-race-')); + metadataPath = path.join(dir, 'gitnexus.json'); + actual.writeFileSync(path.join(dir, stagingFile), 'stage'); +}); +afterEach(() => actual.rmSync(dir, { recursive: true, force: true })); + +describe('readEmbeddingRecovery metadata races', () => { + it('does not read replacement metadata after checking the original file', () => { + actual.writeFileSync(metadataPath, JSON.stringify({ embeddingCheckpoint: null })); + let replaced = false; + vi.mocked(fs.lstatSync).mockImplementation((...args) => { + const stat = actual.lstatSync(...args); + if (args[0] === metadataPath && !replaced) { + replaced = true; + actual.renameSync(metadataPath, path.join(dir, 'original-metadata.json')); + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + } + return stat; + }); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(replaced).toBe(true); + const descriptor = vi.mocked(fs.openSync).mock.results[0]?.value; + expect(typeof descriptor).toBe('number'); + expect(fs.readFileSync).toHaveBeenCalledWith(descriptor, 'utf8'); + expect(fs.closeSync).toHaveBeenCalledWith(descriptor); + }); + + it('rejects a file replaced between opening and checking its identity', () => { + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + let replaced = false; + vi.mocked(fs.openSync).mockImplementation((...args) => { + const descriptor = actual.openSync(...args); + if (args[0] === metadataPath && !replaced) { + replaced = true; + actual.renameSync(metadataPath, path.join(dir, 'original-metadata.json')); + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + } + return descriptor; + }); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(replaced).toBe(true); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it('reads regular metadata when no-follow opens are unavailable', () => { + Object.assign(fs.constants, { O_NOFOLLOW: 0, O_NONBLOCK: 0 }); + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + + expect(readEmbeddingRecovery(dir)?.stagingFile).toBe(stagingFile); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it('refuses unverifiable file identity when no-follow opens are unavailable', () => { + Object.assign(fs.constants, { O_NOFOLLOW: 0, O_NONBLOCK: 0 }); + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + vi.mocked(fs.fstatSync).mockImplementation((...args) => { + const stat = actual.fstatSync(...args); + Object.defineProperty(stat, 'ino', { value: 0n }); + return stat; + }); + vi.mocked(fs.lstatSync).mockImplementation((...args) => { + const stat = actual.lstatSync(...args); + Object.defineProperty(stat, 'ino', { value: 0n }); + return stat; + }); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it('rejects a symlink introduced before opening without no-follow support', () => { + Object.assign(fs.constants, { O_NOFOLLOW: 0, O_NONBLOCK: 0 }); + actual.writeFileSync(metadataPath, JSON.stringify({ embeddingCheckpoint: null })); + const target = path.join(dir, 'foreign-metadata.json'); + actual.writeFileSync(target, JSON.stringify(receipt)); + let replaced = false; + vi.mocked(fs.openSync).mockImplementation((...args) => { + if (args[0] === metadataPath && !replaced) { + replaced = true; + actual.rmSync(metadataPath); + actual.symlinkSync(target, metadataPath); + } + return actual.openSync(...args); + }); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(replaced).toBe(true); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it('never falls back from a dangling primary symlink without no-follow support', () => { + Object.assign(fs.constants, { O_NOFOLLOW: 0, O_NONBLOCK: 0 }); + actual.symlinkSync(path.join(dir, 'missing-metadata.json'), metadataPath); + actual.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(receipt)); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(fs.readFileSync).not.toHaveBeenCalled(); + }); + + it('rejects a symlinked legacy receipt when the primary is absent', () => { + Object.assign(fs.constants, { O_NOFOLLOW: 0, O_NONBLOCK: 0 }); + const target = path.join(dir, 'foreign-metadata.json'); + actual.writeFileSync(target, JSON.stringify(receipt)); + actual.symlinkSync(target, path.join(dir, 'meta.json')); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it('closes a descriptor when fstat fails without falling back to legacy metadata', () => { + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + actual.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(receipt)); + vi.mocked(fs.fstatSync).mockImplementationOnce(() => { + throw Object.assign(new Error('stat failed'), { code: 'EIO' }); + }); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it('closes a descriptor when JSON parsing fails without falling back', () => { + actual.writeFileSync(metadataPath, '{'); + actual.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(receipt)); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it.skipIf(process.platform === 'win32')('rejects a substituted FIFO without blocking', () => { + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + let replaced = false; + vi.mocked(fs.openSync).mockImplementation((...args) => { + if (args[0] === metadataPath && !replaced) { + replaced = true; + actual.rmSync(metadataPath); + execFileSync('mkfifo', [metadataPath]); + } + return actual.openSync(...args); + }); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(replaced).toBe(true); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); +}); diff --git a/gitnexus/test/unit/embedding-recovery.test.ts b/gitnexus/test/unit/embedding-recovery.test.ts new file mode 100644 index 000000000..a8f1064a0 --- /dev/null +++ b/gitnexus/test/unit/embedding-recovery.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + readEmbeddingRecovery, + resolveEmbeddingRecovery, +} from '../../src/storage/embedding-recovery.js'; + +const stagingFile = 'lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46'; +const familySuffixes = [ + '', + '.wal', + '.shadow', + '.wal.checkpoint', + '.lock', + '.checkpoint.intent.lock', + '.checkpoint.apply.lock', +]; +const checkpoint = () => ({ + kind: 'interrupted', + at: '2026-10-03T12:00:00.000Z', + nodesProcessed: 1, + totalNodes: 3, + chunksProcessed: 2, + model: 'test-model', + dimensions: 2, + provider: 'local', + pendingNodeIds: ['active'], + recovery: { + stagingFile, + schemaFingerprint: 'test-schema', + unsafeNodeIds: ['active', 'incomplete-restore'], + }, +}); + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(path.join(os.tmpdir(), 'gnx-embedding-recovery-')); + writeFileSync(path.join(dir, stagingFile), 'stage'); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +describe('resolveEmbeddingRecovery', () => { + it('resolves the exact staged generation and carries all unsafe node IDs', () => { + expect(resolveEmbeddingRecovery(dir, checkpoint())).toEqual({ + ...checkpoint().recovery, + dbPath: path.join(dir, stagingFile), + familyFiles: familySuffixes.map((suffix) => stagingFile + suffix), + }); + }); + + it.each([ + '../lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46', + '/tmp/lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46', + 'branches/foreign/lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46', + '..\\lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46', + 'lbug', + 'lbug.new', + 'lbug.staging.orphan', + `${stagingFile}.wal`, + 'lbug.staging.00000000-0000-4000-8000-000000000000', + ])('rejects a foreign, unrecognized or missing generation: %s', (filename) => { + const marker = checkpoint(); + marker.recovery.stagingFile = filename; + expect(resolveEmbeddingRecovery(dir, marker)).toBeUndefined(); + }); + + it.each([ + null, + [], + { recovery: checkpoint().recovery }, + { ...checkpoint(), kind: 'partial' }, + { ...checkpoint(), kind: 'unverified-count' }, + { ...checkpoint(), kind: 'unknown' }, + { ...checkpoint(), at: 'invalid-time' }, + { ...checkpoint(), nodesProcessed: -1 }, + { ...checkpoint(), nodesProcessed: 4 }, + { ...checkpoint(), totalNodes: 1.5 }, + { ...checkpoint(), chunksProcessed: Number.NaN }, + { ...checkpoint(), model: '' }, + { ...checkpoint(), provider: null }, + { ...checkpoint(), dimensions: 0 }, + { ...checkpoint(), pendingNodeIds: [42] }, + { ...checkpoint(), pendingNodeIds: ['unexcluded'] }, + { ...checkpoint(), recovery: { ...checkpoint().recovery, schemaFingerprint: '' } }, + { ...checkpoint(), recovery: { ...checkpoint().recovery, unsafeNodeIds: undefined } }, + { ...checkpoint(), recovery: { ...checkpoint().recovery, unsafeNodeIds: [''] } }, + ])('rejects malformed or incompatible checkpoint shape %#', (marker) => { + expect(resolveEmbeddingRecovery(dir, marker)).toBeUndefined(); + }); + + it('accepts a durable restored stage even before a new embedding window completes', () => { + expect( + resolveEmbeddingRecovery(dir, { + ...checkpoint(), + nodesProcessed: 0, + chunksProcessed: 0, + pendingNodeIds: [], + }), + ).toBeDefined(); + }); + + it('rejects a directory in place of the staged database', () => { + rmSync(path.join(dir, stagingFile)); + mkdirSync(path.join(dir, stagingFile)); + expect(resolveEmbeddingRecovery(dir, checkpoint())).toBeUndefined(); + }); + + it.each(familySuffixes)('rejects a symlink in the staged family: %s', (suffix) => { + const target = path.join(dir, 'external-file'); + writeFileSync(target, 'external'); + const candidate = path.join(dir, stagingFile + suffix); + rmSync(candidate, { force: true }); + symlinkSync(target, candidate); + expect(resolveEmbeddingRecovery(dir, checkpoint())).toBeUndefined(); + }); + + it.each(familySuffixes.slice(1))('rejects a dangling staged sidecar symlink: %s', (suffix) => { + symlinkSync(path.join(dir, 'missing'), path.join(dir, stagingFile + suffix)); + expect(resolveEmbeddingRecovery(dir, checkpoint())).toBeUndefined(); + }); + + it.each(familySuffixes.slice(1))('rejects a non-file staged sidecar: %s', (suffix) => { + mkdirSync(path.join(dir, stagingFile + suffix)); + expect(resolveEmbeddingRecovery(dir, checkpoint())).toBeUndefined(); + }); +}); + +describe('readEmbeddingRecovery', () => { + const writeMeta = (filename: string, marker: unknown = checkpoint()): void => { + writeFileSync(path.join(dir, filename), JSON.stringify({ embeddingCheckpoint: marker })); + }; + + it('prefers the primary metadata file over a stale legacy reference', () => { + writeMeta('gitnexus.json'); + writeMeta('meta.json', null); + expect(readEmbeddingRecovery(dir)?.stagingFile).toBe(stagingFile); + }); + + it('loads the legacy mirror only when the primary metadata file is absent', () => { + writeMeta('meta.json'); + expect(readEmbeddingRecovery(dir)?.stagingFile).toBe(stagingFile); + }); + + it('does not resurrect a legacy reference when the primary file is malformed', () => { + writeFileSync(path.join(dir, 'gitnexus.json'), '{'); + writeMeta('meta.json'); + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + }); + + it('does not fall back from valid primary metadata with no recovery reference', () => { + writeMeta('gitnexus.json', null); + writeMeta('meta.json'); + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + }); + + it('rejects symlinked primary metadata rather than reading a foreign receipt', () => { + writeMeta('meta.json'); + symlinkSync(path.join(dir, 'meta.json'), path.join(dir, 'gitnexus.json')); + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + }); + + it('resolves branch-slot provenance within that slot despite a flat storagePath', () => { + const branchSlot = path.join(dir, 'branches', 'feature'); + mkdirSync(branchSlot, { recursive: true }); + writeFileSync(path.join(branchSlot, stagingFile), 'branch-stage'); + writeFileSync( + path.join(branchSlot, 'gitnexus.json'), + JSON.stringify({ storagePath: dir, embeddingCheckpoint: checkpoint() }), + ); + expect(readEmbeddingRecovery(branchSlot)?.dbPath).toBe(path.join(branchSlot, stagingFile)); + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/embedding-restore-spill.test.ts b/gitnexus/test/unit/embedding-restore-spill.test.ts index b11695110..7f0fd8a8b 100644 --- a/gitnexus/test/unit/embedding-restore-spill.test.ts +++ b/gitnexus/test/unit/embedding-restore-spill.test.ts @@ -39,7 +39,7 @@ function row(id: string, fill: number, hash = `hash-${id}`) { } describe('embedding-restore-spill (#3306)', () => { - const spills: Array<{ path: string }> = []; + const spills: Array[0]> = []; afterEach(() => { for (const spill of spills) disposeEmbeddingSpill(spill); spills.length = 0; @@ -170,7 +170,7 @@ describe('embedding-restore-spill (#3306)', () => { const shortPath = path.join(os.tmpdir(), `gitnexus-embed-restore-short-${process.pid}.bin`); writeFileSync(shortPath, Buffer.from('NOPE')); - spills.push({ path: shortPath }); + spills.push({ path: shortPath, dims: DIMS, rowCount: 1 }); expect(() => readSpillVectors({ path: shortPath, dims: DIMS, rowCount: 1 }, [0])).toThrow( /invalid embedding spill header/, ); @@ -181,7 +181,7 @@ describe('embedding-restore-spill (#3306)', () => { badMagic.writeUInt32LE(DIMS, 5); const badPath = path.join(os.tmpdir(), `gitnexus-embed-restore-bad-${process.pid}.bin`); writeFileSync(badPath, badMagic); - spills.push({ path: badPath }); + spills.push({ path: badPath, dims: DIMS, rowCount: 1 }); expect(() => readSpillVectors({ path: badPath, dims: DIMS, rowCount: 1 }, [0])).toThrow( /invalid embedding spill header/, ); diff --git a/gitnexus/test/unit/embedding-sidecar-client.test.ts b/gitnexus/test/unit/embedding-sidecar-client.test.ts index 0bdf5745f..7e6943419 100644 --- a/gitnexus/test/unit/embedding-sidecar-client.test.ts +++ b/gitnexus/test/unit/embedding-sidecar-client.test.ts @@ -3,6 +3,8 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; +import type { ForkImpl } from '../../src/core/embeddings/embedding-sidecar-client.js'; import type { ChildProcess } from 'node:child_process'; import type { SidecarRequest, @@ -57,7 +59,7 @@ describe('embedding sidecar client', () => { const hostPlatform = process.platform; const hostArch = process.arch; - let forkMock: ReturnType; + let forkMock: Mock; let children: FakeChild[]; beforeEach(async () => { @@ -66,7 +68,7 @@ describe('embedding sidecar client', () => { Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); Object.defineProperty(process, 'arch', { value: 'x64', configurable: true }); children = []; - forkMock = vi.fn((_script: string, _args: string[], _opts: unknown) => { + forkMock = vi.fn((_script, _args, _opts) => { const child = new FakeChild(); children.push(child); return child as unknown as ChildProcess; diff --git a/gitnexus/test/unit/embeddings-install-command.test.ts b/gitnexus/test/unit/embeddings-install-command.test.ts index b60348c6c..00961e090 100644 --- a/gitnexus/test/unit/embeddings-install-command.test.ts +++ b/gitnexus/test/unit/embeddings-install-command.test.ts @@ -13,13 +13,15 @@ import type { LoggerCapture } from '../../src/core/logger.js'; const resolveEmbeddingRuntimeMock = vi.fn<() => { source: string } | null>(); const isPrefixRuntimeLoadableMock = vi.fn(() => true); -const installEmbeddingRuntimeMock = vi.fn(async () => undefined); +const installEmbeddingRuntimeMock = vi.fn< + typeof import('../../src/core/embeddings/runtime-install.js').installEmbeddingRuntime +>(async () => undefined); vi.mock('../../src/core/embeddings/runtime-install.js', async (importOriginal) => ({ ...(await importOriginal()), resolveEmbeddingRuntime: () => resolveEmbeddingRuntimeMock(), isPrefixRuntimeLoadable: () => isPrefixRuntimeLoadableMock(), - installEmbeddingRuntime: (opts?: unknown) => installEmbeddingRuntimeMock(opts), + installEmbeddingRuntime: installEmbeddingRuntimeMock, getEmbeddingRuntimeDir: () => '/fake/embedding-runtime', getEmbeddingStackSpecs: () => ({ '@huggingface/transformers': '^4.1.0' }), })); diff --git a/gitnexus/test/unit/embeddings-sync-command.test.ts b/gitnexus/test/unit/embeddings-sync-command.test.ts index 01afa40c3..ec33fee6d 100644 --- a/gitnexus/test/unit/embeddings-sync-command.test.ts +++ b/gitnexus/test/unit/embeddings-sync-command.test.ts @@ -3,13 +3,15 @@ * index lock, missing-DB preflight, identity fail-closed, tri-state count, * closeLbug masking, and hash-only cache load. */ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { acquireIndexLockMock, + ensurePrivateSharedGraphMock, releaseMock, getStoragePathsMock, loadMetaMock, @@ -27,6 +29,7 @@ const { reapEmbeddingSidecarMock, } = vi.hoisted(() => ({ acquireIndexLockMock: vi.fn(), + ensurePrivateSharedGraphMock: vi.fn(), releaseMock: vi.fn(), getStoragePathsMock: vi.fn(), loadMetaMock: vi.fn(), @@ -48,6 +51,10 @@ vi.mock('../../src/storage/git.js', () => ({ getGitRoot: () => '/tmp/emb-sync-repo', })); +vi.mock('../../src/core/shared-store-analyze.js', () => ({ + ensurePrivateSharedGraph: (...args: unknown[]) => ensurePrivateSharedGraphMock(...args), +})); + vi.mock('../../src/storage/index-lock.js', async (importOriginal) => ({ ...(await importOriginal()), acquireIndexLock: (...args: unknown[]) => acquireIndexLockMock(...args), @@ -113,6 +120,25 @@ async function run(inputPath = '/tmp/emb-sync-repo') { await embeddingsSyncCommand(inputPath); } +async function useRealIndexLock() { + vi.stubEnv('GITNEXUS_INDEX_LOCK_BACKEND', 'file'); + const actual = await vi.importActual( + '../../src/storage/index-lock.js', + ); + acquireIndexLockMock.mockImplementation( + async (...args: Parameters) => { + const lock = await actual.acquireIndexLock(...args); + return { + ...lock, + release: () => { + lock.release(); + releaseMock(); + }, + }; + }, + ); +} + describe('embeddingsSyncCommand writer safety (#3065)', () => { const tmpDirs: string[] = []; const originalEmbeddingUrl = process.env.GITNEXUS_EMBEDDING_URL; @@ -132,6 +158,7 @@ describe('embeddingsSyncCommand writer safety (#3065)', () => { beforeEach(() => { vi.resetModules(); acquireIndexLockMock.mockReset().mockResolvedValue(lockHandle()); + ensurePrivateSharedGraphMock.mockReset().mockResolvedValue(true); releaseMock.mockReset(); getStoragePathsMock.mockReset(); loadMetaMock.mockReset().mockResolvedValue({ ...BASE_META }); @@ -156,6 +183,7 @@ describe('embeddingsSyncCommand writer safety (#3065)', () => { }); afterEach(async () => { + vi.unstubAllEnvs(); if (originalEmbeddingUrl === undefined) delete process.env.GITNEXUS_EMBEDDING_URL; else process.env.GITNEXUS_EMBEDDING_URL = originalEmbeddingUrl; if (originalEmbeddingModel === undefined) delete process.env.GITNEXUS_EMBEDDING_MODEL; @@ -183,7 +211,7 @@ describe('embeddingsSyncCommand writer safety (#3065)', () => { await run(); - expect(acquireIndexLockMock).toHaveBeenCalledWith(dir); + expect(acquireIndexLockMock).toHaveBeenCalledWith(dir, { sweep: false }); expect(order[0]).toBe('lock'); expect(order.indexOf('loadMeta')).toBeGreaterThan(order.indexOf('lock')); expect(order.indexOf('init')).toBeGreaterThan(order.indexOf('loadMeta')); @@ -299,6 +327,90 @@ describe('embeddingsSyncCommand writer safety (#3065)', () => { expect(releaseMock).toHaveBeenCalled(); }); + it.each([ + { + name: 'valid staged receipt', + recovery: { + stagingFile: 'lbug.staging.12345678-1234-4123-8123-123456789abc', + schemaFingerprint: 'test-schema', + unsafeNodeIds: ['n2', 'inherited-window-node'], + }, + }, + { name: 'null receipt', recovery: null }, + { name: 'malformed receipt', recovery: { stagingFile: 'invalid' } }, + { name: 'false receipt', recovery: false }, + ])('preserves a $name before any writable sync work', async ({ recovery }) => { + const { dir, lbugPath, metaPath } = await store(); + await useRealIndexLock(); + const lockPath = path.join(dir, 'analyze.lock'); + const sourcePath = path.join(dir, 'lbug.staging.12345678-1234-4123-8123-123456789abc'); + await writeFile(sourcePath, 'completed paid vectors'); + await writeFile(`${sourcePath}.wal`, 'unfinished window'); + const metadataBytes = JSON.stringify({ + ...BASE_META, + embeddingCheckpoint: { + ...IDENTITY, + at: '2026-01-01T00:00:00.000Z', + nodesProcessed: 1, + totalNodes: 2, + chunksProcessed: 1, + kind: 'interrupted', + pendingNodeIds: ['n2'], + recovery, + }, + }); + await writeFile(metaPath, metadataBytes); + loadMetaMock.mockImplementation(async () => { + expect(existsSync(lockPath)).toBe(true); + return JSON.parse(await readFile(metaPath, 'utf8')); + }); + resolveEmbeddingRuntimeMock.mockReturnValue(null); + + await expect(run()).rejects.toThrow( + /staged embeddings.*Run `gitnexus analyze` to recover them first/, + ); + + expect(acquireIndexLockMock).toHaveBeenCalledWith(dir, { sweep: false }); + expect(loadMetaMock.mock.invocationCallOrder[0]).toBeGreaterThan( + acquireIndexLockMock.mock.invocationCallOrder[0]!, + ); + expect(ensurePrivateSharedGraphMock).not.toHaveBeenCalled(); + expect(resolveEmbeddingIdentityMock).not.toHaveBeenCalled(); + expect(installEmbeddingRuntimeMock).not.toHaveBeenCalled(); + expect(initLbugMock).not.toHaveBeenCalled(); + expect(runEmbeddingPipelineMock).not.toHaveBeenCalled(); + expect(saveMetaMock).not.toHaveBeenCalled(); + expect(releaseMock).toHaveBeenCalledTimes(1); + expect(existsSync(lockPath)).toBe(false); + expect(await readFile(metaPath, 'utf8')).toBe(metadataBytes); + expect(await readFile(lbugPath, 'utf8')).toBe('db'); + expect(await readFile(sourcePath, 'utf8')).toBe('completed paid vectors'); + expect(await readFile(`${sourcePath}.wal`, 'utf8')).toBe('unfinished window'); + }); + + it('sweeps orphaned staging files before writable sync work without a recovery receipt', async () => { + const { dir, metaPath } = await store(); + await useRealIndexLock(); + await writeFile(metaPath, JSON.stringify(BASE_META)); + const sourcePath = path.join(dir, 'lbug.staging.12345678-1234-4123-8123-123456789abc'); + await writeFile(sourcePath, 'orphaned database'); + await writeFile(`${sourcePath}.wal`, 'orphaned WAL'); + loadMetaMock.mockImplementation(async () => JSON.parse(await readFile(metaPath, 'utf8'))); + ensurePrivateSharedGraphMock.mockImplementation(async () => { + expect(existsSync(path.join(dir, 'analyze.lock'))).toBe(true); + expect(existsSync(sourcePath)).toBe(false); + expect(existsSync(`${sourcePath}.wal`)).toBe(false); + return true; + }); + + await run(); + + expect(ensurePrivateSharedGraphMock).toHaveBeenCalledTimes(1); + expect(runEmbeddingPipelineMock).toHaveBeenCalledTimes(1); + expect(releaseMock).toHaveBeenCalledTimes(1); + expect(existsSync(path.join(dir, 'analyze.lock'))).toBe(false); + }); + it('persists an interrupted checkpoint from the pipeline checkpoint callbacks', async () => { // The resume contract lives in these callbacks; a mock that never invokes // them leaves the whole save path unexecuted. diff --git a/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts b/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts index 5dc089e56..6ae5835c6 100644 --- a/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts +++ b/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts @@ -29,7 +29,7 @@ vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => { }; }); -import { LocalBackend } from '../../src/mcp/local/local-backend'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; describe('ensureInitialized reinit watermark (tri-review NEW-7)', () => { const poolKey = '/tmp/nonexistent-repo/.gitnexus/lbug'; diff --git a/gitnexus/test/unit/entry-point-scoring.test.ts b/gitnexus/test/unit/entry-point-scoring.test.ts index 5d0992831..e468a94dd 100644 --- a/gitnexus/test/unit/entry-point-scoring.test.ts +++ b/gitnexus/test/unit/entry-point-scoring.test.ts @@ -1,3 +1,4 @@ +import { SupportedLanguages } from 'gitnexus-shared'; import { describe, it, expect } from 'vitest'; import { calculateEntryPointScore, @@ -8,34 +9,58 @@ import { describe('calculateEntryPointScore', () => { describe('base scoring', () => { it('returns 0 for functions with no outgoing calls', () => { - const result = calculateEntryPointScore('handler', 'typescript', true, 0, 0); + const result = calculateEntryPointScore('handler', SupportedLanguages.TypeScript, true, 0, 0); expect(result.score).toBe(0); expect(result.reasons).toContain('no-outgoing-calls'); }); it('calculates base score as calleeCount / (callerCount + 1)', () => { - const result = calculateEntryPointScore('doStuff', 'typescript', false, 0, 5); + const result = calculateEntryPointScore( + 'doStuff', + SupportedLanguages.TypeScript, + false, + 0, + 5, + ); // base = 5 / (0 + 1) = 5, no export bonus, no name bonus expect(result.score).toBe(5); }); it('reduces score for functions with many callers', () => { - const few = calculateEntryPointScore('doStuff', 'typescript', false, 1, 5); - const many = calculateEntryPointScore('doStuff', 'typescript', false, 10, 5); + const few = calculateEntryPointScore('doStuff', SupportedLanguages.TypeScript, false, 1, 5); + const many = calculateEntryPointScore('doStuff', SupportedLanguages.TypeScript, false, 10, 5); expect(few.score).toBeGreaterThan(many.score); }); }); describe('export multiplier', () => { it('applies 2.0 multiplier for exported functions', () => { - const exported = calculateEntryPointScore('doStuff', 'typescript', true, 0, 4); - const notExported = calculateEntryPointScore('doStuff', 'typescript', false, 0, 4); + const exported = calculateEntryPointScore( + 'doStuff', + SupportedLanguages.TypeScript, + true, + 0, + 4, + ); + const notExported = calculateEntryPointScore( + 'doStuff', + SupportedLanguages.TypeScript, + false, + 0, + 4, + ); expect(exported.score).toBe(notExported.score * 2); expect(exported.reasons).toContain('exported'); }); it('does not add exported reason when not exported', () => { - const result = calculateEntryPointScore('doStuff', 'typescript', false, 0, 4); + const result = calculateEntryPointScore( + 'doStuff', + SupportedLanguages.TypeScript, + false, + 0, + 4, + ); expect(result.reasons).not.toContain('exported'); }); }); @@ -44,7 +69,7 @@ describe('calculateEntryPointScore', () => { it.each(['main', 'init', 'bootstrap', 'start', 'run', 'setup', 'configure'])( 'recognizes "%s" as entry point pattern', (name) => { - const result = calculateEntryPointScore(name, 'typescript', false, 0, 3); + const result = calculateEntryPointScore(name, SupportedLanguages.TypeScript, false, 0, 3); expect(result.reasons).toContain('entry-pattern'); }, ); @@ -64,13 +89,19 @@ describe('calculateEntryPointScore', () => { 'fireEvent', 'emitEvent', ])('recognizes "%s" as entry point pattern', (name) => { - const result = calculateEntryPointScore(name, 'typescript', false, 0, 3); + const result = calculateEntryPointScore(name, SupportedLanguages.TypeScript, false, 0, 3); expect(result.reasons).toContain('entry-pattern'); }); it('applies 1.5x name multiplier for entry patterns', () => { - const matching = calculateEntryPointScore('handleLogin', 'typescript', false, 0, 4); - const plain = calculateEntryPointScore('doStuff', 'typescript', false, 0, 4); + const matching = calculateEntryPointScore( + 'handleLogin', + SupportedLanguages.TypeScript, + false, + 0, + 4, + ); + const plain = calculateEntryPointScore('doStuff', SupportedLanguages.TypeScript, false, 0, 4); // matching gets 1.5x, plain gets 1.0x expect(matching.score).toBe(plain.score * 1.5); }); @@ -78,63 +109,81 @@ describe('calculateEntryPointScore', () => { describe('language-specific patterns', () => { it('recognizes React hooks for TypeScript', () => { - const result = calculateEntryPointScore('useEffect', 'typescript', false, 0, 2); + const result = calculateEntryPointScore( + 'useEffect', + SupportedLanguages.TypeScript, + false, + 0, + 2, + ); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes React hooks for JavaScript', () => { - const result = calculateEntryPointScore('useState', 'javascript', false, 0, 2); + const result = calculateEntryPointScore( + 'useState', + SupportedLanguages.JavaScript, + false, + 0, + 2, + ); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes Python REST patterns', () => { - const result = calculateEntryPointScore('get_users', 'python', false, 0, 2); + const result = calculateEntryPointScore('get_users', SupportedLanguages.Python, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes Java servlet patterns', () => { - const result = calculateEntryPointScore('doGet', 'java', false, 0, 2); + const result = calculateEntryPointScore('doGet', SupportedLanguages.Java, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes Go handler patterns', () => { - const result = calculateEntryPointScore('NewServer', 'go', false, 0, 2); + const result = calculateEntryPointScore('NewServer', SupportedLanguages.Go, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes Rust entry patterns', () => { - const result = calculateEntryPointScore('handle_request', 'rust', false, 0, 2); + const result = calculateEntryPointScore( + 'handle_request', + SupportedLanguages.Rust, + false, + 0, + 2, + ); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes Swift UIKit lifecycle', () => { - const result = calculateEntryPointScore('viewDidLoad', 'swift', false, 0, 2); + const result = calculateEntryPointScore('viewDidLoad', SupportedLanguages.Swift, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes Swift SwiftUI body', () => { - const result = calculateEntryPointScore('body', 'swift', false, 0, 2); + const result = calculateEntryPointScore('body', SupportedLanguages.Swift, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes PHP Laravel patterns', () => { // __invoke starts with '_' which matches utility pattern first - const result = calculateEntryPointScore('handle', 'php', false, 0, 2); + const result = calculateEntryPointScore('handle', SupportedLanguages.PHP, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes PHP RESTful resource methods', () => { - const result = calculateEntryPointScore('index', 'php', false, 0, 2); + const result = calculateEntryPointScore('index', SupportedLanguages.PHP, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes C# ASP.NET patterns', () => { - const result = calculateEntryPointScore('GetUsers', 'csharp', false, 0, 2); + const result = calculateEntryPointScore('GetUsers', SupportedLanguages.CSharp, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); it('recognizes C main entry point', () => { - const result = calculateEntryPointScore('main', 'c', false, 0, 2); + const result = calculateEntryPointScore('main', SupportedLanguages.C, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); @@ -152,7 +201,7 @@ describe('calculateEntryPointScore', () => { 'session_create', 'window_resize', ])('recognizes C pattern "%s"', (name) => { - const result = calculateEntryPointScore(name, 'c', false, 0, 2); + const result = calculateEntryPointScore(name, SupportedLanguages.C, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); @@ -167,7 +216,7 @@ describe('calculateEntryPointScore', () => { 'OnEventReceived', 'on_click', ])('recognizes C++ pattern "%s"', (name) => { - const result = calculateEntryPointScore(name, 'cpp', false, 0, 2); + const result = calculateEntryPointScore(name, SupportedLanguages.CPlusPlus, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); @@ -186,7 +235,7 @@ describe('calculateEntryPointScore', () => { 'module', 'AuthService', ])('recognizes Kotlin pattern "%s"', (name) => { - const result = calculateEntryPointScore(name, 'kotlin', false, 0, 2); + const result = calculateEntryPointScore(name, SupportedLanguages.Kotlin, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); @@ -197,14 +246,14 @@ describe('calculateEntryPointScore', () => { it.each(['main', 'build', 'createState', 'initState', 'dispose', 'runApp', 'onEvent'])( 'recognizes Dart pattern "%s"', (name) => { - const result = calculateEntryPointScore(name, 'dart', false, 0, 2); + const result = calculateEntryPointScore(name, SupportedLanguages.Dart, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }, ); // Ruby-specific patterns (Rails callable/job/service objects) it.each(['call', 'perform', 'execute'])('recognizes Ruby pattern "%s"', (name) => { - const result = calculateEntryPointScore(name, 'ruby', false, 0, 2); + const result = calculateEntryPointScore(name, SupportedLanguages.Ruby, false, 0, 2); expect(result.reasons).toContain('entry-pattern'); }); }); @@ -226,15 +275,21 @@ describe('calculateEntryPointScore', () => { 'cloneDeep', 'mergeObjects', ])('penalizes utility function "%s"', (name) => { - const result = calculateEntryPointScore(name, 'typescript', false, 0, 3); + const result = calculateEntryPointScore(name, SupportedLanguages.TypeScript, false, 0, 3); expect(result.reasons).toContain('utility-pattern'); // 0.3 multiplier - const plain = calculateEntryPointScore('doStuff', 'typescript', false, 0, 3); + const plain = calculateEntryPointScore('doStuff', SupportedLanguages.TypeScript, false, 0, 3); expect(result.score).toBeLessThan(plain.score); }); it('penalizes private-by-convention functions', () => { - const result = calculateEntryPointScore('_internal', 'typescript', false, 0, 3); + const result = calculateEntryPointScore( + '_internal', + SupportedLanguages.TypeScript, + false, + 0, + 3, + ); expect(result.reasons).toContain('utility-pattern'); }); }); @@ -243,7 +298,7 @@ describe('calculateEntryPointScore', () => { it('boosts Next.js page entry points', () => { const result = calculateEntryPointScore( 'render', - 'typescript', + SupportedLanguages.TypeScript, true, 0, 3, @@ -256,7 +311,7 @@ describe('calculateEntryPointScore', () => { it('does not apply framework bonus for non-framework paths', () => { const result = calculateEntryPointScore( 'render', - 'typescript', + SupportedLanguages.TypeScript, true, 0, 3, @@ -271,7 +326,7 @@ describe('calculateEntryPointScore', () => { // handleLogin: entry pattern (1.5x) + exported (2.0x) + base const result = calculateEntryPointScore( 'handleLogin', - 'typescript', + SupportedLanguages.TypeScript, true, 0, 4, diff --git a/gitnexus/test/unit/eval-formatters.test.ts b/gitnexus/test/unit/eval-formatters.test.ts index bfc025432..d43ec5f38 100644 --- a/gitnexus/test/unit/eval-formatters.test.ts +++ b/gitnexus/test/unit/eval-formatters.test.ts @@ -641,7 +641,7 @@ describe('formatDetectChangesResult', () => { // counts at zero. Without the note the pre-commit gate reads as "clean". const result = formatDetectChangesResult({ partial: true, summary: { changed_count: 0 } }); expect(result).toContain('PARTIAL RESULT'); - expect(result).toContain('a graph query failed'); + expect(result).toContain('mapping is incomplete'); expect(result).not.toContain('No changes detected.'); }); diff --git a/gitnexus/test/unit/eval-server-bind-restriction.test.ts b/gitnexus/test/unit/eval-server-bind-restriction.test.ts index 0f96e301e..0e46a05cb 100644 --- a/gitnexus/test/unit/eval-server-bind-restriction.test.ts +++ b/gitnexus/test/unit/eval-server-bind-restriction.test.ts @@ -22,7 +22,7 @@ describe('isEvalServerBindRestriction', () => { ['Error: listen EADDRNOTAVAIL: address not available 192.168.1.99:0'], ['something then bind EACCES: permission denied'], ['LISTEN EACCES: permission denied'], - ])('matches %p', (stderr) => { + ])('matches %s', (stderr) => { expect(isEvalServerBindRestriction(stderr)).toBe(true); }); }); @@ -35,7 +35,7 @@ describe('isEvalServerBindRestriction', () => { ['GITNEXUS_EVAL_SERVER_READY:127.0.0.1:5173'], [''], ['unknown option --host'], - ])('does not match %p', (stderr) => { + ])('does not match %s', (stderr) => { expect(isEvalServerBindRestriction(stderr)).toBe(false); }); }); diff --git a/gitnexus/test/unit/evidence-provenance-helper.test.ts b/gitnexus/test/unit/evidence-provenance-helper.test.ts index 7159a38b8..84597e23b 100644 --- a/gitnexus/test/unit/evidence-provenance-helper.test.ts +++ b/gitnexus/test/unit/evidence-provenance-helper.test.ts @@ -953,20 +953,23 @@ SAFE_WRITE_FIXTURES('generated-plan safe writer', () => { ['--expected-plan-path', SAFE_PLAN_PATH], /--expected-plan-path and --expected-plan-digest require --replace/, ], - ] as const)('rejects command-inapplicable CLI options for %s', (command, extra, pattern) => { - const repo = createBaseRepo('gitnexus-plan-cli-options-'); - try { - const result = spawnSync( - process.execPath, - [PLAN_HELPER, command, '--repo', repo, '--generated-plan', SAFE_PLAN_PATH, ...extra], - { encoding: 'utf8', input: '# plan\n' }, - ); - expect(result.status).toBe(1); - expect(result.stderr).toMatch(pattern); - } finally { - fs.rmSync(repo, { recursive: true, force: true }); - } - }); + ] as const)( + 'rejects command-inapplicable CLI options for %s (case %#)', + (command, extra, pattern) => { + const repo = createBaseRepo('gitnexus-plan-cli-options-'); + try { + const result = spawnSync( + process.execPath, + [PLAN_HELPER, command, '--repo', repo, '--generated-plan', SAFE_PLAN_PATH, ...extra], + { encoding: 'utf8', input: '# plan\n' }, + ); + expect(result.status).toBe(1); + expect(result.stderr).toMatch(pattern); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }, + ); it('never overwrites a destination created immediately before initial publication', async () => { const repo = createBaseRepo('gitnexus-plan-writer-'); diff --git a/gitnexus/test/unit/fetch-reason-parsing.test.ts b/gitnexus/test/unit/fetch-reason-parsing.test.ts index 4d81cc5db..ffb0fc802 100644 --- a/gitnexus/test/unit/fetch-reason-parsing.test.ts +++ b/gitnexus/test/unit/fetch-reason-parsing.test.ts @@ -84,7 +84,8 @@ describe('fetch reason field parsing', () => { describe('confidence derivation from fetchCount', () => { it('high confidence when fetchCount is undefined (single fetch)', () => { - const isMultiFetch = (undefined ?? 1) > 1; + const fetchCount: number | undefined = undefined; + const isMultiFetch = (fetchCount ?? 1) > 1; expect(isMultiFetch).toBe(false); }); diff --git a/gitnexus/test/unit/field-extraction.test.ts b/gitnexus/test/unit/field-extraction.test.ts index fd86c3aaa..81062ced2 100644 --- a/gitnexus/test/unit/field-extraction.test.ts +++ b/gitnexus/test/unit/field-extraction.test.ts @@ -62,7 +62,8 @@ const createMockContext = (): FieldExtractorContext => ({ fileScope: () => new Map(), allScopes: () => new Map(), constructorTypeMap: new Map(), - } as TypeEnvironment, + flush: () => {}, + } satisfies TypeEnvironment, symbolTable: createSemanticModel().symbols, filePath: 'test.ts', language: SupportedLanguages.TypeScript, @@ -1327,7 +1328,7 @@ describeKotlin('GenericFieldExtractor — Kotlin (F52 companion)', () => { /** The first companion_object node in `src`. */ function companion(src: string): Parser.SyntaxNode { - parser.setLanguage(Kotlin as Parser.Language); + parser.setLanguage(Kotlin); const tree = parser.parse(src); let found: Parser.SyntaxNode | undefined; const walk = (n: Parser.SyntaxNode) => { @@ -1401,7 +1402,7 @@ describeKotlin('GenericFieldExtractor — Kotlin (F52 companion)', () => { /** Parse `src` and return the first node of the given type (depth-first). */ function firstNodeOfType(src: string, type: string): Parser.SyntaxNode { - parser.setLanguage(Kotlin as Parser.Language); + parser.setLanguage(Kotlin); const tree = parser.parse(src); let found: Parser.SyntaxNode | undefined; const walk = (n: Parser.SyntaxNode) => { @@ -1436,7 +1437,7 @@ describeSwift('GenericFieldExtractor — Swift (F75 protocol property requiremen /** Parse `src` and return the first class/protocol declaration node. */ function declNode(src: string): Parser.SyntaxNode { - parser.setLanguage(Swift as Parser.Language); + parser.setLanguage(Swift); const tree = parser.parse(src); const node = tree.rootNode.child(0); if (!node) throw new Error('no declaration node'); diff --git a/gitnexus/test/unit/format-path.test.ts b/gitnexus/test/unit/format-path.test.ts new file mode 100644 index 000000000..a24446fe5 --- /dev/null +++ b/gitnexus/test/unit/format-path.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { formatPathForTerminal } from '../../src/cli/format-path.js'; + +describe('terminal path rendering', () => { + it('preserves ordinary Unicode and spaces', () => { + expect(formatPathForTerminal('src/王 name.ts')).toBe('src/王 name.ts'); + }); + + it.each(['\u001b', '\u0007', '\r', '\n', '\u007f', '\u0085', '\u009b'])( + 'renders control %j visibly without changing the decoded path', + (control) => { + const filePath = `src/a${control}.ts`; + const rendered = formatPathForTerminal(filePath); + expect(rendered).not.toMatch(/[\u0000-\u001f\u007f-\u009f]/); + expect(JSON.parse(rendered)).toBe(filePath); + }, + ); +}); diff --git a/gitnexus/test/unit/framework-detection.test.ts b/gitnexus/test/unit/framework-detection.test.ts index 7757f7e18..58a25dea7 100644 --- a/gitnexus/test/unit/framework-detection.test.ts +++ b/gitnexus/test/unit/framework-detection.test.ts @@ -227,14 +227,14 @@ describe('detectFrameworkFromPath', () => { it('detects main.rs', () => { const result = detectFrameworkFromPath('src/main.rs'); expect(result).not.toBeNull(); - expect(result!.framework).toBe('rust'); + expect(result!.framework).toBe(SupportedLanguages.Rust); expect(result!.entryPointMultiplier).toBe(3.0); }); it('detects bin folder', () => { const result = detectFrameworkFromPath('src/bin/cli.rs'); expect(result).not.toBeNull(); - expect(result!.framework).toBe('rust'); + expect(result!.framework).toBe(SupportedLanguages.Rust); }); }); @@ -325,52 +325,53 @@ describe('detectFrameworkFromPath', () => { }); describe('detectFrameworkFromAST', () => { + // Deliberately invalid enum inputs exercise runtime tolerance at the public boundary. it('returns null for empty inputs', () => { - expect(detectFrameworkFromAST('', '')).toBeNull(); - expect(detectFrameworkFromAST('typescript', '')).toBeNull(); - expect(detectFrameworkFromAST('', 'some code')).toBeNull(); + expect(detectFrameworkFromAST('' as SupportedLanguages, '')).toBeNull(); + expect(detectFrameworkFromAST(SupportedLanguages.TypeScript, '')).toBeNull(); + expect(detectFrameworkFromAST('' as SupportedLanguages, 'some code')).toBeNull(); }); it('detects NestJS decorators in TypeScript', () => { - const result = detectFrameworkFromAST('typescript', '@Controller("/users")'); + const result = detectFrameworkFromAST(SupportedLanguages.TypeScript, '@Controller("/users")'); expect(result).not.toBeNull(); expect(result!.framework).toBe('nestjs'); expect(result!.entryPointMultiplier).toBe(3.2); }); it('detects NestJS decorators in JavaScript', () => { - const result = detectFrameworkFromAST('javascript', '@Get("/")'); + const result = detectFrameworkFromAST(SupportedLanguages.JavaScript, '@Get("/")'); expect(result).not.toBeNull(); expect(result!.framework).toBe('nestjs'); }); it('detects FastAPI decorators in Python', () => { - const result = detectFrameworkFromAST('python', '@app.get("/users")'); + const result = detectFrameworkFromAST(SupportedLanguages.Python, '@app.get("/users")'); expect(result).not.toBeNull(); expect(result!.framework).toBe('fastapi'); }); it('detects Flask decorators in Python', () => { - const result = detectFrameworkFromAST('python', '@app.route("/users")'); + const result = detectFrameworkFromAST(SupportedLanguages.Python, '@app.route("/users")'); expect(result).not.toBeNull(); expect(result!.framework).toBe('flask'); }); it('detects Spring annotations in Java', () => { - const result = detectFrameworkFromAST('java', '@RestController'); + const result = detectFrameworkFromAST(SupportedLanguages.Java, '@RestController'); expect(result).not.toBeNull(); expect(result!.framework).toBe('spring'); }); it('detects ASP.NET attributes in C#', () => { - const result = detectFrameworkFromAST('csharp', '[ApiController]'); + const result = detectFrameworkFromAST(SupportedLanguages.CSharp, '[ApiController]'); expect(result).not.toBeNull(); expect(result!.framework).toBe('aspnet'); }); it('detects Laravel route definitions in PHP', () => { const result = detectFrameworkFromAST( - 'php', + SupportedLanguages.PHP, "Route::get('/users', [UserController::class, 'index'])", ); expect(result).not.toBeNull(); @@ -378,27 +379,33 @@ describe('detectFrameworkFromAST', () => { }); it('detects Actix-web route attributes in Rust', () => { - const result = detectFrameworkFromAST('rust', '#[get("/")]'); + const result = detectFrameworkFromAST(SupportedLanguages.Rust, '#[get("/")]'); expect(result).not.toBeNull(); expect(result!.framework).toBe('actix-web'); }); it('returns null for language with no matching pattern', () => { - expect(detectFrameworkFromAST('c', 'int main() { return 0; }')).toBeNull(); + expect(detectFrameworkFromAST(SupportedLanguages.C, 'int main() { return 0; }')).toBeNull(); }); it('is case-insensitive', () => { - const result = detectFrameworkFromAST('TypeScript', '@controller("/")'); + const result = detectFrameworkFromAST('TypeScript' as SupportedLanguages, '@controller("/")'); expect(result).not.toBeNull(); }); it('detects Expo Router useRouter hook', () => { - const result = detectFrameworkFromAST('typescript', 'const router = useRouter()'); + const result = detectFrameworkFromAST( + SupportedLanguages.TypeScript, + 'const router = useRouter()', + ); expect(result).not.toBeNull(); expect(result!.framework).toBe('expo-router'); }); it('detects Expo Router router.push', () => { - const result = detectFrameworkFromAST('javascript', "router.push('/settings')"); + const result = detectFrameworkFromAST( + SupportedLanguages.JavaScript, + "router.push('/settings')", + ); expect(result).not.toBeNull(); expect(result!.framework).toBe('expo-router'); }); diff --git a/gitnexus/test/unit/fts-indexes.test.ts b/gitnexus/test/unit/fts-indexes.test.ts index 84a9dd0ee..37026dede 100644 --- a/gitnexus/test/unit/fts-indexes.test.ts +++ b/gitnexus/test/unit/fts-indexes.test.ts @@ -34,13 +34,19 @@ const { getSearchFTSStemmer, initialiseSearchFTSStemmer, missingSearchFTSIndexTables, + verifySearchFTSIndexes, } = await import('../../src/core/search/fts-indexes.js'); const { FTS_INDEXES, getFtsIndexes } = await import('../../src/core/search/fts-schema.js'); const { createFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js'); /** SHOW_INDEXES rows covering every configured FTS index's expected properties. */ const fullCoverageRows = () => - FTS_INDEXES.map((i) => ({ index_name: i.indexName, property_names: [...i.properties] })); + FTS_INDEXES.map((i) => ({ + table_name: i.table, + index_name: i.indexName, + index_type: 'FTS', + property_names: [...i.properties], + })); /** The row-level tokenizer error of #2544/#2546/#2889, verbatim. */ const POISON = 'Runtime exception: Failed calling LOWER: Invalid UTF-8.'; @@ -141,6 +147,91 @@ describe('createSearchFTSIndexes', () => { }); }); +describe('verifySearchFTSIndexes', () => { + const propertyIndex = FTS_INDEXES.find((index) => index.table === 'Property')!; + const propertyRow = () => fullCoverageRows().find((row) => row.table_name === 'Property')!; + + it('accepts exact identity/type with additional properties, using one catalog read', async () => { + const executeQuery = vi.fn(async () => [ + { ...propertyRow(), property_names: [...propertyIndex.properties, 'extra'] }, + ]); + const onMismatch = vi.fn(); + + expect(await verifySearchFTSIndexes(executeQuery, [propertyIndex], onMismatch)).toEqual([]); + expect(executeQuery).toHaveBeenCalledExactlyOnceWith('CALL SHOW_INDEXES() RETURN *'); + expect(onMismatch).not.toHaveBeenCalled(); + }); + + it.each([ + ['wrong table', { table_name: 'Function' }], + ['wrong type', { index_type: 'HASH' }], + ['missing type', { index_type: undefined }], + ['missing properties', { property_names: undefined }], + ['non-array properties', { property_names: 'name,content,description' }], + ['malformed properties', { property_names: ['name', 'content', 'description', 17] }], + ['incomplete properties', { property_names: ['name', 'content'] }], + ])('rejects %s without accepting the matching index name', async (_label, mismatch) => { + const executeQuery = vi.fn(async () => [{ ...propertyRow(), ...mismatch }]); + const onMismatch = vi.fn(); + + expect(await verifySearchFTSIndexes(executeQuery, [propertyIndex], onMismatch)).toEqual([ + 'Property.property_fts', + ]); + expect(onMismatch).toHaveBeenCalledExactlyOnceWith({ + table: 'Property', + indexName: 'property_fts', + message: expect.stringContaining('Property.property_fts'), + }); + expect(onMismatch.mock.calls[0][0].message).toContain('observed'); + }); + + it('accepts a valid row even when another table has the same index name', async () => { + const executeQuery = vi.fn(async () => [ + propertyRow(), + { ...propertyRow(), table_name: 'Function', property_names: ['name'] }, + ]); + + expect(await verifySearchFTSIndexes(executeQuery, [propertyIndex])).toEqual([]); + }); + + it('bounds diagnostic evidence and excludes source and index definitions', async () => { + const privateSource = 'PRIVATE_SOURCE_MUST_NOT_APPEAR'; + const executeQuery = vi.fn(async () => + Array.from({ length: 50 }, (_, i) => ({ + ...propertyRow(), + table_name: `Wrong${i}${'x'.repeat(10_000)}`, + property_names: [{ source: privateSource }, ...Array(100).fill('x'.repeat(10_000))], + content: privateSource, + index_definition: privateSource, + })), + ); + const onMismatch = vi.fn(); + + expect(await verifySearchFTSIndexes(executeQuery, [propertyIndex], onMismatch)).toEqual([ + 'Property.property_fts', + ]); + const message = onMismatch.mock.calls[0][0].message as string; + expect(message).toContain('Wrong0'); + expect(message).toContain('truncated'); + expect(message.length).toBeLessThanOrEqual(2048); + expect(message).not.toContain(privateSource); + expect(message).not.toContain('index_definition'); + expect(executeQuery).toHaveBeenCalledTimes(1); + }); + + it('preserves unknown catalog state as an exception, without reporting absence', async () => { + const executeQuery = vi.fn(async () => { + throw new Error('catalog unavailable'); + }); + const onMismatch = vi.fn(); + + await expect(verifySearchFTSIndexes(executeQuery, [propertyIndex], onMismatch)).rejects.toThrow( + 'catalog unavailable', + ); + expect(onMismatch).not.toHaveBeenCalled(); + }); +}); + describe('buildSearchIndexesOrDegrade', () => { it('returns ok:true when every index builds and verifies (#2544/#2546)', async () => { const executeQuery = vi.fn(async () => fullCoverageRows()); @@ -215,6 +306,69 @@ describe('buildSearchIndexesOrDegrade', () => { expect(result.error).toContain(`${failed} (${POISON})`); expect(result.error).not.toContain('missing indexes'); }); + + it('explains missing catalog coverage when CREATE returned no build error', async () => { + const executeQuery = vi.fn(async () => + fullCoverageRows().filter((row) => row.table_name !== 'Property'), + ); + + const result = await buildSearchIndexesOrDegrade(executeQuery); + + expect(result.ok).toBe(false); + expect(result.failureClass).toBe('capability'); + expect(result.error).toContain('Property.property_fts'); + expect(result.error).toContain('no build error was returned'); + expect(result.error).toContain('observed'); + expect(result.error).toContain('no matching'); + expect(executeQuery).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['integrity', 'IO exception: checkpoint failed', 'integrity'], + ['capability', POISON, 'capability'], + ['unknown', 'catalog unavailable', 'capability'], + ])( + 'classifies a catalog-only %s error after every index builds', + async (_label, error, failureClass) => { + const onIndexReady = vi.fn(); + const executeQuery = vi.fn(async () => { + throw new Error(error); + }); + + const result = await buildSearchIndexesOrDegrade(executeQuery, { onIndexReady }); + + expect(createFTSIndex).toHaveBeenCalledTimes(FTS_INDEXES.length); + expect(onIndexReady.mock.calls).toEqual( + FTS_INDEXES.map(({ table, indexName }) => [table, indexName]), + ); + expect(executeQuery).toHaveBeenCalledExactlyOnceWith('CALL SHOW_INDEXES() RETURN *'); + expect(result).toEqual({ + ok: false, + error: `FTS catalog verification failed: ${error}`, + failureClass, + }); + }, + ); + + it('retains integrity and tokenizer causes when catalog verification also throws', async () => { + const integrityError = 'IO exception: checkpoint failed'; + vi.mocked(createFTSIndex) + .mockRejectedValueOnce(new Error(integrityError)) + .mockRejectedValueOnce(new Error(POISON)); + const executeQuery = vi.fn(async () => { + throw new Error('catalog unavailable'); + }); + + const result = await buildSearchIndexesOrDegrade(executeQuery); + + expect(result.ok).toBe(false); + expect(result.failureClass).toBe('integrity'); + expect(result.error).toContain(integrityError); + expect(result.error).toContain(POISON); + expect(result.error).toContain('catalog unavailable'); + expect(result.error).not.toContain('missing indexes'); + expect(executeQuery).toHaveBeenCalledTimes(1); + }); }); describe('missingSearchFTSIndexTables (#3016)', () => { diff --git a/gitnexus/test/unit/git.test.ts b/gitnexus/test/unit/git.test.ts index 7ed94cfa8..b48fa9f89 100644 --- a/gitnexus/test/unit/git.test.ts +++ b/gitnexus/test/unit/git.test.ts @@ -196,6 +196,9 @@ describe('git utilities', () => { stdout: '', stderr: '', error: Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' }), + pid: 0, + output: ['', '', ''], + signal: null, } as ReturnType); expect(listLocalHeads('/missing-git')).toBeNull(); }); diff --git a/gitnexus/test/unit/graph-collapse-wiring.test.ts b/gitnexus/test/unit/graph-collapse-wiring.test.ts index 13c09605a..1e09c1968 100644 --- a/gitnexus/test/unit/graph-collapse-wiring.test.ts +++ b/gitnexus/test/unit/graph-collapse-wiring.test.ts @@ -34,7 +34,7 @@ const graphWith = ( ): Pick => ({ forEachRelationshipFields(fn) { for (const [type, count] of Object.entries(byType)) { - for (let i = 0; i < (count ?? 0); i++) fn('src', 'dst', type as RelationshipType, 1); + for (let i = 0; i < (count ?? 0); i++) fn('src', 'dst', type as RelationshipType, 1, ''); } }, }); diff --git a/gitnexus/test/unit/graph.test.ts b/gitnexus/test/unit/graph.test.ts index 47be5125d..1106b3e12 100644 --- a/gitnexus/test/unit/graph.test.ts +++ b/gitnexus/test/unit/graph.test.ts @@ -6,7 +6,7 @@ */ import { describe, it, expect } from 'vitest'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; -import type { GraphNode, GraphRelationship } from '../../src/core/graph/types.js'; +import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; function makeNode(id: string, name: string, filePath: string = 'src/test.ts'): GraphNode { return { @@ -410,8 +410,9 @@ describe('createKnowledgeGraph', () => { const node: Parameters[0] = { id: 'cluster:x', label: 'Community', - properties: { name: 'x' }, + properties: { name: 'x', filePath: '' }, }; + Reflect.deleteProperty(node.properties, 'filePath'); g.addNode(node); g.addNode(makeNode('fn:a', 'a', 'src/a.ts')); diff --git a/gitnexus/test/unit/group/cross-impact.test.ts b/gitnexus/test/unit/group/cross-impact.test.ts index bea545017..66b329a3a 100644 --- a/gitnexus/test/unit/group/cross-impact.test.ts +++ b/gitnexus/test/unit/group/cross-impact.test.ts @@ -60,7 +60,7 @@ describe('cross-impact', () => { direction: 'sideways', }); expect(r.ok).toBe(false); - if (!r.ok) expect(r.error).toContain('direction'); + if (r.ok === false) expect(r.error).toContain('direction'); }); it('test_validateGroupImpactParams_clamps_crossDepth_and_warns', () => { @@ -110,7 +110,7 @@ describe('cross-impact', () => { direction: 'upstream', }); expect(r.ok).toBe(false); - if (!r.ok) expect(r.error).toBe('target or target_uid is required'); + if (r.ok === false) expect(r.error).toBe('target or target_uid is required'); }); it('test_collectImpactSymbolUids_respects_service_prefix', () => { diff --git a/gitnexus/test/unit/group/fastapi-composed-provider.test.ts b/gitnexus/test/unit/group/fastapi-composed-provider.test.ts index b0d342ebd..01dcb37fb 100644 --- a/gitnexus/test/unit/group/fastapi-composed-provider.test.ts +++ b/gitnexus/test/unit/group/fastapi-composed-provider.test.ts @@ -34,6 +34,7 @@ function run(files: Record): RunResult { }; const readFile = (rel: string): string | null => files[rel] ?? null; const ctx = PYTHON_HTTP_PLUGIN.prepareRepo?.({ + repoPath: '/repo', files: Object.keys(files), parser, readFile, diff --git a/gitnexus/test/unit/group/go-gin-route-groups.test.ts b/gitnexus/test/unit/group/go-gin-route-groups.test.ts new file mode 100644 index 000000000..5a2a0413c --- /dev/null +++ b/gitnexus/test/unit/group/go-gin-route-groups.test.ts @@ -0,0 +1,1187 @@ +/** + * Group HTTP-contract layer: Go gin/echo framework routes registered through + * route groups and method-value handlers. Exercises `GO_HTTP_PLUGIN.scan` + * directly with a real tree-sitter parser, asserting the FULL registered path + * (every enclosing `x := y.Group("/p")` prefix joined in) and the handler name + * the group layer resolves by. The last block runs the real extractor on a Go + * provider repo and a fetch() consumer repo and pairs them with `runExactMatch`. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import Parser from 'tree-sitter'; +import Go from 'tree-sitter-go'; +import { GO_HTTP_PLUGIN } from '../../../src/core/group/extractors/http-patterns/go.js'; +import { + HttpRouteExtractor, + RESOLVE_BY_NAME_QUERY, +} from '../../../src/core/group/extractors/http-route-extractor.js'; +import { runExactMatch } from '../../../src/core/group/matching.js'; +import type { RepoHandle, StoredContract } from '../../../src/core/group/types.js'; + +const parser = new Parser(); + +interface Provider { + method: string; + path: string; + name: string | null; +} + +function providers(src: string): Provider[] { + parser.setLanguage(Go); + return GO_HTTP_PLUGIN.scan(parser.parse(src)) + .filter((d) => d.role === 'provider') + .map(({ method, path: p, name }) => ({ method, path: p, name })); +} + +// Mirrors the shapes of a real gin `RegisterRoutes`: an engine-level group, +// `{ }` blocks, nested groups three levels deep, empty-prefix groups used only +// to attach middleware, method-value / package-func / identifier / inline +// handlers, and variadic middleware before the handler. +const GIN_ROUTES = `package handlers + +import "github.com/gin-gonic/gin" + +func RegisterRoutes(r *gin.Engine, svc *service.Service) { + playerHandler := NewPlayerHandler(svc.Player) + matchHandler := NewMatchHandler(svc.Match) + r.GET("/ping", pingHandle) + v1 := r.Group("/api/v1") + { + v1.GET("/health", func(c *gin.Context) { c.Status(200) }) + v1.GET("/players", playerHandler.GetPlayersHandle) + v1.POST("/upload/avatar", UploadAvatarHandle) + v1.GET("/exports", exports.ListExportsHandle) + v1.PATCH("/players/:playerId", middleware.AuthRequired(svc.Auth), playerHandler.UpdatePlayerHandle) + admin := v1.Group("/admin") + admin.Use(middleware.AdminRequired(svc.AdminControl)) + { + admin.POST("/seasons/:seasonId/rounds/:roundId/unfinalize", matchHandler.UnfinalizeRoundHandle) + newsAdmin := admin.Group("/news") + { + newsAdmin.DELETE("/:id", newsHandler.DeleteNewsHandle) + } + knockoutAdmin := admin.Group("", middleware.KnockoutGuard()) + knockoutAdmin.POST("/seasons/:seasonId/knockout", knockoutHandler.CreateKnockoutHandle) + adminOnly := admin.Group("") + adminOnly.PUT("/seasons/:seasonId/streak-config", streakHandler.SaveStreakConfigHandle) + } + } +} +`; + +describe('GO_HTTP_PLUGIN — gin route groups', () => { + const got = providers(GIN_ROUTES); + const find = (method: string, p: string) => got.find((d) => d.method === method && d.path === p); + + it('emits exactly one provider per registered route (Use() and Group() are not routes)', () => { + expect(got).toHaveLength(10); + }); + + it('keeps a route on the engine root, outside any group, at its literal path', () => { + expect(find('GET', '/ping')).toEqual({ method: 'GET', path: '/ping', name: 'pingHandle' }); + }); + + it('prefixes an inline func_literal handler and leaves it unnamed', () => { + expect(find('GET', '/api/v1/health')).toEqual({ + method: 'GET', + path: '/api/v1/health', + name: null, + }); + }); + + it('accepts a method-value handler and names it by its field', () => { + expect(find('GET', '/api/v1/players')?.name).toBe('GetPlayersHandle'); + }); + + it('accepts a package-qualified function handler and names it by its field', () => { + expect(find('GET', '/api/v1/exports')?.name).toBe('ListExportsHandle'); + }); + + it('keeps an identifier handler', () => { + expect(find('POST', '/api/v1/upload/avatar')?.name).toBe('UploadAvatarHandle'); + }); + + it('binds the last argument, not the variadic middleware before it', () => { + expect(find('PATCH', '/api/v1/players/:playerId')?.name).toBe('UpdatePlayerHandle'); + }); + + it('joins a nested group inside a { } block', () => { + expect(find('POST', '/api/v1/admin/seasons/:seasonId/rounds/:roundId/unfinalize')?.name).toBe( + 'UnfinalizeRoundHandle', + ); + }); + + it('joins groups nested three levels deep', () => { + expect(find('DELETE', '/api/v1/admin/news/:id')?.name).toBe('DeleteNewsHandle'); + }); + + it('treats an empty-prefix group (with or without middleware) as its parent prefix', () => { + expect(find('POST', '/api/v1/admin/seasons/:seasonId/knockout')?.name).toBe( + 'CreateKnockoutHandle', + ); + expect(find('PUT', '/api/v1/admin/seasons/:seasonId/streak-config')?.name).toBe( + 'SaveStreakConfigHandle', + ); + }); +}); + +describe('GO_HTTP_PLUGIN — group binding edge cases', () => { + it('follows plain `=` assignment and `var x = …` declarations', () => { + const got = providers(`package main +func routes(r *gin.Engine) { + var api *gin.RouterGroup + api = r.Group("/v2") + api.GET("/a", h.A) + var ops = api.Group("/ops") + ops.GET("/b", h.B) +} +`); + expect(got).toEqual([ + { method: 'GET', path: '/v2/a', name: 'A' }, + { method: 'GET', path: '/v2/ops/b', name: 'B' }, + ]); + }); + + it('joins a Group() call chained directly onto the route call', () => { + expect( + providers(`package main +func routes(r *gin.Engine) { + r.Group("/inline").GET("/y", h.Y) +} +`), + ).toEqual([{ method: 'GET', path: '/inline/y', name: 'Y' }]); + }); + + it('uses the binding visible at the call site when a name is reused in sibling blocks', () => { + expect( + providers(`package main +func routes(r *gin.Engine) { + { + g := r.Group("/a") + g.GET("/x", h.AX) + } + { + g := r.Group("/b") + g.GET("/x", h.BX) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/a/x', name: 'AX' }, + { method: 'GET', path: '/b/x', name: 'BX' }, + ]); + }); + + it('uses the latest assignment that precedes the route, not one after it', () => { + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group("/first") + g.GET("/x", h.X) + g = r.Group("/second") + g.GET("/y", h.Y) +} +`), + ).toEqual([ + { method: 'GET', path: '/first/x', name: 'X' }, + { method: 'GET', path: '/second/y', name: 'Y' }, + ]); + }); + + it('resolves a group captured by a closure registered inside the function', () => { + expect( + providers(`package main +func routes(r *gin.Engine) { + v1 := r.Group("/api/v1") + register := func() { + v1.GET("/inner", h.Inner) + } + register() +} +`), + ).toEqual([{ method: 'GET', path: '/api/v1/inner', name: 'Inner' }]); + }); + + it('keeps the literal path when the receiver is a parameter (group passed from another function)', () => { + expect( + providers(`package main +func registerAdmin(g *gin.RouterGroup) { + g.GET("/extra", extraHandle) +} +`), + ).toEqual([{ method: 'GET', path: '/extra', name: 'extraHandle' }]); + }); + + it('keeps the literal path when the receiver is bound to something other than Group()', () => { + expect( + providers(`package main +func routes() { + g := newRouter("/ignored") + g.GET("/x", h.X) +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'X' }]); + }); + + it('does not resolve a group bound in a different function', () => { + expect( + providers(`package main +func a(r *gin.Engine) { + g := r.Group("/a") + g.GET("/in-a", h.A) +} +func b(g *gin.RouterGroup) { + g.GET("/in-b", h.B) +} +`), + ).toEqual([ + { method: 'GET', path: '/a/in-a', name: 'A' }, + { method: 'GET', path: '/in-b', name: 'B' }, + ]); + }); + + it('ignores a Group() whose prefix is not a string literal and keeps the route literal', () => { + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group(prefix) + g.GET("/x", h.X) +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'X' }]); + }); + + it('uses the group bound by an if initializer in the body and in the else branch', () => { + expect( + providers(`package main +func routes(r *gin.Engine, cond bool) { + g := r.Group("/outer") + if g := r.Group("/inner"); cond { + g.GET("/x", h.X) + } else { + g.GET("/y", h.Y) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/inner/x', name: 'X' }, + { method: 'GET', path: '/inner/y', name: 'Y' }, + ]); + }); + + it('keeps the outer group when an if initializer binds a different name', () => { + expect( + providers(`package main +func routes(r *gin.Engine, cond bool) { + g := r.Group("/outer") + if x := prepare(); cond { + g.GET("/x", h.X) + } +} +`), + ).toEqual([{ method: 'GET', path: '/outer/x', name: 'X' }]); + }); + + it('stops at an if initializer bound to something other than Group()', () => { + expect( + providers(`package main +func routes(r *gin.Engine, cond bool) { + g := r.Group("/outer") + if g := build(); cond { + g.GET("/x", h.X) + } +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'X' }]); + }); + + it('uses a switch initializer group and a group declared inside a case clause', () => { + expect( + providers(`package main +func routes(r *gin.Engine, cond bool) { + g := r.Group("/outer") + switch g := r.Group("/s"); g != nil { + case cond: + g.GET("/x", h.X) + } + switch { + case cond: + g := r.Group("/case") + g.GET("/y", h.Y) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/s/x', name: 'X' }, + { method: 'GET', path: '/case/y', name: 'Y' }, + ]); + }); + + it('stops at a type-switch guard binding and resolves groups declared in a type case', () => { + expect( + providers(`package main +func routes(r *gin.Engine, anyVal any) { + g := r.Group("/outer") + switch g := anyVal.(type) { + case *Router: + g.GET("/x", h.X) + } + switch anyVal.(type) { + case interface{}: + g := r.Group("/t") + g.GET("/y", h.Y) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/x', name: 'X' }, + { method: 'GET', path: '/t/y', name: 'Y' }, + ]); + }); + + it('stops at a select receive binding instead of inheriting an outer group', () => { + // The value received from a channel is statically unknown, so a case that + // rebinds `g` must shadow the outer group with "nothing traceable" (the + // route keeps its literal path) — not leak `/outer` into the id. An + // unrebound case still inherits, and a default clause declaring its own + // group shadows like any other statement list. + expect( + providers(`package main +func routes(r *gin.Engine, ch chan *gin.RouterGroup) { + g := r.Group("/outer") + select { + case g := <-ch: + g.GET("/x", h.X) + } + select { + case <-ch: + g.GET("/y", h.Y) + } + select { + default: + g := r.Group("/d") + g.GET("/z", h.Z) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/x', name: 'X' }, + { method: 'GET', path: '/outer/y', name: 'Y' }, + { method: 'GET', path: '/d/z', name: 'Z' }, + ]); + }); + + it('keeps the outer group through a plain for init and stops at a range shadow binding', () => { + expect( + providers(`package main +func routes(r *gin.Engine, n int, subs []*gin.RouterGroup) { + g := r.Group("/outer") + for i := 0; i < n; i++ { + g.GET("/i", h.I) + } + for _, g := range subs { + g.GET("/r", h.R) + } + for g := r.Group("/loop"); ; { + g.GET("/l", h.L) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/outer/i', name: 'I' }, + { method: 'GET', path: '/r', name: 'R' }, + { method: 'GET', path: '/loop/l', name: 'L' }, + ]); + }); + + it('declines a route whose group a loop reassigns between iterations', () => { + // The post statement (or the body) runs between iterations, so from the + // second pass on the body sees the reassigned group rather than the one + // it entered with: the prefix is control-flow dependent, so the route is + // declined instead of emitting either value. A loop that never writes the + // name keeps its initializer binding. + expect( + providers(`package main +func routes(r *gin.Engine, cond bool, n int) { + g := r.Group("/old") + for ; cond; g = r.Group("/post") { + g.GET("/a", h.A) + } + for g := r.Group("/init"); ; g = r.Group("/post3") { + g.GET("/c", h.C) + } + for k := r.Group("/k"); cond; { + k.GET("/d", h.D) + } +} +`), + ).toEqual([{ method: 'GET', path: '/k/d', name: 'D' }]); + }); + + it('accepts a raw-string (backtick) group prefix', () => { + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group(\`/api\`) + g.GET("/x", h.X) +} +`), + ).toEqual([{ method: 'GET', path: '/api/x', name: 'X' }]); + }); + + it('accepts a raw-string (backtick) route path, as ingestion does', () => { + // Ingestion (Strategy A) decodes both Go string forms for the route path; + // the group layer must emit the same contract for a backtick path, both + // on a bound group and on a chained `Group(...).GET(...)` receiver. + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group("/api") + g.GET(\`/health\`, h.Health) + r.Group("/v1").POST(\`/raw\\x2fy\`, h.Raw) +} +`), + ).toEqual([ + { method: 'GET', path: '/api/health', name: 'Health' }, + { method: 'POST', path: '/v1/raw\\x2fy', name: 'Raw' }, + ]); + }); + + it('decodes Go string escapes in group prefixes and route paths', () => { + // "/api\x2fv1" and "/health\x2fcheck" are `/api/v1` and `/health/check` + // once Go processes the escapes — the id must match the registered URL. + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group("/api\\x2fv1") + g.GET("/health\\x2fcheck", h.H) +} +`), + ).toEqual([{ method: 'GET', path: '/api/v1/health/check', name: 'H' }]); + }); + + it('leaves escapes literal inside a raw-string (backtick) prefix', () => { + // Go raw strings process no escapes: the prefix is `/raw\x2fy`, backslash included. + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group(\`/raw\\x2fy\`) + g.GET("/z", h.Z) +} +`), + ).toEqual([{ method: 'GET', path: '/raw\\x2fy/z', name: 'Z' }]); + }); + + it('collapses duplicate slashes so the path matches ingestion normalization', () => { + // normalizeExtractedRoutePath collapses every "//" run; the downstream + // contract-id normalizer does not — a path keeping "//" would get a + // different id than the graph's route node for the same route. + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group("/api//v1") + g.GET("/a//b", h.A) + r.GET("//root", h.R) + r.GET("/ok", h.Ok) +} +`), + ).toEqual([ + { method: 'GET', path: '/api/v1/a/b', name: 'A' }, + { method: 'GET', path: '/root', name: 'R' }, + { method: 'GET', path: '/ok', name: 'Ok' }, + ]); + }); + + it('forces a leading slash on slashless literals and group prefixes', () => { + // Ingestion's normalizeExtractedRoutePath always adds a leading "/" but + // normalizeHttpPath (the shared contract-id normalizer) does not — a path + // like "x" would become `http::GET::x` here and `http::GET::/x` there, + // splitting the id across the two strategies. Gin likewise panics when a + // route is registered without one. + expect( + providers(`package main +func routes(r *gin.Engine) { + r.GET("x", h.X) + g := r.Group("api") + g.GET("y", h.Y) + r.GET("", h.Root) +} +`), + ).toEqual([ + { method: 'GET', path: '/x', name: 'X' }, + { method: 'GET', path: '/api/y', name: 'Y' }, + { method: 'GET', path: '/', name: 'Root' }, + ]); + }); + + it("binds echo's handler (first argument) when the file imports echo only", () => { + // echo is `GET(path, handler, middleware...)` — the handler is second, + // not last, so a middleware selector must not become the route's name. + expect( + providers(`package main +import "github.com/labstack/echo/v4" + +func routes(e *echo.Echo) { + e.GET("/x", h.Handler, auth.Middleware) + e.POST("/y", handlerID) + e.PUT("/z", func(c echo.Context) error { return nil }) +} +`), + ).toEqual([ + { method: 'GET', path: '/x', name: 'Handler' }, + { method: 'POST', path: '/y', name: 'handlerID' }, + { method: 'PUT', path: '/z', name: null }, + ]); + }); + + it('picks the handler order from a typed receiver parameter in a mixed-import file', () => { + // A parameter's static type proves its framework: `*echo.Echo` / + // `*echo.Group` take echo's order (handler FIRST), gin's types and types + // the file cannot tie to echo keep the last-argument fallback. Method + // receivers and func-literal parameters count the same way. + expect( + providers(`package main +import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +) + +func routes(e *echo.Echo, g *echo.Group, r *gin.RouterGroup, x *Router) { + e.GET("/e", h.Handler, auth.Middleware) + g.GET("/g", h.Handler, auth.Middleware) + r.GET("/r", auth.Middleware, h.Handler) + x.GET("/x", h.Handler, auth.Middleware) + register := func(sub *echo.Group) { + sub.GET("/f", h.Handler, auth.Middleware) + } + _ = register +} + +type Server struct{} + +func (s *Server) routes(api *echo.Group) { + api.GET("/m", h.Handler, auth.Middleware) +} +`), + ).toEqual([ + { method: 'GET', path: '/e', name: 'Handler' }, + { method: 'GET', path: '/g', name: 'Handler' }, + { method: 'GET', path: '/r', name: 'Handler' }, + { method: 'GET', path: '/x', name: 'Middleware' }, + { method: 'GET', path: '/f', name: 'Handler' }, + { method: 'GET', path: '/m', name: 'Handler' }, + ]); + }); + + it('matches framework imports by exact path, as ingestion does', () => { + // `example.com/labstack/echo-wrapper` merely contains "labstack/echo": it + // is not echo, so this gin-style call keeps the last-argument handler. + expect( + providers(`package main +import "example.com/labstack/echo-wrapper" + +func routes(r *Router) { + r.GET("/x", auth.Middleware, h.Handler) +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'Handler' }]); + // The versioned module path is echo. + expect( + providers(`package main +import "github.com/labstack/echo/v4" + +func routes(e *echo.Echo) { + e.GET("/x", h.Handler, auth.Middleware) +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'Handler' }]); + }); + + it('picks the handler order per receiver constructor in a mixed-import file', () => { + // Mixed imports are ambiguous at file scope, but `e := echo.New()` proves + // this call follows echo's order (handler FIRST after the path) and + // `r := gin.Default()` proves gin's (handler LAST). + expect( + providers(`package main +import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +) + +func routes() { + e := echo.New() + r := gin.Default() + e.GET("/x", h.Handler, auth.Middleware) + r.POST("/y", auth.Middleware, h.Post) +} +`), + ).toEqual([ + { method: 'GET', path: '/x', name: 'Handler' }, + { method: 'POST', path: '/y', name: 'Post' }, + ]); + }); + + it('does not treat an unrelated New() as a framework constructor', () => { + // `wrapper.New()` is neither echo's nor gin's constructor: no proof → + // conservative last-argument fallback, not a guessed echo order. + expect( + providers(`package main +import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +) + +func routes() { + e := wrapper.New() + e.GET("/x", h.Handler, auth.Middleware) +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'Middleware' }]); + }); + + it('does not mistake a local shadowing the echo import for its constructor', () => { + // A parameter named `echo` shadows the package qualifier: `echo.New()` + // is then a method on that value, not echo's constructor, so the mixed + // file keeps the conservative last-argument fallback. + expect( + providers(`package main +import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +) + +func routes(echo *Factory) { + e := echo.New() + e.GET("/x", h.Handler, auth.Middleware) +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'Middleware' }]); + }); + + it('does not mistake a value-less local declaration of echo for the import', () => { + // `var echo Factory` declares a local without an initializer; it still + // shadows the package qualifier, so `echo.New()` proves nothing. + expect( + providers(`package main +import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +) + +func routes() { + var echo Factory + e := echo.New() + e.GET("/x", h.Handler, auth.Middleware) +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'Middleware' }]); + }); + + it('declines a route whose Group chain exceeds the depth cap', () => { + // Past MAX_GROUP_DEPTH (32) the full prefix — and, in a mixed file, the + // framework order — is unprovable: emitting the outer prefixes alone (or + // gin's handler order for an echo chain) would be a silent guess. + const chain = (ctor: string, imports: string) => { + const lines = [`g0 := ${ctor}`]; + for (let i = 1; i <= 34; i++) lines.push(`g${i} := g${i - 1}.Group("/p${i}")`); + return `package main +${imports} + +func routes() { + ${lines.join('\n\t')} + g34.GET("/x", h.Handler, auth.Middleware) + g2.GET("/y", h.Handler, auth.Middleware) +} +`; + }; + expect(providers(chain('gin.Default()', 'import "github.com/gin-gonic/gin"'))).toEqual([ + { method: 'GET', path: '/p1/p2/y', name: 'Middleware' }, + ]); + const mixed = `import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +)`; + expect(providers(chain('echo.New()', mixed))).toEqual([ + { method: 'GET', path: '/p1/p2/y', name: 'Handler' }, + ]); + }); + + it('traces a grouped receiver back to its framework constructor in a mixed file', () => { + // The normal grouped shape: `users := api.Group(…)` ← `api := e.Group(…)` + // ← `echo.New()` proves echo order through the Group chain, while the + // gin chain resolves to gin.Default() and keeps the last-argument rule. + expect( + providers(`package main +import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +) + +func routes() { + e := echo.New() + api := e.Group("/api") + users := api.Group("/users") + users.GET("/:id", h.Handler, auth.Middleware) + r := gin.Default() + v1 := r.Group("/v1") + v1.POST("/x", auth.Middleware, h.Post) +} +`), + ).toEqual([ + { method: 'GET', path: '/api/users/:id', name: 'Handler' }, + { method: 'POST', path: '/v1/x', name: 'Post' }, + ]); + }); + + it('marks handler resolution by how the handler is designated', () => { + // `h.List` / `o.List` emit the field name `List`, but the operand does not + // prove where `List` is declared: they are qualifiedHandler (repo-wide + // unique match only, never a same-named local method). A bare name + // declared more than once in the file (`Show` as function and method) + // resolves only when unique in the file; a bare unique name keeps the + // default resolution. + parser.setLanguage(Go); + const flags = GO_HTTP_PLUGIN.scan( + parser.parse(`package main +import "github.com/gin-gonic/gin" + +type A struct{} + +func (a *A) List(c *gin.Context) {} +func (a *A) Show(c *gin.Context) {} +func Show(c *gin.Context) {} +func Ping(c *gin.Context) {} + +func routes(r *gin.Engine, h *A, o *B) { + r.GET("/a", h.List) + r.GET("/b", o.List) + r.GET("/s", Show) + r.GET("/p", Ping) +} +`), + ) + .filter((d) => d.role === 'provider') + .map((d) => [ + d.path, + d.name, + d.qualifiedHandler ?? false, + d.strictHandlerResolution ?? false, + ]); + expect(flags).toEqual([ + ['/a', 'List', true, false], + ['/b', 'List', true, false], + ['/s', 'Show', false, true], + ['/p', 'Ping', false, false], + ]); + }); + + it('resolves grouped var specs that precede the use', () => { + // Earlier specs in a grouped `var (…)` are in scope for later ones; the + // ingestion side emits /api/admin/x for this, so both strategies agree. + expect( + providers(`package main +func routes(r *gin.Engine) { + var ( + api = r.Group("/api") + admin = api.Group("/admin") + ) + admin.GET("/x", handler) +} +`), + ).toEqual([{ method: 'GET', path: '/api/admin/x', name: 'handler' }]); + }); + + it('follows unconditional block writes and declines conditional ones', () => { + // A bare `{ … }` always runs, so `{ g = r.Group("/new") }` makes the + // route deterministically /new/x. A write in a branch (`if cond { k = … }`, + // also when nested inside a bare block) may or may not run, so that + // prefix is unprovable and the route is declined. A `:=` in a nested + // block declares a new variable: writes after it never reach the outer m. + expect( + providers(`package main +func routes(r *gin.Engine, cond bool) { + g := r.Group("/old") + { g = r.Group("/new") } + g.GET("/x", handler) + k := r.Group("/k") + if cond { k = r.Group("/other") } + k.GET("/y", handler) + n := r.Group("/n") + { if cond { n = r.Group("/maybe") } } + n.GET("/w", handler) + m := r.Group("/m") + { m := r.Group("/inner"); m = r.Group("/inner2"); m.GET("/i", handler) } + m.GET("/z", handler) +} +`), + ).toEqual([ + { method: 'GET', path: '/new/x', name: 'handler' }, + { method: 'GET', path: '/inner2/i', name: 'handler' }, + { method: 'GET', path: '/m/z', name: 'handler' }, + ]); + }); + + it('declines a route whose group a shadowing initializer or a later closure writes', () => { + // A `:=`/`var` shadowing the name runs its initializer BEFORE the new + // variable is in scope, so a closure body there writes the OUTER group + // and the prefix of a use outside the block is unprovable. A closure + // declared before a direct assignment can also run AFTER it (`reset()`), + // so captured writes are checked independently of statement order rather + // than being hidden behind the block's last direct assignment. Both + // shapes were declined before bare-block writes were followed, and a + // closure that only READS the group must not decline anything. + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group("/old") + { + g := func() *gin.RouterGroup { + g = r.Group("/new") + return r.Group("/inner") + }() + _ = g + } + g.GET("/a", h.A) + k := r.Group("/k") + { + var k = func() *gin.RouterGroup { + k = r.Group("/k2") + return r.Group("/k3") + }() + _ = k + } + k.GET("/b", h.B) + m := r.Group("/m") + { + reset := func() { m = r.Group("/closure") } + m = r.Group("/new") + reset() + } + m.GET("/c", h.C) + n := r.Group("/n") + { + n = r.Group("/n2") + register := func() { n.GET("/registered", h.R) } + register() + } + n.GET("/d", h.D) +} +`), + ).toEqual([ + { method: 'GET', path: '/n2/registered', name: 'R' }, + { method: 'GET', path: '/n2/d', name: 'D' }, + ]); + }); + + it('takes the last target of a repeated assignment and declines range and receive writes', () => { + // Go carries an assignment out left to right, so `g, g = a, b` leaves the + // LAST value in place. `for _, g = range xs` and `case g = <-ch` write the + // existing name — the range only when its body runs, the receive with a + // statically unknown value — so those blocks stay CONFLICT. Their `:=` + // forms declare a loop- or case-local variable instead and leave the + // outer group alone. + expect( + providers(`package main +func routes(r *gin.Engine, groups []*gin.RouterGroup, ch chan *gin.RouterGroup) { + g := r.Group("/old") + { g, g = r.Group("/first"), r.Group("/second") } + g.GET("/a", h.A) + k := r.Group("/k") + { + k = r.Group("/k2") + for _, k = range groups {} + } + k.GET("/b", h.B) + m := r.Group("/m") + { + m = r.Group("/m2") + select { case m = <-ch: } + } + m.GET("/c", h.C) + n := r.Group("/n") + { + n = r.Group("/n2") + for _, n := range groups {} + } + n.GET("/d", h.D) +} +`), + ).toEqual([ + { method: 'GET', path: '/second/a', name: 'A' }, + { method: 'GET', path: '/n2/d', name: 'D' }, + ]); + }); + + it('resolves a name used in its own statement initializer to the outer binding', () => { + // `if g := g.Group("/inner"); …` — the right-hand g is the OUTER group; + // the new g only scopes over what follows. Same for a for initializer. + expect( + providers(`package main +func routes(r *gin.Engine, enabled bool) { + g := r.Group("/api") + if g := g.Group("/inner"); enabled { + g.GET("/x", handler) + } + for g := g.Group("/loop"); enabled; { + g.GET("/y", handler) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/api/inner/x', name: 'handler' }, + { method: 'GET', path: '/api/loop/y', name: 'handler' }, + ]); + }); + + it('skips comments when locating the path and the handler', () => { + // tree-sitter names comments, so they must not count as arguments: a + // leading comment must not hide the path, and a comment must not be + // picked as the echo (first) or gin (last) handler. + expect( + providers(`package main +import "github.com/labstack/echo/v4" +func routes(e *echo.Echo) { + e.GET("/users", /* description */ users) + e.POST(/* description */ "/posts", posts /* trailing */) +} +`), + ).toEqual([ + { method: 'GET', path: '/users', name: 'users' }, + { method: 'POST', path: '/posts', name: 'posts' }, + ]); + expect( + providers(`package main +import "github.com/gin-gonic/gin" +func routes(r *gin.Engine) { + r.GET(/* description */ "/users", users /* trailing */) + g := r.Group(/* c */ "/api") + g.GET("/x", h.X) +} +`), + ).toEqual([ + { method: 'GET', path: '/users', name: 'users' }, + { method: 'GET', path: '/api/x', name: 'X' }, + ]); + }); + + it('applies the same group logic to echo', () => { + expect( + providers(`package main +func main() { + e := echo.New() + api := e.Group("/api") + users := api.Group("/users") + users.GET("/:id", userHandler.Get) +} +`), + ).toEqual([{ method: 'GET', path: '/api/users/:id', name: 'Get' }]); + }); + + it('does not decline for a stored closure or a shadowed closure write', () => { + // Storing a function literal in a value does not run its body, so a `:=` + // whose initializer is an UNCALLED literal leaves the outer group alone; + // only an invoked literal (`… }()`), which runs during initialization, + // writes it. A closure whose own body declares the name — a nested `:=` + // or a parameter — writes that local, not the captured outer group. + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group("/old") + { + g := func() { g = r.Group("/stored") } + _ = g + } + g.GET("/a", h.A) + k := r.Group("/k") + { + reset := func() { k := r.Group("/local"); k = r.Group("/local2") } + k = r.Group("/new") + reset() + } + k.GET("/b", h.B) + m := r.Group("/m") + { + reset := func(m *gin.RouterGroup) { m = r.Group("/param") } + m = r.Group("/new") + reset(m) + } + m.GET("/c", h.C) +} +`), + ).toEqual([ + { method: 'GET', path: '/old/a', name: 'A' }, + { method: 'GET', path: '/new/b', name: 'B' }, + { method: 'GET', path: '/new/c', name: 'C' }, + ]); + }); + + it('resolves through closure-local range/receive shadows and parenthesized IIFEs', () => { + // A range or receive `=` inside a closure writes the name that closure's + // own body declares, so an earlier `if` holding it does not make the outer + // prefix unprovable — while the same `=` against the captured outer name + // does. A parenthesized immediately-invoked literal still runs its body, + // so it writes the outer group exactly like the unparenthesized form, + // whereas a stored parenthesized literal does not. + expect( + providers(`package main +func routes(r *gin.Engine, cond bool, groups []*gin.RouterGroup, ch chan *gin.RouterGroup) { + g := r.Group("/old") + { + if cond { + func() { + g := r.Group("/local") + for _, g = range groups {} + }() + } + g.GET("/a", h.A) + } + k := r.Group("/k") + { + if cond { + func() { + k := r.Group("/local") + select { case k = <-ch: _ = k } + }() + } + k.GET("/b", h.B) + } + m := r.Group("/m") + { + if cond { + func() { + for _, m = range groups {} + }() + } + m.GET("/c", h.C) + } + n := r.Group("/n") + { + n := (func() *gin.RouterGroup { n = r.Group("/paren"); return r.Group("/inner") })() + _ = n + } + n.GET("/d", h.D) + p := r.Group("/p") + { + p := (func() { p = r.Group("/stored") }) + _ = p + } + p.GET("/e", h.E) +} +`), + ).toEqual([ + { method: 'GET', path: '/old/a', name: 'A' }, + { method: 'GET', path: '/k/b', name: 'B' }, + { method: 'GET', path: '/p/e', name: 'E' }, + ]); + }); +}); + +describe('Go gin provider ↔ fetch() consumer pairing', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-go-gin-groups-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const repoHandle = (repoPath: string, id: string): RepoHandle => ({ + id, + path: id, + repoPath, + storagePath: path.join(repoPath, '.gitnexus'), + }); + + it('does not bind a qualified handler to an unrelated same-named local method', async () => { + // routes.go declares A.List; the route's handler is b.List, with B.List in + // b.go. The file-first name lookup would pick A.List, so a qualified + // handler skips it: a repo-wide unique List resolves, an ambiguous one + // keeps the file-level fallback (empty symbolUid) instead of A.List. + const repo = path.join(tmpDir, 'repo'); + fs.mkdirSync(repo, { recursive: true }); + fs.writeFileSync( + path.join(repo, 'routes.go'), + `package main + +import "github.com/gin-gonic/gin" + +type A struct{} + +func (a *A) List(c *gin.Context) {} + +func routes(r *gin.Engine, b *B) { + r.GET("/bs", b.List) +} +`, + ); + const aList = { + uid: 'Method:routes.go:A.List', + name: 'List', + filePath: 'routes.go', + startLine: 6, + endLine: 6, + labels: ['Method'], + }; + const bList = { uid: 'Method:b.go:B.List', name: 'List', filePath: 'b.go' }; + const run = async (repoWide: Record[]) => { + const db = async (query: string, params?: Record) => { + if (query === RESOLVE_BY_NAME_QUERY) return params?.name === 'List' ? repoWide : []; + if (query.includes('UNION ALL') && params?.filePath === 'routes.go') return [aList]; + return []; + }; + const out = await new HttpRouteExtractor().extract(db, repo, repoHandle(repo, 'repo')); + return out.find((c) => c.contractId === 'http::GET::/bs')?.symbolUid; + }; + expect(await run([aList, bList])).toBe(''); + expect(await run([bList])).toBe(bList.uid); + }); + + it('cross-links a grouped method-value route to a ${API_BASE}-prefixed fetch', async () => { + const backend = path.join(tmpDir, 'backend'); + const web = path.join(tmpDir, 'web'); + fs.mkdirSync(path.join(backend, 'internal/handlers'), { recursive: true }); + fs.mkdirSync(path.join(web, 'src/pages'), { recursive: true }); + fs.writeFileSync(path.join(backend, 'internal/handlers/routes.go'), GIN_ROUTES); + fs.writeFileSync( + path.join(web, 'src/pages/AdminMatchesPage.tsx'), + `const API_BASE = import.meta.env.VITE_API_BASE; + +export async function handleUnfinalize(seasonId: string, roundId: string) { + await fetch(\`\${API_BASE}/api/v1/admin/seasons/\${seasonId}/rounds/\${roundId}/unfinalize\`, { + method: 'POST', + }); +} +`, + ); + + const extractor = new HttpRouteExtractor(); + const contracts: StoredContract[] = [ + ...(await extractor.extract(null, backend, repoHandle(backend, 'backend'))).map((c) => ({ + ...c, + repo: 'backend', + })), + ...(await extractor.extract(null, web, repoHandle(web, 'web'))).map((c) => ({ + ...c, + repo: 'web', + })), + ]; + + const { matched } = runExactMatch(contracts); + const link = matched.find( + (l) => l.contractId === 'http::POST::/api/v1/admin/seasons/{param}/rounds/{param}/unfinalize', + ); + expect(link).toMatchObject({ + from: { repo: 'web', symbolRef: { filePath: 'src/pages/AdminMatchesPage.tsx' } }, + to: { + repo: 'backend', + symbolRef: { filePath: 'internal/handlers/routes.go', name: 'UnfinalizeRoundHandle' }, + }, + matchType: 'exact', + }); + }); +}); diff --git a/gitnexus/test/unit/group/grpc-extractor.test.ts b/gitnexus/test/unit/group/grpc-extractor.test.ts index bf1a98033..0c244b3f2 100644 --- a/gitnexus/test/unit/group/grpc-extractor.test.ts +++ b/gitnexus/test/unit/group/grpc-extractor.test.ts @@ -1391,6 +1391,7 @@ service Foo { rpc Bar (Req) returns (Res); }`; describe('resolveProtoConflict', () => { const makeInfo = (pkg: string, protoPath: string): ProtoServiceInfo => ({ package: pkg, + javaPackage: '', serviceName: 'Svc', methods: ['Do'], protoPath, diff --git a/gitnexus/test/unit/group/java-const-route-parity.test.ts b/gitnexus/test/unit/group/java-const-route-parity.test.ts index 62c028e8e..256959c3b 100644 --- a/gitnexus/test/unit/group/java-const-route-parity.test.ts +++ b/gitnexus/test/unit/group/java-const-route-parity.test.ts @@ -29,7 +29,6 @@ import { expandJavaWildcardStaticImports, extractJavaModuleConstants, foldJavaOperands, - type RepoConstants, } from '../../../src/core/ingestion/route-extractors/java-const-resolver.js'; const parser = new Parser(); @@ -42,6 +41,7 @@ const parse = (src: string): Parser.Tree => parseSource(parser, src); /** Group side: prepareRepo + a 3-argument scan over every .java file. */ function groupProviders(files: Record): string[] { const ctx = JAVA_HTTP_PLUGIN.prepareRepo?.({ + repoPath: '/repo', files: Object.keys(files), parser: new Parser(), readFile: (rel: string) => files[rel] ?? null, @@ -59,7 +59,7 @@ function groupProviders(files: Record): string[] { /** Ingestion side: extract routes, then fold operands against the same map. */ function ingestionRoutes(files: Record): string[] { - const repo: RepoConstants = new Map(); + const repo = new Map>(); for (const [rel, src] of Object.entries(files)) { repo.set(rel, extractJavaModuleConstants(parse(src))); } diff --git a/gitnexus/test/unit/group/kotlin-const-route-fold.test.ts b/gitnexus/test/unit/group/kotlin-const-route-fold.test.ts index e38e05079..c486a4ed3 100644 --- a/gitnexus/test/unit/group/kotlin-const-route-fold.test.ts +++ b/gitnexus/test/unit/group/kotlin-const-route-fold.test.ts @@ -61,7 +61,7 @@ const describeKotlin = Kotlin && KOTLIN_HTTP_PLUGIN ? describe : describe.skip; const plugin = KOTLIN_HTTP_PLUGIN as HttpLanguagePlugin; const parseSource = (p: Parser, src: string): Parser.Tree => { - p.setLanguage(Kotlin as Parser.Language); + p.setLanguage(Kotlin); return p.parse(src); }; diff --git a/gitnexus/test/unit/group/service.test.ts b/gitnexus/test/unit/group/service.test.ts index f56522ef9..fde1be468 100644 --- a/gitnexus/test/unit/group/service.test.ts +++ b/gitnexus/test/unit/group/service.test.ts @@ -2,13 +2,17 @@ import { describe, it, expect, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; +import { execFileSync } from 'node:child_process'; import { GroupService, type GroupToolPort, type GroupRepoHandle, } from '../../../src/core/group/service.js'; import { writeContractRegistry } from '../../../src/core/group/storage.js'; -import { formatIndexStatusCell } from '../../../src/cli/group-status-format.js'; +import { + formatIndexStatusCell, + type GroupRepoIndexRow, +} from '../../../src/cli/group-status-format.js'; import type { ContractRegistry, StoredContract, CrossLink } from '../../../src/core/group/types.js'; function makeTmpGroup(): { tmpDir: string; groupDir: string; cleanup: () => void } { @@ -299,7 +303,7 @@ describe('GroupService', () => { const { cleanup, tmpDir } = makeTmpGroup(); try { vi.stubEnv('GITNEXUS_HOME', tmpDir); - const query = vi.fn(async () => ({ processes: [] })); + const query = vi.fn(async () => ({ processes: [] })); const svc = new GroupService(makePort({ query })); await svc.groupQuery({ name: 'test-group', query: 'auth flow' }); expect(query).toHaveBeenCalled(); @@ -316,7 +320,7 @@ describe('GroupService', () => { const { cleanup, tmpDir } = makeTmpGroup(); try { vi.stubEnv('GITNEXUS_HOME', tmpDir); - const query = vi.fn(async () => ({ processes: [] })); + const query = vi.fn(async () => ({ processes: [] })); const svc = new GroupService(makePort({ query })); await svc.groupQuery({ name: 'test-group', @@ -338,7 +342,7 @@ describe('GroupService', () => { const { cleanup, tmpDir } = makeTmpGroup(); try { vi.stubEnv('GITNEXUS_HOME', tmpDir); - const query = vi.fn(async () => ({ processes: [] })); + const query = vi.fn(async () => ({ processes: [] })); const svc = new GroupService(makePort({ query })); const infiniteLimit = await svc.groupQuery({ @@ -399,7 +403,7 @@ describe('GroupService', () => { const { cleanup, tmpDir } = makeTmpGroup(); try { vi.stubEnv('GITNEXUS_HOME', tmpDir); - const query = vi.fn(async () => ({ processes: [] })); + const query = vi.fn(async () => ({ processes: [] })); const svc = new GroupService(makePort({ query })); await svc.groupQuery({ name: 'test-group', query: 'auth flow', chain_depth: 2 }); expect(query).toHaveBeenCalled(); @@ -571,7 +575,7 @@ repos: const { cleanup, tmpDir } = makeTmpGroup(); try { vi.stubEnv('GITNEXUS_HOME', tmpDir); - const context = vi.fn(async () => ({ status: 'found' })); + const context = vi.fn(async () => ({ status: 'found' })); const svc = new GroupService(makePort({ context })); const r = await svc.groupContext({ name: 'test-group', @@ -590,7 +594,7 @@ repos: const { cleanup, tmpDir } = makeTmpGroup(); try { vi.stubEnv('GITNEXUS_HOME', tmpDir); - const context = vi.fn(async () => ({ + const context = vi.fn(async () => ({ status: 'found', symbol: { filePath: 'services/auth/x.ts', uid: 'u1', name: 'X' }, })); @@ -717,10 +721,7 @@ repos: const svc = new GroupService(port); const result = (await svc.groupStatus({ name: 'test-group' })) as { - repos: Record< - string, - { indexStale: boolean; commitsBehind?: number; status?: string; missing: boolean } - >; + repos: Record; }; const row = result.repos['app/backend']; @@ -736,5 +737,67 @@ repos: cleanup(); } }); + + it('renders a real rollback as an index that differs from HEAD', async () => { + const { cleanup, tmpDir } = makeTmpGroup(); + try { + vi.stubEnv('GITNEXUS_HOME', tmpDir); + const repoPath = path.join(tmpDir, 'rollback'); + fs.mkdirSync(repoPath); + const git = (...args: string[]): string => + execFileSync( + 'git', + [ + '-c', + 'user.email=t@example.com', + '-c', + 'user.name=T', + '-c', + 'commit.gpgsign=false', + ...args, + ], + { cwd: repoPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, + ).trim(); + git('init', '-q', '--initial-branch=main'); + git('commit', '--allow-empty', '-qm', 'first'); + const firstCommit = git('rev-parse', 'HEAD'); + git('commit', '--allow-empty', '-qm', 'indexed'); + const indexedCommit = git('rev-parse', 'HEAD'); + git('checkout', '-q', '--detach', firstCommit); + + const storagePath = path.join(repoPath, '.gitnexus'); + fs.mkdirSync(storagePath); + fs.writeFileSync( + path.join(storagePath, 'gitnexus.json'), + JSON.stringify({ lastCommit: indexedCommit, indexedAt: '2026-01-01T00:00:00.000Z' }), + ); + const port = makePort({ + resolveRepo: vi.fn( + async (name?: string): Promise => ({ + id: name || 'test', + name: name || 'test', + repoPath, + storagePath, + }), + ), + }); + + const svc = new GroupService(port); + const result = (await svc.groupStatus({ name: 'test-group' })) as { + repos: Record; + }; + const row = result.repos['app/backend']; + expect(row).toMatchObject({ + missing: false, + indexStale: true, + commitsBehind: 0, + status: 'diverged', + }); + expect(formatIndexStatusCell(row)).toBe('STALE (index differs from HEAD)'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); }); }); diff --git a/gitnexus/test/unit/group/sync-partial-extraction.test.ts b/gitnexus/test/unit/group/sync-partial-extraction.test.ts index e1588be1c..d676cdcbe 100644 --- a/gitnexus/test/unit/group/sync-partial-extraction.test.ts +++ b/gitnexus/test/unit/group/sync-partial-extraction.test.ts @@ -54,10 +54,12 @@ const grpcExtract = vi.fn(); // Bound through an arrow so the test body can read its calls: which repos the // deferred manifest phase re-opens is the observable side of dropping a failed // repo's handle, and a `vi.fn()` created inside the factory is unreachable here. -const initLbugMock = vi.fn(async () => {}); +const initLbugMock = vi.fn( + async () => true, +); vi.mock('../../../src/core/lbug/pool-adapter.js', () => ({ - initLbug: (...args: unknown[]) => initLbugMock(...args), + initLbug: initLbugMock, executeParameterized: vi.fn(async () => []), pinRepo: vi.fn(() => () => {}), getMaxResidentRepos: vi.fn(() => 5), diff --git a/gitnexus/test/unit/group/sync-registry-identity.test.ts b/gitnexus/test/unit/group/sync-registry-identity.test.ts index fb63a1386..f718ad515 100644 --- a/gitnexus/test/unit/group/sync-registry-identity.test.ts +++ b/gitnexus/test/unit/group/sync-registry-identity.test.ts @@ -9,10 +9,12 @@ import type { GroupConfig } from '../../../src/core/group/types.js'; import { GroupService } from '../../../src/core/group/service.js'; import type { GroupToolPort } from '../../../src/core/group/service.js'; -const initLbugMock = vi.fn(async () => {}); +const initLbugMock = vi.fn( + async () => true, +); vi.mock('../../../src/core/lbug/pool-adapter.js', () => ({ - initLbug: (...args: unknown[]) => initLbugMock(...args), + initLbug: (...args: Parameters) => initLbugMock(...args), executeParameterized: vi.fn(async () => []), pinRepo: vi.fn(() => () => {}), getMaxResidentRepos: vi.fn(() => 5), diff --git a/gitnexus/test/unit/group/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts index 9e844fced..878aa0581 100644 --- a/gitnexus/test/unit/group/sync.test.ts +++ b/gitnexus/test/unit/group/sync.test.ts @@ -1438,6 +1438,7 @@ describe('syncGroup windowed manifest resolution (issue #2189 / PR #2191 review) const n = (initCounts.get(id) ?? 0) + 1; initCounts.set(id, n); if (id === 'app-repo-2' && n === 2) throw new Error('window init boom'); + return true; }); const execSpy = vi.spyOn(poolAdapter, 'executeParameterized').mockResolvedValue([]); const pinSpy = vi.spyOn(poolAdapter, 'pinRepo').mockImplementation(() => { diff --git a/gitnexus/test/unit/has-method.test.ts b/gitnexus/test/unit/has-method.test.ts index 5c17cc730..7c3484b04 100644 --- a/gitnexus/test/unit/has-method.test.ts +++ b/gitnexus/test/unit/has-method.test.ts @@ -509,24 +509,17 @@ class Outer { }); it('returns null for a node without parent', () => { - // Simulate a node with null parent - const fakeNode = { parent: null }; - const result = findEnclosingClassId(fakeNode, filePath); + const root = parseCode(TypeScript.typescript, 'const value = 1;').rootNode; + expect(root.parent).toBeNull(); + const result = findEnclosingClassId(root, filePath); expect(result).toBeNull(); }); it('skips containers without a name node', () => { - // Simulate AST nodes where the class container has no name - const fakeClassNode = { - type: 'class_declaration', - childForFieldName: () => null, - children: [], - parent: null, - }; - const fakeChild = { - parent: fakeClassNode, - }; - const result = findEnclosingClassId(fakeChild, filePath); + const tree = parseCode(TypeScript.typescript, 'const Anonymous = class { method() {} };'); + const method = findNode(tree.rootNode, (node) => node.type === 'method_definition'); + expect(method).not.toBeNull(); + const result = findEnclosingClassId(method!, filePath); // The class has no name, so should return null expect(result).toBeNull(); }); diff --git a/gitnexus/test/unit/hook-db-lock-probe.test.ts b/gitnexus/test/unit/hook-db-lock-probe.test.ts index c31c214c1..91d035eb0 100644 --- a/gitnexus/test/unit/hook-db-lock-probe.test.ts +++ b/gitnexus/test/unit/hook-db-lock-probe.test.ts @@ -738,7 +738,7 @@ describe.skipIf(!isLinux)('Linux cmdline-first DB-owner scan (#2180)', () => { err.code = code; throw err; } - return (realReaddir as (...a: unknown[]) => unknown)(p, ...rest); + return realReaddir(p, ...rest); }); cleanups.push(() => spy.mockRestore()); // White-box: assert the exported tri-state verdict directly (the diff --git a/gitnexus/test/unit/ignore-service.test.ts b/gitnexus/test/unit/ignore-service.test.ts index 60d6e8f77..307fd5a15 100644 --- a/gitnexus/test/unit/ignore-service.test.ts +++ b/gitnexus/test/unit/ignore-service.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } import fs from 'fs/promises'; import path from 'path'; import os from 'os'; +import { execFileSync } from 'child_process'; import { shouldIgnorePath, isHardcodedIgnoredDirectory, @@ -687,6 +688,319 @@ describe('createIgnoreFilter', () => { }); }); +describe('createIgnoreFilter with nested .gitignore files (#2675)', () => { + let tmpDir: string; + let originalNoGitignore: string | undefined; + + const asPath = (rel: string) => ({ name: path.basename(rel), relative: () => rel }) as any; + + beforeEach(async () => { + originalNoGitignore = process.env.GITNEXUS_NO_GITIGNORE; + // These tests expect nested rules to apply, so a value inherited from the + // invoking shell must not switch them off. afterEach restores it. + delete process.env.GITNEXUS_NO_GITIGNORE; + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-nested-ignore-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + if (originalNoGitignore === undefined) { + delete process.env.GITNEXUS_NO_GITIGNORE; + } else { + process.env.GITNEXUS_NO_GITIGNORE = originalNoGitignore; + } + }); + + it('applies a nested .gitignore relative to its own directory', async () => { + await fs.mkdir(path.join(tmpDir, 'app', 'public', 'generated'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'app', '.gitignore'), 'public/generated/\n*.log\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(asPath('app/public/generated'))).toBe(true); + expect(filter.ignored(asPath('app/public/generated/bundle.js'))).toBe(true); + expect(filter.ignored(asPath('app/debug.log'))).toBe(true); + expect(filter.ignored(asPath('app/src/deep/debug.log'))).toBe(true); + + // Rules stay scoped to the directory that declares them. + expect(filter.childrenIgnored(asPath('public/generated'))).toBe(false); + expect(filter.ignored(asPath('debug.log'))).toBe(false); + expect(filter.ignored(asPath('other/debug.log'))).toBe(false); + expect(filter.ignored(asPath('app/src/index.ts'))).toBe(false); + }); + + it('preserves independent root exclusions beneath a GitNexus directory negation', async () => { + await fs.mkdir(path.join(tmpDir, 'pkg', 'generated'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.gitignore'), '*.log\n**/pkg/generated/\n'); + await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!pkg/\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(asPath('pkg'))).toBe(false); + expect(filter.ignored(asPath('pkg/debug.log'))).toBe(true); + expect(filter.childrenIgnored(asPath('pkg/generated'))).toBe(true); + expect(filter.ignored(asPath('pkg/generated/index.ts'))).toBe(true); + expect(filter.ignored(asPath('pkg/index.ts'))).toBe(false); + }); + + it('preserves root inclusions after an unrelated nested directory negation', async () => { + await fs.mkdir(path.join(tmpDir, 'pkg', 'reports', '__tests__'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.gitignore'), '!__tests__/\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', '.gitignore'), '!reports/\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', 'reports', '__tests__', 'test.ts'), 'export {};\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(asPath('pkg/reports/__tests__'))).toBe(false); + expect(filter.ignored(asPath('pkg/reports/__tests__/test.ts'))).toBe(false); + const { walkRepositoryPaths } = await import('../../src/core/ingestion/filesystem-walker.js'); + expect((await walkRepositoryPaths(tmpDir)).map((f) => f.path)).toContain( + 'pkg/reports/__tests__/test.ts', + ); + }); + + it('lets a deeper nested negation re-include what an outer nested file ignored', async () => { + await fs.mkdir(path.join(tmpDir, 'app', 'lib'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'app', '.gitignore'), '*.gen.ts\n'); + await fs.writeFile(path.join(tmpDir, 'app', 'lib', '.gitignore'), '!keep.gen.ts\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.ignored(asPath('app/lib/keep.gen.ts'))).toBe(false); + expect(filter.ignored(asPath('app/lib/other.gen.ts'))).toBe(true); + }); + + it('lets a nested negation re-include what the root .gitignore ignored', async () => { + await fs.mkdir(path.join(tmpDir, 'pkg', 'reports'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.gitignore'), '*.log\nreports/\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', '.gitignore'), '!keep.log\n!reports/\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.ignored(asPath('pkg/keep.log'))).toBe(false); + expect(filter.ignored(asPath('pkg/other.log'))).toBe(true); + expect(filter.childrenIgnored(asPath('pkg/reports'))).toBe(false); + expect(filter.childrenIgnored(asPath('reports'))).toBe(true); + }); + + it('re-includes the files inside a directory a nested negation un-ignores', async () => { + await fs.mkdir(path.join(tmpDir, 'pkg', 'reports', 'daily'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.gitignore'), 'reports/\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', '.gitignore'), '!reports/\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', 'reports', 'summary.ts'), 'export {};\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', 'reports', 'daily', 'run.ts'), 'export {};\n'); + await fs.mkdir(path.join(tmpDir, 'reports'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'reports', 'top.ts'), 'export {};\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.ignored(asPath('pkg/reports/summary.ts'))).toBe(false); + expect(filter.childrenIgnored(asPath('pkg/reports/daily'))).toBe(false); + expect(filter.ignored(asPath('pkg/reports/daily/run.ts'))).toBe(false); + expect(filter.ignored(asPath('reports/top.ts'))).toBe(true); + + const { walkRepositoryPaths } = await import('../../src/core/ingestion/filesystem-walker.js'); + const scanned = (await walkRepositoryPaths(tmpDir)).map((f) => f.path); + + expect(scanned).toContain('pkg/reports/summary.ts'); + expect(scanned).toContain('pkg/reports/daily/run.ts'); + expect(scanned).not.toContain('reports/top.ts'); + }); + + it('keeps independent root exclusions inside a directory re-included by nested rules', async () => { + await fs.mkdir(path.join(tmpDir, 'pkg', 'reports', 'private'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.gitignore'), '*.ts\nreports/\n**/reports/private/\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', '.gitignore'), '!reports/\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', 'reports', 'file.ts'), 'export {};\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', 'reports', 'keep.js'), 'export {};\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', 'reports', 'private', 'secret.js'), 'export {};\n'); + + const { walkRepositoryPaths } = await import('../../src/core/ingestion/filesystem-walker.js'); + const scanned = (await walkRepositoryPaths(tmpDir)).map((f) => f.path); + + expect(scanned).toContain('pkg/reports/keep.js'); + expect(scanned).not.toContain('pkg/reports/file.ts'); + expect(scanned).not.toContain('pkg/reports/private/secret.js'); + }); + + it('re-includes descendants when a deeper directory negation overrides an outer nested file', async () => { + await fs.mkdir(path.join(tmpDir, 'app', 'pkg', 'reports', 'daily'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'app', '.gitignore'), 'reports/\n*.gen.ts\n'); + await fs.writeFile(path.join(tmpDir, 'app', 'pkg', '.gitignore'), '!reports/\n'); + await fs.writeFile(path.join(tmpDir, 'app', 'pkg', 'reports', 'keep.ts'), 'export {};\n'); + await fs.writeFile(path.join(tmpDir, 'app', 'pkg', 'reports', 'drop.gen.ts'), 'export {};\n'); + await fs.writeFile( + path.join(tmpDir, 'app', 'pkg', 'reports', 'daily', 'run.ts'), + 'export {};\n', + ); + + const { walkRepositoryPaths } = await import('../../src/core/ingestion/filesystem-walker.js'); + const scanned = (await walkRepositoryPaths(tmpDir)).map((f) => f.path); + + expect(scanned).toContain('app/pkg/reports/keep.ts'); + expect(scanned).toContain('app/pkg/reports/daily/run.ts'); + expect(scanned).not.toContain('app/pkg/reports/drop.gen.ts'); + }); + + it.each(['reports [daily]', ...(process.platform === 'win32' ? [] : ['reports\ndaily'])])( + 'keeps re-included directory names literal: %j', + async (directory) => { + await fs.mkdir(path.join(tmpDir, 'pkg', directory), { recursive: true }); + await fs.mkdir(path.join(tmpDir, 'other', directory), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.gitignore'), 'reports*/\n*.ts\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', '.gitignore'), '!reports*/\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', directory, 'keep.js'), 'export {};\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', directory, 'drop.ts'), 'export {};\n'); + await fs.writeFile(path.join(tmpDir, 'other', directory, 'drop.js'), 'export {};\n'); + + const { walkRepositoryPaths } = await import('../../src/core/ingestion/filesystem-walker.js'); + const scanned = (await walkRepositoryPaths(tmpDir)).map((f) => f.path); + + expect(scanned).toContain(`pkg/${directory}/keep.js`); + expect(scanned).not.toContain(`pkg/${directory}/drop.ts`); + expect(scanned).not.toContain(`other/${directory}/drop.js`); + }, + ); + + it('keeps .gitnexusignore above a nested negation', async () => { + await fs.mkdir(path.join(tmpDir, 'pkg'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'pkg', '.gitignore'), '!keep.log\n'); + await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), 'pkg/keep.log\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.ignored(asPath('pkg/keep.log'))).toBe(true); + }); + + it('keeps an explicit root .gitnexusignore negation in charge', async () => { + await fs.mkdir(path.join(tmpDir, 'app'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'app', '.gitignore'), 'generated/\n'); + await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!app/generated/\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(asPath('app/generated'))).toBe(false); + expect(filter.ignored(asPath('app/generated/schema.ts'))).toBe(false); + }); + + it('lets a nested ignore beat a root .gitignore file negation', async () => { + await fs.mkdir(path.join(tmpDir, 'pkg'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.gitignore'), '*.log\n!pkg/keep.log\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', '.gitignore'), 'keep.log\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.ignored(asPath('pkg/keep.log'))).toBe(true); + }); + + it('lets a nested ignore beat a root .gitignore directory negation', async () => { + await fs.mkdir(path.join(tmpDir, 'app', 'generated'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.gitignore'), '!app/generated/\n'); + await fs.writeFile(path.join(tmpDir, 'app', '.gitignore'), 'generated/\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(asPath('app/generated'))).toBe(true); + expect(filter.ignored(asPath('app/generated/schema.ts'))).toBe(true); + }); + + it('does not let a nested negation rescue hardcoded defaults', async () => { + await fs.mkdir(path.join(tmpDir, 'pkg', 'node_modules'), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'pkg', '.gitignore'), + '!node_modules/\n!package-lock.json\n', + ); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(asPath('pkg/node_modules'))).toBe(true); + expect(filter.ignored(asPath('pkg/package-lock.json'))).toBe(true); + }); + + it.skipIf(process.platform === 'win32')('ignores a symlinked nested .gitignore', async () => { + await fs.mkdir(path.join(tmpDir, 'app'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'rules.txt'), '*.log\n'); + await fs.symlink(path.join(tmpDir, 'rules.txt'), path.join(tmpDir, 'app', '.gitignore')); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.ignored(asPath('app/debug.log'))).toBe(false); + }); + + it('skips nested .gitignore files when GITNEXUS_NO_GITIGNORE is set', async () => { + await fs.mkdir(path.join(tmpDir, 'app'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'app', '.gitignore'), 'generated/\n'); + process.env.GITNEXUS_NO_GITIGNORE = '1'; + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(asPath('app/generated'))).toBe(false); + }); + + it('prunes nested-ignored files from a real repository walk', async () => { + await fs.mkdir(path.join(tmpDir, 'app', 'src'), { recursive: true }); + await fs.mkdir(path.join(tmpDir, 'app', 'public', 'generated'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'app', '.gitignore'), 'public/generated/\n'); + await fs.writeFile(path.join(tmpDir, 'app', 'src', 'index.ts'), 'export {};\n'); + await fs.writeFile(path.join(tmpDir, 'app', 'public', 'generated', 'bundle.js'), 'x;\n'); + + const { walkRepositoryPaths } = await import('../../src/core/ingestion/filesystem-walker.js'); + const scanned = (await walkRepositoryPaths(tmpDir)).map((f) => f.path); + + expect(scanned).toContain('app/src/index.ts'); + expect(scanned).not.toContain('app/public/generated/bundle.js'); + }); + + it('follows git when a nested file negates a path inside an ignored directory', async () => { + // git cannot re-include a file whose parent directory is excluded, so + // `gen/` + `!gen/keep.ts` leaves keep.ts out, while `gen/*` excludes only + // the contents and lets the negation bring keep.ts back. Root rules behave + // the same way. (`gen`, not `build`: `build` is a hardcoded default.) + const { walkRepositoryPaths } = await import('../../src/core/ingestion/filesystem-walker.js'); + for (const [pkg, rules] of [ + ['dir', 'gen/\n!gen/keep.ts\n'], + ['star', 'gen/*\n!gen/keep.ts\n'], + ]) { + await fs.mkdir(path.join(tmpDir, pkg, 'gen'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, pkg, '.gitignore'), rules); + await fs.writeFile(path.join(tmpDir, pkg, 'gen', 'keep.ts'), 'export {};\n'); + await fs.writeFile(path.join(tmpDir, pkg, 'gen', 'drop.ts'), 'export {};\n'); + } + + const scanned = (await walkRepositoryPaths(tmpDir)).map((f) => f.path); + + expect(scanned).not.toContain('dir/gen/keep.ts'); + expect(scanned).not.toContain('dir/gen/drop.ts'); + expect(scanned).toContain('star/gen/keep.ts'); + expect(scanned).not.toContain('star/gen/drop.ts'); + }); + + it.skipIf(process.platform === 'win32')( + 'does not hang on a nested .gitignore that is a FIFO', + async () => { + // Opening a FIFO for reading blocks until a writer appears. Without + // O_NONBLOCK the walk would stop in open(2), before the regular-file + // check, and never return. + await fs.mkdir(path.join(tmpDir, 'pkg', 'src'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'pkg', 'src', 'index.ts'), 'export {};\n'); + execFileSync('mkfifo', [path.join(tmpDir, 'pkg', '.gitignore')]); + + // A timer in this worker cannot interrupt a blocked synchronous open. + // Enforce the deadline outside the process that performs the walk. + const walkerUrl = new URL('../../src/core/ingestion/filesystem-walker.ts', import.meta.url) + .href; + const output = execFileSync( + process.execPath, + [ + '--import', + import.meta.resolve('tsx'), + '--input-type=module', + '--eval', + `import { walkRepositoryPaths } from ${JSON.stringify(walkerUrl)}; + const files = await walkRepositoryPaths(${JSON.stringify(tmpDir)}); + console.log(JSON.stringify(files.map((file) => file.path)));`, + ], + { + encoding: 'utf8', + timeout: 5_000, + killSignal: 'SIGKILL', + env: { ...process.env, GITNEXUS_NO_GLOBAL_IGNORE: '1' }, + }, + ); + + expect(JSON.parse(output)).toContain('pkg/src/index.ts'); + }, + 10_000, + ); +}); + describe('loadIgnoreRules — error handling', () => { let tmpDir: string; diff --git a/gitnexus/test/unit/impact-batching-grouping.test.ts b/gitnexus/test/unit/impact-batching-grouping.test.ts index e068870f9..d56aee1f5 100644 --- a/gitnexus/test/unit/impact-batching-grouping.test.ts +++ b/gitnexus/test/unit/impact-batching-grouping.test.ts @@ -9,7 +9,7 @@ const executeParameterizedMock = vi.fn(); // imports) and the re-export shim (mcp/core/lbug-adapter.js) so the mocks intercept // regardless of import path. vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, initLbug: vi.fn(), @@ -20,7 +20,7 @@ vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { }; }); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, initLbug: vi.fn(), @@ -31,7 +31,7 @@ vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { }; }); -import { LocalBackend } from '../../src/mcp/local/local-backend'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; describe('impact: batching and grouping', () => { beforeEach(() => { @@ -79,6 +79,8 @@ describe('impact: batching and grouping', () => { // Handle parameterized calls (including chunked STEP_IN_PROCESS queries) executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; const params = args[2] || {}; // Match only the aggregation chunk (which uses COUNT(DISTINCT s.id)), // not the per-symbol enrichment pass added by impact byDepth processes @@ -91,6 +93,7 @@ describe('impact: batching and grouping', () => { const idx = chunkCallIndex++; return [ { + pId: 'proc-' + idx, entryPointId: `ep-${Math.floor(idx)}`, epName: `epName-${idx}`, epType: 'Function', @@ -148,6 +151,8 @@ describe('impact: batching and grouping', () => { executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; // BFS frontier query (parameterized #1907 U3): return 6 impacted nodes. if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) { const res: any[] = []; @@ -166,6 +171,7 @@ describe('impact: batching and grouping', () => { // For STEP_IN_PROCESS in this test, return grouping rows return [ { + pId: 'proc-1a', entryPointId: 'ep-1', epName: 'EP1', epType: 'Function', @@ -174,6 +180,7 @@ describe('impact: batching and grouping', () => { minStep: 1, }, { + pId: 'proc-2', entryPointId: 'ep-2', epName: 'EP2', epType: 'Function', @@ -182,6 +189,7 @@ describe('impact: batching and grouping', () => { minStep: 2, }, { + pId: 'proc-1b', entryPointId: 'ep-1', epName: 'EP1', epType: 'Function', @@ -190,6 +198,7 @@ describe('impact: batching and grouping', () => { minStep: 3, }, { + pId: 'proc-3', entryPointId: 'ep-3', epName: 'EP3', epType: 'Function', @@ -245,6 +254,8 @@ describe('impact: batching and grouping', () => { executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; const params = args[2] || {}; // Match only the aggregation chunk (which uses COUNT(DISTINCT s.id)), // not the per-symbol enrichment pass added by impact byDepth processes @@ -254,6 +265,7 @@ describe('impact: batching and grouping', () => { chunkSizes.push(ids.length); return [ { + pId: 'proc-x-' + chunkSizes.length, entryPointId: 'ep-x', epName: 'EPX', epType: 'Function', @@ -351,6 +363,7 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) { return [ { @@ -394,6 +407,7 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; if (query.includes('STEP_IN_PROCESS')) { throw new Error('process chunk failed'); } @@ -443,6 +457,8 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; if (query.includes('MEMBER_OF')) throw new Error('module chunk failed'); if (query.includes('STEP_IN_PROCESS') && query.includes('COUNT(DISTINCT s.id)')) { return [ @@ -500,6 +516,8 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; if (query.includes('MIN(r.step) AS minStep') && !query.includes('COUNT(DISTINCT s.id)')) { throw new Error('minStep backfill failed'); } @@ -559,6 +577,8 @@ describe('impact: batching and grouping', () => { let processChunk = 0; executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; if (query.includes('STEP_IN_PROCESS') && query.includes('COUNT(DISTINCT s.id)')) { processChunk += 1; if (processChunk === 2) throw new Error('later process chunk failed'); @@ -615,6 +635,7 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; if (query.includes('MEMBER_OF') && query.includes('RETURN DISTINCT c.heuristicLabel')) { throw new Error('module classification failed'); } diff --git a/gitnexus/test/unit/impact-context-integrity.test.ts b/gitnexus/test/unit/impact-context-integrity.test.ts new file mode 100644 index 000000000..5ad29172b --- /dev/null +++ b/gitnexus/test/unit/impact-context-integrity.test.ts @@ -0,0 +1,847 @@ +/** Corrupt detail rows must not become usable context/impact answers (#3354). */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { db, aop } = vi.hoisted(() => ({ + db: { + initLbug: vi.fn().mockResolvedValue(undefined), + executeQuery: vi.fn().mockResolvedValue([]), + executeParameterized: vi.fn().mockResolvedValue([]), + closeLbug: vi.fn().mockResolvedValue(undefined), + isLbugReady: vi.fn().mockReturnValue(true), + }, + aop: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => ({ + ...(await importOriginal()), + ...db, +})); +vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => ({ + ...(await importOriginal()), + ...db, +})); +vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => ({ + ...(await importOriginal()), + listRegisteredRepos: vi.fn().mockResolvedValue([ + { + name: 'integrity-fixture', + path: '/tmp/integrity-fixture', + storagePath: '/tmp/integrity-fixture/.gitnexus', + indexedAt: '2026-10-03T12:00:00Z', + lastCommit: 'fixture', + stats: { files: 2, nodes: 2, edges: 1, communities: 0, processes: 1 }, + }, + ]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), + loadMeta: vi.fn().mockResolvedValue({ + pdg: { maxCdgEdgesPerFunction: 0, maxReachingDefEdgesPerFunction: 0 }, + }), +})); +vi.mock('../../src/core/git-staleness.js', () => ({ + checkStalenessAsync: vi.fn().mockResolvedValue({ isStale: false, commitsBehind: 0 }), + checkStaleness: vi.fn().mockReturnValue({ isStale: false, commitsBehind: 0 }), + checkCwdMatch: vi.fn().mockResolvedValue({ match: 'none' }), +})); +vi.mock('../../src/storage/git.js', async (importOriginal) => ({ + ...(await importOriginal()), + getGitRoot: vi.fn().mockReturnValue(null), +})); +vi.mock('../../src/mcp/local/aop-metadata.js', () => ({ querySpringAopMetadata: aop })); + +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { SymbolIdentityError } from '../../src/mcp/local/query-result-integrity.js'; + +const TARGET = { + id: 'func:target', + name: 'target', + type: 'Function', + filePath: 'src/target.ts', + startLine: 1, + endLine: 4, +}; +const REF = { + relType: 'CALLS', + uid: 'func:caller', + name: 'caller', + filePath: 'src/caller.ts', + kind: 'Function', +}; +const EDGE = { + sourceId: TARGET.id, + id: REF.uid, + name: REF.name, + type: REF.kind, + filePath: REF.filePath, + relType: 'CALLS', + confidence: 1, +}; +const PROCESS = { + pId: 'proc:caller', + name: 'Caller flow', + processType: 'intra_community', + entryPointId: REF.uid, + hits: 1, + minStep: 0, + stepCount: 2, + epName: REF.name, + epType: REF.kind, + epFilePath: REF.filePath, +}; +const BAD = 'corrupt\0persisted-value'; + +type Seam = + | 'target' + | 'targetLabels' + | 'aopRows' + | 'pdgSeed' + | 'pdgNeighbor' + | 'pdgOwner' + | 'pdgStatement' + | 'pdgSelf' + | 'pdgCalleeBlocks' + | 'pdgSummary' + | 'pdgSpans' + | 'incoming' + | 'classIncoming' + | 'outgoing' + | 'typedProperties' + | 'contextProcess' + | 'contextRoute' + | 'interfaceBoundary' + | 'interfaceCount' + | 'chain' + | 'seeds' + | 'members' + | 'frontier' + | 'process' + | 'backfill' + | 'membership' + | 'modules' + | 'impactRoute' + | 'metadata'; +let backend: LocalBackend; +let rows: Partial>; +let failedSeam: Seam | undefined; + +function fixture(seam: Seam): unknown[] { + if (failedSeam === seam) throw new Error('ordinary unavailable query'); + return rows[seam] ?? []; +} + +function querySeam(query: string, params: Record | undefined): Seam | undefined { + if (params?.symName || params?.uid) return 'target'; + if (query.includes("RETURN n.id AS id, 'Class' AS label")) return 'targetLabels'; + if (query.includes("r.reason STARTS WITH 'spring-aop:v1:'")) return 'aopRows'; + if (query.includes('RETURN s.id AS id, s.name AS name')) return 'pdgOwner'; + if (query.includes('RETURN s.id AS id, s.filePath AS filePath')) return 'pdgSpans'; + if (query.includes('RETURN c.id AS id, r.reason AS reason')) return 'pdgSummary'; + if (query.includes('RETURN a.id AS id, r.reason AS reason')) return 'pdgSelf'; + if (query.includes('RETURN a.id AS id ORDER BY a.startLine')) return 'pdgSeed'; + if (query.includes('RETURN b.id AS id')) { + if (query.includes('b.calleeIds AS calleeIds')) return 'pdgCalleeBlocks'; + if (query.includes('b.text AS text')) return 'pdgStatement'; + return 'pdgSeed'; + } + if (query.includes('BasicBlock') && query.includes('RETURN DISTINCT')) return 'pdgNeighbor'; + if (query.includes('WITH DISTINCT caller') || query.includes('WITH DISTINCT target')) + return 'chain'; + if (query.includes('caller.id AS uid')) + return query.includes('(ctor:Constructor)') ? 'classIncoming' : 'incoming'; + if (query.includes('target.id AS uid')) return 'outgoing'; + if (query.includes('RETURN p.id AS uid')) return 'typedProperties'; + if (query.includes('RETURN p.id AS pid, p.heuristicLabel AS label')) return 'contextProcess'; + if (query.includes('RETURN route.name AS url')) return 'contextRoute'; + if (query.includes('RETURN DISTINCT iface.id AS id')) return 'interfaceBoundary'; + if (query.includes('RETURN COUNT(DISTINCT other.id) AS cnt')) return 'interfaceCount'; + if ( + query.includes('RETURN c.id AS id') || + query.includes('RETURN f.id AS id') || + query.includes('RETURN p.id AS id') + ) + return 'seeds'; + if (query.includes('RETURN DISTINCT member.id AS id')) return 'members'; + if (query.includes('AS sourceId')) return 'frontier'; + if (query.includes('RETURN p.id AS pId')) return 'process'; + if (query.includes('RETURN p.id AS pid, MIN(r.step) AS minStep')) return 'backfill'; + if (query.includes('RETURN s.id AS sid')) return 'membership'; + if (query.includes('c.heuristicLabel AS name')) return 'modules'; + if (query.includes('RETURN h.id AS hid')) return 'impactRoute'; + if (query.includes('n.visibility AS visibility')) return 'metadata'; + return undefined; +} + +async function context(extra = {}) { + return backend.callTool('context', { name: TARGET.name, ...extra }); +} +async function impact(extra = {}) { + return backend.callTool('impact', { + target: TARGET.name, + direction: 'upstream', + maxDepth: 1, + ...extra, + }); +} +function expectIntegrityError(result: any, isImpact = false) { + expect(result.error).toMatch(/invalid symbol identity/i); + expect(result.recoverySuggestion).toMatch(/analyze.*--force/); + expect(JSON.stringify(result)).not.toContain('persisted-value'); + expect(result).not.toHaveProperty('symbol'); + expect(result).not.toHaveProperty('incoming'); + if (isImpact) { + expect(result.risk).toBe('UNKNOWN'); + expect(result.impactedCount).toBeNull(); + expect(result.epistemic).not.toBe('exact'); + } +} +function tuple(value: Record, keys: string[]): unknown[] { + return keys.map((key) => value[key]); +} + +beforeEach(async () => { + vi.clearAllMocks(); + aop.mockResolvedValue(undefined); + failedSeam = undefined; + rows = { target: [{ ...TARGET }], frontier: [{ ...EDGE }] }; + db.executeParameterized.mockImplementation(async (_db, query, params) => { + const seam = querySeam(query, params); + return seam ? fixture(seam) : []; + }); + backend = new LocalBackend(); + await backend.init(); + vi.spyOn(backend as any, 'ensureInitialized').mockResolvedValue(undefined); + vi.spyOn(backend as any, 'computeEpistemicBoundary').mockResolvedValue({ epistemic: 'exact' }); +}); + +describe('identity corruption before target selection', () => { + for (const corrupt of [true, false]) { + it('distinguishes corrupt and ordinary ambiguous candidate failures: ' + corrupt, async () => { + rows.target = [ + { ...TARGET, id: 'func:one', filePath: 'src/one.ts' }, + { ...TARGET, id: 'func:two', filePath: 'src/two.ts' }, + ]; + vi.spyOn(backend as any, '_runImpactBFS').mockRejectedValue( + corrupt ? new SymbolIdentityError() : new Error('ordinary candidate failure'), + ); + const result = await impact(); + if (corrupt) { + expectIntegrityError(result, true); + } else { + expect(result.error).toBeUndefined(); + expect(result.status).toBe('ambiguous'); + expect(result.partialProbe).toBe(true); + } + }); + } + for (const tool of ['context', 'impact']) { + for (const badRow of [ + { id: BAD, label: 'Class' }, + { id: '', label: 'Class' }, + { id: 42, label: 'Class' }, + { id: TARGET.id, label: BAD }, + ]) { + it('rejects corrupt label enrichment for ' + tool + JSON.stringify(badRow), async () => { + rows.target = [{ ...TARGET, type: '' }]; + rows.targetLabels = [badRow, { id: TARGET.id, label: 'Class' }]; + expectIntegrityError( + tool === 'context' ? await context() : await impact(), + tool === 'impact', + ); + }); + } + } + for (const shape of ['object', 'tuple']) { + for (const field of ['name', 'filePath']) { + for (const tool of ['context', 'impact', 'pdg impact']) { + it('rejects NUL in target ' + field + ' from ' + shape + ' rows for ' + tool, async () => { + const target = { ...TARGET, [field]: BAD }; + rows.target = [ + shape === 'tuple' + ? tuple(target, ['id', 'name', 'type', 'filePath', 'startLine', 'endLine']) + : target, + ]; + expectIntegrityError( + tool === 'context' + ? await context() + : await impact(tool === 'pdg impact' ? { mode: 'pdg' } : {}), + tool !== 'context', + ); + }); + } + } + } + it('rejects corrupt exact-UID metadata before expansion', async () => { + rows.target = [{ ...TARGET, filePath: BAD }]; + expectIntegrityError(await context({ uid: TARGET.id })); + expectIntegrityError(await impact({ target_uid: TARGET.id }), true); + }); + for (const field of ['name', 'filePath']) { + it('rejects non-string target ' + field, async () => { + rows.target = [{ ...TARGET, [field]: 42 }]; + expectIntegrityError(await context()); + }); + } + it('validates every candidate before exact File narrowing', async () => { + rows.target = [ + { ...TARGET, id: 'File:src/target.ts', name: 'target.ts', type: 'File' }, + { ...TARGET, id: 'func:other', filePath: BAD }, + ]; + expectIntegrityError(await context({ name: TARGET.filePath })); + }); +}); + +describe('context detail row integrity', () => { + for (const seam of ['incoming', 'outgoing'] as const) { + for (const shape of ['object', 'tuple']) { + for (const field of ['uid', 'name', 'filePath']) { + it('rejects NUL in ' + seam + ' ' + field + ' from ' + shape + ' rows', async () => { + const ref = { ...REF, [field]: BAD }; + rows[seam] = [ + shape === 'tuple' ? tuple(ref, ['relType', 'uid', 'name', 'filePath', 'kind']) : ref, + ]; + expectIntegrityError(await context()); + }); + } + for (const badRow of [null, {}, [], { ...REF, uid: '' }, { ...REF, relType: '' }]) { + it( + 'rejects incomplete ' + + seam + + ' row ' + + JSON.stringify(badRow) + + ' in ' + + shape + + ' response', + async () => { + rows[seam] = [ + shape === 'tuple' && badRow !== null + ? tuple(badRow, ['relType', 'uid', 'name', 'filePath', 'kind']) + : badRow, + ]; + expectIntegrityError(await context()); + }, + ); + } + } + } + for (const badRow of [ + null, + {}, + [''], + { pid: '' }, + { pid: 'proc:target', label: BAD }, + ['proc:target', BAD, 0, 0], + ]) { + it('rejects corrupt context process ' + JSON.stringify(badRow), async () => { + rows.contextProcess = [badRow]; + expectIntegrityError(await context()); + }); + } + it('does not swallow corrupt class expansion or typed property rows', async () => { + rows.target = [{ ...TARGET, type: 'Class' }]; + rows.typedProperties = [{ uid: 'prop:target', name: 'prop', filePath: BAD, kind: 'Property' }]; + expectIntegrityError(await context()); + }); + it('rejects corrupt class refs before deduplication can discard them', async () => { + rows.target = [{ ...TARGET, type: 'Class' }]; + rows.incoming = [REF]; + rows.classIncoming = [{ ...REF, filePath: BAD }]; + expectIntegrityError(await context()); + }); + for (const badRow of [{}, { ...REF, filePath: BAD }, { ...REF, uid: '' }]) { + it('does not swallow corrupt chain rows ' + JSON.stringify(badRow), async () => { + rows.chain = [badRow]; + expectIntegrityError(await context({ chain_depth: 1 })); + }); + } + it('rejects NUL in route names', async () => { + rows.contextRoute = [{ url: BAD, method: 'GET' }]; + expectIntegrityError(await context()); + }); + it('rejects nested AOP identity fields while keeping the shared error envelope', async () => { + aop.mockResolvedValue({ + framework: 'spring', + advices: [{ adviceId: 'advice:1', adviceName: BAD }], + }); + expectIntegrityError(await context()); + }); +}); + +describe('epistemic boundary row integrity', () => { + const iface = { id: 'iface:target', name: 'Target contract', label: 'Interface' }; + + function prepareBoundary() { + vi.mocked((backend as any).computeEpistemicBoundary).mockRestore(); + rows.interfaceBoundary = [iface]; + rows.interfaceCount = [{ cnt: 2 }]; + } + + for (const tool of ['context', 'impact']) { + for (const shape of ['object', 'tuple']) { + for (const badRow of [ + { ...iface, id: undefined }, + { ...iface, id: '' }, + { ...iface, id: BAD }, + { ...iface, id: 42 }, + { ...iface, name: BAD }, + { ...iface, name: 42 }, + { ...iface, label: BAD }, + { ...iface, label: 42 }, + ]) { + it( + 'rejects corrupt ' + + tool + + ' boundary before deduplication: ' + + shape + + JSON.stringify(badRow), + async () => { + prepareBoundary(); + rows.interfaceBoundary = [iface, badRow].map((row) => + shape === 'tuple' ? tuple(row, ['id', 'name', 'label']) : row, + ); + expectIntegrityError( + tool === 'context' ? await context() : await impact(), + tool === 'impact', + ); + }, + ); + } + } + for (const seam of ['interfaceBoundary', 'interfaceCount'] as const) { + for (const corrupt of [true, false]) { + it( + 'distinguishes ' + tool + ' boundary query failure: ' + seam + ' ' + corrupt, + async () => { + prepareBoundary(); + db.executeParameterized.mockImplementation(async (_db, query, params) => { + const currentSeam = querySeam(query, params); + if (currentSeam === seam) { + throw corrupt ? new SymbolIdentityError() : new Error('ordinary boundary failure'); + } + return currentSeam ? fixture(currentSeam) : []; + }); + const result = tool === 'context' ? await context() : await impact(); + if (corrupt) { + expectIntegrityError(result, tool === 'impact'); + } else { + expect(result.error).toBeUndefined(); + expect(result.recoverySuggestion).toBeUndefined(); + expect(result.epistemic).toBe('lower-bound'); + if (tool === 'impact') expect(result.impactedCount).toBe(1); + } + }, + ); + } + } + it('preserves healthy ' + tool + ' boundary descriptions', async () => { + prepareBoundary(); + const result = tool === 'context' ? await context() : await impact(); + expect(result.error).toBeUndefined(); + expect(result.epistemic).toBe('lower-bound'); + expect(result.boundaries).toContainEqual( + expect.stringContaining('Target contract is an interface'), + ); + expect(result.causes.dispatchBoundary).toBe(4); + }); + } +}); + +describe('impact detail row integrity', () => { + for (const seam of ['frontier', 'process'] as const) { + it( + 'PDG detail integrity rejects corrupt ' + seam + ' rows through the outer envelope', + async () => { + rows[seam] = [ + seam === 'frontier' ? { ...EDGE, filePath: BAD } : { ...PROCESS, epName: BAD }, + ]; + expectIntegrityError(await impact({ mode: 'pdg' }), true); + }, + ); + } + for (const shape of ['object', 'tuple']) { + for (const field of ['id', 'name', 'filePath', 'sourceId']) { + it('rejects NUL in frontier ' + field + ' from ' + shape + ' rows', async () => { + const edge = { ...EDGE, [field]: BAD }; + rows.frontier = [ + shape === 'tuple' + ? tuple(edge, [ + 'sourceId', + 'id', + 'name', + 'type', + 'filePath', + 'relType', + 'confidence', + 'staticGated', + ]) + : edge, + ]; + expectIntegrityError(await impact(), true); + }); + } + } + for (const badRow of [null, {}, [], { ...EDGE, id: '' }]) { + it('rejects incomplete frontier rows ' + JSON.stringify(badRow), async () => { + rows.frontier = [badRow]; + expectIntegrityError(await impact(), true); + }); + } + it('validates corrupt rows before test filtering', async () => { + rows.frontier = [{ ...EDGE, filePath: 'test/corrupt\0.test.ts' }]; + expectIntegrityError(await impact({ includeTests: false }), true); + }); + for (const shape of ['object', 'tuple']) { + for (const field of ['pId', 'name', 'entryPointId', 'epName', 'epFilePath']) { + it('rejects NUL in process ' + field + ' from ' + shape + ' rows', async () => { + const process = { ...PROCESS, [field]: BAD }; + rows.process = [ + shape === 'tuple' + ? tuple(process, [ + 'pId', + 'name', + 'processType', + 'entryPointId', + 'hits', + 'minStep', + 'stepCount', + 'epName', + 'epType', + 'epFilePath', + ]) + : process, + ]; + expectIntegrityError(await impact({ summaryOnly: true }), true); + }); + } + } + for (const badRow of [null, {}, [], { ...PROCESS, pId: '' }]) { + it('does not aggregate incomplete processes ' + JSON.stringify(badRow), async () => { + rows.process = [badRow]; + expectIntegrityError(await impact(), true); + }); + } + for (const badRow of [{}, { pid: BAD, minStep: 1 }]) { + it( + 'does not swallow corrupt backfill process identities ' + JSON.stringify(badRow), + async () => { + rows.process = [{ ...PROCESS, minStep: null }]; + rows.backfill = [badRow]; + expectIntegrityError(await impact(), true); + }, + ); + } + for (const badRow of [ + {}, + { sid: REF.uid, pid: '' }, + { sid: REF.uid, pid: 'proc:caller', pName: BAD }, + ]) { + it( + 'does not swallow corrupt per-symbol process identities ' + JSON.stringify(badRow), + async () => { + rows.process = [PROCESS]; + rows.membership = [badRow]; + expectIntegrityError(await impact(), true); + }, + ); + } + for (const type of ['Class', 'Const']) { + for (const badRow of [{}, { ...TARGET, id: 'seed:1', filePath: BAD }]) { + it('rejects corrupt ' + type + ' seeds ' + JSON.stringify(badRow), async () => { + rows.target = [{ ...TARGET, type }]; + rows[type === 'Class' ? 'seeds' : 'members'] = [badRow]; + expectIntegrityError(await impact({ direction: 'downstream' }), true); + }); + } + } + it('rejects NUL in module names before aggregation', async () => { + rows.modules = [{ name: BAD, hits: 1 }]; + expectIntegrityError(await impact(), true); + }); + it('rejects NUL in route names', async () => { + rows.impactRoute = [{ hid: REF.uid, url: BAD }]; + expectIntegrityError(await impact(), true); + }); + for (const shape of ['object', 'tuple']) { + for (const hid of [undefined, null, '', ' ', BAD, 42, {}]) { + it( + 'rejects corrupt route handler IDs from ' + shape + ' rows: ' + JSON.stringify(hid), + async () => { + const route = { hid, url: '/target', method: 'GET' }; + rows.impactRoute = [shape === 'tuple' ? tuple(route, ['hid', 'url', 'method']) : route]; + expectIntegrityError(await impact(), true); + }, + ); + } + } + it('rejects nested AOP paths', async () => { + aop.mockResolvedValue({ + framework: 'spring', + advices: [{ adviceId: 'advice:1', adviceFilePath: BAD }], + }); + expectIntegrityError(await impact(), true); + }); +}); + +describe('healthy and ordinary-failure compatibility', () => { + it('preserves Unicode, opaque IDs, optional NULL metadata and source NUL', async () => { + const content = 'const value = "actual\0source";'; + rows.target = [{ ...TARGET, name: 'café�', filePath: '源/café�.ts', content }]; + rows.incoming = [{ ...REF, name: '呼び出し�', filePath: null, kind: '' }]; + rows.contextProcess = [ + { pid: 'legacy:proc ', label: '', step: 0, stepCount: 0, entryPointId: null }, + ]; + rows.metadata = [{ annotations: ['@Text("source\0value")'], parameterTypes: null }]; + const result = await context({ include_content: true }); + expect(result.error).toBeUndefined(); + expect(result.symbol).toMatchObject({ + uid: TARGET.id, + name: 'café�', + filePath: '源/café�.ts', + content, + }); + expect(result.symbol.methodMetadata).toEqual({ annotations: ['@Text("source\0value")'] }); + expect(result.incoming.calls[0]).toMatchObject({ uid: REF.uid, name: '呼び出し�' }); + expect(result.processes).toEqual([ + { id: 'legacy:proc ', name: undefined, step_index: 0, step_count: 0 }, + ]); + }); + it('preserves tuple zero steps and empty process labels', async () => { + rows.contextProcess = [['proc:0', '', 0, 0, null]]; + expect((await context()).processes).toEqual([ + { id: 'proc:0', name: '', step_index: 0, step_count: 0 }, + ]); + }); + it('allows absent OPTIONAL MATCH entry-point fields and missing legacy sourceId', async () => { + rows.frontier = [{ ...EDGE, sourceId: undefined }]; + rows.process = [ + { ...PROCESS, name: '', entryPointId: null, epName: null, epType: null, epFilePath: null }, + ]; + const result = await impact(); + expect(result.error).toBeUndefined(); + expect(result.impactedCount).toBe(1); + expect(result.affected_processes).toEqual([ + { + name: 'unknown', + type: 'Function', + filePath: '', + affected_process_count: 1, + total_hits: 1, + earliest_broken_step: 0, + }, + ]); + }); + it('keeps missing optional route fields as ordinary skipped enrichment', async () => { + rows.contextRoute = [{}]; + rows.impactRoute = [{ hid: REF.uid }]; + expect((await context()).error).toBeUndefined(); + expect((await impact()).error).toBeUndefined(); + }); + it('does not misdiagnose an unmatched user-supplied NUL as index corruption', async () => { + rows.target = []; + const contextResult = await context({ name: 'client\0input' }); + const impactResult = await impact({ target: 'client\0input' }); + expect(contextResult.error).toContain('not found'); + expect(impactResult.error).toContain('not found'); + expect(contextResult.recoverySuggestion).toBeUndefined(); + expect(impactResult.recoverySuggestion).toBeUndefined(); + }); + it('keeps ordinary process query failures degraded rather than integrity errors', async () => { + failedSeam = 'process'; + const result = await impact(); + expect(result.error).toBeUndefined(); + expect(result.partial).toBe(true); + expect(result.impactedCount).toBe(1); + expect(result.affected_processes).toEqual([]); + }); + it('keeps ordinary PDG interprocedural failures as degraded results', async () => { + vi.spyOn(backend as any, '_runImpactBFS').mockRejectedValue( + new Error('ordinary bridge failure'), + ); + const result = await impact({ mode: 'pdg' }); + expect(result.error).toBeUndefined(); + expect(result.partial).toBe(true); + expect(result.interproceduralError).toBe('ordinary bridge failure'); + expect(result.recoverySuggestion).toBeUndefined(); + }); + it('keeps ordinary context process query failures as unavailable enrichment', async () => { + failedSeam = 'contextProcess'; + const result = await context(); + expect(result.error).toBeUndefined(); + expect(result.processes).toEqual([]); + }); +}); + +describe('raw PDG identities before coercion, caps, and projection', () => { + const seed = 'BasicBlock:src/target.ts:2:0:0'; + const reached = 'BasicBlock:src/caller.ts:1:0:0'; + + function preparePdg() { + rows.pdgSeed = [{ id: seed }]; + rows.pdgNeighbor = [{ id: reached }]; + rows.pdgOwner = [{ id: REF.uid, name: REF.name, label: 'Function', startLine: 0 }]; + rows.pdgStatement = [{ id: reached, line: 1, endLine: 1, text: 'value = 1;' }]; + } + + for (const seam of ['pdgSeed', 'pdgNeighbor', 'pdgOwner', 'pdgStatement'] as const) { + for (const bad of [BAD, '', null, 42]) { + it('rejects raw ' + seam + ' identity ' + JSON.stringify(bad), async () => { + preparePdg(); + rows[seam] = [{ id: bad, name: 'caller', label: 'Function', line: 1, startLine: 0 }]; + expectIntegrityError( + await impact({ mode: 'pdg', ...(seam === 'pdgStatement' ? { line: 2 } : {}) }), + true, + ); + }); + } + } + for (const seam of ['pdgSeed', 'pdgNeighbor'] as const) { + it('validates ' + seam + ' cap probe rows', async () => { + preparePdg(); + rows[seam] = [{ id: seam === 'pdgSeed' ? seed : reached }, { id: BAD }]; + expectIntegrityError(await impact({ mode: 'pdg', limit: 1 }), true); + }); + } + for (const field of ['name', 'label']) { + it('rejects raw owner ' + field + ' before String coercion', async () => { + preparePdg(); + rows.pdgOwner = [{ id: REF.uid, name: 'caller', label: 'Function', [field]: BAD }]; + expectIntegrityError(await impact({ mode: 'pdg' }), true); + }); + } + for (const field of ['seedBlocks', 'reachableBlocks', 'intraReachableBlocks']) { + for (const bad of [BAD, '', null, 42]) { + it('rejects malformed final ' + field + ' members ' + JSON.stringify(bad), async () => { + const healthy = await impact({ mode: 'pdg' }); + vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValue({ + ...healthy, + [field]: [bad], + }); + expectIntegrityError(await impact({ mode: 'pdg' }), true); + }); + } + } + it('allows a generated unresolved owner marker', async () => { + preparePdg(); + rows.pdgOwner = []; + const result = await impact({ mode: 'pdg' }); + expect(result.error).toBeUndefined(); + expect(result.unresolvedBlockCount).toBe(1); + }); + it('preserves Unicode owner tuples and source NUL', async () => { + preparePdg(); + rows.pdgOwner = [[REF.uid, '呼び出し�', 'Function', 0]]; + expect((await impact({ mode: 'pdg' })).error).toBeUndefined(); + rows.pdgStatement = [{ id: reached, line: 1, endLine: 1, text: 'value = "source\0text";' }]; + const result = await impact({ mode: 'pdg', line: 2 }); + expect(result.error).toBeUndefined(); + expect(result.affectedStatements.some((s: any) => s.text.includes('\0'))).toBe(true); + }); + for (const field of ['callees', 'calleeIds']) { + it('does not swallow a corrupt statement bridge ' + field, async () => { + preparePdg(); + const original = db.executeParameterized.getMockImplementation()!; + db.executeParameterized.mockImplementation(async (...args) => { + if (args[1].includes('RETURN b.' + field + ' AS ' + field)) { + return [{ [field]: BAD }]; + } + return original(...args); + }); + expectIntegrityError(await impact({ mode: 'pdg', direction: 'downstream' }), true); + }); + } +}); + +describe('real AOP helper identities before deduplication', () => { + const reason = + 'spring-aop:v1:' + + JSON.stringify({ + kind: 'advice', + annotation: 'org.aspectj.lang.annotation.Around', + advice: 'around', + pointcut: 'execution(*)', + match: 'static', + activation: 'unknown', + proxy: 'possible', + }); + const advice = { + sourceId: TARGET.id, + sourceName: 'target', + sourceFilePath: TARGET.filePath, + targetId: 'advice:1', + targetName: 'audit', + targetFilePath: 'src/audit.ts', + reason, + }; + async function useRealAop() { + const actual = await vi.importActual( + '../../src/mcp/local/aop-metadata.js', + ); + aop.mockImplementation(actual.querySpringAopMetadata); + rows.target = [{ ...TARGET, type: 'Method' }]; + } + for (const tool of ['context', 'impact']) { + for (const field of ['sourceId', 'targetId']) { + for (const bad of [BAD, '', null, 42]) { + it('rejects raw AOP ' + field + ' for ' + tool + JSON.stringify(bad), async () => { + await useRealAop(); + rows.aopRows = [{ ...advice, [field]: bad }, advice]; + expectIntegrityError( + tool === 'context' ? await context() : await impact(), + tool === 'impact', + ); + }); + } + } + for (const field of ['sourceName', 'sourceFilePath', 'targetName', 'targetFilePath']) { + it('rejects corrupt duplicate AOP ' + field + ' for ' + tool, async () => { + await useRealAop(); + rows.aopRows = [{ ...advice, [field]: BAD }, advice]; + expectIntegrityError( + tool === 'context' ? await context() : await impact(), + tool === 'impact', + ); + }); + } + } + it('validates the AOP cap-probe row', async () => { + await useRealAop(); + rows.aopRows = [...Array.from({ length: 1000 }, () => advice), { ...advice, targetId: BAD }]; + expectIntegrityError(await context()); + }); + it('preserves Unicode and optional NULL metadata', async () => { + await useRealAop(); + rows.aopRows = [ + { ...advice, sourceName: '源�', sourceFilePath: null, targetName: '', targetFilePath: null }, + ]; + const result = await context(); + expect(result.error).toBeUndefined(); + expect(result.symbol.aop.advices[0]).toMatchObject({ + adviceId: advice.targetId, + advisedId: advice.sourceId, + advisedName: '源�', + }); + }); + it('keeps ordinary AOP query failures fail-soft', async () => { + await useRealAop(); + failedSeam = 'aopRows'; + expect((await context()).error).toBeUndefined(); + expect((await impact()).error).toBeUndefined(); + }); + it('handles early AOP rejection while the frontier is pending', async () => { + const unhandled = vi.fn(); + process.on('unhandledRejection', unhandled); + aop.mockRejectedValue(new SymbolIdentityError()); + const original = db.executeParameterized.getMockImplementation()!; + db.executeParameterized.mockImplementation(async (...args) => { + if (querySeam(args[1], args[2]) === 'frontier') { + await new Promise((resolve) => setTimeout(resolve, 30)); + } + return original(...args); + }); + try { + expectIntegrityError(await impact(), true); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off('unhandledRejection', unhandled); + } + }); +}); diff --git a/gitnexus/test/unit/impact-pagination.test.ts b/gitnexus/test/unit/impact-pagination.test.ts index 9b8aa8e21..2aecb445a 100644 --- a/gitnexus/test/unit/impact-pagination.test.ts +++ b/gitnexus/test/unit/impact-pagination.test.ts @@ -4,7 +4,7 @@ const executeQueryMock = vi.fn(); const executeParameterizedMock = vi.fn(); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, initLbug: vi.fn(), @@ -15,7 +15,7 @@ vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { }; }); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, initLbug: vi.fn(), @@ -26,8 +26,8 @@ vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { }; }); -import { LocalBackend } from '../../src/mcp/local/local-backend'; -import { collectImpactSymbolUids } from '../../src/core/group/cross-impact'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { collectImpactSymbolUids } from '../../src/core/group/cross-impact.js'; function makeBackend() { const backend = new LocalBackend(); @@ -56,6 +56,7 @@ function setupMultiDepthHub(d1Count: number, d2Count: number) { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); if (query.includes('STEP_IN_PROCESS')) return []; if (query.includes('MEMBER_OF')) return []; + if (query.includes('RETURN h.id AS hid')) return []; // The #1858 epistemic-boundary probe (computeEpistemicBoundary) runs // concurrently with the BFS and also matches `r.type IN`, but targets the // `iface` alias. Return empty so it stays `epistemic: 'exact'` and does not @@ -99,6 +100,7 @@ function setupHubSymbol(count: number) { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); if (query.includes('STEP_IN_PROCESS')) return []; if (query.includes('MEMBER_OF')) return []; + if (query.includes('RETURN h.id AS hid')) return []; // See setupMultiDepthHub — keep the #1858 epistemic probe from matching the // `r.type IN` caller branch below. if (query.includes('iface')) return []; diff --git a/gitnexus/test/unit/impact-pdg-id-bridge-gate.test.ts b/gitnexus/test/unit/impact-pdg-id-bridge-gate.test.ts index 6c08ec43d..0a11e244b 100644 --- a/gitnexus/test/unit/impact-pdg-id-bridge-gate.test.ts +++ b/gitnexus/test/unit/impact-pdg-id-bridge-gate.test.ts @@ -11,7 +11,6 @@ // `npm test`. import { describe, expect, it } from 'vitest'; -// @ts-expect-error — .mjs pure-JS harness module, no types (intentional; build-free). import * as M from '../../bench/impact-pdg/measure.mjs'; interface ReachedItem { diff --git a/gitnexus/test/unit/impact-pdg-id-vs-name-metrics.test.ts b/gitnexus/test/unit/impact-pdg-id-vs-name-metrics.test.ts index 757ee8668..990ade365 100644 --- a/gitnexus/test/unit/impact-pdg-id-vs-name-metrics.test.ts +++ b/gitnexus/test/unit/impact-pdg-id-vs-name-metrics.test.ts @@ -9,7 +9,6 @@ // `node --import tsx bench/impact-pdg/name-collision.mjs`, never in `npm test`. import { describe, expect, it } from 'vitest'; -// @ts-expect-error — .mjs pure-JS module, no types; intentional (build-free harness). import * as M from '../../bench/impact-pdg/name-collision.mjs'; interface ReachedItem { diff --git a/gitnexus/test/unit/impact-pdg-metric-math.test.ts b/gitnexus/test/unit/impact-pdg-metric-math.test.ts index e52051254..43007787b 100644 --- a/gitnexus/test/unit/impact-pdg-metric-math.test.ts +++ b/gitnexus/test/unit/impact-pdg-metric-math.test.ts @@ -9,7 +9,6 @@ // `measure.mjs`, never in `npm test`. import { describe, it, expect } from 'vitest'; -// @ts-expect-error — .mjs pure-JS module, no types; intentional (build-free harness). import * as M from '../../bench/impact-pdg/metrics.mjs'; const k = (sym: string, file = 'src/a.ts') => M.symbolKey(sym, file); diff --git a/gitnexus/test/unit/impact-route-enrichment.test.ts b/gitnexus/test/unit/impact-route-enrichment.test.ts index 9e9cc8791..a2ffdb901 100644 --- a/gitnexus/test/unit/impact-route-enrichment.test.ts +++ b/gitnexus/test/unit/impact-route-enrichment.test.ts @@ -11,7 +11,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const executeParameterizedMock = vi.fn(); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...(actual as object), initLbug: vi.fn(), @@ -22,7 +22,7 @@ vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { }; }); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...(actual as object), initLbug: vi.fn(), @@ -33,7 +33,7 @@ vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { }; }); -import { LocalBackend } from '../../src/mcp/local/local-backend'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; const REPO = { id: 'repo', @@ -80,7 +80,10 @@ async function runImpact(routeRows: readonly RouteRow[], routeQueryFails = false : []; } if (query.includes('STEP_IN_PROCESS') || query.includes('MEMBER_OF')) return []; - return [{ id: 'svc', name: 'UnfinalizeRound', filePath: 'svc.go', type: 'Method' }]; + if (query.includes('n.id AS id')) { + return [{ id: 'svc', name: 'UnfinalizeRound', filePath: 'svc.go', type: 'Method' }]; + } + return []; }); const backend = new LocalBackend(); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 523d6405f..6f14c4e4e 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -26,10 +26,8 @@ const minimalResult = (overrides: Partial = {}): ParseWorkerR nodes: [], relationships: [], symbols: [], - imports: [], calls: [], assignments: [], - heritage: [], routes: [], fetchCalls: [], fetchWrapperDefs: [], @@ -302,12 +300,12 @@ describe('PARSE_CACHE_VERSION', () => { // Moved 121 -> 122 for #3414 restoring helper calls. // Moved 122 -> 123 for #3408 FastAPI nested router-prefix capture fields. // Moved 123 -> 124 for #3402 Go gin/echo decorator routes. - // Moved 124 -> 125 for #3402 Go route hints (lexical declarations, captured writes). - // Moved 125 -> 126 for R `@scope.function` caller-attribution anchors. - // Moved 126 -> 127 for R `pkg::` qualifier capture on namespaced calls. - // Moved 127 -> 128 for the R provider's first-argument selection of S4 definitions. - it('pins SCHEMA_BUMP to 128 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(128); + // Moved 125 -> 126 for #3446: SDK positional tool definitions and attribution. + // Moved 126 -> 127 for #3450: reject destructured SDK registration-method writes. + // Moved 127 -> 128 for #3450: recognize SDK namespace imports. + // Moved 128 -> 129 for the R provider fixes (scope-function anchors, `pkg::` qualifier capture, S4/import first-argument selection). + it('pins SCHEMA_BUMP to 129 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402, #3446, #3450)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(129); expect(PARSE_CACHE_BUCKET_COUNT).toBe(128); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly @@ -317,7 +315,7 @@ describe('PARSE_CACHE_VERSION', () => { 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, - 123, 124, 125, 126, 127, + 123, 124, 125, 126, 127, 128, ]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } @@ -898,15 +896,12 @@ describe('loadParseCache / saveParseCache (round-trip)', () => { ['k2', 'v2'], ]); const innerSet = new Set(['s1', 's2']); - const fake = minimalResult({ - fileCount: 9, - imports: [ - { - typeBindings: innerMap, - extras: innerSet, - } as unknown as ParseWorkerResult['imports'][number], - ], - }); + // Exercise opaque structured-clone data without relying on a removed + // worker field (ParsedFiles intentionally live in a separate store). + const fake = { + ...minimalResult({ fileCount: 9 }), + serializationProbe: { typeBindings: innerMap, extras: innerSet }, + }; const key = 'f'.repeat(64); const cache: ParseCache = { version: PARSE_CACHE_VERSION, @@ -921,17 +916,29 @@ describe('loadParseCache / saveParseCache (round-trip)', () => { expect(names.some((n) => n.endsWith('.json') && n !== 'index.json')).toBe(false); const loaded = await loadParseCacheChunk(cache, key); expect(loaded?.[0]?.fileCount).toBe(9); - const smuggled = loaded?.[0]?.imports[0] as unknown as { - typeBindings?: unknown; - extras?: unknown; - }; + const restored = loaded?.[0]; + if (!restored || !('serializationProbe' in restored)) { + throw new Error('Missing serialized probe'); + } + const smuggled = restored.serializationProbe; + if ( + typeof smuggled !== 'object' || + smuggled === null || + !('typeBindings' in smuggled) || + !('extras' in smuggled) + ) { + throw new Error('Invalid serialized probe'); + } expect(smuggled.typeBindings).toBeInstanceOf(Map); - expect([...(smuggled.typeBindings as Map)]).toEqual([ + expect(smuggled.extras).toBeInstanceOf(Set); + if (!(smuggled.typeBindings instanceof Map) || !(smuggled.extras instanceof Set)) { + throw new Error('Structured-clone collections were not preserved'); + } + expect([...smuggled.typeBindings]).toEqual([ ['k1', 'v1'], ['k2', 'v2'], ]); - expect(smuggled.extras).toBeInstanceOf(Set); - expect([...(smuggled.extras as Set)].sort()).toEqual(['s1', 's2']); + expect([...smuggled.extras].sort()).toEqual(['s1', 's2']); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/gitnexus/test/unit/incremental-write-benchmark-verdict.test.ts b/gitnexus/test/unit/incremental-write-benchmark-verdict.test.ts new file mode 100644 index 000000000..e1c3b0c35 --- /dev/null +++ b/gitnexus/test/unit/incremental-write-benchmark-verdict.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import { parse } from '@babel/parser'; +import { isVariableDeclarator, traverseFast } from '@babel/types'; +import { expect, it } from 'vitest'; +import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import { reconcileGraphNodeIdentities } from '../../src/core/incremental/write-reconciliation.js'; + +// Execute the standalone benchmark's actual audit closure, without its timed +// workload, against the production oracle and controlled native scan results. +const source = readFileSync( + new URL('../../bench/incremental-write-integrity/measure.cjs', import.meta.url), + 'utf8', +); +let auditSource: string | undefined; +traverseFast(parse(source, { sourceType: 'script' }), (node) => { + if ( + isVariableDeclarator(node) && + node.id.type === 'Identifier' && + node.id.name === 'audit' && + node.init?.start != null && + node.init.end != null + ) { + auditSource = source.slice(node.init.start, node.init.end); + } +}); +if (!auditSource) throw new Error('Benchmark audit closure not found'); + +const row = { id: 'Function:a.ts:f', name: 'f', filePath: 'a.ts', startLine: 0, endLine: 1 }; +it.each([ + { name: 'healthy scan', rows: [row], verdict: 'certified' }, + { name: 'complete tuple set plus duplicate row', rows: [row, row], verdict: 'rejected' }, + { + name: 'complete tuple set plus unexpected row', + rows: [row, { ...row, id: 'Function:a.ts:extra' }], + verdict: 'rejected', + }, + { name: 'missing tuple', rows: [], verdict: 'rejected' }, + { name: 'inconsistent range', rows: [{ ...row, startLine: 99 }], verdict: 'rejected' }, +])('reports the production rejection verdict for $name', async ({ rows, verdict }) => { + const graph = createKnowledgeGraph(); + graph.addNode({ id: row.id, label: 'Function', properties: { ...row } }); + const wanted = new Set([ + JSON.stringify([row.id, row.name, row.filePath, row.startLine, row.endLine]), + ]); + const nativePhases: { verdict: string }[] = []; + const audit = vm.runInNewContext(`(${auditSource})`, { + assert, + graph, + wanted, + nativePhases, + size: { nodes: 1 }, + reconcileGraphNodeIdentities, + adapter: { executeQuery: async (query: string) => (query.includes('Function') ? rows : []) }, + }) as (phase: string) => Promise; + await audit('controlled-scan'); + expect(nativePhases).toEqual([expect.objectContaining({ verdict, rows: rows.length })]); +}); diff --git a/gitnexus/test/unit/incremental-write-integrity.test.ts b/gitnexus/test/unit/incremental-write-integrity.test.ts new file mode 100644 index 000000000..bd201851a --- /dev/null +++ b/gitnexus/test/unit/incremental-write-integrity.test.ts @@ -0,0 +1,271 @@ +import { appendFile } from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { setupMiniRepo } from '../helpers/mini-repo.js'; +import { getStoragePaths, loadMeta, readRegistry } from '../../src/storage/repo-manager.js'; +import * as adapter from '../../src/core/lbug/lbug-adapter.js'; +import * as fts from '../../src/core/search/fts-indexes.js'; +import * as analyzerIdentity from '../../src/core/analyzer-identity.js'; +import * as checkpoints from '../../src/core/lbug/wal-checkpoint-driver.js'; +import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js'; +import { runFullAnalysis } from '../../src/core/run-analyze.js'; +import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import { reconcileGraphNodeIdentities } from '../../src/core/incremental/write-reconciliation.js'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +const options = { skipAgentsMd: true, skipSkills: true }; +const callbacks = { onProgress: () => {} }; + +async function touchHandler(repoPath: string): Promise { + initGitRepo(repoPath); + await appendFile(path.join(repoPath, 'src/handler.ts'), '\n// integrity probe\n'); + commitAll(repoPath, 'touch handler'); +} + +describe('incremental graph identity before publication', () => { + it('chooses a full write before selective mutation when manual checkpoints are disabled', async () => { + const repo = await setupMiniRepo('gitnexus-test-checkpoint-opt-out-'); + const { storagePath } = getStoragePaths(repo.dbPath); + try { + await runFullAnalysis(repo.dbPath, options, callbacks); + await touchHandler(repo.dbPath); + vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', '0'); + const deleteNodes = vi.spyOn(adapter, 'deleteNodesForFiles'); + const flush = vi.spyOn(adapter, 'tryFlushWAL'); + const logs: string[] = []; + const result = await runFullAnalysis(repo.dbPath, options, { + ...callbacks, + onLog: (line) => logs.push(line), + }); + expect(deleteNodes).not.toHaveBeenCalled(); + expect(flush).not.toHaveBeenCalled(); + expect(result.incrementalStats).toBeUndefined(); + expect(logs).toContain( + 'Manual WAL checkpoints are disabled; switching to a full DB write before mutation.', + ); + expect((await loadMeta(storagePath))?.incrementalInProgress).toBeUndefined(); + } finally { + await adapter.closeLbug(); + await repo.cleanup(); + } + }); + + it('retries a transient checkpoint I/O failure at the final publication gate', async () => { + const repo = await setupMiniRepo('gitnexus-test-final-checkpoint-retry-'); + const { storagePath } = getStoragePaths(repo.dbPath); + try { + await runFullAnalysis(repo.dbPath, options, callbacks); + await touchHandler(repo.dbPath); + // Isolate the final gate from the periodic driver's independent cadence. + vi.spyOn(checkpoints, 'startWalCheckpointDriver').mockReturnValue({ stop: async () => {} }); + const flush = adapter.tryFlushWAL; + const build = fts.buildSearchIndexesOrDegrade; + let attempts = 0; + vi.spyOn(fts, 'buildSearchIndexesOrDegrade').mockImplementation(async (...args) => { + const result = await build(...args); + vi.spyOn(adapter, 'tryFlushWAL').mockImplementation(async () => { + if (++attempts === 1) { + throw new Error( + 'Runtime exception: IO exception: Error renaming file db.wal to db.wal.checkpoint', + ); + } + return flush(); + }); + return result; + }); + const result = await runFullAnalysis(repo.dbPath, options, callbacks); + expect(attempts).toBe(2); + expect(result.incrementalStats?.writeMode).toBe('incremental'); + expect((await loadMeta(storagePath))?.incrementalInProgress).toBeUndefined(); + } finally { + await adapter.closeLbug(); + await repo.cleanup(); + } + }); + it('matches native CSV normalization for nullable, absent-range and Unicode identity fields', async () => { + const repo = await setupMiniRepo('gitnexus-test-identity-parity-'); + const { storagePath, lbugPath } = getStoragePaths(repo.dbPath); + const graph = createKnowledgeGraph(); + graph.addNode({ + id: 'File:src/Å.ts', + label: 'File', + properties: { name: 'Å.ts', filePath: 'src/Å.ts' }, + }); + graph.addNode({ + id: 'Route:/王', + label: 'Route', + properties: { name: '/王', filePath: 'src/Å.ts' }, + }); + graph.addNode({ + id: 'Tool:王', + label: 'Tool', + properties: { name: '王', filePath: 'src/Å.ts' }, + }); + graph.addNode({ + id: 'Destination:topic', + label: 'Destination', + properties: { name: 'topic', filePath: '' }, + }); + graph.addNode({ + id: 'Function:src/Å.ts:王\u0000\uD800', + label: 'Function', + properties: { name: '王\r\n\uD800', filePath: 'src/Å.ts' }, + }); + try { + await adapter.initLbug(lbugPath, { skipFts: true }); + await adapter.loadGraphToLbug( + graph, + repo.dbPath, + storagePath, + undefined, + undefined, + undefined, + 'none', + ); + await adapter.tryFlushWAL(); + await expect( + reconcileGraphNodeIdentities(graph, adapter.executeQuery, 'native CSV parity'), + ).resolves.toMatchObject({ nodes: 5 }); + } finally { + await adapter.closeLbug(); + await repo.cleanup(); + } + }); + + it.each([ + { phase: 'after-copy', atomicIncremental: false }, + { phase: 'after-fts', atomicIncremental: false }, + { phase: 'after-fts', atomicIncremental: true }, + { phase: 'after-fts-finalization', atomicIncremental: false }, + { phase: 'checkpoint-false', atomicIncremental: false }, + { phase: 'checkpoint-throw', atomicIncremental: true }, + ])( + 'refuses freshness after $phase failure (atomic=$atomicIncremental)', + async ({ phase, atomicIncremental }) => { + const repo = await setupMiniRepo('gitnexus-test-write-integrity-'); + const { storagePath, lbugPath } = getStoragePaths(repo.dbPath); + try { + const healthy = await runFullAnalysis(repo.dbPath, options, callbacks); + const before = await loadMeta(storagePath); + const registeredBefore = (await readRegistry()).find((entry) => entry.path === repo.dbPath); + const logs: string[] = []; + const victim = [...healthy.pipelineResult.graph.iterNodes()].find( + (node) => node.label === 'Function' && node.properties.filePath === 'src/validator.ts', + ); + expect(victim?.id).toBeTruthy(); + + await touchHandler(repo.dbPath); + const damage = async () => { + await adapter.executePrepared('MATCH (n:Function {id: $id}) DETACH DELETE n', { + id: victim.id, + }); + }; + if (phase === 'after-copy') { + const load = adapter.loadGraphToLbug; + vi.spyOn(adapter, 'loadGraphToLbug').mockImplementation(async (...args) => { + const result = await load(...args); + await damage(); + return result; + }); + } else { + const build = fts.buildSearchIndexesOrDegrade; + vi.spyOn(fts, 'buildSearchIndexesOrDegrade').mockImplementation(async (...args) => { + const result = await build(...args); + if (phase === 'checkpoint-false') { + vi.spyOn(adapter, 'tryFlushWAL').mockResolvedValue(false); + } else if (phase === 'checkpoint-throw') { + vi.spyOn(adapter, 'tryFlushWAL').mockRejectedValue( + new Error('injected final checkpoint failure'), + ); + } else { + await damage(); + if (phase === 'after-fts-finalization') { + vi.spyOn(analyzerIdentity, 'finalizeAnalyzerRunnerIdentity').mockImplementation( + () => { + throw new Error('injected analyzer finalization failure'); + }, + ); + } + } + return result; + }); + } + + await expect( + runFullAnalysis( + repo.dbPath, + { ...options, atomicIncremental }, + { + ...callbacks, + onLog: (line) => logs.push(line), + }, + ), + ).rejects.toThrow( + phase.startsWith('checkpoint') + ? /checkpoint/i + : phase === 'after-fts-finalization' + ? /injected analyzer finalization failure/ + : /graph.*reconciliation/i, + ); + const after = await loadMeta(storagePath); + expect(after?.lastCommit).toBe(before?.lastCommit); + expect(after?.indexedAt).toBe(before?.indexedAt); + expect(logs.filter((line) => line.includes('atomic-incremental'))).toEqual( + atomicIncremental ? [expect.stringContaining('staged')] : [], + ); + expect(Boolean(after?.incrementalInProgress)).toBe(!atomicIncremental); + const registeredAfter = (await readRegistry()).find((entry) => entry.path === repo.dbPath); + expect(registeredAfter?.indexedAt).toBe(registeredBefore?.indexedAt); + expect(registeredAfter?.lastCommit).toBe(registeredBefore?.lastCommit); + vi.restoreAllMocks(); + if (atomicIncremental) { + await adapter.initLbug(lbugPath); + const retained = await adapter.executePrepared( + 'MATCH (n:Function {id: $id}) RETURN n.id AS id', + { + id: victim.id, + }, + ); + expect(retained).toEqual([{ id: victim.id }]); + await adapter.closeLbug(); + } else { + expect(after?.incrementalInProgress).toMatchObject({ + phase: 'graph-reconciliation', + checkpointSucceeded: false, + }); + await expect( + runFullAnalysis(repo.dbPath, { ...options, repairFts: true }, callbacks), + ).rejects.toThrow(/mid-incremental-recovery/); + expect((await loadMeta(storagePath))?.incrementalInProgress?.phase).toBe( + 'graph-reconciliation', + ); + // The dirty marker drives recovery without a manual --force, and a + // no-op rerun after recovery must stop rebuilding. + const recovered = await runFullAnalysis(repo.dbPath, options, callbacks); + expect(recovered.incrementalStats).toBeUndefined(); + expect((await loadMeta(storagePath))?.incrementalInProgress).toBeUndefined(); + await adapter.initLbug(lbugPath); + await expect( + reconcileGraphNodeIdentities( + recovered.pipelineResult.graph, + adapter.executeQuery, + 'recovered/reopened', + ), + ).resolves.toMatchObject({ nodes: expect.any(Number) }); + await adapter.closeLbug(); + expect((await runFullAnalysis(repo.dbPath, options, callbacks)).alreadyUpToDate).toBe( + true, + ); + } + } finally { + await adapter.closeLbug(); + await repo.cleanup(); + } + }, + 120_000, + ); +}); diff --git a/gitnexus/test/unit/incremental-write-reproducer-cleanup.test.ts b/gitnexus/test/unit/incremental-write-reproducer-cleanup.test.ts new file mode 100644 index 000000000..ed3f2dbfe --- /dev/null +++ b/gitnexus/test/unit/incremental-write-reproducer-cleanup.test.ts @@ -0,0 +1,265 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import vm from 'node:vm'; +import { expect, it } from 'vitest'; + +const source = readFileSync( + new URL('../../bench/incremental-write-integrity/reproduce.cjs', import.meta.url), + 'utf8', +); + +type FailurePoint = + | 'write:1' + | 'write:2' + | 'database:1' + | 'database:2' + | 'connection:1' + | 'connection:2' + | 'connection-close:1' + | 'connection-close:2' + | 'database-close:1' + | 'database-close:2' + | 'remove'; + +// Execute the actual entry point with controlled native and file APIs. Healthy +// scan tuples allow constructor and cleanup faults in both native sessions. +async function runReproducer( + failures: FailurePoint[] = [], + keep = false, + options: { + requireCorruption?: boolean; + copiedScan?: 'missing' | 'duplicate' | 'wrong-field'; + } = {}, +) { + const directory = path.join(os.tmpdir(), 'ladybug-copy-identity-controlled'); + const events: string[] = []; + const diagnostics: unknown[] = []; + const injected = new Map(failures.map((point) => [point, new Error(point)])); + let databases = 0; + let connections = 0; + let writes = 0; + let scans = 0; + let indices = Array.from({ length: 8192 }, (_, i) => i); + const output: string[] = []; + const processDouble = { + argv: [ + 'node', + 'reproduce.cjs', + ...(keep ? ['--keep'] : []), + ...(options.requireCorruption ? ['--require-corruption'] : []), + ], + version: process.version, + exitCode: 0, + stdout: { write: (value: string) => output.push(value) }, + }; + const fail = (point: FailurePoint) => { + const error = injected.get(point); + if (error) throw error; + }; + class Database { + readonly number = ++databases; + constructor() { + events.push(`database:${this.number}`); + fail(`database:${this.number}` as FailurePoint); + } + async close() { + events.push(`database-close:${this.number}`); + fail(`database-close:${this.number}` as FailurePoint); + } + } + class Connection { + readonly number = ++connections; + constructor(database: Database) { + events.push(`connection:${this.number}`); + expect(database.number).toBe(this.number); + fail(`connection:${this.number}` as FailurePoint); + } + async query(cypher: string) { + if (cypher.includes('DETACH DELETE')) { + indices = indices.filter((i) => i >= 32 && i < 8192 - 32); + } else if (cypher.startsWith('COPY')) { + indices = Array.from({ length: 8192 }, (_, i) => i); + } + const rows = cypher.includes('RETURN id(n)') + ? indices.map((i) => ({ + internalID: { offset: i }, + id: `Function:src/owner${Math.floor(i / 32)}.ts:fn${i}`, + name: `fn${i}`, + filePath: `src/owner${Math.floor(i / 32)}.ts`, + startLine: (i % 32) * 4, + endLine: (i % 32) * 4 + 2, + })) + : []; + if (cypher.includes('RETURN id(n)') && ++scans === 3) { + if (options.copiedScan === 'missing') rows.pop(); + if (options.copiedScan === 'duplicate') rows.push({ ...rows[0] }); + if (options.copiedScan === 'wrong-field') rows[0].name = 'wrong-function'; + } + return { getAll: async () => rows, close: async () => {} }; + } + async close() { + events.push(`connection-close:${this.number}`); + fail(`connection-close:${this.number}` as FailurePoint); + } + } + const fsDouble = { + mkdtemp: async () => directory, + writeFile: async () => { + const point = `write:${++writes}` as FailurePoint; + events.push(point); + fail(point); + }, + rm: async (target: string, options: { recursive: boolean; force: boolean }) => { + expect(target).toBe(directory); + expect(options).toEqual({ recursive: true, force: true }); + events.push('remove'); + fail('remove'); + }, + }; + const modules: Record = { + 'node:assert/strict': assert, + 'node:fs/promises': fsDouble, + 'node:os': os, + 'node:path': path, + '@ladybugdb/core': { Database, Connection, VERSION: 'controlled' }, + }; + await vm.runInNewContext(source, { + require: (name: string) => { + if (!(name in modules)) throw new Error(`Unexpected require: ${name}`); + return modules[name]; + }, + process: processDouble, + console: { error: (error: unknown) => diagnostics.push(error) }, + AggregateError, + }); + return { events, diagnostics, injected, output, exitCode: processDouble.exitCode, directory }; +} + +it.each([ + { point: 'write:1', closed: [] }, + { point: 'write:2', closed: [] }, + { point: 'database:1', closed: [] }, + { point: 'connection:1', closed: ['database-close:1'] }, + { point: 'database:2', closed: ['connection-close:1', 'database-close:1'] }, + { + point: 'connection:2', + closed: ['connection-close:1', 'database-close:1', 'database-close:2'], + }, + { point: 'connection-close:1', closed: ['connection-close:1', 'database-close:1'] }, + { point: 'database-close:1', closed: ['connection-close:1', 'database-close:1'] }, + { + point: 'connection-close:2', + closed: ['connection-close:1', 'database-close:1', 'connection-close:2', 'database-close:2'], + }, + { + point: 'database-close:2', + closed: ['connection-close:1', 'database-close:1', 'connection-close:2', 'database-close:2'], + }, +] satisfies { point: FailurePoint; closed: string[] }[])( + 'closes acquired resources and removes the directory when $point fails', + async ({ point, closed }) => { + const result = await runReproducer([point]); + expect(result.exitCode).toBe(1); + expect(result.diagnostics).toEqual([result.injected.get(point)]); + expect(result.events.filter((event) => event.includes('-close:'))).toEqual(closed); + expect(result.events.at(-1)).toBe('remove'); + expect(result.events.filter((event) => event === 'remove')).toHaveLength(1); + }, +); + +it('closes both native sessions and removes the directory after a healthy run', async () => { + const result = await runReproducer(); + expect(result.exitCode).toBe(0); + expect(result.diagnostics).toEqual([]); + expect(result.events.slice(-5)).toEqual([ + 'database:2', + 'connection:2', + 'connection-close:2', + 'database-close:2', + 'remove', + ]); + const report = JSON.parse(result.output.join('')); + expect(report.phases).toEqual([ + { phase: 'baseline', rows: 8192, wrong_tuples: 0, missing_tuples: 0, examples: [] }, + { phase: 'after-delete', rows: 8128, wrong_tuples: 0, missing_tuples: 64, examples: [] }, + { phase: 'after-copy', rows: 8192, wrong_tuples: 0, missing_tuples: 0, examples: [] }, + { phase: 'after-checkpoint', rows: 8192, wrong_tuples: 0, missing_tuples: 0, examples: [] }, + { phase: 'reopened', rows: 8192, wrong_tuples: 0, missing_tuples: 0, examples: [] }, + ]); + expect(report.directory).toBeUndefined(); +}); + +it.each([ + { copiedScan: 'missing', rows: 8191, wrong_tuples: 0, missing_tuples: 1 }, + { copiedScan: 'duplicate', rows: 8193, wrong_tuples: 0, missing_tuples: 0 }, + { copiedScan: 'wrong-field', rows: 8192, wrong_tuples: 1, missing_tuples: 1 }, +] as const)( + 'accepts a $copiedScan scan discrepancy as reproduced corruption', + async ({ copiedScan, ...expected }) => { + const result = await runReproducer([], false, { requireCorruption: true, copiedScan }); + const report = JSON.parse(result.output.join('')); + expect( + report.phases.find((phase: { phase: string }) => phase.phase === 'after-copy'), + ).toMatchObject(expected); + expect(result.exitCode).toBe(0); + expect(result.diagnostics).toEqual([]); + expect(result.events.at(-1)).toBe('remove'); + }, +); + +it('rejects --require-corruption when the native scan is healthy', async () => { + const result = await runReproducer([], false, { requireCorruption: true }); + expect(result.exitCode).toBe(1); + expect(result.diagnostics).toHaveLength(1); + expect((result.diagnostics[0] as Error).message).toContain('Native failure did not reproduce'); + expect(result.events.at(-1)).toBe('remove'); +}); + +it('keeps the directory deliberately without retaining native handles for --keep', async () => { + const result = await runReproducer([], true); + expect(result.exitCode).toBe(0); + expect(result.events).not.toContain('remove'); + expect(result.events.slice(-2)).toEqual(['connection-close:2', 'database-close:2']); + expect(JSON.parse(result.output.join('')).directory).toBe(result.directory); +}); + +it('honors --keep when connection construction fails', async () => { + const result = await runReproducer(['connection:1'], true); + expect(result.exitCode).toBe(1); + expect(result.diagnostics).toEqual([result.injected.get('connection:1')]); + expect(result.events.at(-1)).toBe('database-close:1'); + expect(result.events).not.toContain('remove'); +}); + +it('retains both close failures and still removes the directory', async () => { + const result = await runReproducer(['connection-close:1', 'database-close:1']); + expect(result.exitCode).toBe(1); + const error = result.diagnostics[0] as AggregateError; + expect(error).toBeInstanceOf(AggregateError); + expect(error.errors).toEqual([ + result.injected.get('connection-close:1'), + result.injected.get('database-close:1'), + ]); + expect(result.events.at(-1)).toBe('remove'); +}); + +it('retains the original construction error when database cleanup also fails', async () => { + const result = await runReproducer(['connection:1', 'database-close:1']); + expect(result.exitCode).toBe(1); + const error = result.diagnostics[0] as AggregateError; + expect(error).toBeInstanceOf(AggregateError); + expect(error.errors).toEqual([ + result.injected.get('connection:1'), + result.injected.get('database-close:1'), + ]); + expect(result.events.at(-1)).toBe('remove'); +}); + +it('reports directory-removal failures after closing the native handles', async () => { + const result = await runReproducer(['remove']); + expect(result.exitCode).toBe(1); + expect(result.diagnostics).toEqual([result.injected.get('remove')]); + expect(result.events.slice(-3)).toEqual(['connection-close:2', 'database-close:2', 'remove']); +}); diff --git a/gitnexus/test/unit/index-lock.test.ts b/gitnexus/test/unit/index-lock.test.ts index 1a6846cc0..120e09727 100644 --- a/gitnexus/test/unit/index-lock.test.ts +++ b/gitnexus/test/unit/index-lock.test.ts @@ -17,6 +17,7 @@ import { existsSync, chmodSync, symlinkSync, + mkdirSync, } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -178,6 +179,109 @@ describe('release', () => { }); describe('sweepStagingArtifacts', () => { + const recoveryStage = 'lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46'; + const seedRecovery = (stagingFile = recoveryStage): void => { + writeFileSync( + path.join(dir, 'gitnexus.json'), + JSON.stringify({ + embeddingCheckpoint: { + kind: 'interrupted', + at: '2026-10-03T12:00:00.000Z', + nodesProcessed: 1, + totalNodes: 2, + chunksProcessed: 1, + model: 'test-model', + dimensions: 2, + provider: 'local', + pendingNodeIds: ['node-2'], + recovery: { + stagingFile, + schemaFingerprint: 'schema-v1', + unsafeNodeIds: ['node-2'], + }, + }, + }), + ); + }; + + it('retains the checkpoint-referenced family while reclaiming unrelated orphans', () => { + const family = [ + '', + '.wal', + '.shadow', + '.wal.checkpoint', + '.lock', + '.checkpoint.intent.lock', + '.checkpoint.apply.lock', + ].map((suffix) => recoveryStage + suffix); + for (const name of [...family, `${recoveryStage}.unexpected`, 'lbug.staging.orphan']) { + writeFileSync(path.join(dir, name), 'x'); + } + seedRecovery(); + + sweepStagingArtifacts(dir); + + for (const name of family) expect(existsSync(path.join(dir, name))).toBe(true); + expect(existsSync(path.join(dir, `${recoveryStage}.unexpected`))).toBe(false); + expect(existsSync(path.join(dir, 'lbug.staging.orphan'))).toBe(false); + }); + + it('preserves a referenced generation when a shared caller acquires the lock', async () => { + writeFileSync(path.join(dir, recoveryStage), 'x'); + seedRecovery(); + const lock = await acquireIndexLock(dir); + try { + expect(existsSync(path.join(dir, recoveryStage))).toBe(true); + } finally { + lock.release(); + } + }); + + it('retains only the referenced family in a branch sub-slot', async () => { + const branchDir = path.join(dir, 'branches', 'feature'); + mkdirSync(branchDir, { recursive: true }); + seedRecovery(); + const metadata = JSON.parse(readFileSync(path.join(dir, 'gitnexus.json'), 'utf8')); + writeFileSync( + path.join(branchDir, 'gitnexus.json'), + JSON.stringify({ ...metadata, storagePath: dir }), + ); + writeFileSync(path.join(branchDir, recoveryStage), 'stage'); + writeFileSync(path.join(branchDir, 'lbug.staging.orphan'), 'orphan'); + writeFileSync(path.join(dir, 'lbug.staging.orphan'), 'other-slot'); + + const lock = await acquireIndexLock(branchDir); + try { + expect(existsSync(path.join(branchDir, recoveryStage))).toBe(true); + expect(existsSync(path.join(branchDir, 'lbug.staging.orphan'))).toBe(false); + expect(existsSync(path.join(dir, 'lbug.staging.orphan'))).toBe(true); + } finally { + lock.release(); + } + }); + + it('rejects a symlinked reference and never follows it while reclaiming the stage', () => { + const external = path.join(dir, 'foreign-database'); + writeFileSync(external, 'external'); + symlinkSync(external, path.join(dir, recoveryStage)); + seedRecovery(); + + sweepStagingArtifacts(dir); + + expect(existsSync(path.join(dir, recoveryStage))).toBe(false); + expect(readFileSync(external, 'utf8')).toBe('external'); + }); + + it('does not sweep any generation when acquisition explicitly defers cleanup', async () => { + writeFileSync(path.join(dir, 'lbug.staging.orphan'), 'x'); + const lock = await acquireIndexLock(dir, { sweep: false }); + try { + expect(existsSync(path.join(dir, 'lbug.staging.orphan'))).toBe(true); + } finally { + lock.release(); + } + }); + it('removes only staging files, never the live index or its sidecars', () => { const files = [ 'lbug', diff --git a/gitnexus/test/unit/index-repo-command.test.ts b/gitnexus/test/unit/index-repo-command.test.ts index 6fcb6a7ca..88f129927 100644 --- a/gitnexus/test/unit/index-repo-command.test.ts +++ b/gitnexus/test/unit/index-repo-command.test.ts @@ -71,7 +71,7 @@ describe('indexCommand', () => { const storageFailure = ( state: 'empty' | 'owned' | 'unowned', hasCodeIndexDB: boolean, - ): StorageRequirementError => + ): InstanceType => new StorageRequirementError( { repoPath: resolvedRepo, diff --git a/gitnexus/test/unit/ipynb-python-captures.test.ts b/gitnexus/test/unit/ipynb-python-captures.test.ts index e6074a73c..1b886c1a7 100644 --- a/gitnexus/test/unit/ipynb-python-captures.test.ts +++ b/gitnexus/test/unit/ipynb-python-captures.test.ts @@ -45,8 +45,6 @@ describe('Python notebook scope captures', () => { expect(fnCapture?.['@scope.function']?.range.startLine).toBe(expectedJson + 1); const parsed = extractParsedFile(pythonProvider, notebook, 'analysis.ipynb'); expect(parsed).toBeDefined(); - expect(parsed!.localDefs.some((d) => d.qualifiedName === 'train' || d.name === 'train')).toBe( - true, - ); + expect(parsed!.localDefs.some((d) => d.qualifiedName === 'train')).toBe(true); }); }); diff --git a/gitnexus/test/unit/java-runtime-symbol-strategy.test.ts b/gitnexus/test/unit/java-runtime-symbol-strategy.test.ts index bfdc6f7da..d0932decf 100644 --- a/gitnexus/test/unit/java-runtime-symbol-strategy.test.ts +++ b/gitnexus/test/unit/java-runtime-symbol-strategy.test.ts @@ -3,7 +3,7 @@ import type { GraphNode } from 'gitnexus-shared'; import { javaProvider } from '../../src/core/ingestion/languages/java.js'; import { javaRuntimeSymbolStrategy } from '../../src/core/ingestion/languages/java/spring-actuator.js'; -function method(name: string, properties: GraphNode['properties'] = {}): GraphNode { +function method(name: string, properties: Partial = {}): GraphNode { return { id: `method:${name}:${JSON.stringify(properties.parameterTypes)}`, label: 'Method', diff --git a/gitnexus/test/unit/jvm-package-siblings.test.ts b/gitnexus/test/unit/jvm-package-siblings.test.ts index efa912408..bdcede07b 100644 --- a/gitnexus/test/unit/jvm-package-siblings.test.ts +++ b/gitnexus/test/unit/jvm-package-siblings.test.ts @@ -1,4 +1,5 @@ -import type { ParsedFile, ScopeResolutionIndexes, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../src/core/ingestion/model/scope-resolution-indexes.js'; +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; import { describe, expect, it } from 'vitest'; import { createJvmPackageSiblingVisibility } from '../../src/core/ingestion/languages/jvm/package-siblings.js'; import { collectJavaCaptureSideChannel } from '../../src/core/ingestion/languages/java/capture-side-channel.js'; diff --git a/gitnexus/test/unit/kotlin-route-const-resolver.test.ts b/gitnexus/test/unit/kotlin-route-const-resolver.test.ts index 1c2cd8b48..c92bddde0 100644 --- a/gitnexus/test/unit/kotlin-route-const-resolver.test.ts +++ b/gitnexus/test/unit/kotlin-route-const-resolver.test.ts @@ -51,7 +51,7 @@ try { } const parser = new Parser(); -if (Kotlin) parser.setLanguage(Kotlin as Parser.Language); +if (Kotlin) parser.setLanguage(Kotlin); const parse = (src: string): Parser.Tree => parser.parse(src); diff --git a/gitnexus/test/unit/kotlin-spring-route-ingestion.test.ts b/gitnexus/test/unit/kotlin-spring-route-ingestion.test.ts index 51c2225dd..7c98c11d6 100644 --- a/gitnexus/test/unit/kotlin-spring-route-ingestion.test.ts +++ b/gitnexus/test/unit/kotlin-spring-route-ingestion.test.ts @@ -24,7 +24,7 @@ try { } const parser = new Parser(); -if (Kotlin) parser.setLanguage(Kotlin as Parser.Language); +if (Kotlin) parser.setLanguage(Kotlin); const parse = (source: string): Parser.Tree => parser.parse(source); const describeKotlin = Kotlin ? describe : describe.skip; diff --git a/gitnexus/test/unit/lazy-action.test.ts b/gitnexus/test/unit/lazy-action.test.ts index 973d728ca..fcc6ce3ae 100644 --- a/gitnexus/test/unit/lazy-action.test.ts +++ b/gitnexus/test/unit/lazy-action.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it, vi } from 'vitest'; import { createAnalyzerLbugLazyAction, createLazyAction } from '../../src/cli/lazy-action.js'; const { checkLbugNativeMock } = vi.hoisted(() => ({ - checkLbugNativeMock: vi.fn(() => ({ ok: true })), + checkLbugNativeMock: vi.fn( + () => ({ ok: true }), + ), })); vi.mock('../../src/core/lbug/native-check.js', () => ({ diff --git a/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts b/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts index 8592546dc..aaa5c58a8 100644 --- a/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts +++ b/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts @@ -730,6 +730,68 @@ describe('lbug adapter CHECKPOINT lifecycle', () => { await adapter.closeLbug(); }); + it.each([false, true])( + 'drains every FTS CREATE result and preserves a deferred failure (failure=%s)', + async (fails) => { + vi.resetModules(); + const nativeError = new Error('Runtime exception: Failed calling LOWER: Invalid UTF-8.'); + const events: string[] = []; + const results = ['first', 'second', 'third'].map((name) => ({ + getAll: vi.fn(async () => { + events.push(`${name}:getAll`); + if (fails && name === 'second') throw nativeError; + return []; + }), + close: vi.fn(() => { + events.push(`${name}:close`); + }), + })); + const genericResult = { getAll: vi.fn(async () => []), close: vi.fn() }; + const conn = makeConn(async (sql: string) => + sql.startsWith('CALL CREATE_FTS_INDEX') ? results : genericResult, + ); + const db = { close: vi.fn(async () => {}) }; + const dbPath = '/tmp/gitnexus-lbug-fts-create-drain/lbug'; + mockFsForInit(dbPath); + vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ + openLbugConnection: vi.fn(async () => ({ db, conn })), + closeLbugConnection: vi.fn(async () => {}), + isDbBusyError: vi.fn(() => false), + isOpenRetryExhausted: vi.fn(() => false), + waitForWindowsHandleRelease: vi.fn(async () => true), + isStorageVersionMismatchError: vi.fn(() => false), + throwIfStorageVersionMismatch: vi.fn(), + STORAGE_VERSION_MISMATCH_SUGGESTION: '', + })); + vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ + extensionManager: { + ensure: vi.fn(async () => true), + getCapabilities: vi.fn(() => []), + reset: vi.fn(), + }, + })); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.initLbug(dbPath); + try { + const create = adapter.createFTSIndex('Property', 'property_fts', ['name']); + if (fails) await expect(create).rejects.toBe(nativeError); + else await expect(create).resolves.toBeUndefined(); + expect(events).toEqual([ + 'first:getAll', + 'first:close', + 'second:getAll', + 'second:close', + 'third:getAll', + 'third:close', + ]); + for (const result of results) expect(result.close).toHaveBeenCalledOnce(); + } finally { + await adapter.closeLbug(); + } + }, + ); + it('closes non-first stream query results when LadybugDB returns an array', async () => { vi.resetModules(); diff --git a/gitnexus/test/unit/lbug-pool-evict-reopen-race.test.ts b/gitnexus/test/unit/lbug-pool-evict-reopen-race.test.ts index 64cd767d5..346d6d742 100644 --- a/gitnexus/test/unit/lbug-pool-evict-reopen-race.test.ts +++ b/gitnexus/test/unit/lbug-pool-evict-reopen-race.test.ts @@ -122,6 +122,8 @@ describe('pool-adapter evict-then-reopen race (fire-and-forget close fix)', () = vi.mocked(createLbugDatabase).mockImplementationOnce(() => ({ init: vi.fn().mockResolvedValue(undefined), + initSync: vi.fn(), + closeSync: vi.fn(), close: vi.fn().mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); order.push('repo1-close-end'); @@ -155,6 +157,8 @@ describe('pool-adapter evict-then-reopen race (fire-and-forget close fix)', () = vi.mocked(createLbugDatabase).mockImplementationOnce(() => ({ init: vi.fn().mockResolvedValue(undefined), + initSync: vi.fn(), + closeSync: vi.fn(), close: vi.fn().mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); order.push('close-end'); diff --git a/gitnexus/test/unit/lbug-pool-pinning.test.ts b/gitnexus/test/unit/lbug-pool-pinning.test.ts index d6fb1dbf8..8276b136a 100644 --- a/gitnexus/test/unit/lbug-pool-pinning.test.ts +++ b/gitnexus/test/unit/lbug-pool-pinning.test.ts @@ -264,6 +264,8 @@ describe('pool-adapter repo pinning (issue #2189)', () => { it('a pin acquired while closeOne awaits db.close() does not survive teardown', async () => { vi.mocked(createLbugDatabase).mockImplementationOnce(() => ({ init: vi.fn().mockResolvedValue(undefined), + initSync: vi.fn(), + closeSync: vi.fn(), close: vi.fn().mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); }), @@ -284,6 +286,8 @@ describe('pool-adapter repo pinning (issue #2189)', () => { it('initLbugWithDb waits for an in-flight closeOne before registering', async () => { vi.mocked(createLbugDatabase).mockImplementationOnce(() => ({ init: vi.fn().mockResolvedValue(undefined), + initSync: vi.fn(), + closeSync: vi.fn(), close: vi.fn().mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); }), @@ -294,6 +298,8 @@ describe('pool-adapter repo pinning (issue #2189)', () => { await Promise.resolve(); const injected = { init: vi.fn().mockResolvedValue(undefined), + initSync: vi.fn(), + closeSync: vi.fn(), close: vi.fn().mockResolvedValue(undefined), }; const injecting = initLbugWithDb( diff --git a/gitnexus/test/unit/lbug-pool-storage-version-lock-retry.test.ts b/gitnexus/test/unit/lbug-pool-storage-version-lock-retry.test.ts index aef8867de..7818e26ac 100644 --- a/gitnexus/test/unit/lbug-pool-storage-version-lock-retry.test.ts +++ b/gitnexus/test/unit/lbug-pool-storage-version-lock-retry.test.ts @@ -136,7 +136,10 @@ describe('pool-adapter storage-version fail-fast and lock-retry backoff', () => return goodDb(); }); - const lockedInit = initLbug(lockedId, lockedPath); + // Handle the expected rejection before advancing the retry timers. + const lockedRejection = expect(initLbug(lockedId, lockedPath)).rejects.toThrow( + /LadybugDB unavailable for locked/, + ); await vi.waitFor(() => { expect(createLbugDatabaseMock).toHaveBeenCalledTimes(1); }); @@ -151,7 +154,9 @@ describe('pool-adapter storage-version fail-fast and lock-retry backoff', () => expect(createLbugDatabaseMock).toHaveBeenCalledTimes(3); }); await vi.advanceTimersByTimeAsync(4000); - await expect(lockedInit).rejects.toThrow(/LadybugDB unavailable for locked/); + // Leave a turn for Node to detect any rejection without a handler. + await new Promise((resolve) => setImmediate(resolve)); + await lockedRejection; expect(createLbugDatabaseMock).toHaveBeenCalledTimes(4); }); }); diff --git a/gitnexus/test/unit/local-cli-subprocess.test.ts b/gitnexus/test/unit/local-cli-subprocess.test.ts index 9e46f75f4..2910334fe 100644 --- a/gitnexus/test/unit/local-cli-subprocess.test.ts +++ b/gitnexus/test/unit/local-cli-subprocess.test.ts @@ -56,7 +56,7 @@ describe('Claude CLI subprocess contract', () => { beforeEach(() => { vi.resetModules(); fakeChild = makeFakeChild({ stdout: 'Claude response text' }); - spawnSpy = vi.fn(() => fakeChild.child); + spawnSpy = vi.fn(() => fakeChild.child); }); afterEach(() => { @@ -147,7 +147,7 @@ describe('Claude CLI subprocess contract', () => { it('rejects with exit code and stderr on non-zero exit', async () => { fakeChild = makeFakeChild({ exitCode: 1, stderr: 'auth required' }); - spawnSpy = vi.fn(() => fakeChild.child); + spawnSpy = vi.fn(() => fakeChild.child); vi.doMock('child_process', () => ({ execFileSync: vi.fn().mockReturnValue('claude 1.0.0'), @@ -162,7 +162,7 @@ describe('Claude CLI subprocess contract', () => { it('rejects with actionable error on empty stdout', async () => { fakeChild = makeFakeChild({ stdout: '' }); - spawnSpy = vi.fn(() => fakeChild.child); + spawnSpy = vi.fn(() => fakeChild.child); vi.doMock('child_process', () => ({ execFileSync: vi.fn().mockReturnValue('claude 1.0.0'), @@ -183,7 +183,7 @@ describe('Codex CLI subprocess contract', () => { beforeEach(() => { vi.resetModules(); fakeChild = makeFakeChild({ stdout: 'codex response' }); - spawnSpy = vi.fn(() => fakeChild.child); + spawnSpy = vi.fn(() => fakeChild.child); }); afterEach(() => { @@ -435,7 +435,7 @@ describe('Codex output file fallback', () => { vi.doMock('child_process', () => ({ execFileSync: vi.fn().mockReturnValue('codex 0.1.0'), - spawn: vi.fn(() => fakeChild.child), + spawn: vi.fn(() => fakeChild.child), })); const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js'); @@ -448,7 +448,7 @@ describe('Codex output file fallback', () => { vi.doMock('child_process', () => ({ execFileSync: vi.fn().mockReturnValue('codex 0.1.0'), - spawn: vi.fn(() => fakeChild.child), + spawn: vi.fn(() => fakeChild.child), })); const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js'); @@ -563,7 +563,7 @@ describe('Codex CLI flag contract snapshot', () => { it('spawn args match the exact expected contract (flag rename = test failure)', async () => { const fakeChild = makeFakeChild({ stdout: 'codex output' }); - const spawnSpy = vi.fn(() => fakeChild.child); + const spawnSpy = vi.fn(() => fakeChild.child); vi.doMock('child_process', () => ({ execFileSync: vi.fn().mockReturnValue('codex 0.1.0'), @@ -608,7 +608,7 @@ describe('Codex CLI flag contract snapshot', () => { it('--model appears before - (stdin marker) and after --output-last-message', async () => { const fakeChild = makeFakeChild({ stdout: 'codex output' }); - const spawnSpy = vi.fn(() => fakeChild.child); + const spawnSpy = vi.fn(() => fakeChild.child); vi.doMock('child_process', () => ({ execFileSync: vi.fn().mockReturnValue('codex 0.1.0'), @@ -1070,13 +1070,14 @@ describe('Grok CLI timeout', () => { } async function loadGrokWithChild(child: any, execFileSyncImpl?: (...args: any[]) => unknown) { - const spawnSpy = vi.fn(() => child); - const execFileSync = + const spawnSpy = vi.fn(() => child); + const execFileSync = vi.fn( execFileSyncImpl ?? - vi.fn().mockImplementation((cmd: string) => { - if (cmd === 'taskkill') return ''; - return 'grok 1.0.5'; - }); + ((cmd: string) => { + if (cmd === 'taskkill') return ''; + return 'grok 1.0.5'; + }), + ); vi.doMock('../../src/core/logger.js', () => ({ logger: { info: vi.fn(), warn: vi.fn() }, })); diff --git a/gitnexus/test/unit/mcp-context-route-chain.test.ts b/gitnexus/test/unit/mcp-context-route-chain.test.ts index 7cd84ae4d..e2ff0440d 100644 --- a/gitnexus/test/unit/mcp-context-route-chain.test.ts +++ b/gitnexus/test/unit/mcp-context-route-chain.test.ts @@ -18,12 +18,12 @@ const { lbugMocks } = vi.hoisted(() => ({ })); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); diff --git a/gitnexus/test/unit/mcp-read-file-grep.test.ts b/gitnexus/test/unit/mcp-read-file-grep.test.ts index 3f9186011..48f80c52a 100644 --- a/gitnexus/test/unit/mcp-read-file-grep.test.ts +++ b/gitnexus/test/unit/mcp-read-file-grep.test.ts @@ -23,12 +23,12 @@ const { lbugMocks } = vi.hoisted(() => ({ })); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); diff --git a/gitnexus/test/unit/mcp-read-only.test.ts b/gitnexus/test/unit/mcp-read-only.test.ts index fd27d93ef..07d01a567 100644 --- a/gitnexus/test/unit/mcp-read-only.test.ts +++ b/gitnexus/test/unit/mcp-read-only.test.ts @@ -145,7 +145,7 @@ describe('MCP read-only mode', () => { it.each([ ['impact', { target: 'auth', direction: 'upstream', crossDepth: 5 }], ['impact', { target: 'auth', direction: 'upstream', subgroup: 'services' }], - ])('rejects group-only arguments before backend dispatch: %s %o', async (name, args) => { + ])('rejects group-only arguments before dispatch #%#: %s %o', async (name, args) => { enableReadOnly(); const session = await connect(); try { diff --git a/gitnexus/test/unit/mcp-repository-policy.test.ts b/gitnexus/test/unit/mcp-repository-policy.test.ts index 3c55fcc19..c1eb743ce 100644 --- a/gitnexus/test/unit/mcp-repository-policy.test.ts +++ b/gitnexus/test/unit/mcp-repository-policy.test.ts @@ -192,7 +192,7 @@ describe('MCP repository policy', () => { [{ GITNEXUS_MCP_ALLOWED_REPOS: 'Missing' }, 'invalid'], [{ GITNEXUS_MCP_ALLOWED_REPOS: 'Duplicate' }, 'ambiguous'], [{ GITNEXUS_MCP_DEFAULT_REPO: 'Duplicate' }, 'ambiguous'], - ])('fails startup with a sanitized %s configuration error', async (env, reason) => { + ])('fails startup with a sanitized %s configuration error (case %#)', async (env, reason) => { const backend = createBackend(); let message = ''; try { @@ -283,7 +283,7 @@ describe('MCP repository policy', () => { ); it.each([{ GITNEXUS_MCP_ALLOWED_REPOS: ' ' }, { GITNEXUS_MCP_DEFAULT_REPO: ' ' }])( - 'fails closed for explicitly blank repository configuration', + 'fails closed for explicitly blank repository configuration (case %#)', async (env) => { await expect(createMcpRepositoryPolicy(createBackend(), env)).rejects.toThrow( /must not be blank/i, diff --git a/gitnexus/test/unit/mcp-wal-feedback.test.ts b/gitnexus/test/unit/mcp-wal-feedback.test.ts index 65515a987..4de7c4f75 100644 --- a/gitnexus/test/unit/mcp-wal-feedback.test.ts +++ b/gitnexus/test/unit/mcp-wal-feedback.test.ts @@ -17,12 +17,12 @@ const { lbugMocks, repoMocks } = vi.hoisted(() => ({ })); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); diff --git a/gitnexus/test/unit/method-extraction.test.ts b/gitnexus/test/unit/method-extraction.test.ts index f792cfdce..9945727bd 100644 --- a/gitnexus/test/unit/method-extraction.test.ts +++ b/gitnexus/test/unit/method-extraction.test.ts @@ -49,7 +49,7 @@ try { Dart = requireVendoredGrammar('tree-sitter-dart'); // Verify the grammar actually works with the installed tree-sitter version const testParser = new Parser(); - testParser.setLanguage(Dart as Parser.Language); + testParser.setLanguage(Dart); } catch { Dart = null; } @@ -59,7 +59,7 @@ try { Swift = requireVendoredGrammar('tree-sitter-swift'); // Verify the grammar actually works with the installed tree-sitter version const testParser = new Parser(); - testParser.setLanguage(Swift as Parser.Language); + testParser.setLanguage(Swift); } catch { Swift = null; } @@ -73,7 +73,7 @@ const parseJava = (code: string) => { const parseKotlin = (code: string) => { if (!Kotlin) throw new Error('tree-sitter-kotlin not available'); - parser.setLanguage(Kotlin as Parser.Language); + parser.setLanguage(Kotlin); return parser.parse(code); }; @@ -3783,7 +3783,7 @@ end const parseDart = (code: string) => { if (!Dart) throw new Error('tree-sitter-dart not available'); - parser.setLanguage(Dart as Parser.Language); + parser.setLanguage(Dart); return parser.parse(code); }; @@ -4043,7 +4043,7 @@ extension StringExt on String { // --------------------------------------------------------------------------- const parsePHP = (code: string) => { - parser.setLanguage(PHP.php_only as Parser.Language); + parser.setLanguage(PHP.php_only); return parser.parse(code); }; @@ -4683,7 +4683,7 @@ describe('Rust MethodExtractor', () => { const parseSwift = (code: string) => { if (!Swift) throw new Error('tree-sitter-swift not available'); - parser.setLanguage(Swift as Parser.Language); + parser.setLanguage(Swift); return parser.parse(code); }; diff --git a/gitnexus/test/unit/model/owned-members-lookup.test.ts b/gitnexus/test/unit/model/owned-members-lookup.test.ts index 35ee5d56a..207df6f23 100644 --- a/gitnexus/test/unit/model/owned-members-lookup.test.ts +++ b/gitnexus/test/unit/model/owned-members-lookup.test.ts @@ -224,7 +224,7 @@ describe('Step 2 perf contract', () => { const callScope: Scope = { id: 'scope:method-body', parent: 'scope:module', - kind: 'Method', + kind: 'Function', range: { startLine: 2, startCol: 0, endLine: 99, endCol: 0 }, filePath: 'x.ts', bindings: new Map(), @@ -240,7 +240,9 @@ describe('Step 2 perf contract', () => { scopes: buildScopeTree([moduleScope, callScope]), defs, qualifiedNames: buildQualifiedNameIndex([userClass, saveMethod]), - moduleScopes: buildModuleScopeIndex([moduleScope]), + moduleScopes: buildModuleScopeIndex([ + { filePath: moduleScope.filePath, moduleScopeId: moduleScope.id }, + ]), methodDispatch: buildMethodDispatchIndex({ owners: ['def:User'], computeMro: () => [], diff --git a/gitnexus/test/unit/name-guessed-edges-excluded-from-flows.test.ts b/gitnexus/test/unit/name-guessed-edges-excluded-from-flows.test.ts index d879c0ec9..4bcff43bc 100644 --- a/gitnexus/test/unit/name-guessed-edges-excluded-from-flows.test.ts +++ b/gitnexus/test/unit/name-guessed-edges-excluded-from-flows.test.ts @@ -82,7 +82,9 @@ describe('process tracing excludes name-guessed CALLS edges', () => { const result = await processProcesses(graph, []); expect(result.processes.length).toBeGreaterThan(0); - const tracedNames = new Set(result.steps.map((step) => step.toName)); + const tracedNames = new Set( + result.steps.map((step) => graph.getNode(step.nodeId)?.properties.name), + ); expect(tracedNames.has('unrelatedHelper')).toBe(false); }); }); diff --git a/gitnexus/test/unit/objective-c-provider.test.ts b/gitnexus/test/unit/objective-c-provider.test.ts index c191be2e7..5d65dab27 100644 --- a/gitnexus/test/unit/objective-c-provider.test.ts +++ b/gitnexus/test/unit/objective-c-provider.test.ts @@ -1,3 +1,5 @@ +import type { ObjCFileFacts } from '../../src/core/ingestion/languages/objective-c/facts.js'; +import { finalizeScopeModel } from '../../src/core/ingestion/finalize-orchestrator.js'; import { describe, expect, it } from 'vitest'; import path from 'path'; import Parser from 'tree-sitter'; @@ -504,27 +506,41 @@ static int helper(void) { return 1; } graph.addNode({ id: container.nodeId, label: container.label, - properties: { filePath: facts.filePath, qualifiedName: container.qualifiedName }, + properties: { + name: container.name, + filePath: facts.filePath, + qualifiedName: container.qualifiedName, + }, }); } for (const method of facts.methods) { graph.addNode({ id: method.nodeId, label: 'Method', - properties: { filePath: facts.filePath, qualifiedName: method.qualifiedName }, + properties: { + name: method.name, + filePath: facts.filePath, + qualifiedName: method.qualifiedName, + }, }); } - objectiveCScopeResolver.emitPostResolutionEdges?.(graph, [ - { - filePath: facts.filePath, - moduleScope: 0, - scopes: [], - parsedImports: [], - localDefs: [], - referenceSites: [], - captureSideChannel: { kind: 'objective-c', facts }, - }, - ]); + objectiveCScopeResolver.emitPostResolutionEdges?.( + graph, + [ + { + filePath: facts.filePath, + moduleScope: `scope:${facts.filePath}#module`, + scopes: [], + parsedImports: [], + localDefs: [], + referenceSites: [], + captureSideChannel: { kind: 'objective-c', facts }, + }, + ], + new Map(), + finalizeScopeModel([]), + { fileContents: new Map() }, + ); const go = facts.methods.find( (method) => method.selector === 'go' && method.declarationRole === 'implementation', @@ -779,7 +795,7 @@ static int helper(void) { return 1; } protocols: [] as string[], }; const protocolRunQn = objcMethodQualifiedName(protocolQn, '-', 'run'); - const factsList = [ + const factsList: ObjCFileFacts[] = [ { providerVersion: '0', grammarPackage: 'tree-sitter-objc', @@ -893,7 +909,7 @@ static int helper(void) { return 1; } const graph = createKnowledgeGraph(); const parsedFiles = factsList.map((facts) => ({ filePath: facts.filePath, - moduleScope: 0, + moduleScope: `scope:${facts.filePath}#module`, scopes: [], parsedImports: [], localDefs: [], @@ -904,25 +920,43 @@ static int helper(void) { return 1; } graph.addNode({ id: `File:${facts.filePath}`, label: 'File', - properties: { filePath: facts.filePath, qualifiedName: facts.filePath }, + properties: { + name: facts.filePath, + filePath: facts.filePath, + qualifiedName: facts.filePath, + }, }); for (const container of facts.containers) { graph.addNode({ id: container.nodeId, label: container.label, - properties: { filePath: facts.filePath, qualifiedName: container.qualifiedName }, + properties: { + name: container.name, + filePath: facts.filePath, + qualifiedName: container.qualifiedName, + }, }); } for (const method of facts.methods) { graph.addNode({ id: method.nodeId, label: 'Method', - properties: { filePath: facts.filePath, qualifiedName: method.qualifiedName }, + properties: { + name: method.name, + filePath: facts.filePath, + qualifiedName: method.qualifiedName, + }, }); } } - objectiveCScopeResolver.emitPostResolutionEdges?.(graph, parsedFiles); + objectiveCScopeResolver.emitPostResolutionEdges?.( + graph, + parsedFiles, + new Map(), + finalizeScopeModel([]), + { fileContents: new Map() }, + ); let implementerUses = 0; let receiverUses = 0; diff --git a/gitnexus/test/unit/ops-snapshot.test.ts b/gitnexus/test/unit/ops-snapshot.test.ts index 29fa32a9f..298b823e1 100644 --- a/gitnexus/test/unit/ops-snapshot.test.ts +++ b/gitnexus/test/unit/ops-snapshot.test.ts @@ -27,8 +27,8 @@ describe('serializeOpsJob / summarizeOpsLane', () => { expect(view.durationMs).toBe(5_000); expect(view.lane).toBe('analyze'); expect(view.repoName).toBe('repo'); - expect(view.repoUrl).toBeUndefined(); - expect(view.repoPath).toBeUndefined(); + expect(view).not.toHaveProperty('repoUrl'); + expect(view).not.toHaveProperty('repoPath'); expect(view.branch).toBeUndefined(); }); @@ -72,7 +72,7 @@ describe('serializeOpsJob / summarizeOpsLane', () => { const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze'); expect(view.repoName).toBe('repo'); expect(view.repoName).not.toContain('access_token'); - expect(view.repoUrl).toBeUndefined(); + expect(view).not.toHaveProperty('repoUrl'); }); it('redacts the full repository URL from job.error on the public ops view', () => { diff --git a/gitnexus/test/unit/parse-diff-hunks.test.ts b/gitnexus/test/unit/parse-diff-hunks.test.ts index 0057803f5..84ba9ad03 100644 --- a/gitnexus/test/unit/parse-diff-hunks.test.ts +++ b/gitnexus/test/unit/parse-diff-hunks.test.ts @@ -164,7 +164,30 @@ describe('parseDiffHunks', () => { 'rename from src/old.ts', 'rename to src/new.ts', ].join('\n'); - expect(parseDiffHunks(diff)).toEqual([{ filePath: 'src/new.ts', hunks: [] }]); + expect(parseDiffHunks(diff)).toEqual([ + { filePath: 'src/new.ts', oldFilePath: 'src/old.ts', hunks: [] }, + ]); + }); + + it('decodes the source of a C-quoted rename and preserves it through content headers', () => { + const diff = [ + 'diff --git "a/src/old\\040name.py" b/src/new.txt', + 'similarity index 80%', + 'rename from "src/old\\040name.py"', + 'rename to src/new.txt', + '--- "a/src/old\\040name.py"', + '+++ b/src/new.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ].join('\n'); + expect(parseDiffHunks(diff)).toEqual([ + { + filePath: 'src/new.txt', + oldFilePath: 'src/old name.py', + hunks: [{ startLine: 1, endLine: 1 }], + }, + ]); }); it('keeps line ranges for whitespace-only hunks', () => { @@ -241,7 +264,9 @@ describe('parseDiffHunks', () => { 'rename from plain.ts', 'rename to foo b/plain.ts', ].join('\n'); - expect(parseDiffHunks(diff)).toEqual([{ filePath: 'foo b/plain.ts', hunks: [] }]); + expect(parseDiffHunks(diff)).toEqual([ + { filePath: 'foo b/plain.ts', oldFilePath: 'plain.ts', hunks: [] }, + ]); }); it('keeps one FileDiff when a content line repeats +++ b/', () => { @@ -278,7 +303,7 @@ describe('parseDiffHunks', () => { 'rename to new.ts', ].join('\n'); expect(parseDiffHunksResult(diff)).toEqual({ - files: [{ filePath: 'new.ts', hunks: [] }], + files: [{ filePath: 'new.ts', oldFilePath: 'old name.ts', hunks: [] }], unparsedGitHeaders: 0, }); }); @@ -291,7 +316,7 @@ describe('parseDiffHunks', () => { 'rename to new name.ts', ].join('\n'); expect(parseDiffHunksResult(diff)).toEqual({ - files: [{ filePath: 'new name.ts', hunks: [] }], + files: [{ filePath: 'new name.ts', oldFilePath: 'old.ts', hunks: [] }], unparsedGitHeaders: 0, }); }); diff --git a/gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts b/gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts index 216170a2b..9e58cedfb 100644 --- a/gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts +++ b/gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts @@ -12,6 +12,7 @@ import os from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; import { runChunkedParseAndResolve } from '../../src/core/ingestion/pipeline-phases/parse-impl.js'; import { computeChunkHash, fileContentHash } from '../../src/storage/parse-cache.js'; @@ -27,16 +28,15 @@ const emptyWorkerResult = (filePath: string, name: string): ParseWorkerResult => filePath, startLine: 1, endLine: 1, - language: 'typescript', + language: SupportedLanguages.TypeScript, + isExported: true, }, }, ], relationships: [], symbols: [], - imports: [], calls: [], assignments: [], - heritage: [], routes: [], fetchCalls: [], fetchWrapperDefs: [], diff --git a/gitnexus/test/unit/parsedfile-store.test.ts b/gitnexus/test/unit/parsedfile-store.test.ts index 7834a9d3c..2f5c3536c 100644 --- a/gitnexus/test/unit/parsedfile-store.test.ts +++ b/gitnexus/test/unit/parsedfile-store.test.ts @@ -26,29 +26,40 @@ import { * Maps — the round-trip's fidelity hinges on those Maps surviving JSON * serialization (they would otherwise collapse to `{}`). */ -const makeParsedFile = (filePath: string): ParsedFile => - ({ - filePath, - moduleScope: `${filePath}:module`, - parsedImports: [], - localDefs: [ - { nodeId: `Function:${filePath}:fn`, filePath, type: 'Function', qualifiedName: 'fn' }, - ], - referenceSites: [], - scopes: [ - { - id: `${filePath}:module`, - parent: null, - kind: 'Module', - range: { startLine: 1, startCol: 0, endLine: 9, endCol: 0 }, - filePath, - bindings: new Map([['fn', [{ defId: `Function:${filePath}:fn`, origin: 'local' }]]]), - ownedDefs: [], - imports: [], - typeBindings: new Map([['x', { name: 'int' }]]), - }, - ], - }) as unknown as ParsedFile; +const makeParsedFile = (filePath: string): ParsedFile => ({ + filePath, + moduleScope: `${filePath}:module`, + parsedImports: [], + localDefs: [ + { nodeId: `Function:${filePath}:fn`, filePath, type: 'Function', qualifiedName: 'fn' }, + ], + referenceSites: [], + scopes: [ + { + id: `${filePath}:module`, + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 9, endCol: 0 }, + filePath, + bindings: new Map([ + [ + 'fn', + [ + { + def: { nodeId: `Function:${filePath}:fn`, filePath, type: 'Function' }, + origin: 'local', + }, + ], + ], + ]), + ownedDefs: [], + imports: [], + typeBindings: new Map([ + ['x', { rawName: 'int', declaredAtScope: `${filePath}:module`, source: 'annotation' }], + ]), + }, + ], +}); /** * Store payload with arbitrary (possibly corrupt) field overrides. The one @@ -79,9 +90,9 @@ describe('parsedfile-store', () => { const a = loaded.get('a.c')!; const scope = a.scopes[0]; expect(scope.bindings).toBeInstanceOf(Map); - expect(scope.bindings.get('fn')?.[0]?.defId).toBe('Function:a.c:fn'); + expect(scope.bindings.get('fn')?.[0]?.def.nodeId).toBe('Function:a.c:fn'); expect(scope.typeBindings).toBeInstanceOf(Map); - expect((scope.typeBindings.get('x') as { name: string }).name).toBe('int'); + expect(scope.typeBindings.get('x')?.rawName).toBe('int'); } finally { await rm(dir, { recursive: true, force: true }); } @@ -374,7 +385,7 @@ describe('parsedfile-store', () => { const loaded = await loadParsedFilesForPaths(dir, new Set(['a.c'])); const scope = loaded.get('a.c')!.scopes[0]; expect(scope.bindings).toBeInstanceOf(Map); - expect(scope.bindings.get('fn')?.[0]?.defId).toBe('Function:a.c:fn'); + expect(scope.bindings.get('fn')?.[0]?.def.nodeId).toBe('Function:a.c:fn'); expect(scope.typeBindings).toBeInstanceOf(Map); } finally { await rm(dir, { recursive: true, force: true }); @@ -916,9 +927,12 @@ describe('parsedfile-store receiverChain sanitation', () => { type: 'Function' as const, qualifiedName: 'fn', }; - const pf = makeParsedFile('a.c'); - (pf.localDefs as unknown as object[])[0] = def; - (pf.scopes[0] as { ownedDefs: object[] }).ownedDefs = [def]; + const base = makeParsedFile('a.c'); + const pf: ParsedFile = { + ...base, + localDefs: [def], + scopes: [{ ...base.scopes[0], ownedDefs: [def] }], + }; await persistParsedFileChunk(dir, 'ok', [pf]); const loadedFile = (await loadParsedFilesForPaths(dir, new Set(['a.c']))).get('a.c'); expect(loadedFile).toBeDefined(); diff --git a/gitnexus/test/unit/pdg-callee-id-capture.test.ts b/gitnexus/test/unit/pdg-callee-id-capture.test.ts index ef2804f72..efd1e2aaa 100644 --- a/gitnexus/test/unit/pdg-callee-id-capture.test.ts +++ b/gitnexus/test/unit/pdg-callee-id-capture.test.ts @@ -140,6 +140,7 @@ function makeIndexes( unresolvedEdges: 0, sccCount: 0, largestSccSize: 0, + ambiguousWildcardExports: [], }, }; } diff --git a/gitnexus/test/unit/pdg-impact-engine.test.ts b/gitnexus/test/unit/pdg-impact-engine.test.ts index e163df4be..4d6f3f558 100644 --- a/gitnexus/test/unit/pdg-impact-engine.test.ts +++ b/gitnexus/test/unit/pdg-impact-engine.test.ts @@ -1,12 +1,95 @@ import { describe, expect, it } from 'vitest'; import { IMPACT_MAX_DEPTH } from '../../src/mcp/tools.js'; +import { CALLEES_TRUNCATED_SENTINEL } from '../../src/core/ingestion/cfg/callee-cell-format.js'; import { pdgLayerStatus, runImpactPDG, + splitCalleeIds, type RunPdgImpactDeps, } from '../../src/mcp/local/pdg-impact.js'; +import { SymbolIdentityError } from '../../src/mcp/local/query-result-integrity.js'; + +describe('splitCalleeIds', () => { + const firstId = 'Function:src/my dir/rené.cpp:unsigned char'; + const secondId = 'Function:src/my dir/rené.cpp:long double'; + + it.each([undefined, null, '', ' ', '\t', '\t \t'])( + 'preserves an entirely empty optional cell (%j)', + (cell) => { + expect(splitCalleeIds(cell)).toEqual([]); + }, + ); + + it('preserves spaces and Unicode within healthy callee identities', () => { + expect(splitCalleeIds(`${firstId}\t${secondId}`)).toEqual([firstId, secondId]); + }); + + it('drops the generated truncation sentinel without changing resolved identities', () => { + expect(splitCalleeIds(CALLEES_TRUNCATED_SENTINEL)).toEqual([]); + expect(splitCalleeIds(`${firstId}\t${CALLEES_TRUNCATED_SENTINEL}\t${secondId}`)).toEqual([ + firstId, + secondId, + ]); + }); + + it.each([ + ['leading', `\t${firstId}`], + ['interior', `${firstId}\t\t${secondId}`], + ['trailing', `${firstId}\t`], + ['whitespace-only token', `${firstId}\t \t${secondId}`], + ['before a sentinel', `\t${CALLEES_TRUNCATED_SENTINEL}`], + ['after a sentinel', `${CALLEES_TRUNCATED_SENTINEL}\t`], + ['mixed with a sentinel', `${firstId}\t\t${CALLEES_TRUNCATED_SENTINEL}\t${secondId}`], + ])('rejects a populated cell with an empty identity (%s)', (_case, cell) => { + expect(() => splitCalleeIds(cell)).toThrow(SymbolIdentityError); + }); +}); describe('runImpactPDG', () => { + it.each([ + ['leading', '\tFunction:src/hot.ts:callee'], + ['interior', 'Function:src/hot.ts:a\t\tFunction:src/hot.ts:b'], + ['trailing', 'Function:src/hot.ts:callee\t'], + ['sentinel-adjacent', `Function:src/hot.ts:callee\t${CALLEES_TRUNCATED_SENTINEL}\t`], + ])( + 'rejects an empty callee identity before interprocedural descent (%s)', + async (_case, cell) => { + const seed = 'BasicBlock:src/hot.ts:1:0:0'; + const queries: string[] = []; + const exec: RunPdgImpactDeps['executeParameterized'] = async (_repo, query) => { + queries.push(query); + if (query.includes('MATCH (a:BasicBlock) WHERE')) return [{ id: seed }]; + if (query.includes('RETURN b.id AS id, b.calleeIds AS calleeIds')) { + return [{ id: seed, calleeIds: cell, callees: 'callee' }]; + } + return []; + }; + + await expect( + runImpactPDG({ + repo: { lbugPath: 'repo' }, + sym: { + id: 'Function:src/hot.ts:hot', + name: 'hot', + filePath: 'src/hot.ts', + startLine: 0, + endLine: 3, + }, + symType: 'Function', + direction: 'downstream', + maxDepth: 2, + limit: 50, + line: 1, + executeParameterized: exec, + }), + ).rejects.toBeInstanceOf(SymbolIdentityError); + expect( + queries.some((query) => query.includes('RETURN b.id AS id, b.calleeIds AS calleeIds')), + ).toBe(true); + expect(queries.some((query) => query.includes("r.type = 'CALL_SUMMARY'"))).toBe(false); + }, + ); + it('clamps huge maxDepth values to the documented impact traversal cap', async () => { let bfsQueries = 0; const exec = async (_repo: string, query: string) => { @@ -33,6 +116,8 @@ describe('runImpactPDG', () => { }); expect(bfsQueries).toBe(IMPACT_MAX_DEPTH); + expect('truncated' in result).toBe(true); + if (!('truncated' in result)) throw new Error('Expected a successful PDG result'); expect(result.truncated).toBe(true); expect(result.truncatedBy).toBe('depth'); }); diff --git a/gitnexus/test/unit/publish.test.ts b/gitnexus/test/unit/publish.test.ts index fdbccbb47..58230fbe7 100644 --- a/gitnexus/test/unit/publish.test.ts +++ b/gitnexus/test/unit/publish.test.ts @@ -113,7 +113,7 @@ describe('understand-quickly helpers (gitnexus-shared)', () => { describe('publishCommand (no-token no-op)', () => { let tempDir: string; let originalToken: string | undefined; - let exitCodeBefore: number | undefined; + let exitCodeBefore: typeof process.exitCode; beforeEach(async () => { vi.resetModules(); @@ -180,7 +180,7 @@ describe('publishCommand (no-token no-op)', () => { describe('publishCommand response branches (MEDIUM 5)', () => { let tempDir: string; let originalToken: string | undefined; - let exitCodeBefore: number | undefined; + let exitCodeBefore: typeof process.exitCode; let fetchSpy: ReturnType; beforeEach(async () => { diff --git a/gitnexus/test/unit/query-degraded-signal.test.ts b/gitnexus/test/unit/query-degraded-signal.test.ts index a7ed807e8..7e28c5f6a 100644 --- a/gitnexus/test/unit/query-degraded-signal.test.ts +++ b/gitnexus/test/unit/query-degraded-signal.test.ts @@ -41,12 +41,13 @@ vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => { }; }); -import { LocalBackend } from '../../src/mcp/local/local-backend'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { resetExtensionState } from '../../src/core/lbug/extension-loader.js'; // A backend whose hybrid search yields exactly one matched symbol, so the // enrichment chunk loop runs and can be made to fail. `ftsUsed` is parameterized // so we can exercise the FTS-missing + enrichment-degraded composition. -function makeBackend(ftsUsed = true, nonBenignErrors?: string[]): LocalBackend { +function makeBackend(ftsUsed = true, nonBenignErrors?: string[]) { const backend = new LocalBackend(); const repoHandle = { id: 'repo1', @@ -72,7 +73,7 @@ function makeBackend(ftsUsed = true, nonBenignErrors?: string[]): LocalBackend { .fn() .mockResolvedValue({ results: [sym], ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) }); (backend as any).semanticSearch = vi.fn().mockResolvedValue([]); - return { backend, repoHandle } as any; + return { backend, repoHandle }; } const runQuery = (b: any, params: any = { query: 'x' }) => @@ -415,3 +416,100 @@ describe('query: degraded-enrichment signal', () => { } }); }); + +describe('query: partial missing FTS indexes', () => { + beforeEach(() => { + vi.clearAllMocks(); + loadMetaMock.mockResolvedValue(null); + executeParameterizedMock.mockResolvedValue([]); + }); + afterEach(() => vi.unstubAllEnvs()); + + it('returns successful symbols with a repair hint and partial flag when another index is missing', async () => { + const b = makeBackend(true) as any; + const resultWithMissing = await b.backend.bm25Search(); + b.backend.bm25Search.mockResolvedValue({ + ...resultWithMissing, + missingIndexes: ['Function.function_fts'], + }); + + const result = await runQuery(b); + + expect(result.definitions.map((d: any) => d.id)).toContain('func:x'); + expect(result.partial).toBe(true); + expect(result.warning).toContain('Function.function_fts'); + expect(result.warning).toContain('repair-fts'); + }); + + it('composes missing indexes with real FTS errors and enrichment failures', async () => { + const b = makeBackend(true, ['connection reset']) as any; + const resultWithMissing = await b.backend.bm25Search(); + b.backend.bm25Search.mockResolvedValue({ + ...resultWithMissing, + missingIndexes: ['Function.function_fts'], + }); + executeParameterizedMock.mockImplementation(async (_repo: string, cypher: string) => { + if (cypher.includes('STEP_IN_PROCESS')) throw new Error('timed out'); + return []; + }); + + const result = await runQuery(b); + + expect(result.partial).toBe(true); + expect(result.warning).toContain('Function.function_fts'); + expect(result.warning).toContain('FTS keyword search partially failed'); + expect(result.warning).toContain('enrichment'); + }); +}); + +it('propagates missing-index diagnostics through the real bm25Search helper into query', async () => { + vi.clearAllMocks(); + loadMetaMock.mockResolvedValue(null); + executeParameterizedMock.mockResolvedValue([]); + const search = await import('../../src/core/search/bm25-index.js'); + const spy = vi.spyOn(search, 'searchFTSFromLbug').mockResolvedValue({ + results: [], + ftsAvailable: true, + missingIndexes: ['Function.function_fts'], + }); + try { + const b = makeBackend(true) as any; + b.backend.bm25Search = (LocalBackend.prototype as any).bm25Search; + const result = await runQuery(b); + expect(result.partial).toBe(true); + expect(result.warning).toContain('Function.function_fts'); + } finally { + spy.mockRestore(); + } +}); + +it('reports missing indexes and real errors when every FTS query fails through the search boundary', async () => { + vi.clearAllMocks(); + resetExtensionState(); + loadMetaMock.mockResolvedValue(null); + executeParameterizedMock.mockImplementation(async (_repo: string, cypher: string) => { + if (cypher.includes("QUERY_FTS_INDEX('Function'")) { + throw new Error( + "Binder exception: Table Function doesn't have an index with name function_fts.", + ); + } + if (cypher.includes('QUERY_FTS_INDEX')) { + throw new Error('connection reset at /home/alice/private/index.lbug'); + } + return []; + }); + const b = makeBackend(false) as any; + b.backend.bm25Search = (LocalBackend.prototype as any).bm25Search; + + const result = await runQuery(b); + + expect(result.definitions).toEqual([]); + expect(result.warning).toContain('FTS keyword search failed'); + expect(result.warning).toContain('connection reset at '); + expect(result.warning).toContain('Function.function_fts'); + expect(result.warning).toContain('gitnexus analyze --repair-fts'); + expect(result.warning).toContain('resolved: repo1'); + expect(result.warning).not.toContain('not a missing-index'); + expect(result.warning).not.toContain('partially failed'); + expect(JSON.stringify(result)).not.toContain('/home/alice'); +}); diff --git a/gitnexus/test/unit/query-fts-missing-index.test.ts b/gitnexus/test/unit/query-fts-missing-index.test.ts new file mode 100644 index 000000000..aa50a50bf --- /dev/null +++ b/gitnexus/test/unit/query-fts-missing-index.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +const native = vi.hoisted(() => ({ + error: undefined as string | undefined, + params: [] as unknown[], +})); + +// Control the native statement result, while exercising the real adapter's +// connection lock, prepared-query path and missing-index error handling. +vi.mock('@ladybugdb/core', () => { + const emptyResult = { + getAll: async () => [], + close: async () => {}, + isSuccess: () => true, + getErrorMessage: async () => '', + }; + class Database { + async init() {} + async close() {} + } + class Connection { + async query() { + return emptyResult; + } + async prepare() { + return { + isSuccess: () => native.error === undefined, + getErrorMessage: async () => native.error ?? '', + }; + } + async execute(_statement: unknown, params: unknown) { + native.params.push(params); + return emptyResult; + } + async close() {} + } + const mod = { Database, Connection }; + return { ...mod, default: mod, lbug: mod }; +}); + +import { closeLbug, queryFTS, withLbugDb } from '../../src/core/lbug/lbug-adapter.js'; + +const MISSING = "Binder exception: Table Function doesn't have an index with name function_fts."; + +describe('queryFTS missing-index diagnostics', () => { + let dir: string; + let dbPath: string; + beforeEach(async () => { + native.error = undefined; + native.params = []; + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-test-')); + dbPath = path.join(dir, 'lbug'); + await fs.writeFile(dbPath, ''); + }); + afterEach(async () => { + await closeLbug(); + await fs.rm(dir, { recursive: true, force: true }); + }); + + it('retains the existing empty-result default for a missing index', async () => { + await withLbugDb( + dbPath, + async () => { + native.error = MISSING; + expect(await queryFTS('Function', 'function_fts', 'auth')).toEqual([]); + }, + { readOnly: true, skipFts: true }, + ); + }); + + it('can propagate a missing index so search does not count it as a successful zero-match query', async () => { + await withLbugDb( + dbPath, + async () => { + native.error = MISSING; + await expect( + queryFTS('Function', 'function_fts', 'auth', 5, false, 'throw'), + ).rejects.toThrow(MISSING); + }, + { readOnly: true, skipFts: true }, + ); + }); + + it('preserves prepared query binding and successful empty results in diagnostic mode', async () => { + await withLbugDb( + dbPath, + async () => { + const query = "auth' DELETE n"; + expect(await queryFTS('Function', 'function_fts', query, 5, false, 'throw')).toEqual([]); + expect(native.params).toEqual([{ query }]); + }, + { readOnly: true, skipFts: true }, + ); + }); + + it('still propagates real failures in either missing-index mode', async () => { + await withLbugDb( + dbPath, + async () => { + native.error = 'Runtime exception: connection reset'; + await expect(queryFTS('Function', 'function_fts', 'auth')).rejects.toThrow( + 'connection reset', + ); + await expect( + queryFTS('Function', 'function_fts', 'auth', 5, false, 'throw'), + ).rejects.toThrow('connection reset'); + }, + { readOnly: true, skipFts: true }, + ); + }); +}); diff --git a/gitnexus/test/unit/range-binding-parse-timeout.test.ts b/gitnexus/test/unit/range-binding-parse-timeout.test.ts index 7e07d0616..683d88759 100644 --- a/gitnexus/test/unit/range-binding-parse-timeout.test.ts +++ b/gitnexus/test/unit/range-binding-parse-timeout.test.ts @@ -1,5 +1,6 @@ +import type { ScopeResolutionIndexes } from '../../src/core/ingestion/model/scope-resolution-indexes.js'; import { describe, it, expect, afterEach } from 'vitest'; -import type { ParsedFile, ScopeResolutionIndexes } from 'gitnexus-shared'; +import type { ParsedFile } from 'gitnexus-shared'; import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js'; import { goScopeResolver } from '../../src/core/ingestion/languages/go/scope-resolver.js'; import { cppScopeResolver } from '../../src/core/ingestion/languages/cpp/scope-resolver.js'; diff --git a/gitnexus/test/unit/rate-limit.test.ts b/gitnexus/test/unit/rate-limit.test.ts index bfb2865df..90e428b25 100644 --- a/gitnexus/test/unit/rate-limit.test.ts +++ b/gitnexus/test/unit/rate-limit.test.ts @@ -119,7 +119,7 @@ describe('createRouteLimiter — integration with a real route', () => { const res = await fetch(`${baseUrl}/test/file`); expect(res.status).toBe(429); const body = await res.json(); - expect(body.error).toContain('Too many'); + expect(body).toHaveProperty('error', expect.stringContaining('Too many')); }); it('emits draft-7 RateLimit response header (combined form), not legacy X-RateLimit-*', async () => { diff --git a/gitnexus/test/unit/repo-manager-ensure-ignore-readonly.test.ts b/gitnexus/test/unit/repo-manager-ensure-ignore-readonly.test.ts index 8e49a01f3..abf620bf4 100644 --- a/gitnexus/test/unit/repo-manager-ensure-ignore-readonly.test.ts +++ b/gitnexus/test/unit/repo-manager-ensure-ignore-readonly.test.ts @@ -13,7 +13,7 @@ const fswCtx = vi.hoisted(() => ({ vi.mock('fs/promises', async (importOriginal) => { const actual = await importOriginal(); - const d = actual.default; + const d = actual; fswCtx.realWrite = d.writeFile.bind(d); fswCtx.writeFileMock.mockImplementation((...args) => fswCtx.realWrite!(...args)); return { diff --git a/gitnexus/test/unit/repo-manager-reconcile.test.ts b/gitnexus/test/unit/repo-manager-reconcile.test.ts index 4e6ab26d6..9d3b1b50c 100644 --- a/gitnexus/test/unit/repo-manager-reconcile.test.ts +++ b/gitnexus/test/unit/repo-manager-reconcile.test.ts @@ -91,9 +91,10 @@ describe('reconcileMetadataFiles', () => { // The dirty flag travels through this file; a reconciliation that // reconstructed a trimmed object instead of carrying fields verbatim // would silently drop it and skip the recovery full-rebuild. - const dirty = metaAt('2026-06-01T00:00:00.000Z', 'crashed-run', { + const dirty = { + ...metaAt('2026-06-01T00:00:00.000Z', 'crashed-run'), incrementalInProgress: true, - } as Partial); + }; await fs.writeFile(path.join(storagePath, 'meta.json'), JSON.stringify(dirty)); await reconcileMetadataFiles(tmpRepo.dbPath); diff --git a/gitnexus/test/unit/repo-manager-rm-failure.test.ts b/gitnexus/test/unit/repo-manager-rm-failure.test.ts index 427cc8367..ab8b8a076 100644 --- a/gitnexus/test/unit/repo-manager-rm-failure.test.ts +++ b/gitnexus/test/unit/repo-manager-rm-failure.test.ts @@ -14,7 +14,7 @@ const fsCtx = vi.hoisted(() => ({ vi.mock('fs/promises', async (importOriginal) => { const actual = await importOriginal(); - const d = actual.default; + const d = actual; fsCtx.realRm = d.rm.bind(d); fsCtx.rmMock.mockImplementation((...args) => fsCtx.realRm!(...args)); return { diff --git a/gitnexus/test/unit/repo-manager.test.ts b/gitnexus/test/unit/repo-manager.test.ts index fddafbe39..d168aef57 100644 --- a/gitnexus/test/unit/repo-manager.test.ts +++ b/gitnexus/test/unit/repo-manager.test.ts @@ -1959,7 +1959,7 @@ describe('assertSafeStoragePath (#1003)', () => { await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError); }); - it.each([null, 42])('rejects malformed storagePath %p with the safety error', async (value) => { + it.each([null, 42])('rejects malformed storagePath %s with the safety error', async (value) => { const entry = { ...base, storagePath: value, diff --git a/gitnexus/test/unit/repro-3291-staleness-indexed-ref.test.ts b/gitnexus/test/unit/repro-3291-staleness-indexed-ref.test.ts index fa79f6c85..a58963d31 100644 --- a/gitnexus/test/unit/repro-3291-staleness-indexed-ref.test.ts +++ b/gitnexus/test/unit/repro-3291-staleness-indexed-ref.test.ts @@ -162,9 +162,10 @@ describe('#3291 — tool staleness names the indexed ref', () => { // The graph answer itself is held constant so the only thing that can differ // between the two responses is the freshness signalling under test. - vi.spyOn(backend as unknown as { impact: unknown }, 'impact').mockResolvedValue( - IMPACT_RESULT as never, - ); + vi.spyOn( + backend as unknown as { impact: () => Promise }, + 'impact', + ).mockResolvedValue(IMPACT_RESULT as never); const fromMain = (await backend.callTool('impact', { target: 'doWork', @@ -218,9 +219,10 @@ describe('#3291 — tool staleness names the indexed ref', () => { vi.spyOn(backend, 'selectToolRepository').mockResolvedValue( handleFor(mainClone, twoBack, 'main') as never, ); - vi.spyOn(backend as unknown as { impact: unknown }, 'impact').mockResolvedValue( - IMPACT_RESULT as never, - ); + vi.spyOn( + backend as unknown as { impact: () => Promise }, + 'impact', + ).mockResolvedValue(IMPACT_RESULT as never); const result = (await backend.callTool('impact', { target: 'doWork', @@ -244,9 +246,10 @@ describe('#3291 — tool staleness names the indexed ref', () => { const { branch: _unlabelled, ...unlabelledHandle } = handleFor(mainClone, head, 'main'); vi.spyOn(backend, 'selectToolRepository').mockResolvedValue(unlabelledHandle as never); - vi.spyOn(backend as unknown as { impact: unknown }, 'impact').mockResolvedValue( - IMPACT_RESULT as never, - ); + vi.spyOn( + backend as unknown as { impact: () => Promise }, + 'impact', + ).mockResolvedValue(IMPACT_RESULT as never); const result = (await backend.callTool('impact', { target: 'doWork', diff --git a/gitnexus/test/unit/resolve-enclosing-owner.test.ts b/gitnexus/test/unit/resolve-enclosing-owner.test.ts index 6af25951b..4856d7291 100644 --- a/gitnexus/test/unit/resolve-enclosing-owner.test.ts +++ b/gitnexus/test/unit/resolve-enclosing-owner.test.ts @@ -30,7 +30,7 @@ const parseRuby = (code: string) => { }; const parseKotlin = (code: string) => { - parser.setLanguage(Kotlin as Parser.Language); + parser.setLanguage(Kotlin); return parser.parse(code); }; diff --git a/gitnexus/test/unit/resolve-route-handler-symbols.test.ts b/gitnexus/test/unit/resolve-route-handler-symbols.test.ts index 017a945f8..950a757bc 100644 --- a/gitnexus/test/unit/resolve-route-handler-symbols.test.ts +++ b/gitnexus/test/unit/resolve-route-handler-symbols.test.ts @@ -237,7 +237,7 @@ describe('resolveRouteHandlerSymbols — decorator routes', () => { const owner = model.symbols.add('src/handlers.js', 'auth', 'object:auth', 'Variable'); model.methods.register(owner.nodeId, 'getCurrentUser', { filePath: 'src/handlers.js', - name: 'getCurrentUser', + qualifiedName: 'getCurrentUser', nodeId: 'method:auth.getCurrentUser', type: 'Method', ownerId: owner.nodeId, @@ -281,7 +281,7 @@ describe('resolveRouteHandlerSymbols — decorator routes', () => { model.symbols.add('src/routes.js', 'auth', 'object:auth', 'Variable'); model.methods.register('object:auth', 'getCurrentUser', { filePath: 'src/routes.js', - name: 'getCurrentUser', + qualifiedName: 'getCurrentUser', nodeId: 'method:auth.getCurrentUser', type: 'Method', ownerId: 'object:auth', @@ -326,7 +326,7 @@ describe('resolveRouteHandlerSymbols — decorator routes', () => { model.symbols.add('src/routes.js', 'services', 'object:services', 'Variable'); model.methods.register('object:services', 'getCurrentUser', { filePath: 'src/routes.js', - name: 'getCurrentUser', + qualifiedName: 'getCurrentUser', nodeId: 'method:decoy', type: 'Method', ownerId: 'object:services', diff --git a/gitnexus/test/unit/review-agent-workflow.test.ts b/gitnexus/test/unit/review-agent-workflow.test.ts index a882ebc5f..415535b69 100644 --- a/gitnexus/test/unit/review-agent-workflow.test.ts +++ b/gitnexus/test/unit/review-agent-workflow.test.ts @@ -686,12 +686,12 @@ describe('gitnexus review-agent workflow security contract', () => { it('pins every third-party action and the GitNexus analyzer exactly', () => { const expectedPins = [ - 'actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0', + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1', 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3', 'actions/setup-node@820762786026740c76f36085b0efc47a31fe5020', 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a', 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c', - 'anthropics/claude-code-action/base-action@3553f84341b92da26052e28acf1aa898f9511f32', + 'anthropics/claude-code-action/base-action@e0cf66d1d257526b5d07f141838c338921cb8455', ]; for (const pin of expectedPins) { @@ -714,7 +714,7 @@ describe('gitnexus review-agent workflow security contract', () => { expect(workflow).toContain('.github/scripts/npm-ci-retry.sh'); expect(workflow).not.toContain('--package-lock=false'); expect(workflow).toContain( - 'actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0', + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1', ); expect(workflow).toContain( 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0', diff --git a/gitnexus/test/unit/route-constant-baseline-gate.test.ts b/gitnexus/test/unit/route-constant-baseline-gate.test.ts index eb4919cff..0f4d4db2a 100644 --- a/gitnexus/test/unit/route-constant-baseline-gate.test.ts +++ b/gitnexus/test/unit/route-constant-baseline-gate.test.ts @@ -10,7 +10,6 @@ // `node --import tsx bench/*/measure.mjs --check`, never in `npm test`. import { describe, expect, it } from 'vitest'; -// @ts-expect-error — .mjs pure-JS harness module, no types (intentional; build-free). import * as G from '../../bench/lib/route-constant-guard.mjs'; const collectBaselineErrors = G.collectBaselineErrors as ( diff --git a/gitnexus/test/unit/route-process-linking.test.ts b/gitnexus/test/unit/route-process-linking.test.ts index 46bb69815..57397ec2d 100644 --- a/gitnexus/test/unit/route-process-linking.test.ts +++ b/gitnexus/test/unit/route-process-linking.test.ts @@ -101,7 +101,7 @@ describe('Route → process linking (ENTRY_POINT_OF) re-keying', () => { await processesPhase.execute( makeCtx(graph), - new Map([ + new Map>([ ['structure', phaseResult('structure', { totalFiles: 1 })], ['communities', phaseResult('communities', { communityResult: { memberships: [] } })], ['routes', phaseResult('routes', { routeRegistry })], @@ -168,7 +168,7 @@ describe('Route → process linking (ENTRY_POINT_OF) re-keying', () => { await processesPhase.execute( makeCtx(graph), - new Map([ + new Map>([ ['structure', phaseResult('structure', { totalFiles: 1 })], ['communities', phaseResult('communities', { communityResult: { memberships: [] } })], ['routes', phaseResult('routes', { routeRegistry })], @@ -239,7 +239,7 @@ describe('Route → process linking (ENTRY_POINT_OF) re-keying', () => { await processesPhase.execute( makeCtx(graph), - new Map([ + new Map>([ ['structure', phaseResult('structure', { totalFiles: 1 })], ['communities', phaseResult('communities', { communityResult: { memberships: [] } })], ['routes', phaseResult('routes', { routeRegistry })], diff --git a/gitnexus/test/unit/run-analyze-adopt-failure.test.ts b/gitnexus/test/unit/run-analyze-adopt-failure.test.ts index 7ffb6e6a2..4661b6ce6 100644 --- a/gitnexus/test/unit/run-analyze-adopt-failure.test.ts +++ b/gitnexus/test/unit/run-analyze-adopt-failure.test.ts @@ -130,7 +130,11 @@ describe('fast-path restamp failure modes (#2364 F3)', () => { const logs: string[] = []; rmCtx.adoptMock.mockRejectedValueOnce(new Error('mock adopt failure')); - const first = await runFullAnalysis(tmpRepo.dbPath, {}, { onLog: (m) => logs.push(m) }); + const first = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { onProgress: () => {}, onLog: (m) => logs.push(m) }, + ); expect(first.alreadyUpToDate).toBe(true); expect(logs.some((m) => m.includes('could not restamp the workspace branch label'))).toBe(true); @@ -140,7 +144,7 @@ describe('fast-path restamp failure modes (#2364 F3)', () => { await expect(fs.access(branchMetaDir)).resolves.toBeUndefined(); // …and the next same-commit run retries and completes the whole sync. - const second = await runFullAnalysis(tmpRepo.dbPath, {}, {}); + const second = await runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} }); expect(second.alreadyUpToDate).toBe(true); const healed = await loadMeta(flatStorage); expect(healed?.branch).toBe('feature/x'); @@ -152,7 +156,7 @@ describe('fast-path restamp failure modes (#2364 F3)', () => { rmCtx.adoptMock.mockClear(); rmCtx.saveMetaMock.mockClear(); - const result = await runFullAnalysis(tmpRepo.dbPath, {}, {}); + const result = await runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} }); expect(result.alreadyUpToDate).toBe(true); expect(rmCtx.adoptMock).toHaveBeenCalledTimes(1); @@ -171,7 +175,11 @@ describe('fast-path restamp failure modes (#2364 F3)', () => { const logs: string[] = []; rmCtx.saveMetaMock.mockRejectedValueOnce(Object.assign(new Error('mock ro'), { code })); - const result = await runFullAnalysis(tmpRepo.dbPath, {}, { onLog: (m) => logs.push(m) }); + const result = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { onProgress: () => {}, onLog: (m) => logs.push(m) }, + ); expect(result.alreadyUpToDate).toBe(true); expect(logs.some((m) => m.includes('read-only') && m.includes('#1549'))).toBe(true); @@ -187,7 +195,7 @@ describe('fast-path restamp failure modes (#2364 F3)', () => { Object.assign(new Error('mock read-only storage'), { code: 'EROFS' }), ); - const result = await runFullAnalysis(tmpRepo.dbPath, {}, {}); + const result = await runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} }); expect(result.alreadyUpToDate).toBe(true); expect(rmCtx.writableMock).not.toHaveBeenCalled(); @@ -199,7 +207,9 @@ describe('fast-path restamp failure modes (#2364 F3)', () => { const readOnly = Object.assign(new Error('mock read-only storage'), { code: 'EROFS' }); rmCtx.writableMock.mockRejectedValueOnce(readOnly); - await expect(runFullAnalysis(tmpRepo.dbPath, {}, {})).rejects.toBe(readOnly); + await expect(runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} })).rejects.toBe( + readOnly, + ); expect(rmCtx.writableMock).toHaveBeenCalledWith(flatStorage); }); }); diff --git a/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts b/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts index 6997a0604..a68f4a5ae 100644 --- a/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts +++ b/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts @@ -99,7 +99,7 @@ const fileGraph = () => { graph.addNode({ id: 'file:src/a.ts', label: 'File', - properties: { filePath: REL_FILE }, + properties: { name: REL_FILE, filePath: REL_FILE }, }); return graph; }; @@ -113,7 +113,13 @@ const mockLbugAdapter = async () => { initLbug: vi.fn(async () => undefined), loadGraphToLbug: vi.fn(async () => undefined), getLbugStats: vi.fn(async () => ({ nodes: 1, edges: 0, communities: 0, processes: 0 })), - executeQuery: vi.fn(async () => []), + // The policy fixture has one stored File row. Publication now reconciles + // that identity instead of trusting the synthetic node count alone. + executeQuery: vi.fn(async (query: string) => + query.startsWith('MATCH (n:`File`) RETURN n.id AS id') + ? [{ id: 'file:src/a.ts', name: REL_FILE, filePath: REL_FILE }] + : [], + ), executeWithReusedStatement: vi.fn(async () => []), closeLbug: vi.fn(async () => undefined), wipeLbugDbFiles: vi.fn(async () => undefined), @@ -1011,9 +1017,12 @@ describe('runFullAnalysis FTS crash marker', () => { ); it('treats a boundary checkpoint failure as best-effort on an in-place plan', async () => { - const checkpointOnce = vi.fn(async () => { - throw new Error('checkpoint rename failed'); - }); + // Only the earlier boundary is best-effort. Publication still requires + // its own successful checkpoint through the same policy helper. + const checkpointOnce = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('checkpoint rename failed')) + .mockResolvedValue(true); let stamped: RepoMeta['incrementalInProgress']; vi.doMock('../../src/core/lbug/wal-checkpoint-driver.js', async (importActual) => ({ ...(await importActual()), @@ -1060,6 +1069,7 @@ describe('runFullAnalysis FTS crash marker', () => { { onProgress: () => {}, onLog: () => {} }, ); expect(result.ftsSkipped).not.toBe(true); + expect(checkpointOnce).toHaveBeenCalledTimes(2); expect(stamped).toMatchObject({ phase: FTS_DIRTY_PHASE, writePlan: 'in-place', diff --git a/gitnexus/test/unit/run-analyze-fts-repair.test.ts b/gitnexus/test/unit/run-analyze-fts-repair.test.ts index 2f0304ef4..007cbc391 100644 --- a/gitnexus/test/unit/run-analyze-fts-repair.test.ts +++ b/gitnexus/test/unit/run-analyze-fts-repair.test.ts @@ -1,13 +1,19 @@ import { execSync } from 'child_process'; import fs from 'fs/promises'; -import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { basename } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { getStoragePaths, loadMeta, saveMeta, type RepoMeta, } from '../../src/storage/repo-manager.js'; -import { EMBEDDING_DIMS, STALE_HASH_SENTINEL } from '../../src/core/lbug/schema.js'; +import { + EMBEDDING_DIMS, + STALE_HASH_SENTINEL, + SCHEMA_FINGERPRINT, +} from '../../src/core/lbug/schema.js'; import { getIndexIncompleteReasons } from '../../src/core/index-freshness.js'; import type { EmbeddingPipelineOptions, @@ -290,6 +296,123 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => { ...overrides, }); + it.each([ + { label: 'old catalog entry survives a failed DROP', buildError: true, catalog: 'complete' }, + { label: 'CREATE succeeds without a catalog entry', buildError: false, catalog: 'missing' }, + { label: 'build and catalog verification both fail', buildError: true, catalog: 'missing' }, + { label: 'catalog read throws after a build failure', buildError: true, catalog: 'throws' }, + { label: 'catalog read alone throws', buildError: false, catalog: 'throws' }, + ])('keeps repair incomplete when $label', async ({ buildError, catalog }) => { + const closeLbug = vi.fn(async () => undefined); + vi.doMock('../../src/core/lbug/lbug-adapter.js', () => + mockRepairSuccessLbugAdapter({ closeLbug }), + ); + vi.doMock('../../src/core/search/fts-indexes.js', async (importActual) => ({ + ...(await importActual()), + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + createSearchFTSIndexes: vi.fn(async () => + buildError + ? [ + { + table: 'Property', + indexName: 'property_fts', + error: 'DROP failed: native I/O error', + }, + ] + : [], + ), + verifySearchFTSIndexes: vi.fn(async (_query, _indexes, onMismatch) => { + if (catalog === 'throws') throw new Error('catalog read unavailable'); + if (catalog === 'missing') { + onMismatch?.({ + table: 'Property', + indexName: 'property_fts', + message: 'Property.property_fts: no catalog row returned', + }); + return ['Property.property_fts']; + } + return []; + }), + })); + + const tmpRepo = await createTempDir('gitnexus-repair-incomplete-'); + try { + const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); + await fs.mkdir(storagePath, { recursive: true }); + const seeded: RepoMeta = { + repoPath: tmpRepo.dbPath, + lastCommit: 'healthy-commit', + indexedAt: '2026-01-01T00:00:00.000Z', + stats: { files: 7, nodes: 42, edges: 10, embeddings: 3 }, + runnerIdentity: { + schemaVersion: 4, + runtime: { + executablePath: '/usr/bin/node', + version: 'v24.11.0', + platform: 'linux', + architecture: 'x64', + modulesAbi: '137', + libc: 'glibc', + }, + cliVersion: '1.0.0', + invokedArtifact: { path: '/x/cli/index.ts', digest: 'src-digest' }, + build: { + kind: 'source' as const, + rootPath: '/x', + canonicalization: 'gitnexus-analyzer-build-v2', + digest: 'build-digest', + }, + dependencyRuntime: { + manifestPath: '/x/package.json', + lockfilePath: null, + canonicalization: 'gitnexus-analyzer-dependency-runtime-v4', + packageCount: 1, + artifactCount: 1, + digest: 'dep-digest', + }, + }, + capabilities: { + graph: { provider: 'ladybugdb', status: 'available' }, + fts: { provider: 'ladybugdb-fts', status: 'degraded' }, + vectorSearch: { provider: 'exact-scan', status: 'unavailable', exactScanLimit: 500 }, + }, + }; + await saveMeta(storagePath, seeded); + await createPlaceholderGraphStore(lbugPath); + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const error = await runFullAnalysis( + tmpRepo.dbPath, + { repairFts: true }, + { onProgress: () => {} }, + ).then( + () => null, + (cause: unknown) => cause, + ); + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain('FTS repair failed'); + if (buildError) expect(message).toContain('DROP failed: native I/O error'); + if (catalog === 'missing') { + expect(message).toContain('Property.property_fts: no catalog row returned'); + if (!buildError) expect(message).toContain('no build error was returned'); + } + if (catalog === 'throws') { + expect(message).toContain('catalog read unavailable'); + expect(message).not.toContain('missing indexes'); + } + const after = await loadMeta(storagePath); + expect(after?.incrementalInProgress).toMatchObject({ phase: 'fts' }); + expect(after?.capabilities).toEqual(seeded.capabilities); + expect(after?.indexedAt).toBe(seeded.indexedAt); + expect(after?.lastCommit).toBe(seeded.lastCommit); + expect(after?.stats).toEqual(seeded.stats); + expect(after?.runnerIdentity).toEqual(seeded.runnerIdentity); + expect(closeLbug).toHaveBeenCalled(); + } finally { + await tmpRepo.cleanup(); + } + }); + it('--repair-fts stamps capabilities.fts.status while leaving indexedAt/lastCommit/runnerIdentity/stats byte-identical (#2767)', async () => { vi.doMock('../../src/core/lbug/lbug-adapter.js', () => mockRepairSuccessLbugAdapter()); vi.doMock('../../src/core/search/fts-indexes.js', () => ({ @@ -313,7 +436,17 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => { indexedAt: seededIndexedAt, stats: { files: 7, nodes: 42, edges: 10 }, runnerIdentity: { - source: { kind: 'source' as const, digest: 'src-digest' }, + schemaVersion: 4, + runtime: { + executablePath: '/usr/bin/node', + version: 'v24.11.0', + platform: 'linux', + architecture: 'x64', + modulesAbi: '137', + libc: 'glibc', + }, + cliVersion: '1.0.0', + invokedArtifact: { path: '/x/cli/index.ts', digest: 'src-digest' }, build: { kind: 'source' as const, rootPath: '/x', @@ -1103,7 +1236,9 @@ describe('runFullAnalysis wipe-and-restore vector-index stamp (tri-review 466951 properties: { filePath: 'src/app.ts' }, }; const buildVectorIndex = vi.fn(async () => false); - const executeWithReusedStatement = vi.fn(async () => []); + const executeWithReusedStatement = vi.fn< + typeof import('../../src/core/lbug/lbug-adapter.js').executeWithReusedStatement + >(async () => {}); vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({ initLbug: vi.fn(async () => undefined), loadGraphToLbug: vi.fn(async () => undefined), @@ -1234,7 +1369,9 @@ describe('runFullAnalysis wipe-and-restore vector-index stamp (tri-review 466951 name: 'handler', properties: { filePath: 'src/app.ts' }, }; - const executeWithReusedStatement = vi.fn(async () => []); + const executeWithReusedStatement = vi.fn< + typeof import('../../src/core/lbug/lbug-adapter.js').executeWithReusedStatement + >(async () => {}); vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({ initLbug: vi.fn(async () => undefined), loadGraphToLbug: vi.fn(async () => undefined), @@ -2215,10 +2352,11 @@ describe('runFullAnalysis embedding-checkpoint meta write (#2790)', () => { vi.unstubAllEnvs(); }); - it('preserves lastCommit / fileHashes / the dirty flag, and never restates a stale count', async () => { + it('keeps staging counts out of published metadata until the index is swapped', async () => { const STALE_COMMIT = '1111111111111111111111111111111111111111'; const STALE_HASHES = { 'src/app.ts': 'stale-hash' }; const LIVE_EMBEDDING_COUNT = 42; + vi.stubEnv('GITNEXUS_ATOMIC_WINDOWS_SWAP', '1'); vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({ initLbug: vi.fn(async () => undefined), @@ -2315,15 +2453,14 @@ describe('runFullAnalysis embedding-checkpoint meta write (#2790)', () => { nodeIds: ['node-1', 'node-2'], }); snapshots.windowStart = await loadMeta(storagePath); - // Post-window checkpoint — this one MEASURED the live count. + // Post-window checkpoint measures staging, not the published DB. await pipelineOptions.onCheckpoint?.({ nodesProcessed: 2, totalNodes: 4, chunksProcessed: 4, }); snapshots.postWindow = await loadMeta(storagePath); - // Window 2 — the old code restated the PREVIOUS run's count here and - // clobbered the live figure the post-window save had just written. + // Window 2 must retain the published count too. await pipelineOptions.onCheckpointWindowStart?.({ nodesProcessed: 2, totalNodes: 4, @@ -2375,19 +2512,27 @@ describe('runFullAnalysis embedding-checkpoint meta write (#2790)', () => { }); expect(snapshots.windowStart?.lastCommit).not.toBe(currentCommit); - // ── Post-window: the one save that legitimately measured the count ── + // ── Post-window: the staged count is not published yet ───────────── expect(snapshots.postWindow).toMatchObject({ lastCommit: STALE_COMMIT, fileHashes: STALE_HASHES, incrementalInProgress: { phase: 'full-rebuild' }, - stats: { embeddings: LIVE_EMBEDDING_COUNT }, + stats: { embeddings: 7 }, + }); + expect(snapshots.postWindow?.embeddingCheckpoint?.recovery).toMatchObject({ + stagingFile: expect.stringMatching(/^lbug\.staging\.[a-f0-9-]+$/), + schemaFingerprint: SCHEMA_FINGERPRINT, + unsafeNodeIds: [], }); - // ── Window 2: no stale restatement over the measured figure ──────── + // ── Window 2: the published count remains unchanged ──────────────── expect(snapshots.secondWindow).toMatchObject({ lastCommit: STALE_COMMIT, - stats: { embeddings: LIVE_EMBEDDING_COUNT }, - embeddingCheckpoint: { pendingNodeIds: ['node-3', 'node-4'] }, + stats: { embeddings: 7 }, + embeddingCheckpoint: { + pendingNodeIds: ['node-3', 'node-4'], + recovery: { unsafeNodeIds: ['node-3', 'node-4'] }, + }, }); // Only the finalize write — after the index is published — advances @@ -2395,7 +2540,7 @@ describe('runFullAnalysis embedding-checkpoint meta write (#2790)', () => { const finalMeta = JSON.parse( await fs.readFile(`${storagePath}/meta.json`, 'utf-8'), ) as RepoMeta; - expect(finalMeta).toMatchObject({ lastCommit: currentCommit }); + expect(finalMeta).toMatchObject({ lastCommit: currentCommit, stats: { embeddings: 42 } }); expect(finalMeta.embeddingCheckpoint).toBeUndefined(); expect(finalMeta.incrementalInProgress).toBeUndefined(); } finally { @@ -2427,6 +2572,14 @@ describe('runFullAnalysis embedding-checkpoint meta write (#2790)', () => { * NEXT run does with it). */ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => { + const actualPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + + beforeEach(() => { + // These mocked checkpoint tests exercise the atomic rebuild used on POSIX. + // Select that same path on Windows; the in-place case below opts out. + vi.stubEnv('GITNEXUS_ATOMIC_WINDOWS_SWAP', '1'); + }); + const RESILIENCE_NODE_ID = 'Function:src/app.ts:handler:1'; const stubNode = { id: RESILIENCE_NODE_ID, @@ -2461,7 +2614,7 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => const mockResilienceHarness = ( controls: ResilienceControls, - ): { runEmbeddingPipeline: Mock; loadCachedEmbeddings: Mock } => { + ): { runEmbeddingPipeline: Mock; loadCachedEmbeddings: Mock; batchInsertEmbeddings: Mock } => { const loadCachedEmbeddings = vi.fn(async () => ({ embeddingNodeIds: new Set(), embeddings: [], @@ -2530,11 +2683,13 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => pipelineOptions: EmbeddingPipelineOptions, ): Promise => controls.pipeline(pipelineOptions), ); + const batchInsertEmbeddings = vi.fn(async () => undefined); vi.doMock('../../src/core/embeddings/embedding-pipeline.js', () => ({ runEmbeddingPipeline, + batchInsertEmbeddings, buildVectorIndex: vi.fn(async () => false), })); - return { runEmbeddingPipeline, loadCachedEmbeddings }; + return { runEmbeddingPipeline, loadCachedEmbeddings, batchInsertEmbeddings }; }; /** A checkpoint shaped exactly as `RepoMeta` declares it. */ @@ -2583,12 +2738,18 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => }; afterEach(() => { + if (actualPlatformDescriptor) { + Object.defineProperty(process, 'platform', actualPlatformDescriptor); + } vi.doUnmock('../../src/core/lbug/lbug-adapter.js'); vi.doUnmock('../../src/core/search/fts-indexes.js'); vi.doUnmock('../../src/core/ingestion/pipeline.js'); vi.doUnmock('../../src/storage/repo-manager.js'); vi.doUnmock('../../src/core/embeddings/embedding-identity.js'); vi.doUnmock('../../src/core/embeddings/embedding-pipeline.js'); + vi.doUnmock('../../src/core/embeddings/staged-embedding-recovery.js'); + vi.doUnmock('../../src/core/analyzer-identity.js'); + vi.restoreAllMocks(); vi.resetModules(); vi.clearAllMocks(); vi.unstubAllEnvs(); @@ -2699,6 +2860,7 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => */ it('carries the mid-run count forward, not the run-start snapshot, so --force still loads the cache', async () => { const MID_RUN_COUNT = 12; + vi.stubEnv('GITNEXUS_ATOMIC_WINDOWS_SWAP', '1'); const tmpRepo = await createTempDir('gitnexus-2790r-latest-meta-'); try { const { storagePath } = getStoragePaths(tmpRepo.dbPath); @@ -3039,4 +3201,664 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => await tmpRepo.cleanup(); } }); + + const mockStagedFiles = async (): Promise => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.initLbug).mockImplementation(async (dbPath) => { + if (dbPath.includes('.staging.')) await fs.writeFile(dbPath, 'staged fixture'); + return { db: null, conn: null }; + }); + vi.mocked(adapter.wipeLbugDbFiles).mockImplementation(async (dbPath) => { + await fs.rm(dbPath, { force: true }); + }); + }; + + it.each([ + { mode: 'staged', atomicSwap: '1', checkpointCount: 7 }, + { mode: 'in-place', atomicSwap: '0', checkpointCount: 42 }, + ])( + 'keeps Windows $mode checkpoint counts consistent with the live index', + async ({ mode, atomicSwap, checkpointCount }) => { + const tmpRepo = await createTempDir('gitnexus-2790-checkpoint-meta-'); + try { + const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); + await seedMeta(storagePath, tmpRepo.dbPath, { stats: { nodes: 2, embeddings: 7 } }); + await fs.writeFile(lbugPath, 'published fixture'); + const snapshots: Array = []; + mockResilienceHarness({ + count: [{ cnt: 42 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: [RESILIENCE_NODE_ID], + }); + snapshots.push(await loadMeta(storagePath)); + await options.onCheckpoint?.({ nodesProcessed: 3, totalNodes: 3, chunksProcessed: 3 }); + snapshots.push(await loadMeta(storagePath)); + return cleanResult(); + }, + }); + await mockStagedFiles(); + // Load modules on the actual host before changing only the platform + // branch exercised by runFullAnalysis; all native DB work is mocked. + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + vi.stubEnv('GITNEXUS_ATOMIC_WINDOWS_SWAP', atomicSwap); + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + await runFullAnalysis( + tmpRepo.dbPath, + { force: true, embeddings: true, skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {}, onLog: () => {} }, + ); + + expect(snapshots[0]?.stats?.embeddings).toBe(7); + expect(snapshots[1]?.stats?.embeddings).toBe(checkpointCount); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const buildPath = vi.mocked(adapter.initLbug).mock.calls.at(-1)?.[0]; + if (mode === 'staged') { + expect(buildPath).toMatch(/\.staging\.[a-f0-9-]+$/); + expect(snapshots[1]?.embeddingCheckpoint?.recovery).toMatchObject({ + stagingFile: expect.stringMatching(/^lbug\.staging\.[a-f0-9-]+$/), + unsafeNodeIds: [], + }); + expect(await fs.readFile(lbugPath, 'utf8')).toBe('staged fixture'); + } else { + expect(buildPath).toBe(lbugPath); + expect(snapshots[0]?.embeddingCheckpoint?.recovery).toBeUndefined(); + expect(snapshots[1]?.embeddingCheckpoint?.recovery).toBeUndefined(); + } + const finalMeta = await loadMeta(storagePath); + expect(finalMeta?.stats?.embeddings).toBe(42); + expect(finalMeta?.embeddingCheckpoint).toBeUndefined(); + } finally { + if (actualPlatformDescriptor) { + Object.defineProperty(process, 'platform', actualPlatformDescriptor); + } + await tmpRepo.cleanup(); + } + }, + ); + + it.each(['close', 'rename'] as const)( + 'keeps the published count and database when %s fails before the staged publish', + async (failure) => { + const tmpRepo = await createTempDir('gitnexus-2790-checkpoint-meta-'); + try { + const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); + await seedMeta(storagePath, tmpRepo.dbPath, { stats: { nodes: 2, embeddings: 7 } }); + await fs.writeFile(lbugPath, 'published fixture'); + const rename = fs.rename.bind(fs); + let checkpointMeta: RepoMeta | null = null; + let failedPublishRename: Mock | undefined; + mockResilienceHarness({ + count: [{ cnt: 42 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: [RESILIENCE_NODE_ID], + }); + await options.onCheckpoint?.({ nodesProcessed: 3, totalNodes: 3, chunksProcessed: 3 }); + checkpointMeta = await loadMeta(storagePath); + if (failure === 'close') { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.closeLbug).mockRejectedValueOnce( + new Error('pre-publish close failed'), + ); + } else { + failedPublishRename = vi.fn(async () => { + throw Object.assign(new Error('staged publish rename failed'), { code: 'EIO' }); + }); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).startsWith(`${lbugPath}.staging.`) && + String(destination) === lbugPath + ) { + return failedPublishRename?.(); + } + return rename(source, destination); + }); + } + return cleanResult(); + }, + }); + await mockStagedFiles(); + expect( + await runAnalyze( + tmpRepo.dbPath, + { force: true, embeddings: true, skipAgentsMd: true, skipSkills: true }, + [], + ), + ).toMatchObject({ + message: + failure === 'close' ? 'pre-publish close failed' : 'staged publish rename failed', + }); + expect(checkpointMeta?.stats?.embeddings).toBe(7); + expect((await loadMeta(storagePath))?.stats?.embeddings).toBe(7); + expect(await fs.readFile(lbugPath, 'utf8')).toBe('published fixture'); + const recovery = (await loadMeta(storagePath))?.embeddingCheckpoint?.recovery; + if (!recovery) throw new Error('expected durable stage after failed publish'); + expect(await fs.readFile(`${storagePath}/${recovery.stagingFile}`, 'utf8')).toBe( + 'staged fixture', + ); + if (failure === 'rename') expect(failedPublishRename).toHaveBeenCalledTimes(1); + } finally { + vi.restoreAllMocks(); + await tmpRepo.cleanup(); + } + }, + ); + + const seedRecovery = async (storagePath: string, repoPath: string) => { + const stagingFile = 'lbug.staging.11111111-1111-4111-8111-111111111111'; + const checkpoint = checkpointFixture({ + kind: 'interrupted', + pendingNodeIds: [], + recovery: { stagingFile, schemaFingerprint: SCHEMA_FINGERPRINT, unsafeNodeIds: [] }, + }); + await seedMeta(storagePath, repoPath, { + stats: { nodes: 2, embeddings: 7 }, + embeddingCheckpoint: checkpoint, + }); + await fs.writeFile(`${storagePath}/${stagingFile}`, 'previous durable source'); + vi.doMock('../../src/core/embeddings/staged-embedding-recovery.js', () => ({ + recoverStagedEmbeddings: vi.fn(async () => ({ rows: [], embeddingNodeIds: new Set() })), + })); + return { checkpoint, stagingFile }; + }; + + it.each( + ['1', '0'].flatMap((manualCheckpoint) => + ( + ['window-start crash', 'post-window crash', 'final-metadata failure', 'success'] as const + ).map((outcome) => ({ manualCheckpoint, outcome })), + ), + )( + 'preserves the in-place recovery receipt with manual checkpoints=$manualCheckpoint through $outcome', + async ({ manualCheckpoint, outcome }) => { + vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', manualCheckpoint); + vi.stubEnv('GITNEXUS_INDEX_LOCK_BACKEND', 'file'); + const tmpRepo = await createTempDir('gitnexus-3456-opt-out-source-'); + try { + const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); + const { checkpoint, stagingFile } = await seedRecovery(storagePath, tmpRepo.dbPath); + const original = { + ...checkpoint, + pendingNodeIds: ['original-pending'], + recovery: { + stagingFile, + schemaFingerprint: SCHEMA_FINGERPRINT, + unsafeNodeIds: ['original-pending', 'original-unsafe'], + }, + } satisfies NonNullable; + await seedMeta(storagePath, tmpRepo.dbPath, { + stats: { nodes: 2, embeddings: 7 }, + embeddingCheckpoint: original, + }); + const sourceFiles = [ + { filename: stagingFile, contents: 'previous durable source' }, + { filename: `${stagingFile}.wal`, contents: 'previous durable WAL' }, + { filename: `${stagingFile}.shadow`, contents: 'previous durable shadow' }, + ]; + for (const source of sourceFiles) { + await fs.writeFile(`${storagePath}/${source.filename}`, source.contents); + } + await fs.writeFile(lbugPath, 'previous published index'); + // This unit fixture switches process.platform to exercise Windows + // recovery. Hash an immutable, test-owned analyzer runtime instead of + // the shared checkout under concurrent CI activity. Keep the real + // identity validation and finalization. + const identity = await import('../../src/core/analyzer-identity.js'); + const runtimeRoot = `${tmpRepo.dbPath}/analyzer-runtime`; + await fs.mkdir(`${runtimeRoot}/src`, { recursive: true }); + await fs.writeFile( + `${runtimeRoot}/package.json`, + '{"name":"recovery-fixture","version":"1.0.0"}', + ); + await fs.writeFile(`${runtimeRoot}/package-lock.json`, '{"lockfileVersion":3}'); + await fs.writeFile(`${runtimeRoot}/src/analyzer.ts`, 'export const analyzer = 1;'); + const runtimeUrl = pathToFileURL(`${runtimeRoot}/src/analyzer.ts`).href; + const identityOptions = { cacheDirectory: `${tmpRepo.dbPath}/identity-cache` }; + const resolveRunnerIdentity = vi.fn(() => + identity.resolveAnalyzerRunnerIdentity(runtimeUrl, identityOptions), + ); + vi.doMock('../../src/core/analyzer-identity.js', () => ({ + ...identity, + resolveAnalyzerRunnerIdentity: resolveRunnerIdentity, + finalizeAnalyzerRunnerIdentity: ( + _url: string, + startedWith: NonNullable, + ) => identity.finalizeAnalyzerRunnerIdentity(runtimeUrl, startedWith, identityOptions), + })); + const { normalizeCachedEmbeddings } = + await import('../../src/core/embeddings/embedding-restore-spill.js'); + // Resolve the real merge implementation on the host, before selecting + // the Windows branch. A lazy importActual factory otherwise loads and + // transforms real modules while process.platform is temporarily win32. + const recovery = await vi.importActual< + typeof import('../../src/core/embeddings/staged-embedding-recovery.js') + >('../../src/core/embeddings/staged-embedding-recovery.js'); + const recoverStagedEmbeddings = vi.fn(async () => + normalizeCachedEmbeddings({ + embeddings: [ + { + nodeId: RESILIENCE_NODE_ID, + chunkIndex: 0, + startLine: 1, + endLine: 2, + contentHash: 'current-hash', + embedding: new Array(EMBEDDING_DIMS).fill(0), + }, + ], + }), + ); + vi.doMock('../../src/core/embeddings/staged-embedding-recovery.js', () => ({ + ...recovery, + recoverStagedEmbeddings, + })); + const snapshots: Array = []; + const rename = fs.rename.bind(fs); + const { batchInsertEmbeddings } = mockResilienceHarness({ + count: [{ cnt: 9 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: ['current-window'], + }); + snapshots.push(await loadMeta(storagePath)); + if (outcome === 'window-start crash') throw new Error(outcome); + await options.onCheckpoint?.({ nodesProcessed: 3, totalNodes: 3, chunksProcessed: 9 }); + snapshots.push(await loadMeta(storagePath)); + if (outcome === 'post-window crash') throw new Error(outcome); + if (outcome === 'final-metadata failure') { + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if (basename(String(destination)) === 'gitnexus.json') throw new Error(outcome); + return rename(source, destination); + }); + } + return cleanResult(); + }, + }); + await mockStagedFiles(); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.loadGraphToLbug).mockImplementation(async () => { + await fs.writeFile(lbugPath, 'in-place replacement'); + return { success: true, insertedRels: 0, skippedRels: 0, warnings: [] }; + }); + // Import on the host first, then choose the Windows default in-place + // branch. The native adapter is mocked; source files and lock cleanup are real. + await import('../../src/core/run-analyze.js'); + vi.stubEnv('GITNEXUS_ATOMIC_WINDOWS_SWAP', '0'); + const logs: string[] = []; + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + const error = await runAnalyze( + tmpRepo.dbPath, + { force: true, embeddings: true, skipAgentsMd: true, skipSkills: true }, + logs, + ); + if (actualPlatformDescriptor) { + Object.defineProperty(process, 'platform', actualPlatformDescriptor); + } + expect(error, logs.join('\n')).toEqual( + outcome === 'success' ? null : expect.objectContaining({ message: outcome }), + ); + expect(resolveRunnerIdentity, logs.join('\n')).toHaveBeenCalledOnce(); + expect(recoverStagedEmbeddings, logs.join('\n')).toHaveBeenCalledOnce(); + expect(logs).toContain( + 'Recovered 1 complete staged embedding chunk(s) for 1 node(s); unchanged content can reuse them.', + ); + expect(batchInsertEmbeddings, logs.join('\n')).toHaveBeenCalled(); + expect(vi.mocked(adapter.initLbug).mock.calls.at(-1)?.[0]).toBe(lbugPath); + const finalMeta = await loadMeta(storagePath); + // Reacquiring the real lock performs the next retry's orphan sweep. + // A lost receipt would delete every byte of the proven old generation here. + const { acquireIndexLock } = await import('../../src/storage/index-lock.js'); + const lock = await acquireIndexLock(storagePath, { timeoutMs: 1000 }); + try { + if (outcome === 'success') { + expect(finalMeta?.embeddingCheckpoint).toBeUndefined(); + expect(finalMeta?.stats?.embeddings).toBe(9); + expect(await fs.readFile(lbugPath, 'utf8')).toBe('in-place replacement'); + for (const source of sourceFiles) { + await expect(fs.stat(`${storagePath}/${source.filename}`)).rejects.toMatchObject({ + code: 'ENOENT', + }); + } + } else { + for (const source of sourceFiles) { + expect(await fs.readFile(`${storagePath}/${source.filename}`, 'utf8')).toBe( + source.contents, + ); + } + expect(finalMeta?.embeddingCheckpoint).toEqual(original); + expect(finalMeta?.stats?.embeddings).toBe(outcome === 'window-start crash' ? 7 : 9); + } + expect(snapshots[0]?.embeddingCheckpoint).toEqual(original); + expect(snapshots[0]?.stats?.embeddings).toBe(7); + if (outcome !== 'window-start crash') { + expect(snapshots[1]?.embeddingCheckpoint).toEqual(original); + expect(snapshots[1]?.stats?.embeddings).toBe(9); + } + } finally { + lock.release(); + } + } finally { + if (actualPlatformDescriptor) { + Object.defineProperty(process, 'platform', actualPlatformDescriptor); + } + vi.restoreAllMocks(); + await tmpRepo.cleanup(); + } + }, + ); + + it('finishes a staged embedding run with manual checkpoints disabled', async () => { + vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', '0'); + const tmpRepo = await createTempDir('gitnexus-3456-checkpoint-opt-out-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await seedMeta(storagePath, tmpRepo.dbPath, { stats: { embeddings: 7 } }); + mockResilienceHarness({ + count: [{ cnt: 9 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: ['active-node'], + }); + expect((await loadMeta(storagePath))?.embeddingCheckpoint?.recovery).toBeUndefined(); + await options.onCheckpoint?.({ nodesProcessed: 3, totalNodes: 3, chunksProcessed: 9 }); + const midRun = await loadMeta(storagePath); + expect(midRun?.embeddingCheckpoint?.recovery).toBeUndefined(); + expect(midRun?.stats?.embeddings).toBe(7); + return cleanResult(); + }, + }); + await mockStagedFiles(); + expect(await runAnalyze(tmpRepo.dbPath, { force: true, embeddings: true }, [])).toBeNull(); + expect((await loadMeta(storagePath))?.embeddingCheckpoint).toBeUndefined(); + expect((await loadMeta(storagePath))?.stats?.embeddings).toBe(9); + expect(await fs.readFile(getStoragePaths(tmpRepo.dbPath).lbugPath, 'utf8')).toBe( + 'staged fixture', + ); + } finally { + await tmpRepo.cleanup(); + } + }); + + it('keeps the previous durable source when manual checkpoints are disabled', async () => { + vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', '0'); + const tmpRepo = await createTempDir('gitnexus-3456-opt-out-source-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + const { checkpoint: original, stagingFile } = await seedRecovery(storagePath, tmpRepo.dbPath); + mockResilienceHarness({ + count: [{ cnt: 9 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: ['active-node'], + }); + await options.onCheckpoint?.({ nodesProcessed: 3, totalNodes: 3, chunksProcessed: 9 }); + expect((await loadMeta(storagePath))?.embeddingCheckpoint).toEqual(original); + throw new Error('endpoint failure with manual checkpoints disabled'); + }, + }); + await mockStagedFiles(); + expect(await runAnalyze(tmpRepo.dbPath, { force: true, embeddings: true }, [])).toMatchObject( + { message: 'endpoint failure with manual checkpoints disabled' }, + ); + expect((await loadMeta(storagePath))?.embeddingCheckpoint).toEqual(original); + expect(await fs.readFile(`${storagePath}/${stagingFile}`, 'utf8')).toBe( + 'previous durable source', + ); + expect( + (await fs.readdir(storagePath)).filter((name) => name.startsWith('lbug.staging.')), + ).toEqual([stagingFile]); + } finally { + await tmpRepo.cleanup(); + } + }); + + it.skipIf(process.platform === 'win32')( + 'retains the referenced stage through a symlinked storage directory', + async () => { + const tmpRepo = await createTempDir('gitnexus-3456-storage-alias-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + const actualStorage = `${tmpRepo.dbPath}/actual-index`; + await fs.mkdir(actualStorage); + await fs.symlink(actualStorage, storagePath, 'dir'); + await seedMeta(storagePath, tmpRepo.dbPath, { stats: { embeddings: 7 } }); + mockResilienceHarness({ + count: [{ cnt: 9 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: ['active-node'], + }); + await options.onCheckpoint?.({ nodesProcessed: 1, totalNodes: 3, chunksProcessed: 9 }); + throw new Error('endpoint failed after durable window'); + }, + }); + await mockStagedFiles(); + expect( + await runAnalyze(tmpRepo.dbPath, { force: true, embeddings: true }, []), + ).toMatchObject({ message: 'endpoint failed after durable window' }); + const recovery = (await loadMeta(storagePath))?.embeddingCheckpoint?.recovery; + if (!recovery) throw new Error('expected retained recovery generation'); + expect(await fs.readFile(`${actualStorage}/${recovery.stagingFile}`, 'utf8')).toBe( + 'staged fixture', + ); + expect((await loadMeta(storagePath))?.stats?.embeddings).toBe(7); + } finally { + await tmpRepo.cleanup(); + } + }, + ); + + it.each(['checkpoint', 'metadata'] as const)( + 'keeps the previous source when %s fails before recovery handoff', + async (failure) => { + const tmpRepo = await createTempDir('gitnexus-3456-handoff-failure-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + const { checkpoint: original, stagingFile } = await seedRecovery( + storagePath, + tmpRepo.dbPath, + ); + const rename = fs.rename.bind(fs); + mockResilienceHarness({ + count: [{ cnt: 9 }], + pipeline: async (options) => { + if (failure === 'metadata') { + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if (basename(String(destination)) === 'gitnexus.json') + throw new Error('metadata write failed'); + return rename(source, destination); + }); + } else { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.tryFlushWAL).mockResolvedValue(false); + } + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: ['active-node'], + }); + return cleanResult(); + }, + }); + await mockStagedFiles(); + const error = await runAnalyze(tmpRepo.dbPath, { force: true, embeddings: true }, []); + expect(error).toMatchObject({ + message: + failure === 'metadata' + ? 'metadata write failed' + : 'Could not checkpoint restored embeddings before recovery handoff.', + }); + expect((await loadMeta(storagePath))?.embeddingCheckpoint).toEqual(original); + expect((await loadMeta(storagePath))?.stats?.embeddings).toBe(7); + expect(await fs.readFile(`${storagePath}/${stagingFile}`, 'utf8')).toBe( + 'previous durable source', + ); + expect( + (await fs.readdir(storagePath)).filter((name) => name.startsWith('lbug.staging.')), + ).toEqual([stagingFile]); + } finally { + vi.restoreAllMocks(); + await tmpRepo.cleanup(); + } + }, + ); + + it('keeps an active window unsafe when its completion checkpoint fails', async () => { + const tmpRepo = await createTempDir('gitnexus-3456-completion-failure-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await seedMeta(storagePath, tmpRepo.dbPath, { stats: { embeddings: 7 } }); + mockResilienceHarness({ + count: [{ cnt: 9 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: ['active-node'], + }); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.tryFlushWAL).mockResolvedValue(false); + await options.onCheckpoint?.({ nodesProcessed: 1, totalNodes: 3, chunksProcessed: 9 }); + return cleanResult(); + }, + }); + await mockStagedFiles(); + expect(await runAnalyze(tmpRepo.dbPath, { force: true, embeddings: true }, [])).toMatchObject( + { message: 'Could not checkpoint the completed embedding window for recovery.' }, + ); + const meta = await loadMeta(storagePath); + expect(meta?.stats?.embeddings).toBe(7); + expect(meta?.embeddingCheckpoint?.pendingNodeIds).toEqual(['active-node']); + expect(meta?.embeddingCheckpoint?.recovery?.unsafeNodeIds).toEqual(['active-node']); + if (!meta?.embeddingCheckpoint?.recovery) throw new Error('expected retained active window'); + expect( + await fs.readFile( + `${storagePath}/${meta.embeddingCheckpoint.recovery.stagingFile}`, + 'utf8', + ), + ).toBe('staged fixture'); + } finally { + await tmpRepo.cleanup(); + } + }); + + it('retains a failed stage and keeps future incomplete restore groups unsafe', async () => { + const tmpRepo = await createTempDir('gitnexus-3456-unsafe-restore-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await seedMeta(storagePath, tmpRepo.dbPath, { stats: { nodes: 2, embeddings: 1 } }); + let completedWindow: RepoMeta | null = null; + const { loadCachedEmbeddings, batchInsertEmbeddings } = mockResilienceHarness({ + count: [{ cnt: 5 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 2, + chunksProcessed: 0, + nodeIds: ['earlier-window-node'], + }); + await options.onCheckpoint?.({ nodesProcessed: 1, totalNodes: 2, chunksProcessed: 1 }); + completedWindow = await loadMeta(storagePath); + throw new Error('Maximum database size exceeded'); + }, + }); + loadCachedEmbeddings.mockResolvedValue({ + embeddingNodeIds: new Set([RESILIENCE_NODE_ID]), + embeddings: [ + { + nodeId: RESILIENCE_NODE_ID, + chunkIndex: 0, + startLine: 1, + endLine: 2, + contentHash: 'current-hash', + embedding: new Array(EMBEDDING_DIMS).fill(0), + }, + ], + }); + batchInsertEmbeddings.mockRejectedValue(new Error('restore batch partially inserted')); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.initLbug).mockImplementation(async (dbPath) => { + if (dbPath.includes('.staging.')) await fs.writeFile(dbPath, 'staged fixture'); + return { db: null, conn: null }; + }); + vi.mocked(adapter.wipeLbugDbFiles).mockImplementation(async (dbPath) => { + await fs.rm(dbPath, { force: true }); + }); + const logs: string[] = []; + expect( + await runAnalyze( + tmpRepo.dbPath, + { embeddings: true, force: true, skipAgentsMd: true, skipSkills: true }, + logs, + ), + ).toMatchObject({ message: 'Maximum database size exceeded' }); + expect(completedWindow?.embeddingCheckpoint?.recovery?.unsafeNodeIds).toEqual([ + RESILIENCE_NODE_ID, + ]); + expect(completedWindow?.stats?.embeddings).toBe(1); + const recovery = (await loadMeta(storagePath))?.embeddingCheckpoint?.recovery; + if (!recovery) throw new Error('expected retained recovery generation'); + expect(await fs.readFile(`${storagePath}/${recovery.stagingFile}`, 'utf8')).toBe( + 'staged fixture', + ); + expect(logs).toContainEqual(expect.stringContaining('GITNEXUS_LBUG_MAX_DB_SIZE')); + } finally { + await tmpRepo.cleanup(); + } + }); + + it('reclaims a failed current stage before any recovery checkpoint exists', async () => { + const tmpRepo = await createTempDir('gitnexus-3456-no-checkpoint-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await seedMeta(storagePath, tmpRepo.dbPath, {}); + mockResilienceHarness({ + count: [{ cnt: 0 }], + pipeline: async () => { + throw new Error('failed before first window'); + }, + }); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.initLbug).mockImplementation(async (dbPath) => { + if (dbPath.includes('.staging.')) await fs.writeFile(dbPath, 'staged fixture'); + return { db: null, conn: null }; + }); + vi.mocked(adapter.wipeLbugDbFiles).mockImplementation(async (dbPath) => { + await fs.rm(dbPath, { force: true }); + }); + expect( + await runAnalyze( + tmpRepo.dbPath, + { embeddings: true, force: true, skipAgentsMd: true, skipSkills: true }, + [], + ), + ).toMatchObject({ message: 'failed before first window' }); + expect( + (await fs.readdir(storagePath)).filter((name) => name.startsWith('lbug.staging.')), + ).toEqual([]); + expect((await loadMeta(storagePath))?.embeddingCheckpoint).toBeUndefined(); + } finally { + await tmpRepo.cleanup(); + } + }); }); diff --git a/gitnexus/test/unit/scope-resolution/c/c-workspace.test.ts b/gitnexus/test/unit/scope-resolution/c/c-workspace.test.ts index 732f01b46..9134a0289 100644 --- a/gitnexus/test/unit/scope-resolution/c/c-workspace.test.ts +++ b/gitnexus/test/unit/scope-resolution/c/c-workspace.test.ts @@ -462,7 +462,7 @@ describe('C/C++ monorepo config', () => { targetRaw: string, fromFile: string, workspace: ReadonlySet, - ): string | null { + ): ReturnType { return cScopeResolver.resolveImportTarget( targetRaw, fromFile, diff --git a/gitnexus/test/unit/scope-resolution/callable-value-flow-worklist.test.ts b/gitnexus/test/unit/scope-resolution/callable-value-flow-worklist.test.ts index 9c0ffcea1..9de8ac5f3 100644 --- a/gitnexus/test/unit/scope-resolution/callable-value-flow-worklist.test.ts +++ b/gitnexus/test/unit/scope-resolution/callable-value-flow-worklist.test.ts @@ -98,6 +98,7 @@ function indexes(scopes: readonly Scope[]): ScopeResolutionIndexes { unresolvedEdges: 0, sccCount: 1, largestSccSize: 1, + ambiguousWildcardExports: [], }, }; } @@ -115,8 +116,8 @@ function operand(name: string, line: number): CallableFlowOperand { function runReverseChain(length: number) { const moduleBindings = new Map([ - ['target', [{ def: targetDef }]], - ['entry', [{ def: entryDef }]], + ['target', [{ def: targetDef, origin: 'local' }]], + ['entry', [{ def: entryDef, origin: 'local' }]], ]); const scopes = [ scope(MODULE, null, 'Module', [targetDef, entryDef], moduleBindings), diff --git a/gitnexus/test/unit/scope-resolution/construction-syntax-wiring.test.ts b/gitnexus/test/unit/scope-resolution/construction-syntax-wiring.test.ts index 3729f3937..962aea08e 100644 --- a/gitnexus/test/unit/scope-resolution/construction-syntax-wiring.test.ts +++ b/gitnexus/test/unit/scope-resolution/construction-syntax-wiring.test.ts @@ -12,6 +12,7 @@ */ import { describe, it, expect } from 'vitest'; +import { SupportedLanguages } from 'gitnexus-shared'; import { SCOPE_RESOLVERS } from '../../../src/core/ingestion/scope-resolution/pipeline/registry.js'; describe('constructionSyntax wiring inventory', () => { @@ -33,7 +34,13 @@ describe('constructionSyntax wiring inventory', () => { it('leaves the languages that already resolve the shape unwired', () => { // Java resolves it via the #2564 object_creation_expression capture // rewrite; php/swift/dart/kotlin via their own capture-side paths. - for (const language of ['java', 'php', 'swift', 'dart', 'kotlin'] as const) { + for (const language of [ + SupportedLanguages.Java, + SupportedLanguages.PHP, + SupportedLanguages.Swift, + SupportedLanguages.Dart, + SupportedLanguages.Kotlin, + ] as const) { const resolver = SCOPE_RESOLVERS.get(language); expect(resolver, `${language} resolver is registered`).toBeDefined(); expect(resolver!.constructionSyntax, `${language} stays unwired`).toBeUndefined(); diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-constraint.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-constraint.test.ts index 032b8b0eb..00797e0d2 100644 --- a/gitnexus/test/unit/scope-resolution/cpp/cpp-constraint.test.ts +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-constraint.test.ts @@ -232,7 +232,7 @@ describe('Tier-A predicate registry', () => { type: 'Function', templateConstraints: payload, ...(opts.parameterTypeClasses !== undefined - ? { parameterTypeClasses: opts.parameterTypeClasses } + ? { parameterTypeClasses: [...opts.parameterTypeClasses] } : {}), }; return cppConstraintCompatibility({ arity: argumentTypes.length }, def, { diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-qualified-ns-index.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-qualified-ns-index.test.ts index de7fd9f06..098c832a4 100644 --- a/gitnexus/test/unit/scope-resolution/cpp/cpp-qualified-ns-index.test.ts +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-qualified-ns-index.test.ts @@ -1,3 +1,4 @@ +import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js'; /** * #2788 — `resolveCppQualifiedNamespaceMember` serves qualified `ns::member()` * lookups from a per-pipeline index instead of rescanning every parsed file per @@ -15,12 +16,7 @@ * This is the failure mode the index introduces; nothing else covers it. */ -import type { - ParsedFile, - ScopeId, - ScopeResolutionIndexes, - SymbolDefinition, -} from 'gitnexus-shared'; +import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import { beforeEach, describe, expect, it } from 'vitest'; import { clearCppInlineNamespaces, diff --git a/gitnexus/test/unit/scope-resolution/emit-references.test.ts b/gitnexus/test/unit/scope-resolution/emit-references.test.ts index c88dc998e..097ac269a 100644 --- a/gitnexus/test/unit/scope-resolution/emit-references.test.ts +++ b/gitnexus/test/unit/scope-resolution/emit-references.test.ts @@ -99,6 +99,12 @@ function makeIndexes(scopes: Scope[], allDefs: SymbolDefinition[]): ScopeResolut }), imports: new Map(), bindings: new Map(), + bindingAugmentations: new Map(), + workspaceFqnBindings: new Map(), + workspaceTypeBindings: new Map(), + namespaceFqnBindings: new Map(), + namespaceTypeBindings: new Map(), + accessibleNamespacesByScope: new Map(), referenceSites: [], sccs: [], stats: { @@ -108,6 +114,7 @@ function makeIndexes(scopes: Scope[], allDefs: SymbolDefinition[]): ScopeResolut unresolvedEdges: 0, sccCount: 0, largestSccSize: 0, + ambiguousWildcardExports: [], }, }; } diff --git a/gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts b/gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts index 45eaaebd6..8972ba40e 100644 --- a/gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts +++ b/gitnexus/test/unit/scope-resolution/esm-export-marker.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect } from 'vitest'; import { emitTsScopeCaptures } from '../../../src/core/ingestion/languages/typescript/captures.js'; import { emitJsScopeCaptures } from '../../../src/core/ingestion/languages/javascript/captures.js'; import { extract } from '../../../src/core/ingestion/scope-extractor.js'; -import { typescriptScopeResolver } from '../../../src/core/ingestion/languages/typescript/scope-resolver.js'; +import { typescriptProvider } from '../../../src/core/ingestion/languages/typescript.js'; type Emit = typeof emitTsScopeCaptures; @@ -309,7 +309,7 @@ describe('SymbolDefinition.isExported through the extractor', () => { const esm = extract( emitTsScopeCaptures('export function a() {}\nfunction b() {}\n', 'x.ts'), 'x.ts', - typescriptScopeResolver, + typescriptProvider, ); const byName = new Map(esm.localDefs.map((d) => [d.qualifiedName, d.isExported])); expect(byName.get('a')).toBe(true); @@ -317,7 +317,7 @@ describe('SymbolDefinition.isExported through the extractor', () => { const cjs = extract( emitTsScopeCaptures('function a() {}\nmodule.exports = a;\n', 'y.ts'), 'y.ts', - typescriptScopeResolver, + typescriptProvider, ); expect(cjs.localDefs.find((d) => d.qualifiedName === 'a')?.isExported).toBeUndefined(); expect('isExported' in cjs.localDefs.find((d) => d.qualifiedName === 'a')!).toBe(false); diff --git a/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts b/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts index 5b044246c..9d06c5054 100644 --- a/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts +++ b/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts @@ -1,4 +1,11 @@ import { describe, expect, it } from 'vitest'; +import { + buildDefIndex, + buildQualifiedNameIndex, + buildScopeTree, + buildMethodDispatchIndex, + buildModuleScopeIndex, +} from 'gitnexus-shared'; import type { BindingRef, Callsite, @@ -30,10 +37,6 @@ describe('Go arity compatibility', () => { it('returns unknown when no param count info', () => { const def = makeDef(); const callsite: Callsite = { - name: 'F', - inScope: 's', - atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, - kind: 'call', arity: 1, }; expect(goArityCompatibility(def, callsite)).toBe('unknown'); @@ -42,10 +45,6 @@ describe('Go arity compatibility', () => { it('exact match is compatible', () => { const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 }); const callsite: Callsite = { - name: 'F', - inScope: 's', - atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, - kind: 'call', arity: 2, }; expect(goArityCompatibility(def, callsite)).toBe('compatible'); @@ -54,10 +53,6 @@ describe('Go arity compatibility', () => { it('too few args is incompatible', () => { const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 }); const callsite: Callsite = { - name: 'F', - inScope: 's', - atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, - kind: 'call', arity: 1, }; expect(goArityCompatibility(def, callsite)).toBe('incompatible'); @@ -70,10 +65,6 @@ describe('Go arity compatibility', () => { parameterTypes: ['string', '...string'], }); const callsite: Callsite = { - name: 'F', - inScope: 's', - atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, - kind: 'call', arity: 5, }; expect(goArityCompatibility(def, callsite)).toBe('compatible'); @@ -82,10 +73,6 @@ describe('Go arity compatibility', () => { it('non-variadic rejects extra args', () => { const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 }); const callsite: Callsite = { - name: 'F', - inScope: 's', - atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 }, - kind: 'call', arity: 3, }; expect(goArityCompatibility(def, callsite)).toBe('incompatible'); @@ -222,32 +209,40 @@ function scopeIndexes( readonly imports?: ReadonlyMap; } = {}, ): ScopeResolutionIndexes { - const defsById = new Map(defs.map((def) => [def.nodeId, def])); - const qualifiedNames = new Map(); - for (const def of defs) { - const ids = qualifiedNames.get(def.qualifiedName) ?? []; - ids.push(def.nodeId); - qualifiedNames.set(def.qualifiedName, ids); - } - const scopesById = new Map(scopes.map((s) => [s.id, s])); return { - defs: { get: (id: string) => defsById.get(id) }, - qualifiedNames: { get: (name: string) => qualifiedNames.get(name) ?? [] }, - scopeTree: { getScope: (id: ScopeId) => scopesById.get(id) }, + defs: buildDefIndex(defs), + qualifiedNames: buildQualifiedNameIndex(defs), + scopeTree: buildScopeTree([...scopes]), bindings: new Map(), bindingAugmentations: options.bindingAugmentations ?? new Map(), imports: options.imports ?? new Map(), workspaceFqnBindings: new Map(), namespaceFqnBindings: new Map(), accessibleNamespacesByScope: new Map(), - methodDispatch: {} as any, - moduleScopes: {} as any, + methodDispatch: buildMethodDispatchIndex({ + owners: [], + computeMro: () => [], + implementsOf: () => [], + }), + moduleScopes: buildModuleScopeIndex( + scopes + .filter((scope) => scope.kind === 'Module') + .map((scope) => ({ filePath: scope.filePath, moduleScopeId: scope.id })), + ), workspaceTypeBindings: new Map(), namespaceTypeBindings: new Map(), referenceSites: [], sccs: [], - stats: {} as any, - } as ScopeResolutionIndexes; + stats: { + totalFiles: new Set(scopes.map((scope) => scope.filePath)).size, + totalEdges: 0, + linkedEdges: 0, + unresolvedEdges: 0, + sccCount: 0, + largestSccSize: 0, + ambiguousWildcardExports: [], + }, + }; } const emptyIndexes = scopeIndexes([]); @@ -257,13 +252,19 @@ function scope( kind: Scope['kind'], ownedDefs: readonly SymbolDefinition[], parent: ScopeId | null = null, + line = 1, ): Scope { return { id, parent, kind, filePath: 'repo.go', - range: { startLine: 1, startCol: 0, endLine: 1, endCol: 1 }, + range: { + startLine: kind === 'Module' ? 0 : line, + startCol: 0, + endLine: kind === 'Module' ? 100 : line, + endCol: 1, + }, bindings: new Map(), ownedDefs, imports: [], @@ -629,7 +630,11 @@ describe('Go structural interface detection', () => { returnType: 'error', }); const defs = [reader, base, file, readerRead, baseRead]; - const scopes = [scope('scope:Base', 'Class', [base]), scope('scope:File', 'Class', [file])]; + const scopes = [ + scope('scope:module', 'Module', []), + scope('scope:Base', 'Class', [base], 'scope:module', 1), + scope('scope:File', 'Class', [file], 'scope:module', 2), + ]; const result = detectGoInterfaceImplementations( parsedGoDefs(defs, { @@ -671,8 +676,9 @@ describe('Go structural interface detection', () => { ); const defs = [reader, base, shadowFile, readerRead, baseRead, shadowRead]; const scopes = [ - scope('scope:Base', 'Class', [base]), - scope('scope:ShadowFile', 'Class', [shadowFile]), + scope('scope:module', 'Module', []), + scope('scope:Base', 'Class', [base], 'scope:module', 1), + scope('scope:ShadowFile', 'Class', [shadowFile], 'scope:module', 2), ]; const result = detectGoInterfaceImplementations( @@ -709,9 +715,10 @@ describe('Go structural interface detection', () => { }); const defs = [reader, baseA, baseB, file, readerRead, baseARead, baseBRead]; const scopes = [ - scope('scope:BaseA', 'Class', [baseA]), - scope('scope:BaseB', 'Class', [baseB]), - scope('scope:File', 'Class', [file]), + scope('scope:module', 'Module', []), + scope('scope:BaseA', 'Class', [baseA], 'scope:module', 1), + scope('scope:BaseB', 'Class', [baseB], 'scope:module', 2), + scope('scope:File', 'Class', [file], 'scope:module', 3), ]; const result = detectGoInterfaceImplementations( @@ -749,10 +756,11 @@ describe('Go structural interface detection', () => { }); const defs = [reader, shallow, deepBase, deepWrapper, file, readerRead, shallowRead, deepRead]; const scopes = [ - scope('scope:Shallow', 'Class', [shallow]), - scope('scope:DeepBase', 'Class', [deepBase]), - scope('scope:DeepWrapper', 'Class', [deepWrapper]), - scope('scope:File', 'Class', [file]), + scope('scope:module', 'Module', []), + scope('scope:Shallow', 'Class', [shallow], 'scope:module', 1), + scope('scope:DeepBase', 'Class', [deepBase], 'scope:module', 2), + scope('scope:DeepWrapper', 'Class', [deepWrapper], 'scope:module', 3), + scope('scope:File', 'Class', [file], 'scope:module', 4), ]; const result = detectGoInterfaceImplementations( @@ -846,9 +854,9 @@ describe('Go structural interface detection', () => { ]; const scopes = [ scope('scope:module', 'Module', []), - scope('scope:ReaderA', 'Class', [readerA], 'scope:module'), - scope('scope:ReaderB', 'Class', [readerB], 'scope:module'), - scope('scope:ReadCloser', 'Class', [readCloser], 'scope:module'), + scope('scope:ReaderA', 'Class', [readerA], 'scope:module', 1), + scope('scope:ReaderB', 'Class', [readerB], 'scope:module', 2), + scope('scope:ReadCloser', 'Class', [readCloser], 'scope:module', 3), ]; const result = detectGoInterfaceImplementations( diff --git a/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts b/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts index 6c8c28561..6cbbea6d3 100644 --- a/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts @@ -42,7 +42,9 @@ describe('Go import decomposition', () => { ])('uses the same package qualifier for %s as route extraction', (spec, expected) => { const matches = parseThenSplit(`import ${spec}`); expect(matches[0]['@import.name']?.text).toBe(expected); - expect(interpretGoImport(matches[0])?.localName).toBe(expected); + const parsed = interpretGoImport(matches[0]); + expect(parsed).not.toBeNull(); + expect(parsed && 'localName' in parsed ? parsed.localName : undefined).toBe(expected); }); it('decomposes grouped imports', () => { diff --git a/gitnexus/test/unit/scope-resolution/go/go-range-binding-null-guard.test.ts b/gitnexus/test/unit/scope-resolution/go/go-range-binding-null-guard.test.ts index edf48b33a..93f7d3f43 100644 --- a/gitnexus/test/unit/scope-resolution/go/go-range-binding-null-guard.test.ts +++ b/gitnexus/test/unit/scope-resolution/go/go-range-binding-null-guard.test.ts @@ -1,8 +1,10 @@ +import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js'; import { describe, it, expect } from 'vitest'; import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js'; import { goScopeResolver } from '../../../../src/core/ingestion/languages/go/scope-resolver.js'; import { populateGoRangeBindings } from '../../../../src/core/ingestion/languages/go/range-binding.js'; -import type { ParsedFile, ScopeResolutionIndexes } from 'gitnexus-shared'; +import type { ParsedFile } from 'gitnexus-shared'; +import { finalizeScopeModel } from '../../../../src/core/ingestion/finalize-orchestrator.js'; function parseGo(src: string, path = 'main.go'): ParsedFile { const p = extractParsedFile(goScopeResolver.languageProvider, src, path); @@ -12,13 +14,7 @@ function parseGo(src: string, path = 'main.go'): ParsedFile { } function makeEmptyIndexes(): ScopeResolutionIndexes { - return { - bindings: new Map(), - imports: [], - scopeTree: { roots: [] } as any, - methodDispatch: new Map(), - sccs: [], - } as ScopeResolutionIndexes; + return finalizeScopeModel([]); } describe('Go range binding — null guard (#1346, #1366)', () => { diff --git a/gitnexus/test/unit/scope-resolution/import-target-index-parity.test.ts b/gitnexus/test/unit/scope-resolution/import-target-index-parity.test.ts index bc8384257..e029c4260 100644 --- a/gitnexus/test/unit/scope-resolution/import-target-index-parity.test.ts +++ b/gitnexus/test/unit/scope-resolution/import-target-index-parity.test.ts @@ -848,6 +848,8 @@ describe('import-target index hoist — built once per file set, not once per im filePath, bindings: new Map([[name, [{ def, origin: 'local' }]]]), ownedDefs: [def], + imports: [], + typeBindings: new Map(), }, ], parsedImports: [], diff --git a/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts b/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts index 228fcead8..a02d40f2d 100644 --- a/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts +++ b/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts @@ -389,7 +389,7 @@ describe('Rust: isGlobalNameFallbackPlausible', () => { ['src/a/b.rs', 'super::unique_helper_xyz', 'src/a.rs'], ['src/a/b/c.rs', 'super::super::unique_helper_xyz', 'src/a/mod.rs'], ['src/a/b.rs', 'self::child::unique_helper_xyz', 'src/a/b/child.rs'], - ])('resolves relative imports from %s', (caller, target, candidate) => { + ])('resolves relative imports from %s via %s', (caller, target, candidate) => { expect( rustIsGlobalNameFallbackPlausible({ site: BARE_SITE, diff --git a/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts b/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts index 976247da4..62a6763f2 100644 --- a/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts +++ b/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts @@ -1,3 +1,4 @@ +import { SupportedLanguages } from 'gitnexus-shared'; import type { NodeLabel } from 'gitnexus-shared'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -25,20 +26,28 @@ interface Candidate { function buildLookup(candidates: readonly Candidate[]) { const graph = createKnowledgeGraph(); - const nodes = candidates.map( - (candidate) => - ({ - id: candidate.id, - label: candidate.label ?? ('Method' as NodeLabel), - properties: { - name: candidate.name ?? 'save', - qualifiedName: candidate.qualifiedName ?? 'Service.save', - filePath: FILE, - ...(candidate.startLine !== undefined ? { startLine: candidate.startLine } : {}), - ...(candidate.startColumn !== undefined ? { startColumn: candidate.startColumn } : {}), - }, - }) satisfies ParseWorkerResult['nodes'][number], - ); + const nodes = candidates.map((candidate) => { + const node = { + id: candidate.id, + label: candidate.label ?? ('Method' as NodeLabel), + properties: { + name: candidate.name ?? 'save', + qualifiedName: candidate.qualifiedName ?? 'Service.save', + filePath: FILE, + startLine: candidate.startLine, + endLine: candidate.startLine, + language: SupportedLanguages.TypeScript, + isExported: false, + ...(candidate.startColumn !== undefined ? { startColumn: candidate.startColumn } : {}), + }, + } satisfies ParseWorkerResult['nodes'][number]; + // Missing positions deliberately exercise legacy worker-result tolerance. + if (candidate.startLine === undefined) { + Reflect.deleteProperty(node.properties, 'startLine'); + Reflect.deleteProperty(node.properties, 'endLine'); + } + return node; + }); const result: ParseWorkerResult = { nodes, relationships: [], diff --git a/gitnexus/test/unit/scope-resolution/php-import-target-parity.test.ts b/gitnexus/test/unit/scope-resolution/php-import-target-parity.test.ts index ef3238706..25118b542 100644 --- a/gitnexus/test/unit/scope-resolution/php-import-target-parity.test.ts +++ b/gitnexus/test/unit/scope-resolution/php-import-target-parity.test.ts @@ -323,10 +323,14 @@ function parsedFilesFor(testCase: Case): readonly ParsedFile[] { (filePath) => ({ filePath, + moduleScope: `scope:${filePath}#module`, + scopes: [], + parsedImports: [], + referenceSites: [], localDefs: (testCase.defs?.get(filePath) ?? []).map(([type, name]) => definition(filePath, type, name), ), - }) as ParsedFile, + }) satisfies ParsedFile, ); } diff --git a/gitnexus/test/unit/scope-resolution/property-name-index.test.ts b/gitnexus/test/unit/scope-resolution/property-name-index.test.ts index ec08650b9..a079bf494 100644 --- a/gitnexus/test/unit/scope-resolution/property-name-index.test.ts +++ b/gitnexus/test/unit/scope-resolution/property-name-index.test.ts @@ -56,11 +56,13 @@ describe('buildPropertyNameIndex', () => { label: 'Function', properties: { name: 'run', filePath: 'a.js', startLine: 1, endLine: 2 }, }); - graph.addNode({ + const noPathNode = { id: 'Property:a.js:noPath', - label: 'Property', - properties: { name: 'noPath', startLine: 1, endLine: 1 }, - }); + label: 'Property' as const, + properties: { name: 'noPath', filePath: '', startLine: 1, endLine: 1 }, + }; + Reflect.deleteProperty(noPathNode.properties, 'filePath'); + graph.addNode(noPathNode); const index = buildPropertyNameIndex(graph); expect(index.get('run')).toBeUndefined(); diff --git a/gitnexus/test/unit/scope-resolution/python/python-hooks.test.ts b/gitnexus/test/unit/scope-resolution/python/python-hooks.test.ts index 8ffadb3f2..9723253b5 100644 --- a/gitnexus/test/unit/scope-resolution/python/python-hooks.test.ts +++ b/gitnexus/test/unit/scope-resolution/python/python-hooks.test.ts @@ -216,10 +216,11 @@ describe('pythonMissingReceiverSubtypeDecision', () => { it('declines class receivers without suppressing their ordinary resolution path', () => { expect( - pythonMissingReceiverSubtypeDecision( - { ...selfType, source: 'cls' }, - { receiverBindingIsStatic: true, memberName: 'hook', callArity: 0 }, - ), + pythonMissingReceiverSubtypeDecision(selfType, { + receiverBindingIsStatic: true, + memberName: 'hook', + callArity: 0, + }), ).toBe(false); }); }); diff --git a/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts b/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts index 497b77db9..afeb935ce 100644 --- a/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts +++ b/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts @@ -47,7 +47,7 @@ const mkProperty = (opts: { filePath: string; name: string; ownerId: string; - type?: 'Property' | 'Variable'; + type?: 'Property' | 'Variable' | 'Const' | 'Static'; }): SymbolDefinition => ({ nodeId: opts.nodeId, filePath: opts.filePath, diff --git a/gitnexus/test/unit/scope-resolution/references-to-edges-call-form.test.ts b/gitnexus/test/unit/scope-resolution/references-to-edges-call-form.test.ts index 99cf0ad91..51d318553 100644 --- a/gitnexus/test/unit/scope-resolution/references-to-edges-call-form.test.ts +++ b/gitnexus/test/unit/scope-resolution/references-to-edges-call-form.test.ts @@ -97,6 +97,7 @@ function makeIndexes(scope: Scope, allDefs: readonly SymbolDefinition[]): ScopeR unresolvedEdges: 0, sccCount: 0, largestSccSize: 0, + ambiguousWildcardExports: [], }, }; } diff --git a/gitnexus/test/unit/scope-resolution/resolve-references.test.ts b/gitnexus/test/unit/scope-resolution/resolve-references.test.ts index e5524d9e6..670657cee 100644 --- a/gitnexus/test/unit/scope-resolution/resolve-references.test.ts +++ b/gitnexus/test/unit/scope-resolution/resolve-references.test.ts @@ -79,6 +79,11 @@ function makeIndexes( }), imports: new Map(), bindings: new Map(), + workspaceFqnBindings: new Map(), + workspaceTypeBindings: new Map(), + namespaceFqnBindings: new Map(), + namespaceTypeBindings: new Map(), + accessibleNamespacesByScope: new Map(), bindingAugmentations: new Map(), referenceSites, sccs: [], @@ -89,6 +94,7 @@ function makeIndexes( unresolvedEdges: 0, sccCount: 0, largestSccSize: 0, + ambiguousWildcardExports: [], }, }; } diff --git a/gitnexus/test/unit/scope-resolution/resolved-callee-names.test.ts b/gitnexus/test/unit/scope-resolution/resolved-callee-names.test.ts index 20a81a8e2..3ba7e5647 100644 --- a/gitnexus/test/unit/scope-resolution/resolved-callee-names.test.ts +++ b/gitnexus/test/unit/scope-resolution/resolved-callee-names.test.ts @@ -21,7 +21,13 @@ describe('collectResolvedCalleeNames', () => { fn('b', 'b', 'src/b.go'); fn('c', 'c', 'src/c.ts'); fn('d', 'd', 'src/d.go'); - g.addNode({ id: 'file', label: 'File' as NodeLabel, properties: { filePath: 'src/a.go' } }); + const namelessNode = { + id: 'file', + label: 'File' as const, + properties: { name: '', filePath: 'src/a.go' }, + }; + Reflect.deleteProperty(namelessNode.properties, 'name'); + g.addNode(namelessNode); g.addRelationship({ id: 'r1', sourceId: 'a', diff --git a/gitnexus/test/unit/scope-resolution/rust-cargo-targets.test.ts b/gitnexus/test/unit/scope-resolution/rust-cargo-targets.test.ts index 7458c5fcc..97ece0be7 100644 --- a/gitnexus/test/unit/scope-resolution/rust-cargo-targets.test.ts +++ b/gitnexus/test/unit/scope-resolution/rust-cargo-targets.test.ts @@ -150,7 +150,7 @@ describe('Cargo manifest target metadata', () => { `${PACKAGE}build=1\n`, `${PACKAGE}[[bin]]\npath="custom/entry.rs"\n`, `${PACKAGE}[[test]]\npath="custom/entry.rs"\n`, - ])('does not manufacture evidence from malformed metadata', (manifest) => { + ])('does not manufacture evidence from malformed metadata (case %#)', (manifest) => { expect(cargoTargetRoots('Cargo.toml', manifest, files)).toBeUndefined(); }); }); diff --git a/gitnexus/test/unit/scope-resolution/rust/rust-mod-scope-qualifier.test.ts b/gitnexus/test/unit/scope-resolution/rust/rust-mod-scope-qualifier.test.ts index 84610395a..a963570fd 100644 --- a/gitnexus/test/unit/scope-resolution/rust/rust-mod-scope-qualifier.test.ts +++ b/gitnexus/test/unit/scope-resolution/rust/rust-mod-scope-qualifier.test.ts @@ -16,7 +16,7 @@ import { import { couldNameAModule } from '../../../../src/core/ingestion/languages/rust/module-path.js'; const parser = new Parser(); -parser.setLanguage(Rust as unknown as Parser.Language); +parser.setLanguage(Rust as unknown); /** First node of `type` in a post-order walk — the innermost such node. */ function findNode(root: SyntaxNode, type: string): SyntaxNode { diff --git a/gitnexus/test/unit/scope-resolution/rust/rust-range-binding-order.test.ts b/gitnexus/test/unit/scope-resolution/rust/rust-range-binding-order.test.ts index 734f1d992..e2e6de192 100644 --- a/gitnexus/test/unit/scope-resolution/rust/rust-range-binding-order.test.ts +++ b/gitnexus/test/unit/scope-resolution/rust/rust-range-binding-order.test.ts @@ -1,5 +1,7 @@ +import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js'; import { describe, expect, it } from 'vitest'; -import type { ParsedFile, ScopeResolutionIndexes } from 'gitnexus-shared'; +import type { ParsedFile } from 'gitnexus-shared'; +import { finalizeScopeModel } from '../../../../src/core/ingestion/finalize-orchestrator.js'; import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js'; import { rustScopeResolver } from '../../../../src/core/ingestion/languages/rust/scope-resolver.js'; import { populateRustRangeBindings } from '../../../../src/core/ingestion/languages/rust/range-binding.js'; @@ -25,14 +27,7 @@ function parse(src: string, path: string): ParsedFile { } function makeEmptyIndexes(): ScopeResolutionIndexes { - return { - bindings: new Map(), - bindingAugmentations: new Map(), - imports: [], - scopeTree: { roots: [] }, - methodDispatch: new Map(), - sccs: [], - } as unknown as ScopeResolutionIndexes; + return finalizeScopeModel([]); } function boundTypeOf(parsed: ParsedFile, variableName: string): string | undefined { diff --git a/gitnexus/test/unit/scope-resolution/swift/callable-visibility.test.ts b/gitnexus/test/unit/scope-resolution/swift/callable-visibility.test.ts new file mode 100644 index 000000000..7cc8daed5 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/callable-visibility.test.ts @@ -0,0 +1,267 @@ +import { + buildDefIndex, + buildMethodDispatchIndex, + buildQualifiedNameIndex, + buildScopeTree, + type Scope, + type SymbolDefinition, +} from 'gitnexus-shared'; +import { describe, expect, it } from 'vitest'; +import { swiftIsCallableVisibleFromCaller } from '../../../../src/core/ingestion/languages/swift/callable-visibility.js'; +import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js'; + +const filePath = 'Service.swift'; +const moduleRange = { startLine: 1, startCol: 0, endLine: 12, endCol: 0 }; +const classRange = { startLine: 2, startCol: 0, endLine: 10, endCol: 0 }; +const functionRange = { startLine: 5, startCol: 0, endLine: 8, endCol: 0 }; + +function scope( + id: string, + parent: string | null, + kind: Scope['kind'], + ownedDefs: SymbolDefinition[], + range: Scope['range'], +): Scope { + return { + id, + parent, + kind, + range, + filePath, + ownedDefs, + bindings: new Map(), + imports: [], + typeBindings: new Map(), + }; +} + +const classDef: SymbolDefinition = { + nodeId: 'Service', + filePath, + type: 'Class', + qualifiedName: 'Service', +}; +const property: SymbolDefinition = { + nodeId: 'Service.clock', + filePath, + type: 'Property', + qualifiedName: 'Service.clock', + ownerId: 'Service', +}; +const method: SymbolDefinition = { + nodeId: 'Service.refresh', + filePath, + type: 'Method', + qualifiedName: 'Service.refresh', + ownerId: 'Service', +}; +const scopes = { + scopeTree: buildScopeTree([ + scope('module', null, 'Module', [], moduleRange), + scope('class', 'module', 'Class', [classDef, property], classRange), + scope('function', 'class', 'Function', [method], functionRange), + ]), + defs: buildDefIndex([classDef, property, method]), + qualifiedNames: buildQualifiedNameIndex([classDef, property, method]), + methodDispatch: buildMethodDispatchIndex({ + owners: [classDef.nodeId], + computeMro: () => [], + implementsOf: () => [], + }), +} as ScopeResolutionIndexes; + +describe('Swift caller-side callable visibility', () => { + it('rejects an unrelated same-name method shadowed by a stored property', () => { + expect( + swiftIsCallableVisibleFromCaller({ + candidate: { + nodeId: 'Other.clock', + filePath: 'Other.swift', + type: 'Method', + qualifiedName: 'Other.clock', + }, + callerScope: 'function', + callArity: 0, + scopes, + }), + ).toBe(false); + }); + + it('keeps differently named methods and unknown caller scopes eligible', () => { + const candidate: SymbolDefinition = { + nodeId: 'Other.run', + filePath: 'Other.swift', + type: 'Method', + qualifiedName: 'Other.run', + }; + expect( + swiftIsCallableVisibleFromCaller({ + candidate, + callerScope: 'function', + callArity: 0, + scopes, + }), + ).toBe(true); + expect( + swiftIsCallableVisibleFromCaller({ + candidate: { ...candidate, qualifiedName: 'Other.clock' }, + }), + ).toBe(true); + }); + + it('preserves a selected local function before checking the enclosing property', () => { + const local: SymbolDefinition = { + nodeId: 'local.clock', + filePath, + type: 'Function', + qualifiedName: 'Service.refresh.clock', + }; + const localScopes = { + ...scopes, + scopeTree: buildScopeTree([ + scope('module', null, 'Module', [], moduleRange), + scope('class', 'module', 'Class', [classDef, property], classRange), + scope('function', 'class', 'Function', [method], functionRange), + scope('local', 'function', 'Function', [local], { + startLine: 6, + startCol: 0, + endLine: 7, + endCol: 0, + }), + ]), + } as ScopeResolutionIndexes; + expect( + swiftIsCallableVisibleFromCaller({ + candidate: local, + callerParsed: { + filePath, + moduleScope: 'module', + scopes: [...localScopes.scopeTree.byId.values()], + localDefs: [classDef, property, method, local], + parsedImports: [], + referenceSites: [], + }, + callerScope: 'function', + callArity: 0, + scopes: localScopes, + }), + ).toBe(true); + }); + + it('preserves a selected method owned by the current type', () => { + expect( + swiftIsCallableVisibleFromCaller({ + candidate: { ...method, nodeId: 'Service.clock', qualifiedName: 'Service.clock' }, + callerScope: 'function', + callArity: 0, + scopes, + }), + ).toBe(true); + }); + + it('does not veto a call with arguments or unknown arity', () => { + const candidate: SymbolDefinition = { + nodeId: 'Other.clock', + filePath: 'Other.swift', + type: 'Method', + qualifiedName: 'Other.clock', + }; + expect( + swiftIsCallableVisibleFromCaller({ + candidate, + callerScope: 'function', + callArity: 1, + scopes, + }), + ).toBe(true); + expect(swiftIsCallableVisibleFromCaller({ candidate, callerScope: 'function', scopes })).toBe( + true, + ); + }); + + it('rejects a decoy when a superclass owns the closure property', () => { + const base: SymbolDefinition = { + nodeId: 'BaseService', + filePath, + type: 'Class', + qualifiedName: 'BaseService', + }; + const derived: SymbolDefinition = { + nodeId: 'DerivedService', + filePath, + type: 'Class', + qualifiedName: 'DerivedService', + }; + const inheritedProperty: SymbolDefinition = { + nodeId: 'BaseService.clock', + filePath, + type: 'Property', + qualifiedName: 'BaseService.clock', + ownerId: base.nodeId, + }; + const inheritedScopes = { + scopeTree: buildScopeTree([ + scope('derivedModule', null, 'Module', [], moduleRange), + scope('derivedClass', 'derivedModule', 'Class', [derived], classRange), + scope('derivedFunction', 'derivedClass', 'Function', [method], functionRange), + ]), + defs: buildDefIndex([base, derived, inheritedProperty]), + qualifiedNames: buildQualifiedNameIndex([base, derived, inheritedProperty]), + methodDispatch: buildMethodDispatchIndex({ + owners: [derived.nodeId], + computeMro: () => [base.nodeId], + implementsOf: () => [], + }), + } as ScopeResolutionIndexes; + expect( + swiftIsCallableVisibleFromCaller({ + candidate: { + nodeId: 'Other.clock', + filePath: 'Other.swift', + type: 'Method', + qualifiedName: 'Other.clock', + }, + callerScope: 'derivedFunction', + callArity: 0, + scopes: inheritedScopes, + }), + ).toBe(false); + + const extensionScopes = { + ...inheritedScopes, + bindings: new Map([ + [ + 'extensionModule', + new Map([['DerivedService', [{ def: derived, origin: 'local' as const }]]]), + ], + ]), + bindingAugmentations: new Map(), + scopeTree: buildScopeTree([ + scope('extensionModule', null, 'Module', [], moduleRange), + scope('extensionClass', 'extensionModule', 'Class', [], classRange), + { + ...scope('extensionFunction', 'extensionClass', 'Function', [method], functionRange), + typeBindings: new Map([ + [ + 'self', + { rawName: 'DerivedService', declaredAtScope: 'extensionFunction', source: 'self' }, + ], + ]), + }, + ]), + } as ScopeResolutionIndexes; + expect( + swiftIsCallableVisibleFromCaller({ + candidate: { + nodeId: 'Other.clock', + filePath: 'Other.swift', + type: 'Method', + qualifiedName: 'Other.clock', + }, + callerScope: 'extensionFunction', + callArity: 0, + scopes: extensionScopes, + }), + ).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts b/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts index 40ba4a332..bb41357b7 100644 --- a/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts @@ -18,7 +18,6 @@ import * as importResolverUtils from '../../../../src/core/ingestion/import-reso import { typescriptScopeResolver } from '../../../../src/core/ingestion/languages/typescript/scope-resolver.js'; import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helpers.js'; import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; -import { SupportedLanguages } from 'gitnexus-shared'; function importsFor(src: string): ParsedImport[] { const matches = emitTsScopeCaptures(src, 'test.ts'); @@ -116,17 +115,17 @@ describe('interpretTsImport — static imports', () => { expect(imps).toHaveLength(3); expect(imps.map((i) => i.kind)).toEqual(['alias', 'named', 'alias']); - const def = imps.find((i) => i.localName === 'D'); + const def = imps.find((i) => 'localName' in i && i.localName === 'D'); expect(def).toMatchObject({ importedName: 'default', targetRaw: './m' }); - const named = imps.find((i) => i.localName === 'X'); + const named = imps.find((i) => 'localName' in i && i.localName === 'X'); expect(named).toMatchObject({ kind: 'named', importedName: 'X', targetRaw: './m', }); - const aliased = imps.find((i) => i.localName === 'Z'); + const aliased = imps.find((i) => 'localName' in i && i.localName === 'Z'); expect(aliased).toMatchObject({ kind: 'alias', importedName: 'Y', @@ -139,11 +138,11 @@ describe('interpretTsImport — static imports', () => { const imps = importsFor('import D, * as N from "./m";'); expect(imps).toHaveLength(2); - const def = imps.find((i) => i.localName === 'D'); + const def = imps.find((i) => 'localName' in i && i.localName === 'D'); expect(def?.kind).toBe('alias'); expect((def as { importedName: string }).importedName).toBe('default'); - const ns = imps.find((i) => i.localName === 'N'); + const ns = imps.find((i) => 'localName' in i && i.localName === 'N'); expect(ns?.kind).toBe('namespace'); expect((ns as { importedName: string }).importedName).toBe('./m'); }); @@ -523,19 +522,14 @@ describe('resolveTsImportTarget — standard suffix + alias resolution', () => { expect(result).toBe(null); }); - it('switches extensions when language=JavaScript', () => { + it('resolves JavaScript extensions from a JavaScript source file', () => { const parsed: ParsedImport = { kind: 'named', localName: 'X', importedName: 'X', targetRaw: './a', }; - const result = resolveTsImportTarget( - parsed, - ctx('src/main.js', ['src/main.js', 'src/a.js'], { - language: SupportedLanguages.JavaScript, - }), - ); + const result = resolveTsImportTarget(parsed, ctx('src/main.js', ['src/main.js', 'src/a.js'])); expect(result).toBe('src/a.js'); }); diff --git a/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts b/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts index 5661467fb..99301bec6 100644 --- a/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts +++ b/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts @@ -465,25 +465,47 @@ describe('classifyReceiverShape', () => { }); it('reports no-chain for a chain with no steps', () => { - expect(classifyReceiverShape({ steps: [] })).toBe('no-chain'); + expect( + classifyReceiverShape({ baseReceiverName: 'receiver', truncated: false, steps: [] }), + ).toBe('no-chain'); }); it('reports chain-call when every step is a call', () => { - expect(classifyReceiverShape({ steps: [{ kind: 'call' }, { kind: 'call' }] })).toBe( - 'chain-call', - ); + expect( + classifyReceiverShape({ + baseReceiverName: 'receiver', + truncated: false, + steps: [ + { kind: 'call', name: 'method' }, + { kind: 'call', name: 'method' }, + ], + }), + ).toBe('chain-call'); }); it('reports chain-field when every step is a field', () => { - expect(classifyReceiverShape({ steps: [{ kind: 'field' }] })).toBe('chain-field'); + expect( + classifyReceiverShape({ + baseReceiverName: 'receiver', + truncated: false, + steps: [{ kind: 'field', name: 'property' }], + }), + ).toBe('chain-field'); }); // The distinction that makes the census actionable: a mixed chain fails for // different reasons than a pure one, so collapsing it into either bucket // would misattribute the population a fix has to target. it('reports chain-mixed when the chain interleaves calls and fields', () => { - expect(classifyReceiverShape({ steps: [{ kind: 'call' }, { kind: 'field' }] })).toBe( - 'chain-mixed', - ); + expect( + classifyReceiverShape({ + baseReceiverName: 'receiver', + truncated: false, + steps: [ + { kind: 'call', name: 'method' }, + { kind: 'field', name: 'property' }, + ], + }), + ).toBe('chain-mixed'); }); }); diff --git a/gitnexus/test/unit/scope-resolution/walkers-augmentations.test.ts b/gitnexus/test/unit/scope-resolution/walkers-augmentations.test.ts index a4dbb0e5b..60bbadb87 100644 --- a/gitnexus/test/unit/scope-resolution/walkers-augmentations.test.ts +++ b/gitnexus/test/unit/scope-resolution/walkers-augmentations.test.ts @@ -72,13 +72,14 @@ function scope(id: ScopeId, bindings = new Map()) function indexesForScopeLookup( moduleScope: Scope, augmented: Map, + bindings: ScopeResolutionIndexes['bindings'] = new Map(), ): ScopeResolutionIndexes { const scopeTree = { getScope: (id: ScopeId) => (id === moduleScope.id ? moduleScope : undefined), } as unknown as ScopeTree; return { scopeTree, - bindings: new Map(), + bindings, bindingAugmentations: new Map([[moduleScope.id, augmented]]), } as unknown as ScopeResolutionIndexes; } @@ -225,8 +226,11 @@ describe('walker helpers read bindingAugmentations', () => { origin: 'import', visibility: 'static-member-import', } as BindingRef; - const indexes = indexesForScopeLookup(moduleScope, new Map([['callMe', [staticImport]]])); - indexes.bindings.set(SCOPE, new Map([['callMe', [finalized]]])); + const indexes = indexesForScopeLookup( + moduleScope, + new Map([['callMe', [staticImport]]]), + new Map([[SCOPE, new Map([['callMe', [finalized]]])]]), + ); const candidates = findAllCallableBindingCandidatesInScope(SCOPE, 'callMe', indexes); diff --git a/gitnexus/test/unit/semantic-search-ready.test.ts b/gitnexus/test/unit/semantic-search-ready.test.ts index 0c2ad3f17..682f912d2 100644 --- a/gitnexus/test/unit/semantic-search-ready.test.ts +++ b/gitnexus/test/unit/semantic-search-ready.test.ts @@ -15,7 +15,7 @@ vi.mock('../../src/core/embeddings/embedder.js', () => ({ })); vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ - loadVectorExtension: (...args: unknown[]) => loadVectorExtensionMock(...args), + loadVectorExtension: loadVectorExtensionMock, createVectorIndex: vi.fn(), })); diff --git a/gitnexus/test/unit/server-api-repo-resolution.test.ts b/gitnexus/test/unit/server-api-repo-resolution.test.ts index 66d0c0b2c..aaea0ef8a 100644 --- a/gitnexus/test/unit/server-api-repo-resolution.test.ts +++ b/gitnexus/test/unit/server-api-repo-resolution.test.ts @@ -142,7 +142,7 @@ describe('resolveOmittedRepoSelection', () => { const second = entry({ name: 'beta', path: '/tmp/beta', storagePath: '/tmp/beta/.gitnexus' }); const result = resolveOmittedRepoSelection([first, second]); expect(result.ok).toBe(false); - if (result.ok) return; + if (result.ok !== false) throw new Error('expected ambiguous repository selection'); expect(result.status).toBe(400); expect(result.error).toMatch(/Multiple repositories indexed/); expect(result.error).toContain('alpha'); diff --git a/gitnexus/test/unit/server.test.ts b/gitnexus/test/unit/server.test.ts index da5c37d90..6d7e4f048 100644 --- a/gitnexus/test/unit/server.test.ts +++ b/gitnexus/test/unit/server.test.ts @@ -16,6 +16,7 @@ import { describe, it, expect, vi } from 'vitest'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; import { createMCPServer, installSignalShutdown, @@ -60,7 +61,7 @@ async function callToolThroughServer( try { await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); - const response = await client.callTool({ name, arguments: args }); + const response = CallToolResultSchema.parse(await client.callTool({ name, arguments: args })); const text = response.content.find((item) => item.type === 'text')?.text; if (typeof text !== 'string') throw new Error('Expected an MCP text response'); return { text, isError: response.isError === true }; diff --git a/gitnexus/test/unit/setup-codex.test.ts b/gitnexus/test/unit/setup-codex.test.ts index 10a19e38c..6b19738dd 100644 --- a/gitnexus/test/unit/setup-codex.test.ts +++ b/gitnexus/test/unit/setup-codex.test.ts @@ -14,7 +14,7 @@ const execFileMock = vi.fn((...args: any[]) => { } }); -const execFileSyncMock = vi.fn(() => { +const execFileSyncMock = vi.fn((): string => { throw new Error('not found'); }); @@ -27,6 +27,7 @@ describe('setupCommand codex execution', () => { let tempHome: string; let originalHome: string | undefined; let originalUserProfile: string | undefined; + let originalCodexHome: string | undefined; let platformDescriptor: PropertyDescriptor | undefined; const setPlatform = (value: NodeJS.Platform) => { @@ -42,6 +43,8 @@ describe('setupCommand codex execution', () => { originalHome = process.env.HOME; originalUserProfile = process.env.USERPROFILE; + originalCodexHome = process.env.CODEX_HOME; + delete process.env.CODEX_HOME; tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-codex-setup-')); process.env.HOME = tempHome; process.env.USERPROFILE = tempHome; @@ -62,6 +65,8 @@ describe('setupCommand codex execution', () => { process.env.HOME = originalHome; process.env.USERPROFILE = originalUserProfile; + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; await fs.rm(tempHome, { recursive: true, force: true }); }); @@ -110,6 +115,93 @@ describe('setupCommand codex execution', () => { await expect(fs.access(path.join(tempHome, '.codex', 'config.toml'))).rejects.toThrow(); }); + it('keeps an existing HTTP entry and its bearer-token setting without invoking codex mcp add', async () => { + const configPath = path.join(tempHome, '.codex', 'config.toml'); + const raw = + '[mcp_servers.gitnexus]\nurl = "http://127.0.0.1:4748/mcp"\nbearer_token_env_var = "GITNEXUS_TOKEN"\n'; + await fs.writeFile(configPath, raw, 'utf-8'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + expect(execFileMock).not.toHaveBeenCalled(); + expect(await fs.readFile(configPath, 'utf-8')).toBe(raw); + expect( + await fs.stat(path.join(tempHome, '.agents', 'skills', 'gitnexus-guide', 'SKILL.md')), + ).toBeDefined(); + }); + + it('does not mistake an HTTP entry in the default home for the active CODEX_HOME entry', async () => { + const defaultConfig = path.join(tempHome, '.codex', 'config.toml'); + const activeHome = path.join(tempHome, 'active-codex'); + await fs.writeFile(defaultConfig, '[mcp_servers.gitnexus]\nurl = "https://example.test/mcp"\n'); + await fs.mkdir(activeHome); + process.env.CODEX_HOME = activeHome; + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + expect(execFileMock).toHaveBeenCalledWith( + 'codex', + ['mcp', 'add', 'gitnexus', '--', 'cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'], + { shell: true, windowsHide: true }, + expect.any(Function), + ); + }); + + it('preserves an HTTP entry in the active CODEX_HOME even when the default home is empty', async () => { + const activeHome = path.join(tempHome, 'active-codex'); + const activeConfig = path.join(activeHome, 'config.toml'); + const raw = '[mcp_servers.gitnexus]\nurl = "https://example.test/mcp"\n'; + await fs.mkdir(activeHome); + await fs.writeFile(activeConfig, raw); + process.env.CODEX_HOME = activeHome; + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + expect(execFileMock).not.toHaveBeenCalled(); + expect(await fs.readFile(activeConfig, 'utf-8')).toBe(raw); + }); + + it('writes the fallback entry to the active CODEX_HOME when the Codex CLI is unavailable', async () => { + const activeHome = path.join(tempHome, 'active-codex'); + process.env.CODEX_HOME = activeHome; + execFileMock.mockImplementationOnce((...args: unknown[]) => { + const callback = args.at(-1); + if (typeof callback === 'function') callback(new Error('codex unavailable')); + }); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + expect(await fs.readFile(path.join(activeHome, 'config.toml'), 'utf-8')).toContain( + '[mcp_servers.gitnexus]', + ); + await expect(fs.access(path.join(tempHome, '.codex', 'config.toml'))).rejects.toThrow(); + }); + + it('does not print TOML source or nearby credentials when the active config is malformed', async () => { + const activeHome = path.join(tempHome, 'active-codex'); + const configPath = path.join(activeHome, 'config.toml'); + const token = 'Bearer FAKE_PR3460_REVIEW_TOKEN'; + await fs.mkdir(activeHome); + await fs.writeFile( + configPath, + `[mcp_servers.gitnexus]\nhttp_headers = { Authorization = "${token}" }\ninvalid =\n`, + ); + process.env.CODEX_HOME = activeHome; + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + const output = JSON.stringify(vi.mocked(console.log).mock.calls); + expect(output).toMatch(/invalid config\.toml \(line 3, column \d+\)/); + expect(output).not.toContain(token); + expect(execFileMock).not.toHaveBeenCalled(); + expect(await fs.readFile(configPath, 'utf-8')).toContain(token); + }); + it('skips Codex setup entirely when ~/.codex is missing', async () => { await fs.rm(path.join(tempHome, '.codex'), { recursive: true, force: true }); diff --git a/gitnexus/test/unit/setup-jsonc.test.ts b/gitnexus/test/unit/setup-jsonc.test.ts index d84b9a41b..a06c1f5bb 100644 --- a/gitnexus/test/unit/setup-jsonc.test.ts +++ b/gitnexus/test/unit/setup-jsonc.test.ts @@ -15,7 +15,7 @@ const execFileMock = vi.fn((...args: any[]) => { } }); -const execFileSyncMock = vi.fn(() => { +const execFileSyncMock = vi.fn((): string => { throw new Error('not found'); }); diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts index 942211ae3..e61859ab4 100644 --- a/gitnexus/test/unit/setup.test.ts +++ b/gitnexus/test/unit/setup.test.ts @@ -23,7 +23,7 @@ const execFileMock = vi.fn((...args: any[]) => { // By default, execFileSync throws (simulating `which gitnexus` not found) // so getMcpEntry() falls back to the npx path. -const execFileSyncMock = vi.fn(() => { +const execFileSyncMock = vi.fn((): string => { throw new Error('not found'); }); @@ -134,6 +134,40 @@ describe('setupClaudeCode', () => { expect(config.mcpServers.gitnexus).toBeDefined(); }); + it('keeps existing Claude and Cursor HTTP MCP entries while installing skills', async () => { + const claudePath = path.join(tempHome, '.claude.json'); + const cursorDir = path.join(tempHome, '.cursor'); + const cursorPath = path.join(cursorDir, 'mcp.json'); + await fs.mkdir(cursorDir, { recursive: true }); + const claudeRaw = `{ + // Shared GitNexus server + "mcpServers": { + "gitnexus": { "type": "http", "url": "http://127.0.0.1:4748/mcp", "headers": { "Authorization": "Bearer $GITNEXUS_TOKEN" } } + } +}`; + const cursorRaw = JSON.stringify({ + mcpServers: { + gitnexus: { + url: 'http://127.0.0.1:4748/mcp', + headers: { Authorization: 'Bearer $GITNEXUS_TOKEN' }, + }, + }, + }); + await fs.writeFile(claudePath, claudeRaw, 'utf-8'); + await fs.writeFile(cursorPath, cursorRaw, 'utf-8'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: ['claude', 'cursor'] }); + + expect(await fs.readFile(claudePath, 'utf-8')).toBe(claudeRaw); + expect(await fs.readFile(cursorPath, 'utf-8')).toBe(cursorRaw); + expect(logLines()).toContain('Claude Code (existing HTTP MCP entry kept)'); + expect(logLines()).toContain('Cursor (existing HTTP MCP entry kept)'); + expect( + await fs.stat(path.join(tempHome, '.claude', 'skills', 'gitnexus-guide', 'SKILL.md')), + ).toBeDefined(); + }); + it('handles missing ~/.claude.json (creates fresh)', async () => { setPlatform('linux'); @@ -800,6 +834,7 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => { let tempHome: string; let originalHome: string | undefined; let originalUserProfile: string | undefined; + let originalCodexHome: string | undefined; const hooksJsonPath = () => path.join(tempHome, '.codex', 'hooks.json'); @@ -809,6 +844,8 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => { originalHome = process.env.HOME; originalUserProfile = process.env.USERPROFILE; + originalCodexHome = process.env.CODEX_HOME; + delete process.env.CODEX_HOME; tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-codex-hooks-')); process.env.HOME = tempHome; process.env.USERPROFILE = tempHome; @@ -824,6 +861,8 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => { vi.restoreAllMocks(); process.env.HOME = originalHome; process.env.USERPROFILE = originalUserProfile; + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; await fs.rm(tempHome, { recursive: true, force: true }); }); @@ -844,6 +883,28 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => { ).resolves.toBeUndefined(); }); + it('installs hooks and skills for active CODEX_HOME without touching the default home', async () => { + const activeHome = path.join(tempHome, 'active-codex'); + const defaultHooks = hooksJsonPath(); + await fs.rm(path.join(tempHome, '.codex'), { recursive: true, force: true }); + await fs.mkdir(activeHome); + process.env.CODEX_HOME = activeHome; + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + const hooks = JSON.parse(await fs.readFile(path.join(activeHome, 'hooks.json'), 'utf-8')).hooks; + expect(hooks.PreToolUse).toHaveLength(1); + expect(hooks.PostToolUse).toHaveLength(1); + await expect( + fs.access(path.join(activeHome, 'hooks', 'gitnexus', 'gitnexus-hook.cjs')), + ).resolves.toBeUndefined(); + await expect( + fs.access(path.join(tempHome, '.agents', 'skills', 'gitnexus-guide', 'SKILL.md')), + ).resolves.toBeUndefined(); + await expect(fs.access(defaultHooks)).rejects.toThrow(); + }); + it('is idempotent — a second setup run adds no duplicate entries', async () => { const { setupCommand } = await import('../../src/cli/setup.js'); await setupCommand(); diff --git a/gitnexus/test/unit/shard-balance.test.ts b/gitnexus/test/unit/shard-balance.test.ts index ff2feb372..9b2f9f402 100644 --- a/gitnexus/test/unit/shard-balance.test.ts +++ b/gitnexus/test/unit/shard-balance.test.ts @@ -7,8 +7,8 @@ import { describe, it, expect } from 'vitest'; import { assignShards, specWeight } from '../helpers/shard-balance.js'; -const key = (s: { id: string }) => s.id; -const weight = (s: { w: number }) => s.w; +const key = (s: { id: string; w: number }) => s.id; +const weight = (s: { id: string; w: number }) => s.w; const make = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `f${i}`, w: (i % 5) + 1 })); describe('assignShards', () => { diff --git a/gitnexus/test/unit/skill-gen.test.ts b/gitnexus/test/unit/skill-gen.test.ts index 0ffb60ec0..d21225a55 100644 --- a/gitnexus/test/unit/skill-gen.test.ts +++ b/gitnexus/test/unit/skill-gen.test.ts @@ -11,7 +11,8 @@ import path from 'path'; import os from 'os'; import { generateSkillFiles } from '../../src/cli/skill-gen.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; -import type { GraphNode, GraphRelationship, KnowledgeGraph } from '../../src/core/graph/types.js'; +import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../src/core/graph/types.js'; import type { CommunityNode, CommunityMembership, @@ -22,6 +23,9 @@ import type { ProcessDetectionResult, } from '../../src/core/ingestion/process-processor.js'; import type { PipelineResult } from '../../src/types/pipeline.js'; +import { communitiesPhase } from '../../src/core/ingestion/pipeline-phases/communities.js'; +import { processesPhase } from '../../src/core/ingestion/pipeline-phases/processes.js'; +import type { PhaseResult } from '../../src/core/ingestion/pipeline-phases/types.js'; // ============================================================================ // FIXTURE HELPERS @@ -122,6 +126,15 @@ function buildPipelineResult(opts: { ? opts.processes.reduce((s, p) => s + p.stepCount, 0) / opts.processes.length : 0, entryPointsFound: 0, + truncation: { + truncated: false, + entryPointCandidatesDropped: 0, + entryPointsUnexplored: 0, + walksCutByBudget: 0, + tracesDepthCapped: 0, + calleesDropped: 0, + processesDropped: 0, + }, }, } : undefined; @@ -132,6 +145,30 @@ function buildPipelineResult(opts: { totalFileCount: 0, communityResult, processResult, + resolutionOutcomes: [], + usedWorkerPool: false, + reparsedFileCount: 0, + scopeExtractionFailures: [], + unavailableScopeLanguageFiles: 0, + }; +} + +/** Run the real community phase, including graph node and membership edge emission. */ +async function detectCommunities(graph: KnowledgeGraph, repoPath: string): Promise { + const { communityResult } = await communitiesPhase.execute( + { graph, repoPath, onProgress: () => {}, pipelineStart: Date.now() }, + new Map([['structure', { phaseName: 'structure', output: { totalFiles: 0 }, durationMs: 0 }]]), + ); + return { + graph, + repoPath, + totalFileCount: 0, + communityResult, + resolutionOutcomes: [], + usedWorkerPool: false, + reparsedFileCount: 0, + scopeExtractionFailures: [], + unavailableScopeLanguageFiles: 0, }; } @@ -407,6 +444,179 @@ describe('generateSkillFiles — return values', () => { expect(result.skills[0].label).toBe('Auth'); }); + it('generates a folder skill from real singleton assignments without dangling membership edges', async () => { + const graph = createKnowledgeGraph(); + graph.addNode(makeNode('file:target', 'target', 'File', `${tmpDir}/target.ts`, 1, false)); + for (const name of ['gamma', 'alpha', 'beta']) { + graph.addNode( + makeNode(`fn:${name}`, name, 'Function', `${tmpDir}/src/auth/${name}.ts`, 1, true), + ); + // The File target admits the symbol to the projection, then is excluded + // itself, leaving a singleton in the real Leiden result. + graph.addRelationship(makeRel(`rel:${name}`, `fn:${name}`, 'file:target', 'CALLS')); + } + + const pipeline = await detectCommunities(graph, tmpDir); + const result = await generateSkillFiles(tmpDir, 'TestProject', pipeline); + + expect(result.skills).toHaveLength(1); + expect(result.skills[0]).toMatchObject({ + name: 'gitnexus-area-auth', + label: 'Auth', + symbolCount: 3, + fileCount: 3, + }); + expect(pipeline.communityResult?.communities).toEqual([]); + expect(pipeline.communityResult?.memberships).toEqual([]); + expect(pipeline.communityResult?.rawMemberships).toEqual([ + { nodeId: 'fn:alpha', communityId: 'comm_0' }, + { nodeId: 'fn:beta', communityId: 'comm_1' }, + { nodeId: 'fn:gamma', communityId: 'comm_2' }, + ]); + expect([...graph.iterRelationships()].filter((rel) => rel.type === 'MEMBER_OF')).toEqual([]); + const content = await fs.readFile( + path.join(result.outputPath, result.skills[0].name, 'SKILL.md'), + 'utf-8', + ); + for (const name of ['alpha', 'beta', 'gamma']) { + expect(content).toContain(name); + expect(content).toContain(`src/auth/${name}.ts`); + } + }); + + it('preserves execution flows for real singleton fallback skills', async () => { + const graph = createKnowledgeGraph(); + // Large-graph projection prunes the degree-one endpoints of each chain, + // leaving only its middle function as a singleton community. + for (let i = 0; i < 10_001; i++) { + graph.addNode(makeNode(`fn:unused${i}`, `unused${i}`, 'Function', 'src/unused.ts', 1, false)); + } + for (let i = 0; i < 4; i++) { + const folder = i < 3 ? 'auth' : 'billing'; + for (const role of ['handle', 'middle', 'end']) { + const name = `${role}${i}`; + graph.addNode( + makeNode( + `fn:${name}`, + name, + 'Function', + `${tmpDir}/src/${folder}/${name}.ts`, + 1, + role === 'handle', + ), + ); + } + graph.addRelationship(makeRel(`rel:handle${i}`, `fn:handle${i}`, `fn:middle${i}`, 'CALLS')); + graph.addRelationship(makeRel(`rel:middle${i}`, `fn:middle${i}`, `fn:end${i}`, 'CALLS')); + } + + const pipeline = await detectCommunities(graph, tmpDir); + const { processResult } = await processesPhase.execute( + { graph, repoPath: tmpDir, onProgress: () => {}, pipelineStart: Date.now() }, + new Map>([ + ['structure', { phaseName: 'structure', output: { totalFiles: 0 }, durationMs: 0 }], + [ + 'communities', + { + phaseName: 'communities', + output: { communityResult: pipeline.communityResult }, + durationMs: 0, + }, + ], + ['routes', { phaseName: 'routes', output: { routeRegistry: new Map() }, durationMs: 0 }], + ['tools', { phaseName: 'tools', output: { toolDefs: [] }, durationMs: 0 }], + ]), + ); + pipeline.processResult = processResult; + + expect(pipeline.communityResult?.communities).toEqual([]); + expect(pipeline.communityResult?.memberships).toEqual([]); + expect(pipeline.communityResult?.rawMemberships).toHaveLength(4); + expect(processResult.processes).toHaveLength(4); + expect(processResult.processes.every((process) => process.communities.length === 0)).toBe(true); + expect([...graph.iterRelationships()].filter((rel) => rel.type === 'MEMBER_OF')).toEqual([]); + + const result = await generateSkillFiles(tmpDir, 'TestProject', pipeline); + expect(result.skills).toHaveLength(1); + expect(result.skills[0]).toMatchObject({ label: 'Auth', symbolCount: 3 }); + const content = await fs.readFile( + path.join(result.outputPath, result.skills[0].name, 'SKILL.md'), + 'utf-8', + ); + expect(content).toContain('## Execution Flows'); + for (const process of processResult.processes) { + if (process.entryPointId === 'fn:handle3') { + expect(content).not.toContain(process.heuristicLabel); + } else { + expect(content).toContain(process.heuristicLabel); + } + } + }); + + it.each([0, 2])('skips real singleton fallback below threshold (%i symbols)', async (count) => { + const graph = createKnowledgeGraph(); + if (count > 0) { + graph.addNode(makeNode('file:target', 'target', 'File', `${tmpDir}/target.ts`, 1, false)); + } + for (let i = 0; i < count; i++) { + graph.addNode( + makeNode(`fn:n${i}`, `n${i}`, 'Function', `${tmpDir}/src/auth/f${i}.ts`, 1, true), + ); + graph.addRelationship(makeRel(`rel:${i}`, `fn:n${i}`, 'file:target', 'CALLS')); + } + + const pipeline = await detectCommunities(graph, tmpDir); + const result = await generateSkillFiles(tmpDir, 'TestProject', pipeline); + + expect(result.skills).toEqual([]); + expect(pipeline.communityResult?.rawMemberships).toHaveLength(count); + expect(pipeline.communityResult?.memberships).toEqual([]); + expect([...graph.iterRelationships()].filter((rel) => rel.type === 'MEMBER_OF')).toEqual([]); + }); + + it('keeps real retained skills and membership edges separate from filtered singletons', async () => { + const graph = createKnowledgeGraph(); + for (const name of ['alpha', 'beta', 'gamma', 'singleton']) { + graph.addNode( + makeNode(`fn:${name}`, name, 'Function', `${tmpDir}/src/auth/${name}.ts`, 1, true), + ); + } + graph.addNode(makeNode('file:target', 'target', 'File', `${tmpDir}/target.ts`, 1, false)); + graph.addRelationship(makeRel('rel:ab', 'fn:alpha', 'fn:beta', 'CALLS')); + graph.addRelationship(makeRel('rel:bc', 'fn:beta', 'fn:gamma', 'CALLS')); + graph.addRelationship(makeRel('rel:ca', 'fn:gamma', 'fn:alpha', 'CALLS')); + graph.addRelationship(makeRel('rel:singleton', 'fn:singleton', 'file:target', 'CALLS')); + + const pipeline = await detectCommunities(graph, tmpDir); + const result = await generateSkillFiles(tmpDir, 'TestProject', pipeline); + + expect(result.skills).toHaveLength(1); + expect(result.skills[0]).toMatchObject({ label: 'Auth', symbolCount: 3, fileCount: 3 }); + expect(pipeline.communityResult?.rawMemberships).toHaveLength(4); + expect(pipeline.communityResult?.memberships.map((membership) => membership.nodeId)).toEqual([ + 'fn:alpha', + 'fn:beta', + 'fn:gamma', + ]); + const membershipEdges = [...graph.iterRelationships()].filter( + (rel) => rel.type === 'MEMBER_OF', + ); + expect(membershipEdges).toHaveLength(3); + for (const edge of membershipEdges) { + expect(graph.getNode(edge.sourceId)).toBeDefined(); + expect(graph.getNode(edge.targetId)?.label).toBe('Community'); + expect(edge.sourceId).not.toBe('fn:singleton'); + } + const content = await fs.readFile( + path.join(result.outputPath, result.skills[0].name, 'SKILL.md'), + 'utf-8', + ); + expect(content).not.toContain('singleton'); + for (const name of ['alpha', 'beta', 'gamma']) { + expect(content).toContain(`src/auth/${name}.ts`); + } + }); + /** * When processResult is undefined, the generator should still work * without crashing — it simply has no execution flows. @@ -432,6 +642,11 @@ describe('generateSkillFiles — return values', () => { stats: { totalCommunities: 1, modularity: 0.5, nodesProcessed: 4 }, }, processResult: undefined, + resolutionOutcomes: [], + usedWorkerPool: false, + reparsedFileCount: 0, + scopeExtractionFailures: [], + unavailableScopeLanguageFiles: 0, }; const result = await generateSkillFiles(tmpDir, 'TestProject', pipeline); diff --git a/gitnexus/test/unit/spring-aop.test.ts b/gitnexus/test/unit/spring-aop.test.ts index 93e42436c..407ef86b6 100644 --- a/gitnexus/test/unit/spring-aop.test.ts +++ b/gitnexus/test/unit/spring-aop.test.ts @@ -326,7 +326,7 @@ describe('Spring AOP persisted reason contract (#2416)', () => { }, ]; - it.each(reasons)('round-trips the $kind reason', (reason) => { + it.each(reasons)('round-trips the $kind reason for $annotation', (reason) => { expect(decodeSpringAopReason(encodeSpringAopReason(reason))).toEqual(reason); }); diff --git a/gitnexus/test/unit/spring-destinations-phase.test.ts b/gitnexus/test/unit/spring-destinations-phase.test.ts index 44fb534a6..a46d7ec4c 100644 --- a/gitnexus/test/unit/spring-destinations-phase.test.ts +++ b/gitnexus/test/unit/spring-destinations-phase.test.ts @@ -1,3 +1,4 @@ +import type { JavaAnnotationSyntaxFact } from '../../src/core/ingestion/languages/java/spring-di.js'; import { afterAll, beforeEach, describe, expect, it } from 'vitest'; import { mkdtemp, writeFile, symlink, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -31,6 +32,19 @@ import { generateId } from '../../src/lib/utils.js'; const OWNER_RANGE = { startLine: 4, startCol: 4, endLine: 6, endCol: 5 } as const; +function annotation( + fact: Omit, +): JavaAnnotationSyntaxFact { + const args = fact.args + ?.map((arg) => (arg.name ? `${arg.name} = ${arg.text}` : arg.text)) + .join(', '); + return { + ...fact, + line: OWNER_RANGE.startLine - 1, + text: `@${fact.name}${args === undefined ? '' : `(${args})`}`, + }; +} + function callableNode(graph: KnowledgeGraph, filePath: string, name: string): void { graph.addNode({ id: generateId('Method', `${filePath}:${name}`), @@ -87,10 +101,13 @@ describe('springDestinations phase', () => { ownerFilePath: filePath, ownerRange: OWNER_RANGE, annotations: [ - { name: 'KafkaListener', args: [{ name: 'topicPattern', text: '"orders.*"' }] }, - { name: 'KafkaListener', args: [{ name: 'topics', text: '{}' }] }, - { name: 'KafkaListener', args: [{ name: 'groupId', text: '"g"' }] }, - { name: 'KafkaListener' }, + annotation({ + name: 'KafkaListener', + args: [{ name: 'topicPattern', text: '"orders.*"' }], + }), + annotation({ name: 'KafkaListener', args: [{ name: 'topics', text: '{}' }] }), + annotation({ name: 'KafkaListener', args: [{ name: 'groupId', text: '"g"' }] }), + annotation({ name: 'KafkaListener' }), ], }, ]); @@ -137,7 +154,10 @@ describe('springDestinations phase', () => { ownerFilePath: filePath, ownerRange: OWNER_RANGE, annotations: [ - { name: 'KafkaListener', args: [{ name: 'topics', text: '"${app.topic}"' }] }, + annotation({ + name: 'KafkaListener', + args: [{ name: 'topics', text: '"${app.topic}"' }], + }), ], }, ]); @@ -258,14 +278,14 @@ describe('springDestinations phase', () => { ownerScopeId: `${filePath}#first` as never, ownerFilePath: filePath, annotations: [ - { name: 'KafkaListener', args: [{ name: 'topics', text: '"${app.topic}"' }] }, + annotation({ name: 'KafkaListener', args: [{ name: 'topics', text: '"${app.topic}"' }] }), ], }, { ownerScopeId: `${filePath}#second` as never, ownerFilePath: filePath, annotations: [ - { name: 'KafkaListener', args: [{ name: 'topics', text: '"${app.topic}"' }] }, + annotation({ name: 'KafkaListener', args: [{ name: 'topics', text: '"${app.topic}"' }] }), ], }, ]); @@ -296,7 +316,10 @@ describe('springDestinations phase', () => { ownerFilePath: filePath, ownerRange: OWNER_RANGE, annotations: [ - { name: 'KafkaListener', args: [{ name: 'topics', text: `"\${${key}:events}"` }] }, + annotation({ + name: 'KafkaListener', + args: [{ name: 'topics', text: `"\${${key}:events}"` }], + }), ], }, ]); @@ -329,10 +352,10 @@ describe('springDestinations phase', () => { ownerFilePath: filePath, ownerRange: OWNER_RANGE, annotations: [ - { + annotation({ name: 'KafkaListener', args: [{ name: 'topics', text: '"#{@kafkaProps.ordersTopic}"' }], - }, + }), ], }, ]); @@ -361,7 +384,9 @@ describe('springDestinations phase', () => { ownerScopeId: `${filePath}#consume` as never, ownerFilePath: filePath, ownerRange: OWNER_RANGE, - annotations: [{ name: 'KafkaListener', args: [{ name: 'topics', text: '"${}"' }] }], + annotations: [ + annotation({ name: 'KafkaListener', args: [{ name: 'topics', text: '"${}"' }] }), + ], }, ]); setJavaSpringMessageProducerFacts(filePath, []); @@ -406,7 +431,9 @@ describe('springDestinations phase', () => { ownerScopeId: `${listenerFile}#consume` as never, ownerFilePath: listenerFile, ownerRange: OWNER_RANGE, - annotations: [{ name: 'KafkaListener', args: [{ name: 'topics', text: '"orders"' }] }], + annotations: [ + annotation({ name: 'KafkaListener', args: [{ name: 'topics', text: '"orders"' }] }), + ], }, ]); setJavaSpringMessageProducerFacts(listenerFile, []); @@ -417,7 +444,9 @@ describe('springDestinations phase', () => { ownerScopeId: `${strangerFile}#consume` as never, ownerFilePath: strangerFile, ownerRange: OWNER_RANGE, - annotations: [{ name: 'RabbitListener', args: [{ name: 'queues', text: '"orders"' }] }], + annotations: [ + annotation({ name: 'RabbitListener', args: [{ name: 'queues', text: '"orders"' }] }), + ], }, ]); setJavaSpringMessageProducerFacts(strangerFile, []); @@ -483,7 +512,9 @@ describe('springDestinations phase', () => { ownerScopeId: `${filePath}#consume` as never, ownerFilePath: filePath, ownerRange: OWNER_RANGE, - annotations: [{ name: 'RabbitListener', args: [{ name: 'queues', text: '"orders"' }] }], + annotations: [ + annotation({ name: 'RabbitListener', args: [{ name: 'queues', text: '"orders"' }] }), + ], }, ]); setJavaSpringMessageProducerFacts(filePath, [ @@ -535,7 +566,9 @@ describe('springDestinations phase', () => { ownerScopeId: `${filePath}#consume` as never, ownerFilePath: filePath, ownerRange: OWNER_RANGE, - annotations: [{ name: 'KafkaListener', args: [{ name: 'topics', text: '"orders"' }] }], + annotations: [ + annotation({ name: 'KafkaListener', args: [{ name: 'topics', text: '"orders"' }] }), + ], }, ]); setJavaSpringMessageProducerFacts(filePath, [ @@ -571,8 +604,8 @@ describe('springDestinations phase', () => { ownerFilePath: filePath, ownerRange: OWNER_RANGE, annotations: [ - { name: 'RabbitListener', args: [{ name: 'queues', text: '"orders"' }] }, - { name: 'KafkaListener', args: [{ name: 'topics', text: '"shipments"' }] }, + annotation({ name: 'RabbitListener', args: [{ name: 'queues', text: '"orders"' }] }), + annotation({ name: 'KafkaListener', args: [{ name: 'topics', text: '"shipments"' }] }), ], }, ]); @@ -834,7 +867,7 @@ operations: ownerFilePath: filePath, ownerRange: OWNER_RANGE, annotations: [ - { name: 'KafkaListener', args: [{ name: 'topics', text: '"${app.topic}"' }] }, + annotation({ name: 'KafkaListener', args: [{ name: 'topics', text: '"${app.topic}"' }] }), ], }, ]); diff --git a/gitnexus/test/unit/staged-embedding-recovery-child.test.ts b/gitnexus/test/unit/staged-embedding-recovery-child.test.ts new file mode 100644 index 000000000..58711493b --- /dev/null +++ b/gitnexus/test/unit/staged-embedding-recovery-child.test.ts @@ -0,0 +1,300 @@ +import fs from 'node:fs'; +import { EventEmitter } from 'node:events'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const h = vi.hoisted(() => ({ + dbCtor: vi.fn(), + connCtor: vi.fn(), + dbClose: vi.fn<() => Promise>(), + connClose: vi.fn<() => Promise>(), + query: vi.fn(), + abortBuilder: vi.fn(), + spawn: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ spawn: h.spawn })); + +vi.mock('@ladybugdb/core', () => { + class Database { + constructor(...args: unknown[]) { + h.dbCtor(...args); + } + close = h.dbClose; + } + class Connection { + constructor(db: unknown) { + h.connCtor(db); + } + query = h.query; + close = h.connClose; + } + return { default: { Database, Connection } }; +}); + +vi.mock('../../src/core/embeddings/embedding-restore-spill.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + abortCachedEmbeddingsBuilder: ( + ...args: Parameters + ) => { + h.abortBuilder(...args); + return actual.abortCachedEmbeddingsBuilder(...args); + }, + }; +}); + +describe('staged embedding recovery child native lifecycle', () => { + const suffixes = [ + '', + '.wal', + '.shadow', + '.wal.checkpoint', + '.lock', + '.checkpoint.intent.lock', + '.checkpoint.apply.lock', + ]; + let tmp: string; + let dbPath: string; + let exportDir: string; + let originalArgv: string[]; + let originalExitCode: typeof process.exitCode; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + h.dbCtor.mockReset(); + h.connCtor.mockReset(); + h.dbClose.mockReset().mockResolvedValue(undefined); + h.connClose.mockReset().mockResolvedValue(undefined); + h.query.mockReset().mockResolvedValue({ + hasNext: vi.fn().mockResolvedValue(false), + getNext: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + }); + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-recovery-child-')); + dbPath = path.join(tmp, 'lbug.stage-test'); + exportDir = path.join(tmp, 'export'); + fs.writeFileSync(dbPath, 'mock native database'); + fs.writeFileSync(`${dbPath}.wal`, 'retained WAL'); + fs.mkdirSync(exportDir); + originalArgv = process.argv; + originalExitCode = process.exitCode; + process.argv = [process.execPath, 'staged-embedding-recovery-child', dbPath, exportDir, '2']; + process.exitCode = undefined; + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + vi.useRealTimers(); + process.argv = originalArgv; + process.exitCode = originalExitCode; + vi.restoreAllMocks(); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + function sourceFamily() { + return Object.fromEntries( + suffixes.map((suffix) => [suffix, fs.readFileSync(dbPath + suffix, 'utf8')]), + ); + } + + function seedCompleteFamily() { + for (const suffix of suffixes) fs.writeFileSync(dbPath + suffix, `retained ${suffix}`); + return sourceFamily(); + } + + async function runRejectedChild(message: string): Promise { + await import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + await vi.waitFor(() => { + expect(process.exitCode).toBe(1); + expect(process.stderr.write).toHaveBeenCalledWith(`${message}\n`); + }); + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + expect(fs.readFileSync(`${dbPath}.wal`, 'utf8')).toBe('retained WAL'); + } + + it('closes the opened database and aborts the builder when Connection construction fails', async () => { + h.connCtor.mockImplementation(() => { + throw new Error('connection constructor failed'); + }); + + await runRejectedChild('connection constructor failed'); + + expect(h.dbClose).toHaveBeenCalledOnce(); + expect(h.connClose).not.toHaveBeenCalled(); + expect(h.abortBuilder).toHaveBeenCalledOnce(); + expect(h.query).not.toHaveBeenCalled(); + expect(h.dbCtor.mock.calls[0][7]).toBe(true); + }); + + it('aborts the builder when Database construction fails', async () => { + h.dbCtor.mockImplementation(() => { + throw new Error('database constructor failed'); + }); + + await runRejectedChild('database constructor failed'); + + expect(h.abortBuilder).toHaveBeenCalledOnce(); + expect(h.dbClose).not.toHaveBeenCalled(); + expect(h.connCtor).not.toHaveBeenCalled(); + }); + + it('rejects output and closes the database when Connection close fails', async () => { + h.connClose.mockRejectedValue(new Error('connection close failed')); + + await runRejectedChild('connection close failed'); + + expect(h.dbClose).toHaveBeenCalled(); + expect(h.abortBuilder).toHaveBeenCalledOnce(); + }); + + it('rejects output when Database close fails', async () => { + h.dbClose.mockRejectedValue(new Error('database close failed')); + + await runRejectedChild('database close failed'); + + expect(h.connClose).toHaveBeenCalled(); + expect(h.abortBuilder).toHaveBeenCalledOnce(); + }); + + it('confines writable replay and failed checkpoint close to a separate copied family', async () => { + const sourceBefore = seedCompleteFamily(); + h.dbCtor.mockImplementation((openedPath: string) => { + for (const suffix of suffixes) { + expect(fs.readFileSync(openedPath + suffix, 'utf8')).toBe(sourceBefore[suffix]); + fs.writeFileSync(openedPath + suffix, `replayed ${suffix}`); + } + }); + h.dbClose.mockImplementation(async () => { + const openedPath = h.dbCtor.mock.calls[0][0] as string; + fs.writeFileSync(openedPath, 'partial checkpoint'); + throw new Error('checkpoint close failed'); + }); + + await import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + await vi.waitFor(() => expect(process.exitCode).toBe(1)); + + expect(h.dbCtor.mock.calls[0][0]).not.toBe(dbPath); + expect(path.relative(exportDir, h.dbCtor.mock.calls[0][0] as string)).not.toMatch(/^\.\./); + expect(sourceFamily()).toEqual(sourceBefore); + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + expect(process.stderr.write).toHaveBeenCalledWith('checkpoint close failed\n'); + }); + + it('reclaims a timed-out writer copy without changing the retained source', async () => { + const sourceBefore = seedCompleteFamily(); + h.query.mockReturnValue(new Promise(() => {})); + h.dbCtor.mockImplementation((openedPath: string) => { + for (const suffix of suffixes) fs.writeFileSync(openedPath + suffix, 'writer opened'); + }); + let childImport: Promise | undefined; + const child = Object.assign(new EventEmitter(), { + stderr: new EventEmitter(), + kill: vi.fn(() => { + queueMicrotask(() => child.emit('close', null, 'SIGKILL')); + return true; + }), + }); + h.spawn.mockImplementation((_command: string, args: string[]) => { + process.argv = [process.execPath, 'staged-embedding-recovery-child', ...args.slice(-3)]; + childImport = import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + return child; + }); + const { recoverStagedEmbeddings } = + await import('../../src/core/embeddings/staged-embedding-recovery.js'); + vi.useFakeTimers(); + const recovering = expect( + recoverStagedEmbeddings(dbPath, { dimensions: 2, timeoutMs: 500 }), + ).rejects.toThrow(/timeout/); + await childImport; + expect(h.dbCtor).toHaveBeenCalledOnce(); + const openedPath = h.dbCtor.mock.calls[0][0] as string; + + await vi.advanceTimersByTimeAsync(500); + await recovering; + + expect(child.kill).toHaveBeenCalledWith('SIGKILL'); + expect(openedPath).not.toBe(dbPath); + expect(fs.existsSync(path.dirname(openedPath))).toBe(false); + expect(sourceFamily()).toEqual(sourceBefore); + expect(h.dbClose).not.toHaveBeenCalled(); + }); + + it.each(['.wal', '.checkpoint.intent.lock', '.checkpoint.apply.lock'])( + 'refuses a dangling family symlink before native open: %s', + async (suffix) => { + fs.rmSync(dbPath + suffix, { force: true }); + fs.symlinkSync(path.join(tmp, 'missing-sidecar'), dbPath + suffix); + + await import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + await vi.waitFor(() => expect(process.exitCode).toBe(1)); + + expect(h.dbCtor).not.toHaveBeenCalled(); + expect(process.stderr.write).toHaveBeenCalledWith( + 'staged embedding family is not a regular file\n', + ); + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + }, + ); + + it('refuses a family entry replaced with a symlink between lstat and open', async () => { + const foreignPath = path.join(tmp, 'foreign-file'); + fs.writeFileSync(foreignPath, 'foreign'); + const open = fs.openSync; + const read = vi.spyOn(fs, 'readSync'); + vi.spyOn(fs, 'openSync').mockImplementation((...args) => { + if (args[0] === dbPath) { + fs.rmSync(dbPath); + fs.symlinkSync(foreignPath, dbPath); + } + return open(...args); + }); + + await import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + await vi.waitFor(() => expect(process.exitCode).toBe(1)); + + expect(read).not.toHaveBeenCalled(); + expect(h.dbCtor).not.toHaveBeenCalled(); + expect(fs.readFileSync(foreignPath, 'utf8')).toBe('foreign'); + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + }); + + it('fails closed when copying the complete family runs out of space', async () => { + const sourceBefore = seedCompleteFamily(); + vi.spyOn(fs, 'writeFileSync').mockImplementation(() => { + throw Object.assign(new Error('copy ran out of space'), { code: 'ENOSPC' }); + }); + + await import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + await vi.waitFor(() => expect(process.exitCode).toBe(1)); + + expect(h.dbCtor).not.toHaveBeenCalled(); + expect(sourceFamily()).toEqual(sourceBefore); + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + expect(process.stderr.write).toHaveBeenCalledWith('copy ran out of space\n'); + }); + + it('writes the manifest only after both native closes succeed', async () => { + const closed: string[] = []; + h.connClose.mockImplementation(async () => { + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + closed.push('connection'); + }); + h.dbClose.mockImplementation(async () => { + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + closed.push('database'); + }); + + await import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + await vi.waitFor(() => expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(true)); + + expect(closed).toEqual(['connection', 'database']); + expect(h.abortBuilder).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + expect(fs.readFileSync(`${dbPath}.wal`, 'utf8')).toBe('retained WAL'); + }); +}); diff --git a/gitnexus/test/unit/staged-embedding-recovery.test.ts b/gitnexus/test/unit/staged-embedding-recovery.test.ts new file mode 100644 index 000000000..552e48dc5 --- /dev/null +++ b/gitnexus/test/unit/staged-embedding-recovery.test.ts @@ -0,0 +1,95 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + createCachedEmbeddingsBuilder, + disposeEmbeddingSpill, + finalizeCachedEmbeddingsSnapshot, + ingestCachedEmbeddingRow, + materializeCachedEmbeddings, + type CachedEmbeddingsSnapshot, +} from '../../src/core/embeddings/embedding-restore-spill.js'; +import { + mergeRecoveredEmbeddings, + validateRecoveredNodeGroups, +} from '../../src/core/embeddings/staged-embedding-recovery.js'; + +describe('staged embedding recovery', () => { + const snapshots: CachedEmbeddingsSnapshot[] = []; + let tmp: string | undefined; + afterEach(() => { + for (const snapshot of snapshots) disposeEmbeddingSpill(snapshot.spill); + snapshots.length = 0; + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); + tmp = undefined; + }); + + function snapshot( + rows: { nodeId: string; chunkIndex: number; contentHash?: string; embedding?: number[] }[], + ) { + tmp ??= fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-stage-test-')); + const builder = createCachedEmbeddingsBuilder({ inMemoryRowLimit: 0, spillDir: tmp }); + for (const row of rows) { + ingestCachedEmbeddingRow( + builder, + { startLine: 1, endLine: 2, embedding: [1, 2], ...row }, + true, + ); + } + const result = finalizeCachedEmbeddingsSnapshot(builder); + snapshots.push(result); + return result; + } + + it('accepts only complete groups with one content hash and unique contiguous chunk ordinals', () => { + const cached = snapshot([ + { nodeId: 'complete', chunkIndex: 1, contentHash: 'same' }, + { nodeId: 'complete', chunkIndex: 0, contentHash: 'same' }, + { nodeId: 'gap', chunkIndex: 1, contentHash: 'same' }, + { nodeId: 'duplicate', chunkIndex: 0, contentHash: 'same' }, + { nodeId: 'duplicate', chunkIndex: 0, contentHash: 'same' }, + { nodeId: 'mixed', chunkIndex: 0, contentHash: 'old' }, + { nodeId: 'mixed', chunkIndex: 1, contentHash: 'new' }, + { nodeId: 'no-hash', chunkIndex: 0 }, + { nodeId: 'unsafe', chunkIndex: 0, contentHash: 'same' }, + ]); + expect([...validateRecoveredNodeGroups(cached.rows, new Set(['unsafe']))]).toEqual([ + 'complete', + ]); + }); + + it('replaces an entire published node group and keeps unrelated rows without retaining vector arrays', () => { + const live = snapshot([ + { nodeId: 'changed', chunkIndex: 0, contentHash: 'old' }, + { nodeId: 'changed', chunkIndex: 1, contentHash: 'old' }, + { nodeId: 'other', chunkIndex: 0, contentHash: 'other' }, + ]); + const recovered = snapshot([ + { nodeId: 'changed', chunkIndex: 0, contentHash: 'new', embedding: [7, 8] }, + ]); + const merged = mergeRecoveredEmbeddings(live, recovered); + snapshots.push(merged); + expect(merged.embeddings).toEqual([]); + expect(merged.rows).toHaveLength(2); + expect(materializeCachedEmbeddings(merged, merged.rows)).toEqual([ + expect.objectContaining({ nodeId: 'other', contentHash: 'other' }), + expect.objectContaining({ + nodeId: 'changed', + chunkIndex: 0, + contentHash: 'new', + embedding: [7, 8], + }), + ]); + expect(fs.existsSync(live.spill.path)).toBe(true); + expect(fs.existsSync(recovered.spill.path)).toBe(true); + }); + + it('does not accept missing vector bytes during a merge', () => { + const cached = snapshot([{ nodeId: 'complete', chunkIndex: 0, contentHash: 'same' }]); + fs.truncateSync(cached.spill.path, 12); + expect(() => mergeRecoveredEmbeddings(snapshot([]), cached)).toThrow( + /short embedding spill read/, + ); + }); +}); diff --git a/gitnexus/test/unit/staleness-fallback.test.ts b/gitnexus/test/unit/staleness-fallback.test.ts index 427c52b40..baed8e13b 100644 --- a/gitnexus/test/unit/staleness-fallback.test.ts +++ b/gitnexus/test/unit/staleness-fallback.test.ts @@ -3,7 +3,7 @@ * OTHER than a timeout. `staleness.test.ts` reaches `diverged` and `unknown` * against real repositories, but not the third arm of `fromHead`: HEAD still * resolves to the indexed commit, so the index is at HEAD however `rev-list` - * failed. Real git cannot fail `..HEAD` while HEAD prints that same SHA + * failed. Real git cannot fail `...HEAD` while HEAD prints that same SHA * without a corrupted object store, so this drives it through a mock. * * Its own file for the same reason as `staleness-timeout.test.ts`: the mock @@ -12,14 +12,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { plan, spawnedArgs } = vi.hoisted(() => ({ - plan: { head: null as string | null }, + plan: { head: null as string | null, enoent: false }, spawnedArgs: [] as string[][], })); // `rev-list` exits 128 the way git does for a missing object; `rev-parse HEAD` -// answers `plan.head`, or fails when it is null. +// answers `plan.head`, or fails when it is null. `plan.enoent` instead fails +// every invocation the way Node reports a missing executable (git not on +// PATH) — no exit code, `code: 'ENOENT'` — to check that shape of failure is +// caught by the same `catch` as a present-but-failing git (#3127). const answer = (args: readonly string[]): { error: Error | null; stdout: string } => { spawnedArgs.push([...args]); + if (plan.enoent) { + const failure = Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' }); + return { error: failure, stdout: '' }; + } if (args[0] === 'rev-parse' && plan.head) return { error: null, stdout: `${plan.head}\n` }; const failure = Object.assign(new Error(`Command failed: git ${args.join(' ')}`), { code: 128, @@ -54,7 +61,7 @@ vi.mock('node:child_process', async (importOriginal) => { import { checkStaleness, checkStalenessAsync } from '../../src/core/git-staleness.js'; const INDEXED_COMMIT = 'a'.repeat(40); -const REV_LIST = ['rev-list', '--count', `${INDEXED_COMMIT}..HEAD`]; +const REV_LIST = ['rev-list', '--left-right', '--count', `${INDEXED_COMMIT}...HEAD`]; const REV_PARSE = ['rev-parse', 'HEAD']; const bothHelpers = { @@ -65,6 +72,7 @@ const bothHelpers = { describe('staleness after a failed (not timed-out) rev-list (#3256)', () => { beforeEach(() => { plan.head = null; + plan.enoent = false; spawnedArgs.length = 0; }); @@ -95,6 +103,19 @@ describe('staleness after a failed (not timed-out) rev-list (#3256)', () => { expect(result).toEqual({ isStale: false, commitsBehind: 0, status: 'unknown' }); expect(spawnedArgs).toEqual([REV_LIST, REV_PARSE]); }); + + it('reports unknown — never current — when git itself is not on PATH', async () => { + // nikolai-vysotskyi (#3127): "git not on PATH" specifically, as a + // distinct failure shape (ENOENT, no exit code) from a present git + // that fails against a bad ref. Same requirement either way: it must + // never be silently read as fresh. + plan.enoent = true; + + const result = await check('/repo', INDEXED_COMMIT); + + expect(result).toEqual({ isStale: false, commitsBehind: 0, status: 'unknown' }); + expect(spawnedArgs).toEqual([REV_LIST, REV_PARSE]); + }); }); } }); diff --git a/gitnexus/test/unit/staleness-timeout.test.ts b/gitnexus/test/unit/staleness-timeout.test.ts index 1b80df5b8..6f4670135 100644 --- a/gitnexus/test/unit/staleness-timeout.test.ts +++ b/gitnexus/test/unit/staleness-timeout.test.ts @@ -43,6 +43,8 @@ describe('checkStalenessAsync — timed-out rev-list (#3256)', () => { expect(result).toEqual({ status: 'unknown', isStale: false, commitsBehind: 0 }); // Exactly one spawn. A `rev-parse HEAD` follow-up here is the regression: // it doubles the hung-mount bound and can flip this answer. - expect(spawnedArgs).toEqual([['rev-list', '--count', `${INDEXED_COMMIT}..HEAD`]]); + expect(spawnedArgs).toEqual([ + ['rev-list', '--left-right', '--count', `${INDEXED_COMMIT}...HEAD`], + ]); }); }); diff --git a/gitnexus/test/unit/staleness-zero-count.test.ts b/gitnexus/test/unit/staleness-zero-count.test.ts new file mode 100644 index 000000000..4e2b510b7 --- /dev/null +++ b/gitnexus/test/unit/staleness-zero-count.test.ts @@ -0,0 +1,162 @@ +/** + * Count both sides in one Git process to distinguish freshness from rollback + * without mixing HEAD snapshots (#3127). + * Keep the child-process mock isolated from tests that use real repositories. + */ +import type { ExecFileOptions } from 'node:child_process'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { plan, invocations } = vi.hoisted(() => ({ + plan: { counts: '0\t0\n' as string | null, head: 'failure', advanceHead: false }, + invocations: [] as { args: string[]; options: ExecFileOptions }[], +})); + +const answer = (args: readonly string[], options: ExecFileOptions): string => { + invocations.push({ args: [...args], options }); + if (args[0] === 'rev-list') { + if (plan.counts === null) { + throw Object.assign(new Error('Command failed: git rev-list'), { code: 128, killed: false }); + } + // Simulate a checkout/commit after Git measured the relationship. A second + // process would see this different HEAD and could misclassify the result. + if (plan.advanceHead) plan.head = 'b'.repeat(40); + return plan.counts; + } + if (plan.head === 'empty') return ' \n'; + if (plan.head === 'timeout') { + throw Object.assign(new Error('Command failed: git rev-parse HEAD'), { + killed: true, + signal: 'SIGTERM', + }); + } + if (plan.head === 'failure') { + throw Object.assign(new Error('Command failed: git rev-parse HEAD'), { + code: 128, + killed: false, + }); + } + return `${plan.head}\n`; +}; + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + const { promisify } = await import('node:util'); + const execFile = ( + _file: string, + args: readonly string[], + options: ExecFileOptions, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ): void => { + try { + callback(null, answer(args, options), ''); + } catch (error) { + callback(error as Error, '', ''); + } + }; + // Real execFile has a custom promisifier returning both output streams. + Object.defineProperty(execFile, promisify.custom, { + value: async (_file: string, args: readonly string[], options: ExecFileOptions) => ({ + stdout: answer(args, options), + stderr: '', + }), + }); + const execFileSync = (_file: string, args: readonly string[], options: ExecFileOptions): string => + answer(args, options); + return { + ...actual, + execFile: execFile as unknown as typeof actual.execFile, + execFileSync: execFileSync as unknown as typeof actual.execFileSync, + }; +}); + +import { checkStaleness, checkStalenessAsync } from '../../src/core/git-staleness.js'; + +const INDEXED_COMMIT = 'a'.repeat(40); +const REV_LIST = ['rev-list', '--left-right', '--count', `${INDEXED_COMMIT}...HEAD`]; +const REV_PARSE = ['rev-parse', 'HEAD']; + +const bothHelpers = { + checkStaleness: async (repo: string, lastCommit: string) => checkStaleness(repo, lastCommit), + checkStalenessAsync, +}; + +describe('staleness from one relationship query (#3127)', () => { + beforeEach(() => { + plan.counts = '0\t0\n'; + plan.head = 'failure'; + plan.advanceHead = false; + invocations.length = 0; + }); + + for (const [name, check] of Object.entries(bothHelpers)) { + describe(name, () => { + it.each([ + { counts: '0\t0\n', status: 'current', isStale: false, commitsBehind: 0 }, + { counts: '2\t0\n', status: 'diverged', isStale: true, commitsBehind: 0 }, + { counts: '0\t3\n', status: 'behind', isStale: true, commitsBehind: 3 }, + { counts: '2\t3\n', status: 'behind', isStale: true, commitsBehind: 3 }, + ])('reports $status from $counts in one process', async ({ counts, ...expected }) => { + plan.counts = counts; + + const result = await check('/repo', INDEXED_COMMIT); + + expect(result).toMatchObject(expected); + expect(invocations.map(({ args }) => args)).toEqual([REV_LIST]); + }); + + it('keeps the count snapshot when HEAD advances after the query', async () => { + plan.head = INDEXED_COMMIT; + plan.advanceHead = true; + + const result = await check('/repo', INDEXED_COMMIT); + + expect(plan.head).not.toBe(INDEXED_COMMIT); + expect(result).toEqual({ status: 'current', isStale: false, commitsBehind: 0 }); + expect(invocations.map(({ args }) => args)).toEqual([REV_LIST]); + }); + + it.each(['', '0\n', '0\tbogus\n'])( + 'reports unknown for malformed counts %j', + async (counts) => { + plan.counts = counts; + + const result = await check('/repo', INDEXED_COMMIT); + + expect(result).toEqual({ status: 'unknown', isStale: false, commitsBehind: 0 }); + expect(invocations.map(({ args }) => args)).toEqual([REV_LIST]); + }, + ); + + it('reports unknown when the follow-up HEAD command fails', async () => { + plan.counts = null; + const result = await check('/repo', INDEXED_COMMIT); + + expect(result).toEqual({ status: 'unknown', isStale: false, commitsBehind: 0 }); + expect(invocations.map(({ args }) => args)).toEqual([REV_LIST, REV_PARSE]); + }); + + it('reports unknown when the follow-up HEAD command returns no commit', async () => { + plan.counts = null; + plan.head = 'empty'; + + const result = await check('/repo', INDEXED_COMMIT); + + expect(result).toEqual({ status: 'unknown', isStale: false, commitsBehind: 0 }); + expect(invocations.map(({ args }) => args)).toEqual([REV_LIST, REV_PARSE]); + }); + + it('bounds the HEAD command and reports unknown without retrying after its timeout', async () => { + plan.counts = null; + plan.head = 'timeout'; + + const result = await check('/repo', INDEXED_COMMIT); + + expect(result).toEqual({ status: 'unknown', isStale: false, commitsBehind: 0 }); + expect(invocations.map(({ args }) => args)).toEqual([REV_LIST, REV_PARSE]); + const timeout = invocations[1].options.timeout; + expect(Number.isFinite(timeout)).toBe(true); + expect(timeout).toBeGreaterThan(0); + }); + }); + } +}); diff --git a/gitnexus/test/unit/staleness.test.ts b/gitnexus/test/unit/staleness.test.ts index ef4a6a517..0e6084d7e 100644 --- a/gitnexus/test/unit/staleness.test.ts +++ b/gitnexus/test/unit/staleness.test.ts @@ -335,3 +335,52 @@ describe('branch-pinned serve clone once git prunes the indexed commit (#3256)', }); }); }); + +// ── #3127: a 0 forward count is not "the index matches this tree" ─────────── +// +// nikolai-vysotskyi (issue #3127, comment on the `--stale-policy` proposal): +// `git rev-list --count lastCommit..HEAD` answers "commits reachable from +// HEAD but not lastCommit", which is also 0 when HEAD is an *ancestor* of +// lastCommit — i.e. the working tree checked out an older commit than the one +// indexed, or a release branch behind the indexed tip. Before this fix that +// read as `current`; a `--stale-policy error`-style caller would exit 0 +// exactly when the index is provably wrong about the checked-out tree. +describe('staleness when the checkout has regressed behind the indexed commit (#3127)', () => { + let root: string; + let fixture: ReturnType; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'gitnexus-staleness-regressed-')); + fixture = makeRepo(root, 'repo'); + // Roll the working tree back to c1. `rev-list --count c3..HEAD` alone + // answers 0 here (HEAD/c1 has no commits c3 lacks) — exactly the count a + // pre-fix caller read as "index matches HEAD". + git(fixture.repo, 'checkout', '-q', fixture.c1); + }); + afterAll(() => removeTree(root)); + + for (const [name, check] of Object.entries(bothHelpers)) { + describe(name, () => { + it('reports diverged, not current, when HEAD is behind the indexed commit', async () => { + const result = await check(fixture.repo, fixture.c3); + + expect(result.status).toBe('diverged'); + // Established by a positive indexed-only count and a zero HEAD-only + // count (not a `rev-list` failure), so — unlike the failure-path + // `diverged` above — `isStale` reflects the mismatch instead of the + // historical fail-open `false`. + expect(result.isStale).toBe(true); + expect(result.commitsBehind).toBe(0); + expect(result.hint).toContain('not reachable from the checked-out commit'); + }); + + it('still reports current for the commit actually checked out', async () => { + expect(await check(fixture.repo, fixture.c1)).toMatchObject({ + status: 'current', + isStale: false, + commitsBehind: 0, + }); + }); + }); + } +}); diff --git a/gitnexus/test/unit/storage/shared-store.test.ts b/gitnexus/test/unit/storage/shared-store.test.ts index 0ca91f9d6..9be52ef0a 100644 --- a/gitnexus/test/unit/storage/shared-store.test.ts +++ b/gitnexus/test/unit/storage/shared-store.test.ts @@ -133,7 +133,7 @@ describe('resolveSharedStoreKey', () => { [{ [SHARED_STORE_ENV]: '0' }], [{ [STORAGE_PATH_ENV]: '/tmp/explicit-index' }], [{ [STORAGE_ROOT_ENV]: '/tmp/index-root' }], - ])('returns null for every checkout when disabled by %o', async (env) => { + ])('returns null for every checkout when disabled by %o (case %#)', async (env) => { const { main, wts } = await makeRepo(['wt']); expect(isSharedStoreDisabled(env)).toBe(true); expect(resolveSharedStoreKey(main, env)).toBeNull(); diff --git a/gitnexus/test/unit/stream-graph-emit-config.test.ts b/gitnexus/test/unit/stream-graph-emit-config.test.ts index 878698b8c..96c901806 100644 --- a/gitnexus/test/unit/stream-graph-emit-config.test.ts +++ b/gitnexus/test/unit/stream-graph-emit-config.test.ts @@ -1,3 +1,4 @@ +import type { GraphNode } from 'gitnexus-shared'; /** * Streamed structural graph emit — config gate and pruner integration (#2680). * @@ -98,7 +99,7 @@ describe('buildPhaseList under streamGraphEmit', () => { // The sink answers a complete relationship read, so these phases work // unchanged. If this ever regresses to filtering them out, streaming can no // longer be the default. - const streamed = names({ streamGraphEmit: true, pdg: true, force: true }); + const streamed = names({ streamGraphEmit: true, pdg: true }); expect(streamed).toContain('communities'); expect(streamed).toContain('processes'); @@ -107,7 +108,7 @@ describe('buildPhaseList under streamGraphEmit', () => { }); it('keeps mro and di, whose reads are all in the retained set', () => { - const streamed = names({ streamGraphEmit: true, pdg: true, force: true }); + const streamed = names({ streamGraphEmit: true, pdg: true }); expect(streamed).toContain('mro'); expect(streamed).toContain('di'); @@ -119,7 +120,7 @@ describe('buildPhaseList under streamGraphEmit', () => { it('leaves the phase list untouched when the flag is off', () => { // Guards the default path: the gating predicates must not filter anything // for existing (flag-off) users. - const withPdg = names({ pdg: true, force: true }); + const withPdg = names({ pdg: true }); expect(withPdg).toContain('communities'); expect(withPdg).toContain('processes'); diff --git a/gitnexus/test/unit/symbol-identity-validation.test.ts b/gitnexus/test/unit/symbol-identity-validation.test.ts new file mode 100644 index 000000000..230f78a9b --- /dev/null +++ b/gitnexus/test/unit/symbol-identity-validation.test.ts @@ -0,0 +1,234 @@ +/** Regression for unusable database lookup identities (#3424). */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { lbugMocks } = vi.hoisted(() => ({ + lbugMocks: { + initLbug: vi.fn().mockResolvedValue(undefined), + executeQuery: vi.fn().mockResolvedValue([]), + executeParameterized: vi.fn().mockResolvedValue([]), + closeLbug: vi.fn().mockResolvedValue(undefined), + isLbugReady: vi.fn().mockReturnValue(true), + }, +})); + +vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ...lbugMocks }; +}); + +vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ...lbugMocks }; +}); + +vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listRegisteredRepos: vi.fn().mockResolvedValue([ + { + name: 'test-project', + path: '/tmp/test-project', + storagePath: '/tmp/.gitnexus/test-project', + indexedAt: '2024-06-01T12:00:00Z', + lastCommit: 'abc123', + stats: { files: 10, nodes: 50, edges: 100, communities: 3, processes: 5 }, + }, + ]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock('../../src/core/git-staleness.js', () => ({ + checkStaleness: vi.fn().mockReturnValue({ isStale: false, commitsBehind: 0 }), + checkStalenessAsync: vi.fn().mockResolvedValue({ isStale: false, commitsBehind: 0 }), + checkCwdMatch: vi.fn().mockResolvedValue({ match: 'none' }), +})); + +vi.mock('../../src/storage/git.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getGitRoot: vi.fn().mockReturnValue(null) }; +}); + +vi.mock('../../src/core/search/bm25-index.js', () => ({ + searchFTSFromLbug: vi.fn().mockResolvedValue({ results: [], ftsAvailable: true }), +})); + +vi.mock('../../src/mcp/core/embedder.js', () => ({ + embedQuery: vi.fn().mockResolvedValue([]), + getEmbeddingDims: vi.fn().mockReturnValue(384), +})); + +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { executeParameterized } from '../../src/mcp/core/lbug-adapter.js'; + +const SYMBOL = { + id: 'Method:tests/test_supervisor.py:Supervisor.test_run', + name: 'test_run', + type: 'Method', + filePath: 'tests/test_supervisor.py', + startLine: 3, + endLine: 5, +}; + +const badIds = [ + ['missing', undefined], + ['null', null], + ['number', 42], + ['empty', ''], + ['blank', ' \t '], + ['NUL-only', '\0\0'], + ['embedded NUL', 'Method:tests/test_supervisor.py:\0test_run'], +] as const; + +function row(id: unknown, shape: string): unknown { + return shape === 'tuple' + ? [id, SYMBOL.name, SYMBOL.type, SYMBOL.filePath, SYMBOL.startLine, SYMBOL.endLine] + : { ...SYMBOL, id }; +} + +const surfaces = [ + { tool: 'context', params: { name: SYMBOL.name, file_path: SYMBOL.filePath } }, + { tool: 'impact', params: { target: SYMBOL.name, direction: 'upstream' } }, + { tool: 'impact', params: { target: SYMBOL.name, direction: 'upstream', mode: 'pdg' } }, +] as const; + +describe('symbol lookup identity validation (#3424)', () => { + let backend: LocalBackend; + + beforeEach(async () => { + vi.clearAllMocks(); + backend = new LocalBackend(); + await backend.init(); + (backend as any).ensureInitialized = vi.fn().mockResolvedValue(undefined); + }); + + function lookupRows(rows: unknown[]) { + vi.mocked(executeParameterized).mockImplementation(async (_db, _query, params) => { + return params?.symName || params?.uid ? rows : []; + }); + } + + function expectNoExpansion() { + const queries = vi.mocked(executeParameterized).mock.calls.map(([, query]) => query); + expect(queries.length).toBeGreaterThan(0); + expect(queries.every((query) => !query.includes('CodeRelation'))).toBe(true); + expect(queries.every((query) => !query.includes('UNION'))).toBe(true); + } + + function expectIdentityError(result: any, tool: string) { + expect(result.error).toMatch(/symbol identity/i); + expect(result.recoverySuggestion).toMatch(/analyze.*--force/); + expect(result.epistemic).not.toBe('exact'); + expect(result).not.toHaveProperty('symbol'); + expect(result).not.toHaveProperty('incoming'); + if (tool === 'impact') { + expect(result.risk).toBe('UNKNOWN'); + expect(result.impactedCount).toBeNull(); + expect(result.target).not.toHaveProperty('id'); + expect(result.suggestion ?? '').not.toContain('context'); + } + expectNoExpansion(); + } + + for (const surface of surfaces) { + describe(`${surface.tool} ${'mode' in surface.params ? surface.params.mode : 'default'}`, () => { + for (const shape of ['object', 'tuple']) { + it.each(badIds)(`rejects %s IDs in ${shape} rows before traversal`, async (_label, id) => { + lookupRows([row(id, shape)]); + const result = await backend.callTool(surface.tool, surface.params); + expectIdentityError(result, surface.tool); + }); + it.each(badIds)( + `rejects %s IDs from exact UID lookups in ${shape} rows`, + async (_label, id) => { + lookupRows([row(id, shape)]); + const uidParam = + surface.tool === 'context' ? { uid: SYMBOL.id } : { target_uid: SYMBOL.id }; + expectIdentityError( + await backend.callTool(surface.tool, { ...surface.params, ...uidParam }), + surface.tool, + ); + }, + ); + } + + it('does not choose a healthy candidate beside a corrupt candidate', async () => { + lookupRows([SYMBOL, { ...SYMBOL, id: '\0', type: '' }]); + expectIdentityError(await backend.callTool(surface.tool, surface.params), surface.tool); + }); + + it('rejects a different identity returned for an exact UID', async () => { + lookupRows([{ ...SYMBOL, id: 'Constructor:Services.swift:Service.init' }]); + const uidParam = + surface.tool === 'context' ? { uid: SYMBOL.id } : { target_uid: SYMBOL.id }; + expectIdentityError( + await backend.callTool(surface.tool, { ...surface.params, ...uidParam }), + surface.tool, + ); + }); + + it('keeps an empty lookup distinct from an invalid identity', async () => { + lookupRows([]); + const result = await backend.callTool(surface.tool, surface.params); + expect(result.error).toMatch(/not found/); + expect(result.recoverySuggestion).toBeUndefined(); + }); + }); + } + + it('validates every row before exact File narrowing', async () => { + lookupRows([ + { ...SYMBOL, id: 'File:tests/test_supervisor.py', name: 'test_supervisor.py' }, + { ...SYMBOL, id: undefined }, + ]); + expectIdentityError(await backend.callTool('context', { name: SYMBOL.filePath }), 'context'); + }); + + for (const shape of ['object', 'tuple']) { + it(`accepts an opaque legacy identity in a healthy ${shape} row`, async () => { + lookupRows([row('func:alpha', shape)]); + const result = await backend.callTool('context', { uid: 'func:alpha' }); + expect(result).not.toHaveProperty('error'); + expect(result.symbol.uid).toBe('func:alpha'); + }); + } + + it('preserves a valid ID byte-for-byte instead of trimming it', async () => { + lookupRows([{ ...SYMBOL, id: 'func:alpha ' }]); + const result = await backend.callTool('context', { name: SYMBOL.name }); + expect(result).not.toHaveProperty('error'); + expect(result.symbol.uid).toBe('func:alpha '); + }); + + describe('group impact UID adapter', () => { + const opts = { maxDepth: 3, relationTypes: ['CALLS'], minConfidence: 0, includeTests: true }; + + it.each(badIds)('rejects a %s persisted ID before BFS', async (_label, id) => { + lookupRows([{ ...SYMBOL, id }]); + const bfs = vi.spyOn(backend as any, '_runImpactBFS').mockResolvedValue({ byDepth: {} }); + const repoId = [...(backend as any).repos.keys()][0]; + expect(await backend.impactByUid(repoId, SYMBOL.id, 'upstream', opts)).toBeNull(); + expect(bfs).not.toHaveBeenCalled(); + }); + + it('rejects an otherwise valid mismatched UID', async () => { + lookupRows([{ ...SYMBOL, id: 'route:other' }]); + const bfs = vi.spyOn(backend as any, '_runImpactBFS').mockResolvedValue({ byDepth: {} }); + const repoId = [...(backend as any).repos.keys()][0]; + expect(await backend.impactByUid(repoId, SYMBOL.id, 'upstream', opts)).toBeNull(); + expect(bfs).not.toHaveBeenCalled(); + }); + + it('accepts a legitimate synthetic identity', async () => { + lookupRows([{ ...SYMBOL, id: 'Route:svc:/health' }]); + const bfs = vi.spyOn(backend as any, '_runImpactBFS').mockResolvedValue({ byDepth: {} }); + const repoId = [...(backend as any).repos.keys()][0]; + expect(await backend.impactByUid(repoId, 'Route:svc:/health', 'upstream', opts)).toEqual({ + byDepth: {}, + }); + expect(bfs).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/gitnexus/test/unit/taint/path-codec.test.ts b/gitnexus/test/unit/taint/path-codec.test.ts index a7b891844..70005df8e 100644 --- a/gitnexus/test/unit/taint/path-codec.test.ts +++ b/gitnexus/test/unit/taint/path-codec.test.ts @@ -29,7 +29,7 @@ function unescapeCSVField(cell: string): string { const roundTrip = (hops: readonly TaintPathHopInput[]) => { const { reason } = encodeTaintPath(hops); const decoded = decodeTaintPath(reason); - if (!decoded.ok) throw new Error(`decode failed: ${decoded.error}`); + if (decoded.ok === false) throw new Error(`decode failed: ${decoded.error}`); return { reason, decoded }; }; @@ -293,7 +293,7 @@ describe('typed parse failures (never a throw)', () => { it(`fails typed on ${label}`, () => { const decoded = decodeTaintPath(input); expect(decoded.ok).toBe(false); - if (!decoded.ok) expect(decoded.error.length).toBeGreaterThan(0); + if (decoded.ok === false) expect(decoded.error.length).toBeGreaterThan(0); }); } diff --git a/gitnexus/test/unit/taint/propagate.test.ts b/gitnexus/test/unit/taint/propagate.test.ts index a1bb61e3d..3ab469240 100644 --- a/gitnexus/test/unit/taint/propagate.test.ts +++ b/gitnexus/test/unit/taint/propagate.test.ts @@ -36,8 +36,15 @@ import { computeTaintFlows, type FunctionTaintResult, type TaintLimits, + type TaintSourceOccurrence, } from '../../../src/core/ingestion/taint/propagate.js'; +function sourceProperty(source: TaintSourceOccurrence): string { + expect(source.type).toBe('member-read'); + if (source.type !== 'member-read') throw new Error('Expected a member-read source'); + return source.property; +} + /** The single binding index for `name` (throws when shadowed/ambiguous). */ function bindingIdx(cfg: FunctionCfg, name: string): number { const idxs = (cfg.bindings ?? []).map((b, i) => (b.name === name ? i : -1)).filter((i) => i >= 0); @@ -153,7 +160,7 @@ describe('rule (b) — statement-local findings', () => { expect(r.findings).toHaveLength(1); const f = r.findings[0]; expect(f.sinkKind).toBe('command-injection'); - expect(f.source.property).toBe('body'); + expect(sourceProperty(f.source)).toBe('body'); expect(f.source.point.line).toBe(2); expect(f.sink.point.line).toBe(2); expect(f.sink.argIndex).toBe(0); @@ -168,14 +175,14 @@ describe('rule (b) — statement-local findings', () => { }; const r = analyze(`function f(req) { exec(req.body, req.query); }`, { spec }); expect(r.findings).toHaveLength(2); - const ids = r.findings.map((f) => `${f.source.property}@arg${f.sink.argIndex}`); + const ids = r.findings.map((f) => `${sourceProperty(f.source)}@arg${f.sink.argIndex}`); expect(ids).toEqual(['body@arg0', 'query@arg1']); }); it('exec(req.body.toString()) → finding via the statement-local rule', () => { const r = analyze(`function f(req) { exec(req.body.toString()); }`); expect(r.findings).toHaveLength(1); - expect(r.findings[0].source.property).toBe('body'); + expect(sourceProperty(r.findings[0].source)).toBe('body'); }); it('a source read at an UNREGISTERED sink position produces no finding', () => { @@ -194,7 +201,7 @@ describe('rule (a) — worklist over def→use facts', () => { }`); expect(r.findings).toHaveLength(1); expect(hopSummary(r)).toEqual(['b@2', 'b@3']); - expect(r.findings[0].source.property).toBe('body'); + expect(sourceProperty(r.findings[0].source)).toBe('body'); expect(r.findings[0].sink.point.line).toBe(3); }); @@ -491,7 +498,7 @@ describe('multi-source identity — distinct sources do not merge at one def', ( spec: MULTI, }); expect(r.findings).toHaveLength(2); - const props = r.findings.map((f) => f.source.property).sort(); + const props = r.findings.map((f) => sourceProperty(f.source)).sort(); expect(props).toEqual(['body', 'query']); }); @@ -691,7 +698,7 @@ describe('caps — deterministic truncation (R6 substrate)', () => { expect(r.findings).toHaveLength(2); expect(r.droppedFindings).toBe(2); // deterministic prefix of the sorted order: statement order - expect(r.findings.map((f) => f.source.property)).toEqual(['body', 'query']); + expect(r.findings.map((f) => sourceProperty(f.source))).toEqual(['body', 'query']); }); it('without a cap all findings emit and droppedFindings is 0', () => { diff --git a/gitnexus/test/unit/taint/taint-emit.test.ts b/gitnexus/test/unit/taint/taint-emit.test.ts index c6d8f734a..dda9e1cb9 100644 --- a/gitnexus/test/unit/taint/taint-emit.test.ts +++ b/gitnexus/test/unit/taint/taint-emit.test.ts @@ -189,7 +189,7 @@ function handler(req: { body: string; query: string }) { expect(new Set(tainted.map((t) => t.id)).size).toBe(2); const variables = tainted.map((t) => { const d = decodeTaintPath(t.reason); - if (!d.ok) throw new Error(d.error); + if (d.ok === false) throw new Error(d.error); return d.hops[d.hops.length - 1].variable; }); expect(variables.sort()).toEqual(['a', 'b']); diff --git a/gitnexus/test/unit/test-completeness.test.ts b/gitnexus/test/unit/test-completeness.test.ts new file mode 100644 index 000000000..93cf5466f --- /dev/null +++ b/gitnexus/test/unit/test-completeness.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { reconcileTestReports } from '../../scripts/test-completeness.js'; + +const report = (name: string, statuses: string[]) => ({ + success: !statuses.includes('failed'), + executionErrors: 0, + executionReason: 'passed', + testResults: [ + { + name, + status: statuses.includes('failed') ? 'failed' : 'passed', + assertionResults: statuses.map((status, i) => ({ + ancestorTitles: ['contract'], + title: `case ${i}`, + fullName: `contract case ${i}`, + status, + location: { line: i + 1, column: 1 }, + })), + }, + ], +}); + +describe('required test execution across CI jobs', () => { + it('fails when a collected test never executes, including todo', () => { + const result = reconcileTestReports([ + report('/home/runner/work/GitNexus/GitNexus/gitnexus/test/unit/a.test.ts', [ + 'passed', + 'pending', + 'todo', + ]), + ]); + expect(result.total).toBe(3); + expect(result.passed).toBe(1); + expect(result.unverified).toHaveLength(2); + }); + + it('accepts a platform skip only with an actual pass from another job', () => { + const result = reconcileTestReports([ + report('/home/runner/work/GitNexus/GitNexus/gitnexus/test/unit/a.test.ts', [ + 'passed', + 'pending', + ]), + report('D:\\a\\GitNexus\\GitNexus\\gitnexus\\test\\unit\\a.test.ts', ['pending', 'passed']), + ]); + expect(result.total).toBe(2); + expect(result.passed).toBe(2); + expect(result.unverified).toEqual([]); + expect(result.failures).toEqual([]); + }); + + it('never lets a passing job erase a failure on another platform', () => { + const result = reconcileTestReports([ + report('/repo/gitnexus/test/unit/a.test.ts', ['failed']), + report('/repo/gitnexus/test/unit/a.test.ts', ['passed']), + ]); + expect(result.failures.some((message) => message.includes('contract case 0'))).toBe(true); + }); + + it('counts failed tests separately from tests that never executed', () => { + const result = reconcileTestReports([ + report('/repo/gitnexus/test/unit/a.test.ts', ['passed', 'failed', 'pending']), + ]); + expect(result.report.numPassedTests).toBe(1); + expect(result.report.numFailedTests).toBe(1); + expect(result.report.numPendingTests).toBe(1); + expect(result.unverified).toHaveLength(1); + expect(result.report.success).toBe(false); + }); + + it('does not collapse identical titles from different files or repeated cases', () => { + const repeated = report('/repo/gitnexus/test/unit/a.test.ts', ['passed', 'pending']); + repeated.testResults[0].assertionResults[1].title = 'case 0'; + repeated.testResults[0].assertionResults[1].fullName = 'contract case 0'; + const result = reconcileTestReports([ + repeated, + report('/repo/gitnexus/test/unit/b.test.ts', ['passed']), + ]); + expect(result.total).toBe(3); + expect(result.unverified).toHaveLength(1); + }); + + it('matches source locations when repeated titles are collected in a different order', () => { + const first = report('/repo/gitnexus/test/unit/a.test.ts', ['pending', 'passed']); + first.testResults[0].assertionResults[1].title = 'case 0'; + first.testResults[0].assertionResults[1].fullName = 'contract case 0'; + const second = structuredClone(first); + second.testResults[0].assertionResults.reverse(); + const result = reconcileTestReports([first, second]); + expect(result.total).toBe(2); + expect(result.unverified).toHaveLength(1); + }); + + it('rejects ambiguous parameterized cases with the same name and location', () => { + const input = report('/repo/gitnexus/test/unit/a.test.ts', ['passed']); + input.testResults[0].assertionResults.push({ ...input.testResults[0].assertionResults[0] }); + expect(() => reconcileTestReports([input])).toThrow(/ambiguous/i); + }); + + it('requires an unambiguous title and a real pass for helper-generated tests without locations', () => { + const input = report('/repo/gitnexus/test/unit/a.test.ts', ['pending']); + Reflect.deleteProperty(input.testResults[0].assertionResults[0], 'location'); + expect(reconcileTestReports([input]).unverified).toHaveLength(1); + const passing = structuredClone(input); + passing.testResults[0].assertionResults[0].status = 'passed'; + expect(reconcileTestReports([input, passing]).report.success).toBe(true); + passing.testResults[0].assertionResults.push({ ...passing.testResults[0].assertionResults[0] }); + expect(() => reconcileTestReports([passing])).toThrow(/ambiguous/i); + }); + + it('reconciles source-adjacent web tests on Linux and Windows', () => { + const result = reconcileTestReports( + [ + report('/repo/gitnexus-web/src/lib/upload-filter.test.ts', ['passed']), + report('C:/repo/gitnexus-web/src/lib/upload-filter.test.ts', ['passed']), + ], + ['src/lib/upload-filter.test.ts'], + 'gitnexus-web', + ); + expect(result.report.success).toBe(true); + expect(result.total).toBe(1); + expect(() => + reconcileTestReports( + [report('/repo/gitnexus-web/test/unit/a.test.ts', ['passed'])], + ['test/unit/a.test.ts', 'src/lib/upload-filter.test.ts'], + 'gitnexus-web', + ), + ).toThrow(/never collected/); + }); + + it('requires every shard receipt and the full web inventory through the CLI', () => { + const temp = mkdtempSync(path.join(tmpdir(), 'gitnexus-completeness-cli-')); + const packageRoot = fileURLToPath(new URL('../../', import.meta.url)); + const root = path.join(temp, 'gitnexus'); + const webRoot = path.join(temp, 'gitnexus-web'); + const receipts = path.join(temp, 'receipts'); + const output = path.join(temp, 'combined.json'); + for (const directory of [ + path.join(root, 'scripts'), + path.join(root, 'test/unit'), + path.join(webRoot, 'src/lib'), + receipts, + ]) { + mkdirSync(directory, { recursive: true }); + } + const script = path.join(root, 'scripts/test-completeness.ts'); + copyFileSync(path.join(packageRoot, 'scripts/test-completeness.ts'), script); + writeFileSync(path.join(temp, 'package.json'), '{"type":"module"}'); + writeFileSync(path.join(root, 'test/unit/a.test.ts'), '// inventory fixture'); + writeFileSync(path.join(webRoot, 'src/lib/a.test.ts'), '// inventory fixture'); + const core = report(path.join(root, 'test/unit/a.test.ts'), ['passed']); + const web = report(path.join(webRoot, 'src/lib/a.test.ts'), ['passed']); + const webReport = path.join(webRoot, 'web-test-results.json'); + for (const name of [ + 'coverage', + 'benchmarks', + 'preflight', + 'windows-latest-1', + 'windows-latest-2', + 'macos-latest-1', + 'macos-latest-2', + ]) { + writeFileSync(path.join(receipts, `${name}.json`), JSON.stringify(core)); + } + const run = () => { + writeFileSync(webReport, JSON.stringify(web)); + return spawnSync(process.execPath, ['--import', 'tsx', script, receipts, output, '2'], { + cwd: packageRoot, + encoding: 'utf8', + timeout: 30_000, + }); + }; + try { + const complete = run(); + expect(complete.error, complete.stderr).toBeUndefined(); + expect(complete.status, complete.stderr).toBe(0); + expect(JSON.parse(readFileSync(output, 'utf8')).numPendingTests).toBe(0); + writeFileSync(path.join(webRoot, 'src/lib/missing.test.ts'), '// missing receipt'); + const missingSource = run(); + expect(missingSource.status).not.toBe(0); + expect(missingSource.stderr).toContain( + 'Test file was never collected: src/lib/missing.test.ts', + ); + rmSync(path.join(webRoot, 'src/lib/missing.test.ts')); + rmSync(path.join(receipts, 'windows-latest-2.json')); + const missingShard = run(); + expect(missingShard.status).not.toBe(0); + expect(missingShard.stderr).toContain('windows-latest-2.json'); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it('rejects test files missing from the collected inventory', () => { + expect(() => + reconcileTestReports( + [report('/repo/gitnexus/test/unit/a.test.ts', ['passed'])], + ['test/unit/a.test.ts', 'test/unit/b.test.ts'], + ), + ).toThrow(/b.test.ts/); + }); + + it('rejects missing, empty or malformed reports instead of reporting zero skips', () => { + expect(() => reconcileTestReports([])).toThrow(); + expect(() => reconcileTestReports([{ testResults: [] }])).toThrow(); + expect(() => + reconcileTestReports([{ testResults: [{ name: 'a', assertionResults: [] }] }]), + ).toThrow(); + expect(() => + reconcileTestReports([report('/repo/gitnexus/test/unit/a.test.ts', ['unknown'])]), + ).toThrow(); + }); + + it('keeps failed collection and unhandled runner errors fatal', () => { + const broken = report('/repo/gitnexus/test/unit/a.test.ts', ['passed']); + broken.success = false; + expect(reconcileTestReports([broken]).failures).not.toHaveLength(0); + broken.success = true; + broken.executionErrors = 1; + expect(reconcileTestReports([broken]).failures).not.toHaveLength(0); + broken.executionErrors = 0; + broken.testResults[0].status = 'failed'; + expect(reconcileTestReports([broken]).failures).not.toHaveLength(0); + expect(reconcileTestReports([broken]).report.numFailedTestSuites).toBe(1); + expect(reconcileTestReports([broken]).report.testResults[0].status).toBe('failed'); + }); + + it('captures a real unhandled rejection even when Vitest exits successfully', () => { + const temp = mkdtempSync(path.join(tmpdir(), 'gitnexus-receipt-')); + const packageRoot = fileURLToPath(new URL('../../', import.meta.url)); + const root = path.join(temp, 'gitnexus'); + const output = path.join(temp, 'receipt.json'); + mkdirSync(path.join(root, 'test/unit'), { recursive: true }); + const vitestImport = pathToFileURL( + path.join(packageRoot, 'node_modules/vitest/dist/index.js'), + ).href; + writeFileSync( + path.join(root, 'test/unit/rejection.test.ts'), + ` +import { it, expect } from ${JSON.stringify(vitestImport)}; +it('executes its assertion but leaks a rejection', async () => { + expect(2 + 2).toBe(4); + setTimeout(() => { void Promise.reject(new Error('receipt rejection probe')); }, 0); + await new Promise(resolve => setTimeout(resolve, 50)); +}); +`, + ); + const config = path.join(root, 'vitest.config.mjs'); + writeFileSync( + config, + 'export default { test: { include: ["test/**/*.test.ts"], includeTaskLocation: true, dangerouslyIgnoreUnhandledErrors: true } };', + ); + try { + const child = spawnSync( + process.execPath, + [ + path.join(packageRoot, 'node_modules/vitest/vitest.mjs'), + 'run', + '--root', + root, + '--config', + config, + '--reporter', + path.join(packageRoot, 'scripts/execution-reporter.ts'), + '--outputFile', + output, + ], + { cwd: packageRoot, encoding: 'utf8', timeout: 30_000 }, + ); + expect(child.error, child.stderr).toBeUndefined(); + expect(child.status, child.stderr).toBe(0); + const receipt = JSON.parse(readFileSync(output, 'utf8')); + expect(receipt.numPassedTests).toBe(1); + expect(receipt.executionErrors).toBeGreaterThan(0); + expect(reconcileTestReports([receipt]).report.success).toBe(false); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); +}); diff --git a/gitnexus/test/unit/tool-process-linking.test.ts b/gitnexus/test/unit/tool-process-linking.test.ts index 6c536bb38..0c7203db2 100644 --- a/gitnexus/test/unit/tool-process-linking.test.ts +++ b/gitnexus/test/unit/tool-process-linking.test.ts @@ -55,41 +55,50 @@ function addCall(graph: KnowledgeGraph, sourceId: string, targetId: string) { } describe('Tool handler and process linking phases', () => { - it('falls back to the file node when a parsed tool handler is missing from the graph', async () => { - const graph = createKnowledgeGraph(); - addNode(graph, 'File:src/tools.py', 'File', 'tools.py', 'src/tools.py'); + it.each([undefined, false] as const)( + 'retains file attribution policy %s when a parsed handler is missing', + async (allowFileFallback) => { + const graph = createKnowledgeGraph(); + addNode(graph, 'File:src/tools.py', 'File', 'tools.py', 'src/tools.py'); - const output = await toolsPhase.execute( - makeCtx(graph), - new Map([ - [ - 'parse', - phaseResult('parse', { - allToolDefs: [ - { - filePath: 'src/tools.py', - toolName: 'stale_tool', - description: 'Stale handler', - lineNumber: 1, - handlerNodeId: 'Function:src/tools.py:missing', - }, - ], - allPaths: [], - }), - ], - ]), - ); + const output = await toolsPhase.execute( + makeCtx(graph), + new Map([ + [ + 'parse', + phaseResult('parse', { + allToolDefs: [ + { + filePath: 'src/tools.py', + toolName: 'stale_tool', + description: 'Stale handler', + lineNumber: 1, + handlerNodeId: 'Function:src/tools.py:missing', + ...(allowFileFallback === false ? { allowFileFallback } : {}), + }, + ], + allPaths: [], + }), + ], + ]), + ); - expect(output.toolDefs).toEqual([ - { name: 'stale_tool', filePath: 'src/tools.py', description: 'Stale handler' }, - ]); + expect(output.toolDefs).toEqual([ + { + name: 'stale_tool', + filePath: 'src/tools.py', + description: 'Stale handler', + ...(allowFileFallback === false ? { allowFileFallback } : {}), + }, + ]); - const edge = graph.relationships.find((rel) => rel.type === 'HANDLES_TOOL'); - expect(edge).toMatchObject({ - sourceId: 'File:src/tools.py', - targetId: 'Tool:stale_tool', - }); - }); + const edge = graph.relationships.find((rel) => rel.type === 'HANDLES_TOOL'); + expect(edge).toMatchObject({ + sourceId: 'File:src/tools.py', + targetId: 'Tool:stale_tool', + }); + }, + ); it('does not attach file-level fallback tools to handler-specific processes', async () => { const graph = createKnowledgeGraph(); @@ -110,6 +119,7 @@ describe('Tool handler and process linking phases', () => { addNode(graph, fileLeaf, 'Function', 'fileLeaf', filePath); addNode(graph, 'Tool:alpha', 'Tool', 'alpha', filePath); addNode(graph, 'Tool:fallback_tool', 'Tool', 'fallback_tool', filePath); + addNode(graph, 'Tool:unresolved_tool', 'Tool', 'unresolved_tool', filePath); addCall(graph, alpha, alphaHelper); addCall(graph, alphaHelper, alphaLeaf); addCall(graph, fileEntry, fileHelper); @@ -117,7 +127,7 @@ describe('Tool handler and process linking phases', () => { await processesPhase.execute( makeCtx(graph), - new Map([ + new Map>([ ['structure', phaseResult('structure', { totalFiles: 1 })], ['communities', phaseResult('communities', { communityResult: { memberships: [] } })], ['routes', phaseResult('routes', { routeRegistry: new Map() })], @@ -127,6 +137,7 @@ describe('Tool handler and process linking phases', () => { toolDefs: [ { name: 'alpha', filePath, description: '', handlerNodeId: alpha }, { name: 'fallback_tool', filePath, description: '' }, + { name: 'unresolved_tool', filePath, description: '', allowFileFallback: false }, ], }), ], @@ -147,5 +158,6 @@ describe('Tool handler and process linking phases', () => { expect(linkedEntriesByTool.get('Tool:alpha')).toEqual([alpha]); expect(linkedEntriesByTool.get('Tool:fallback_tool')).toEqual([fileEntry]); + expect(linkedEntriesByTool.has('Tool:unresolved_tool')).toBe(false); }); }); diff --git a/gitnexus/test/unit/trace-bfs.test.ts b/gitnexus/test/unit/trace-bfs.test.ts index c76ef9a01..bf287ec1c 100644 --- a/gitnexus/test/unit/trace-bfs.test.ts +++ b/gitnexus/test/unit/trace-bfs.test.ts @@ -17,12 +17,12 @@ const { lbugMocks } = vi.hoisted(() => ({ })); vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, ...lbugMocks }; }); diff --git a/gitnexus/test/unit/trpc-entry-point-scoring.test.ts b/gitnexus/test/unit/trpc-entry-point-scoring.test.ts index 03b1de78c..f6a81558e 100644 --- a/gitnexus/test/unit/trpc-entry-point-scoring.test.ts +++ b/gitnexus/test/unit/trpc-entry-point-scoring.test.ts @@ -1,3 +1,4 @@ +import { SupportedLanguages } from 'gitnexus-shared'; import { describe, it, expect } from 'vitest'; import { calculateEntryPointScore } from '../../src/core/ingestion/entry-point-scoring.js'; @@ -5,7 +6,7 @@ describe('tRPC entry-point scoring', () => { it('scores a T3 router procedure higher than the same name on a utility file', () => { const t3 = calculateEntryPointScore( 'setSettings', - 'typescript', + SupportedLanguages.TypeScript, true, 0, 3, @@ -13,7 +14,7 @@ describe('tRPC entry-point scoring', () => { ); const util = calculateEntryPointScore( 'setSettings', - 'typescript', + SupportedLanguages.TypeScript, true, 0, 3, @@ -28,7 +29,14 @@ describe('tRPC entry-point scoring', () => { it('still applies utility-pattern to non-accessor helpers in a T3 router file', () => { const routerPath = 'src/server/api/routers/settings.ts'; for (const name of ['formatDate', '_internal', 'parseInput'] as const) { - const result = calculateEntryPointScore(name, 'typescript', true, 0, 3, routerPath); + const result = calculateEntryPointScore( + name, + SupportedLanguages.TypeScript, + true, + 0, + 3, + routerPath, + ); expect(result.reasons).toContain('utility-pattern'); expect(result.reasons).toContain('framework:trpc-router'); } @@ -37,7 +45,7 @@ describe('tRPC entry-point scoring', () => { it('skips utility-pattern for accessor procedures on a T3 JavaScript router', () => { const result = calculateEntryPointScore( 'setSettings', - 'javascript', + SupportedLanguages.JavaScript, true, 0, 3, @@ -50,7 +58,7 @@ describe('tRPC entry-point scoring', () => { it('still applies utility-pattern to non-accessor helpers on a T3 JavaScript router', () => { const result = calculateEntryPointScore( 'formatDate', - 'javascript', + SupportedLanguages.JavaScript, true, 0, 3, @@ -63,7 +71,7 @@ describe('tRPC entry-point scoring', () => { it('does not crash on a .js router path whose framework detection returns null', () => { const jsResult = calculateEntryPointScore( 'settingsRouter', - 'javascript', + SupportedLanguages.JavaScript, true, 0, 3, diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index 785c1ec83..a472ce12e 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { buildTypeEnv, type TypeEnvironment } from '../../src/core/ingestion/type-env.js'; import { BindingAccumulator } from '../../src/core/ingestion/binding-accumulator.js'; -import { type SymbolDefinition } from 'gitnexus-shared'; +import { SupportedLanguages, type SymbolDefinition } from 'gitnexus-shared'; import { createSemanticModel, type SemanticModel, @@ -29,7 +29,7 @@ let Dart: unknown; try { Dart = requireVendoredGrammar('tree-sitter-dart'); const testParser = new Parser(); - testParser.setLanguage(Dart as Parser.Language); + testParser.setLanguage(Dart); } catch { Dart = null; } @@ -38,27 +38,27 @@ let Swift: unknown; try { Swift = requireVendoredGrammar('tree-sitter-swift'); const testParser = new Parser(); - testParser.setLanguage(Swift as Parser.Language); + testParser.setLanguage(Swift); } catch { Swift = null; } const parser = new Parser(); -const parse = (code: string, lang: any) => { +const parse = (code: string, lang: unknown) => { parser.setLanguage(lang); return parser.parse(code); }; const parseDart = (code: string) => { if (!Dart) throw new Error('tree-sitter-dart not available'); - parser.setLanguage(Dart as Parser.Language); + parser.setLanguage(Dart); return parser.parse(code); }; const parseSwift = (code: string) => { if (!Swift) throw new Error('tree-sitter-swift not available'); - parser.setLanguage(Swift as Parser.Language); + parser.setLanguage(Swift); return parser.parse(code); }; @@ -95,56 +95,56 @@ describe('buildTypeEnv', () => { describe('TypeScript', () => { it('extracts type from const declaration', () => { const tree = parse('const user: User = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); it('extracts type from let declaration', () => { const tree = parse('let repo: Repository;', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'repo')).toBe('Repository'); }); it('extracts type from function parameters', () => { const tree = parse('function save(user: User, repo: Repository) {}', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(flatGet(typeEnv, 'repo')).toBe('Repository'); }); it('extracts type from arrow function parameters', () => { const tree = parse('const fn = (user: User) => user.save();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); it('ignores variables without type annotations', () => { const tree = parse('const x = 5; let y = "hello";', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatSize(typeEnv)).toBe(0); }); it('extracts type from nullable union User | null', () => { const tree = parse('const user: User | null = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); it('extracts type from optional union User | undefined', () => { const tree = parse('let user: User | undefined;', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); it('extracts type from triple nullable union User | null | undefined', () => { const tree = parse('const user: User | null | undefined = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); it('ignores non-nullable unions like User | Repo', () => { const tree = parse('const entity: User | Repo = getEntity();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'entity')).toBeUndefined(); }); }); @@ -162,7 +162,7 @@ describe('buildTypeEnv', () => { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(flatGet(typeEnv, 'repo')).toBe('Repository'); }); @@ -176,7 +176,7 @@ describe('buildTypeEnv', () => { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(flatGet(typeEnv, 'repo')).toBe('Repository'); }); @@ -190,7 +190,7 @@ describe('buildTypeEnv', () => { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -207,7 +207,7 @@ describe('buildTypeEnv', () => { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -222,7 +222,7 @@ describe('buildTypeEnv', () => { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -235,7 +235,7 @@ describe('buildTypeEnv', () => { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(flatGet(typeEnv, 'repo')).toBe('Repository'); }); @@ -254,7 +254,7 @@ describe('buildTypeEnv', () => { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -270,7 +270,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -284,7 +284,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -298,7 +298,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -312,7 +312,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(flatGet(typeEnv, 'repo')).toBe('Repo'); }); @@ -327,7 +327,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -341,7 +341,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); @@ -355,7 +355,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'sl')).toBe('User'); }); @@ -369,7 +369,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'm')).toBe('User'); }); @@ -384,7 +384,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -399,7 +399,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -411,7 +411,7 @@ describe('buildTypeEnv', () => { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); // Go parameter extraction depends on tree-sitter grammar structure // Parameters may or may not have 'name'/'type' fields }); @@ -427,7 +427,7 @@ describe('buildTypeEnv', () => { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -438,7 +438,7 @@ describe('buildTypeEnv', () => { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(flatGet(typeEnv, 'repo')).toBe('Repository'); }); @@ -452,7 +452,7 @@ describe('buildTypeEnv', () => { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -460,19 +460,19 @@ describe('buildTypeEnv', () => { describe('Python', () => { it('extracts type from annotated assignment (PEP 484)', () => { const tree = parse('user: User = get_user()', Python); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBe('User'); }); it('extracts type from standalone annotation without value (file scope)', () => { const tree = parse('active_user: User', Python); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'active_user')).toBe('User'); }); it('extracts type from function parameters', () => { const tree = parse('def process(user: User, repo: Repository): pass', Python); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); // Python uses typed_parameter nodes, check if they match }); @@ -484,7 +484,7 @@ describe('buildTypeEnv', () => { `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'name')).toBe('str'); expect(flatGet(typeEnv, 'age')).toBe('int'); }); @@ -496,7 +496,7 @@ describe('buildTypeEnv', () => { `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'repo')).toBe('UserRepo'); }); @@ -512,7 +512,7 @@ describe('buildTypeEnv', () => { `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'name')).toBe('str'); expect(flatGet(typeEnv, 'age')).toBe('int'); expect(flatGet(typeEnv, 'repo')).toBe('UserRepo'); @@ -533,7 +533,7 @@ def process(x): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'u')).toBe('User'); }); @@ -551,7 +551,7 @@ def process(x): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); // u is already bound from the annotation, pattern binding should not overwrite expect(flatGet(typeEnv, 'u')).toBe('User'); }); @@ -574,7 +574,7 @@ def process(x): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'r')).toBe('Repo'); }); @@ -590,7 +590,7 @@ def process(x): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); // No class_pattern child — should return undefined expect(flatGet(typeEnv, 'n')).toBeUndefined(); }); @@ -607,7 +607,7 @@ def process(x): `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -620,7 +620,7 @@ def process(x): `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -633,7 +633,7 @@ def process(x): `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -644,7 +644,7 @@ def process(x): `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(flatGet(typeEnv, 'repo')).toBe('Repository'); }); @@ -661,7 +661,7 @@ def process(x): `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -677,7 +677,7 @@ def process(x): `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -690,7 +690,7 @@ def process(x): `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); // PHP parameter type extraction expect(flatGet(typeEnv, '$user')).toBe('User'); expect(flatGet(typeEnv, '$repo')).toBe('Repository'); @@ -704,7 +704,7 @@ class UserService { } }`; const tree = parse(code, PHP.php); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); // Find the call node ($this->save()) const calls: any[] = []; @@ -731,7 +731,7 @@ class User { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$repo')).toBe('UserRepo'); }); @@ -744,7 +744,7 @@ class UserService { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$repo')).toBe('UserRepo'); }); @@ -757,7 +757,7 @@ class UserService { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$name')).toBe('string'); }); @@ -774,7 +774,7 @@ function create($repo, $name) { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$repo')).toBe('UserRepo'); expect(flatGet(typeEnv, '$name')).toBe('string'); }); @@ -792,7 +792,7 @@ function process($repo, $name) { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$repo')).toBe('UserRepo'); expect(flatGet(typeEnv, '$name')).toBe('string'); }); @@ -812,7 +812,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(flatGet(typeEnv, 'repo')).toBe('UserRepo'); expect(flatGet(typeEnv, 'name')).toBe('String'); }); @@ -826,7 +826,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -839,7 +839,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(flatGet(typeEnv, 'name')).toBe('String'); }); @@ -852,7 +852,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(flatGet(typeEnv, 'value')).toBeUndefined(); }); @@ -865,7 +865,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(flatSize(typeEnv)).toBe(0); }); @@ -881,7 +881,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(flatGet(typeEnv, 'name')).toBe('String'); }); @@ -894,7 +894,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(flatGet(typeEnv, 'users')).toBe('Array'); }); }); @@ -912,7 +912,7 @@ class User extends BaseModel { } }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const calls: any[] = []; function findCalls(node: any) { @@ -942,7 +942,7 @@ class User extends BaseModel { } }`; const tree = parse(code, Java); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); const calls: any[] = []; function findCalls(node: any) { @@ -968,7 +968,7 @@ class User(BaseModel): return True `; const tree = parse(code, Python); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); const calls: any[] = []; function findCalls(node: any) { @@ -991,7 +991,7 @@ class Standalone { } }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // No calls in this code — test the resolution function directly // by using the class body as the context node @@ -1009,7 +1009,7 @@ object AppConfig { } }`; const tree = parse(code, Kotlin); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); const calls: any[] = []; function findCalls(node: any) { @@ -1036,7 +1036,7 @@ object AppConfig { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Each function has its own scope for 'user' (keyed by funcName@startIndex) // Find the scope keys that start with handleUser/handleRepo @@ -1058,7 +1058,7 @@ function handleRepo(user: Repo) { user.save(); }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Find the call nodes inside each function const calls: any[] = []; @@ -1090,7 +1090,7 @@ class RepoService { } }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Find the call nodes inside each process method const calls: any[] = []; @@ -1120,7 +1120,7 @@ class RepoService { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // config is at file-level scope const fileScope = typeEnv.fileScope(); @@ -1150,7 +1150,7 @@ class RepoService { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // The typed variable is captured expect(flatGet(typeEnv, 'user')).toBe('User'); // Destructured bindings (name, email) would need type inference to resolve @@ -1168,53 +1168,32 @@ class RepoService { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Complex type annotation (object type) — extractSimpleTypeName returns undefined expect(flatSize(typeEnv)).toBe(0); }); }); describe('destructured call results', () => { - // Minimal mock SemanticModel for call-result return type lookup - // (SM-21 inversion — buildTypeEnv takes a SemanticModel via `model:`). - const makeSymbolTable = (callables: Array<{ name: string; returnType?: string }>) => ({ - types: { - lookupClassByName: () => [], - lookupClassByQualifiedName: () => [], - lookupImplByName: () => [], - }, - methods: { - lookupMethodByOwner: () => undefined, - lookupMethodByName: () => [], - }, - fields: { - lookupFieldByOwner: () => undefined, - }, - symbols: { - add: () => {}, - lookupExact: () => undefined, - lookupExactFull: () => undefined, - lookupExactAll: () => [], - lookupCallableByName: (name: string) => - callables - .filter((c) => c.name === name) - .map((c) => ({ - nodeId: 'n1', - filePath: 'src.ts', - type: 'Function' as const, - returnType: c.returnType, - })), - getFiles: () => [][Symbol.iterator](), - getStats: () => ({ fileCount: 0 }), - }, - }); + // Register only the callable return types needed by these fixtures. + const makeSymbolTable = ( + callables: Array<{ name: string; returnType?: string }>, + ): SemanticModel => { + const model = createSemanticModel(); + for (const [index, callable] of callables.entries()) { + model.symbols.add('src.ts', callable.name, `function:${index}`, 'Function', { + returnType: callable.returnType, + }); + } + return model; + }; it('emits callResult + fieldAccess items for const { x } = fn()', () => { const symbolTable = makeSymbolTable([{ name: 'getUser', returnType: 'User' }]); const tree = parse('const { name } = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { model: symbolTable }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model: symbolTable }); // callResult resolves __destr_getUser_N → User - // fieldAccess resolves name via User's properties (no Property nodes in mock → undefined) + // fieldAccess resolves name via User's properties (no Property nodes in model → undefined) // But the callResult itself IS emitted — verify constructorBindings is still empty expect(typeEnv.constructorBindings).toEqual([]); }); @@ -1225,14 +1204,14 @@ class RepoService { 'async function f() { const { data } = await fetchData(); }', TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript', { model: symbolTable }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model: symbolTable }); expect(typeEnv.constructorBindings).toEqual([]); }); it('gracefully handles no return type (composable without annotation)', () => { const symbolTable = makeSymbolTable([{ name: 'useUserRole' }]); // no returnType const tree = parse('const { isMaker } = useUserRole();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { model: symbolTable }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model: symbolTable }); // No return type → callResult unresolved → fieldAccess unresolved expect(flatGet(typeEnv, 'isMaker')).toBeUndefined(); }); @@ -1240,7 +1219,7 @@ class RepoService { it('resolves destructured properties when return type has declared fields', () => { // importedReturnTypes provides the return type since no real SymbolTable const tree = parse('const { name } = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedReturnTypes: new Map([['getUser', 'User']]), }); // callResult resolves __destr_getUser_N → User via importedReturnTypes @@ -1264,7 +1243,7 @@ class RepoService { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // repo is resolved to Repo via Tier 1 (constructor inference) expect(flatGet(typeEnv, 'repo')).toBe('Repo'); // Destructured method call emits methodCallResult + fieldAccess @@ -1274,7 +1253,7 @@ class RepoService { it('handles renamed destructuring: const { address: addr } = fn()', () => { const tree = parse('const { address: addr } = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedReturnTypes: new Map([['getUser', 'User']]), }); // 'addr' should be the binding, not 'address' @@ -1294,53 +1273,53 @@ class RepoService { describe('TypeScript', () => { it('infers type from new expression when no annotation', () => { const tree = parse('const user = new User();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); it('prefers explicit annotation over constructor inference', () => { const tree = parse('const user: BaseUser = new User();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('BaseUser'); }); it('infers from namespaced constructor: new ns.Service()', () => { // extractSimpleTypeName handles member_expression via property_identifier const tree = parse('const svc = new ns.Service();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'svc')).toBe('Service'); }); it('infers type from new expression with as cast', () => { const tree = parse('const x = new User() as BaseUser;', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Unwraps as_expression to find the inner new_expression → User expect(flatGet(typeEnv, 'x')).toBe('User'); }); it('infers type from new expression with non-null assertion', () => { const tree = parse('const x = new User()!;', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Unwraps non_null_expression to find the inner new_expression → User expect(flatGet(typeEnv, 'x')).toBe('User'); }); it('infers type from double-cast (new X() as unknown as T)', () => { const tree = parse('const x = new User() as unknown as Admin;', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Unwraps nested as_expression to find inner new_expression → User expect(flatGet(typeEnv, 'x')).toBe('User'); }); it('ignores non-new assignments', () => { const tree = parse('const x = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatSize(typeEnv)).toBe(0); }); it('handles mixed annotated + unannotated declarators', () => { const tree = parse('const a: A = getA(), b = new B();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'a')).toBe('A'); expect(flatGet(typeEnv, 'b')).toBe('B'); }); @@ -1358,7 +1337,7 @@ class RepoService { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1373,7 +1352,7 @@ class RepoService { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1388,7 +1367,7 @@ class RepoService { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'x')).toBeUndefined(); }); }); @@ -1403,7 +1382,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1416,7 +1395,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'config')).toBe('Config'); }); @@ -1429,7 +1408,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); // ::default() should be excluded from scanConstructorBinding just like ::new() // extractInitializer already resolves it, so a scanner binding would be redundant const defaultBinding = typeEnv.constructorBindings.find((b) => b.calleeName === 'default'); @@ -1445,7 +1424,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); const newBinding = typeEnv.constructorBindings.find((b) => b.calleeName === 'new'); expect(newBinding).toBeUndefined(); }); @@ -1460,7 +1439,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('BaseUser'); }); @@ -1473,7 +1452,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1489,7 +1468,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'instance')).toBe('User'); }); @@ -1505,7 +1484,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'cfg')).toBe('Config'); }); @@ -1518,7 +1497,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'x')).toBeUndefined(); }); @@ -1531,7 +1510,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); @@ -1544,7 +1523,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1557,7 +1536,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'config')).toBe('Config'); }); @@ -1570,7 +1549,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('BaseUser'); }); @@ -1586,7 +1565,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'fresh')).toBe('User'); }); @@ -1599,7 +1578,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'x')).toBeUndefined(); }); }); @@ -1616,7 +1595,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1631,7 +1610,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1646,7 +1625,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'item')).toBe('Config'); }); @@ -1661,7 +1640,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); // Option is unwrapped to "User" in TypeEnv via NULLABLE_WRAPPER_TYPES. // extractPatternBinding maps `user` → "User" from the scopeEnv lookup for `opt`. expect(flatGet(typeEnv, 'user')).toBe('User'); @@ -1678,7 +1657,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); // 'name' is a field of User — we don't know its type without field-type resolution expect(flatGet(typeEnv, 'name')).toBeUndefined(); // 'val' should still be extracted from the parameter annotation @@ -1696,7 +1675,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); // scoped_type_identifier: Message::Data — extractSimpleTypeName returns "Data" expect(flatGet(typeEnv, 'msg')).toBe('Data'); }); @@ -1712,7 +1691,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); // Option unwraps to User (nullable wrapper unwrapping) expect(flatGet(typeEnv, 'opt')).toBe('User'); expect(flatGet(typeEnv, 'user')).toBe('User'); @@ -1729,7 +1708,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); // opt: Option → scopeEnv stores "User" (NULLABLE_WRAPPER_TYPES unwrapping) // extractPatternBinding maps user → opt's type → "User" expect(flatGet(typeEnv, 'user')).toBe('User'); @@ -1746,7 +1725,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); // unknown_var is not in scopeEnv — conservative, produces no binding expect(flatGet(typeEnv, 'x')).toBeUndefined(); }); @@ -1762,7 +1741,7 @@ class RepoService { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); // SomeOtherVariant is not a known unwrap wrapper — no binding expect(flatGet(typeEnv, 'x')).toBeUndefined(); }); @@ -1782,7 +1761,7 @@ class RepoService { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1797,7 +1776,7 @@ class RepoService { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); // No pattern variable — b gets its declared type 'boolean', not 'User' expect(flatGet(typeEnv, 'b')).toBe('boolean'); }); @@ -1818,7 +1797,7 @@ class RepoService { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(flatGet(typeEnv, 'repo')).toBe('Repo'); }); @@ -1832,7 +1811,7 @@ class RepoService { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$user')).toBe('User'); }); @@ -1848,7 +1827,7 @@ class RepoService { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$a')).toBe('Foo'); expect(flatGet(typeEnv, '$b')).toBe('Foo'); }); @@ -1865,7 +1844,7 @@ class RepoService { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$p')).toBe('Bar'); }); @@ -1876,7 +1855,7 @@ class RepoService { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$a')).toBeUndefined(); }); @@ -1887,7 +1866,7 @@ class RepoService { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$user')).toBeUndefined(); }); }); @@ -1902,7 +1881,7 @@ class RepoService { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1916,7 +1895,7 @@ class RepoService { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1929,7 +1908,7 @@ class RepoService { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1944,7 +1923,7 @@ class RepoService { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); // getUser is an identifier but NOT a known class — no inference expect(flatGet(typeEnv, 'x')).toBeUndefined(); }); @@ -1959,7 +1938,7 @@ class RepoService { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -1973,7 +1952,7 @@ class RepoService { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'cfg')).toBe('Config'); }); @@ -1987,7 +1966,7 @@ class RepoService { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -2002,7 +1981,7 @@ class RepoService { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -2016,7 +1995,7 @@ class RepoService { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -2030,7 +2009,7 @@ class RepoService { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); // getUser is not a class name — should NOT produce a binding expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); @@ -2047,7 +2026,7 @@ class RepoService { // User is NOT defined in this file, but SemanticModel knows it's a Class const model = createSemanticModel(); model.symbols.add('models.kt', 'User', 'n1', 'Class'); - const typeEnv = buildTypeEnv(tree, 'kotlin', { model }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin, { model }); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -2061,7 +2040,7 @@ class RepoService { Kotlin, ); const model = createSemanticModel(); - const typeEnv = buildTypeEnv(tree, 'kotlin', { model }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin, { model }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); }); @@ -2075,7 +2054,7 @@ class RepoService { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); // Tier 0 (explicit annotation) wins over Tier 1 (constructor inference) expect(flatGet(typeEnv, 'user')).toBe('BaseEntity'); }); @@ -2093,7 +2072,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -2108,7 +2087,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); // extractSimpleTypeName extracts "User" from attribute node "models.User" expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -2121,7 +2100,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); }); @@ -2145,7 +2124,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python, { model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.py')], }), @@ -2161,7 +2140,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python, { model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); @@ -2175,7 +2154,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python, { model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.py')], }), @@ -2192,7 +2171,7 @@ void run() { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus, { model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.h')], }), @@ -2209,7 +2188,7 @@ void run() { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus, { model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); @@ -2224,7 +2203,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby, { model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models/user.rb')], }), @@ -2241,7 +2220,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby, { model: makeClassLookupTable({ UserService: [createClassDef('UserService', 'Class', 'models/user_service.rb')], }), @@ -2258,7 +2237,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby, { model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); @@ -2273,7 +2252,7 @@ void run() { } `, ); - const typeEnv = buildTypeEnv(tree, 'dart', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Dart, { model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.dart')], }), @@ -2289,7 +2268,7 @@ void run() { } `, ); - const typeEnv = buildTypeEnv(tree, 'dart', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Dart, { model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'models.dart')], }), @@ -2305,7 +2284,7 @@ void run() { } `, ); - const typeEnv = buildTypeEnv(tree, 'dart', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Dart, { model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); @@ -2321,7 +2300,7 @@ fn run() { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust, { model: makeClassLookupTable({ UserService: [createClassDef('UserService', 'Struct', 'models.rs')], }), @@ -2338,7 +2317,7 @@ fn run() { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust, { model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'value')).toBeUndefined(); @@ -2353,7 +2332,7 @@ func run() { } `, ); - const typeEnv = buildTypeEnv(tree, 'swift', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Swift, { model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'Models/User.swift')], }), @@ -2369,7 +2348,7 @@ func run() { } `, ); - const typeEnv = buildTypeEnv(tree, 'swift', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Swift, { model: makeClassLookupTable({ User: [createClassDef('User', 'Class', 'Models/User.swift')], }), @@ -2385,7 +2364,7 @@ func run() { } `, ); - const typeEnv = buildTypeEnv(tree, 'swift', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Swift, { model: makeClassLookupTable({}), }); expect(flatGet(typeEnv, 'result')).toBeUndefined(); @@ -2407,7 +2386,7 @@ function process(user: User) { ownerId: 'class:User', declaredType: 'Address', }); - const typeEnv = buildTypeEnv(tree, 'typescript', { model }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model }); expect(flatGet(typeEnv, 'addr')).toBe('Address'); }); @@ -2421,7 +2400,7 @@ function process(user: User) { TypeScript.typescript, ); const model = createSemanticModel(); - const typeEnv = buildTypeEnv(tree, 'typescript', { model }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model }); expect(flatGet(typeEnv, 'addr')).toBeUndefined(); }); @@ -2441,7 +2420,7 @@ function process(repo: Repo) { returnType: 'Profile', }); const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); - const typeEnv = buildTypeEnv(tree, 'typescript', { model }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2463,7 +2442,7 @@ function process(repo: Repo) { returnType: 'Profile', }); const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); @@ -2488,7 +2467,7 @@ function process(repo: Repo) { returnType: 'Profile', }); const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); - const typeEnv = buildTypeEnv(tree, 'typescript', { model }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2511,7 +2490,7 @@ function process(repo: Repo) { returnType: 'Profile', }); const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); @@ -2540,7 +2519,7 @@ function process(repo: Repo) { returnType: 'Profile', }); const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); - const typeEnv = buildTypeEnv(tree, 'typescript', { model }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model }); expect(flatGet(typeEnv, 'profile')).toBeUndefined(); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2565,7 +2544,7 @@ function process(repo: Repo) { returnType: 'Profile', }); const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); - const typeEnv = buildTypeEnv(tree, 'typescript', { model }); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model }); expect(flatGet(typeEnv, 'profile')).toBe('Profile'); expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile'); }); @@ -2595,7 +2574,7 @@ function process(repo: Repo) { returnType: 'Admin', }); const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); @@ -2624,7 +2603,7 @@ function process(repo: Repo) { returnType: 'Profile', }); const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); @@ -2657,7 +2636,7 @@ function process(repo: Repo) { returnType: 'Admin', }); const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName'); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model, parentMap: new Map([['Repo', ['BaseRepo']]]), }); @@ -2679,7 +2658,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -2692,7 +2671,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'data')).toBeUndefined(); }); }); @@ -2701,7 +2680,7 @@ def main(): describe('edge cases', () => { it('returns empty map for code without type annotations', () => { const tree = parse('const x = 5;', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatSize(typeEnv)).toBe(0); }); @@ -2713,7 +2692,7 @@ def main(): `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Both declarations are at file level; last one wins expect(flatGet(typeEnv, 'x')).toBeDefined(); }); @@ -2732,7 +2711,7 @@ class User extends BaseModel { } }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const calls: any[] = []; function findCalls(node: any) { @@ -2759,7 +2738,7 @@ class User extends BaseModel { } }`; const tree = parse(code, Java); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); const calls: any[] = []; function findCalls(node: any) { @@ -2785,7 +2764,7 @@ class User(Model): super().save() `; const tree = parse(code, Python); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); const calls: any[] = []; function findCalls(node: any) { @@ -2811,7 +2790,7 @@ class User : BaseModel { } }`; const tree = parse(code, CSharp); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); const calls: any[] = []; function findCalls(node: any) { @@ -2840,7 +2819,7 @@ class User : BaseModel { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); // Should extract "HttpClient" from the scoped_identifier ns::HttpClient const binding = typeEnv.constructorBindings.find((b) => b.varName === 'client'); expect(binding).toBeDefined(); @@ -2857,7 +2836,7 @@ class User : BaseModel { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); // User() with known class resolves via extractInitializer, not constructor bindings expect(flatGet(typeEnv, 'user')).toBe('User'); // No unresolved bindings since User is locally known @@ -2875,7 +2854,7 @@ class User : BaseModel { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); // UnknownClass is not defined locally — should appear as unverified binding expect(flatGet(typeEnv, 'user')).toBeUndefined(); expect(typeEnv.constructorBindings.length).toBe(1); @@ -2892,7 +2871,7 @@ class User : BaseModel { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); // Explicit annotation resolves it — no unverified binding needed expect(flatGet(typeEnv, 'user')).toBe('User'); expect(typeEnv.constructorBindings.find((b) => b.varName === 'user')).toBeUndefined(); @@ -2905,7 +2884,7 @@ func run() { let user = User.init(name: "alice") } `); - const typeEnv = buildTypeEnv(tree, 'swift'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Swift); expect(flatGet(typeEnv, 'user')).toBeUndefined(); expect(typeEnv.constructorBindings).toEqual([ expect.objectContaining({ @@ -2924,7 +2903,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBeUndefined(); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('user'); @@ -2939,7 +2918,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('user'); expect(typeEnv.constructorBindings[0].calleeName).toBe('User'); @@ -2954,7 +2933,7 @@ def main(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBeUndefined(); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('user'); @@ -2971,7 +2950,7 @@ def main(): `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(typeEnv.constructorBindings).toEqual([]); }); @@ -2982,7 +2961,7 @@ REPO = Repo.new `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('REPO'); expect(typeEnv.constructorBindings[0].calleeName).toBe('Repo'); @@ -2995,7 +2974,7 @@ service = Models::UserService.new `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('service'); expect(typeEnv.constructorBindings[0].calleeName).toBe('UserService'); @@ -3008,7 +2987,7 @@ svc = App::Models::Service.new `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('svc'); expect(typeEnv.constructorBindings[0].calleeName).toBe('Service'); @@ -3023,14 +3002,14 @@ svc = App::Models::Service.new `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].scope).toMatch(/^process@\d+$/); }); it('returns constructor bindings for TypeScript const user = getUser()', () => { const tree = parse('const user = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBeUndefined(); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('user'); @@ -3039,20 +3018,20 @@ svc = App::Models::Service.new it('does NOT emit constructor binding when TypeScript var has explicit type annotation', () => { const tree = parse('const user: User = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(typeEnv.constructorBindings.find((b) => b.varName === 'user')).toBeUndefined(); }); it('skips destructuring patterns (array_pattern) for TypeScript', () => { const tree = parse('const [a, b] = getPair();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(typeEnv.constructorBindings).toEqual([]); }); it('skips destructuring patterns (object_pattern) for TypeScript', () => { const tree = parse('const { name, age } = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(typeEnv.constructorBindings).toEqual([]); }); @@ -3061,7 +3040,7 @@ svc = App::Models::Service.new 'async function f() { const user = await fetchUser(); }', TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('user'); expect(typeEnv.constructorBindings[0].calleeName).toBe('fetchUser'); @@ -3069,7 +3048,7 @@ svc = App::Models::Service.new it('handles qualified callee in TypeScript: const user = repo.getUser()', () => { const tree = parse('const user = repo.getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('user'); expect(typeEnv.constructorBindings[0].calleeName).toBe('getUser'); @@ -3077,7 +3056,7 @@ svc = App::Models::Service.new it('does not emit binding for TypeScript new expression (handled by extractInitializer)', () => { const tree = parse('const user = new User();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(typeEnv.constructorBindings.find((b) => b.varName === 'user')).toBeUndefined(); }); @@ -3094,7 +3073,7 @@ svc = App::Models::Service.new `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); const binding = typeEnv.constructorBindings.find((b) => b.varName === 'user'); expect(binding).toBeDefined(); expect(binding!.calleeName).toBe('GetUser'); @@ -3109,7 +3088,7 @@ svc = App::Models::Service.new `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(typeEnv.constructorBindings.length).toBe(1); expect(typeEnv.constructorBindings[0].varName).toBe('user'); expect(typeEnv.constructorBindings[0].calleeName).toBe('get_user'); @@ -3127,7 +3106,7 @@ svc = App::Models::Service.new `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); const binding = typeEnv.constructorBindings.find((b) => b.varName === 'user'); expect(binding).toBeDefined(); expect(binding!.calleeName).toBe('GetUserAsync'); @@ -3144,7 +3123,7 @@ svc = App::Models::Service.new `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); const binding = typeEnv.constructorBindings.find((b) => b.varName === 'user'); expect(binding).toBeDefined(); expect(binding!.calleeName).toBe('GetUser'); @@ -3160,7 +3139,7 @@ svc = App::Models::Service.new `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'a')).toBe('User'); expect(flatGet(typeEnv, 'b')).toBe('User'); }); @@ -3173,7 +3152,7 @@ svc = App::Models::Service.new `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'a')).toBe('User'); expect(flatGet(typeEnv, 'b')).toBe('User'); }); @@ -3190,7 +3169,7 @@ svc = App::Models::Service.new `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'a')).toBe('User'); expect(flatGet(typeEnv, 'b')).toBe('User'); expect(flatGet(typeEnv, 'c')).toBe('User'); @@ -3206,7 +3185,7 @@ svc = App::Models::Service.new `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // 'alias' should get User from the parameter 'user' const scopeKey = [...typeEnv.allScopes().keys()].find((k) => k.startsWith('process@')); expect(scopeKey).toBeDefined(); @@ -3224,7 +3203,7 @@ svc = App::Models::Service.new `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // cfg in process scope picks up Config from the file-level config binding const scopeKey = [...typeEnv.allScopes().keys()].find((k) => k.startsWith('process@')); expect(scopeKey).toBeDefined(); @@ -3238,7 +3217,7 @@ svc = App::Models::Service.new `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // getUser() is a call_expression — should not create a binding expect(flatGet(typeEnv, 'x')).toBeUndefined(); }); @@ -3338,7 +3317,7 @@ svc = App::Models::Service.new `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const scopeKey = [...typeEnv.allScopes().keys()].find((k) => k.startsWith('process@')); expect(scopeKey).toBeDefined(); expect(typeEnv.allScopes().get(scopeKey!)?.get('a')).toBe('User'); @@ -3360,7 +3339,7 @@ svc = App::Models::Service.new `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const scopeKey = [...typeEnv.allScopes().keys()].find((k) => k.startsWith('process@')); expect(scopeKey).toBeDefined(); expect(typeEnv.allScopes().get(scopeKey!)?.get('c')).toBe('User'); @@ -3384,7 +3363,7 @@ svc = App::Models::Service.new `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'a')).toBe('User'); expect(flatGet(typeEnv, 'b')).toBe('User'); }); @@ -3403,7 +3382,7 @@ svc = App::Models::Service.new `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'alias')).toBe('User'); }); @@ -3420,7 +3399,7 @@ svc = App::Models::Service.new `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'alias')).toBe('User'); }); @@ -3437,7 +3416,7 @@ svc = App::Models::Service.new `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'alias')).toBe('User'); }); @@ -3456,7 +3435,7 @@ svc = App::Models::Service.new `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'alias')).toBe('User'); }); @@ -3472,7 +3451,7 @@ def process(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'alias')).toBe('User'); }); @@ -3487,7 +3466,7 @@ def process(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'alias')).toBe('User'); }); @@ -3504,7 +3483,7 @@ def process(): `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'alias')).toBe('User'); }); @@ -3521,7 +3500,7 @@ def process(): `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$u')).toBe('User'); expect(flatGet(typeEnv, '$alias')).toBe('User'); }); @@ -3542,7 +3521,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); // Without a known type for 'user' (no annotation in Ruby), alias_user stays undefined. // This verifies the extractor doesn't crash or produce false bindings. expect(flatGet(typeEnv, 'alias_user')).toBeUndefined(); @@ -3558,7 +3537,7 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); // get_user() is a call — user has no resolved type, so alias_user should not resolve either expect(flatGet(typeEnv, 'alias_user')).toBeUndefined(); }); @@ -3576,7 +3555,7 @@ end `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Find the call node for .save() const scopeKey = [...typeEnv.allScopes().keys()].find((k) => k.startsWith('process@')); expect(scopeKey).toBeDefined(); @@ -3592,7 +3571,7 @@ def process(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); // Python 3.10+ union syntax is stored as raw text "User | None" // which stripNullable resolves at lookup time const rawVal = flatGet(typeEnv, 'user'); @@ -3616,7 +3595,7 @@ def process(): `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -3629,7 +3608,7 @@ def process(): `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -3644,7 +3623,7 @@ def process(): `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'users')).toBe('List'); }); @@ -3659,7 +3638,7 @@ def process(): `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'lookup')).toBe('Map'); }); @@ -3674,7 +3653,7 @@ def process(): `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'future')).toBe('CompletableFuture'); }); @@ -3705,7 +3684,7 @@ def process(): `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'alias')).toBe('User'); }); @@ -3725,7 +3704,7 @@ def process(): `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -3740,7 +3719,7 @@ def process(): `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -3755,7 +3734,7 @@ def process(): `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // for...in yields string keys — extractor must NOT bind 'key' to User expect(flatGet(typeEnv, 'key')).toBeUndefined(); }); @@ -3771,7 +3750,7 @@ def process(): `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); @@ -3787,7 +3766,7 @@ def process(): `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Note: users itself is stored with no binding (extractSimpleTypeName returns undefined // for array_type), but the for-loop extractor uses AST walking to resolve the element type. expect(flatGet(typeEnv, 'user')).toBe('User'); @@ -3804,7 +3783,7 @@ def process(): `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -3819,7 +3798,7 @@ def process(users: List[User]): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -3832,7 +3811,7 @@ def process(users: Sequence[User]): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -3845,7 +3824,7 @@ def process(users): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); @@ -3859,7 +3838,7 @@ def process(): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); // List[User] → extractSimpleTypeName returns 'List' (base name), stored as 'List' // extractElementTypeFromString('List') → undefined (no brackets in the string) // So user is unresolved unless users is stored as 'List[User]' raw. @@ -3883,7 +3862,7 @@ func process(users []User) { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -3899,7 +3878,7 @@ func process(users []User) { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); // In Go, `for v := range slice` gives the INDEX (int), not the element. expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); @@ -3916,7 +3895,7 @@ func process(myMap map[string]User) { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); expect(flatGet(typeEnv, 'v')).toBe('User'); }); @@ -3932,7 +3911,7 @@ func process(myMap map[string]User) { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); // Single-var map range gives the KEY, not the value expect(flatGet(typeEnv, 'k')).toBeUndefined(); }); @@ -3948,7 +3927,7 @@ func process() { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); // C-style for loop has no range_clause — extractor must return early expect(flatGet(typeEnv, 'i')).toBeUndefined(); }); @@ -3966,7 +3945,7 @@ func process() { `, Go, ); - const typeEnv = buildTypeEnv(tree, 'go'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Go); // users has no type annotation — only a constructor binding candidate // Without a resolved type for users, user cannot be inferred expect(flatGet(typeEnv, 'user')).toBeUndefined(); @@ -3985,7 +3964,7 @@ fn process(users: Vec) { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4000,7 +3979,7 @@ fn process(users: &[User]) { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4015,7 +3994,7 @@ fn process() { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'i')).toBeUndefined(); }); @@ -4031,7 +4010,7 @@ fn process() { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); }); @@ -4051,7 +4030,7 @@ class Foo { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4068,7 +4047,7 @@ class Foo { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4086,7 +4065,7 @@ class Foo { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); }); @@ -4103,7 +4082,7 @@ fun process(users: List) { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4118,7 +4097,7 @@ fun process(users: List) { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4134,7 +4113,7 @@ fun process() { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); }); @@ -4153,7 +4132,7 @@ class Foo { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4171,7 +4150,7 @@ class Foo { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); }); @@ -4190,7 +4169,7 @@ function process(entries: Map) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4206,7 +4185,7 @@ def process(data: dict[str, User]): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4222,7 +4201,7 @@ def process(data: dict[str, User]): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'v')).toBe('User'); // i is the int index from enumerate — must NOT be bound to User expect(flatGet(typeEnv, 'i')).toBeUndefined(); @@ -4240,7 +4219,7 @@ def process(data: dict[str, User]): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'v')).toBe('User'); expect(flatGet(typeEnv, 'i')).toBeUndefined(); }); @@ -4257,7 +4236,7 @@ def process(users: List[User]): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); // enumerate yields (index, element) — k is int (unbound), v is User expect(flatGet(typeEnv, 'v')).toBe('User'); expect(flatGet(typeEnv, 'k')).toBeUndefined(); @@ -4277,7 +4256,7 @@ function process(x) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'x')).toBe('User'); }); @@ -4294,7 +4273,7 @@ fn process(users: Vec) { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -4311,7 +4290,7 @@ function process(data: Map) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4326,7 +4305,7 @@ function process(data: Map) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'key')).toBe('string'); }); @@ -4339,7 +4318,7 @@ def process(data: dict[str, User]): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'key')).toBe('str'); }); @@ -4352,7 +4331,7 @@ def process(data: dict[str, User]): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4367,7 +4346,7 @@ fn process(data: HashMap) { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'key')).toBe('String'); }); @@ -4382,7 +4361,7 @@ fn process(data: HashMap) { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -4399,7 +4378,7 @@ fn process(data: HashMap) { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'key')).toBe('String'); }); @@ -4414,7 +4393,7 @@ fn process(data: HashMap) { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4429,7 +4408,7 @@ fn process(users: Vec) { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4445,7 +4424,7 @@ function process(cache: MyCache) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'key')).toBe('string'); }); }); @@ -4462,7 +4441,7 @@ function process(users: User[]) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Object destructuring should NOT produce bindings — field types are unknown expect(flatGet(typeEnv, 'id')).toBeUndefined(); expect(flatGet(typeEnv, 'name')).toBeUndefined(); @@ -4479,7 +4458,7 @@ function process(users: User[]) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4492,7 +4471,7 @@ def process(users: List[User]): `, Python, ); - const typeEnv = buildTypeEnv(tree, 'python'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Python); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4507,7 +4486,7 @@ void process(std::map& map) { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'value')).toBe('User'); }); @@ -4534,17 +4513,16 @@ void processRepoMap(std::map repoMap) { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); expect(flatGet(typeEnv, 'user')).toBe('User'); expect(flatGet(typeEnv, 'repo')).toBe('Repo'); }); }); - describe('known limitations (documented skip tests)', () => { - it.skip('Ruby block parameter: users.each { |user| } — closure param inference, different feature', () => { - // Not a for-loop; .each { |user| } is a method call with a block. - // Requires closure parameter inference — a different feature category - // applicable to Ruby, Swift closures, Kotlin lambdas, and Java lambdas. + describe('unknown Ruby block parameter types', () => { + it('does not invent a type for a block parameter from an untyped receiver', () => { + // Neither the parameter name nor calling `save` proves users contains + // User instances. Inferring User here would introduce false call edges. const tree = parse( ` def process(users) @@ -4553,8 +4531,8 @@ end `, Ruby, ); - const typeEnv = buildTypeEnv(tree, 'ruby'); - expect(flatGet(typeEnv, 'user')).toBe('User'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Ruby); + expect(flatGet(typeEnv, 'user')).toBeUndefined(); }); }); @@ -4570,7 +4548,7 @@ fun process(x: Any) { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'x')).toBe('User'); }); @@ -4586,7 +4564,7 @@ fun process(x: Any) { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); // allowPatternBindingOverwrite means each arm overwrites — last one wins expect(flatGet(typeEnv, 'x')).toBe('Admin'); }); @@ -4603,7 +4581,7 @@ fun process() { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); // x has type String from its declaration — no pattern binding should narrow it // (else branch has no type_test node, so extractKotlinPatternBinding never fires) expect(flatGet(typeEnv, 'x')).toBe('String'); @@ -4622,7 +4600,7 @@ fun processValues(data: HashMap) { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4637,7 +4615,7 @@ fun processList(users: List) { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -4656,7 +4634,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'u')).toBe('User'); }); @@ -4674,7 +4652,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'u')).toBe('User'); expect(flatGet(typeEnv, 'a')).toBe('Admin'); }); @@ -4692,7 +4670,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); // Only the parameter x:Object should exist, no extra bindings from case 42 expect(flatGet(typeEnv, 'x')).toBe('Object'); }); @@ -4710,7 +4688,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -4730,7 +4708,7 @@ public class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4745,7 +4723,7 @@ fun process(data: MutableMap) { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4760,7 +4738,7 @@ fun process(users: MutableList) { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4778,7 +4756,7 @@ public class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4795,7 +4773,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -4814,7 +4792,7 @@ public class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'u')).toBe('User'); }); @@ -4832,7 +4810,7 @@ public class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'r')).toBe('Repo'); }); @@ -4848,7 +4826,7 @@ public class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); // obj → object from the parameter, but no pattern binding expect(flatGet(typeEnv, 'obj')).toBe('object'); expect(flatSize(typeEnv)).toBe(1); // only the parameter binding @@ -4870,7 +4848,7 @@ public class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -4889,7 +4867,7 @@ public class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); // Verify lookup works from the call site (user.Save()) const saveCall = tree.rootNode.descendantsOfType('invocation_expression')[0]; @@ -4912,7 +4890,7 @@ class UserService { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // User[] is an array_type — extractSimpleTypeName returns undefined (no simple base name). // But declarationTypeNodes captures the raw AST node, so for-loop resolution // uses Strategy 1 (extractTsElementTypeFromAnnotation) to resolve the element type. @@ -4928,7 +4906,7 @@ class RepoService { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'repos')).toBe('Map'); }); }); @@ -4949,7 +4927,7 @@ class App { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$user')).toBe('User'); }); @@ -4967,7 +4945,7 @@ class App { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); expect(flatGet(typeEnv, '$item')).toBeUndefined(); }); @@ -4995,7 +4973,7 @@ class B { `, PHP.php, ); - const typeEnv = buildTypeEnv(tree, 'php'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.PHP); // Both $item bindings exist but may share the same key if scoped to method name // Conservative: just verify at least one resolves correctly expect(flatGet(typeEnv, '$item')).toBeDefined(); @@ -5015,7 +4993,7 @@ fn process(opt: Option) { `, Rust, ); - const typeEnv = buildTypeEnv(tree, 'rust'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Rust); // user should be typed from the first arm (Some unwrap) // Known limitation: binding leaks across arms (first-writer-wins) expect(flatGet(typeEnv, 'user')).toBe('User'); @@ -5025,7 +5003,7 @@ fn process(opt: Option) { describe('performance optimizations — coverage for new code paths', () => { it('fastStripNullable: passes through simple identifier without stripping', () => { const tree = parse('function f(user: User) { user.save(); }', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // lookup exercises fastStripNullable — "User" has no | or ? markers const callNode = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('save')); expect(typeEnv.lookup('user', callNode)).toBe('User'); @@ -5033,14 +5011,14 @@ fn process(opt: Option) { it('fastStripNullable: strips nullable union type via full stripNullable', () => { const tree = parse('function f(user: User | null) { user.save(); }', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const callNode = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('save')); expect(typeEnv.lookup('user', callNode)).toBe('User'); }); it('fastStripNullable: rejects bare nullable keyword', () => { const tree = parse('function f(x: null) { x.save(); }', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const callNode = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('save')); expect(typeEnv.lookup('x', callNode)).toBeUndefined(); }); @@ -5056,7 +5034,7 @@ class Foo { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const callNode = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('save')); expect(typeEnv.lookup('user', callNode)).toBe('User'); }); @@ -5071,7 +5049,7 @@ function f(user: User) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -5087,7 +5065,7 @@ function calculate(service: Service) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'service')).toBe('Service'); }); }); @@ -5101,7 +5079,7 @@ function process(x: User | null) { } }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Inside the if-body, x should resolve to User (nullable stripped) const saveCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.save')); expect(typeEnv.lookup('x', saveCall)).toBe('User'); @@ -5115,7 +5093,7 @@ function process(x: User | undefined) { } }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const saveCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.save')); expect(typeEnv.lookup('x', saveCall)).toBe('User'); }); @@ -5128,7 +5106,7 @@ function process(x: User | null) { } }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const saveCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.save')); expect(typeEnv.lookup('x', saveCall)).toBe('User'); }); @@ -5143,7 +5121,7 @@ function process(x: User | null) { } }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // Inside else branch, x should retain original nullable type (User via fastStripNullable) const fallbackCall = tree.rootNode.descendantForIndex( tree.rootNode.text.indexOf('x.fallback'), @@ -5165,7 +5143,7 @@ function process(x: User | null) { x.other(); }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // After the if-block, x should use the flat scopeEnv (User | null → User via fastStripNullable) const otherCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.other')); expect(typeEnv.lookup('x', otherCall)).toBe('User'); @@ -5179,7 +5157,7 @@ function process(x: User) { } }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // x is already non-nullable — no narrowing override is emitted, but lookup still works const saveCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.save')); expect(typeEnv.lookup('x', saveCall)).toBe('User'); @@ -5196,7 +5174,7 @@ function process(x) { `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'x')).toBe('User'); }); @@ -5208,7 +5186,7 @@ fun process(x: User?) { } }`; const tree = parse(code, Kotlin); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); const saveCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.save')); expect(typeEnv.lookup('x', saveCall)).toBe('User'); }); @@ -5224,7 +5202,7 @@ fun process(x: Any) { `, Kotlin, ); - const typeEnv = buildTypeEnv(tree, 'kotlin'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Kotlin); expect(flatGet(typeEnv, 'x')).toBe('User'); }); @@ -5238,7 +5216,7 @@ class App { } }`; const tree = parse(code, CSharp); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); const saveCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.Save')); expect(typeEnv.lookup('x', saveCall)).toBe('User'); }); @@ -5256,7 +5234,7 @@ class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'user')).toBe('User'); }); }); @@ -5275,7 +5253,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'a')).toBe('User'); expect(flatGet(typeEnv, 'b')).toBe('User'); }); @@ -5295,7 +5273,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); // x should NOT have a type binding (it's untyped via var) expect(flatGet(typeEnv, 'x')).toBeUndefined(); @@ -5314,7 +5292,7 @@ class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); expect(flatGet(typeEnv, 'a')).toBe('User'); expect(flatGet(typeEnv, 'b')).toBe('User'); }); @@ -5331,7 +5309,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'user')).toBe('User'); }); @@ -5351,7 +5329,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); expect(flatGet(typeEnv, 'users')).toBe('List'); expect(flatGet(typeEnv, 'admins')).toBe('List'); expect(flatGet(typeEnv, 'u')).toBe('User'); @@ -5372,7 +5350,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); // Find the entry for variable 'a' let ctorType: string | undefined; for (const [key, value] of typeEnv.constructorTypeMap) { @@ -5396,7 +5374,7 @@ class App { `, Java, ); - const typeEnv = buildTypeEnv(tree, 'java'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.Java); let found = false; for (const [key] of typeEnv.constructorTypeMap) { if (key.endsWith('\0u')) { @@ -5419,7 +5397,7 @@ const a: Animal = new Dog(); `, TypeScript.typescript, ); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); expect(flatGet(typeEnv, 'a')).toBe('Animal'); let found = false; for (const [key] of typeEnv.constructorTypeMap) { @@ -5442,7 +5420,7 @@ void run() { `, CPP, ); - const typeEnv = buildTypeEnv(tree, 'cpp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CPlusPlus); let ctorType: string | undefined; for (const [key, value] of typeEnv.constructorTypeMap) { if (key.endsWith('\0a')) { @@ -5466,7 +5444,7 @@ class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); let ctorType: string | undefined; for (const [key, value] of typeEnv.constructorTypeMap) { if (key.endsWith('\0a')) { @@ -5489,7 +5467,7 @@ class App { `, CSharp, ); - const typeEnv = buildTypeEnv(tree, 'csharp'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.CSharp); // d should be bound via declared type path expect(flatGet(typeEnv, 'd')).toBe('Dog'); // constructorTypeMap should NOT have an entry (same type, no override needed) @@ -5508,7 +5486,7 @@ class App { it('seeds imported bindings into file scope for unbound names', () => { // Source has no local declaration of 'config', so the imported binding wins const tree = parse('', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedBindings: new Map([['config', 'Config']]), }); const fileScope = typeEnv.fileScope(); @@ -5518,7 +5496,7 @@ class App { it('local declarations take precedence over imported bindings', () => { // Source declares config: AppConfig — imported binding for 'config' must not overwrite it const tree = parse('const config: AppConfig = getConfig();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedBindings: new Map([['config', 'Config']]), }); const fileScope = typeEnv.fileScope(); @@ -5528,8 +5506,8 @@ class App { it('does nothing when importedBindings is empty', () => { const tree = parse('const user: User = getUser();', TypeScript.typescript); - const typeEnvWithout = buildTypeEnv(tree, 'typescript'); - const typeEnvWith = buildTypeEnv(tree, 'typescript', { + const typeEnvWithout = buildTypeEnv(tree, SupportedLanguages.TypeScript); + const typeEnvWith = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedBindings: new Map(), }); // Both envs should produce the same file-scope content @@ -5541,7 +5519,7 @@ class App { it('seeds multiple bindings', () => { const tree = parse('', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedBindings: new Map([ ['user', 'User'], ['config', 'Config'], @@ -5559,7 +5537,7 @@ function process() { config.validate(); }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedBindings: new Map([['config', 'Config']]), }); @@ -5577,7 +5555,7 @@ function process() { it('seeds bindings with no conflict when local file has unrelated declarations', () => { // File has 'user' declared locally; 'config' comes from importedBindings const tree = parse('const user: User = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedBindings: new Map([['config', 'Config']]), }); const fileScope = typeEnv.fileScope(); @@ -5609,7 +5587,7 @@ function process() { // importedReturnTypes says getConfig → WrongType — SymbolTable must win const symbolTable = makeSymbolTable([{ name: 'getConfig', returnType: 'Config' }]); const tree = parse('const c = getConfig();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model: symbolTable, importedReturnTypes: new Map([['getConfig', 'WrongType']]), }); @@ -5621,7 +5599,7 @@ function process() { // SymbolTable knows nothing about getConfig const symbolTable = makeSymbolTable([]); const tree = parse('const c = getConfig();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model: symbolTable, importedReturnTypes: new Map([['getConfig', 'Config']]), }); @@ -5636,7 +5614,7 @@ function process() { { name: 'process', returnType: 'Admin' }, ]); const tree = parse('const r = process();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model: symbolTable, importedReturnTypes: new Map([['process', 'User']]), }); @@ -5646,7 +5624,7 @@ function process() { it('no SymbolTable → uses cross-file return types directly', () => { const tree = parse('const c = getConfig();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedReturnTypes: new Map([['getConfig', 'Config']]), }); expect(flatGet(typeEnv, 'c')).toBe('Config'); @@ -5656,7 +5634,7 @@ function process() { // SymbolTable provides the authoritative return type; cross-file entry is ignored const symbolTable = makeSymbolTable([{ name: 'getUser', returnType: 'User' }]); const tree = parse('const u = getUser();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { model: symbolTable, importedReturnTypes: new Map([['getUser', 'CrossFileUser']]), }); @@ -5666,7 +5644,7 @@ function process() { it('does nothing when importedReturnTypes is absent', () => { const tree = parse('const c = getConfig();', TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); // No annotation, no SymbolTable, no cross-file → no binding expect(flatGet(typeEnv, 'c')).toBeUndefined(); }); @@ -5681,7 +5659,7 @@ function process() { c.validate(); }`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript', { + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript, { importedReturnTypes: new Map([['getConfig', 'Config']]), }); @@ -5704,7 +5682,7 @@ function process() { it('flushes file-scope bindings into accumulator', () => { const code = `const user: User = getUser();\nconst count: number = 0;`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const acc = new BindingAccumulator(); typeEnv.flush('/src/test.ts', acc); @@ -5726,7 +5704,7 @@ function process() { it('does NOT flush function-scoped bindings into accumulator (file-scope narrowing)', () => { const code = `function process() {\n const result: Response = fetch();\n}`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const acc = new BindingAccumulator(); typeEnv.flush('/src/test.ts', acc); @@ -5746,7 +5724,7 @@ function process() { // the primary narrowing-contract assertion for the sequential path. const code = `const dbClient: Database = connectDb();\nfunction handleRequest() {\n const localRequest: Request = parseRequest();\n const localUser: User = loadUser();\n}`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const acc = new BindingAccumulator(); typeEnv.flush('/src/service.ts', acc); @@ -5767,7 +5745,7 @@ function process() { it('flushes nothing for an empty TypeEnv', () => { const code = `// empty file`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const acc = new BindingAccumulator(); typeEnv.flush('/src/empty.ts', acc); @@ -5780,8 +5758,8 @@ function process() { const code2 = `const b: B = makeB();`; const tree1 = parse(code1, TypeScript.typescript); const tree2 = parse(code2, TypeScript.typescript); - const typeEnv1 = buildTypeEnv(tree1, 'typescript'); - const typeEnv2 = buildTypeEnv(tree2, 'typescript'); + const typeEnv1 = buildTypeEnv(tree1, SupportedLanguages.TypeScript); + const typeEnv2 = buildTypeEnv(tree2, SupportedLanguages.TypeScript); const acc = new BindingAccumulator(); typeEnv1.flush('/src/a.ts', acc); @@ -5795,7 +5773,7 @@ function process() { it('throws on second flush of the same TypeEnv (single-use)', () => { const code = `const x: X = makeX();`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const acc = new BindingAccumulator(); typeEnv.flush('/src/a.ts', acc); @@ -5829,7 +5807,7 @@ function internal() { } `; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const acc = new BindingAccumulator(); // Real flush — exercises the narrowed FILE_SCOPE-only iteration. @@ -5883,7 +5861,7 @@ function internal() { const code = `export const helper: WorkerInferredType = makeHelper();`; const tree = parse(code, TypeScript.typescript); - const typeEnv = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, SupportedLanguages.TypeScript); const acc = new BindingAccumulator(); typeEnv.flush('src/utils.ts', acc); acc.finalize(); diff --git a/gitnexus/test/unit/typescript-tool-definitions.test.ts b/gitnexus/test/unit/typescript-tool-definitions.test.ts new file mode 100644 index 000000000..e248a682c --- /dev/null +++ b/gitnexus/test/unit/typescript-tool-definitions.test.ts @@ -0,0 +1,441 @@ +import { describe, expect, it } from 'vitest'; +import Parser from 'tree-sitter'; +import JavaScript from 'tree-sitter-javascript'; +import TypeScript from 'tree-sitter-typescript'; +import { extractToolDefinitions } from '../../src/core/ingestion/languages/typescript/tool-definitions.js'; + +const tsParser = new Parser(); +tsParser.setLanguage(TypeScript.typescript); +const jsParser = new Parser(); +jsParser.setLanguage(JavaScript); + +const sdkImport = `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`; +const server = `${sdkImport}\nconst server = new McpServer({ name: 'example', version: '1' });`; +const extract = (source: string, parser = tsParser, filePath = 'src/server.ts', offset = 0) => + extractToolDefinitions(parser.parse(source), filePath, offset); +const metadata = (source: string) => + extract(source).map(({ toolName, description }) => ({ toolName, description })); + +describe('SDK tool registration extraction', () => { + it.each([ + ['TypeScript', tsParser, 'src/server.ts'], + ['JavaScript', jsParser, 'src/server.js'], + ] as const)( + 'extracts modern %s registrations in ordinary server files', + (_language, parser, filePath) => { + expect( + extract( + `${server}\nserver.registerTool('search', { description: 'Search files' }, handler);`, + parser, + filePath, + 10, + ), + ).toEqual([ + { + filePath, + toolName: 'search', + description: 'Search files', + lineNumber: 13, + allowFileFallback: false, + }, + ]); + }, + ); + + it.each([ + ['TypeScript', tsParser, 'src/server.ts'], + ['JavaScript', jsParser, 'src/server.js'], + ] as const)('recognizes namespace imports in %s', (_language, parser, filePath) => { + expect( + extract( + ` + import * as SDK from '@modelcontextprotocol/sdk/server/mcp.js'; + const server = new SDK.McpServer({}); + server.registerTool('modern', { description: 'Namespace tool' }, handler); + server.tool('legacy', handler); + `, + parser, + filePath, + ).map(({ toolName }) => toolName), + ).toEqual(['modern', 'legacy']); + }); + + it.each(['', 'type '])('recognizes %snamespace imports in directly typed helpers', (typeOnly) => { + expect( + metadata(` + import ${typeOnly}* as SDK from '@modelcontextprotocol/sdk/server/mcp'; + function install(server: SDK.McpServer) { server.tool('typed', handler); } + `), + ).toEqual([{ toolName: 'typed', description: '' }]); + }); + + it.each([ + "function install(SDK) { const server = new SDK.McpServer({}); server.tool('fake', handler); }", + "function install(server: SDK.McpServer) { server.tool('fake', handler); }", + "SDK = other; const server = new SDK.McpServer({}); server.tool('fake', handler);", + "SDK.McpServer = other; const server = new SDK.McpServer({}); server.tool('fake', handler);", + "({ value: SDK.McpServer } = other); const server = new SDK.McpServer({}); server.tool('fake', handler);", + "SDK.McpServer.prototype.tool = other; const server = new SDK.McpServer({}); server.tool('fake', handler);", + "SDK[key] = other; const server = new SDK.McpServer({}); server.tool('fake', handler);", + "const server = new SDK.McpServer({}); ({ method: server.tool } = other); server.tool('fake', handler);", + "const alias = SDK; const server = new alias.McpServer({}); server.tool('fake', handler);", + "const server = new SDK.OtherServer({}); server.tool('fake', handler);", + ])('rejects unproven namespace receivers: %s', (source) => { + expect( + metadata(`import * as SDK from '@modelcontextprotocol/sdk/server/mcp.js'; ${source}`), + ).toEqual([]); + }); + + it('rejects type-only namespace construction and unrelated namespace imports', () => { + expect( + metadata(` + import type * as SDK from '@modelcontextprotocol/sdk/server/mcp.js'; + import * as Other from 'unrelated'; + const first = new SDK.McpServer({}); first.tool('type-only', handler); + const second = new Other.McpServer({}); second.tool('unrelated', handler); + `), + ).toEqual([]); + }); + + it('does not require description or inputSchema, and ignores nested descriptions', () => { + expect( + metadata(`${server} + server.registerTool('empty', {}, () => {}); + server.registerTool('nested', { inputSchema: { description: 'Schema decoy' } }, handler); + server.registerTool('dynamic-description', { description: getDescription() }, handler); + `), + ).toEqual([ + { toolName: 'empty', description: '' }, + { toolName: 'nested', description: '' }, + { toolName: 'dynamic-description', description: '' }, + ]); + }); + + it('recognizes legacy callback-last overloads with descriptions, schemas and annotations', () => { + expect( + metadata(`${server} + server.tool('bare', handler); + server.tool('described', 'Human description', handler); + server.tool('schema', { query: z.string().describe('Field decoy') }, handler); + server.tool('annotated', { readOnlyHint: true }, handler); + server.tool('full', 'Full description', { query: z.string() }, { readOnlyHint: true }, handler); + `), + ).toEqual([ + { toolName: 'bare', description: '' }, + { toolName: 'described', description: 'Human description' }, + { toolName: 'schema', description: '' }, + { toolName: 'annotated', description: '' }, + { toolName: 'full', description: 'Full description' }, + ]); + }); + + it('decodes static names and quoted description keys without distance or property-order limits', () => { + expect( + metadata(`${server} + server.registerTool(\`find\\x2ditems!?\`, { + inputSchema: { description: 'Nested decoy', example: '${'x'.repeat(2000)}' }, + 'descr\\u0069ption': 'Line\\nwith \\"quotes\\" and \\u{1F680}', + }, handler); + server.tool('legacy\\u002fname', \`Static description\`, handler); + `), + ).toEqual([ + { toolName: 'find-items!?', description: 'Line\nwith "quotes" and 🚀' }, + { toolName: 'legacy/name', description: 'Static description' }, + ]); + }); + + it('recognizes SDK aliases, locally constructed instances and directly typed helper parameters', () => { + expect( + metadata(` + import { McpServer as Server } from '@modelcontextprotocol/sdk/server/mcp.js'; + function install(server: Server) { server.registerTool('helper', {}, handler); } + const add = (server: Server) => server.tool('arrow-helper', handler); + function start() { + const local = new Server({ name: 'local', version: '1' }); + local.registerTool('local', {}, handler); + } + `), + ).toEqual([ + { toolName: 'helper', description: '' }, + { toolName: 'arrow-helper', description: '' }, + { toolName: 'local', description: '' }, + ]); + }); + + it('ignores dynamic names, comments, string decoys and unrelated receivers', () => { + expect( + metadata(`${server} + // server.registerTool('comment', { description: 'decoy' }, handler); + const decoy = "server.tool('string', handler)"; + server.registerTool(runtimeName, {}, handler); + server.tool(\`dynamic-\${runtimeName}\`, handler); + server.registerTool('prefix' + suffix, {}, handler); + unrelated.registerTool('unrelated', {}, handler); + const alias = server; + alias.tool('alias', handler); + `), + ).toEqual([]); + }); + + it('rejects shadowed SDK names and receivers, including declarations later in the scope', () => { + expect( + metadata(`${server} + function parameter(server) { server.tool('parameter', handler); } + function constructor(McpServer) { + const fake = new McpServer(); + fake.tool('constructor', handler); + } + { + server.registerTool('temporal-shadow', {}, handler); + const server = unrelated; + } + function localType() { + class McpServer {} + function helper(server: McpServer) { server.tool('type-shadow', handler); } + } + `), + ).toEqual([]); + }); + + it('rejects reassigned receivers and SDK constructors', () => { + expect( + metadata(`${sdkImport} + let changed = new McpServer(); + changed = unrelated; + changed.tool('changed', handler); + const instance = new McpServer(); + function replace() { McpServer = OtherServer; } + instance.registerTool('constructor-mutated', {}, handler); + `), + ).toEqual([]); + }); + + it('uses decoded type-only imports for helper parameters, but not construction', () => { + expect( + metadata(` + import type { McpServer as Server } from '@modelcontextprotocol/\\u0073dk/server/mcp.js'; + function install(server: Server) { server.tool('typed', handler); } + const fake = new Server(); + fake.tool('type-only-constructor', handler); + `), + ).toEqual([{ toolName: 'typed', description: '' }]); + }); + + it.each(['before', 'after'])('allows SDK lifecycle configuration %s registration', (when) => { + const configure = `server.server.oninitialized = () => {}; server.server.onerror = () => {};`; + const registration = `server.registerTool('visible', {}, handler);`; + expect( + metadata( + `${server}\n${when === 'before' ? configure + registration : registration + configure}`, + ), + ).toEqual([{ toolName: 'visible', description: '' }]); + }); + + describe.each([ + ['TypeScript', tsParser], + ['JavaScript', jsParser], + ] as const)('%s destructuring writes', (_language, parser) => { + it.each([ + ['object member', `({ registerTool: server.registerTool } = replacement);`], + ['array member', `[server.tool] = replacement;`], + ['nested quoted member', `({ nested: [server['registerTool']] } = replacement);`], + ['defaulted member', `({ registerTool: server.registerTool = fallback } = replacement);`], + ['rest member', `[...server.tool] = replacement;`], + ['computed member', `[server[method]] = replacement;`], + ['loop target', `for ({ registerTool: server.registerTool } of replacements) {}`], + ['constructor member', `[McpServer.prototype.registerTool] = replacement;`], + ])('rejects registrations after a write to an %s target', (_name, write) => { + expect( + extract(`${server}\n${write}\nserver.registerTool('fake', {}, handler);`, parser), + ).toEqual([]); + }); + + it('preserves lifecycle writes and ignores pattern keys and default-value reads', () => { + expect( + extract( + `${server} + ({ oninitialized: server.server.oninitialized } = callbacks); + ({ [server.registerTool]: ignored } = source); + ({ untouched = server.registerTool } = source); + server.registerTool('visible', {}, handler); + `, + parser, + ).map((tool) => tool.toolName), + ).toEqual(['visible']); + }); + }); + + it.each([ + [ + 'different package', + `import { McpServer } from 'unrelated'; const server = new McpServer(); server.tool('fake', h);`, + ], + [ + 'destructured parameter', + `${server} function install({ server }) { server.tool('fake', h); }`, + ], + ['destructured local', `${server} { const { other: server } = obj; server.tool('fake', h); }`], + ['catch parameter', `${server} try {} catch (server) { server.tool('fake', h); }`], + ['loop binding', `${server} for (const server of other) { server.tool('fake', h); }`], + [ + 'hoisted var', + `${server} function install() { server.tool('fake', h); { var server = other; } }`, + ], + [ + 'generic type', + `${sdkImport} function install(server: McpServer) { server.tool('fake', h); }`, + ], + [ + 'named class expression', + `${sdkImport} const Other = class McpServer { install() { const server = new McpServer(); server.tool('fake', h); } };`, + ], + ['method write', `${server} server.tool = unrelated; server.tool('fake', h);`], + ['quoted method write', `${server} server['tool'] = unrelated; server.tool('fake', h);`], + ['computed method write', `${server} server[method] = unrelated; server.tool('fake', h);`], + ['method delete', `${server} delete server.registerTool; server.registerTool('fake', {}, h);`], + ['destructured write', `${server} ({ server } = other); server.tool('fake', h);`], + [ + 'spread arguments', + `${server} server.registerTool('fake', ...args); server.tool('fake', ...args);`, + ], + ])('rejects %s', (_name, source) => { + expect(metadata(source)).toEqual([]); + }); + + it('keeps evidence outside shadowing scopes and in closures declared before the instance', () => { + expect( + metadata(`${sdkImport} + function install() { server.tool('closure', handler); } + const server = new McpServer(); + { const server = unrelated; server.tool('decoy', handler); } + server.registerTool('outer', {}, handler); + `), + ).toEqual([ + { toolName: 'closure', description: '' }, + { toolName: 'outer', description: '' }, + ]); + }); + + it('bounds descriptions to top-level properties and respects property overrides', () => { + expect( + metadata(`${server} + server.registerTool('shorthand', { description: 'Kept', title }, handler); + server.registerTool('spread-after', { description: 'Unproven', ...config }, handler); + server.registerTool('spread-before', { ...config, description: 'Known' }, handler); + server.registerTool('last-wins', { description: 'Old', description: 'New' }, handler); + server.registerTool('comments', /* first */ 'not an object', /* callback */ handler); + `), + ).toEqual([ + { toolName: 'shorthand', description: 'Kept' }, + { toolName: 'spread-after', description: '' }, + { toolName: 'spread-before', description: 'Known' }, + { toolName: 'last-wins', description: 'New' }, + { toolName: 'comments', description: '' }, + ]); + }); + + it('resolves hoisted declarations and immutable callable bindings using supplied graph IDs', () => { + const tree = tsParser.parse(`${server} + server.tool('declared', declaration); + function declaration() {} + const arrow = () => {}; + const expression = function () {}; + server.registerTool('arrow', {}, arrow); + server.tool('expression', expression); + server.tool('inline', () => {}); + `); + const bindings = new Map(); + for (const declaration of tree.rootNode.descendantsOfType([ + 'function_declaration', + 'variable_declarator', + ])) { + const name = declaration.childForFieldName('name')!; + bindings.set(name.id, `existing-graph-id:${name.text}`); + } + expect( + extractToolDefinitions(tree, 'server.ts', 0, bindings).map((tool) => [ + tool.toolName, + tool.handlerNodeId, + ]), + ).toEqual([ + ['declared', 'existing-graph-id:declaration'], + ['arrow', 'existing-graph-id:arrow'], + ['expression', 'existing-graph-id:expression'], + ['inline', undefined], + ]); + expect( + extractToolDefinitions(tree, 'server.ts').every((tool) => tool.handlerNodeId === undefined), + ).toBe(true); + }); + + it('uses the nearest callable binding without conflating same-name declarations', () => { + const tree = tsParser.parse(`${server} + function handler() {} + function install() { + const handler = () => {}; + server.tool('inner', handler); + } + server.tool('outer', handler); + `); + const outer = tree.rootNode + .descendantsOfType('function_declaration')[0] + .childForFieldName('name')!; + const inner = tree.rootNode + .descendantsOfType('variable_declarator') + .find((node) => node.childForFieldName('name')?.text === 'handler')! + .childForFieldName('name')!; + const bindings = new Map([ + [outer.id, 'emitted-outer'], + [inner.id, 'emitted-inner'], + ]); + expect( + extractToolDefinitions(tree, 'server.ts', 0, bindings).map((tool) => [ + tool.toolName, + tool.handlerNodeId, + ]), + ).toEqual([ + ['inner', 'emitted-inner'], + ['outer', 'emitted-outer'], + ]); + }); + + it('keeps ambiguous, shadowed, mutable and noncallable handler bindings unresolved', () => { + const tree = tsParser.parse(`${server} + import { imported } from './handlers'; + function handler() {} + function parameter(handler) { server.tool('parameter', handler); } + { const handler = 42; server.tool('shadowed', handler); } + const alias = handler; + server.tool('alias', alias); + server.tool('imported', imported); + let mutable = () => {}; + server.tool('mutable', mutable); + const reassigned = () => {}; + ({ reassigned } = replacements); + server.tool('reassigned', reassigned); + function duplicate() {} + function duplicate() {} + server.tool('duplicate', duplicate); + server.tool('before-initialization', later); + const later = () => {}; + `); + const bindings = new Map(); + // Even graph nodes sharing these names cannot establish a safe callback binding. + for (const node of tree.rootNode.descendantsOfType('identifier')) + bindings.set(node.id, `emitted:${node.text}`); + const tools = extractToolDefinitions(tree, 'server.ts', 0, bindings); + expect(tools.map((tool) => tool.toolName)).toEqual([ + 'parameter', + 'shadowed', + 'alias', + 'imported', + 'mutable', + 'reassigned', + 'duplicate', + 'before-initialization', + ]); + expect( + tools.every((tool) => tool.handlerNodeId === undefined && tool.allowFileFallback === false), + ).toBe(true); + }); +}); diff --git a/gitnexus/test/unit/uninstall.test.ts b/gitnexus/test/unit/uninstall.test.ts index ad20eb5ca..5c3a2e285 100644 --- a/gitnexus/test/unit/uninstall.test.ts +++ b/gitnexus/test/unit/uninstall.test.ts @@ -23,6 +23,7 @@ describe('uninstallCommand', () => { let tempHome: string; let originalHome: string | undefined; let originalUserProfile: string | undefined; + let originalCodexHome: string | undefined; let originalSkillsRoot: string | undefined; let originalExitCode: typeof process.exitCode; let skillsRoot: string; @@ -35,6 +36,8 @@ describe('uninstallCommand', () => { originalHome = process.env.HOME; originalUserProfile = process.env.USERPROFILE; + originalCodexHome = process.env.CODEX_HOME; + delete process.env.CODEX_HOME; originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT; originalExitCode = process.exitCode; @@ -57,6 +60,8 @@ describe('uninstallCommand', () => { vi.restoreAllMocks(); process.env.HOME = originalHome; process.env.USERPROFILE = originalUserProfile; + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; if (originalSkillsRoot === undefined) delete process.env.GITNEXUS_TEST_SKILLS_ROOT; else process.env.GITNEXUS_TEST_SKILLS_ROOT = originalSkillsRoot; // The command sets process.exitCode=1 on partial failure; restore it so a @@ -203,6 +208,52 @@ describe('uninstallCommand', () => { expect(result).toContain('command = "other"'); }); + it('dry-runs and removes only the active CODEX_HOME MCP entry', async () => { + const activeHome = path.join(tempHome, 'active-codex'); + const activeConfig = path.join(activeHome, 'config.toml'); + const defaultConfig = path.join(tempHome, '.codex', 'config.toml'); + const activeHooks = path.join(activeHome, 'hooks.json'); + const defaultHooks = path.join(tempHome, '.codex', 'hooks.json'); + const activeScriptDir = path.join(activeHome, 'hooks', 'gitnexus'); + const defaultScriptDir = path.join(tempHome, '.codex', 'hooks', 'gitnexus'); + const activeRaw = '[mcp_servers.gitnexus]\ncommand = "active"\n'; + const defaultRaw = '[mcp_servers.gitnexus]\ncommand = "default"\n'; + const hooksRaw = JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: 'Bash', hooks: [{ type: 'command', command: 'node gitnexus-hook.cjs' }] }, + { matcher: 'Read', hooks: [{ type: 'command', command: 'user-hook' }] }, + ], + }, + }); + await fs.mkdir(activeHome); + await fs.mkdir(path.dirname(defaultConfig)); + await fs.mkdir(activeScriptDir, { recursive: true }); + await fs.mkdir(defaultScriptDir, { recursive: true }); + await fs.writeFile(activeConfig, activeRaw); + await fs.writeFile(defaultConfig, defaultRaw); + await fs.writeFile(activeHooks, hooksRaw); + await fs.writeFile(defaultHooks, hooksRaw); + process.env.CODEX_HOME = activeHome; + + const uninstallCommand = await importUninstall(); + await uninstallCommand(); + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain(activeConfig); + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain(activeHooks); + expect(await fs.readFile(activeConfig, 'utf-8')).toBe(activeRaw); + expect(await fs.readFile(activeHooks, 'utf-8')).toBe(hooksRaw); + + await uninstallCommand({ force: true }); + expect(await fs.readFile(activeConfig, 'utf-8')).not.toContain('[mcp_servers.gitnexus]'); + expect(await fs.readFile(defaultConfig, 'utf-8')).toBe(defaultRaw); + const remaining = JSON.parse(await fs.readFile(activeHooks, 'utf-8')); + expect(remaining.hooks.PreToolUse).toHaveLength(1); + expect(remaining.hooks.PreToolUse[0].hooks[0].command).toBe('user-hook'); + expect(await fs.readFile(defaultHooks, 'utf-8')).toBe(hooksRaw); + await expect(fs.access(activeScriptDir)).rejects.toThrow(); + await expect(fs.access(defaultScriptDir)).resolves.toBeUndefined(); + }); + it('leaves a corrupt JSON config untouched', async () => { const claudeJson = path.join(tempHome, '.claude.json'); const corrupt = '{ not valid json !!!'; diff --git a/gitnexus/test/unit/upload-ingest.test.ts b/gitnexus/test/unit/upload-ingest.test.ts index 03d04d49d..ed31b37a6 100644 --- a/gitnexus/test/unit/upload-ingest.test.ts +++ b/gitnexus/test/unit/upload-ingest.test.ts @@ -43,7 +43,6 @@ describe('resolveContainedDest', () => { }); it('rejects non-string input', () => { - // @ts-expect-error testing runtime guard expect(() => resolveContainedDest(ROOT, ['a', 'b'])).toThrow(); }); diff --git a/gitnexus/test/unit/variable-extraction.test.ts b/gitnexus/test/unit/variable-extraction.test.ts index b9b2e7279..e0502545c 100644 --- a/gitnexus/test/unit/variable-extraction.test.ts +++ b/gitnexus/test/unit/variable-extraction.test.ts @@ -894,7 +894,7 @@ describeKotlin('VariableExtractor — Kotlin (F51 destructuring)', () => { /** The first property_declaration whose text starts with `prefix`. */ function propertyDecl(src: string, prefix: string): SyntaxNode { - parser.setLanguage(Kotlin as Parser.Language); + parser.setLanguage(Kotlin); const tree = parser.parse(src); let found: SyntaxNode | undefined; const walk = (n: SyntaxNode) => { diff --git a/gitnexus/test/unit/watch-queue.test.ts b/gitnexus/test/unit/watch-queue.test.ts index 260008a96..741778aee 100644 --- a/gitnexus/test/unit/watch-queue.test.ts +++ b/gitnexus/test/unit/watch-queue.test.ts @@ -24,7 +24,9 @@ describe('WatchRefreshQueue', () => { const batches: readonly string[][] = []; const mutable = batches as string[][]; const queue = new WatchRefreshQueue( - async (paths) => mutable.push([...paths]), + async (paths) => { + mutable.push([...paths]); + }, () => {}, 100, ); @@ -102,7 +104,9 @@ describe('WatchRefreshQueue', () => { vi.useFakeTimers(); const batches: string[][] = []; const queue = new WatchRefreshQueue( - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, () => {}, 50, { holdEventsUntilInitialRefresh: true }, @@ -250,7 +254,9 @@ describe('WatchRefreshQueue', () => { vi.useFakeTimers(); const batches: string[][] = []; const queue = new WatchRefreshQueue( - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, () => {}, 100, { maxWaitMs: 250 }, @@ -271,7 +277,9 @@ describe('WatchRefreshQueue', () => { vi.useFakeTimers(); const batches: string[][] = []; const queue = new WatchRefreshQueue( - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, () => {}, 10, { @@ -292,7 +300,9 @@ describe('WatchRefreshQueue', () => { vi.useFakeTimers(); const batches: string[][] = []; const queue = new WatchRefreshQueue( - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, () => {}, 10, { @@ -314,7 +324,9 @@ describe('WatchRefreshQueue', () => { vi.useFakeTimers(); const batches: string[][] = []; const queue = new WatchRefreshQueue( - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, () => {}, 10, { maxPendingPaths: 2 }, @@ -333,7 +345,9 @@ describe('WatchRefreshQueue', () => { vi.useFakeTimers(); const batches: string[][] = []; const queue = new WatchRefreshQueue( - async (paths) => batches.push([...paths]), + async (paths) => { + batches.push([...paths]); + }, () => {}, 10, { maxPendingPaths: 0 }, diff --git a/gitnexus/test/unit/wiki-db-pin.test.ts b/gitnexus/test/unit/wiki-db-pin.test.ts index 8af799710..3cba3d7ae 100644 --- a/gitnexus/test/unit/wiki-db-pin.test.ts +++ b/gitnexus/test/unit/wiki-db-pin.test.ts @@ -84,6 +84,8 @@ describe('WikiGenerator DB pinning', () => { model: 'test-model', baseUrl: 'http://127.0.0.1:1/v1', apiKey: 'test', + maxTokens: 1024, + temperature: 0, }, { force: true, reviewOnly: true }, ); diff --git a/gitnexus/test/unit/wiki-flags.test.ts b/gitnexus/test/unit/wiki-flags.test.ts index 6ffd46d57..935b8953b 100644 --- a/gitnexus/test/unit/wiki-flags.test.ts +++ b/gitnexus/test/unit/wiki-flags.test.ts @@ -1532,7 +1532,7 @@ describe('local agent CLI calls', () => { return stdinText; }); - const spawnSpy = vi.fn(() => child); + const spawnSpy = vi.fn(() => child); vi.doMock('child_process', () => ({ execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), spawn: spawnSpy, @@ -1583,7 +1583,7 @@ describe('local agent CLI calls', () => { }); }); - const spawnSpy = vi.fn(() => child); + const spawnSpy = vi.fn(() => child); vi.doMock('child_process', () => ({ execFileSync: vi.fn().mockReturnValue('opencode 1.15.13'), spawn: spawnSpy, @@ -1780,7 +1780,7 @@ describe('local agent CLI calls', () => { }); }); - const spawnSpy = vi.fn(() => child); + const spawnSpy = vi.fn(() => child); vi.doMock('child_process', () => ({ execFileSync: vi.fn().mockReturnValue('codex-cli 0.132.0'), spawn: spawnSpy, diff --git a/gitnexus/test/unit/worker-pool-ready-timeout-env.test.ts b/gitnexus/test/unit/worker-pool-ready-timeout-env.test.ts index f64135974..b61d68b38 100644 --- a/gitnexus/test/unit/worker-pool-ready-timeout-env.test.ts +++ b/gitnexus/test/unit/worker-pool-ready-timeout-env.test.ts @@ -15,6 +15,7 @@ */ import { describe, expect, it, beforeEach, afterEach } from 'vitest'; import { EventEmitter } from 'node:events'; +import type { Worker } from 'node:worker_threads'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs'; @@ -63,9 +64,10 @@ describe('worker pool — GITNEXUS_WORKER_READY_TIMEOUT_MS override', () => { workerFactory: () => new NeverReadyWorker() as unknown as Worker, }); - const err = await pool - .dispatch([{ path: 'a.ts', content: 'x' }]) - .catch((e: unknown) => e as InstanceType); + const err = await pool.dispatch([{ path: 'a.ts', content: 'x' }]).catch((e: unknown) => e); + if (!(err instanceof WorkerPoolInitializationError)) { + throw new Error('expected worker initialization failure'); + } expect(err).toBeInstanceOf(WorkerPoolInitializationError); expect(err.readinessFailures.join('\n')).toContain('within 50ms'); @@ -79,9 +81,10 @@ describe('worker pool — GITNEXUS_WORKER_READY_TIMEOUT_MS override', () => { workerFactory: () => new NeverReadyWorker() as unknown as Worker, }); - const err = await pool - .dispatch([{ path: 'a.ts', content: 'x' }]) - .catch((e: unknown) => e as InstanceType); + const err = await pool.dispatch([{ path: 'a.ts', content: 'x' }]).catch((e: unknown) => e); + if (!(err instanceof WorkerPoolInitializationError)) { + throw new Error('expected worker initialization failure'); + } expect(err).toBeInstanceOf(WorkerPoolInitializationError); expect(err.readinessFailures.join('\n')).toContain('within 5000ms'); diff --git a/gitnexus/test/unit/worker-pool-resilience.test.ts b/gitnexus/test/unit/worker-pool-resilience.test.ts index 1427f45f1..997773e9a 100644 --- a/gitnexus/test/unit/worker-pool-resilience.test.ts +++ b/gitnexus/test/unit/worker-pool-resilience.test.ts @@ -286,7 +286,7 @@ describe('worker pool resilience', () => { expect(results).toEqual([{ fileCount: 2 }]); // Two bad files quarantined; both pre-crash 'starting-file' targets. - expect(pool.getQuarantinedPaths().sort()).toEqual(['src/a.ts', 'src/b.ts']); + expect([...pool.getQuarantinedPaths()].sort()).toEqual(['src/a.ts', 'src/b.ts']); await pool.terminate(); }); @@ -306,7 +306,7 @@ describe('worker pool resilience', () => { ]), ).rejects.toBeInstanceOf(WorkerPoolDispatchError); - expect(pool.getQuarantinedPaths().sort()).toEqual(['src/x.ts', 'src/y.ts']); + expect([...pool.getQuarantinedPaths()].sort()).toEqual(['src/x.ts', 'src/y.ts']); // Subsequent dispatch on a tripped pool rejects without running anything. await expect( pool.dispatch<{ path: string; content: string }, unknown>([ @@ -348,7 +348,7 @@ describe('worker pool resilience', () => { { path: 'src/ok.ts', content: '' }, ]); expect(r2).toEqual([{ fileCount: 1 }]); - expect(pool.getQuarantinedPaths().sort()).toEqual(['src/bad.ts', 'src/bad2.ts']); + expect([...pool.getQuarantinedPaths()].sort()).toEqual(['src/bad.ts', 'src/bad2.ts']); await pool.terminate(); }); diff --git a/gitnexus/test/unit/worker-pool-startup-stderr.test.ts b/gitnexus/test/unit/worker-pool-startup-stderr.test.ts index b09568e95..67fa0cd7d 100644 --- a/gitnexus/test/unit/worker-pool-startup-stderr.test.ts +++ b/gitnexus/test/unit/worker-pool-startup-stderr.test.ts @@ -15,6 +15,7 @@ */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { EventEmitter } from 'node:events'; +import type { Worker } from 'node:worker_threads'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs'; @@ -93,7 +94,10 @@ describe('worker pool — startup stderr surfacing (#1741)', () => { const dispatch = pool.dispatch([{ path: 'src/a.ts', content: 'x' }]); await expect(dispatch).rejects.toBeInstanceOf(WorkerPoolInitializationError); - const err = await dispatch.catch((e: unknown) => e as WorkerPoolInitializationError); + const err = await dispatch.catch((e: unknown) => e); + if (!(err instanceof WorkerPoolInitializationError)) { + throw new Error('expected worker initialization failure'); + } expect(err.readinessFailures.length).toBeGreaterThan(0); // The real crash reason, recovered from the worker's stderr, is present. const joined = err.readinessFailures.join('\n'); @@ -127,7 +131,7 @@ describe('worker pool — startup self-healing (#1741)', () => { expect(pool.getStats().poolBroken).toBe(false); // R1 (clear-on-settle): the ref'd backoff timer self-cleared when it fired, // so no startup timer lingers after the slot's retry loop exited. - expect(pool.getStats().pendingStartupTimers).toBe(0); + expect(pool.getStats()).toHaveProperty('pendingStartupTimers', 0); await pool.terminate().catch(() => undefined); }); @@ -143,9 +147,10 @@ describe('worker pool — startup self-healing (#1741)', () => { }, }); - const err = await pool - .dispatch([{ path: 'a.ts', content: 'x' }]) - .catch((e: unknown) => e as WorkerPoolInitializationError); + const err = await pool.dispatch([{ path: 'a.ts', content: 'x' }]).catch((e: unknown) => e); + if (!(err instanceof WorkerPoolInitializationError)) { + throw new Error('expected worker initialization failure'); + } expect(err).toBeInstanceOf(WorkerPoolInitializationError); expect(err.crashClass).toBe('deterministic-startup'); @@ -193,9 +198,10 @@ describe('worker pool — startup self-healing (#1741)', () => { }, }); - const err = await pool - .dispatch([{ path: 'a.ts', content: 'x' }]) - .catch((e: unknown) => e as WorkerPoolInitializationError); + const err = await pool.dispatch([{ path: 'a.ts', content: 'x' }]).catch((e: unknown) => e); + if (!(err instanceof WorkerPoolInitializationError)) { + throw new Error('expected worker initialization failure'); + } expect(err).toBeInstanceOf(WorkerPoolInitializationError); expect(err.crashClass).toBe('transient-exhausted'); @@ -217,9 +223,10 @@ describe('worker pool — startup self-healing (#1741)', () => { }, }); - const err = await pool - .dispatch([{ path: 'a.ts', content: 'x' }]) - .catch((e: unknown) => e as WorkerPoolInitializationError); + const err = await pool.dispatch([{ path: 'a.ts', content: 'x' }]).catch((e: unknown) => e); + if (!(err instanceof WorkerPoolInitializationError)) { + throw new Error('expected worker initialization failure'); + } expect(err.crashClass).toBe('deterministic-startup'); expect(calls).toBe(2); // attempt 0 + one respawn that reproduced @@ -246,7 +253,7 @@ describe('worker pool — startup self-healing (#1741)', () => { await pool.terminate(); expect(pool.getStats().terminated).toBe(true); // No ref'd backoff timer left pinning the loop after terminate. - expect(pool.getStats().pendingStartupTimers).toBe(0); + expect(pool.getStats()).toHaveProperty('pendingStartupTimers', 0); expect(pool.getStats().activeSlots).toBe(0); // No worker is spawned after terminate — the woken loop sees `terminated` and gives up. diff --git a/gitnexus/test/unit/worker-pool-stdout-forward.test.ts b/gitnexus/test/unit/worker-pool-stdout-forward.test.ts index 79e2654de..a6ab9c871 100644 --- a/gitnexus/test/unit/worker-pool-stdout-forward.test.ts +++ b/gitnexus/test/unit/worker-pool-stdout-forward.test.ts @@ -15,6 +15,7 @@ */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { EventEmitter } from 'node:events'; +import type { Worker } from 'node:worker_threads'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs'; diff --git a/gitnexus/test/unit/write-lock-required.test.ts b/gitnexus/test/unit/write-lock-required.test.ts index c4d3a5bd2..617f4205b 100644 --- a/gitnexus/test/unit/write-lock-required.test.ts +++ b/gitnexus/test/unit/write-lock-required.test.ts @@ -16,7 +16,7 @@ vi.mock('../../src/storage/index-lock.js', async (original) => ({ vi.mock('../../src/core/ingestion/pipeline.js', () => ({ runPipelineFromRepo: pipeline })); vi.mock('../../src/storage/repo-manager.js', async (original) => ({ ...(await original()), - resolveBranchPlacement: (...args: unknown[]) => resolvePlacement(...args), + resolveBranchPlacement: resolvePlacement, })); import { runFullAnalysis } from '../../src/core/run-analyze.js'; diff --git a/gitnexus/test/unit/write-reconciliation.test.ts b/gitnexus/test/unit/write-reconciliation.test.ts new file mode 100644 index 000000000..d4d4a86ea --- /dev/null +++ b/gitnexus/test/unit/write-reconciliation.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest'; +import { reconcileGraphNodeIdentities } from '../../src/core/incremental/write-reconciliation.js'; +import { buildTestGraph } from '../helpers/test-graph.js'; + +const graph = () => + buildTestGraph( + [ + { + id: 'Function:src/lock.ts:acquire', + label: 'Function', + name: 'acquire', + filePath: 'src/lock.ts', + startLine: 504, + endLine: 719, + }, + { + id: 'Function:bench/measure.mjs:retainedHeapBytes', + label: 'Function', + name: 'retainedHeapBytes', + filePath: 'bench/measure.mjs', + startLine: 437, + endLine: 453, + }, + ], + [], + ); +const rows = () => [ + { + id: 'Function:src/lock.ts:acquire', + name: 'acquire', + filePath: 'src/lock.ts', + startLine: 504, + endLine: 719, + }, + { + id: 'Function:bench/measure.mjs:retainedHeapBytes', + name: 'retainedHeapBytes', + filePath: 'bench/measure.mjs', + startLine: 437, + endLine: 453, + }, +]; +const queryFor = (functions: ReturnType) => + vi.fn(async (query: string) => (query.includes('(n:`Function`)') ? functions : [])); + +describe('incremental identity reconciliation', () => { + it('certifies every identity using one unfiltered query per label', async () => { + const query = queryFor(rows()); + const receipt = await reconcileGraphNodeIdentities(graph(), query, 'post-COPY'); + expect(receipt.nodes).toBe(2); + expect(query).toHaveBeenCalledTimes(receipt.tables); + for (const [cypher] of query.mock.calls) { + expect(cypher).toMatch(/^MATCH \(n:`\w+`\) RETURN/); + expect(cypher).not.toMatch(/WHERE|content/); + } + }); + + it('rejects a missing unchanged symbol', async () => { + await expect( + reconcileGraphNodeIdentities(graph(), queryFor(rows().slice(1)), 'post-COPY'), + ).rejects.toThrow(/1 missing ID.*src\/lock.ts:acquire/); + }); + + it.each(['name', 'filePath', 'startLine', 'endLine'] as const)( + 'rejects column/row inconsistency in %s despite identical ID counts', + async (field) => { + const persisted = rows(); + const swapped = persisted.map((row, index) => ({ + ...row, + [field]: persisted[1 - index][field], + })); + await expect( + reconcileGraphNodeIdentities(graph(), queryFor(swapped), 'pre-publish'), + ).rejects.toThrow(field); + }, + ); + + it('rejects a duplicated row that would hide a missing ID in a count check', async () => { + await expect( + reconcileGraphNodeIdentities(graph(), queryFor([rows()[0], rows()[0]]), 'post-COPY'), + ).rejects.toThrow(/duplicate ID/); + }); + + it('surfaces a failed scan rather than certifying an unreadable graph', async () => { + const query = vi.fn().mockRejectedValue(new Error('Invalid UTF-8')); + await expect(reconcileGraphNodeIdentities(graph(), query, 'pre-publish')).rejects.toThrow( + /could not read identity fields.*Invalid UTF-8/, + ); + }); +}); diff --git a/gitnexus/tsconfig.test.json b/gitnexus/tsconfig.test.json index 425ff7485..3f22f556a 100644 --- a/gitnexus/tsconfig.test.json +++ b/gitnexus/tsconfig.test.json @@ -6,5 +6,5 @@ "types": ["node", "vitest/globals"] }, "include": ["src/**/*", "test/**/*"], - "exclude": ["test/fixtures/mini-repo/**", "test/fixtures/sample-code/**"] + "exclude": ["node_modules", "test/fixtures/**"] } diff --git a/gitnexus/vendor/lbug-fts/manifest.json b/gitnexus/vendor/lbug-fts/manifest.json index 6d93ddd03..62c4da0c7 100644 --- a/gitnexus/vendor/lbug-fts/manifest.json +++ b/gitnexus/vendor/lbug-fts/manifest.json @@ -1,6 +1,6 @@ { - "coreVersion": "0.18.3", - "extensionVersion": "0.18.1", + "coreVersion": "0.21.1", + "extensionVersion": "0.21.0", "filename": "libfts.lbug_extension", "officialRepo": "https://extension.ladybugdb.com/", "urlTemplate": "{officialRepo}v{extensionVersion}/{upstreamPlatform}/fts/{filename}", diff --git a/gitnexus/vendor/lbug-fts/prebuilds/SHA256SUMS b/gitnexus/vendor/lbug-fts/prebuilds/SHA256SUMS index 3aa968510..9683a3704 100644 --- a/gitnexus/vendor/lbug-fts/prebuilds/SHA256SUMS +++ b/gitnexus/vendor/lbug-fts/prebuilds/SHA256SUMS @@ -1,5 +1,5 @@ -01cf0d4debae1b0c7c182bf78747ee1b148a76667bbaa5bb0cb3cc848998028d ./win32-x64/libfts.lbug_extension -4af2602007a4a02d5b18d0b6ecb20b1cc2f493242a915faf7df1c033136107b1 ./linux-arm64/libfts.lbug_extension -cf687fda0f82bfbe116e775f2f17c39faf45be6300fd1937c2b1afd21142c121 ./linux-x64/libfts.lbug_extension -d3564c05ec28a0293a5488eaff2c06591624ac01a44af183d8cfacd92a29d785 ./darwin-arm64/libfts.lbug_extension -d8589c0a91c6667e959da6a81f712f69b330d0563602933da983edeebee60fc6 ./darwin-x64/libfts.lbug_extension +248332de781a7781f612a55b5397e88f58f1f1ba2a973836ada199eab0f6bf10 ./darwin-arm64/libfts.lbug_extension +742f2756c2f80bcd1886b6ee0446483038cff0ecf6cf47f698bc6fe855cbaef6 ./linux-x64/libfts.lbug_extension +7b6132ae6a554e3eeb3c4b0da7e2eb5c483b258b31e848a0e3d6000ac29885ee ./win32-x64/libfts.lbug_extension +b8675b086664e3d6742330a517c5b619f0ac10dd094d250f12bef9822a8d5750 ./linux-arm64/libfts.lbug_extension +c86f427503555a3ccdc55eb66a8d82793a206aac599a6092f7d1d4eb50c21403 ./darwin-x64/libfts.lbug_extension diff --git a/gitnexus/vendor/lbug-fts/prebuilds/darwin-arm64/libfts.lbug_extension b/gitnexus/vendor/lbug-fts/prebuilds/darwin-arm64/libfts.lbug_extension index 31f85a0da..53bcff048 100644 Binary files a/gitnexus/vendor/lbug-fts/prebuilds/darwin-arm64/libfts.lbug_extension and b/gitnexus/vendor/lbug-fts/prebuilds/darwin-arm64/libfts.lbug_extension differ diff --git a/gitnexus/vendor/lbug-fts/prebuilds/darwin-x64/libfts.lbug_extension b/gitnexus/vendor/lbug-fts/prebuilds/darwin-x64/libfts.lbug_extension index b0fd3b7fd..6fa05c0da 100644 Binary files a/gitnexus/vendor/lbug-fts/prebuilds/darwin-x64/libfts.lbug_extension and b/gitnexus/vendor/lbug-fts/prebuilds/darwin-x64/libfts.lbug_extension differ diff --git a/gitnexus/vendor/lbug-fts/prebuilds/linux-arm64/libfts.lbug_extension b/gitnexus/vendor/lbug-fts/prebuilds/linux-arm64/libfts.lbug_extension index 41f2fa575..bf4c01920 100644 Binary files a/gitnexus/vendor/lbug-fts/prebuilds/linux-arm64/libfts.lbug_extension and b/gitnexus/vendor/lbug-fts/prebuilds/linux-arm64/libfts.lbug_extension differ diff --git a/gitnexus/vendor/lbug-fts/prebuilds/linux-x64/libfts.lbug_extension b/gitnexus/vendor/lbug-fts/prebuilds/linux-x64/libfts.lbug_extension index 0971ff059..f7ab6c269 100644 Binary files a/gitnexus/vendor/lbug-fts/prebuilds/linux-x64/libfts.lbug_extension and b/gitnexus/vendor/lbug-fts/prebuilds/linux-x64/libfts.lbug_extension differ diff --git a/gitnexus/vendor/lbug-fts/prebuilds/win32-x64/libfts.lbug_extension b/gitnexus/vendor/lbug-fts/prebuilds/win32-x64/libfts.lbug_extension index 093f8b7f9..dec711432 100644 Binary files a/gitnexus/vendor/lbug-fts/prebuilds/win32-x64/libfts.lbug_extension and b/gitnexus/vendor/lbug-fts/prebuilds/win32-x64/libfts.lbug_extension differ diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 29d44bf92..77b457172 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -8,6 +8,10 @@ export default defineConfig({ hookTimeout: 120000, pool: 'forks', globals: true, + // Keep the Vitest 4 mock lifecycle while upgrading the runner to Vitest 5. + clearMocks: false, + // Stable test identity across the Linux/macOS/Windows execution receipts. + includeTaskLocation: true, teardownTimeout: 3000, // E2E harnesses pin a small NODE_OPTIONS heap so spawned CLI children // stay light; without this opt-out the #2649 auto-heap override would @@ -68,6 +72,8 @@ export default defineConfig({ include: [ 'test/integration/skip-fts.test.ts', 'test/integration/impact-callable-value-references.test.ts', + 'test/integration/impact-context-integrity.test.ts', + 'test/integration/incremental-worktree-context.test.ts', 'test/integration/impact-epistemic-lower-bound.test.ts', 'test/integration/impact-scope-omission-persistence.test.ts', 'test/integration/lbug-core-adapter.test.ts', @@ -97,6 +103,7 @@ export default defineConfig({ 'test/integration/class-impact-all-languages.test.ts', 'test/integration/lbug-orphan-sidecar-recovery.test.ts', 'test/integration/lbug-interrupted-checkpoint-recovery.test.ts', + 'test/integration/lbug-incremental-search.test.ts', 'test/integration/lbug-readonly-init.test.ts', // Shared sibling store (#3352): each file runs real analyses and opens // the resulting LadybugDB graphs. @@ -109,6 +116,7 @@ export default defineConfig({ 'test/integration/analyze-wal-checkpoint-failure.test.ts', 'test/integration/lbug-non-ascii-path.test.ts', 'test/integration/lbug-conn-serialization.test.ts', + 'test/integration/lbug-load-overlap-errors.test.ts', 'test/integration/load-cached-embeddings-spill.test.ts', 'test/integration/group/manifest-resolve-symbol-2325.test.ts', 'test/integration/group/manifest-synthetic-impact-lbug.test.ts', @@ -130,6 +138,8 @@ export default defineConfig({ // fake that answers on `query.includes(...)`. 'test/integration/wiki-graph-queries-engine.test.ts', 'test/unit/incremental-dirty-recovery.test.ts', + // Publication reconciliation uses real native COPY/checkpoints. + 'test/unit/incremental-write-integrity.test.ts', 'test/unit/incremental-orchestration.test.ts', // #2841. Native @ladybugdb/core: it runs real analyses, reopens the // DB under different extension-install policies, and reads @@ -159,6 +169,8 @@ export default defineConfig({ exclude: [ 'test/integration/skip-fts.test.ts', 'test/integration/impact-callable-value-references.test.ts', + 'test/integration/impact-context-integrity.test.ts', + 'test/integration/incremental-worktree-context.test.ts', 'test/integration/impact-epistemic-lower-bound.test.ts', 'test/integration/impact-scope-omission-persistence.test.ts', 'test/integration/lbug-core-adapter.test.ts', @@ -186,10 +198,12 @@ export default defineConfig({ 'test/integration/class-impact-all-languages.test.ts', 'test/integration/lbug-orphan-sidecar-recovery.test.ts', 'test/integration/lbug-interrupted-checkpoint-recovery.test.ts', + 'test/integration/lbug-incremental-search.test.ts', 'test/integration/lbug-readonly-init.test.ts', 'test/integration/analyze-wal-checkpoint-failure.test.ts', 'test/integration/lbug-non-ascii-path.test.ts', 'test/integration/lbug-conn-serialization.test.ts', + 'test/integration/lbug-load-overlap-errors.test.ts', 'test/integration/load-cached-embeddings-spill.test.ts', 'test/integration/group/manifest-resolve-symbol-2325.test.ts', 'test/integration/group/manifest-synthetic-impact-lbug.test.ts', @@ -206,6 +220,7 @@ export default defineConfig({ 'test/integration/detect-changes-path-anchoring.test.ts', 'test/integration/wiki-graph-queries-engine.test.ts', 'test/unit/incremental-dirty-recovery.test.ts', + 'test/unit/incremental-write-integrity.test.ts', 'test/unit/incremental-orchestration.test.ts', // Excluded here because it is included by `lbug-db` above; a file // in two projects would be collected (and run) twice.