diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 163376ab0..46a2c16de 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -108,7 +108,7 @@ jobs: release-candidates/${{ matrix.goos }}-${{ matrix.goarch }}/unstripped/* release-candidates/${{ matrix.goos }}-${{ matrix.goarch }}/THIRD_PARTY_NOTICES.md - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-candidates-${{ matrix.goos }}-${{ matrix.goarch }} path: release-candidates/${{ matrix.goos }}-${{ matrix.goarch }} @@ -166,7 +166,7 @@ jobs: release-candidates/windows-amd64/unstripped/* release-candidates/windows-amd64/THIRD_PARTY_NOTICES.md - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-candidates-windows-amd64 path: release-candidates/windows-amd64 @@ -228,7 +228,7 @@ jobs: release-candidates/windows-arm64/unstripped/* release-candidates/windows-arm64/THIRD_PARTY_NOTICES.md - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-candidates-windows-arm64 path: release-candidates/windows-arm64 @@ -292,7 +292,7 @@ jobs: release-candidates/linux-${{ matrix.arch }}-portable/unstripped/* release-candidates/linux-${{ matrix.arch }}-portable/THIRD_PARTY_NOTICES.md - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-candidates-linux-${{ matrix.arch }}-portable path: release-candidates/linux-${{ matrix.arch }}-portable @@ -317,7 +317,7 @@ jobs: persist-credentials: false - name: Download all native candidate pairs - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: release-candidates-* path: ${{ runner.temp }}/release-candidate-artifacts @@ -446,7 +446,7 @@ jobs: - name: Preserve candidate selection evidence if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-selection-evidence path: selection-evidence @@ -455,56 +455,56 @@ jobs: # Keep the established artifact names so the reusable smoke workflow and # all callers consume only the selected, byte-verified containers. - name: Upload linux-amd64 containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-linux-amd64 path: release-containers/linux-amd64/* if-no-files-found: error - name: Upload linux-arm64 containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-linux-arm64 path: release-containers/linux-arm64/* if-no-files-found: error - name: Upload linux-amd64-portable containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-linux-amd64-portable path: release-containers/linux-amd64-portable/* if-no-files-found: error - name: Upload linux-arm64-portable containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-linux-arm64-portable path: release-containers/linux-arm64-portable/* if-no-files-found: error - name: Upload darwin-amd64 containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-darwin-amd64 path: release-containers/darwin-amd64/* if-no-files-found: error - name: Upload darwin-arm64 containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-darwin-arm64 path: release-containers/darwin-arm64/* if-no-files-found: error - name: Upload windows-amd64 containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-windows-amd64 path: release-containers/windows-amd64/* if-no-files-found: error - name: Upload windows-arm64 containers - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binaries-windows-arm64 path: release-containers/windows-arm64/* diff --git a/.github/workflows/_memwaste.yml b/.github/workflows/_memwaste.yml index 52ae8a8fb..08e197c5d 100644 --- a/.github/workflows/_memwaste.yml +++ b/.github/workflows/_memwaste.yml @@ -52,7 +52,7 @@ jobs: run: python3 scripts/memwaste-ratchet.py --out "$RUNNER_TEMP/memwaste-event" --venue ${{ inputs.mode }}-linux - name: Upload report if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memwaste-event-linux-${{ inputs.mode }} path: | @@ -82,7 +82,7 @@ jobs: --out "$RUNNER_TEMP/memwaste-scaling" | tee "$RUNNER_TEMP/memwaste-scaling/report.txt" - name: Upload report if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memwaste-scaling-linux-${{ inputs.mode }} path: ${{ runner.temp }}/memwaste-scaling/report.txt @@ -108,7 +108,7 @@ jobs: --out "$RUNNER_TEMP/memwaste-access" | tee "$RUNNER_TEMP/memwaste-access/report.txt" - name: Upload report if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memwaste-access-linux-${{ inputs.mode }} path: | @@ -135,7 +135,7 @@ jobs: run: python3 scripts/memwaste-ratchet.py --out "$RUNNER_TEMP/memwaste-event" --venue full-macos - name: Upload report if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memwaste-event-macos-${{ inputs.mode }} path: | @@ -177,7 +177,7 @@ jobs: run: python3 scripts/memwaste-ratchet.py --out "$(cygpath -u "$RUNNER_TEMP")/memwaste-event" --venue full-windows - name: Upload report if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memwaste-event-windows-${{ inputs.mode }} path: | diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index ac4ca595d..cfe022a6a 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -103,7 +103,7 @@ jobs: go-version-file: pkg/go/go.mod cache: false - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: binaries-${{ matrix.goos }}-${{ matrix.goarch }} @@ -203,7 +203,7 @@ jobs: zip coreutils - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: binaries-windows-${{ matrix.arch }} @@ -303,7 +303,7 @@ jobs: go-version-file: pkg/go/go.mod cache: false - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: binaries-linux-${{ matrix.arch }}-portable diff --git a/.github/workflows/_soak.yml b/.github/workflows/_soak.yml index c25a1988d..fe82cfb8a 100644 --- a/.github/workflows/_soak.yml +++ b/.github/workflows/_soak.yml @@ -73,7 +73,7 @@ jobs: run: scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} - name: Download selected release container if: ${{ inputs.use_release_artifacts }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: binaries-${{ matrix.goos }}-${{ matrix.goarch }} path: ${{ runner.temp }}/cbm-selected-container @@ -98,14 +98,14 @@ jobs: scripts/soak-legs.sh --legs "${{ inputs.legs }}" "$binary" ${{ inputs.duration_minutes }} - name: Upload metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-quick-${{ matrix.goos }}-${{ matrix.goarch }} path: soak-results/ retention-days: 14 - name: Upload query-leak metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-query-leak-${{ matrix.goos }}-${{ matrix.goarch }} path: soak-results-query-leak/ @@ -147,7 +147,7 @@ jobs: run: scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=clang CXX=clang++ - name: Download selected release container if: ${{ inputs.use_release_artifacts }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: binaries-windows-amd64 path: ${{ runner.temp }}/cbm-selected-container @@ -175,14 +175,14 @@ jobs: scripts/soak-legs.sh --legs "${{ inputs.legs }}" "$binary" ${{ inputs.duration_minutes }} - name: Upload metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-quick-windows-amd64 path: soak-results/ retention-days: 14 - name: Upload query-leak metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-query-leak-windows-amd64 path: soak-results-query-leak/ @@ -225,7 +225,7 @@ jobs: run: scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=clang CXX=clang++ - name: Download selected release container if: ${{ inputs.use_release_artifacts }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: binaries-windows-arm64 path: ${{ runner.temp }}/cbm-selected-container @@ -253,14 +253,14 @@ jobs: scripts/soak-legs.sh --legs "${{ inputs.legs }}" "$binary" ${{ inputs.duration_minutes }} - name: Upload metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-quick-windows-arm64 path: soak-results/ retention-days: 14 - name: Upload query-leak metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-query-leak-windows-arm64 path: soak-results-query-leak/ @@ -287,7 +287,7 @@ jobs: timeout-minutes: 10 run: APT_RETRIES=3; [ -f /etc/apt/apt-mirrors.txt ] && APT_RETRIES=0; sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 --error-on=any update && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y python3 git - name: Download selected portable release container - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: binaries-linux-${{ matrix.arch }}-portable path: ${{ runner.temp }}/cbm-selected-container @@ -305,14 +305,14 @@ jobs: ${{ inputs.duration_minutes }} - name: Upload metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-quick-linux-${{ matrix.arch }}-portable path: soak-results/ retention-days: 14 - name: Upload query-leak metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-query-leak-linux-${{ matrix.arch }}-portable path: soak-results-query-leak/ @@ -373,7 +373,7 @@ jobs: run: scripts/soak-legs.sh --legs quick build/c/codebase-memory-mcp 15 - name: Upload metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-asan-${{ matrix.goos }}-${{ matrix.goarch }} path: soak-results/ @@ -413,7 +413,7 @@ jobs: run: scripts/soak-legs.sh --legs quick build/c/codebase-memory-mcp 15 - name: Upload metrics if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: soak-asan-windows-amd64 path: soak-results/ diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index d6ec6a85b..55e61cbe1 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -231,7 +231,7 @@ jobs: # producer, so arm64, macOS and Windows code under #ifdef has static # evidence only and those legs run every test. if: ${{ inputs.test_selection != '' && runner.os == 'Linux' && runner.arch == 'X64' }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: ${{ inputs.test_selection }} path: ${{ runner.temp }}/test-selection @@ -253,7 +253,7 @@ jobs: # too keeps red iterations warm. - name: Shard manifest (completeness proof) if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: shard-manifest-${{ matrix.os }}-${{ matrix.cc }}-${{ strategy.job-index }} path: build/c/test-logs/shard-manifest.txt @@ -274,7 +274,7 @@ jobs: # directory, and a second error would only bury the first. - name: Failing suite logs if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: suite-logs-${{ matrix.os }}-${{ matrix.cc }}-${{ strategy.job-index }} path: build/c/test-logs/failed/ @@ -406,7 +406,7 @@ jobs: # not end green, only when the job failed. - name: Failing suite logs if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: suite-logs-diag path: build/c/test-logs/failed/ @@ -504,7 +504,7 @@ jobs: # not end green, only when the job failed. - name: Failing suite logs if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: suite-logs-lsan-macos path: build/c/test-logs/failed/ @@ -669,7 +669,7 @@ jobs: - name: Shard manifest (completeness proof) if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: shard-manifest-${{ matrix.os }}-${{ matrix.msystem }}-${{ strategy.job-index }} path: build/c/test-logs/shard-manifest.txt @@ -684,7 +684,7 @@ jobs: # not end green, only when the job failed. - name: Failing suite logs if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: suite-logs-${{ matrix.os }}-${{ matrix.msystem }}-${{ strategy.job-index }} path: build/c/test-logs/failed/ @@ -720,7 +720,7 @@ jobs: with: path: repo - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: shard-manifest-* path: manifests diff --git a/.github/workflows/dry-run.yml b/.github/workflows/dry-run.yml index c582716fe..7554f16fb 100644 --- a/.github/workflows/dry-run.yml +++ b/.github/workflows/dry-run.yml @@ -148,7 +148,7 @@ jobs: run: scripts/fuzz.sh all --runs 20000 --seed 1 - name: Upload reproducers if: ${{ failure() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: fuzz-artifacts path: build/fuzz/artifacts-*/ diff --git a/.github/workflows/nightly-soak.yml b/.github/workflows/nightly-soak.yml index d4357aad6..e45d3395f 100644 --- a/.github/workflows/nightly-soak.yml +++ b/.github/workflows/nightly-soak.yml @@ -51,7 +51,7 @@ jobs: run: scripts/fuzz.sh all --max-total-time 1800 --seed 0 - name: Upload reproducers if: ${{ failure() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: fuzz-artifacts-nightly path: build/fuzz/artifacts-*/ diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3b4ea807c..30b36e2cb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -78,7 +78,7 @@ jobs: run: scripts/ci/test-impact-select.sh --binary build/c/codebase-memory-mcp --out "$RUNNER_TEMP/test-impact" --gate "$RUNNER_TEMP/test-selection" - name: Upload the selection if: ${{ steps.select.outputs.selection == 'narrowed' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-selection path: ${{ runner.temp }}/test-selection @@ -88,7 +88,7 @@ jobs: # for the vetoes, never read by a test leg. - name: Upload the vetoed selection if: ${{ !cancelled() && steps.select.outcome != 'skipped' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-selection-vetoed path: ${{ runner.temp }}/test-impact/selection-vetoed.json @@ -342,7 +342,7 @@ jobs: run: scripts/ci/test-impact-select.sh --binary build/c/codebase-memory-mcp --out "$RUNNER_TEMP/test-impact" - name: Upload the prediction if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-impact-prediction path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 020ea3f0f..d7b1f1af0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -185,7 +185,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download only selected release containers - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: binaries-* merge-multiple: true @@ -194,7 +194,7 @@ jobs: # download above. Preserve only the three reviewable manifests that bind # candidate bytes, VT results, and the tuple-local decision. - name: Download candidate selection evidence separately - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release-selection-evidence path: selection-evidence @@ -236,7 +236,7 @@ jobs: # same-run workflow artifact, not a draft-release download, so that # job keeps contents: read and no gh dependency. - name: Preserve checksums for the registry job - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-checksums path: checksums.txt @@ -429,7 +429,7 @@ jobs: - name: Preserve extracted-object VirusTotal evidence if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: virustotal-evidence-release-${{ github.run_id }} path: | @@ -617,7 +617,7 @@ jobs: # the release-draft job as a same-run artifact (not a draft-release # download — that would need contents beyond read plus gh). - name: Fetch release checksums - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release-checksums diff --git a/.github/workflows/test-impact-artifact.yml b/.github/workflows/test-impact-artifact.yml index 49f70dd67..a0a53061c 100644 --- a/.github/workflows/test-impact-artifact.yml +++ b/.github/workflows/test-impact-artifact.yml @@ -79,7 +79,7 @@ jobs: --out "$RUNNER_TEMP/bundle" --mode "$mode" --before "$before" - name: Upload the bundle - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-impact-${{ github.sha }} path: ${{ runner.temp }}/bundle diff --git a/Makefile.cbm b/Makefile.cbm index 3265590c4..4a8823926 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -417,6 +417,7 @@ EXTRACTION_SRCS = \ $(CBM_DIR)/openapi.c \ $(CBM_DIR)/extract_nextjs.c \ $(CBM_DIR)/sql_values.c \ + $(CBM_DIR)/php_inline_html.c \ $(CBM_DIR)/helpers.c \ $(CBM_DIR)/cpp_branch_views.c \ $(CBM_DIR)/result_compact.c \ @@ -887,6 +888,7 @@ TEST_CS_LSP_SRCS = tests/test_cs_lsp.c TEST_CS_LSP_BENCH_SRCS = tests/test_cs_lsp_bench.c TEST_PERL_LSP_SRCS = tests/test_perl_lsp.c +TEST_RUBY_LSP_SRCS = tests/test_ruby_lsp.c TEST_SCOPE_SRCS = tests/test_scope.c @@ -1010,7 +1012,7 @@ TEST_REPRO_SRCS = \ tests/repro/repro_lsp_java_cs.c \ tests/repro/repro_lsp_kt_php_rust.c -ALL_TEST_SRCS =$(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DAEMON_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_ZSTD_SRCS) $(TEST_ARTIFACT_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_PHP_LSP_SRCS) $(TEST_CS_LSP_SRCS) $(TEST_CS_LSP_BENCH_SRCS) $(TEST_PERL_LSP_SRCS) $(TEST_SCOPE_SRCS) $(TEST_TYPE_REP_SRCS) $(TEST_PY_LSP_SRCS) $(TEST_PY_LSP_BENCH_SRCS) $(TEST_PY_LSP_STRESS_SRCS) $(TEST_PY_LSP_SCALE_SRCS) $(TEST_TS_LSP_SRCS) $(TEST_JAVA_LSP_SRCS) $(TEST_KOTLIN_LSP_SRCS) $(TEST_RUST_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_HTTPD_SRCS) $(TEST_SECURITY_SRCS) $(TEST_YAML_SRCS) $(TEST_SEMANTIC_SRCS) $(TEST_AST_PROFILE_SRCS) $(TEST_SLAB_ALLOC_SRCS) $(TEST_SIMHASH_SRCS) $(TEST_STACK_OVERFLOW_SRCS) $(TEST_INTEGRATION_SRCS) +ALL_TEST_SRCS =$(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DAEMON_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_ZSTD_SRCS) $(TEST_ARTIFACT_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_PHP_LSP_SRCS) $(TEST_CS_LSP_SRCS) $(TEST_CS_LSP_BENCH_SRCS) $(TEST_PERL_LSP_SRCS) $(TEST_RUBY_LSP_SRCS) $(TEST_SCOPE_SRCS) $(TEST_TYPE_REP_SRCS) $(TEST_PY_LSP_SRCS) $(TEST_PY_LSP_BENCH_SRCS) $(TEST_PY_LSP_STRESS_SRCS) $(TEST_PY_LSP_SCALE_SRCS) $(TEST_TS_LSP_SRCS) $(TEST_JAVA_LSP_SRCS) $(TEST_KOTLIN_LSP_SRCS) $(TEST_RUST_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_HTTPD_SRCS) $(TEST_SECURITY_SRCS) $(TEST_YAML_SRCS) $(TEST_SEMANTIC_SRCS) $(TEST_AST_PROFILE_SRCS) $(TEST_SLAB_ALLOC_SRCS) $(TEST_SIMHASH_SRCS) $(TEST_STACK_OVERFLOW_SRCS) $(TEST_INTEGRATION_SRCS) # ── Build directories ──────────────────────────────────────────── diff --git a/README.md b/README.md index be8338f2a..46fb6317b 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,8 @@ To turn the watcher off entirely, set `config set watcher_enabled false` (defaul The watcher follows **git** projects only by default: a project indexed from a directory that is not a git repository is not watched and stays at its last index until you run `index_repository` again. To have such roots polled too, set `config set watch_non_git true` (default `false`, read when the daemon starts): they are then checked on the same cadence with a file-tree scan that uses the indexer's own ignore rules. +Linked git worktrees are indexed as their own projects by default. Set `config set ignore_worktrees true` to skip them on the automatic paths — useful when short-lived `git worktree add` checkouts would otherwise each leave behind a permanent index of the same repository. An explicit `index_repository` call on a worktree is then refused unless you pass `index_worktree=true`. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#ignore_worktrees). + ### Keeping Up to Date **Updates run from the install script on every platform, not from inside the running binary.** `codebase-memory-mcp update` validates your flags and then prints the exact command to run: @@ -1016,6 +1018,7 @@ codebase-memory-mcp config list # show all settings codebase-memory-mcp config set auto_index true # auto-index on session start codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-index codebase-memory-mcp config set auto_watch false # remove/disable all background watches (default: true) +codebase-memory-mcp config set ignore_worktrees true # skip linked git worktrees when auto-indexing (default: false) codebase-memory-mcp config set doc_candidates false # skip doc<->code candidate scoring on huge repos (default: auto = on for full, off for moderate/fast) codebase-memory-mcp config set watcher_enabled false # stop the watcher thread entirely (default: true) codebase-memory-mcp config set watch_non_git true # also poll non-git project roots (default: false) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a2adfd63d..a37e4a4ab 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -87,6 +87,7 @@ Current keys: |---|---|---| | `auto_index` | `false` | Automatically index new projects when an MCP session starts. | | `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. | +| `ignore_worktrees` | `false` | Skip linked git worktrees (`git worktree add`) when indexing automatically. | | `auto_watch` | `true` | Global switch for MCP session watch registration. On the next refresh or registration request, `true` allows session roots and successful `index_repository` targets to be watched; `false` removes all watches. The background watcher can still run with no registered projects. | | `doc_candidates` | `auto` | Score documentation sections against code to store doc↔code candidates (the possible targets of a section, and the sections that may describe a function or file, each with a judged probability). Unset (`auto`), the index mode decides: on for `full` indexes (the default mode), off for `moderate` and `fast`. Set `true` or `false` to choose for every mode; `config reset doc_candidates` returns to `auto`. The scoring grows with sections × functions: under a second up to ~100k functions, ~15 s at ~400k, ~3 minutes on the Linux kernel. Off stores no candidates and leaves the rest of the graph unchanged; on in `fast` mode stores candidates without adding `fast`'s skipped `SIMILAR_TO` / `SEMANTICALLY_RELATED` edges. `index_repository` also takes `doc_candidates` for a single index, which wins over this setting. Applies from the next index. | | `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Active MCP sessions can register their root project and successful explicit index targets; under `daemon start` those watches outlive the session until the daemon stops, the project is deleted, its directory disappears, or `auto_watch` is set to `false`. `index_status` reports the current state in its `watch` object. | @@ -146,6 +147,44 @@ duration is independent of the existing 15-minute no-log-progress timeout. See [Index resource limits](INDEX_RESOURCE_LIMITS.md) for counting, validation, and error-response details. +### `ignore_worktrees` + +Every indexed project is registered under its own absolute root path, so each +linked worktree becomes a separate permanent index. On machines that create many +short-lived worktrees, the automatic paths (`auto_index`, and the session hook's +"index this project first" guidance) turn every throwaway checkout into another +stored index of what is largely the same repository. + +Enable the key to keep those checkouts out of the index: + +```bash +codebase-memory-mcp config set ignore_worktrees true +``` + +With it enabled: + +- automatic indexing skips a session whose root is a linked worktree; +- the `hook-augment` context says the worktree is unindexed on purpose instead + of telling the agent to run `index_repository`; +- an explicit `index_repository` call on a linked worktree is refused, and names + both ways forward — pass `index_worktree=true` for that one call, or turn the + key back off. + +The main checkout of the same repository is unaffected, as are ordinary clones +and submodules. Detection is git plumbing only: a linked worktree's `.git` is a +file pointing at a gitdir that contains a `commondir` entry. + +A subdirectory counts as the worktree it sits in. Detection walks up from the +given path to the nearest `.git` entry, so `index_repository` on any +subdirectory of a linked worktree is refused the same way, and a session root or +hook working directory inside one is treated as that worktree. The walk stops at +the first `.git` it finds; a main checkout or a submodule ends it as "not a +worktree". A path outside any git checkout is checked one directory at a time up +to the filesystem root and is never treated as a worktree. None of this runs +while the key is off. + +The default is `false`, so indexing behavior is unchanged unless you opt in. + ## 3. UI Settings The optional built-in graph UI stores its settings in: diff --git a/docs/EVALUATION_PLAN.md b/docs/EVALUATION_PLAN.md index efd56207d..17b3701f9 100644 --- a/docs/EVALUATION_PLAN.md +++ b/docs/EVALUATION_PLAN.md @@ -274,8 +274,7 @@ then its index is **deleted** before the next language — so **only one project every index is genuinely cold. The skeleton: ```bash -# Start clean: no leftover DBs, fresh results tree -rm -f ~/.cache/codebase-memory-mcp/*.db +# Start clean: fresh results tree mkdir -p /tmp/eval-results for lang in $ALL_LANGS; do # ALL_LANGS = full 159-name list (§2 / §8) @@ -283,8 +282,13 @@ for lang in $ALL_LANGS; do # ALL_LANGS = full 159-name list # --- step 2: cold index in the main channel, TIMED (key metric) --- t0=$(now_ms) + CBM_BENCH_KEEP_RUNTIME=1 \ scripts/benchmark-index.sh ~/.local/bin/codebase-memory-mcp "$lang" /tmp/bench/"$lang" /tmp/eval-results index_ms=$(( $(now_ms) - t0 )) # clone+index wall-clock → manifest + report (§5) + # The harness indexes into a run-private root (#1696). CBM_BENCH_KEEP_RUNTIME leaves that + # root behind on success and records its paths; the graph session in steps 4-7 must be + # started with these CBM_RUNTIME_DIR / CBM_CACHE_DIR, and step 8 removes the root. + set -a; . /tmp/eval-results/"$lang"/runtime-root.txt; set +a # --- step 3: record per-type histograms (zeros back-filled) --- # node-types.json, edge-types.json (every label + all 32 edge types, zeros kept, §7 below) @@ -293,7 +297,14 @@ for lang in $ALL_LANGS; do # ALL_LANGS = full 159-name list # + per-language report + (deferred, blind) judge --- # --- step 8: delete THIS language's index so the next is cold, then mark done --- - rm -f ~/.cache/codebase-memory-mcp/*.db + # The graph session (steps 4-7) may still have a daemon running under the kept + # CBM_RUNTIME_DIR (exported above); stop it before its root goes away. A refused + # stop (a session still attached) means the root is still in use: keep it. + if ~/.local/bin/codebase-memory-mcp daemon stop; then + rm -rf -- "$CBM_BENCH_RUNTIME_ROOT" + else + echo "$lang: daemon under $CBM_RUNTIME_DIR did not stop; keeping $CBM_BENCH_RUNTIME_ROOT" >&2 + fi manifest_mark_done "$lang" "$index_ms" done ``` @@ -860,10 +871,13 @@ Deep-Dive section. # 1. Clone all 159 repos (shallow; skip existing) scripts/clone-bench-repos.sh /tmp/bench -# 2. Cold index all 159 (LSP cohort in full mode) -rm -f ~/.cache/codebase-memory-mcp/*.db +# 2. Cold index all 159 (LSP cohort in full mode). Each language is indexed into a run-private +# runtime and cache (#1696); CBM_BENCH_KEEP_RUNTIME=1 keeps that root after a successful +# index and records its paths in /tmp/eval-results//runtime-root.txt. Source it +# before the graph phase, then stop the daemon and remove the root, as in §7 step 8. mkdir -p /tmp/eval-results for lang in $ALL_LANGS; do + CBM_BENCH_KEEP_RUNTIME=1 \ scripts/benchmark-index.sh ~/.local/bin/codebase-memory-mcp "$lang" /tmp/bench/"$lang" /tmp/eval-results done diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index c98891457..88fa02145 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -16,6 +16,7 @@ #include "lsp/php_lsp.h" #include "lsp/perl_lsp.h" #include "lsp/py_lsp.h" +#include "lsp/ruby_lsp.h" #include "lsp/ts_lsp.h" #include "lsp/cs_lsp.h" #include "lsp/java_lsp.h" @@ -3318,6 +3319,18 @@ CBMFileResult *cbm_extract_file(const char *source, int source_len, CBMLanguage return r; } +/* The bytes the grammar parses. PHP that leaves PHP mode (`?> markup ` (#2000). Same length and line breaks as the input; returns +// `source` itself when nothing needs rewriting. Defined in php_inline_html.c. +const char *cbm_php_mask_inline_html(CBMArena *arena, const char *source, int source_len); void cbm_extract_imports(CBMExtractCtx *ctx); void cbm_extract_usages(CBMExtractCtx *ctx); void cbm_extract_semantic(CBMExtractCtx *ctx); diff --git a/internal/cbm/lsp/generated/ruby_stdlib_data.c b/internal/cbm/lsp/generated/ruby_stdlib_data.c new file mode 100644 index 000000000..f94ccd764 --- /dev/null +++ b/internal/cbm/lsp/generated/ruby_stdlib_data.c @@ -0,0 +1,465 @@ +/* + * ruby_stdlib_data.c — hand-written Ruby core + curated Rails type data. + * + * Strategy mirrors perl_stdlib_data.c / php_stdlib_data.c: + * 1. Ruby core classes (String, Array, Hash, Integer, ...) registered as + * types with receiver-keyed instance methods whose return types enable + * call-chain typing ("a,b".split(",").first). Method QNs are bare + * ("String.upcase") — no project prefix — so they can never collide + * with an indexed def; they exist for TYPING, not for edge emission + * (the resolver only emits edges to project-defined methods). + * 2. Kernel built-ins (puts, require, raise, ...) as global functions so + * bare calls to them type-check without producing spurious edges. + * 3. A curated Rails surface: ActiveRecord::Base class-side query methods + * and instance persistence methods, ActionController::Base helpers, + * and ActiveSupport-style predicates on Object. Registered under + * dotted QNs ("ActiveRecord.Base") to match ruby path dotting + * (Foo::Bar -> Foo.Bar). The resolver special-cases ActiveRecord + * query methods to return the *receiving model's* type (User.find -> + * User), which these generic entries cannot express. + * + * Return types are left UNKNOWN where the real type is polymorphic; the + * seed only needs to be right where chaining matters. + */ + +#include "../type_rep.h" +#include "../type_registry.h" +#include "../../arena.h" +#include "../ruby_lsp.h" +#include + +#define MIXED cbm_type_unknown() + +/* Register a type by bare dotted QN (e.g. "String", "ActiveRecord.Base"). */ +#define REG_TYPE(qn_) \ + do { \ + CBMRegisteredType rt; \ + memset(&rt, 0, sizeof(rt)); \ + rt.qualified_name = (qn_); \ + rt.short_name = (qn_); \ + cbm_registry_add_type(reg, rt); \ + } while (0) + +/* Signature `() -> ret`. Every seed allocation can fail (arena exhausted): + * a NULL return vector is left out, so the signature types as unknown (the + * same fallback ruby_register_method uses) instead of being written through. */ +static const CBMType *ruby_seed_signature(CBMArena *arena, const CBMType *ret) { + const CBMType **rets = (const CBMType **)cbm_arena_alloc(arena, 2 * sizeof(*rets)); + if (rets) { + rets[0] = ret; + rets[1] = NULL; + } + return cbm_type_func(arena, NULL, NULL, rets); +} + +/* Register an instance method on receiver type `recv_` returning `ret_`. + * An entry whose formatted QN came back NULL is not registered: a NULL QN + * would reach strcmp in finalize and in every later lookup. */ +#define REG_METHOD(recv_, name_, ret_) \ + do { \ + memset(&rf, 0, sizeof(rf)); \ + rf.min_params = -1; \ + rf.qualified_name = cbm_arena_sprintf(arena, "%s.%s", (recv_), (name_)); \ + if (!rf.qualified_name) \ + break; \ + rf.short_name = (name_); \ + rf.receiver_type = (recv_); \ + rf.signature = ruby_seed_signature(arena, (ret_)); \ + cbm_registry_add_func(reg, rf); \ + } while (0) + +/* Register a singleton (class-side) method: receiver key ".self" + * matches ruby_lookup_singleton_method's keying convention. Skipped when + * either formatted name came back NULL (see REG_METHOD). */ +#define REG_SMETHOD(recv_, name_, ret_) \ + do { \ + memset(&rf, 0, sizeof(rf)); \ + rf.min_params = -1; \ + rf.qualified_name = cbm_arena_sprintf(arena, "%s.%s", (recv_), (name_)); \ + rf.receiver_type = cbm_arena_sprintf(arena, "%s.self", (recv_)); \ + if (!rf.qualified_name || !rf.receiver_type) \ + break; \ + rf.short_name = (name_); \ + rf.signature = ruby_seed_signature(arena, (ret_)); \ + cbm_registry_add_func(reg, rf); \ + } while (0) + +/* Register a global (Kernel) built-in function returning `ret_`. */ +#define REG_BUILTIN(name_, ret_) \ + do { \ + memset(&rf, 0, sizeof(rf)); \ + rf.min_params = -1; \ + rf.qualified_name = (name_); \ + rf.short_name = (name_); \ + rf.signature = ruby_seed_signature(arena, (ret_)); \ + cbm_registry_add_func(reg, rf); \ + } while (0) + +void cbm_ruby_stdlib_register(CBMTypeRegistry *reg, CBMArena *arena) { + CBMRegisteredFunc rf; + + const CBMType *T_STRING = cbm_type_named(arena, "String"); + const CBMType *T_ARRAY = cbm_type_named(arena, "Array"); + const CBMType *T_HASH = cbm_type_named(arena, "Hash"); + const CBMType *T_INT = cbm_type_named(arena, "Integer"); + const CBMType *T_FLOAT = cbm_type_named(arena, "Float"); + const CBMType *T_SYM = cbm_type_named(arena, "Symbol"); + const CBMType *T_BOOL = cbm_type_builtin(arena, "bool"); + + /* ── Core class constants (resolvable as superclasses / receivers) ── */ + static const char *core_types[] = {"Object", + "BasicObject", + "String", + "Array", + "Hash", + "Integer", + "Float", + "Numeric", + "Symbol", + "Range", + "Regexp", + "Time", + "Date", + "DateTime", + "File", + "IO", + "Dir", + "Struct", + "OpenStruct", + "StringIO", + "Set", + "Proc", + "Method", + "Thread", + "Mutex", + "Queue", + "Rational", + "Complex", + "NilClass", + "TrueClass", + "FalseClass", + "Exception", + "StandardError", + "RuntimeError", + "ArgumentError", + "TypeError", + "NameError", + "NoMethodError", + "IOError", + "KeyError", + "IndexError", + "RangeError", + "NotImplementedError", + "StopIteration", + "Comparable", + "Enumerable", + "Kernel", + "Math", + "JSON", + "ERB", + "Logger", + "Enumerator", + NULL}; + for (int i = 0; core_types[i]; i++) + REG_TYPE(core_types[i]); + + /* ── String ── */ + REG_METHOD("String", "upcase", T_STRING); + REG_METHOD("String", "downcase", T_STRING); + REG_METHOD("String", "capitalize", T_STRING); + REG_METHOD("String", "strip", T_STRING); + REG_METHOD("String", "chomp", T_STRING); + REG_METHOD("String", "gsub", T_STRING); + REG_METHOD("String", "sub", T_STRING); + REG_METHOD("String", "split", T_ARRAY); + REG_METHOD("String", "chars", T_ARRAY); + REG_METHOD("String", "lines", T_ARRAY); + REG_METHOD("String", "bytes", T_ARRAY); + REG_METHOD("String", "to_s", T_STRING); + REG_METHOD("String", "to_str", T_STRING); + REG_METHOD("String", "to_i", T_INT); + REG_METHOD("String", "to_f", T_FLOAT); + REG_METHOD("String", "to_sym", T_SYM); + REG_METHOD("String", "length", T_INT); + REG_METHOD("String", "size", T_INT); + REG_METHOD("String", "reverse", T_STRING); + REG_METHOD("String", "include?", T_BOOL); + REG_METHOD("String", "start_with?", T_BOOL); + REG_METHOD("String", "end_with?", T_BOOL); + REG_METHOD("String", "empty?", T_BOOL); + REG_METHOD("String", "match?", T_BOOL); + REG_METHOD("String", "freeze", T_STRING); + REG_METHOD("String", "dup", T_STRING); + + /* ── Array ── */ + REG_METHOD("Array", "map", T_ARRAY); + REG_METHOD("Array", "flat_map", T_ARRAY); + REG_METHOD("Array", "select", T_ARRAY); + REG_METHOD("Array", "filter", T_ARRAY); + REG_METHOD("Array", "reject", T_ARRAY); + REG_METHOD("Array", "sort", T_ARRAY); + REG_METHOD("Array", "sort_by", T_ARRAY); + REG_METHOD("Array", "uniq", T_ARRAY); + REG_METHOD("Array", "compact", T_ARRAY); + REG_METHOD("Array", "flatten", T_ARRAY); + REG_METHOD("Array", "reverse", T_ARRAY); + REG_METHOD("Array", "concat", T_ARRAY); + REG_METHOD("Array", "push", T_ARRAY); + REG_METHOD("Array", "join", T_STRING); + REG_METHOD("Array", "first", MIXED); + REG_METHOD("Array", "last", MIXED); + REG_METHOD("Array", "sample", MIXED); + REG_METHOD("Array", "length", T_INT); + REG_METHOD("Array", "size", T_INT); + REG_METHOD("Array", "count", T_INT); + REG_METHOD("Array", "sum", MIXED); + REG_METHOD("Array", "min", MIXED); + REG_METHOD("Array", "max", MIXED); + REG_METHOD("Array", "include?", T_BOOL); + REG_METHOD("Array", "empty?", T_BOOL); + REG_METHOD("Array", "any?", T_BOOL); + REG_METHOD("Array", "all?", T_BOOL); + REG_METHOD("Array", "none?", T_BOOL); + REG_METHOD("Array", "each", T_ARRAY); + REG_METHOD("Array", "each_with_index", T_ARRAY); + REG_METHOD("Array", "to_a", T_ARRAY); + REG_METHOD("Array", "group_by", T_HASH); + REG_METHOD("Array", "each_slice", MIXED); + REG_METHOD("Array", "zip", T_ARRAY); + + /* ── Hash ── */ + REG_METHOD("Hash", "keys", T_ARRAY); + REG_METHOD("Hash", "values", T_ARRAY); + REG_METHOD("Hash", "merge", T_HASH); + REG_METHOD("Hash", "merge!", T_HASH); + REG_METHOD("Hash", "fetch", MIXED); + REG_METHOD("Hash", "dig", MIXED); + REG_METHOD("Hash", "key?", T_BOOL); + REG_METHOD("Hash", "has_key?", T_BOOL); + REG_METHOD("Hash", "include?", T_BOOL); + REG_METHOD("Hash", "empty?", T_BOOL); + REG_METHOD("Hash", "size", T_INT); + REG_METHOD("Hash", "length", T_INT); + REG_METHOD("Hash", "count", T_INT); + REG_METHOD("Hash", "each", T_HASH); + REG_METHOD("Hash", "map", T_ARRAY); + REG_METHOD("Hash", "select", T_HASH); + REG_METHOD("Hash", "reject", T_HASH); + REG_METHOD("Hash", "to_a", T_ARRAY); + REG_METHOD("Hash", "to_h", T_HASH); + REG_METHOD("Hash", "transform_values", T_HASH); + REG_METHOD("Hash", "transform_keys", T_HASH); + REG_METHOD("Hash", "symbolize_keys", T_HASH); /* ActiveSupport */ + REG_METHOD("Hash", "stringify_keys", T_HASH); /* ActiveSupport */ + REG_METHOD("Hash", "deep_symbolize_keys", T_HASH); + REG_METHOD("Hash", "with_indifferent_access", T_HASH); + + /* ── Integer / Float ── */ + REG_METHOD("Integer", "to_s", T_STRING); + REG_METHOD("Integer", "to_i", T_INT); + REG_METHOD("Integer", "to_f", T_FLOAT); + REG_METHOD("Integer", "times", MIXED); + REG_METHOD("Integer", "upto", MIXED); + REG_METHOD("Integer", "abs", T_INT); + REG_METHOD("Integer", "zero?", T_BOOL); + REG_METHOD("Integer", "positive?", T_BOOL); + REG_METHOD("Integer", "negative?", T_BOOL); + REG_METHOD("Integer", "even?", T_BOOL); + REG_METHOD("Integer", "odd?", T_BOOL); + REG_METHOD("Float", "to_s", T_STRING); + REG_METHOD("Float", "to_i", T_INT); + REG_METHOD("Float", "round", T_INT); + REG_METHOD("Float", "ceil", T_INT); + REG_METHOD("Float", "floor", T_INT); + REG_METHOD("Float", "abs", T_FLOAT); + + /* ── Symbol / misc ── */ + REG_METHOD("Symbol", "to_s", T_STRING); + REG_METHOD("Symbol", "to_sym", T_SYM); + REG_METHOD("Symbol", "to_proc", cbm_type_named(arena, "Proc")); + REG_METHOD("Range", "to_a", T_ARRAY); + REG_METHOD("Range", "each", MIXED); + REG_METHOD("Range", "map", T_ARRAY); + REG_METHOD("Range", "include?", T_BOOL); + REG_METHOD("Time", "to_s", T_STRING); + REG_METHOD("Time", "to_i", T_INT); + REG_METHOD("Time", "strftime", T_STRING); + REG_METHOD("Time", "year", T_INT); + REG_METHOD("Time", "month", T_INT); + REG_METHOD("Time", "day", T_INT); + REG_SMETHOD("Time", "now", cbm_type_named(arena, "Time")); + REG_SMETHOD("Time", "at", cbm_type_named(arena, "Time")); + REG_SMETHOD("Time", "parse", cbm_type_named(arena, "Time")); + REG_SMETHOD("Time", "current", cbm_type_named(arena, "Time")); /* ActiveSupport */ + REG_SMETHOD("Time", "zone", MIXED); + REG_SMETHOD("File", "read", T_STRING); + REG_SMETHOD("File", "readlines", T_ARRAY); + REG_SMETHOD("File", "open", cbm_type_named(arena, "File")); + REG_SMETHOD("File", "exist?", T_BOOL); + REG_SMETHOD("File", "basename", T_STRING); + REG_SMETHOD("File", "dirname", T_STRING); + REG_SMETHOD("File", "join", T_STRING); + REG_SMETHOD("File", "expand_path", T_STRING); + REG_SMETHOD("JSON", "parse", MIXED); + REG_SMETHOD("JSON", "generate", T_STRING); + REG_SMETHOD("JSON", "pretty_generate", T_STRING); + REG_SMETHOD("Math", "sqrt", T_FLOAT); + REG_SMETHOD("Math", "pow", T_FLOAT); + REG_SMETHOD("Math", "sin", T_FLOAT); + REG_SMETHOD("Math", "cos", T_FLOAT); + REG_SMETHOD("Math", "log", T_FLOAT); + + /* ── Object (universal receivers; ActiveSupport predicates) ── */ + REG_METHOD("Object", "to_s", T_STRING); + REG_METHOD("Object", "inspect", T_STRING); + REG_METHOD("Object", "freeze", MIXED); + REG_METHOD("Object", "frozen?", T_BOOL); + REG_METHOD("Object", "dup", MIXED); + REG_METHOD("Object", "clone", MIXED); + REG_METHOD("Object", "tap", MIXED); + REG_METHOD("Object", "then", MIXED); + REG_METHOD("Object", "nil?", T_BOOL); + REG_METHOD("Object", "is_a?", T_BOOL); + REG_METHOD("Object", "kind_of?", T_BOOL); + REG_METHOD("Object", "instance_of?", T_BOOL); + REG_METHOD("Object", "respond_to?", T_BOOL); + REG_METHOD("Object", "blank?", T_BOOL); /* ActiveSupport */ + REG_METHOD("Object", "present?", T_BOOL); /* ActiveSupport */ + REG_METHOD("Object", "presence", MIXED); /* ActiveSupport */ + REG_METHOD("Object", "to_json", T_STRING); /* ActiveSupport / json */ + + /* ── Kernel built-ins (global functions) ── */ + REG_BUILTIN("puts", MIXED); + REG_BUILTIN("print", MIXED); + REG_BUILTIN("p", MIXED); + REG_BUILTIN("pp", MIXED); + REG_BUILTIN("require", T_BOOL); + REG_BUILTIN("require_relative", T_BOOL); + REG_BUILTIN("raise", MIXED); + REG_BUILTIN("fail", MIXED); + REG_BUILTIN("loop", MIXED); + REG_BUILTIN("sleep", T_INT); + REG_BUILTIN("rand", MIXED); + REG_BUILTIN("format", T_STRING); + REG_BUILTIN("sprintf", T_STRING); + REG_BUILTIN("gets", T_STRING); + REG_BUILTIN("exit", MIXED); + REG_BUILTIN("abort", MIXED); + REG_BUILTIN("lambda", cbm_type_named(arena, "Proc")); + REG_BUILTIN("proc", cbm_type_named(arena, "Proc")); + REG_BUILTIN("binding", MIXED); + REG_BUILTIN("catch", MIXED); + REG_BUILTIN("throw", MIXED); + REG_BUILTIN("attr_accessor", MIXED); + REG_BUILTIN("attr_reader", MIXED); + REG_BUILTIN("attr_writer", MIXED); + + /* ── Rails: ActiveRecord::Base ────────────────────────────────── + * Class-side query interface. Return types here are generic; the + * resolver's AR special case substitutes the receiving model's type + * (User.find -> User) when the receiver's ancestry reaches + * ActiveRecord.Base / ApplicationRecord. */ + REG_TYPE("ActiveRecord.Base"); + REG_TYPE("ApplicationRecord"); + static const char *ar_class_methods[] = {"find", + "find_by", + "find_by!", + "find_or_create_by", + "find_or_initialize_by", + "where", + "all", + "first", + "last", + "take", + "create", + "create!", + "new", + "count", + "order", + "limit", + "offset", + "joins", + "left_joins", + "includes", + "preload", + "eager_load", + "group", + "having", + "distinct", + "pluck", + "ids", + "exists?", + "none", + "unscoped", + "select", + "sum", + "maximum", + "minimum", + "average", + "destroy_all", + "delete_all", + "update_all", + "transaction", + "find_each", + NULL}; + for (int i = 0; ar_class_methods[i]; i++) + REG_SMETHOD("ActiveRecord.Base", ar_class_methods[i], MIXED); + REG_METHOD("ActiveRecord.Base", "save", T_BOOL); + REG_METHOD("ActiveRecord.Base", "save!", T_BOOL); + REG_METHOD("ActiveRecord.Base", "update", T_BOOL); + REG_METHOD("ActiveRecord.Base", "update!", T_BOOL); + REG_METHOD("ActiveRecord.Base", "destroy", MIXED); + REG_METHOD("ActiveRecord.Base", "destroy!", MIXED); + REG_METHOD("ActiveRecord.Base", "delete", MIXED); + REG_METHOD("ActiveRecord.Base", "reload", MIXED); + REG_METHOD("ActiveRecord.Base", "valid?", T_BOOL); + REG_METHOD("ActiveRecord.Base", "invalid?", T_BOOL); + REG_METHOD("ActiveRecord.Base", "persisted?", T_BOOL); + REG_METHOD("ActiveRecord.Base", "new_record?", T_BOOL); + REG_METHOD("ActiveRecord.Base", "errors", MIXED); + REG_METHOD("ActiveRecord.Base", "attributes", T_HASH); + REG_METHOD("ActiveRecord.Base", "assign_attributes", MIXED); + REG_METHOD("ActiveRecord.Base", "update_attribute", T_BOOL); + REG_METHOD("ActiveRecord.Base", "touch", T_BOOL); + REG_METHOD("ActiveRecord.Base", "id", T_INT); + + /* ── Rails: ActionController::Base / ::API ── */ + REG_TYPE("ActionController.Base"); + REG_TYPE("ActionController.API"); + static const char *ac_receivers[] = {"ActionController.Base", "ActionController.API", NULL}; + for (int i = 0; ac_receivers[i]; i++) { + const char *acr = ac_receivers[i]; + REG_METHOD(acr, "render", MIXED); + REG_METHOD(acr, "redirect_to", MIXED); + REG_METHOD(acr, "head", MIXED); + REG_METHOD(acr, "params", MIXED); + REG_METHOD(acr, "session", MIXED); + REG_METHOD(acr, "cookies", MIXED); + REG_METHOD(acr, "request", MIXED); + REG_METHOD(acr, "response", MIXED); + REG_METHOD(acr, "respond_to", MIXED); + REG_METHOD(acr, "flash", MIXED); + } + REG_SMETHOD("ActionController.Base", "before_action", MIXED); + REG_SMETHOD("ActionController.Base", "after_action", MIXED); + REG_SMETHOD("ActionController.Base", "skip_before_action", MIXED); + REG_SMETHOD("ActionController.API", "before_action", MIXED); + REG_SMETHOD("ActionController.API", "after_action", MIXED); + + /* ── Rails: jobs / mailers (constants only, for superclass chains) ── */ + REG_TYPE("ActiveJob.Base"); + REG_TYPE("ApplicationJob"); + REG_TYPE("ActionMailer.Base"); + REG_TYPE("ApplicationMailer"); + REG_TYPE("ApplicationController"); + REG_SMETHOD("ActiveJob.Base", "perform_later", MIXED); + REG_SMETHOD("ActiveJob.Base", "perform_now", MIXED); + REG_SMETHOD("ActiveJob.Base", "set", MIXED); + REG_METHOD("ActionMailer.Base", "mail", MIXED); + REG_SMETHOD("ActionMailer.Base", "with", MIXED); + + /* ── Minitest/RSpec anchors (superclass chains in test files) ── */ + REG_TYPE("Minitest.Test"); + REG_TYPE("ActiveSupport.TestCase"); + REG_TYPE("ActionDispatch.IntegrationTest"); +} diff --git a/internal/cbm/lsp/php_lsp.c b/internal/cbm/lsp/php_lsp.c index 7aae329e8..85118f671 100644 --- a/internal/cbm/lsp/php_lsp.c +++ b/internal/cbm/lsp/php_lsp.c @@ -20,6 +20,7 @@ #include "php_lsp.h" #include "lsp_node_iter.h" #include "lsp_work.h" +#include "../cbm.h" #include "../helpers.h" #include "../../../src/foundation/hash_table.h" #include @@ -4795,6 +4796,9 @@ void cbm_run_php_lsp_cross_with_bindings(CBMArena *arena, const char *source, in const CBMPHPImportBindings *bindings) { if (!arena || !source || source_len <= 0 || !out) return; + /* The retained source is the file on disk; read it the way the per-file + * extract did, so the cached tree and node text agree (#2000). */ + source = cbm_php_mask_inline_html(arena, source, source_len); TSParser *parser = NULL; TSTree *tree = cached_tree; diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index 74c1980e2..b7309bc33 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -3249,7 +3249,28 @@ static void py_emit_call_for(PyLSPContext *ctx, TSNode call_node) { py_emit_resolved_call_reason(ctx, qn, "lsp_import_alias", 0.85f, fname, call_node); } else if (qn && cbm_registry_lookup_type(ctx->registry, qn)) { - py_emit_resolved_call(ctx, qn, "lsp_constructor", 0.85f, call_node); + /* ClassName() binds to the __init__ the class declares + * itself. An inherited __init__ is not followed: B(A) + * without its own __init__ keeps the call on B, so a base + * constructor does not collect every subclass + * construction as its caller. Retarget only when this + * binding IS the class being called. An instance + * (`f = Foo(); f()`) keeps the class row: that call + * dispatches to __call__, and a reason-join would invent + * a Foo.__init__ edge. A builtins.* __init__ is not a + * project constructor, so the class target stays. */ + const CBMRegisteredFunc *fi = NULL; + if (qn_short && strcmp(qn_short, fname) == 0) { + fi = cbm_registry_lookup_method(ctx->registry, qn, "__init__"); + } + if (fi && fi->qualified_name && + strncmp(fi->qualified_name, "builtins.", sizeof("builtins.") - 1) != 0) { + py_emit_resolved_call_reason(ctx, fi->qualified_name, + "lsp_constructor_init", 0.85f, fname, + call_node); + } else { + py_emit_resolved_call(ctx, qn, "lsp_constructor", 0.85f, call_node); + } } } return; diff --git a/internal/cbm/lsp/ruby_lsp.c b/internal/cbm/lsp/ruby_lsp.c new file mode 100644 index 000000000..2464a6891 --- /dev/null +++ b/internal/cbm/lsp/ruby_lsp.c @@ -0,0 +1,1824 @@ +/* + * ruby_lsp.c — Ruby Light Semantic Pass. + * + * In-process type-aware call resolver for Ruby. Mirrors the perl_lsp.c / + * php_lsp.c shape: + * 1. Build a CBMTypeRegistry from file-local definitions + stdlib + * (Ruby core classes + curated Rails surface), with per-class method + * tables keyed by receiver QN. Singleton (class-side) methods are + * keyed ".self" — Ruby constants cannot be lowercase, so + * the suffix never collides with a real nested-constant path. + * 2. ruby_lsp_process_file does a multi-pass walk: + * PASS 1 — collect class/module declarations (with lexical nesting), + * superclasses, include/prepend/extend mixins, and method tables + * (instance vs singleton classified from the AST — `def m` vs + * `def self.m` / `class << self`). Defs and mixin calls inside a + * BLOCK (`class_methods do … end`, `included do … end`) belong to + * some other module and are not recorded. + * PASS 1.5 — infer instance-variable types from `@x = Const.new` + * (and `@x ||= Const.new`) inside method bodies. Any other write + * to the ivar latches it as untypable. + * PASS 2 — walk method bodies, track local variable types through + * assignments with Ruby's scope gates (fresh scope per def/class, + * chained scope per block, parameters unknown, untyped writes + * rebind to unknown), and resolve call expressions into + * CBMResolvedCall edges via Ruby method lookup (prepends → own → + * includes → superclass chain; extends feed the singleton side). + * The walk fails closed the moment an ancestor is unresolved. + * + * Verified tree-sitter-ruby node/field names (vendored compiled grammar at + * internal/cbm/vendored/grammars/ruby/parser.c — ts_symbol_names and + * ts_field_names tables): + * - call : fields `receiver`, `method`, `arguments`, `block`. Both + * `foo(x)` and command calls (`foo x`) surface as "call" nodes. A lone + * `foo` is an `identifier`, never a call — and the extractor emits no + * call for it either, so the resolver does not treat it as one. + * - class : fields `name` (constant | scope_resolution), `superclass` + * (a `superclass` wrapper node), `body` (body_statement). + * - module : fields `name`, `body`. + * - method : fields `name`, `parameters`, `body`. + * - singleton_method : fields `object` (self | constant), `name`, `body`. + * - singleton_class : `class << self` — fields `value`, body children. + * - assignment : fields `left`, `right` (`left` may be a + * left_assignment_list for `a, b = …`). + * - operator_assignment : fields `left`, `operator`, `right` (`||=` etc.). + * - block / do_block / lambda : field `parameters` (block_parameters / + * lambda_parameters), body. + * - method_parameters children: identifier | optional_parameter | + * keyword_parameter | splat_parameter | hash_splat_parameter | + * block_parameter (each with a `name` field) | destructured_parameter. + * - scope_resolution : fields `scope` (may be absent for ::Foo), `name`. + * - leaves: constant, identifier, self, instance_variable, super, + * string, array, hash, integer, float, simple_symbol, regex. + * + * QN scheme (matches the structural extractor): Ruby HAS class node types + * (class/module), so defs are named `.` with + * parent_class = ``, where a class QN is the enclosing class QN + * (or the module QN at top level) joined with the declaration's RAW name + * text. A compact `class Admin::User` therefore yields `module_qn.Admin::User` + * while `module Admin; class User` yields `module_qn.Admin.User` — both are + * reproduced here byte-for-byte because the pipeline joins resolved rows to + * defs by exact caller name. The dotted form is used only for constant + * lookup. Constructor calls: the textual extractor rewrites `Widget.new` to + * callee "Widget" (extract_calls.c), so constructor rows emit the CLASS QN — + * the edge lands on the Class node, exactly like Python's lsp_constructor. + * + * Zero-edge guarantee: if a receiver's type is unknown/unindexed, NO edge + * is emitted (false edges are worse than missing edges). Concretely: + * - dynamic dispatch (`send`, `public_send`, `__send__`, `method_missing`, + * `define_method`, the eval/exec family) is never guessed through; + * - a block that rebinds `self` (instance_eval/exec, class_eval/exec, + * module_eval/exec, define_method, Class.new/Module.new) suppresses bare, + * `self.` and `super` dispatch inside it; + * - an unresolved prepend blocks even an own-method hit, an unresolved + * include/extend/superclass stops the walk, and a blocked walk never + * falls through to a same-named top-level def or the Object fallback; + * - a local or ivar written with anything the resolver cannot type loses + * its type rather than keeping a stale one. + */ + +#include "ruby_lsp.h" +#include "lsp_work.h" +#include "../helpers.h" +#include "../arena.h" +#include +#include +#include +#include + +/* Recursion cap for ruby_eval_expr_type — mirrors perl/php (unknown past 8). */ +#define RUBY_EVAL_MAX_DEPTH 8 + +/* Confidence levels. */ +#define RUBY_CONF_HIGH 0.95f /* constructor / static constant dispatch */ +#define RUBY_CONF_TYPED 0.90f /* inferred receiver type (locals, ivars, chains) */ + +/* Maximum AST-walk recursion depth (see scope.h rationale — graceful + * degradation on pathologically nested sources, never a crash). */ +#define CBM_LSP_RUBY_MAX_WALK_DEPTH 512 + +/* ── forward declarations ───────────────────────────────────────── */ + +static void ruby_resolve_calls_in_node(RubyLSPContext *ctx, TSNode node); +static void ruby_resolve_calls_in_node_inner(RubyLSPContext *ctx, TSNode node); +static void ruby_pass1_scan(RubyLSPContext *ctx, TSNode node); +static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node); +static void ruby_ivar_scan(RubyLSPContext *ctx, TSNode node); +static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node); +static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool emit); +static bool ruby_call_rebinds_self(RubyLSPContext *ctx, TSNode call); + +/* ── helpers ────────────────────────────────────────────────────── */ + +static char *ruby_node_text(RubyLSPContext *ctx, TSNode node) { + return cbm_node_text(ctx->arena, node, ctx->source); +} + +/* Byte length of a node's source text (0 for an empty node). */ +static size_t ruby_node_len(TSNode node) { + uint32_t start = ts_node_start_byte(node); + uint32_t end = ts_node_end_byte(node); + return end > start ? (size_t)(end - start) : 0; +} + +/* One step left along a constant path. `*cur` is a scope_resolution whose + * own segment has been taken; on success `*seg` is the next segment to its + * left and `*cur` the node to continue from. The path ends at a missing + * scope (`::Foo`), at a scope that is not a constant path (`expr::Foo`), + * and at an empty segment. A plain `constant` has no scope field, so it is + * always the last segment. */ +static bool ruby_const_step(TSNode *cur, TSNode *seg) { + TSNode scope = ts_node_child_by_field_name(*cur, "scope", 5); + if (ts_node_is_null(scope)) + return false; + const char *k = ts_node_type(scope); + if (strcmp(k, "constant") == 0) { + if (ruby_node_len(scope) == 0) + return false; + *seg = scope; + *cur = scope; + return true; + } + if (strcmp(k, "scope_resolution") != 0) + return false; + TSNode name = ts_node_child_by_field_name(scope, "name", 4); + if (ts_node_is_null(name) || ruby_node_len(name) == 0) + return false; + *seg = name; + *cur = scope; + return true; +} + +/* Convert a constant expression node (constant | scope_resolution) into a + * dotted path ("Foo", "A.B.C"). Returns NULL for anything else. A leading + * "::Foo" (scope_resolution without a scope) yields "Foo" — the resolver's + * table probe covers the top-level meaning. A scope that is not itself a + * constant path (`expr::Foo`) contributes nothing. + * + * `A::B::C` nests to the LEFT (scope_resolution(scope_resolution(A, B), C)), + * so the path is read from the outermost node along the `scope` fields: one + * pass measures the segments, a second copies them right to left into one + * buffer. No recursion and no re-formatting of a growing prefix, so neither + * stack depth nor bytes grow faster than the path itself. */ +static const char *ruby_const_path(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return NULL; + const char *k = ts_node_type(node); + if (strcmp(k, "constant") == 0) + return ruby_node_text(ctx, node); + if (strcmp(k, "scope_resolution") != 0) + return NULL; + TSNode name = ts_node_child_by_field_name(node, "name", 4); + if (ts_node_is_null(name) || ruby_node_len(name) == 0) + return NULL; + + size_t total = 0; + TSNode cur = node; + TSNode seg = name; + do { + total += ruby_node_len(seg) + 1; /* segment + its '.' (or the NUL) */ + } while (ruby_const_step(&cur, &seg)); + + char *out = (char *)cbm_arena_alloc(ctx->arena, total); + if (!out) + return NULL; + size_t pos = total - 1; + out[pos] = '\0'; + cur = node; + seg = name; + do { + size_t len = ruby_node_len(seg); + pos -= len; + memcpy(out + pos, ctx->source + ts_node_start_byte(seg), len); + if (pos > 0) + out[--pos] = '.'; + } while (ruby_const_step(&cur, &seg)); + return out; +} + +/* Join two dotted path fragments; either side may be empty/NULL. */ +static const char *ruby_path_join(CBMArena *a, const char *left, const char *right) { + if (!left || !left[0]) + return right; + if (!right || !right[0]) + return left; + return cbm_arena_sprintf(a, "%s.%s", left, right); +} + +/* Dotted form of a raw constant spelling: every "::" becomes ".". Used to + * turn nesting frames (raw) into class-table paths (dotted). */ +static const char *ruby_dotted(CBMArena *a, const char *raw) { + if (!raw) + return NULL; + size_t n = strlen(raw); + char *out = (char *)cbm_arena_alloc(a, n + 1); + if (!out) + return raw; + size_t j = 0; + for (size_t i = 0; i < n; i++) { + if (raw[i] == ':' && i + 1 < n && raw[i + 1] == ':') { + out[j++] = '.'; + i++; + continue; + } + out[j++] = raw[i]; + } + out[j] = '\0'; + return out; +} + +/* Class-declaration frame shared by every pass, so PASS 1, PASS 1.5 and + * PASS 2 compute identical nesting + class QNs. `raw_name` is the name + * field's source text (the extractor's spelling, "Admin::User"); `qn` is + * the extractor-shaped class QN; `path` is the dotted constant-table path. */ +typedef struct { + const char *raw_name; + const char *path; + const char *qn; + const char *saved_nesting; + const char *saved_class; + bool saved_singleton; + bool saved_self_unknown; + int saved_block_depth; +} RubyClassFrame; + +/* Enter a class/module node: compute names, push nesting + enclosing class. + * Returns false (nothing pushed) when the name is not a constant path. */ +static bool ruby_class_enter(RubyLSPContext *ctx, TSNode node, RubyClassFrame *fr) { + TSNode name = ts_node_child_by_field_name(node, "name", 4); + const char *cpath = ruby_const_path(ctx, name); + if (!cpath) + return false; + memset(fr, 0, sizeof(*fr)); + fr->raw_name = ruby_node_text(ctx, name); + if (!fr->raw_name || !fr->raw_name[0]) + return false; + fr->saved_nesting = ctx->nesting; + fr->saved_class = ctx->enclosing_class_qn; + fr->saved_singleton = ctx->in_singleton_method; + fr->saved_self_unknown = ctx->self_unknown; + fr->saved_block_depth = ctx->block_depth; + + fr->path = ruby_path_join(ctx->arena, ruby_dotted(ctx->arena, fr->saved_nesting), cpath); + if (fr->saved_class) + fr->qn = cbm_arena_sprintf(ctx->arena, "%s.%s", fr->saved_class, fr->raw_name); + else if (ctx->module_qn) + fr->qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, fr->raw_name); + else + fr->qn = fr->raw_name; + + ctx->nesting = ruby_path_join(ctx->arena, fr->saved_nesting, fr->raw_name); + ctx->enclosing_class_qn = fr->qn; + ctx->in_singleton_method = false; + ctx->self_unknown = false; /* a class body names its self again */ + ctx->block_depth = 0; + return true; +} + +static void ruby_class_leave(RubyLSPContext *ctx, const RubyClassFrame *fr) { + ctx->nesting = fr->saved_nesting; + ctx->enclosing_class_qn = fr->saved_class; + ctx->in_singleton_method = fr->saved_singleton; + ctx->self_unknown = fr->saved_self_unknown; + ctx->block_depth = fr->saved_block_depth; +} + +/* True when a QN belongs to the indexed project (module_qn's first segment + * is its prefix). Stdlib seed QNs ("String.upcase", "ActiveRecord.Base") + * never carry the project prefix, so edges are only ever emitted at + * project-defined targets. */ +static bool ruby_is_project_qn(RubyLSPContext *ctx, const char *qn) { + if (!qn || !ctx->module_qn || !ctx->module_qn[0]) + return false; + const char *dot = strchr(ctx->module_qn, '.'); + size_t plen = dot ? (size_t)(dot - ctx->module_qn) : strlen(ctx->module_qn); + return strncmp(qn, ctx->module_qn, plen) == 0 && qn[plen] == '.'; +} + +/* ── table growth helpers ───────────────────────────────────────── */ + +static RubyClassInfo *ruby_add_class(RubyLSPContext *ctx, const char *path, const char *qn, + bool is_module) { + if (!ctx || !path || !qn) + return NULL; + /* Reopened class/module: reuse the existing record. Keyed on the + * extractor QN, so `module A; class B` and `class A::B` in one file each + * keep their own spelling (constant lookup returns whichever was + * declared first; a miss on the other fails closed at the join). */ + for (int i = 0; i < ctx->class_count; i++) { + if (strcmp(ctx->classes[i].qn, qn) == 0) + return &ctx->classes[i]; + } + if (ctx->class_count >= ctx->class_cap) { + int newcap = ctx->class_cap ? ctx->class_cap * 2 : 8; + RubyClassInfo *nc = + (RubyClassInfo *)cbm_arena_alloc(ctx->arena, (size_t)newcap * sizeof(RubyClassInfo)); + if (!nc) + return NULL; + for (int i = 0; i < ctx->class_count; i++) + nc[i] = ctx->classes[i]; + ctx->classes = nc; + ctx->class_cap = newcap; + } + RubyClassInfo *ci = &ctx->classes[ctx->class_count++]; + memset(ci, 0, sizeof(*ci)); + ci->path = cbm_arena_strdup(ctx->arena, path); + ci->qn = cbm_arena_strdup(ctx->arena, qn); + ci->is_module = is_module; + return ci; +} + +static void ruby_add_mixin(RubyLSPContext *ctx, const char *owner_qn, const char *module_ref, + RubyMixinKind kind) { + if (!ctx || !owner_qn || !module_ref) + return; + if (ctx->mixin_count >= ctx->mixin_cap) { + int newcap = ctx->mixin_cap ? ctx->mixin_cap * 2 : 8; + RubyMixinInfo *nm = + (RubyMixinInfo *)cbm_arena_alloc(ctx->arena, (size_t)newcap * sizeof(RubyMixinInfo)); + if (!nm) + return; + for (int i = 0; i < ctx->mixin_count; i++) + nm[i] = ctx->mixins[i]; + ctx->mixins = nm; + ctx->mixin_cap = newcap; + } + RubyMixinInfo *mi = &ctx->mixins[ctx->mixin_count++]; + memset(mi, 0, sizeof(*mi)); + mi->owner_qn = cbm_arena_strdup(ctx->arena, owner_qn); + mi->module_ref = cbm_arena_strdup(ctx->arena, module_ref); + mi->kind = kind; +} + +/* Find-or-create the (class, @ivar) record. `type_qn` may be NULL when the + * caller only wants to latch a conflict on a name never typed. */ +static RubyIvarInfo *ruby_ivar_slot(RubyLSPContext *ctx, const char *class_qn, + const char *ivar_name) { + for (int i = 0; i < ctx->ivar_count; i++) { + RubyIvarInfo *iv = &ctx->ivars[i]; + if (strcmp(iv->class_qn, class_qn) == 0 && strcmp(iv->ivar_name, ivar_name) == 0) + return iv; + } + if (ctx->ivar_count >= ctx->ivar_cap) { + int newcap = ctx->ivar_cap ? ctx->ivar_cap * 2 : 8; + RubyIvarInfo *ni = + (RubyIvarInfo *)cbm_arena_alloc(ctx->arena, (size_t)newcap * sizeof(RubyIvarInfo)); + if (!ni) + return NULL; + for (int i = 0; i < ctx->ivar_count; i++) + ni[i] = ctx->ivars[i]; + ctx->ivars = ni; + ctx->ivar_cap = newcap; + } + RubyIvarInfo *iv = &ctx->ivars[ctx->ivar_count++]; + memset(iv, 0, sizeof(*iv)); + iv->class_qn = cbm_arena_strdup(ctx->arena, class_qn); + iv->ivar_name = cbm_arena_strdup(ctx->arena, ivar_name); + return iv; +} + +/* Record `@x = ` (a resolved constructor). A second, different + * type latches the conflict. */ +static void ruby_add_ivar(RubyLSPContext *ctx, const char *class_qn, const char *ivar_name, + const char *type_qn) { + if (!ctx || !class_qn || !ivar_name || !type_qn) + return; + RubyIvarInfo *iv = ruby_ivar_slot(ctx, class_qn, ivar_name); + if (!iv) + return; + if (!iv->type_qn) + iv->type_qn = cbm_arena_strdup(ctx->arena, type_qn); + else if (strcmp(iv->type_qn, type_qn) != 0) + iv->conflicted = true; /* disagreeing assignments — no type */ +} + +/* Latch `@x` as untypable: some write to it was not a resolved constructor + * (or `nil`). Lookup then returns nothing regardless of other assignments — + * the zero-edge guarantee beats a coin-flip type. */ +static void ruby_ivar_mark_conflict(RubyLSPContext *ctx, const char *class_qn, + const char *ivar_name) { + if (!ctx || !class_qn || !ivar_name) + return; + RubyIvarInfo *iv = ruby_ivar_slot(ctx, class_qn, ivar_name); + if (iv) + iv->conflicted = true; +} + +static const char *ruby_ivar_type(RubyLSPContext *ctx, const char *class_qn, + const char *ivar_name) { + if (!ctx || !class_qn || !ivar_name) + return NULL; + for (int i = 0; i < ctx->ivar_count; i++) { + RubyIvarInfo *iv = &ctx->ivars[i]; + if (!iv->conflicted && iv->type_qn && strcmp(iv->class_qn, class_qn) == 0 && + strcmp(iv->ivar_name, ivar_name) == 0) + return iv->type_qn; + } + return NULL; +} + +static RubyClassInfo *ruby_class_by_qn(RubyLSPContext *ctx, const char *qn) { + if (!qn) + return NULL; + for (int i = 0; i < ctx->class_count; i++) { + if (strcmp(ctx->classes[i].qn, qn) == 0) + return &ctx->classes[i]; + } + return NULL; +} + +/* Conventional Rails superclasses for stdlib-seeded types the class table + * does not carry (per-file extraction cannot see app/models/ + * application_record.rb from another file; the convention is fixed). */ +static const char *ruby_stdlib_superclass(const char *qn) { + if (!qn) + return NULL; + if (strcmp(qn, "ApplicationRecord") == 0) + return "ActiveRecord.Base"; + if (strcmp(qn, "ApplicationController") == 0) + return "ActionController.Base"; + if (strcmp(qn, "ApplicationJob") == 0) + return "ActiveJob.Base"; + if (strcmp(qn, "ApplicationMailer") == 0) + return "ActionMailer.Base"; + return NULL; +} + +/* Superclass QN of a class: table entry first, then the conventional + * stdlib chain above. */ +static const char *ruby_superclass_qn(RubyLSPContext *ctx, const char *class_qn) { + RubyClassInfo *ci = ruby_class_by_qn(ctx, class_qn); + if (ci) + return ci->superclass_qn; + return ruby_stdlib_superclass(class_qn); +} + +/* ── constant resolution ────────────────────────────────────────── */ + +/* Class-table probe by dotted path. */ +static const char *ruby_class_qn_by_path(RubyLSPContext *ctx, const char *path) { + for (int i = 0; i < ctx->class_count; i++) { + if (strcmp(ctx->classes[i].path, path) == 0) + return ctx->classes[i].qn; + } + return NULL; +} + +const char *ruby_resolve_constant(RubyLSPContext *ctx, const char *path) { + if (!ctx || !path || !path[0]) + return NULL; + /* Lexical nesting probe, innermost frame first — Ruby's constant lookup + * order. The nesting string holds one RAW frame per declaration joined + * by '.', so stripping at the last '.' drops exactly one frame: inside + * `module M; class A::B` (nesting "M.A::B") the probes are "M.A.B.path", + * "M.path", "path" — never "M.A.path", because `class A::B` does not + * open A's scope. */ + const char *nest = ctx->nesting ? ctx->nesting : ""; + char prefix[512]; + size_t nlen = strlen(nest); + if (nlen < sizeof(prefix)) { + memcpy(prefix, nest, nlen + 1); + while (prefix[0]) { + const char *cand = + cbm_arena_sprintf(ctx->arena, "%s.%s", ruby_dotted(ctx->arena, prefix), path); + const char *qn = ruby_class_qn_by_path(ctx, cand); + if (qn) + return qn; + char *last_dot = strrchr(prefix, '.'); + if (last_dot) + *last_dot = '\0'; + else + prefix[0] = '\0'; + } + } + const char *qn = ruby_class_qn_by_path(ctx, path); + if (qn) + return qn; + /* Stdlib types are keyed by their dotted bare path. */ + const CBMRegisteredType *t = cbm_registry_lookup_type(ctx->registry, path); + if (t) + return t->qualified_name; + return NULL; +} + +/* ── method lookup (Ruby MRO approximation) ─────────────────────── */ + +/* Depth cap shared by the instance/singleton lookup walks. */ +#define RUBY_LOOKUP_MAX_DEPTH CBM_LSP_MAX_LOOKUP_DEPTH + +/* Own instance method of one exact class (no ancestry walk). */ +static const CBMRegisteredFunc *ruby_own_instance_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + return cbm_registry_lookup_method(ctx->registry, class_qn, method_name); +} + +/* Own singleton method of one exact class (".self" receiver key). */ +static const CBMRegisteredFunc *ruby_own_singleton_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + const char *key = cbm_arena_sprintf(ctx->arena, "%s.self", class_qn); + return cbm_registry_lookup_method(ctx->registry, key, method_name); +} + +/* Fail-closed ancestry. Every lookup below carries a `blocked` flag that is + * set the moment the walk meets an ancestor it cannot see: a mixin whose + * module did not resolve, or a superclass reference that did not resolve. + * An unseen ancestor may define (or, for a prepend, shadow) the method, so + * from that point on no hit is trustworthy and callers must emit nothing — + * including NOT falling through to a same-named top-level def or to the + * Object typing fallback. Hits found BEFORE the unseen ancestor in Ruby's + * lookup order are still returned. + * + * Each lookup is one walk: the `blocked` flag plus a visit stamp. Ruby + * linearizes ancestors, so a module reached a second time in one walk + * (`prepend M; include M`, two includes sharing a module) was already + * searched and missed: a hit or a block would have ended the walk. Skipping + * the repeat keeps a lookup linear in the ancestors it can see; without it + * every module that both prepends and includes the next one doubles the + * paths per level. */ +typedef struct { + bool blocked; + unsigned int stamp; +} RubyLookupWalk; + +static RubyLookupWalk ruby_walk_begin(RubyLSPContext *ctx) { + if (++ctx->lookup_stamp == 0) { + /* Wrapped: clear every mark so no class looks visited. */ + for (int i = 0; i < ctx->class_count; i++) { + ctx->classes[i].seen_instance = 0; + ctx->classes[i].seen_singleton = 0; + } + ctx->lookup_stamp = 1; + } + RubyLookupWalk w = {false, ctx->lookup_stamp}; + return w; +} + +/* Marks `class_qn` searched on one side (instance or singleton) in this walk; + * true when it already was. Only the file's class table is marked: mixins + * and superclass refs are recorded for those classes alone, so a type outside + * the table (a stdlib type) has nothing to expand and is never a repeat. */ +static bool ruby_walk_seen(RubyLSPContext *ctx, RubyLookupWalk *w, const char *class_qn, + bool singleton) { + RubyClassInfo *ci = ruby_class_by_qn(ctx, class_qn); + if (!ci) + return false; + unsigned int *mark = singleton ? &ci->seen_singleton : &ci->seen_instance; + if (*mark == w->stamp) + return true; + *mark = w->stamp; + return false; +} + +static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, + const char *class_qn, + const char *method_name, + int depth, RubyLookupWalk *w); + +/* True when the class declares a superclass the finalize step could not + * resolve (stdlib-seeded conventions like ApplicationRecord DO resolve). */ +static bool ruby_superclass_unresolved(RubyLSPContext *ctx, const char *class_qn) { + RubyClassInfo *ci = ruby_class_by_qn(ctx, class_qn); + return ci && ci->superclass_ref && !ci->superclass_qn; +} + +/* Search a mixin kind on `owner_qn` (most recent first — later mixins win), + * recursing into each module's own include chain. An UNRESOLVED mixin met + * before any hit blocks the walk. */ +static const CBMRegisteredFunc *ruby_lookup_in_mixins(RubyLSPContext *ctx, const char *owner_qn, + RubyMixinKind kind, const char *method_name, + int depth, RubyLookupWalk *w) { + for (int i = ctx->mixin_count - 1; i >= 0; i--) { + RubyMixinInfo *mi = &ctx->mixins[i]; + if (mi->kind != kind || strcmp(mi->owner_qn, owner_qn) != 0) + continue; + if (!mi->module_qn) { + w->blocked = true; /* unseen module — it may define/shadow the method */ + return NULL; + } + const CBMRegisteredFunc *f = + ruby_lookup_instance_method_depth(ctx, mi->module_qn, method_name, depth + 1, w); + if (f || w->blocked) + return f; + } + return NULL; +} + +static const CBMRegisteredFunc *ruby_lookup_instance_method_depth(RubyLSPContext *ctx, + const char *class_qn, + const char *method_name, + int depth, RubyLookupWalk *w) { + if (!ctx || !class_qn || !method_name || depth > RUBY_LOOKUP_MAX_DEPTH) + return NULL; + CBM_LSP_WORK(1); + if (ruby_walk_seen(ctx, w, class_qn, false)) + return NULL; /* searched earlier in this walk: a repeat can only miss */ + /* Prepended modules shadow the class's own methods — an unresolved + * prepend therefore blocks even an own-method hit. */ + const CBMRegisteredFunc *f = + ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_PREPEND, method_name, depth, w); + if (f || w->blocked) + return f; + f = ruby_own_instance_method(ctx, class_qn, method_name); + if (f) + return f; + /* An unresolved include stops the walk before the superclass chain. */ + f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_INCLUDE, method_name, depth, w); + if (f || w->blocked) + return f; + if (ruby_superclass_unresolved(ctx, class_qn)) { + w->blocked = true; + return NULL; + } + const char *sup = ruby_superclass_qn(ctx, class_qn); + if (sup && strcmp(sup, class_qn) != 0) + return ruby_lookup_instance_method_depth(ctx, sup, method_name, depth + 1, w); + return NULL; +} + +/* Instance lookup with the universal-receiver typing fallback. Skipped when + * the ancestry is blocked: nothing below the unseen ancestor is trustworthy. */ +static const CBMRegisteredFunc *ruby_lookup_instance_method_b(RubyLSPContext *ctx, + const char *class_qn, + const char *method_name, + RubyLookupWalk *w) { + const CBMRegisteredFunc *f = + ruby_lookup_instance_method_depth(ctx, class_qn, method_name, 0, w); + if (f || w->blocked) + return f; + /* Universal receiver fallback (Object / ActiveSupport predicates) — + * typing only; Object methods are stdlib entries, never project defs. */ + if (class_qn && strcmp(class_qn, "Object") != 0) + return cbm_registry_lookup_method(ctx->registry, "Object", method_name); + return NULL; +} + +const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + if (!ctx) + return NULL; + RubyLookupWalk w = ruby_walk_begin(ctx); + return ruby_lookup_instance_method_b(ctx, class_qn, method_name, &w); +} + +static const CBMRegisteredFunc *ruby_lookup_singleton_method_depth(RubyLSPContext *ctx, + const char *class_qn, + const char *method_name, + int depth, RubyLookupWalk *w) { + if (!ctx || !class_qn || !method_name || depth > RUBY_LOOKUP_MAX_DEPTH) + return NULL; + CBM_LSP_WORK(1); + if (ruby_walk_seen(ctx, w, class_qn, true)) + return NULL; /* searched earlier in this walk: a repeat can only miss */ + const CBMRegisteredFunc *f = ruby_own_singleton_method(ctx, class_qn, method_name); + if (f) + return f; + /* `extend Mod` adds Mod's instance methods class-side. */ + f = ruby_lookup_in_mixins(ctx, class_qn, RUBY_MIXIN_EXTEND, method_name, depth, w); + if (f || w->blocked) + return f; + if (ruby_superclass_unresolved(ctx, class_qn)) { + w->blocked = true; + return NULL; + } + const char *sup = ruby_superclass_qn(ctx, class_qn); + if (sup && strcmp(sup, class_qn) != 0) + return ruby_lookup_singleton_method_depth(ctx, sup, method_name, depth + 1, w); + return NULL; +} + +const CBMRegisteredFunc *ruby_lookup_singleton_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name) { + if (!ctx) + return NULL; + RubyLookupWalk w = ruby_walk_begin(ctx); + return ruby_lookup_singleton_method_depth(ctx, class_qn, method_name, 0, &w); +} + +/* ── ActiveRecord model detection (typing special case) ─────────── */ + +/* True when class_qn's superclass chain reaches ActiveRecord::Base (or the + * conventional ApplicationRecord intermediary, which per-file extraction + * usually cannot see through). */ +static bool ruby_is_ar_model(RubyLSPContext *ctx, const char *class_qn, int depth) { + if (!class_qn || depth > RUBY_LOOKUP_MAX_DEPTH) + return false; + if (strcmp(class_qn, "ActiveRecord.Base") == 0 || strcmp(class_qn, "ApplicationRecord") == 0) + return true; + RubyClassInfo *ci = ruby_class_by_qn(ctx, class_qn); + if (ci && ci->superclass_ref && + (strcmp(ci->superclass_ref, "ApplicationRecord") == 0 || + strcmp(ci->superclass_ref, "ActiveRecord.Base") == 0)) + return true; + const char *sup = ruby_superclass_qn(ctx, class_qn); + if (sup && strcmp(sup, class_qn) != 0) + return ruby_is_ar_model(ctx, sup, depth + 1); + return false; +} + +/* ActiveRecord class-side query methods that return the model (or a + * relation we approximate as the model, so chains like + * `User.where(...).first` keep their type). */ +static bool ruby_ar_returns_model(const char *m) { + static const char *names[] = {"find", + "find_by", + "find_by!", + "find_or_create_by", + "find_or_initialize_by", + "where", + "all", + "first", + "last", + "take", + "create", + "create!", + "new", + "order", + "limit", + "offset", + "joins", + "left_joins", + "includes", + "preload", + "eager_load", + "group", + "having", + "distinct", + "none", + "unscoped", + "find_each", + NULL}; + for (int i = 0; names[i]; i++) { + if (strcmp(m, names[i]) == 0) + return true; + } + return false; +} + +/* ── emit ───────────────────────────────────────────────────────── */ + +static void ruby_emit_resolved(RubyLSPContext *ctx, const char *callee_qn, const char *strategy, + float confidence, TSNode site) { + if (!ctx->resolved_calls || !callee_qn || !ctx->enclosing_func_qn) + return; + /* Only project-defined targets become edges (stdlib rows would never + * land on a node and merely add noise). */ + if (!ruby_is_project_qn(ctx, callee_qn)) + return; + CBMResolvedCall rc = {0}; + rc.caller_qn = ctx->enclosing_func_qn; + rc.callee_qn = callee_qn; + rc.strategy = strategy; + rc.confidence = confidence; + rc.reason = NULL; + rc.kind = CBM_RESOLVED_INVOCATION; + if (!ts_node_is_null(site)) { + rc.site_start_byte = ts_node_start_byte(site); + rc.site_end_byte = ts_node_end_byte(site); + } + cbm_resolvedcall_push(ctx->resolved_calls, ctx->arena, rc); +} + +/* ── expression typing ──────────────────────────────────────────── */ + +/* Return type of a registered func's signature, or unknown. */ +static const CBMType *ruby_func_return_type(const CBMRegisteredFunc *f) { + if (f && f->signature && f->signature->kind == CBM_TYPE_FUNC && + f->signature->data.func.return_types && f->signature->data.func.return_types[0]) { + return f->signature->data.func.return_types[0]; + } + return cbm_type_unknown(); +} + +const CBMType *ruby_eval_expr_type(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return cbm_type_unknown(); + if (ctx->eval_depth >= RUBY_EVAL_MAX_DEPTH) + return cbm_type_unknown(); + ctx->eval_depth++; + const CBMType *result = cbm_type_unknown(); + const char *k = ts_node_type(node); + + if (strcmp(k, "identifier") == 0) { + char *txt = ruby_node_text(ctx, node); + if (txt) { + const CBMType *t = cbm_scope_lookup(ctx->current_scope, txt); + if (t) + result = t; + } + } else if (strcmp(k, "instance_variable") == 0) { + char *txt = ruby_node_text(ctx, node); + const char *tqn = ruby_ivar_type(ctx, ctx->enclosing_class_qn, txt); + if (tqn) + result = cbm_type_named(ctx->arena, tqn); + } else if (strcmp(k, "self") == 0) { + if (ctx->enclosing_class_qn && !ctx->in_singleton_method && !ctx->self_unknown) + result = cbm_type_named(ctx->arena, ctx->enclosing_class_qn); + } else if (strcmp(k, "call") == 0) { + result = ruby_eval_call_type(ctx, node, false); + } else if (strcmp(k, "assignment") == 0) { + TSNode right = ts_node_child_by_field_name(node, "right", 5); + if (!ts_node_is_null(right)) + result = ruby_eval_expr_type(ctx, right); + } else if (strcmp(k, "string") == 0 || strcmp(k, "string_content") == 0 || + strcmp(k, "heredoc_body") == 0 || strcmp(k, "chained_string") == 0) { + result = cbm_type_named(ctx->arena, "String"); + } else if (strcmp(k, "array") == 0 || strcmp(k, "string_array") == 0 || + strcmp(k, "symbol_array") == 0) { + result = cbm_type_named(ctx->arena, "Array"); + } else if (strcmp(k, "hash") == 0) { + result = cbm_type_named(ctx->arena, "Hash"); + } else if (strcmp(k, "integer") == 0) { + result = cbm_type_named(ctx->arena, "Integer"); + } else if (strcmp(k, "float") == 0) { + result = cbm_type_named(ctx->arena, "Float"); + } else if (strcmp(k, "simple_symbol") == 0 || strcmp(k, "delimited_symbol") == 0) { + result = cbm_type_named(ctx->arena, "Symbol"); + } else if (strcmp(k, "regex") == 0) { + result = cbm_type_named(ctx->arena, "Regexp"); + } else if (strcmp(k, "parenthesized_statements") == 0 || strcmp(k, "begin") == 0) { + /* Type of the last meaningful child. */ + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = nc; i > 0; i--) { + TSNode c = ts_node_named_child(node, i - 1); + if (ts_node_is_null(c)) + continue; + const char *ck = ts_node_type(c); + if (strcmp(ck, "comment") == 0) + continue; + result = ruby_eval_expr_type(ctx, c); + break; + } + } + + ctx->eval_depth--; + return result; +} + +/* ── call resolution core ───────────────────────────────────────── */ + +/* Dynamic-dispatch method names the resolver must never guess through. */ +static bool ruby_is_dynamic_dispatch(const char *m) { + return strcmp(m, "send") == 0 || strcmp(m, "public_send") == 0 || strcmp(m, "__send__") == 0 || + strcmp(m, "method_missing") == 0 || strcmp(m, "define_method") == 0 || + strcmp(m, "instance_eval") == 0 || strcmp(m, "instance_exec") == 0 || + strcmp(m, "class_eval") == 0 || strcmp(m, "class_exec") == 0 || + strcmp(m, "module_eval") == 0 || strcmp(m, "module_exec") == 0 || + strcmp(m, "instance_variable_get") == 0 || strcmp(m, "instance_variable_set") == 0; +} + +/* True when `call` carries a block whose body runs with a DIFFERENT `self` + * than the enclosing lexical scope: the eval/exec family, define_method + * (self is the future receiver), and `Class.new { }` / `Module.new { }` + * (self is the anonymous class). Bare and `self.` calls in such a block + * must not dispatch on the enclosing class. */ +static bool ruby_call_rebinds_self(RubyLSPContext *ctx, TSNode call) { + if (ts_node_is_null(ts_node_child_by_field_name(call, "block", 5))) + return false; + TSNode meth = ts_node_child_by_field_name(call, "method", 6); + if (ts_node_is_null(meth)) + return false; + char *m = ruby_node_text(ctx, meth); + if (!m) + return false; + if (strcmp(m, "instance_eval") == 0 || strcmp(m, "instance_exec") == 0 || + strcmp(m, "class_eval") == 0 || strcmp(m, "class_exec") == 0 || + strcmp(m, "module_eval") == 0 || strcmp(m, "module_exec") == 0 || + strcmp(m, "define_method") == 0) + return true; + if (strcmp(m, "new") == 0) { + TSNode recv = ts_node_child_by_field_name(call, "receiver", 8); + if (!ts_node_is_null(recv) && strcmp(ts_node_type(recv), "constant") == 0) { + char *r = ruby_node_text(ctx, recv); + if (r && (strcmp(r, "Class") == 0 || strcmp(r, "Module") == 0)) + return true; + } + } + return false; +} + +/* Class-body macro-ish names PASS 1 already interpreted (or that never + * denote resolvable user calls). */ +static bool ruby_is_body_macro(const char *m) { + return strcmp(m, "include") == 0 || strcmp(m, "extend") == 0 || strcmp(m, "prepend") == 0 || + strcmp(m, "require") == 0 || strcmp(m, "require_relative") == 0 || + strcmp(m, "attr_accessor") == 0 || strcmp(m, "attr_reader") == 0 || + strcmp(m, "attr_writer") == 0 || strcmp(m, "private") == 0 || strcmp(m, "public") == 0 || + strcmp(m, "protected") == 0 || strcmp(m, "module_function") == 0 || + strcmp(m, "alias_method") == 0 || strcmp(m, "loop") == 0 || strcmp(m, "lambda") == 0 || + strcmp(m, "proc") == 0; +} + +/* Resolve a singleton-side dispatch on a resolved class QN. Emits (when + * `emit`) and returns the call's type. Also owns the ActiveRecord typing + * special case (User.find -> User). */ +static const CBMType *ruby_dispatch_singleton(RubyLSPContext *ctx, const char *class_qn, + const char *mname, bool emit, TSNode site) { + const CBMRegisteredFunc *f = ruby_lookup_singleton_method(ctx, class_qn, mname); + if (f) { + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_class_method", RUBY_CONF_HIGH, site); + const CBMType *rt = ruby_func_return_type(f); + if (!cbm_type_is_unknown(rt)) + return rt; + /* Stdlib AR query entries return unknown; substitute the model. */ + if (ruby_is_ar_model(ctx, class_qn, 0) && ruby_ar_returns_model(mname)) + return cbm_type_named(ctx->arena, class_qn); + return rt; + } + if (ruby_is_ar_model(ctx, class_qn, 0) && ruby_ar_returns_model(mname)) + return cbm_type_named(ctx->arena, class_qn); + return cbm_type_unknown(); +} + +/* Resolve an instance-side dispatch on a receiver type QN. */ +static const CBMType *ruby_dispatch_instance(RubyLSPContext *ctx, const char *class_qn, + const char *mname, const char *strategy, bool emit, + TSNode site) { + const CBMRegisteredFunc *f = ruby_lookup_instance_method(ctx, class_qn, mname); + if (!f) + return cbm_type_unknown(); + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, strategy, RUBY_CONF_TYPED, site); + return ruby_func_return_type(f); +} + +/* Resolve one `call` node. When `emit` is set, resolved project targets are + * appended as CBMResolvedCall edges; the call's result type is returned + * either way (so chains and assignments can type through it). */ +static const CBMType *ruby_eval_call_type(RubyLSPContext *ctx, TSNode call, bool emit) { + TSNode meth = ts_node_child_by_field_name(call, "method", 6); + TSNode recv = ts_node_child_by_field_name(call, "receiver", 8); + if (ts_node_is_null(meth)) + return cbm_type_unknown(); + + /* `super(...)` is dispatched by the walker (ruby_resolve_super); as an + * expression its type is not modeled. */ + if (strcmp(ts_node_type(meth), "super") == 0) + return cbm_type_unknown(); + + char *mname = ruby_node_text(ctx, meth); + if (!mname || !mname[0]) + return cbm_type_unknown(); + if (ruby_is_dynamic_dispatch(mname)) + return cbm_type_unknown(); /* zero-edge guarantee */ + + /* Constructor: Const.new / A::B.new — the textual extractor rewrites + * the callee to the constant, so the row targets the CLASS QN. */ + if (strcmp(mname, "new") == 0 && !ts_node_is_null(recv)) { + const char *cpath = ruby_const_path(ctx, recv); + if (cpath) { + const char *cqn = ruby_resolve_constant(ctx, cpath); + if (cqn) { + if (emit) + ruby_emit_resolved(ctx, cqn, "ruby_constructor", RUBY_CONF_HIGH, call); + return cbm_type_named(ctx->arena, cqn); + } + return cbm_type_unknown(); + } + /* Fall through: `expr.new` on a non-constant receiver. */ + } + + if (ts_node_is_null(recv)) { + /* Bare call: self-dispatch inside methods, then file-level funcs. */ + if (ruby_is_body_macro(mname)) + return cbm_type_unknown(); + if (ctx->self_unknown) + return cbm_type_unknown(); /* receiver is a rebound self — zero-edge */ + if (ctx->enclosing_class_qn) { + RubyLookupWalk w = ruby_walk_begin(ctx); + const CBMRegisteredFunc *f = + ctx->in_singleton_method + ? ruby_lookup_singleton_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, &w) + : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, &w); + if (f) { + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_self_dispatch", RUBY_CONF_HIGH, + call); + return ruby_func_return_type(f); + } + /* An unseen ancestor may define this method: do NOT fall through + * to a same-named top-level def. */ + if (w.blocked) + return cbm_type_unknown(); + } + const CBMRegisteredFunc *f = + cbm_registry_lookup_symbol(ctx->registry, ctx->module_qn, mname); + if (!f) + f = cbm_registry_lookup_func(ctx->registry, mname); /* Kernel builtins */ + if (f) { + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_function_local", RUBY_CONF_HIGH, + call); + return ruby_func_return_type(f); + } + return cbm_type_unknown(); + } + + const char *rk = ts_node_type(recv); + + /* self.m — explicit self dispatch. */ + if (strcmp(rk, "self") == 0) { + if (!ctx->enclosing_class_qn || ctx->self_unknown) + return cbm_type_unknown(); + RubyLookupWalk w = ruby_walk_begin(ctx); + const CBMRegisteredFunc *f = + ctx->in_singleton_method + ? ruby_lookup_singleton_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, &w) + : ruby_lookup_instance_method_depth(ctx, ctx->enclosing_class_qn, mname, 0, &w); + if (f) { + if (emit) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_self_dispatch", RUBY_CONF_HIGH, + call); + return ruby_func_return_type(f); + } + return cbm_type_unknown(); + } + + /* Const.m / A::B.m — singleton dispatch on the resolved class. */ + if (strcmp(rk, "constant") == 0 || strcmp(rk, "scope_resolution") == 0) { + const char *cpath = ruby_const_path(ctx, recv); + const char *cqn = cpath ? ruby_resolve_constant(ctx, cpath) : NULL; + if (!cqn) + return cbm_type_unknown(); /* unknown constant — zero-edge */ + return ruby_dispatch_singleton(ctx, cqn, mname, emit, call); + } + + /* Typed receiver: locals, ivars, chained calls, literals. */ + const CBMType *rt = ruby_eval_expr_type(ctx, recv); + if (!rt || rt->kind != CBM_TYPE_NAMED) + return cbm_type_unknown(); /* unknown receiver — zero-edge */ + const char *strategy = "ruby_method_typed"; + if (strcmp(rk, "instance_variable") == 0) + strategy = "ruby_ivar_method"; + else if (strcmp(rk, "call") == 0) + strategy = "ruby_method_chained"; + const CBMType *out = + ruby_dispatch_instance(ctx, rt->data.named.qualified_name, mname, strategy, emit, call); + if (!cbm_type_is_unknown(out)) + return out; + /* AR relation approximation: chains on a model type keep the model. */ + if (ruby_is_ar_model(ctx, rt->data.named.qualified_name, 0) && ruby_ar_returns_model(mname)) + return rt; + return out; +} + +/* `super` / `super(...)`: resolve the enclosing method's name starting at + * the enclosing class's ancestry (own def skipped — that IS the caller). */ +static void ruby_resolve_super(RubyLSPContext *ctx, TSNode site) { + if (!ctx->enclosing_class_qn || !ctx->enclosing_func_qn || ctx->self_unknown) + return; + const char *fq = ctx->enclosing_func_qn; + const char *mname = strrchr(fq, '.'); + mname = mname ? mname + 1 : fq; + const CBMRegisteredFunc *f = NULL; + RubyLookupWalk w = ruby_walk_begin(ctx); + /* Search order mirrors Ruby: includes of the class, then superclass + * chain — the class's OWN def is the caller and must be skipped. An + * unresolved mixin or superclass on the way blocks the walk. */ + f = ruby_lookup_in_mixins(ctx, ctx->enclosing_class_qn, + ctx->in_singleton_method ? RUBY_MIXIN_EXTEND : RUBY_MIXIN_INCLUDE, + mname, 0, &w); + if (!f && !w.blocked && !ruby_superclass_unresolved(ctx, ctx->enclosing_class_qn)) { + const char *sup = ruby_superclass_qn(ctx, ctx->enclosing_class_qn); + if (sup) { + f = ctx->in_singleton_method + ? ruby_lookup_singleton_method_depth(ctx, sup, mname, 0, &w) + : ruby_lookup_instance_method_depth(ctx, sup, mname, 0, &w); + } + } + if (f && !w.blocked) + ruby_emit_resolved(ctx, f->qualified_name, "ruby_method_super", RUBY_CONF_HIGH, site); + /* No known parent or unresolved — zero-edge guarantee. */ +} + +/* ── local scope (Ruby scope gates) ─────────────────────────────── */ + +/* Ruby scoping, as modeled here: + * - `def`, `class` and `module` bodies are SCOPE GATES: no outer local is + * visible inside, so each gets a fresh, unparented scope. + * - Blocks (`{ }`, `do end`, `-> { }`) close over the enclosing locals, so + * they get a CHAINED child scope. Block parameters shadow outer names + * for the block's extent only. + * - Assigning to a name that is already bound in an enclosing frame + * rebinds THAT frame (closure assignment), otherwise binds locally. + * - Parameters and any reassignment whose right-hand side has no proven + * type bind the name to UNKNOWN. A stale type must never survive a + * write the resolver could not read (zero-edge guarantee). */ + +/* Assign `type` to `name`: rebind the nearest existing binding in place, or + * bind in the current frame when the name is new. */ +static void ruby_scope_assign(RubyLSPContext *ctx, const char *name, const CBMType *type) { + if (!ctx->current_scope || !name || !name[0]) + return; + for (CBMScope *s = ctx->current_scope; s != NULL; s = s->parent) { + for (CBMScopeChunk *c = s->chunks; c != NULL; c = c->next) { + for (int i = 0; i < c->used; i++) { + if (c->bindings[i].name && strcmp(c->bindings[i].name, name) == 0) { + c->bindings[i].type = type; + c->bindings[i].callable_qn = NULL; + return; + } + } + } + } + cbm_scope_bind(ctx->current_scope, name, type); +} + +/* Bind every name introduced by a parameter list (method, block or lambda + * parameters, including destructured groups and block-local `; x` names) to + * UNKNOWN in the current frame. Parameters arrive untyped, and a parameter + * that shares its name with an outer local must shadow it. */ +static void ruby_bind_params_unknown(RubyLSPContext *ctx, TSNode params) { + if (ts_node_is_null(params)) + return; + const char *k = ts_node_type(params); + if (strcmp(k, "identifier") == 0) { + char *nm = ruby_node_text(ctx, params); + if (nm && nm[0]) + cbm_scope_bind(ctx->current_scope, nm, cbm_type_unknown()); + return; + } + if (strcmp(k, "optional_parameter") == 0 || strcmp(k, "keyword_parameter") == 0 || + strcmp(k, "splat_parameter") == 0 || strcmp(k, "hash_splat_parameter") == 0 || + strcmp(k, "block_parameter") == 0) { + ruby_bind_params_unknown(ctx, ts_node_child_by_field_name(params, "name", 4)); + return; + } + if (strcmp(k, "method_parameters") == 0 || strcmp(k, "block_parameters") == 0 || + strcmp(k, "lambda_parameters") == 0 || strcmp(k, "destructured_parameter") == 0) { + uint32_t nc = ts_node_named_child_count(params); + for (uint32_t i = 0; i < nc; i++) + ruby_bind_params_unknown(ctx, ts_node_named_child(params, i)); + } +} + +/* Rebind every local named on the left of a multiple assignment + * (`a, b = ...`, `a, (b, c) = ...`, `a, *rest = ...`) to UNKNOWN. */ +static void ruby_unbind_assignment_list(RubyLSPContext *ctx, TSNode lhs) { + if (ts_node_is_null(lhs)) + return; + const char *k = ts_node_type(lhs); + if (strcmp(k, "identifier") == 0) { + char *nm = ruby_node_text(ctx, lhs); + if (nm && nm[0]) + ruby_scope_assign(ctx, nm, cbm_type_unknown()); + return; + } + if (strcmp(k, "left_assignment_list") == 0 || strcmp(k, "destructured_left_assignment") == 0 || + strcmp(k, "rest_assignment") == 0) { + uint32_t nc = ts_node_named_child_count(lhs); + for (uint32_t i = 0; i < nc; i++) + ruby_unbind_assignment_list(ctx, ts_node_named_child(lhs, i)); + } +} + +/* ── assignment observer (scope binding) ────────────────────────── */ + +static void ruby_process_assignment(RubyLSPContext *ctx, TSNode assign) { + TSNode left = ts_node_child_by_field_name(assign, "left", 4); + TSNode right = ts_node_child_by_field_name(assign, "right", 5); + if (ts_node_is_null(left) || ts_node_is_null(right)) + return; + const char *lk = ts_node_type(left); + if (strcmp(lk, "left_assignment_list") == 0) { + ruby_unbind_assignment_list(ctx, left); /* no per-element typing */ + return; + } + if (strcmp(lk, "identifier") != 0) + return; /* only simple local targets are tracked */ + char *vname = ruby_node_text(ctx, left); + if (!vname || !vname[0]) + return; + const CBMType *rt = ruby_eval_expr_type(ctx, right); + /* An untyped right-hand side REBINDS to unknown — `a = Foo.new; a = x` + * must not leave `a` typed Foo. */ + ruby_scope_assign(ctx, vname, (rt && rt->kind == CBM_TYPE_NAMED) ? rt : cbm_type_unknown()); +} + +/* `a ||= expr` and the other compound assignments. `||=` on a name with no + * binding yet takes the right-hand type; on a name already bound to the same + * type it keeps it; every other case (untyped right-hand side, different + * type, or any non-`||=` operator) fails closed to UNKNOWN. */ +static void ruby_process_operator_assignment(RubyLSPContext *ctx, TSNode assign) { + TSNode left = ts_node_child_by_field_name(assign, "left", 4); + TSNode op = ts_node_child_by_field_name(assign, "operator", 8); + TSNode right = ts_node_child_by_field_name(assign, "right", 5); + if (ts_node_is_null(left) || ts_node_is_null(right)) + return; + if (strcmp(ts_node_type(left), "identifier") != 0) + return; + char *vname = ruby_node_text(ctx, left); + if (!vname || !vname[0]) + return; + char *optxt = ts_node_is_null(op) ? NULL : ruby_node_text(ctx, op); + const CBMType *result = cbm_type_unknown(); + if (optxt && strcmp(optxt, "||=") == 0) { + const CBMType *rt = ruby_eval_expr_type(ctx, right); + if (rt && rt->kind == CBM_TYPE_NAMED) { + const CBMVarBinding *cur = cbm_scope_lookup_binding(ctx->current_scope, vname); + if (!cur) + result = rt; /* nil || Foo.new → Foo */ + else if (cur->type && cur->type->kind == CBM_TYPE_NAMED && + strcmp(cur->type->data.named.qualified_name, rt->data.named.qualified_name) == + 0) + result = rt; + } + } + ruby_scope_assign(ctx, vname, result); +} + +/* ── PASS 2: resolution walk ────────────────────────────────────── */ + +/* Process one method/singleton_method definition node. `def` is a scope + * gate: the body starts from a FRESH scope holding only its parameters. */ +static void ruby_process_method(RubyLSPContext *ctx, TSNode node, bool singleton) { + CBMScope *saved_scope = ctx->current_scope; + const char *saved_func = ctx->enclosing_func_qn; + bool saved_singleton = ctx->in_singleton_method; + + ctx->current_scope = cbm_scope_push(ctx->arena, NULL); + ctx->in_singleton_method = singleton; + ruby_bind_params_unknown(ctx, ts_node_child_by_field_name(node, "parameters", 10)); + + TSNode name = ts_node_child_by_field_name(node, "name", 4); + char *mname = ts_node_is_null(name) ? NULL : ruby_node_text(ctx, name); + if (mname && mname[0]) { + const char *owner = ctx->enclosing_class_qn ? ctx->enclosing_class_qn : ctx->module_qn; + if (owner) + ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", owner, mname); + else + ctx->enclosing_func_qn = cbm_arena_strdup(ctx->arena, mname); + } + + TSNode body = ts_node_child_by_field_name(node, "body", 4); + if (!ts_node_is_null(body)) + ruby_resolve_calls_in_node(ctx, body); + + ctx->current_scope = saved_scope; + ctx->enclosing_func_qn = saved_func; + ctx->in_singleton_method = saved_singleton; +} + +/* Process a block / do-block / lambda: a CHAINED scope (closures see the + * enclosing locals) in which the block parameters shadow as UNKNOWN. */ +static void ruby_process_block(RubyLSPContext *ctx, TSNode node) { + CBMScope *saved_scope = ctx->current_scope; + ctx->current_scope = cbm_scope_push(ctx->arena, ctx->current_scope); + ruby_bind_params_unknown(ctx, ts_node_child_by_field_name(node, "parameters", 10)); + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(node, i); + if (ts_node_is_null(c)) + continue; + /* Parameters were consumed above; walking them would be harmless + * (identifiers are not calls) but default values may hold calls. */ + ruby_resolve_calls_in_node(ctx, c); + } + ctx->current_scope = saved_scope; +} + +/* Process a class/module node during PASS 2: update nesting + enclosing + * class, then walk the body from a fresh scope (class bodies are scope + * gates too). */ +static void ruby_process_class_body(RubyLSPContext *ctx, TSNode node) { + RubyClassFrame fr; + if (!ruby_class_enter(ctx, node, &fr)) + return; + CBMScope *saved_scope = ctx->current_scope; + ctx->current_scope = cbm_scope_push(ctx->arena, NULL); + + TSNode body = ts_node_child_by_field_name(node, "body", 4); + if (!ts_node_is_null(body)) + ruby_resolve_calls_in_node(ctx, body); + + ctx->current_scope = saved_scope; + ruby_class_leave(ctx, &fr); +} + +static void ruby_resolve_calls_in_node(RubyLSPContext *ctx, TSNode node) { + if (ctx->walk_depth >= CBM_LSP_RUBY_MAX_WALK_DEPTH) + return; + ctx->walk_depth++; + ruby_resolve_calls_in_node_inner(ctx, node); + ctx->walk_depth--; +} + +static void ruby_resolve_calls_in_node_inner(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return; + const char *k = ts_node_type(node); + + if (strcmp(k, "class") == 0 || strcmp(k, "module") == 0) { + ruby_process_class_body(ctx, node); + return; + } + if (strcmp(k, "singleton_class") == 0) { + /* `class << self` — methods inside are singleton methods. */ + bool saved = ctx->in_singleton_method; + ctx->in_singleton_method = true; + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = 0; i < nc; i++) + ruby_resolve_calls_in_node(ctx, ts_node_named_child(node, i)); + ctx->in_singleton_method = saved; + return; + } + if (strcmp(k, "method") == 0 || strcmp(k, "singleton_method") == 0) { + bool singleton = ctx->in_singleton_method || strcmp(k, "singleton_method") == 0; + if (ctx->block_depth > 0) { + /* A def inside a block. Inside a class body the extractor emits + * no def for it, so no row could ever join: skip it. At top + * level the extractor emits a plain Function, so typed-receiver + * calls in its body may resolve — but its `self` is whatever + * the block's DSL made it, so bare and self. calls emit nothing. */ + if (ctx->enclosing_class_qn) + return; + bool saved = ctx->self_unknown; + ctx->self_unknown = true; + ruby_process_method(ctx, node, singleton); + ctx->self_unknown = saved; + return; + } + ruby_process_method(ctx, node, singleton); + return; + } + if (strcmp(k, "block") == 0 || strcmp(k, "do_block") == 0 || strcmp(k, "lambda") == 0) { + ctx->block_depth++; + ruby_process_block(ctx, node); + ctx->block_depth--; + return; + } + + if (strcmp(k, "assignment") == 0) + ruby_process_assignment(ctx, node); + else if (strcmp(k, "operator_assignment") == 0) + ruby_process_operator_assignment(ctx, node); + + if (strcmp(k, "call") == 0) { + TSNode meth = ts_node_child_by_field_name(node, "method", 6); + if (!ts_node_is_null(meth) && strcmp(ts_node_type(meth), "super") == 0) + ruby_resolve_super(ctx, node); + else + ruby_eval_call_type(ctx, node, true); + if (ruby_call_rebinds_self(ctx, node)) { + /* Receiver and arguments are ordinary expressions; the block + * body runs with a `self` this resolver cannot name. */ + TSNode blk = ts_node_child_by_field_name(node, "block", 5); + uint32_t cc = ts_node_child_count(node); + for (uint32_t i = 0; i < cc; i++) { + TSNode c = ts_node_child(node, i); + if (ts_node_is_null(c)) + continue; + if (ts_node_eq(c, blk)) { + bool saved = ctx->self_unknown; + ctx->self_unknown = true; + ruby_resolve_calls_in_node(ctx, c); + ctx->self_unknown = saved; + } else { + ruby_resolve_calls_in_node(ctx, c); + } + } + return; + } + /* Recurse into receiver/arguments/blocks for nested calls. */ + } else if (strcmp(k, "super") == 0) { + /* Bare `super` (no argument list) appears as a standalone node. */ + ruby_resolve_super(ctx, node); + return; + } + + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(node, i); + if (!ts_node_is_null(c)) + ruby_resolve_calls_in_node(ctx, c); + } +} + +/* ── PASS 1: class/mixin/method collection ──────────────────────── */ + +/* Register one method into the registry with the proper receiver key. */ +static void ruby_register_method(RubyLSPContext *ctx, CBMTypeRegistry *reg, const char *owner_qn, + const char *mname, bool singleton) { + if (!mname || !mname[0]) + return; + CBMRegisteredFunc rf; + memset(&rf, 0, sizeof(rf)); + rf.min_params = -1; + const char *owner = owner_qn ? owner_qn : ctx->module_qn; + rf.qualified_name = owner ? cbm_arena_sprintf(ctx->arena, "%s.%s", owner, mname) + : cbm_arena_strdup(ctx->arena, mname); + rf.short_name = cbm_arena_strdup(ctx->arena, mname); + if (owner_qn) { + rf.receiver_type = singleton ? cbm_arena_sprintf(ctx->arena, "%s.self", owner_qn) + : cbm_arena_strdup(ctx->arena, owner_qn); + } + const CBMType **rets = (const CBMType **)cbm_arena_alloc(ctx->arena, 2 * sizeof(*rets)); + if (rets) { + rets[0] = cbm_type_unknown(); + rets[1] = NULL; + } + rf.signature = cbm_type_func(ctx->arena, NULL, NULL, rets); + cbm_registry_add_func(reg, rf); +} + +/* PASS-1 refs are nesting-scoped: the finalize step resolves superclass and + * mixin references once the class table is complete, so PASS 1 records raw + * dotted refs plus the nesting string active at the declaration site + * (packed as "|" when nesting is set). */ +static const char *ruby_pack_ref(RubyLSPContext *ctx, const char *ref) { + const char *nest = ctx->nesting ? ctx->nesting : ""; + if (!nest[0]) + return ref; + return cbm_arena_sprintf(ctx->arena, "%s|%s", nest, ref); +} + +/* Resolve a packed "|" (or bare "") against the class + * table + stdlib, honoring the recorded lexical nesting. */ +static const char *ruby_resolve_packed_ref(RubyLSPContext *ctx, const char *packed) { + if (!packed) + return NULL; + const char *bar = strchr(packed, '|'); + const char *saved_nesting = ctx->nesting; + const char *ref = packed; + if (bar) { + ctx->nesting = cbm_arena_strndup(ctx->arena, packed, (size_t)(bar - packed)); + ref = bar + 1; + } else { + ctx->nesting = ""; + } + const char *qn = ruby_resolve_constant(ctx, ref); + ctx->nesting = saved_nesting; + return qn; +} + +/* Bare ref portion of a packed "|" string. */ +static const char *ruby_packed_bare_ref(const char *packed) { + if (!packed) + return NULL; + const char *bar = strchr(packed, '|'); + return bar ? bar + 1 : packed; +} + +/* Interpret a class-body `include X` / `prepend X` / `extend X` call, + * recording the mixin with the nesting packed for finalize-time resolve. */ +static void ruby_collect_mixin_call(RubyLSPContext *ctx, TSNode call) { + if (!ctx->enclosing_class_qn) + return; + TSNode meth = ts_node_child_by_field_name(call, "method", 6); + TSNode recv = ts_node_child_by_field_name(call, "receiver", 8); + if (ts_node_is_null(meth)) + return; + if (!ts_node_is_null(recv) && strcmp(ts_node_type(recv), "self") != 0) + return; /* not a bare/self class-body macro */ + char *mname = ruby_node_text(ctx, meth); + if (!mname) + return; + RubyMixinKind kind; + if (strcmp(mname, "include") == 0) + kind = RUBY_MIXIN_INCLUDE; + else if (strcmp(mname, "prepend") == 0) + kind = RUBY_MIXIN_PREPEND; + else if (strcmp(mname, "extend") == 0) + kind = RUBY_MIXIN_EXTEND; + else + return; + TSNode args = ts_node_child_by_field_name(call, "arguments", 9); + if (ts_node_is_null(args)) + return; + uint32_t nc = ts_node_named_child_count(args); + for (uint32_t i = 0; i < nc; i++) { + const char *mpath = ruby_const_path(ctx, ts_node_named_child(args, i)); + if (!mpath) + continue; /* `extend self` etc. — not a constant */ + ruby_add_mixin(ctx, ctx->enclosing_class_qn, ruby_pack_ref(ctx, mpath), kind); + } +} + +static void ruby_pass1_class(RubyLSPContext *ctx, TSNode node, bool is_module) { + RubyClassFrame fr; + if (!ruby_class_enter(ctx, node, &fr)) + return; + RubyClassInfo *ci = ruby_add_class(ctx, fr.path, fr.qn, is_module); + + /* Superclass: `class C < Base` — the `superclass` field wraps the + * expression after `<`. The ref is resolved from the DECLARING scope + * (the frame just pushed does not apply to its own superclass), so pack + * it with the outer nesting. */ + if (ci && !is_module && !ci->superclass_ref) { + TSNode sup = ts_node_child_by_field_name(node, "superclass", 10); + if (!ts_node_is_null(sup)) { + uint32_t sc = ts_node_named_child_count(sup); + for (uint32_t i = 0; i < sc; i++) { + const char *spath = ruby_const_path(ctx, ts_node_named_child(sup, i)); + if (spath) { + const char *inner = ctx->nesting; + ctx->nesting = fr.saved_nesting; + ci->superclass_ref = ruby_pack_ref(ctx, spath); + ctx->nesting = inner; + break; + } + } + } + } + + TSNode body = ts_node_child_by_field_name(node, "body", 4); + if (!ts_node_is_null(body)) + ruby_pass1_scan(ctx, body); + ruby_class_leave(ctx, &fr); +} + +static void ruby_pass1_scan(RubyLSPContext *ctx, TSNode node) { + if (ctx->walk_depth >= CBM_LSP_RUBY_MAX_WALK_DEPTH) + return; + ctx->walk_depth++; + ruby_pass1_scan_inner(ctx, node); + ctx->walk_depth--; +} + +/* Registry under construction during PASS 1 (methods are registered as + * they are discovered). Held in a file-scope slot only for the duration of + * cbm_run_ruby_lsp's pass-1 invocation — single-threaded per file. */ +static void ruby_pass1_scan_inner(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return; + const char *k = ts_node_type(node); + + if (strcmp(k, "class") == 0) { + ruby_pass1_class(ctx, node, false); + return; + } + if (strcmp(k, "module") == 0) { + ruby_pass1_class(ctx, node, true); + return; + } + if (strcmp(k, "singleton_class") == 0) { + bool saved = ctx->in_singleton_method; + ctx->in_singleton_method = true; + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = 0; i < nc; i++) + ruby_pass1_scan(ctx, ts_node_named_child(node, i)); + ctx->in_singleton_method = saved; + return; + } + if (strcmp(k, "method") == 0 || strcmp(k, "singleton_method") == 0) { + /* A def inside a block (`class_methods do … end`, `included do … + * end`, a describe group) is not a method of the enclosing + * class/module — and not a file-level function either. */ + if (ctx->block_depth > 0) + return; + bool singleton = ctx->in_singleton_method || strcmp(k, "singleton_method") == 0; + TSNode name = ts_node_child_by_field_name(node, "name", 4); + char *mname = ts_node_is_null(name) ? NULL : ruby_node_text(ctx, name); + if (ctx->build_reg) + ruby_register_method(ctx, ctx->build_reg, ctx->enclosing_class_qn, mname, singleton); + return; /* method bodies are PASS-2 territory */ + } + if (strcmp(k, "call") == 0 && ctx->block_depth == 0) { + /* `include X` inside a block (`included do include X end`, + * `Foo.class_eval { include X }`) applies to some other module. */ + TSNode meth = ts_node_child_by_field_name(node, "method", 6); + if (!ts_node_is_null(meth)) { + char *mn = ruby_node_text(ctx, meth); + if (mn && (strcmp(mn, "include") == 0 || strcmp(mn, "prepend") == 0 || + strcmp(mn, "extend") == 0)) { + ruby_collect_mixin_call(ctx, node); + return; + } + } + } + + bool is_block = + strcmp(k, "block") == 0 || strcmp(k, "do_block") == 0 || strcmp(k, "lambda") == 0; + if (is_block) + ctx->block_depth++; + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(node, i); + if (!ts_node_is_null(c)) + ruby_pass1_scan(ctx, c); + } + if (is_block) + ctx->block_depth--; +} + +/* Finalize PASS 1: resolve superclass + mixin references now that the class + * table is complete. */ +static void ruby_finalize_refs(RubyLSPContext *ctx) { + for (int i = 0; i < ctx->class_count; i++) { + RubyClassInfo *ci = &ctx->classes[i]; + if (ci->superclass_ref) { + ci->superclass_qn = ruby_resolve_packed_ref(ctx, ci->superclass_ref); + /* Keep the bare ref spelling for AR-model heuristics. */ + ci->superclass_ref = ruby_packed_bare_ref(ci->superclass_ref); + } + } + for (int i = 0; i < ctx->mixin_count; i++) { + RubyMixinInfo *mi = &ctx->mixins[i]; + mi->module_qn = ruby_resolve_packed_ref(ctx, mi->module_ref); + mi->module_ref = ruby_packed_bare_ref(mi->module_ref); + } +} + +/* ── PASS 1.5: instance-variable type inference ─────────────────── */ + +/* Statically evaluate `Const.new` / `A::B.new` without scope context. */ +static const char *ruby_static_ctor_type(RubyLSPContext *ctx, TSNode expr) { + if (ts_node_is_null(expr) || strcmp(ts_node_type(expr), "call") != 0) + return NULL; + TSNode meth = ts_node_child_by_field_name(expr, "method", 6); + TSNode recv = ts_node_child_by_field_name(expr, "receiver", 8); + if (ts_node_is_null(meth) || ts_node_is_null(recv)) + return NULL; + char *mname = ruby_node_text(ctx, meth); + if (!mname || strcmp(mname, "new") != 0) + return NULL; + const char *cpath = ruby_const_path(ctx, recv); + if (!cpath) + return NULL; + return ruby_resolve_constant(ctx, cpath); +} + +/* Observe one write to `@x` (the `left` node) with right-hand side `right`. + * Only a RESOLVED `Const.new` types the ivar; `nil` is neutral (a common + * reset that says nothing about the type); every other right-hand side — + * an unresolved constant's `.new`, a method result, a parameter, a literal + * — latches the conflict, exactly like two different constructors do. */ +static void ruby_ivar_observe_write(RubyLSPContext *ctx, TSNode left, TSNode right) { + char *ivar = ruby_node_text(ctx, left); + if (!ivar || !ivar[0]) + return; + if (!ts_node_is_null(right) && strcmp(ts_node_type(right), "nil") == 0) + return; + const char *tqn = ruby_static_ctor_type(ctx, right); + if (tqn) + ruby_add_ivar(ctx, ctx->enclosing_class_qn, ivar, tqn); + else + ruby_ivar_mark_conflict(ctx, ctx->enclosing_class_qn, ivar); +} + +/* Latch every instance variable named on the left of a multiple assignment + * (`@a, @b = ...`): per-element typing is not attempted. */ +static void ruby_ivar_conflict_assignment_list(RubyLSPContext *ctx, TSNode lhs) { + if (ts_node_is_null(lhs)) + return; + const char *k = ts_node_type(lhs); + if (strcmp(k, "instance_variable") == 0) { + char *ivar = ruby_node_text(ctx, lhs); + if (ivar && ivar[0]) + ruby_ivar_mark_conflict(ctx, ctx->enclosing_class_qn, ivar); + return; + } + if (strcmp(k, "left_assignment_list") == 0 || strcmp(k, "destructured_left_assignment") == 0 || + strcmp(k, "rest_assignment") == 0) { + uint32_t nc = ts_node_named_child_count(lhs); + for (uint32_t i = 0; i < nc; i++) + ruby_ivar_conflict_assignment_list(ctx, ts_node_named_child(lhs, i)); + } +} + +static void ruby_ivar_scan(RubyLSPContext *ctx, TSNode node) { + if (ctx->walk_depth >= CBM_LSP_RUBY_MAX_WALK_DEPTH) + return; + ctx->walk_depth++; + ruby_ivar_scan_inner(ctx, node); + ctx->walk_depth--; +} + +static void ruby_ivar_scan_inner(RubyLSPContext *ctx, TSNode node) { + if (ts_node_is_null(node)) + return; + const char *k = ts_node_type(node); + + if (strcmp(k, "class") == 0 || strcmp(k, "module") == 0) { + RubyClassFrame fr; + if (!ruby_class_enter(ctx, node, &fr)) + return; + TSNode body = ts_node_child_by_field_name(node, "body", 4); + if (!ts_node_is_null(body)) + ruby_ivar_scan(ctx, body); + ruby_class_leave(ctx, &fr); + return; + } + + if (ctx->enclosing_class_qn) { + if (strcmp(k, "assignment") == 0) { + TSNode left = ts_node_child_by_field_name(node, "left", 4); + TSNode right = ts_node_child_by_field_name(node, "right", 5); + if (!ts_node_is_null(left)) { + const char *lk = ts_node_type(left); + if (strcmp(lk, "instance_variable") == 0) + ruby_ivar_observe_write(ctx, left, right); + else if (strcmp(lk, "left_assignment_list") == 0) + ruby_ivar_conflict_assignment_list(ctx, left); + } + } else if (strcmp(k, "operator_assignment") == 0) { + /* `@x ||= Const.new` is the memoised-constructor idiom and counts + * as a constructor assignment; any other operator is an untyped + * write. */ + TSNode left = ts_node_child_by_field_name(node, "left", 4); + TSNode op = ts_node_child_by_field_name(node, "operator", 8); + TSNode right = ts_node_child_by_field_name(node, "right", 5); + if (!ts_node_is_null(left) && strcmp(ts_node_type(left), "instance_variable") == 0) { + char *optxt = ts_node_is_null(op) ? NULL : ruby_node_text(ctx, op); + if (optxt && strcmp(optxt, "||=") == 0) { + ruby_ivar_observe_write(ctx, left, right); + } else { + char *ivar = ruby_node_text(ctx, left); + if (ivar && ivar[0]) + ruby_ivar_mark_conflict(ctx, ctx->enclosing_class_qn, ivar); + } + } + } + } + + /* A block that rebinds `self` writes ivars on some OTHER object; do not + * attribute them to the enclosing class. */ + TSNode skip_blk = {0}; + if (strcmp(k, "call") == 0 && ruby_call_rebinds_self(ctx, node)) + skip_blk = ts_node_child_by_field_name(node, "block", 5); + + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(node, i); + if (!ts_node_is_null(c) && !(!ts_node_is_null(skip_blk) && ts_node_eq(c, skip_blk))) + ruby_ivar_scan(ctx, c); + } +} + +/* ── public API ─────────────────────────────────────────────────── */ + +void ruby_lsp_init(RubyLSPContext *ctx, CBMArena *arena, const char *source, int source_len, + const CBMTypeRegistry *registry, const char *module_qn, + CBMResolvedCallArray *out) { + memset(ctx, 0, sizeof(*ctx)); + ctx->arena = arena; + ctx->source = source; + ctx->source_len = source_len; + ctx->registry = registry; + ctx->module_qn = module_qn; + ctx->nesting = ""; + ctx->resolved_calls = out; + ctx->current_scope = cbm_scope_push(arena, NULL); + + const char *dbg = getenv("CBM_LSP_DEBUG"); + ctx->debug = (dbg && dbg[0]); +} + +/* ── entry: cbm_run_ruby_lsp ────────────────────────────────────── */ + +void cbm_run_ruby_lsp(CBMArena *arena, CBMFileResult *result, const char *source, int source_len, + TSNode root) { + if (!result || !arena || ts_node_is_null(root)) + return; + + CBMTypeRegistry reg; + cbm_registry_init(®, arena); + + /* Phase A: stdlib (Ruby core + curated Rails surface). */ + cbm_ruby_stdlib_register(®, arena); + + const char *module_qn = result->module_qn; + + RubyLSPContext ctx; + ruby_lsp_init(&ctx, arena, source, source_len, ®, module_qn, &result->resolved_calls); + + /* Phase B: PASS 1 — collect classes/modules, mixins, and methods from + * the AST (methods register directly into `reg`; the AST is ground + * truth for the instance-vs-singleton split the defs don't carry). */ + ctx.build_reg = ® + ruby_pass1_scan(&ctx, root); + ctx.build_reg = NULL; + ruby_finalize_refs(&ctx); + + /* Finalize the registry for O(1) lookups — index allocations go to a + * per-call scratch arena that dies with this call (see perl_lsp.c). */ + CBMArena idx_arena; + cbm_arena_init(&idx_arena); + cbm_registry_finalize_into(®, &idx_arena); + + /* Phase B.5: PASS 1.5 — instance-variable types (needs the finished + * class table + registry for constant resolution). */ + ctx.nesting = ""; + ctx.enclosing_class_qn = NULL; + ruby_ivar_scan(&ctx, root); + + /* Phase C: PASS 2 — resolution walk. */ + ctx.nesting = ""; + ctx.enclosing_class_qn = NULL; + ctx.enclosing_func_qn = NULL; + ctx.in_singleton_method = false; + ruby_resolve_calls_in_node(&ctx, root); + + if (ctx.debug) { + fprintf(stderr, "[ruby_lsp] module_qn=%s defs=%d resolved=%d classes=%d mixins=%d\n", + module_qn ? module_qn : "(null)", result->defs.count, result->resolved_calls.count, + ctx.class_count, ctx.mixin_count); + for (int i = 0; i < result->resolved_calls.count; i++) { + CBMResolvedCall *r = &result->resolved_calls.items[i]; + fprintf(stderr, "[ruby_lsp] %s -> %s [%s %.2f]\n", r->caller_qn, r->callee_qn, + r->strategy, r->confidence); + } + } + + cbm_arena_destroy(&idx_arena); +} diff --git a/internal/cbm/lsp/ruby_lsp.h b/internal/cbm/lsp/ruby_lsp.h new file mode 100644 index 000000000..317702cb6 --- /dev/null +++ b/internal/cbm/lsp/ruby_lsp.h @@ -0,0 +1,176 @@ +#ifndef CBM_LSP_RUBY_LSP_H +#define CBM_LSP_RUBY_LSP_H + +#include "type_rep.h" +#include "scope.h" +#include "type_registry.h" +#include "../cbm.h" + +/* Ruby mixin kinds. Lookup order for an instance method on class C: + * prepended modules (last prepended first) → C's own methods → + * included modules (last included first) → superclass chain. + * `extend` adds a module's instance methods to C's singleton (class-side). */ +typedef enum { + RUBY_MIXIN_INCLUDE = 0, + RUBY_MIXIN_PREPEND, + RUBY_MIXIN_EXTEND, +} RubyMixinKind; + +/* One class/module discovered in the file (or, cross-file, in the project). + * `path` is the dotted constant path used ONLY for constant lookup ("Admin.User" + * for both `module Admin; class User` and `class Admin::User`). `qn` is the + * graph QN built exactly the way the structural extractor builds it: each + * declaration's raw name text appended to the enclosing class QN (or the + * module QN at top level), so a compact `class Admin::User` yields + * ".Admin::User" while the lexically nested form yields + * ".Admin.User". Emitted rows must carry the extractor spelling + * or the pipeline's exact-name join silently drops them. `superclass_qn` is + * resolved against the class table + stdlib after collection; NULL when + * unknown. */ +typedef struct { + const char *path; + const char *qn; + const char *superclass_ref; /* raw dotted superclass spelling, or NULL */ + const char *superclass_qn; /* resolved QN (table/stdlib), or NULL */ + bool is_module; /* `module` (mixin source) vs `class` */ + /* Method-lookup visit marks: the stamp of the last walk that searched + * this class on the instance / singleton side (ruby_walk_seen). */ + unsigned int seen_instance; + unsigned int seen_singleton; +} RubyClassInfo; + +/* One `include`/`prepend`/`extend` record: owner class QN → module ref. */ +typedef struct { + const char *owner_qn; /* class/module whose body held the call */ + const char *module_ref; /* raw dotted module spelling */ + const char *module_qn; /* resolved QN, or NULL */ + RubyMixinKind kind; +} RubyMixinInfo; + +/* One inferred instance-variable type: (class QN, @name) → type QN. + * `conflicted` latches when two assignments disagree — lookup then returns + * nothing (zero-edge guarantee beats a coin-flip type). */ +typedef struct { + const char *class_qn; + const char *ivar_name; /* including the leading '@' */ + const char *type_qn; + bool conflicted; +} RubyIvarInfo; + +/* RubyLSPContext — per-file state for Ruby type-aware call resolution. + * Mirrors PerlLSPContext / PHPLSPContext structure. + * + * Ruby specifics that shape this context: + * - Classes/modules nest lexically (`module A; class B`), and constants + * resolve through the lexical nesting outward (A::B sees A's constants + * before top-level ones). `nesting` tracks the current dotted path. + * - Inheritance is `class C < Base`; mixins arrive via include/prepend/ + * extend and participate in method lookup (see RubyMixinKind). + * - `Foo.new` constructs a Foo and dispatches to Foo#initialize. + * - `def m` defines an instance method; `def self.m` a singleton (class) + * method. Singleton methods are registered under the receiver key + * ".self" — constants cannot be lowercase, so the suffix can + * never collide with a real nested-constant path. */ +typedef struct { + CBMArena *arena; + const char *source; + int source_len; + const CBMTypeRegistry *registry; + /* Mutable alias of `registry`, set only while PASS 1 populates method + * tables (mirrors perl_lsp's mutable-reg build phase). NULL afterwards. */ + CBMTypeRegistry *build_reg; + CBMScope *current_scope; + + /* Lexical nesting: one frame per enclosing class/module declaration, + * frames joined by '.', each frame keeping its raw source spelling ("" at + * top level, "M.A::B" inside `module M; class A::B`). Ruby's constant + * lookup walks the frames outward — inside `class A::B` the nesting is + * [A::B] only, so A's constants are NOT probed. */ + const char *nesting; + + /* Class/module table (pass 1). */ + RubyClassInfo *classes; + int class_count; + int class_cap; + + /* Mixin table (pass 1). */ + RubyMixinInfo *mixins; + int mixin_count; + int mixin_cap; + + /* Instance-variable type table (pass 1). */ + RubyIvarInfo *ivars; + int ivar_count; + int ivar_cap; + + /* Cross-file import map (require/require_relative resolved by the + * pipeline). Unused per-file: Ruby constants are file-global. */ + const char **import_names; + const char **import_qns; + int import_count; + + /* Current enclosing context during the resolution walk. */ + const char *enclosing_class_qn; /* class QN, or NULL at top level */ + const char *enclosing_func_qn; /* enclosing method QN, or NULL */ + bool in_singleton_method; /* `def self.m` / class << self scope */ + /* Inside a block that rebinds `self` (instance_eval/exec, class_eval/ + * exec, module_eval/exec, define_method, Class.new/Module.new): the + * receiver of bare and `self.` calls is NOT the enclosing class, and + * the resolver cannot know what it is — so they emit nothing. */ + bool self_unknown; + /* Number of enclosing blocks (`{ }`, `do end`, `-> { }`) since the + * nearest class/module body. A `def` met here is NOT a method of the + * enclosing class — ActiveSupport's `class_methods do … end`, + * `included do … end`, RSpec groups — so PASS 1 registers nothing for + * it and collects no mixins from it. */ + int block_depth; + const char *module_qn; + + /* Output: resolved calls accumulate here. */ + CBMResolvedCallArray *resolved_calls; + + /* Recursion guards. */ + int eval_depth; + int walk_depth; + + /* Stamp of the current method-lookup walk; each walk takes a fresh one + * so marks left by earlier walks never match. */ + unsigned int lookup_stamp; + + /* Debug mode (CBM_LSP_DEBUG env). */ + bool debug; +} RubyLSPContext; + +/* Initialize a RubyLSPContext for processing one file. */ +void ruby_lsp_init(RubyLSPContext *ctx, CBMArena *arena, const char *source, int source_len, + const CBMTypeRegistry *registry, const char *module_qn, + CBMResolvedCallArray *out); + +/* Resolve a dotted constant path (e.g. "Foo", "A.B") against the class + * table using the current lexical nesting, then the stdlib. Returns the + * resolved QN or NULL. */ +const char *ruby_resolve_constant(RubyLSPContext *ctx, const char *path); + +/* Look up an instance method on a class, walking prepends → own → includes → + * superclass chain. Returns the resolved CBMRegisteredFunc or NULL. */ +const CBMRegisteredFunc *ruby_lookup_instance_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name); + +/* Look up a singleton (class-side) method: own singleton methods → + * `extend`ed modules' instance methods → superclass singleton chain. */ +const CBMRegisteredFunc *ruby_lookup_singleton_method(RubyLSPContext *ctx, const char *class_qn, + const char *method_name); + +/* Evaluate a Ruby expression's type. May return CBM_TYPE_UNKNOWN. */ +const CBMType *ruby_eval_expr_type(RubyLSPContext *ctx, TSNode node); + +/* Entry point: build registry from file defs + stdlib, then run resolution. + * Called from cbm_extract_file() via the language dispatch in cbm.c. */ +void cbm_run_ruby_lsp(CBMArena *arena, CBMFileResult *result, const char *source, int source_len, + TSNode root); + +/* Register Ruby core stdlib types/methods + a curated Rails surface + * (ActiveRecord/ActiveSupport/ActionController) into a registry. */ +void cbm_ruby_stdlib_register(CBMTypeRegistry *reg, CBMArena *arena); + +#endif /* CBM_LSP_RUBY_LSP_H */ diff --git a/internal/cbm/lsp_all.c b/internal/cbm/lsp_all.c index 4ab1f5155..7cf05a0c4 100644 --- a/internal/cbm/lsp_all.c +++ b/internal/cbm/lsp_all.c @@ -14,6 +14,8 @@ #include "lsp/generated/php_stdlib_data.c" #include "lsp/perl_lsp.c" #include "lsp/generated/perl_stdlib_data.c" +#include "lsp/ruby_lsp.c" +#include "lsp/generated/ruby_stdlib_data.c" #include "lsp/generated/python_stdlib_data.c" #include "lsp/py_lsp.c" #include "lsp/ts_lsp.c" diff --git a/internal/cbm/php_inline_html.c b/internal/cbm/php_inline_html.c new file mode 100644 index 000000000..e766a1fbd --- /dev/null +++ b/internal/cbm/php_inline_html.c @@ -0,0 +1,348 @@ +// php_inline_html.c — let the php_only grammar read a file that leaves PHP mode. +// +// A PHP file starts in inline-HTML mode and may switch in and out of PHP any +// number of times: `?>
...
` or for the +// text that follows it, so every byte after the first close tag used to land in +// an ERROR region and the declarations there never reached the graph (#2000). +// +// Instead of parsing the markup, blank it. The rewritten buffer has the same +// length as the original and keeps every line break, so byte offsets, rows and +// columns are unchanged and node text inside PHP code is byte-identical: +// +// - markup before the first open tag and between tags becomes spaces +// - the first open tag stays, so the tree keeps its php_tag +// - each later `` becomes `; `, the statement terminator PHP itself treats it as +// +// Only code and `//` / `#` comments can close PHP mode. Strings, heredoc, +// nowdoc and block comments are skipped, `#[` is an attribute, and everything +// after __halt_compiler is left alone, as PHP stops reading there. + +#include "cbm.h" +#include "foundation/arena.h" +#include "foundation/constants.h" +#include +#include +#include + +enum { + PHP_TAG_OPEN_LEN = 5, /* = 0 && i <= len && (size_t)(len - i) >= n && memcmp(s + i, lit, n) == 0; +} + +static bool php_is_ident_char(unsigned char c) { + return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c >= PHP_FIRST_NON_ASCII; +} + +static bool php_is_ident_start(unsigned char c) { + return php_is_ident_char(c) && !(c >= '0' && c <= '9'); +} + +static bool php_is_space(unsigned char c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v'; +} + +static bool php_is_blank(char c) { + return c == ' ' || c == '\t'; +} + +/* Case-insensitive match of the lowercase `word` at s[i]. */ +static bool php_ieq(const char *s, int len, int i, const char *word) { + int n = (int)strlen(word); + if (i + n > len) { + return false; + } + for (int k = 0; k < n; k++) { + char c = s[i + k]; + if (c >= 'A' && c <= 'Z') { + c = (char)(c - 'A' + 'a'); + } + if (c != word[k]) { + return false; + } + } + return true; +} + +/* Length of the open tag at i, or 0. `` prolog in markup stays markup. */ +static int php_open_tag_len(const char *s, int len, int i) { + if (!php_at(s, len, i, "")) { + return true; + } + } + return false; +} + +/* Blank [from, to) in place, keeping line breaks. */ +static void php_blank(char *out, int from, int to) { + for (int k = from; k < to; k++) { + if (out[k] != '\n' && out[k] != '\r') { + out[k] = ' '; + } + } +} + +/* Index just past the quoted string opened at i. */ +static int php_skip_quoted(const char *s, int len, int i) { + char quote = s[i]; + i += SKIP_ONE; + while (i < len && s[i] != quote) { + i += (s[i] == '\\') ? PAIR_LEN : SKIP_ONE; + } + return i < len ? i + SKIP_ONE : len; +} + +/* Index just past the block comment opened at i. */ +static int php_skip_block_comment(const char *s, int len, int i) { + for (int k = i + PAIR_LEN; k < len; k++) { + if (php_at(s, len, k, "*/")) { + return k + PAIR_LEN; + } + } + return len; +} + +/* Index of the line break or `?>` that ends the one-line comment at i. */ +static int php_skip_line_comment(const char *s, int len, int i) { + while (i < len && s[i] != '\n' && !php_at(s, len, i, "?>")) { + i++; + } + return i; +} + +/* Parse the heredoc/nowdoc opener `<<= len || s[p] != quote) { + return PHP_NOT_FOUND; + } + p++; + } + if (p < len && s[p] == '\r') { + p++; + } + return (p < len && s[p] == '\n') ? p : PHP_NOT_FOUND; +} + +/* True when the line starting at p closes the heredoc. PHP 7.3+ allows the + * closing label to be indented and followed by any non-identifier byte. */ +static bool php_heredoc_closes(const char *s, int len, int p, int label, int label_len, int *end) { + while (p < len && php_is_blank(s[p])) { + p++; + } + if (len - p < label_len || memcmp(s + p, s + label, (size_t)label_len) != 0) { + return false; + } + int after = p + label_len; + if (after < len && php_is_ident_char((unsigned char)s[after])) { + return false; + } + *end = after; + return true; +} + +/* Index just past the heredoc/nowdoc opened by the `<<<` at i. */ +static int php_skip_heredoc(const char *s, int len, int i) { + int label = 0; + int label_len = 0; + int nl = php_heredoc_opener(s, len, i, &label, &label_len); + if (nl < 0) { + return i + PHP_HEREDOC_LEN; + } + while (nl < len) { + int line = nl + SKIP_ONE; + int end = 0; + if (php_heredoc_closes(s, len, line, label, label_len, &end)) { + return end; + } + const char *next = memchr(s + line, '\n', (size_t)(len - line)); + if (!next) { + return len; + } + nl = (int)(next - s); + } + return len; +} + +/* Index just past the identifier at i; sets *halt when it is __halt_compiler + * (a keyword, so not a `$variable` of that name). */ +static int php_skip_ident(const char *s, int len, int i, bool *halt) { + int start = i; + while (i < len && php_is_ident_char((unsigned char)s[i])) { + i++; + } + bool is_var = start > 0 && s[start - SKIP_ONE] == '$'; + *halt = + !is_var && i - start == PHP_HALT_COMPILER_LEN && php_ieq(s, len, start, PHP_HALT_COMPILER); + return i; +} + +/* Scan PHP code from i to the next close tag. Returns the index of that `?>`, + * len when the file ends in PHP mode, or PHP_NOT_FOUND at __halt_compiler. */ +static int php_scan_code(const char *s, int len, int i) { + while (i < len) { + unsigned char c = (unsigned char)s[i]; + if (php_at(s, len, i, "?>")) { + return i; + } + if (c == '\'' || c == '"' || c == '`') { + i = php_skip_quoted(s, len, i); + } else if (php_at(s, len, i, "/*")) { + i = php_skip_block_comment(s, len, i); + } else if (php_at(s, len, i, "//") || (c == '#' && !php_at(s, len, i, "#["))) { + i = php_skip_line_comment(s, len, i); + } else if (php_at(s, len, i, "<<<")) { + i = php_skip_heredoc(s, len, i); + } else if (php_is_ident_start(c)) { + bool halt = false; + i = php_skip_ident(s, len, i, &halt); + if (halt) { + return PHP_NOT_FOUND; + } + } else { + i++; + } + } + return len; +} + +/* The rewritten buffer. It is allocated and filled from the source on the + * first write, so a file whose only `?>` bytes sit in strings or comments + * costs no copy. */ +typedef struct { + CBMArena *arena; + const char *source; + int len; + char *out; + bool failed; +} PHPMask; + +static char *php_mask_writable(PHPMask *m) { + if (!m->out && !m->failed) { + m->out = (char *)cbm_arena_alloc(m->arena, (size_t)m->len + SKIP_ONE); + if (m->out) { + memcpy(m->out, m->source, (size_t)m->len); + m->out[m->len] = '\0'; + } else { + m->failed = true; + } + } + return m->out; +} + +/* Blank [from, to) of the rewritten buffer, keeping line breaks. */ +static void php_mask_blank(PHPMask *m, int from, int to) { + char *out = php_mask_writable(m); + if (out) { + php_blank(out, from, to); + } +} + +/* Turn the `?>` at i into `; `. */ +static void php_mask_close_tag(PHPMask *m, int i) { + char *out = php_mask_writable(m); + if (out) { + out[i] = ';'; + out[i + SKIP_ONE] = ' '; + } +} + +const char *cbm_php_mask_inline_html(CBMArena *arena, const char *source, int source_len) { + if (!arena || !source || source_len <= 0) { + return source; + } + /* Fast path: a file that opens PHP at its first byte and never writes a + * close tag is exactly what the grammar already accepts. */ + if (php_open_tag_len(source, source_len, 0) > 0 && !php_has_close_tag(source, source_len)) { + return source; + } + + PHPMask m = {.arena = arena, .source = source, .len = source_len}; + bool seen_open = false; + int i = 0; + while (i < source_len && !m.failed) { + /* Inline-HTML mode: blank up to the next open tag. */ + int html_start = i; + int tag = 0; + while (i < source_len && (tag = php_open_tag_len(source, source_len, i)) == 0) { + i++; + } + if (i > html_start) { + php_mask_blank(&m, html_start, i); + } + if (i >= source_len) { + break; + } + if (seen_open) { + php_mask_blank(&m, i, i + tag); + } + seen_open = true; + + /* PHP mode, up to the close tag. */ + int close = php_scan_code(source, source_len, i + tag); + if (close < 0 || close >= source_len) { + break; + } + php_mask_close_tag(&m, close); + i = close + PAIR_LEN; + } + /* On allocation failure parse the file as it is: the old behaviour. */ + return (m.out && !m.failed) ? m.out : source; +} diff --git a/scripts/benchmark-index.sh b/scripts/benchmark-index.sh index 756bda06e..792ec9feb 100755 --- a/scripts/benchmark-index.sh +++ b/scripts/benchmark-index.sh @@ -9,11 +9,74 @@ LANG="${2:?}" REPO="${3:?}" RESULTS_DIR="${4:?}" -# Resolve symlinks -REPO=$(cd "$REPO" && pwd -P) - OUT="$RESULTS_DIR/$LANG" mkdir -p "$OUT" +# A handoff names this run's index or none: the evaluation loop reuses the +# results directory, and a stale one would point the graph session at a root +# step 8 has already removed. It goes before any setup step that can fail, so +# a run that never reaches the index cannot leave an earlier one behind. +rm -f -- "$OUT/runtime-root.txt" + +# The index must run against a daemon rendezvous and cache this run owns: only +# CBM_RUNTIME_DIR moves the rendezvous, and without a private cache the +# benchmark repository was indexed into the operator's live store (#1696). +# shellcheck source=test-runtime.sh +source "$(dirname "${BASH_SOURCE[0]}")/test-runtime.sh" +cbm_test_runtime_init + +# The evaluation plan (docs/EVALUATION_PLAN.md §7) indexes a language here and +# then answers graph questions against that index from its own MCP session. +# A run-private root would be gone before that session starts, so the caller +# may ask for it to be kept: after a SUCCESSFUL run the harness stops its +# daemon, leaves the root in place, records the paths that reach it in +# //runtime-root.txt (sourceable), and ownership of the root — +# including its removal — passes to the caller. A failed run cleans up +# regardless: there is no index worth keeping, and nothing must leak. Never on +# by default, or an unattended run accumulates one root per language. +# +# The handoff promises a stopped daemon, so it is written only once +# `daemon stop` succeeded. A refused stop (a client still committed to the +# daemon, or no answer) fails the run with the product's explanation, writes +# no handoff, and keeps the root without cleaning it up: the daemon may still +# be using it, and the operator decides what happens to it. +bench_finish() { + local rc=$? stop_out="" + [ -z "${BENCH_TMP:-}" ] || rm -rf -- "$BENCH_TMP" || true + if [ "$rc" -eq 0 ] && [ -n "${CBM_BENCH_KEEP_RUNTIME:-}" ] && [ -d "${OUT:-}" ]; then + if ! stop_out=$("$BINARY" daemon stop 2>&1); then + echo " $LANG: private daemon did not stop; runtime kept at $CBM_TEST_RUNTIME_ROOT, no handoff written" >&2 + [ -z "$stop_out" ] || printf '%s\n' "$stop_out" >&2 + exit 1 + fi + printf 'CBM_BENCH_RUNTIME_ROOT=%q\nCBM_RUNTIME_DIR=%q\nCBM_CACHE_DIR=%q\n' \ + "$CBM_TEST_RUNTIME_ROOT" "$CBM_RUNTIME_DIR" "$CBM_CACHE_DIR" > "$OUT/runtime-root.txt" + echo " $LANG: runtime kept at $CBM_TEST_RUNTIME_ROOT; paths in $OUT/runtime-root.txt" >&2 + return 0 + fi + cbm_test_runtime_cleanup "$BINARY" +} +BENCH_TMP="" +trap bench_finish EXIT +# The index call keeps its stderr, the way the search-graph twin does: for a +# one-shot CLI that is the only channel a refusal is reported on, and without +# it the failure below could name only its symptom. +BENCH_TMP=$(mktemp -d) +INDEX_ERR="$BENCH_TMP/index-stderr.log" +DAEMON_START_LOG="$BENCH_TMP/daemon-start.log" + +# Resolve symlinks +REPO=$(cd "$REPO" && pwd -P) +# One pre-escaped spelling of the path for every request below, the way the +# soak harness builds its own: a repository path may legitimately contain a +# quote or a backslash, and hand-built JSON turns that into a parse error. +REPO_JSON=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$REPO") + +# Elapsed time is read from a monotonic clock, never the wall clock: an NTP +# step mid-run would otherwise skew — or negate — a figure whose whole purpose +# is comparison across runs. Its reference point is fixed per boot on every +# platform CPython supports here, so the three readings below are comparable +# even though each comes from its own process. +bench_now_ms() { python3 -c "import time; print(time.monotonic_ns() // 1000000)"; } echo "INDEX: $LANG ($REPO)" @@ -33,16 +96,39 @@ LOC=$(find "$REPO" -type f \ echo "$FILE_COUNT" > "$OUT/file-count.txt" echo "$LOC" > "$OUT/loc.txt" +# Start the private daemon before timing so index-time.txt measures the index +# alone. setup-time.txt keeps the activation cost attributable and +# total-time.txt is their sum — the figure comparable with earlier runs, which +# paid activation inside the index timing whenever no daemon was already warm. +SETUP_START_MS=$(bench_now_ms) +# Its output is kept for the failure path: the product names why a daemon did +# not start, and that is the part an operator needs. +if ! "$BINARY" daemon start >"$DAEMON_START_LOG" 2>&1; then + echo " $LANG: private daemon did not start" >&2 + if [ -s "$DAEMON_START_LOG" ]; then + echo "--- daemon start output ---" >&2 + cat "$DAEMON_START_LOG" >&2 + fi + exit 1 +fi + # Index via CLI and capture timing -START_MS=$(python3 -c "import time; print(int(time.time()*1000))") +START_MS=$(bench_now_ms) -INDEX_JSON=$("$BINARY" cli index_repository "{\"repo_path\":\"$REPO\",\"mode\":\"full\"}" 2>/dev/null || echo '{"error":"index failed"}') +# The CLI's exit status is the index's verdict. It is recorded here and acted +# on below, once every per-run file is written; an empty response stays valid +# JSON in 00-index.json so the failure is legible there as well. +INDEX_RC=0 +INDEX_JSON=$("$BINARY" cli index_repository "{\"repo_path\":$REPO_JSON,\"mode\":\"full\"}" 2>"$INDEX_ERR") || + { INDEX_RC=$?; INDEX_JSON="{\"error\":\"index failed\",\"exit\":$INDEX_RC}"; } -END_MS=$(python3 -c "import time; print(int(time.time()*1000))") +END_MS=$(bench_now_ms) ELAPSED=$((END_MS - START_MS)) echo "$INDEX_JSON" > "$OUT/00-index.json" echo "$ELAPSED" > "$OUT/index-time.txt" +echo "$((START_MS - SETUP_START_MS))" > "$OUT/setup-time.txt" +echo "$((END_MS - SETUP_START_MS))" > "$OUT/total-time.txt" # Extract node/edge counts (CLI wraps in MCP content envelope) NODES=$(echo "$INDEX_JSON" | python3 -c " @@ -78,5 +164,20 @@ echo "$NODES" > "$OUT/nodes.txt" echo "$EDGES" > "$OUT/edges.txt" echo "$PROJECT" > "$OUT/project.txt" +# An index that failed, or that named no project, fails the run: after the +# timing files, so the caller keeps its figures, and through the exit status, +# so the caller's loop notices and bench_finish never keeps a root with no +# index in it. +if [ "$INDEX_RC" -ne 0 ] || [ -z "$PROJECT" ]; then + echo " $LANG: index failed (cli exit $INDEX_RC, project '$PROJECT'); response in $OUT/00-index.json" >&2 + if [ -s "$INDEX_ERR" ]; then + echo "--- index stderr ---" >&2 + cat "$INDEX_ERR" >&2 + fi + echo "--- index response (first 500 bytes) ---" >&2 + printf '%.500s\n' "$INDEX_JSON" >&2 + exit 1 +fi + printf " %s: %s files, %s LOC, %sms, %s nodes, %s edges\n" \ "$LANG" "$FILE_COUNT" "$LOC" "$ELAPSED" "$NODES" "$EDGES" diff --git a/scripts/benchmark-search-graph.sh b/scripts/benchmark-search-graph.sh index cc94147ec..dad4f6ec4 100755 --- a/scripts/benchmark-search-graph.sh +++ b/scripts/benchmark-search-graph.sh @@ -3,18 +3,72 @@ # codebase-memory-mcp binary to measure the regex / LIKE pre-filter performance. # # Usage: -# scripts/benchmark-search-graph.sh +# scripts/benchmark-search-graph.sh # # Example: -# scripts/benchmark-search-graph.sh ./build/c/codebase-memory-mcp my-project +# scripts/benchmark-search-graph.sh ./build/c/codebase-memory-mcp ~/src/my-project +# +# The repository is indexed (untimed) into a private runtime and cache first; +# the queries then run against that index through a daemon this run keeps warm, +# so a timing never includes daemon activation and never touches the operator's +# live store (#1696). set -euo pipefail -BINARY="${1:?Usage: $0 }" -PROJECT="${2:?Usage: $0 }" +BINARY="${1:?Usage: $0 }" +REPO="${2:?Usage: $0 }" +REPO=$(cd "$REPO" && pwd -P) + +# shellcheck source=test-runtime.sh +source "$(dirname "${BASH_SOURCE[0]}")/test-runtime.sh" +cbm_test_runtime_init +BENCH_TMP="" +trap 'cbm_test_runtime_cleanup "$BINARY"; [ -z "$BENCH_TMP" ] || rm -rf -- "$BENCH_TMP"' EXIT +BENCH_TMP=$(mktemp -d) +INDEX_ERR="$BENCH_TMP/index-stderr.log" +DAEMON_START_LOG="$BENCH_TMP/daemon-start.log" + +# Its output is kept for the failure path: the product names why a daemon did +# not start, and that is the part an operator needs. +if ! "$BINARY" daemon start >"$DAEMON_START_LOG" 2>&1; then + echo "private daemon did not start" >&2 + if [ -s "$DAEMON_START_LOG" ]; then + echo "--- daemon start output ---" >&2 + cat "$DAEMON_START_LOG" >&2 + fi + exit 1 +fi +# One pre-escaped spelling of the path, the way the soak harness builds its +# own: a repository path may legitimately contain a quote or a backslash, and +# hand-built JSON turns that into a parse error. +REPO_JSON=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$REPO") +# Index and parse keep their stderr instead of discarding it: without it the +# failure below names only its symptom, and the cause — an unreadable +# repository, a refused daemon, a malformed envelope — is unrecoverable. +INDEX_JSON=$("$BINARY" cli index_repository "{\"repo_path\":$REPO_JSON,\"mode\":\"full\"}" \ + 2>"$INDEX_ERR" || echo '{}') +PROJECT=$(echo "$INDEX_JSON" | python3 -c " +import json, sys +d = json.load(sys.stdin) +if 'content' in d: + d = json.loads(d['content'][0]['text']) +print(d.get('project', '')) +" 2>>"$INDEX_ERR" || echo "") +if [ -z "$PROJECT" ]; then + echo "index of $REPO did not report a project" >&2 + if [ -s "$INDEX_ERR" ]; then + echo "--- index/parse stderr ---" >&2 + cat "$INDEX_ERR" >&2 + fi + if [ -n "$INDEX_JSON" ]; then + echo "--- index response (first 500 bytes) ---" >&2 + printf '%.500s\n' "$INDEX_JSON" >&2 + fi + exit 1 +fi echo "Binary: $BINARY" -echo "Project: $PROJECT" +echo "Project: $PROJECT (indexed from $REPO)" echo "" run_case() { diff --git a/scripts/check-lsp-originality.sh b/scripts/check-lsp-originality.sh index 36633076d..10fa33119 100755 --- a/scripts/check-lsp-originality.sh +++ b/scripts/check-lsp-originality.sh @@ -27,7 +27,7 @@ # USAGE # bash scripts/check-lsp-originality.sh [--lang NAME] [--refresh] # [--list-candidates] [--help] -# --lang NAME scan against ONE reference only (py|ts|go|cs|c|java|kotlin|rust|php|perl) +# --lang NAME scan against ONE reference only (py|ts|go|cs|c|java|kotlin|rust|php|perl|ruby) # --refresh re-fetch reference sources even if cached # --list-candidates print the extracted local tokens and exit (no fetch; self-test) # Exit 0 = no verbatim overlap found. Exit 1 = overlap(s) to review by a human. @@ -64,6 +64,10 @@ REFS=( # Perl (PR #461) was authored clean-room. We scan against PerlNavigator (MIT) # — the leading OSS Perl language server — for defensive copy-detection. "perl|https://github.com/bscan/PerlNavigator|server/src" + # Ruby was authored clean-room from the tree-sitter-ruby grammar + Ruby + # language semantics. We scan against Shopify's ruby-lsp (MIT) — the + # leading OSS Ruby language server — for defensive copy-detection. + "ruby|https://github.com/Shopify/ruby-lsp|lib" # Infrastructure hybrid LSPs (K8s YAML, Kustomize bundles, Flux/Argo deploy # scopes, the Go operator extension) were written from the Kubernetes, Flux, # Argo, Cluster API, cert-manager and Kubermatic API types. Scanned against diff --git a/scripts/test.sh b/scripts/test.sh index 65edf1e03..09ab81088 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -316,6 +316,9 @@ bash "$ROOT/tests/test_parallel_harness_contract.sh" echo "=== Step 0i2: suite failure-report contract ===" bash "$ROOT/tests/test_failure_report_contract.sh" +echo "=== Step 0i3: benchmark harness runtime isolation contract (#1696) ===" +bash "$ROOT/tests/test_benchmark_runtime_isolation_contract.sh" + echo "=== Step 0j: venue parity contract (one harness, every venue) ===" bash "$ROOT/tests/test_venue_parity_contract.sh" diff --git a/src/cli/cli.c b/src/cli/cli.c index f6bebec1c..22f6ae7a1 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -8088,6 +8088,7 @@ static const config_key_def_t CONFIG_KEYS[] = { {CBM_CONFIG_AUTO_INDEX, "false", "Enable auto-indexing on MCP session start"}, {CBM_CONFIG_AUTO_INDEX_LIMIT, "50000", "Max files for auto-indexing new projects"}, {CBM_CONFIG_AUTO_WATCH, "true", "Register background git watcher on session connect"}, + {CBM_CONFIG_IGNORE_WORKTREES, "false", "Skip linked git worktrees when indexing automatically"}, /* Unset: the index mode decides (cbm_pipeline_doc_candidates_enabled). */ {CBM_CONFIG_DOC_CANDIDATES, "auto", "Doc-code candidates: auto = on for full, off for moderate/fast; true/false forces"}, diff --git a/src/cli/cli.h b/src/cli/cli.h index 52ace9003..7d0c4e8da 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -336,7 +336,11 @@ bool cbm_hook_augment_invocation_supported_for_testing(const char *dialect, const char *forced_event); bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate, bool case_insensitive); -const char *cbm_hook_no_project_index_guidance_for_testing(const char *event); +const char *cbm_hook_no_project_index_guidance_for_testing(const char *event, + bool worktree_ignored); +/* Counts lifecycle calls that reached the ignore_worktrees resolution (cwd + * canonicalization and the linked-worktree probe). NULL stops counting. */ +void cbm_hook_set_worktree_gate_counter_for_testing(int *counter); bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz); /* include_posttool false renders the previous lifecycle-only manifest. Release * the result with cbm_free(CBM_MEM_CLASS_OTHER, ...). */ @@ -526,6 +530,7 @@ bool cbm_config_load_index_policy(cbm_config_t *cfg, cbm_index_resource_policy_t #define CBM_CONFIG_AUTO_INDEX "auto_index" #define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit" #define CBM_CONFIG_AUTO_WATCH "auto_watch" +#define CBM_CONFIG_IGNORE_WORKTREES "ignore_worktrees" #define CBM_CONFIG_DOC_CANDIDATES "doc_candidates" #define CBM_CONFIG_UI_LANG "ui-lang" #define CBM_CONFIG_WATCHER_ENABLED "watcher_enabled" diff --git a/src/cli/config_yaml_edit.c b/src/cli/config_yaml_edit.c index a6f8ced10..09a079212 100644 --- a/src/cli/config_yaml_edit.c +++ b/src/cli/config_yaml_edit.c @@ -2589,6 +2589,10 @@ typedef struct { bool identity_found; size_t identity_start; size_t identity_end; + /* The missing rest of the path begins under a `parent: {}` header. */ + bool parent_inline_empty; + size_t parent_line; + size_t parent_colon; } yaml_mapping_sequence_target_t; static void yaml_sequence_key_vec_free(yaml_sequence_key_vec_t *keys) { @@ -2962,6 +2966,37 @@ static int yaml_sequence_parse_block(const yaml_doc_t *doc, size_t begin_line, s return 0; } +/* `section: {}` is how the mapping-entry editor writes a section it emptied + * (yaml_build_last_mapping_entry_removal), and its upsert accepts it back. On + * a PARENT of the sequence path it is that same empty mapping, so the rest of + * the path is missing beneath it: removal is a no-op and upsert expands the + * header in place. The Hermes remover leaves `hooks: {}` whenever our hook was + * the only entry. The sequence key itself holding `{}`, any other flow value, + * and content nested under the inline mapping stay refused. */ +static int yaml_sequence_target_empty_parent(const yaml_doc_t *doc, size_t key_line, size_t colon, + size_t child_end, size_t next_depth, + size_t sequence_path_len, + yaml_mapping_sequence_target_t *target) { + bool inline_empty = false; + if (next_depth >= sequence_path_len || + yaml_tail_is_explicit_empty_mapping(doc->data, colon, doc->lines[key_line].text_end, + &inline_empty) != 0 || + !inline_empty) { + return YAML_ERROR; + } + for (size_t i = key_line + YAML_UNIT; i < child_end; i++) { + if (!doc->lines[i].blank && !doc->lines[i].comment) { + return YAML_ERROR; + } + } + target->missing_index = next_depth; + target->insert_offset = yaml_sequence_line_offset(doc, child_end); + target->parent_inline_empty = true; + target->parent_line = key_line; + target->parent_colon = colon; + return 0; +} + static int yaml_sequence_analyze(const yaml_doc_t *doc, const char *const *sequence_path, const size_t *path_lengths, size_t sequence_path_len, const char *identity_key, const char *identity_value, @@ -2993,10 +3028,11 @@ static int yaml_sequence_analyze(const yaml_doc_t *doc, const char *const *seque return 0; } const yaml_line_t *header = &doc->lines[key_line]; - if (!yaml_tail_is_empty(doc->data, colon, header->text_end)) { - return YAML_ERROR; - } size_t child_end = yaml_sequence_nested_end(doc, key_line, parent_end); + if (!yaml_tail_is_empty(doc->data, colon, header->text_end)) { + return yaml_sequence_target_empty_parent(doc, key_line, colon, child_end, + depth + YAML_UNIT, sequence_path_len, target); + } if (depth + YAML_UNIT == sequence_path_len) { target->sequence_found = true; target->missing_index = sequence_path_len; @@ -3095,13 +3131,52 @@ static int yaml_sequence_validate_canonical(const char *canonical_item, size_t c return result; } +/* Copies the document up to insert_offset with the `parent: {}` header turned + * into a block header `parent:`, exactly as + * yaml_build_inline_empty_mapping_section_add does for a mapping section: a + * trailing comment and the line ending are kept. */ +static int yaml_sequence_append_expanded_parent(yaml_buf_t *out, const yaml_doc_t *doc, + const yaml_mapping_sequence_target_t *target) { + if (target->parent_line >= doc->line_count) { + return YAML_ERROR; + } + const yaml_line_t *header = &doc->lines[target->parent_line]; + size_t comment = 0U; + if (target->insert_offset < header->end || + yaml_find_comment(doc->data, target->parent_colon + YAML_UNIT, header->text_end, + &comment) != 0 || + yaml_buf_append(out, doc->data, target->parent_colon + YAML_UNIT) != 0) { + return YAML_ERROR; + } + size_t kept_tail = header->text_end; + if (comment < header->text_end) { + if (yaml_buf_append_char(out, ' ') != 0) { + return YAML_ERROR; + } + kept_tail = comment; + } + if (yaml_buf_append(out, doc->data + kept_tail, header->end - kept_tail) != 0 || + yaml_buf_append(out, doc->data + header->end, target->insert_offset - header->end) != 0 || + (out->len > 0U && out->data[out->len - YAML_UNIT] != '\n' && + yaml_buf_append(out, doc->eol, doc->eol_len) != 0)) { + return YAML_ERROR; + } + return 0; +} + static int yaml_sequence_build_insert(yaml_buf_t *out, const yaml_doc_t *doc, const yaml_mapping_sequence_target_t *target, const char *const *sequence_path, const size_t *path_lengths, size_t sequence_path_len, const yaml_buf_t *rendered_item) { - if (target->insert_offset > doc->len || - yaml_buf_append(out, doc->data, target->insert_offset) != 0 || - yaml_append_separator_if_needed(out, doc, target->insert_offset) != 0) { + if (target->insert_offset > doc->len) { + return YAML_ERROR; + } + if (target->parent_inline_empty) { + if (yaml_sequence_append_expanded_parent(out, doc, target) != 0) { + return YAML_ERROR; + } + } else if (yaml_buf_append(out, doc->data, target->insert_offset) != 0 || + yaml_append_separator_if_needed(out, doc, target->insert_offset) != 0) { return YAML_ERROR; } if (!target->sequence_found) { diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index ddee6aaa0..58e75c6d2 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -17,11 +17,14 @@ */ #include "cli/cli.h" +#include "foundation/compat.h" #include "foundation/compat_fs.h" #include "foundation/constants.h" #include "foundation/mem.h" #include "foundation/platform.h" +#include "git/git_context.h" #include "mcp/mcp.h" +#include "mcp/mcp_internal.h" #include "pipeline/pipeline.h" #include "yyjson/yyjson.h" @@ -1654,7 +1657,14 @@ static const char *ha_active_tier(yyjson_val *root, const char *event) { return "Tier 2 verification"; } -static const char *ha_no_project_index_guidance(const char *event) { +static const char *ha_no_project_index_guidance(const char *event, bool worktree_ignored) { + /* ignore_worktrees is on and this cwd is a linked worktree: indexing it is + * deliberately disabled, so telling the agent to run index_repository would + * only produce a refusal. Point at the setting instead. */ + if (worktree_ignored) { + return "This is a linked git worktree and ignore_worktrees is enabled, so it is not " + "indexed on purpose; do not run index_repository here."; + } return event && strcmp(event, "SubagentStart") == 0 ? "Ask the parent agent to run index_repository before structural exploration; " "do not attempt graph mutation." @@ -1671,6 +1681,41 @@ static bool ha_invocation_supported(ha_lifecycle_dialect_t dialect, const char * return !forced_event || ha_dialect_event_supported(dialect, forced_event); } +#ifdef CBM_CLI_ENABLE_TEST_API +static CBM_TLS int *g_ha_worktree_gate_counter = NULL; +#endif + +/* Project resolution once ignore_worktrees is on; the caller runs this only + * after reading the key, so the default path does none of it. A symlink alias + * has no .git of its own, and walking its lexical parents leaves the linked + * worktree. index_repository canonicalizes before the same gate, so the hook + * must too or it tells the agent to index a path the setting will refuse. The + * setting refuses a linked worktree even when it sits inside an indexed + * checkout or inside another linked worktree, so a match is kept only when its + * root belongs to this cwd's own linked worktree, not a parent graph. */ +static char *ha_resolve_project_ignoring_worktrees(cbm_mcp_server_t *srv, const char *cwd, + bool *drop_ancestor, bool *worktree_ignored) { +#ifdef CBM_CLI_ENABLE_TEST_API + if (g_ha_worktree_gate_counter) { + (*g_ha_worktree_gate_counter)++; + } +#endif + char canonical_cwd[4096]; + if (ha_canonical_path(cwd, canonical_cwd, sizeof(canonical_cwd))) { + cwd = canonical_cwd; + } + char project_root[4096]; + project_root[0] = '\0'; + char *project = + ha_resolve_indexed_project_with_root(srv, cwd, project_root, sizeof(project_root)); + bool linked = cbm_git_is_linked_worktree(cwd); + /* Drop a parent graph without a second free(): this file's raw free count + * is ratcheted, and the caller's single free(project) still releases it. */ + *drop_ancestor = linked && project && !cbm_git_same_linked_worktree(cwd, project_root); + *worktree_ignored = linked && (!project || *drop_ancestor); + return project; +} + static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root, const char *forced_event, ha_lifecycle_dialect_t dialect) { if (!root || !yyjson_is_obj(root)) { @@ -1688,7 +1733,20 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root srv = owned_server; } const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer)); - char *project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL; + /* Read ignore_worktrees first and do nothing else for it while it is off. + * cbm_mcp_server_new(NULL) has no store, and srv may be the daemon's + * session server, so the read leaves srv untouched (see + * cbm_mcp_ignore_worktrees_runtime_enabled). Off, this is the resolution + * the hook did before the key existed. */ + bool drop_ancestor = false; + bool worktree_ignored = false; + char *project = NULL; + if (srv && cwd && cbm_mcp_ignore_worktrees_runtime_enabled(srv)) { + project = + ha_resolve_project_ignoring_worktrees(srv, cwd, &drop_ancestor, &worktree_ignored); + } else { + project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL; + } cbm_mcp_server_free(owned_server); char context[2048]; @@ -1713,7 +1771,7 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root ? " The same tools are also available from the shell: " "`codebase-memory-mcp cli --help` lists each tool's flags." : ""; - if (project) { + if (project && !drop_ancestor) { char safe_project[HA_METADATA_CAP]; ha_sanitize_metadata(project, safe_project, sizeof(safe_project)); /* The untrusted label covers the graph-derived project name only. Wrapping @@ -1732,7 +1790,7 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root "and file reads for literals, configs, non-code files, and verification.%s", scope, safe_project, tier, cli_hint); } else { - const char *index_guidance = ha_no_project_index_guidance(event); + const char *index_guidance = ha_no_project_index_guidance(event, worktree_ignored); snprintf(context, sizeof(context), "[codebase-memory] %s context: no indexed graph project matched this working " "directory. %s Once indexed, " @@ -1851,8 +1909,13 @@ bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate, return ha_path_contains_mode(root, candidate, case_insensitive); } -const char *cbm_hook_no_project_index_guidance_for_testing(const char *event) { - return ha_no_project_index_guidance(event); +const char *cbm_hook_no_project_index_guidance_for_testing(const char *event, + bool worktree_ignored) { + return ha_no_project_index_guidance(event, worktree_ignored); +} + +void cbm_hook_set_worktree_gate_counter_for_testing(int *counter) { + g_ha_worktree_gate_counter = counter; } bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz) { diff --git a/src/daemon/application.c b/src/daemon/application.c index 5266a03b5..4c15a3e6f 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -17,6 +17,7 @@ #include "foundation/sha256.h" #include "foundation/subprocess.h" #include "foundation/workspace.h" +#include "git/git_context.h" #include "mcp/index_supervisor.h" #include "mcp/mcp.h" #include "mcp/mcp_internal.h" @@ -1980,6 +1981,18 @@ static void application_auto_index_retry_pending_locked(cbm_daemon_application_t application_refresh_watch_locked(session); continue; } + /* ignore_worktrees can be turned on after this session was queued for + * a capacity slot. Initial admission already skips linked worktrees; + * retry must apply the same gate or the pending checkout is indexed + * once a slot opens. */ + if (cbm_mcp_ignore_worktrees_enabled(session->mcp) && + cbm_git_is_linked_worktree(root_path)) { + session->auto_index_retry_pending = false; + cbm_log_info("daemon.autoindex.skipped", "project", project, "reason", + "linked_worktree"); + application_refresh_watch_locked(session); + continue; + } char *args = application_auto_index_args(application, root_path); if (!args) { continue; @@ -2638,6 +2651,11 @@ static void application_background_initialize_impl(cbm_daemon_application_sessio !application_session_workspace_allowed(session, "auto_index_discovery")) { auto_index_candidate = false; } + if (auto_index_candidate && cbm_mcp_ignore_worktrees_enabled(session->mcp) && + cbm_git_is_linked_worktree(root_path)) { + cbm_log_info("daemon.autoindex.skipped", "project", project, "reason", "linked_worktree"); + auto_index_candidate = false; + } bool within_auto_index_limit = !auto_index_candidate || cbm_mcp_auto_index_within_file_limit(root_path, auto_index_limit, &tracked_files); @@ -4047,7 +4065,7 @@ cbm_daemon_runtime_application_callbacks_t cbm_daemon_application_runtime_callba static cbm_daemon_runtime_application_status_t application_client_exchange_tagged( cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token, uint8_t *request, uint32_t request_length, uint8_t **response_out, - uint32_t *response_length_out, uint32_t timeout_ms) { + uint32_t *response_length_out, bool *request_sent_out, uint32_t timeout_ms) { uint8_t *response = NULL; uint32_t response_length = 0; if (response_out) { @@ -4056,13 +4074,16 @@ static cbm_daemon_runtime_application_status_t application_client_exchange_tagge if (response_length_out) { *response_length_out = 0; } + if (request_sent_out) { + *request_sent_out = false; + } cbm_daemon_runtime_application_status_t status = request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID ? cbm_daemon_runtime_client_application_request(client, request, request_length, &response, &response_length, timeout_ms) - : cbm_daemon_runtime_client_application_request_tagged(client, request_token, request, - request_length, &response, - &response_length, timeout_ms); + : cbm_daemon_runtime_client_application_request_tagged( + client, request_token, request, request_length, &response, &response_length, + request_sent_out, timeout_ms); free(request); if (status != CBM_DAEMON_RUNTIME_APPLICATION_OK) { free(response); @@ -4093,7 +4114,7 @@ static cbm_daemon_runtime_application_status_t application_client_exchange( uint8_t **response_out, uint32_t *response_length_out, uint32_t timeout_ms) { return application_client_exchange_tagged(client, CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID, request, request_length, response_out, - response_length_out, timeout_ms); + response_length_out, NULL, timeout_ms); } cbm_daemon_runtime_application_status_t cbm_daemon_application_client_set_context( @@ -4221,7 +4242,7 @@ cbm_daemon_runtime_application_status_t cbm_daemon_application_client_ui_readine static cbm_daemon_runtime_application_status_t application_client_text_request_tagged( cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token, cbm_daemon_application_request_kind_t kind, const char *text, uint8_t **response_out, - uint32_t *response_length_out, uint32_t timeout_ms) { + uint32_t *response_length_out, bool *request_sent_out, uint32_t timeout_ms) { if (!client || !text || !text[0]) { return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; } @@ -4237,7 +4258,7 @@ static cbm_daemon_runtime_application_status_t application_client_text_request_t memcpy(request + 1, text, text_length); return application_client_exchange_tagged(client, request_token, request, (uint32_t)text_length + 1U, response_out, - response_length_out, timeout_ms); + response_length_out, request_sent_out, timeout_ms); } static cbm_daemon_runtime_application_status_t application_client_text_request( @@ -4245,7 +4266,7 @@ static cbm_daemon_runtime_application_status_t application_client_text_request( const char *text, uint8_t **response_out, uint32_t *response_length_out, uint32_t timeout_ms) { return application_client_text_request_tagged( client, CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID, kind, text, response_out, - response_length_out, timeout_ms); + response_length_out, NULL, timeout_ms); } cbm_daemon_runtime_application_status_t cbm_daemon_application_client_mcp( @@ -4258,10 +4279,10 @@ cbm_daemon_runtime_application_status_t cbm_daemon_application_client_mcp( cbm_daemon_runtime_application_status_t cbm_daemon_application_client_mcp_tagged( cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token, const char *message, uint8_t **response_out, uint32_t *response_length_out, - uint32_t timeout_ms) { - return application_client_text_request_tagged(client, request_token, - CBM_DAEMON_APPLICATION_REQUEST_MCP, message, - response_out, response_length_out, timeout_ms); + bool *request_sent_out, uint32_t timeout_ms) { + return application_client_text_request_tagged( + client, request_token, CBM_DAEMON_APPLICATION_REQUEST_MCP, message, response_out, + response_length_out, request_sent_out, timeout_ms); } cbm_daemon_runtime_application_status_t cbm_daemon_application_client_tool( diff --git a/src/daemon/application.h b/src/daemon/application.h index 426bef3db..900f4f84e 100644 --- a/src/daemon/application.h +++ b/src/daemon/application.h @@ -149,11 +149,12 @@ cbm_daemon_runtime_application_status_t cbm_daemon_application_client_mcp( cbm_daemon_runtime_client_t *client, const char *message, uint8_t **response_out, uint32_t *response_length_out, uint32_t timeout_ms); -/* Cancellable frontend variant using a token reserved on the runtime client. */ +/* Cancellable frontend variant using a token reserved on the runtime client. + * request_sent_out preserves the runtime's application-frame delivery fact. */ cbm_daemon_runtime_application_status_t cbm_daemon_application_client_mcp_tagged( cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token, const char *message, uint8_t **response_out, uint32_t *response_length_out, - uint32_t timeout_ms); + bool *request_sent_out, uint32_t timeout_ms); cbm_daemon_runtime_application_status_t cbm_daemon_application_client_tool( cbm_daemon_runtime_client_t *client, const char *tool_name, const char *args_json, diff --git a/src/daemon/frontend.c b/src/daemon/frontend.c index cbe4b282e..e76c920b6 100644 --- a/src/daemon/frontend.c +++ b/src/daemon/frontend.c @@ -76,6 +76,7 @@ typedef struct { cbm_mutex_t mutex; cbm_daemon_runtime_client_t *client; cbm_version_cohort_manager_t *cohort_manager; + const cbm_daemon_frontend_session_config_t *session; FILE *out; frontend_item_t queue[FRONTEND_QUEUE_CAPACITY]; size_t head; @@ -89,6 +90,7 @@ typedef struct { int64_t active_id; const char *active_id_str; cbm_daemon_runtime_application_token_t active_request_token; + bool active_cancelled; bool failed; /* Monotonic count of fully processed queue items (responses written or * cancellations acknowledged). The EOF drain below watches it to tell a @@ -117,6 +119,8 @@ static atomic_bool frontend_test_monitor_is_waiting = ATOMIC_VAR_INIT(false); static atomic_uint_fast64_t frontend_test_monitor_observation_count = ATOMIC_VAR_INIT(0); static atomic_uint_fast64_t frontend_test_worker_observation_count = ATOMIC_VAR_INIT(0); static atomic_uint_fast64_t frontend_test_worker_idle_count = ATOMIC_VAR_INIT(0); +static atomic_uint_fast64_t frontend_test_routed_cancellation_count = ATOMIC_VAR_INIT(0); +static atomic_uint_fast32_t frontend_test_replay_budget_ms = ATOMIC_VAR_INIT(0); void cbm_daemon_frontend_test_observer_reset(bool hold_monitor) { atomic_store_explicit(&frontend_test_monitor_observation_count, 0, memory_order_release); @@ -145,6 +149,14 @@ uint64_t cbm_daemon_frontend_test_worker_observations(void) { uint64_t cbm_daemon_frontend_test_worker_idle_cycles(void) { return atomic_load_explicit(&frontend_test_worker_idle_count, memory_order_acquire); } + +uint64_t cbm_daemon_frontend_test_routed_cancellations(void) { + return atomic_load_explicit(&frontend_test_routed_cancellation_count, memory_order_acquire); +} + +uint32_t cbm_daemon_frontend_test_replay_timeout_ms(void) { + return (uint32_t)atomic_load_explicit(&frontend_test_replay_budget_ms, memory_order_acquire); +} #endif static cbm_version_cohort_maintenance_presence_t frontend_observe_maintenance( @@ -352,6 +364,7 @@ static bool frontend_pop_begin(frontend_state_t *state, frontend_item_t *item) { state->active_id_str = item->id_str; item->request_token = request_token; state->active_request_token = request_token; + state->active_cancelled = false; popped = true; } cbm_mutex_unlock(&state->mutex); @@ -365,6 +378,7 @@ static void frontend_end_request(frontend_state_t *state, bool failed) { state->active_id = 0; state->active_id_str = NULL; state->active_request_token = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + state->active_cancelled = false; state->failed = state->failed || failed; cbm_mutex_unlock(&state->mutex); } @@ -464,12 +478,8 @@ typedef enum { * request routes its exact runtime token without closing the authenticated * session. A queued request is marked and receives a cancellation error * without ever reaching the daemon. Stale/invalid targets are ignored. */ -static frontend_cancellation_route_t frontend_route_cancellation( - frontend_state_t *state, const char *message, - cbm_daemon_runtime_application_token_t *request_token_out) { - if (request_token_out) { - *request_token_out = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; - } +static frontend_cancellation_route_t frontend_route_cancellation(frontend_state_t *state, + const char *message) { cbm_jsonrpc_request_t request = {0}; if (!frontend_parse_cancellation(message, &request)) { return FRONTEND_CANCELLATION_NONE; @@ -478,12 +488,9 @@ static frontend_cancellation_route_t frontend_route_cancellation( frontend_cancellation_route_t route = FRONTEND_CANCELLATION_STALE; cbm_mutex_lock(&state->mutex); if (state->in_request && state->active_has_id && - state->active_request_token != CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID && cbm_mcp_cancel_request_matches(request.params_raw, state->active_id, state->active_id_str)) { - if (request_token_out) { - *request_token_out = state->active_request_token; - } + state->active_cancelled = true; route = FRONTEND_CANCELLATION_ACTIVE; } else { for (size_t offset = 0; offset < state->count; offset++) { @@ -499,9 +506,116 @@ static frontend_cancellation_route_t frontend_route_cancellation( } cbm_mutex_unlock(&state->mutex); cbm_jsonrpc_request_free(&request); +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + if (route == FRONTEND_CANCELLATION_ACTIVE || route == FRONTEND_CANCELLATION_QUEUED) { + atomic_fetch_add_explicit(&frontend_test_routed_cancellation_count, 1, + memory_order_release); + } +#endif return route; } +static cbm_daemon_runtime_cancel_result_t frontend_cancel_active(frontend_state_t *state) { + cbm_mutex_lock(&state->mutex); + cbm_daemon_runtime_cancel_result_t result = CBM_DAEMON_RUNTIME_CANCEL_STALE; + if (state->in_request && state->active_cancelled && state->client && + state->active_request_token != CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID) { + result = cbm_daemon_runtime_client_application_cancel(state->client, + state->active_request_token); + } else if (state->in_request && state->active_cancelled) { + /* A reconnect has no daemon request to cancel yet. The worker observes + * active_cancelled before retrying the frame. */ + result = CBM_DAEMON_RUNTIME_CANCEL_ACCEPTED; + } + cbm_mutex_unlock(&state->mutex); + return result; +} + +static bool frontend_configure_client(cbm_daemon_runtime_client_t *client, + const cbm_daemon_frontend_session_config_t *session) { + /* The replay restores what startup set, so it waits within the startup + * budget like the first set_context and set_ui_config in main.c. The + * connect budget only bounds a probe (1 s in production): a replacement + * daemon whose UI save runs longer would end an otherwise good recovery. */ + uint32_t timeout_ms = session->bootstrap.startup_timeout_ms; +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + atomic_store_explicit(&frontend_test_replay_budget_ms, timeout_ms, memory_order_release); +#endif + if (cbm_daemon_application_client_set_context( + client, session->session_root, session->allowed_root, session->tool_profile, NULL, NULL, + timeout_ms) != CBM_DAEMON_RUNTIME_APPLICATION_OK) { + return false; + } + return session->ui_update_mask == 0 || + cbm_daemon_application_client_set_ui_config( + client, session->ui_update_mask, session->ui_enabled, session->ui_port, + timeout_ms) == CBM_DAEMON_RUNTIME_APPLICATION_OK; +} + +static bool frontend_recover_client(frontend_state_t *state, frontend_item_t *item, + bool *cancelled_out) { + if (cancelled_out) { + *cancelled_out = false; + } + + cbm_mutex_lock(&state->mutex); + if (state->stopping || !state->client) { + cbm_mutex_unlock(&state->mutex); + return false; + } + cbm_daemon_runtime_client_t *previous = state->client; + state->client = NULL; + state->active_request_token = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + cbm_mutex_unlock(&state->mutex); + if (previous) { + (void)cbm_daemon_runtime_client_close(previous, FRONTEND_CLOSE_TIMEOUT_MS); + } + + cbm_daemon_bootstrap_result_t bootstrap = {0}; + /* Bootstrap can report launch failures on stderr from this worker. + * Keep that diagnostic + * to explain failed recovery. It never writes MCP + * stdout, but blocked stderr can delay + * exit. Do not add more logging here. */ + cbm_daemon_bootstrap_status_t bootstrap_status = + cbm_daemon_bootstrap_execute(&state->session->bootstrap, &bootstrap); + cbm_daemon_runtime_client_t *replacement = + bootstrap_status == CBM_DAEMON_BOOTSTRAP_CONNECTED ? bootstrap.client : NULL; + bool configured = replacement && frontend_configure_client(replacement, state->session); + cbm_daemon_runtime_application_token_t replacement_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + + cbm_mutex_lock(&state->mutex); + bool cancelled = state->active_cancelled; + bool usable = configured && !state->stopping && !state->failed; + if (usable && !cancelled) { + usable = + cbm_daemon_runtime_client_application_token_reserve(replacement, &replacement_token); + } + if (usable) { + state->client = replacement; + if (!cancelled) { + state->active_request_token = replacement_token; + item->request_token = replacement_token; + } + if (cancelled_out) { + *cancelled_out = cancelled; + } + } + cbm_mutex_unlock(&state->mutex); + + if (!usable && replacement) { + (void)cbm_daemon_runtime_client_close(replacement, FRONTEND_CLOSE_TIMEOUT_MS); + } + return usable; +} + +static void frontend_response_reset(uint8_t **response, uint32_t *response_length) { + free(*response); + *response = NULL; + *response_length = 0; +} + static void *frontend_worker(void *opaque) { frontend_state_t *state = opaque; for (;;) { @@ -522,10 +636,25 @@ static void *frontend_worker(void *opaque) { if (item.cancelled) { failed = !frontend_write_cancelled_response(state->out, &item); } else { + bool request_sent = false; cbm_daemon_runtime_application_status_t status = - cbm_daemon_application_client_mcp_tagged(state->client, item.request_token, - item.message, &response, &response_length, - FRONTEND_REQUEST_TIMEOUT_MS); + cbm_daemon_application_client_mcp_tagged( + state->client, item.request_token, item.message, &response, &response_length, + &request_sent, FRONTEND_REQUEST_TIMEOUT_MS); + if (status == CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR && !request_sent) { + frontend_response_reset(&response, &response_length); + bool cancelled = false; + if (frontend_recover_client(state, &item, &cancelled)) { + if (cancelled) { + status = CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED; + } else { + bool retry_sent = false; + status = cbm_daemon_application_client_mcp_tagged( + state->client, item.request_token, item.message, &response, + &response_length, &retry_sent, FRONTEND_REQUEST_TIMEOUT_MS); + } + } + } if (status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED) { failed = !frontend_write_cancelled_response(state->out, &item); } else { @@ -537,19 +666,18 @@ static void *frontend_worker(void *opaque) { item.content_length_framed); } } - free(response); + frontend_response_reset(&response, &response_length); bool expected_stop = failed && frontend_should_stop(state); frontend_end_request(state, failed && !expected_stop); frontend_item_free(&item); atomic_fetch_add_explicit(&state->completed_items, 1, memory_order_release); if (failed) { if (!expected_stop) { - /* A failed daemon transport cannot wake a thread blocked in - * stdio portably. This frontend owns no state: immediate - * process exit closes the kernel IPC handle, which cancels - * daemon session ownership without a detached reader or an - * unsafe cross-thread fclose. Logging or flushing here could - * itself block on agent-owned stdout/stderr. */ + /* Recovery was unsafe or the one safe retry failed. Immediate + * process exit wakes an agent blocked on stdio and closes the + * kernel IPC handle without a detached reader or cross-thread + * fclose. Logging or flushing here could itself block on + * agent-owned stdout/stderr. */ _Exit(EXIT_FAILURE); } break; @@ -632,11 +760,11 @@ static bool frontend_enqueue(frontend_state_t *state, char *message, bool conten static bool frontend_stop_begin(frontend_state_t *state) { cbm_mutex_lock(&state->mutex); state->stopping = true; + cbm_daemon_runtime_client_t *client = state->client; + bool stop_begun = !client || cbm_daemon_runtime_client_close_begin(client); cbm_mutex_unlock(&state->mutex); - /* Retain the client allocation until the worker is joined. This covers the - * boundary where the worker has claimed an item but has not yet entered the - * runtime exchange: a late call observes closing instead of freed memory. */ - return cbm_daemon_runtime_client_close_begin(state->client); + /* NULL means recovery owns cleanup. Otherwise retain the client until join. */ + return stop_begun; } static bool frontend_cancel_for_maintenance(void *opaque) { @@ -646,24 +774,33 @@ static bool frontend_cancel_for_maintenance(void *opaque) { cbm_mutex_lock(&state->mutex); state->stopping = true; if (state->in_request) { + state->active_cancelled = true; request_token = state->active_request_token; } - cbm_mutex_unlock(&state->mutex); if (request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID) { + cbm_mutex_unlock(&state->mutex); return false; } - return cbm_daemon_runtime_client_application_cancel(state->client, request_token) == - CBM_DAEMON_RUNTIME_CANCEL_ACCEPTED; + bool cancelled = state->client && + cbm_daemon_runtime_client_application_cancel(state->client, request_token) == + CBM_DAEMON_RUNTIME_CANCEL_ACCEPTED; + cbm_mutex_unlock(&state->mutex); + return cancelled; } int cbm_daemon_frontend_mcp_run(cbm_daemon_runtime_client_t *client, - cbm_version_cohort_manager_t *cohort_manager, FILE *in, FILE *out) { - if (!client || !cohort_manager || !in || !out) { + cbm_version_cohort_manager_t *cohort_manager, + const cbm_daemon_frontend_session_config_t *session, FILE *in, + FILE *out) { + if (!client || !cohort_manager || !session || !session->bootstrap.endpoint || + !session->bootstrap.identity || !session->session_root || !session->session_root[0] || + !in || !out) { return -1; } frontend_state_t state = { .client = client, .cohort_manager = cohort_manager, + .session = session, .out = out, }; cbm_mutex_init(&state.mutex); @@ -687,7 +824,7 @@ int cbm_daemon_frontend_mcp_run(cbm_daemon_runtime_client_t *client, } int result = 0; - bool close_begun = false; + bool stop_begun = false; bool clean_eof = false; for (;;) { char *message = NULL; @@ -699,18 +836,14 @@ int cbm_daemon_frontend_mcp_run(cbm_daemon_runtime_client_t *client, free(message); break; } - cbm_daemon_runtime_application_token_t cancel_token = - CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; - frontend_cancellation_route_t cancellation = - frontend_route_cancellation(&state, message, &cancel_token); + frontend_cancellation_route_t cancellation = frontend_route_cancellation(&state, message); if (cancellation != FRONTEND_CANCELLATION_NONE) { free(message); if (cancellation == FRONTEND_CANCELLATION_ACTIVE) { - cbm_daemon_runtime_cancel_result_t cancelled = - cbm_daemon_runtime_client_application_cancel(state.client, cancel_token); + cbm_daemon_runtime_cancel_result_t cancelled = frontend_cancel_active(&state); if (cancelled == CBM_DAEMON_RUNTIME_CANCEL_ERROR) { result = -1; - close_begun = frontend_stop_begin(&state); + stop_begun = frontend_stop_begin(&state); break; } } @@ -725,7 +858,7 @@ int cbm_daemon_frontend_mcp_run(cbm_daemon_runtime_client_t *client, } } - if (clean_eof && !close_begun) { + if (clean_eof && !stop_begun) { frontend_input_closed(&state); /* EOF ends INPUT, not accepted work: as long as queued items keep * completing, every already-enqueued request still receives its @@ -751,8 +884,8 @@ int cbm_daemon_frontend_mcp_run(cbm_daemon_runtime_client_t *client, } } } - if (!close_begun) { - close_begun = frontend_stop_begin(&state); + if (!stop_begun) { + stop_begun = frontend_stop_begin(&state); } frontend_join_watchdog_t watchdog; cbm_thread_t watchdog_thread; @@ -777,9 +910,9 @@ int cbm_daemon_frontend_mcp_run(cbm_daemon_runtime_client_t *client, if (!cbm_daemon_maintenance_monitor_stop(&maintenance_monitor)) { _Exit(EXIT_FAILURE); } - if (close_begun) { + if (stop_begun && state.client) { (void)cbm_daemon_runtime_client_close_finish(state.client, FRONTEND_CLOSE_TIMEOUT_MS); - } else { + } else if (!stop_begun) { result = -1; } for (size_t i = 0; i < FRONTEND_QUEUE_CAPACITY; i++) { diff --git a/src/daemon/frontend.h b/src/daemon/frontend.h index 9d172606e..8ee33d276 100644 --- a/src/daemon/frontend.h +++ b/src/daemon/frontend.h @@ -4,14 +4,31 @@ #ifndef CBM_DAEMON_FRONTEND_H #define CBM_DAEMON_FRONTEND_H +#include "daemon/bootstrap.h" #include "daemon/runtime.h" #include "daemon/version_cohort.h" +#include "mcp/mcp.h" #include #include typedef struct cbm_daemon_maintenance_monitor cbm_daemon_maintenance_monitor_t; +/* Connection settings replayed when the authenticated daemon is replaced. + * Background activation + * from initialize is not restored here. + * Pointer fields are borrowed for the complete frontend + * call. */ +typedef struct { + cbm_daemon_bootstrap_config_t bootstrap; + const char *session_root; + const char *allowed_root; + cbm_mcp_tool_profile_t tool_profile; + uint8_t ui_update_mask; + bool ui_enabled; + int ui_port; +} cbm_daemon_frontend_session_config_t; + /* Called once when install/update/uninstall requests an active local command * to stop cooperatively. Returning false does not authorize the command to * outlive the bounded grace period. */ @@ -58,6 +75,14 @@ bool cbm_daemon_frontend_test_monitor_waiting(void); uint64_t cbm_daemon_frontend_test_monitor_observations(void); uint64_t cbm_daemon_frontend_test_worker_observations(void); uint64_t cbm_daemon_frontend_test_worker_idle_cycles(void); +/* Counts matching cancellations marked under the frontend mutex. + * Tests snapshot this monotonic + * count before sending a notification. */ +uint64_t cbm_daemon_frontend_test_routed_cancellations(void); +/* Budget in ms that the most recent session replay gave its context and UI + * settings requests. Recovery tests use distinct connect and startup budgets + * and compare. */ +uint32_t cbm_daemon_frontend_test_replay_timeout_ms(void); #endif /* Takes ownership of client and borrows cohort_manager for the complete call. @@ -66,9 +91,12 @@ uint64_t cbm_daemon_frontend_test_worker_idle_cycles(void); * either thread is blocked in stdio, requests cooperative cancellation for the * exact active request, and then bounds process exit. Kernel IPC close cancels * only this session's daemon work. EOF/parse failure closes the authenticated - * session. An unexpected daemon transport failure likewise terminates the + * session. A transport failure before the application frame is sent replaces + * the daemon client and retries once. Sent or repeated failures terminate the * process so an agent waiting with stdin still open observes server EOF. */ int cbm_daemon_frontend_mcp_run(cbm_daemon_runtime_client_t *client, - cbm_version_cohort_manager_t *cohort_manager, FILE *in, FILE *out); + cbm_version_cohort_manager_t *cohort_manager, + const cbm_daemon_frontend_session_config_t *session, FILE *in, + FILE *out); #endif /* CBM_DAEMON_FRONTEND_H */ diff --git a/src/daemon/runtime.c b/src/daemon/runtime.c index 404251a6c..64f6a05f4 100644 --- a/src/daemon/runtime.c +++ b/src/daemon/runtime.c @@ -3391,13 +3391,16 @@ cbm_daemon_runtime_cancel_result_t cbm_daemon_runtime_client_application_cancel( cbm_daemon_runtime_application_status_t cbm_daemon_runtime_client_application_request_tagged( cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token, const void *request, uint32_t request_length, uint8_t **response_out, - uint32_t *response_length_out, uint32_t timeout_ms) { + uint32_t *response_length_out, bool *request_sent_out, uint32_t timeout_ms) { if (response_out) { *response_out = NULL; } if (response_length_out) { *response_length_out = 0; } + if (request_sent_out) { + *request_sent_out = false; + } if (!client || !response_out || !response_length_out || request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID || timeout_ms == CBM_DAEMON_IPC_WAIT_FOREVER || @@ -3453,6 +3456,9 @@ cbm_daemon_runtime_application_status_t cbm_daemon_runtime_client_application_re request_can_send && cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_REQUEST, CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST, wire, (uint32_t)wire_length); + if (request_sent_out) { + *request_sent_out = sent; + } bool send_cancel = false; cbm_mutex_lock(&client->state_mutex); client->application_request_sent = sent; @@ -3551,9 +3557,9 @@ cbm_daemon_runtime_application_status_t cbm_daemon_runtime_client_application_re if (!cbm_daemon_runtime_client_application_token_reserve(client, &request_token)) { return CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; } - return cbm_daemon_runtime_client_application_request_tagged(client, request_token, request, - request_length, response_out, - response_length_out, timeout_ms); + return cbm_daemon_runtime_client_application_request_tagged( + client, request_token, request, request_length, response_out, response_length_out, NULL, + timeout_ms); } bool cbm_daemon_runtime_client_close_begin(cbm_daemon_runtime_client_t *client) { diff --git a/src/daemon/runtime.h b/src/daemon/runtime.h index 4cf4411aa..76d168c73 100644 --- a/src/daemon/runtime.h +++ b/src/daemon/runtime.h @@ -423,11 +423,12 @@ cbm_daemon_runtime_application_status_t cbm_daemon_runtime_client_application_re /* Execute using the sole outstanding token returned by token_reserve(). This * is the cancellable frontend path; the legacy helper above reserves - * internally. */ + * internally. request_sent_out reports whether the application frame crossed + * the local transport, including failures while awaiting its response. */ cbm_daemon_runtime_application_status_t cbm_daemon_runtime_client_application_request_tagged( cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token, const void *request, uint32_t request_length, uint8_t **response_out, - uint32_t *response_length_out, uint32_t timeout_ms); + uint32_t *response_length_out, bool *request_sent_out, uint32_t timeout_ms); /* Begin a two-phase close without freeing the client. This atomically rejects * future exchanges and interrupts an exchange already in flight. Exactly one diff --git a/src/git/git_context.c b/src/git/git_context.c index 66431333b..2ed8d2ec5 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -3,6 +3,7 @@ #include "foundation/compat_fs.h" #include "foundation/constants.h" #include "foundation/log.h" +#include "foundation/mem_core.h" #include "foundation/platform.h" #include "foundation/str_util.h" @@ -17,6 +18,17 @@ enum { GIT_OUTPUT_MAX = 4096, }; +/* Bounded worktree-status stream constants. + * STATUS_FIELD_MAX is the largest single NUL-delimited record field we + * buffer; a field at/over the bound is treated as a malformed stream and the + * whole snapshot fails closed. PATH components of real repos are far below + * this even with long paths. */ +enum { + STATUS_HEADER_LEN = 3, /* porcelain-v1 record prefix "XY " */ + STATUS_CHUNK_MAX = CBM_SZ_4K, + STATUS_FIELD_MAX = CBM_SZ_16K, +}; + static char *git_strdup(const char *s) { if (!s) { s = ""; @@ -73,6 +85,8 @@ static int git_capture_into(const char *repo_path, const char *git_args, char *b * stuck git child is named by the last marker printed (spawn, read or * close). Silent at the default level. */ cbm_log_debug("git.capture", "stage", "spawn", "args", git_args); + /* A hook or editor may export GIT_DIR/GIT_WORK_TREE for another repo. + * Isolate this child so git -C selects repo_path rather than that repo. */ FILE *fp = cbm_popen_git(cmd); if (!fp) { return CBM_NOT_FOUND; @@ -111,6 +125,14 @@ static int git_capture(const char *repo_path, const char *git_args, char **out) return *out ? 0 : CBM_NOT_FOUND; } +/* Regular file, not a directory and not a symlink. Uses the Unicode-safe + * path probe: stat() follows links and rejects non-ASCII paths on Windows, + * which upstream removed from this file in the Unicode repository-path fix. */ +static bool path_is_regular_file(const char *path) { + cbm_path_info_t info; + return cbm_path_info_utf8(path, &info) == CBM_PATH_INFO_OK && info.is_regular; +} + static bool path_is_absolute(const char *path) { if (!path || !path[0]) { return false; @@ -125,6 +147,180 @@ static bool path_is_absolute(const char *path) { #endif } +/* Read the "gitdir: " pointer out of a gitlink FILE at /.git. + * Returns false when .git is missing, a directory (ordinary repo), or holds no + * pointer. A relative pointer is resolved against path. */ +static bool read_gitlink_target(const char *path, char *out, size_t out_size) { + char dot_git[GIT_OUTPUT_MAX]; + int n = snprintf(dot_git, sizeof(dot_git), "%s/.git", path); + if (n < 0 || n >= (int)sizeof(dot_git)) { + return false; + } + if (!path_is_regular_file(dot_git)) { + return false; + } + + FILE *f = cbm_fopen(dot_git, "r"); + if (!f) { + return false; + } + char line[GIT_OUTPUT_MAX]; + bool got = false; + while (fgets(line, sizeof(line), f)) { + trim_newlines(line); + if (strncmp(line, "gitdir:", 7) != 0) { + continue; + } + const char *value = line + 7; + while (*value == ' ' || *value == '\t') { + value++; + } + if (!value[0]) { + break; + } + int written = path_is_absolute(value) ? snprintf(out, out_size, "%s", value) + : snprintf(out, out_size, "%s/%s", path, value); + got = written > 0 && written < (int)out_size; + break; + } + fclose(f); + return got; +} + +/* 1 = linked-worktree gitlink, 0 = a different git anchor (stop walking), + * -1 = no .git at this directory (keep walking). */ +static int linked_worktree_anchor(const char *path) { + char dot_git[GIT_OUTPUT_MAX]; + int n = snprintf(dot_git, sizeof(dot_git), "%s/.git", path); + if (n < 0 || n >= (int)sizeof(dot_git)) { + return 0; + } + cbm_path_info_t info; + if (cbm_path_info_utf8(dot_git, &info) != CBM_PATH_INFO_OK) { + return -1; + } + /* A directory .git is a main checkout. Anything else that is not a + * regular gitlink file (a symlink, for example) is also an anchor: + * do not follow it, and do not blame a parent worktree for this tree. */ + if (!info.is_regular) { + return 0; + } + char git_dir[GIT_OUTPUT_MAX]; + if (!read_gitlink_target(path, git_dir, sizeof(git_dir))) { + return 0; + } + /* Only linked worktrees carry /commondir; a submodule gitlink + * points at /.git/modules/, which does not. */ + char commondir[GIT_OUTPUT_MAX]; + n = snprintf(commondir, sizeof(commondir), "%s/commondir", git_dir); + if (n < 0 || n >= (int)sizeof(commondir)) { + return 0; + } + return path_is_regular_file(commondir) ? 1 : 0; +} + +static bool parent_directory(char *path) { + size_t n = strlen(path); + while (n > 1 && (path[n - 1] == '/' || path[n - 1] == '\\')) { + path[--n] = '\0'; + } +#ifdef _WIN32 + if ((n == 2 && path[1] == ':') || + (n == 3 && path[1] == ':' && (path[2] == '/' || path[2] == '\\'))) { + return false; + } +#endif + if (n <= 1) { + return false; + } + char *slash = NULL; + for (size_t i = 0; i < n; i++) { + if (path[i] == '/' || path[i] == '\\') { + slash = path + i; + } + } + if (!slash) { + return false; + } + if (slash == path) { + path[1] = '\0'; + return true; + } + *slash = '\0'; + return true; +} + +/* On a linked-worktree hit, optionally copy that anchor directory into root_out. */ +static bool walk_linked_worktree_root(const char *path, char *root_out, size_t root_out_size) { + if (!path || !path[0]) { + return false; + } + char current[GIT_OUTPUT_MAX]; + int n = snprintf(current, sizeof(current), "%s", path); + if (n < 0 || n >= (int)sizeof(current)) { + return false; + } + size_t len = strlen(current); + while (len > 1 && (current[len - 1] == '/' || current[len - 1] == '\\')) { + current[--len] = '\0'; + } + /* A session cwd or index_repository path is often a subdirectory. Walk + * ancestors until a git anchor so ignore_worktrees applies to the whole + * linked checkout, not only its root. */ + /* Bound is the path itself: each step drops one component and stops at + * the filesystem root. A fixed depth would miss a deep session cwd. */ + for (;;) { + int kind = linked_worktree_anchor(current); + if (kind >= 0) { + if (kind != 1) { + return false; + } + if (root_out && root_out_size > 0U) { + int written = snprintf(root_out, root_out_size, "%s", current); + if (written < 0 || (size_t)written >= root_out_size) { + return false; + } + } + return true; + } + char previous[GIT_OUTPUT_MAX]; + snprintf(previous, sizeof(previous), "%s", current); + if (!parent_directory(current) || strcmp(previous, current) == 0) { + return false; + } + } +} + +bool cbm_git_is_linked_worktree(const char *path) { + return walk_linked_worktree_root(path, NULL, 0U); +} + +bool cbm_git_same_linked_worktree(const char *a, const char *b) { + char root_a[GIT_OUTPUT_MAX]; + char root_b[GIT_OUTPUT_MAX]; + if (!walk_linked_worktree_root(a, root_a, sizeof(root_a)) || + !walk_linked_worktree_root(b, root_b, sizeof(root_b))) { + return false; + } + /* The gitdir file is unique per linked worktree. Comparing those pointers + * keeps a nested worktree from matching its parent even when both are + * linked checkouts. */ + char git_a[GIT_OUTPUT_MAX]; + char git_b[GIT_OUTPUT_MAX]; + if (!read_gitlink_target(root_a, git_a, sizeof(git_a)) || + !read_gitlink_target(root_b, git_b, sizeof(git_b))) { + return false; + } + /* A relative gitdir is joined onto the walked path. Canonicalize both so + * a symlink spelling of the cwd still matches the real project root. + * Fall back to the raw string when the gitdir does not exist. */ + char norm_a[GIT_OUTPUT_MAX]; + char norm_b[GIT_OUTPUT_MAX]; + const char *left = cbm_canonical_path(git_a, norm_a, sizeof(norm_a)) ? norm_a : git_a; + const char *right = cbm_canonical_path(git_b, norm_b, sizeof(norm_b)) ? norm_b : git_b; + return strcmp(left, right) == 0; +} + static char *join_root_relative(const char *root, const char *rel) { if (!root || !root[0]) { return git_strdup(rel); @@ -457,3 +653,210 @@ int cbm_git_context_props_json(const cbm_git_context_t *ctx, char *buf, int buf_ } return off; } + +/* ── Bounded worktree status ────────────────────────────────────────────── + * git status --porcelain=v1 -z --untracked-files=all is parsed record by + * record, NUL-delimited, over a bounded chunk buffer. The full stream is + * counted but only max_samples paths per class (tracked / untracked) are + * ever retained, so a huge dirty tree cannot grow memory. Rename/copy + * records carry a second NUL-terminated path (the source); it is consumed + * without being counted so one rename counts once. */ + +typedef struct { + cbm_worktree_status_t *out; + int max_samples; + bool expecting_source; /* next field is the source path of an R/C record */ + bool parse_ok; + char partial[STATUS_FIELD_MAX]; + size_t partial_len; +} status_parser_t; + +/* porcelain-v1 status columns (X/Y): documented codes plus the lowercase + * submodule variants and type-change 'T'. Anything else is not a record + * header, so a malformed stream fails closed instead of being misread. */ +static bool status_code_ok(char c) { + switch (c) { + case ' ': + case 'M': + case 'A': + case 'D': + case 'R': + case 'C': + case 'U': + case '?': + case '!': + case 'T': + case 'm': + case 'a': + case 'd': + case 'r': + case 'c': + case 'u': + case 't': + return true; + default: + return false; + } +} + +/* Retain one bounded sample; the running total always advances so the caller + * sees the true stream count even when samples are capped or OOM. */ +static void status_add_sample(int *total, char ***samples, int *sample_count, bool *truncated, + int max_samples, const char *path, size_t path_len) { + (*total)++; + if (*sample_count >= max_samples) { + *truncated = true; + return; + } + char *copy = (char *)cbm_alloc(CBM_MEM_CLASS_OTHER, path_len + 1); + if (!copy) { + *truncated = true; + return; + } + memcpy(copy, path, path_len); + copy[path_len] = '\0'; + char **grown = (char **)cbm_realloc(CBM_MEM_CLASS_OTHER, *samples, + (size_t)(*sample_count + 1) * sizeof(char *)); + if (!grown) { + cbm_free(CBM_MEM_CLASS_OTHER, copy); + *truncated = true; + return; + } + *samples = grown; + (*samples)[*sample_count] = copy; + (*sample_count)++; +} + +/* Parse one complete NUL-delimited field. Returns false on malformed input, + * which fails the whole snapshot closed. */ +static bool status_parse_record(cbm_worktree_status_t *out, status_parser_t *parser, + const char *field, size_t field_len) { + if (parser->expecting_source) { + parser->expecting_source = false; + return true; /* second path of a rename/copy record — not counted */ + } + if (field_len < STATUS_HEADER_LEN || field[2] != ' ' || !status_code_ok(field[0]) || + !status_code_ok(field[1])) { + return false; + } + const char *path = field + STATUS_HEADER_LEN; + size_t path_len = field_len - STATUS_HEADER_LEN; + if (field[0] == '?' && field[1] == '?') { + status_add_sample(&out->untracked_count, &out->untracked_paths, + &out->untracked_sample_count, &out->untracked_truncated, + parser->max_samples, path, path_len); + return true; + } + if (field[0] == 'R' || field[0] == 'C' || field[1] == 'R' || field[1] == 'C') { + parser->expecting_source = true; + } + status_add_sample(&out->tracked_count, &out->tracked_paths, &out->tracked_sample_count, + &out->tracked_truncated, parser->max_samples, path, path_len); + return true; +} + +/* Feed a chunk of the NUL-delimited stream through the bounded record parser. */ +static void status_feed(status_parser_t *parser, const char *data, size_t data_len) { + size_t i = 0; + while (i < data_len) { + if (parser->partial_len >= STATUS_FIELD_MAX) { + parser->parse_ok = false; /* field exceeded the bound: malformed */ + return; + } + size_t j = i; + while (j < data_len && data[j] != '\0' && + parser->partial_len + (j - i) < STATUS_FIELD_MAX) { + j++; + } + size_t take = j - i; + memcpy(parser->partial + parser->partial_len, data + i, take); + parser->partial_len += take; + i += take; + if (i < data_len && data[i] == '\0') { + if (!status_parse_record(parser->out, parser, parser->partial, parser->partial_len)) { + parser->parse_ok = false; + return; + } + parser->partial_len = 0; + i++; /* consume the record terminator */ + } else if (i >= data_len) { + return; /* chunk exhausted mid-record */ + } + } +} + +int cbm_git_worktree_status(const char *validated_root, int max_samples, + cbm_worktree_status_t *out) { + if (!out) { + return CBM_NOT_FOUND; + } + memset(out, 0, sizeof(*out)); + if (!validated_root || !validated_root[0] || max_samples < 0) { + return CBM_NOT_FOUND; + } + if (!git_validate_repo_path(validated_root)) { + return CBM_NOT_FOUND; + } + + char cmd[GIT_CMD_MAX]; +#ifdef _WIN32 + const char *null_dev = "NUL"; +#else + const char *null_dev = "/dev/null"; +#endif + int n = snprintf(cmd, sizeof(cmd), + "git -C \"%s\" status --porcelain=v1 -z --untracked-files=all 2>%s", + validated_root, null_dev); + if (n < 0 || n >= (int)sizeof(cmd)) { + return CBM_NOT_FOUND; + } + + /* An inherited GIT_DIR/GIT_WORK_TREE (a git hook, an editor) overrides + * `git -C`, which would report another repository's worktree here. */ + FILE *fp = cbm_popen_git(cmd); + if (!fp) { + return 0; /* out is zeroed: available=false */ + } + + status_parser_t parser; + memset(&parser, 0, sizeof(parser)); + parser.out = out; + parser.max_samples = max_samples; + parser.parse_ok = true; + + char chunk[STATUS_CHUNK_MAX]; + size_t got; + bool io_error = false; + while (parser.parse_ok && (got = fread(chunk, 1, sizeof(chunk), fp)) > 0) { + status_feed(&parser, chunk, got); + } + if (ferror(fp)) { + io_error = true; + } + int rc = cbm_pclose(fp); + + if (rc != 0 || !parser.parse_ok || io_error || parser.partial_len > 0) { + /* Fail closed: any deviation from a clean, complete status stream is a + * machine-readable "unavailable", never a clean worktree. */ + cbm_git_worktree_status_free(out); + return 0; + } + + out->available = true; + return 0; +} + +void cbm_git_worktree_status_free(cbm_worktree_status_t *st) { + if (!st) { + return; + } + for (int i = 0; i < st->tracked_sample_count; i++) { + cbm_free(CBM_MEM_CLASS_OTHER, st->tracked_paths[i]); + } + cbm_free(CBM_MEM_CLASS_OTHER, st->tracked_paths); + for (int i = 0; i < st->untracked_sample_count; i++) { + cbm_free(CBM_MEM_CLASS_OTHER, st->untracked_paths[i]); + } + cbm_free(CBM_MEM_CLASS_OTHER, st->untracked_paths); + memset(st, 0, sizeof(*st)); +} diff --git a/src/git/git_context.h b/src/git/git_context.h index 2ac419522..506643cdb 100644 --- a/src/git/git_context.h +++ b/src/git/git_context.h @@ -20,6 +20,25 @@ typedef struct { char *base_sha; } cbm_git_context_t; +/* True when path is a LINKED git worktree (`git worktree add`) or a + * subdirectory of one. + * + * Plumbing-only, no subprocess. Walk ancestors until a git anchor. A linked + * worktree's /.git is a regular file holding a "gitdir: " pointer + * AND that gitdir contains a `commondir` file. The walk stops at any other + * anchor: a main checkout (`.git` is a directory) or a submodule (gitlink + * file whose gitdir, /.git/modules/, has no commondir). + * + * Callers run this on every session start, so it stays fork-free; the richer + * cbm_git_context_resolve() shells out to git and is not usable on that path. */ +bool cbm_git_is_linked_worktree(const char *path); + +/* True when both paths sit in the same linked worktree. Identity is the + * gitdir pointer of each path's linked-worktree anchor, so a worktree nested + * inside another does not count as that parent. False when either path is not + * inside a linked worktree. */ +bool cbm_git_same_linked_worktree(const char *a, const char *b); + int cbm_git_context_resolve(const char *path, cbm_git_context_t *out); /* Only the is_git and branch fields of cbm_git_context_resolve, from the same * two git commands it uses for them (2 git processes instead of 7). branch @@ -31,4 +50,40 @@ void cbm_git_context_free(cbm_git_context_t *ctx); char *cbm_git_context_branch_qn(const char *project_name, const cbm_git_context_t *ctx); int cbm_git_context_props_json(const cbm_git_context_t *ctx, char *buf, int buf_size); +/* Bounded snapshot of the git worktree status for one validated repo root. + * + * available is true ONLY when `git status --porcelain=v1 -z --untracked-files=all` + * exited 0 AND every NUL-delimited record parsed cleanly. Any other outcome — + * shell-unsafe path, spawn failure, nonzero exit, malformed/oversized record, + * trailing partial record — leaves available=false with zero counts and no + * samples. Callers MUST treat !available as "status unavailable", never as a + * clean worktree. + * + * The entire stream is counted, but only max_samples paths per class (tracked + * / untracked) are ever retained, in stream order; exceeding the cap sets the + * matching *_truncated flag. Rename/copy records are counted once (their two + * NUL-separated paths are consumed without counting the second), and the + * sampled path is the record's NEW path. Ignored files never appear because + * --ignored is not requested. + * + * cbm_git_worktree_status returns 0 when *out has been filled (available tells + * whether the snapshot succeeded) and CBM_NOT_FOUND for invalid arguments + * (NULL root/out or max_samples < 0). The caller owns the returned samples via + * cbm_git_worktree_status_free. */ +typedef struct { + bool available; + int tracked_count; + int untracked_count; + char **tracked_paths; /* heap-owned samples, tracked_sample_count entries */ + int tracked_sample_count; + bool tracked_truncated; + char **untracked_paths; + int untracked_sample_count; + bool untracked_truncated; +} cbm_worktree_status_t; + +int cbm_git_worktree_status(const char *validated_root, int max_samples, + cbm_worktree_status_t *out); +void cbm_git_worktree_status_free(cbm_worktree_status_t *st); + #endif diff --git a/src/main.c b/src/main.c index 92fcc15ec..e5cb74c34 100644 --- a/src/main.c +++ b/src/main.c @@ -3399,9 +3399,9 @@ int main(int argc, char **argv) { cbm_daemon_bootstrap_result_t bootstrap_result; cbm_daemon_bootstrap_status_t bootstrap_status = main_client_bootstrap_with_upgrade(&bootstrap_config, &bootstrap_result, true); - cbm_daemon_ipc_endpoint_free(endpoint); if (bootstrap_status != CBM_DAEMON_BOOTSTRAP_CONNECTED || !bootstrap_result.client) { main_report_client_bootstrap_failure(role, &bootstrap_result); + cbm_daemon_ipc_endpoint_free(endpoint); (void)main_version_cohort_close(&client_cohort_lease, &client_cohort_manager); return EXIT_FAILURE; } @@ -3412,33 +3412,46 @@ int main(int argc, char **argv) { * like the bootstrap before them: a 1 s bound on the reply (a probe budget) * ended the server whenever the daemon's UI save ran long, which the smoke's * `--ui=true --port` run saw as a binary without UI assets. */ - if (role == CBM_DAEMON_PROCESS_MCP_CLIENT && - !main_set_client_context(g_daemon_client, NULL, tool_profile, NULL, NULL, - bootstrap_config.startup_timeout_ms)) { - (void)fprintf(stderr, "codebase-memory-mcp: daemon session context was rejected\n"); - (void)cbm_daemon_runtime_client_close(g_daemon_client, MAIN_CLOSE_TIMEOUT_MS); - g_daemon_client = NULL; - (void)main_version_cohort_close(&client_cohort_lease, &client_cohort_manager); - return EXIT_FAILURE; + char mcp_session_root[MAIN_PATH_CAP] = {0}; + char mcp_allowed_root[MAIN_PATH_CAP] = {0}; + const char *mcp_allowed_root_ptr = NULL; + if (role == CBM_DAEMON_PROCESS_MCP_CLIENT) { + bool context_resolved = + main_session_context(NULL, mcp_session_root, mcp_allowed_root, &mcp_allowed_root_ptr); + cbm_daemon_runtime_application_status_t context_status = + context_resolved ? cbm_daemon_application_client_set_context( + g_daemon_client, mcp_session_root, mcp_allowed_root_ptr, + tool_profile, NULL, NULL, bootstrap_config.startup_timeout_ms) + : CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + if (context_status != CBM_DAEMON_RUNTIME_APPLICATION_OK) { + (void)fprintf(stderr, "codebase-memory-mcp: daemon session context was rejected\n"); + (void)cbm_daemon_runtime_client_close(g_daemon_client, MAIN_CLOSE_TIMEOUT_MS); + g_daemon_client = NULL; + cbm_daemon_ipc_endpoint_free(endpoint); + (void)main_version_cohort_close(&client_cohort_lease, &client_cohort_manager); + return EXIT_FAILURE; + } } /* Persist UI mutations only after the exact-build HELLO succeeds. A * conflicting binary must be observationally read-only: applying its * flags before bootstrap could reconfigure the already-running daemon * even though that client was then rejected. */ + bool mcp_ui_enabled = false; + int mcp_ui_port = 0; + uint8_t mcp_ui_update_mask = 0; if (role == CBM_DAEMON_PROCESS_MCP_CLIENT && cbm_mcp_tool_profile_allows_http(tool_profile)) { - bool ui_enabled = false; - int ui_port = 0; bool explicitly_enabled = false; - uint8_t update_mask = - parse_ui_flags(argc, argv, &ui_enabled, &ui_port, &explicitly_enabled); - if (update_mask != 0 && + mcp_ui_update_mask = + parse_ui_flags(argc, argv, &mcp_ui_enabled, &mcp_ui_port, &explicitly_enabled); + if (mcp_ui_update_mask != 0 && cbm_daemon_application_client_set_ui_config( - g_daemon_client, update_mask, ui_enabled, ui_port, + g_daemon_client, mcp_ui_update_mask, mcp_ui_enabled, mcp_ui_port, bootstrap_config.startup_timeout_ms) != CBM_DAEMON_RUNTIME_APPLICATION_OK) { (void)fprintf(stderr, "codebase-memory-mcp: daemon UI configuration update failed\n"); (void)cbm_daemon_runtime_client_close(g_daemon_client, MAIN_CLOSE_TIMEOUT_MS); g_daemon_client = NULL; + cbm_daemon_ipc_endpoint_free(endpoint); (void)main_version_cohort_close(&client_cohort_lease, &client_cohort_manager); return EXIT_FAILURE; } @@ -3455,13 +3468,25 @@ int main(int argc, char **argv) { (void)fprintf(stderr, "codebase-memory-mcp: parent-death watchdog could not start\n"); (void)cbm_daemon_runtime_client_close(g_daemon_client, MAIN_CLOSE_TIMEOUT_MS); g_daemon_client = NULL; + cbm_daemon_ipc_endpoint_free(endpoint); (void)main_version_cohort_close(&client_cohort_lease, &client_cohort_manager); return EXIT_FAILURE; } setup_signal_handlers(); - int result = cbm_daemon_frontend_mcp_run(g_daemon_client, client_cohort_manager, stdin, stdout); + cbm_daemon_frontend_session_config_t frontend_session = { + .bootstrap = bootstrap_config, + .session_root = mcp_session_root, + .allowed_root = mcp_allowed_root_ptr, + .tool_profile = tool_profile, + .ui_update_mask = mcp_ui_update_mask, + .ui_enabled = mcp_ui_enabled, + .ui_port = mcp_ui_port, + }; + int result = cbm_daemon_frontend_mcp_run(g_daemon_client, client_cohort_manager, + &frontend_session, stdin, stdout); g_daemon_client = NULL; /* frontend consumed the handle */ + cbm_daemon_ipc_endpoint_free(endpoint); bool client_cohort_cleanup = main_version_cohort_close(&client_cohort_lease, &client_cohort_manager); atomic_store(&g_shutdown, 1); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index a8593d946..7097fd062 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -49,6 +49,8 @@ enum { * promise. The actual cross-platform contract is this deterministic UTF-8 * byte ceiling, applied only at whole semantic-unit boundaries. */ MCP_OUTPUT_BYTES_PER_TOKEN_ESTIMATE = 4, + MCP_STATUS_SAMPLE_MAX = 16, /* per-class path samples in verbose freshness */ + MCP_REASONS_MAX = 8, /* freshness reasons array ceiling (see below) */ }; #define MCP_MS_TO_US 1000LL #define MCP_S_TO_US 1000000LL @@ -551,6 +553,9 @@ static const tool_def_t TOOLS[] = { "\"Name override; Non-ASCII bytes are encoded; unsafe characters normalized.\"}," "\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":" "\"Write .codebase-memory/graph.db.zst.\"}," + "\"index_worktree\":{\"type\":\"boolean\",\"default\":false,\"description\":" + "\"Index repo_path even when it is a linked git worktree and the " + "ignore_worktrees config key is enabled. No effect otherwise.\"}," "\"doc_candidates\":{\"type\":\"boolean\",\"description\":" "\"Doc-code candidates; default: the doc_candidates setting, else on for full, off for " "moderate/fast.\"}," @@ -7789,13 +7794,169 @@ static void add_watch_status_json(cbm_mcp_server_t *srv, yyjson_mut_doc *doc, yy yyjson_mut_obj_add_val(doc, root, "watch", watch); } +/* Emit the bounded worktree-status snapshot inside the freshness block. + * Fail-closed: status_available is false whenever git status could not run + * clean, in which case the API guarantees zero counts and no samples — a + * non-git root or missing git is NEVER presented as a clean worktree. Ignored + * paths are deliberately absent (no --ignored in the underlying query) and + * must not be confused with tracked/untracked sources. */ +static void add_worktree_status_json(yyjson_mut_doc *doc, yyjson_mut_val *freshness, + const cbm_worktree_status_t *st) { + yyjson_mut_obj_add_bool(doc, freshness, "status_available", st->available); + + yyjson_mut_val *tracked = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, tracked, "count", st->tracked_count); + yyjson_mut_val *tpaths = yyjson_mut_arr(doc); + for (int i = 0; i < st->tracked_sample_count; i++) { + yyjson_mut_arr_add_strcpy(doc, tpaths, st->tracked_paths[i]); + } + yyjson_mut_obj_add_val(doc, tracked, "paths", tpaths); + yyjson_mut_obj_add_bool(doc, tracked, "truncated", st->tracked_truncated); + yyjson_mut_obj_add_val(doc, freshness, "tracked_changes", tracked); + + yyjson_mut_val *untracked = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, untracked, "count", st->untracked_count); + yyjson_mut_val *upaths = yyjson_mut_arr(doc); + for (int i = 0; i < st->untracked_sample_count; i++) { + yyjson_mut_arr_add_strcpy(doc, upaths, st->untracked_paths[i]); + } + yyjson_mut_obj_add_val(doc, untracked, "paths", upaths); + yyjson_mut_obj_add_bool(doc, untracked, "truncated", st->untracked_truncated); + yyjson_mut_obj_add_val(doc, freshness, "untracked_source", untracked); +} + +/* BT-240: fail-closed freshness verdict against the indexed-checkout identity + * recorded with the DB at the successful staged-generation boundary. A live + * checkout SHA is not proof of the generation that produced graph content, so + * the verdict comes from that recorded identity, the live git HEAD and the + * bounded worktree status (tracked/untracked deltas): + * - no indexed SHA → unknown / unavailable + * - indexed SHA differs from the live git HEAD → stale / mismatch + * - tracked changes present → stale / tracked + * - untracked sources present → prevents current + * - equal SHA + status available + no changes → current + * Report-only: it never triggers indexing. Verbose-only and read-only; the + * live checkout is resolved fresh from the project root (never mutated). */ +static void add_index_freshness_json(yyjson_mut_doc *doc, yyjson_mut_val *root, cbm_store_t *store, + const char *project, const char *root_path, + const char *indexed_generation) { + yyjson_mut_val *freshness = yyjson_mut_obj(doc); + if (indexed_generation && indexed_generation[0]) { + yyjson_mut_obj_add_strcpy(doc, freshness, "indexed_generation", indexed_generation); + } else { + yyjson_mut_obj_add_str(doc, freshness, "indexed_generation", ""); + } + + cbm_coverage_meta_t meta = {0}; + bool have_meta = store && cbm_store_coverage_meta_get(store, project, &meta) == CBM_STORE_OK; + const char *indexed_sha = NULL; + if (have_meta && meta.indexed_checkout_sha && meta.indexed_checkout_sha[0]) { + indexed_sha = meta.indexed_checkout_sha; + } + + cbm_git_context_t ctx = {0}; + (void)cbm_git_context_resolve(root_path, &ctx); + const char *checkout_sha = ctx.head_sha && ctx.head_sha[0] ? ctx.head_sha : NULL; + + cbm_worktree_status_t st = {0}; + int status_rc = cbm_git_worktree_status(root_path, MCP_STATUS_SAMPLE_MAX, &st); + bool status_available = status_rc == 0 && st.available; + add_worktree_status_json(doc, freshness, &st); + + if (indexed_sha) { + yyjson_mut_obj_add_strcpy(doc, freshness, "indexed_checkout_sha", indexed_sha); + } else { + yyjson_mut_obj_add_null(doc, freshness, "indexed_checkout_sha"); + } + if (checkout_sha) { + yyjson_mut_obj_add_strcpy(doc, freshness, "checkout_sha", checkout_sha); + } else { + yyjson_mut_obj_add_null(doc, freshness, "checkout_sha"); + } + + /* Verdict/reasons composition. Stale reasons (identity mismatch, tracked + * changes) win over everything; a missing indexed identity is unknown + * regardless of live state; status unavailability and untracked sources + * both prevent "current" but only yield stale when a stale reason is + * already present. The reasons array keeps every applicable code in a + * stable order; the singular reason stays as the first (dominant) one for + * compatibility with consumers that read it directly. */ + bool sha_present = indexed_sha != NULL; + bool live_present = checkout_sha != NULL; + bool sha_mismatch = sha_present && live_present && strcmp(indexed_sha, checkout_sha) != 0; + bool has_tracked = st.tracked_count > 0; + bool has_untracked = st.untracked_count > 0; + bool has_stale = sha_mismatch || has_tracked; + + const char *verdict; + if (!sha_present) { + verdict = "unknown"; + } else if (has_stale) { + verdict = "stale"; + } else if (!live_present) { + verdict = "unknown"; + } else if (!status_available) { + verdict = "unknown"; + } else if (has_untracked) { + verdict = "unknown"; + } else { + verdict = "current"; + } + + const char *reasons[MCP_REASONS_MAX]; + int n_reasons = 0; + if (!sha_present) { + reasons[n_reasons++] = "indexed_checkout_unavailable"; + } + if (sha_mismatch) { + reasons[n_reasons++] = "indexed_checkout_mismatch"; + } + if (has_tracked) { + reasons[n_reasons++] = "tracked_changes_present"; + } + if (!status_available) { + reasons[n_reasons++] = "status_unavailable"; + } + if (has_untracked) { + reasons[n_reasons++] = "untracked_not_indexed"; + } + if (n_reasons == 0) { + reasons[n_reasons++] = "indexed_checkout_current"; + } + const char *reason = reasons[0]; + + const char *recommended_action; + if (strcmp(verdict, "current") == 0) { + recommended_action = "use_graph"; + } else if (strcmp(verdict, "stale") == 0) { + recommended_action = "reindex_to_match_checkout"; + } else { + recommended_action = "reindex_to_record_indexed_checkout"; + } + yyjson_mut_obj_add_str(doc, freshness, "verdict", verdict); + yyjson_mut_obj_add_str(doc, freshness, "reason", reason); + yyjson_mut_val *reasons_val = yyjson_mut_arr(doc); + for (int i = 0; i < n_reasons; i++) { + yyjson_mut_arr_add_str(doc, reasons_val, reasons[i]); + } + yyjson_mut_obj_add_val(doc, freshness, "reasons", reasons_val); + yyjson_mut_obj_add_str(doc, freshness, "recommended_action", recommended_action); + yyjson_mut_obj_add_val(doc, root, "freshness", freshness); + + if (have_meta) { + cbm_store_coverage_meta_clear(&meta); + } + cbm_git_worktree_status_free(&st); + cbm_git_context_free(&ctx); +} + static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { char *project = get_project_arg(args); cbm_store_t *store = resolve_store(srv, project); REQUIRE_STORE(store, project); - /* The git context block (worktree/shadow path variants) only matters when - * debugging index-location issues — gate it so the common status call - * stays lean. */ + /* The git context block (worktree/shadow path variants) and the freshness + * verdict only matter when debugging index-location issues — gate them so + * the common status call stays lean. */ bool verbose = cbm_mcp_get_bool_arg(args, "verbose"); char *diagnostics = cbm_mcp_get_string_arg(args, "diagnostics"); int coverage_samples = 0; @@ -7840,6 +8001,8 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { proj_info.indexed_at ? proj_info.indexed_at : ""); if (verbose) { add_git_context_json(doc, root, proj_info.root_path); + add_index_freshness_json(doc, root, store, project, proj_info.root_path, + proj_info.indexed_at); } } add_coverage_report(doc, root, store, project, have_proj_info ? proj_info.indexed_at : NULL, @@ -13171,6 +13334,20 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { return cbm_mcp_text_result(boundary_err, true); } + /* ignore_worktrees: an EXPLICIT index_repository call on a linked worktree + * is refused with the two ways forward (per-call override, or turn the + * setting off) rather than silently skipped — a silent success would be + * indistinguishable from a real index to the caller. */ + if (cbm_mcp_ignore_worktrees_enabled(srv) && !cbm_mcp_get_bool_arg(args, "index_worktree") && + cbm_git_is_linked_worktree(repo_path)) { + index_args_free(repo_path, mode_str, name_override); + return cbm_mcp_text_result( + "repo_path is a linked git worktree and ignore_worktrees is enabled. Pass " + "index_worktree=true to index it anyway, or run: codebase-memory-mcp config set " + "ignore_worktrees false", + true); + } + if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) { if (async_mode) { index_args_free(repo_path, mode_str, name_override); @@ -21677,6 +21854,44 @@ static bool auto_watch_enabled(cbm_mcp_server_t *srv) { return cbm_config_get_bool(srv->config, CBM_CONFIG_AUTO_WATCH, true); } +/* ignore_worktrees config: gates automatic indexing of LINKED git worktrees + * (default off, so existing setups keep indexing them). Users who create many + * short-lived worktrees can stop each throwaway checkout from registering a + * new permanent project with `config set ignore_worktrees true`. */ +bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv) { + if (!srv || !srv->config) { + return false; /* default off */ + } + return cbm_config_get_bool(srv->config, CBM_CONFIG_IGNORE_WORKTREES, false); +} + +/* The same key for a caller whose server may have no store: the stand-alone + * lifecycle hook builds one with cbm_mcp_server_new(NULL), and the daemon lends + * its session server, which this must not modify. A server with a store answers + * from it. Otherwise the runtime store is opened only to read this key, and only + * when it exists: a missing store leaves every key at its default, so this read + * never creates one. */ +bool cbm_mcp_ignore_worktrees_runtime_enabled(const cbm_mcp_server_t *srv) { + if (srv && srv->config) { + return cbm_mcp_ignore_worktrees_enabled(srv); + } + const char *store_dir = cbm_resolve_cache_dir(); + if (!store_dir) { + return false; + } + char store_path[CBM_SZ_4K]; + int written = snprintf(store_path, sizeof(store_path), "%s/" CBM_CONFIG_DB_FILENAME, store_dir); + cbm_path_info_t info; + if (written < 0 || (size_t)written >= sizeof(store_path) || + cbm_path_info_utf8(store_path, &info) != CBM_PATH_INFO_OK || !info.is_regular) { + return false; + } + cbm_config_t *config = cbm_config_open(store_dir); + bool enabled = config && cbm_config_get_bool(config, CBM_CONFIG_IGNORE_WORKTREES, false); + cbm_config_close(config); + return enabled; +} + /* Register the session project with the background watcher for ongoing * change detection — unless auto_watch is disabled. */ static void register_watcher_if_enabled(cbm_mcp_server_t *srv) { @@ -21831,6 +22046,12 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) { return; } + if (cbm_mcp_ignore_worktrees_enabled(srv) && cbm_git_is_linked_worktree(srv->session_root)) { + cbm_log_info("autoindex.skip", "reason", "linked_worktree", "project", + srv->session_project); + return; + } + /* Quick tracked-file count check to avoid OOM on massive repos. */ int file_count = -1; #ifdef CBM_ENABLE_TEST_SEAMS diff --git a/src/mcp/mcp_internal.h b/src/mcp/mcp_internal.h index 7fe9ae289..5df164b1a 100644 --- a/src/mcp/mcp_internal.h +++ b/src/mcp/mcp_internal.h @@ -109,6 +109,16 @@ const char *cbm_mcp_edge_strategy_class(const char *strategy); bool cbm_mcp_auto_index_within_file_limit(const char *root_path, int file_limit, int *file_count_out); +/* True when the `ignore_worktrees` config key is on for this server (default + * off). Callers pair it with cbm_git_is_linked_worktree() to decide whether an + * automatic index of a linked worktree should be skipped. */ +bool cbm_mcp_ignore_worktrees_enabled(const cbm_mcp_server_t *srv); + +/* The same key when srv may have no store (the lifecycle hook). Never attaches + * a store to srv and never creates the runtime store; reads it only when it + * already exists. */ +bool cbm_mcp_ignore_worktrees_runtime_enabled(const cbm_mcp_server_t *srv); + /* detect_changes seed scoping (#1363): does `node`'s line range overlap any * recorded hunk for `file`? Exposed for direct unit testing of the overlap * logic, independent of the git/subprocess/index plumbing around it. */ diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 371cb5536..7fd3905d3 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -388,6 +388,7 @@ static inline bool cbm_pipeline_invocation_reason_join_strategy(const char *stra strcmp(strategy, "lsp_method_ref_ctor_synth") == 0 || strcmp(strategy, "lsp_dict_dispatch") == 0 || strcmp(strategy, "lsp_import_alias") == 0 || + strcmp(strategy, "lsp_constructor_init") == 0 || strcmp(strategy, "php_method_dynamic") == 0); } diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 519fc7d73..3f61e122d 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -1643,6 +1643,10 @@ cbm_index_resource_violation_t *cbm_pipeline_resource_violation(cbm_pipeline_t * return p ? &p->resource_violation : NULL; } +const char *cbm_pipeline_indexed_checkout_sha(const cbm_pipeline_t *p) { + return p ? p->git_ctx.head_sha : NULL; +} + atomic_int *cbm_pipeline_cancelled_ptr(cbm_pipeline_t *p) { return p ? p->cancelled : NULL; } @@ -4177,6 +4181,7 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas .hash_records_complete = true, .unresolved_calls_complete = pipeline_unresolved_capture_complete(p, coverage_rows_available), + .indexed_checkout_sha = p->git_ctx.head_sha, }, .surface_rows = p->surface_rows, .surface_row_count = p->surface_row_count, diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index ac2c253af..4e96e732a 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -3858,6 +3858,7 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char .hash_records_complete = true, .unresolved_calls_complete = incremental_capture_complete( coverage_rows_available, unresolved_complete, retained_unresolved_complete), + .indexed_checkout_sha = cbm_pipeline_indexed_checkout_sha(p), }, .surface_rows = NULL, .surface_row_count = 0, @@ -4543,6 +4544,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil .hash_records_complete = true, .unresolved_calls_complete = incremental_capture_complete( coverage_rows_available, unresolved_complete, retained_unresolved_complete), + .indexed_checkout_sha = cbm_pipeline_indexed_checkout_sha(p), }; /* Publish surfaces: the surviving previous rows plus this run's fresh * ones (closure route). The legacy test route publishes none — its diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 9ef2734b3..ca0f0ec35 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -1883,6 +1883,8 @@ const cbm_test_declarations_t *cbm_pipeline_test_declarations(const cbm_pipeline bool cbm_pipeline_project_source_matches(const cbm_pipeline_t *p, const cbm_userconfig_t *fresh); const cbm_index_resource_policy_t *cbm_pipeline_resource_policy(const cbm_pipeline_t *p); cbm_index_resource_violation_t *cbm_pipeline_resource_violation(cbm_pipeline_t *p); +/* Borrowed checkout identity captured and validated for this pipeline run. */ +const char *cbm_pipeline_indexed_checkout_sha(const cbm_pipeline_t *p); atomic_int *cbm_pipeline_cancelled_ptr(cbm_pipeline_t *p); /* Record committed graph size (#334 gate axis) from the incremental path, * which cannot see the opaque cbm_pipeline struct. Call before the dump. */ diff --git a/src/store/store.c b/src/store/store.c index ed06236a9..c76fd3625 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -340,6 +340,26 @@ bool cbm_store_edges_schema_current(cbm_store_t *s) { return true; } +/* Best-effort probe for the BT-240 index_checkout table. Writable opens run + * init_schema (which creates it), so false is expected only on read-only + * legacy DBs opened via the query path — those must keep working and simply + * report no indexed-checkout identity. */ +static bool index_checkout_table_exists(cbm_store_t *s) { + if (!s || !s->db) { + return false; + } + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, + "SELECT 1 FROM sqlite_master WHERE type = 'table' " + "AND name = 'index_checkout';", + CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + return false; + } + bool has = sqlite3_step(stmt) == SQLITE_ROW; + sqlite3_finalize(stmt); + return has; +} + static int init_schema(cbm_store_t *s) { const char *ddl = "CREATE TABLE IF NOT EXISTS projects (" @@ -495,6 +515,14 @@ static int init_schema(cbm_store_t *s) { " coverage_version INTEGER NOT NULL DEFAULT 1," " hash_records_complete INTEGER NOT NULL DEFAULT 0," " unresolved_calls_complete INTEGER NOT NULL DEFAULT 0" + ");" + /* BT-240: the checkout an index was built from. A table of its own, + * not an index_coverage_meta column: it is source identity, which the + * pinned graph-digest tables deliberately do not bind, and a new + * table needs no migration of existing databases. */ + "CREATE TABLE IF NOT EXISTS index_checkout (" + " project TEXT PRIMARY KEY REFERENCES projects(name) ON DELETE CASCADE," + " indexed_checkout_sha TEXT NOT NULL" ");"; int rc = exec_sql(s, ddl); @@ -3090,6 +3118,7 @@ int cbm_store_delete_project(cbm_store_t *s, const char *name) { static const char *cleanup_sql[] = { "DELETE FROM index_coverage WHERE project = ?1;", "DELETE FROM index_coverage_meta WHERE project = ?1;", + "DELETE FROM index_checkout WHERE project = ?1;", "DELETE FROM projects WHERE name = ?1 || '::missed';", }; /* doc_link_unresolved exists only in databases published by a build that @@ -4838,6 +4867,34 @@ static int cov_rebuild_shadow_graph(cbm_store_t *s, const char *project) { return CBM_STORE_OK; } +/* Record (or, with no sha, clear) the checkout a project's index was built + * from. Runs inside the caller's coverage transaction, so a failed replace + * keeps the previous identity. */ +static int index_checkout_write(cbm_store_t *s, const char *project, const char *sha) { + bool record = sha && sha[0]; + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, + record ? "INSERT INTO index_checkout (project, indexed_checkout_sha) " + "VALUES (?1, ?2) ON CONFLICT(project) DO UPDATE SET " + "indexed_checkout_sha=?2;" + : "DELETE FROM index_checkout WHERE project = ?1;", + CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "index checkout write prepare"); + return CBM_STORE_ERR; + } + bind_text(stmt, SKIP_ONE, project); + if (record) { + bind_text(stmt, ST_COL_2, sha); + } + int rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + store_set_error_sqlite(s, "index checkout write"); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; +} + /* Materialize once at publication, after replacement and deleted-file pruning. * json_each sees a safe marker array for malformed/legacy non-array payloads. * The side table cascades with its authoritative coverage rows. */ @@ -4893,7 +4950,7 @@ static int coverage_write_metadata(cbm_store_t *s, const char *project, store_set_error_sqlite(s, "coverage meta delete"); return CBM_STORE_ERR; } - return CBM_STORE_OK; + return index_checkout_write(s, project, NULL); } char recorded_at[CBM_SZ_64]; if (meta->recorded_at && meta->recorded_at[0]) { @@ -4943,7 +5000,7 @@ static int coverage_write_metadata(cbm_store_t *s, const char *project, store_set_error_sqlite(s, "coverage meta upsert"); return CBM_STORE_ERR; } - return CBM_STORE_OK; + return index_checkout_write(s, project, meta->indexed_checkout_sha); } int cbm_store_coverage_replace_ex(cbm_store_t *s, const char *project, @@ -5218,9 +5275,45 @@ void cbm_store_coverage_meta_clear(cbm_coverage_meta_t *meta) { free((char *)meta->index_mode); free((char *)meta->recorded_at); free((char *)meta->recording_status); + cbm_free(CBM_MEM_CLASS_STORE, (char *)meta->indexed_checkout_sha); memset(meta, 0, sizeof(*meta)); } +/* Fill out->indexed_checkout_sha from index_checkout. A legacy read-only DB + * (query opens skip init_schema) may lack the table: no identity, not an + * error. On failure out is cleared. */ +static int index_checkout_read(cbm_store_t *s, const char *project, cbm_coverage_meta_t *out) { + if (!index_checkout_table_exists(s)) { + return CBM_STORE_OK; + } + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, + "SELECT indexed_checkout_sha FROM index_checkout WHERE project = ?1;", + CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "index checkout read prepare"); + cbm_store_coverage_meta_clear(out); + return CBM_STORE_ERR; + } + bind_text(stmt, SKIP_ONE, project); + int rc = sqlite3_step(stmt); + if (rc == SQLITE_ROW) { + const char *sha = (const char *)sqlite3_column_text(stmt, 0); + if (sha && sha[0]) { + out->indexed_checkout_sha = cbm_mem_strdup(CBM_MEM_CLASS_STORE, sha); + rc = out->indexed_checkout_sha ? SQLITE_DONE : SQLITE_NOMEM; + } else { + rc = SQLITE_DONE; + } + } + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + store_set_error_sqlite(s, "index checkout read"); + cbm_store_coverage_meta_clear(out); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; +} + int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverage_meta_t *out) { if (!out) { return CBM_STORE_ERR; @@ -5267,7 +5360,7 @@ int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverag cbm_store_coverage_meta_clear(out); return CBM_STORE_ERR; } - return CBM_STORE_OK; + return index_checkout_read(s, project, out); } sqlite3_finalize(stmt); if (rc != SQLITE_DONE) { diff --git a/src/store/store.h b/src/store/store.h index eed6f746d..c3dc5613c 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -805,6 +805,11 @@ typedef struct { int coverage_version; bool hash_records_complete; bool unresolved_calls_complete; + /* Git checkout identity frozen at the successful staged-generation + * boundary (BT-240), stored in the index_checkout table. NULL/"" when the + * run had no git context (non-git repo) or the DB predates that table. + * Owned by the row: meta_get allocates it, meta_clear frees it. */ + const char *indexed_checkout_sha; } cbm_coverage_meta_t; /* Replace the project's coverage rows in one transaction, then prune rows for diff --git a/tests/repro/repro_lsp_go_py.c b/tests/repro/repro_lsp_go_py.c index fcb06903d..2b019dce9 100644 --- a/tests/repro/repro_lsp_go_py.c +++ b/tests/repro/repro_lsp_go_py.c @@ -64,8 +64,12 @@ * PYTHON STRATEGY INVENTORY — every literal "lsp_..." emitted by py_lsp.c * (grep '"lsp_' internal/cbm/lsp/py_lsp.c), with its keying site: * lsp_direct (py_lsp.c:1631) module-local f() - * lsp_constructor (py_lsp.c:1624) ClassName() where the name is a - * NAMED type in scope + * lsp_constructor ClassName() where the class declares no __init__ + * of its own (an inherited one is not followed) + * → the Class + * lsp_constructor_init ClassName() where the class declares its own + * __init__ → that __init__. The textual callee + * leaf stays the class name; reason joins it. * lsp_method (py_lsp.c:1731) obj.method() on a NAMED-typed * receiver (covers self.other()) * lsp_super (py_lsp.c:1693) super().method() resolved on a @@ -340,13 +344,24 @@ static const char kPyDirect[] = "def caller(v):\n" " return helper(v)\n"; -/* lsp_constructor — ClassName() where the name is a NAMED type in scope - * (py_lsp.c:1620-1624: cbm_scope_lookup yields a NAMED type → emit constructor - * edge to the class QN). */ +/* lsp_constructor — ClassName() where the class has no user __init__. + * The edge stays on the Class node. A class that defines __init__ is + * lsp_constructor_init below; strstr would otherwise treat that strategy + * as a hit for "lsp_constructor". */ static const char kPyConstructor[] = + "class Widget:\n" + " def label(self):\n" + " return 1\n" + "def caller():\n" + " return Widget()\n"; + +/* lsp_constructor_init — ClassName() resolves to the user __init__ + * (issue #1642). reason carries the class-name leaf so the pipeline join + * still matches the textual call. */ +static const char kPyConstructorInit[] = "class Widget:\n" " def __init__(self):\n" - " pass\n" + " self.ready = True\n" "def caller():\n" " return Widget()\n"; @@ -538,6 +553,10 @@ TEST(repro_lsp_py_constructor) { return assert_lsp_strategy("main.py", kPyConstructor, "lsp_constructor"); } +TEST(repro_lsp_py_constructor_init) { + return assert_lsp_strategy("main.py", kPyConstructorInit, "lsp_constructor_init"); +} + TEST(repro_lsp_py_method) { return assert_lsp_strategy("main.py", kPyMethod, "lsp_method"); } @@ -623,6 +642,7 @@ SUITE(repro_lsp_go_py) { RUN_TEST(repro_lsp_py_direct); RUN_TEST(repro_lsp_py_constructor); + RUN_TEST(repro_lsp_py_constructor_init); RUN_TEST(repro_lsp_py_method); RUN_TEST(repro_lsp_py_super); RUN_TEST(repro_lsp_py_super_init); diff --git a/tests/test_benchmark_runtime_isolation_contract.sh b/tests/test_benchmark_runtime_isolation_contract.sh new file mode 100755 index 000000000..3657910e3 --- /dev/null +++ b/tests/test_benchmark_runtime_isolation_contract.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Runtime-isolation contract for the benchmark harnesses (#1696, follow-up to +# #1691). +# +# scripts/benchmark-index.sh and scripts/benchmark-search-graph.sh ran the +# product with no runtime or cache of their own: the index landed in the +# operator's live store, every one-shot joined the operator's account daemon, +# and the timings depended on whatever that daemon was doing. Drive both with +# an environment-probe fixture and require that no product process ever +# receives the caller's runtime or cache, that the index benchmark records +# the setup cost it now pays explicitly, and that a refused index fails the +# run, names its cause, and leaves nothing behind. A daemon that refuses to +# start or to stop fails the run the same way, and a kept runtime is handed +# over only once its daemon stopped. + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +normalize_path() { + local path=${1%$'\r'} + if command -v cygpath >/dev/null 2>&1; then + cygpath -u "$path" 2>/dev/null && return 0 + fi + printf '%s\n' "${path//\\//}" +} + +ENV_PROBE="$WORKDIR/environment-probe" +cat > "$ENV_PROBE" <<'EOF' +#!/usr/bin/env bash +printf '%s\t%s\n' "${CBM_CACHE_DIR-}" "${CBM_RUNTIME_DIR-}" >> "$CBM_BENCH_ENV_PROBE" +if [[ "${1-} ${2-}" == "cli index_repository" ]]; then + [[ -z "${CBM_BENCH_REQUEST_LOG-}" ]] || printf '%s\n' "${3-}" >> "$CBM_BENCH_REQUEST_LOG" + if [[ -n "${CBM_BENCH_PROBE_INDEX_OK-}" ]]; then + printf '%s\n' '{"project":"probe"}' + exit 0 + fi + echo "probe: index refused" >&2 + exit 1 +fi +if [[ "${1-} ${2-}" == "daemon start" && -n "${CBM_BENCH_PROBE_START_REFUSED-}" ]]; then + echo "probe: daemon start refused" >&2 + exit 1 +fi +# The product reports a busy refusal (a committed client still uses the +# daemon) on stdout, so that is where the fixture reports its own. +if [[ "${1-} ${2-}" == "daemon stop" && -n "${CBM_BENCH_PROBE_STOP_REFUSED-}" ]]; then + echo "probe: daemon NOT stopped, a committed client still uses it" + exit 1 +fi +[[ "${1-} ${2-}" == "daemon status" ]] && exit 1 +exit 0 +EOF +chmod +x "$ENV_PROBE" + +CALLER_CACHE="$WORKDIR/caller-cache" +CALLER_RUNTIME="$WORKDIR/caller-runtime" +REPO="$WORKDIR/repo" +mkdir -p "$CALLER_CACHE" "$CALLER_RUNTIME" "$REPO" +echo 'def bench(): return 1' > "$REPO/bench.py" +CALLER_CACHE_NORMALIZED=$(normalize_path "$CALLER_CACHE") +CALLER_RUNTIME_NORMALIZED=$(normalize_path "$CALLER_RUNTIME") + +# The fixture answers nothing, so the search benchmark stops once the index +# reports no project; only the environment handed to the product is under test. +assert_isolated() { + local harness="$1" env_log="$2" private_root="" + [[ -s "$env_log" ]] || fail "$harness did not execute the environment-probe fixture" + while IFS=$'\t' read -r child_cache_raw child_runtime_raw; do + local child_cache child_runtime + child_cache=$(normalize_path "$child_cache_raw") + child_runtime=$(normalize_path "$child_runtime_raw") + if [[ -z "$child_runtime" || "$child_runtime" == "$CALLER_RUNTIME_NORMALIZED" ]]; then + fail "$harness exposed the caller CBM_RUNTIME_DIR to a product process" + fi + if [[ -z "$child_cache" || "$child_cache" == "$CALLER_CACHE_NORMALIZED" ]]; then + fail "$harness exposed the caller CBM_CACHE_DIR to a product process" + fi + if [[ "${child_runtime%/*}" != "${child_cache%/*}" || + "${child_runtime##*/}" != "runtime" || "${child_cache##*/}" != "cache" ]]; then + fail "$harness runtime/cache were not isolated beneath one private root" + fi + if [[ -n "$private_root" && "$private_root" != "${child_runtime%/*}" ]]; then + fail "$harness switched private roots mid-run" + fi + private_root="${child_runtime%/*}" + done < "$env_log" + [[ ! -e "$private_root" ]] || fail "$harness left its private root behind: $private_root" +} + +# The fixture refuses the index. That must fail the run — after the timing +# files are written, so the caller keeps its figures — and name its cause: the +# fixture's one stderr line has to reach the output. +INDEX_LOG="$WORKDIR/index-environment.log" +index_rc=0 +CBM_CACHE_DIR="$CALLER_CACHE" \ +CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ +CBM_BENCH_ENV_PROBE="$INDEX_LOG" \ + "$ROOT/scripts/benchmark-index.sh" "$ENV_PROBE" probe "$REPO" "$WORKDIR/results" \ + > "$WORKDIR/index.out" 2>&1 || index_rc=$? +[[ "$index_rc" -ne 0 ]] || fail "benchmark-index exited 0 after a refused index" +assert_isolated "benchmark-index" "$INDEX_LOG" +for metric in setup-time total-time index-time; do + [[ -s "$WORKDIR/results/probe/$metric.txt" ]] || + fail "benchmark-index did not record $metric.txt" +done +grep -q -- '--- index stderr ---' "$WORKDIR/index.out" || + fail "benchmark-index hid the index stderr behind its own message" +grep -q 'probe: index refused' "$WORKDIR/index.out" || + fail "benchmark-index did not surface the cause of the index failure" + +# The evaluation plan indexes a language and then reads that index from its own +# MCP session (docs/EVALUATION_PLAN.md §7). Asked to keep the runtime, a +# successful run must leave its root behind and record, sourceably, the paths +# that reach it — and they must be the paths the product processes actually +# used. Unasked, the root is gone (asserted above). The fixture answers this +# index with a minimal envelope, so what is kept is an index that succeeded. +KEEP_LOG="$WORKDIR/keep-environment.log" +keep_rc=0 +CBM_CACHE_DIR="$CALLER_CACHE" \ +CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ +CBM_BENCH_ENV_PROBE="$KEEP_LOG" \ +CBM_BENCH_PROBE_INDEX_OK=1 \ +CBM_BENCH_KEEP_RUNTIME=1 \ + "$ROOT/scripts/benchmark-index.sh" "$ENV_PROBE" keep "$REPO" "$WORKDIR/results" \ + > "$WORKDIR/keep.out" 2>&1 || keep_rc=$? +[[ "$keep_rc" -eq 0 ]] || fail "benchmark-index failed a run whose index succeeded (exit $keep_rc)" +KEPT_PROJECT=$(cat "$WORKDIR/results/keep/project.txt" 2>/dev/null || true) +[[ "${KEPT_PROJECT%$'\r'}" == "probe" ]] || + fail "benchmark-index did not record the project the index reported: '${KEPT_PROJECT:-}'" +HANDOFF="$WORKDIR/results/keep/runtime-root.txt" +[[ -s "$HANDOFF" ]] || fail "benchmark-index was asked to keep its runtime but recorded no handoff" +KEPT_ROOT=$(bash -c '. "$1" && printf "%s" "${CBM_BENCH_RUNTIME_ROOT-}"' _ "$HANDOFF") +KEPT_RUNTIME=$(bash -c '. "$1" && printf "%s" "${CBM_RUNTIME_DIR-}"' _ "$HANDOFF") +KEPT_CACHE=$(bash -c '. "$1" && printf "%s" "${CBM_CACHE_DIR-}"' _ "$HANDOFF") +[[ -n "$KEPT_ROOT" && -d "$KEPT_ROOT" && ! -L "$KEPT_ROOT" ]] || + fail "benchmark-index did not keep its runtime root: ${KEPT_ROOT:-}" +[[ -d "$KEPT_ROOT/cache" && -d "$KEPT_ROOT/runtime" ]] || + fail "the kept root $KEPT_ROOT lost its cache or runtime directory" +grep -qF -- "${KEPT_CACHE}"$'\t'"${KEPT_RUNTIME}" "$KEEP_LOG" || + fail "runtime-root.txt does not name the runtime and cache the product processes used" +rm -rf -- "$KEPT_ROOT" + +# The handoff promises a stopped daemon, so a kept runtime is handed over only +# once `daemon stop` succeeded. A refused stop fails the run, writes no handoff +# and keeps the root: the daemon may still be using it, so removing it is not +# the harness's call. The refusal itself has to reach the output. +STOP_LOG="$WORKDIR/keep-stop-refused-environment.log" +STOP_HANDOFF="$WORKDIR/results/keep-stop-refused/runtime-root.txt" +stop_rc=0 +CBM_CACHE_DIR="$CALLER_CACHE" \ +CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ +CBM_BENCH_ENV_PROBE="$STOP_LOG" \ +CBM_BENCH_PROBE_INDEX_OK=1 \ +CBM_BENCH_PROBE_STOP_REFUSED=1 \ +CBM_BENCH_KEEP_RUNTIME=1 \ + "$ROOT/scripts/benchmark-index.sh" "$ENV_PROBE" keep-stop-refused "$REPO" "$WORKDIR/results" \ + > "$WORKDIR/keep-stop-refused.out" 2>&1 || stop_rc=$? +[[ -s "$STOP_LOG" ]] || fail "benchmark-index (keep, refused stop) did not execute the environment-probe fixture" +STOP_RUNTIME=$(normalize_path "$(tail -n 1 "$STOP_LOG" | cut -f2)") +STOP_ROOT="${STOP_RUNTIME%/*}" +stop_root_kept=0 +[[ ! -d "$STOP_ROOT/runtime" ]] || stop_root_kept=1 +# Removed here, before any verdict, so a failing assertion leaks no root. +case "${STOP_ROOT##*/}" in +cbmrt.?* | cbmrt-?*) rm -rf -- "$STOP_ROOT" ;; +*) fail "benchmark-index (keep, refused stop) used an unexpected private root: $STOP_ROOT" ;; +esac +[[ "$stop_rc" -ne 0 ]] || fail "benchmark-index exited 0 although its daemon refused to stop" +[[ ! -e "$STOP_HANDOFF" ]] || fail "benchmark-index handed over a runtime whose daemon refused to stop" +(( stop_root_kept )) || fail "benchmark-index removed a runtime root whose daemon refused to stop" +grep -q 'probe: daemon NOT stopped' "$WORKDIR/keep-stop-refused.out" || + fail "benchmark-index did not surface why its daemon refused to stop" + +# Asked to keep the runtime and refused the index, the harness has nothing worth +# keeping: the run fails, no root survives, and no handoff is written — nor left +# over from an earlier run, since the evaluation loop reuses the results +# directory. +REFUSED_LOG="$WORKDIR/keep-refused-environment.log" +REFUSED_HANDOFF="$WORKDIR/results/keep-refused/runtime-root.txt" +mkdir -p "${REFUSED_HANDOFF%/*}" +echo 'CBM_BENCH_RUNTIME_ROOT=/stale/root/from/an/earlier/run' > "$REFUSED_HANDOFF" +refused_rc=0 +CBM_CACHE_DIR="$CALLER_CACHE" \ +CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ +CBM_BENCH_ENV_PROBE="$REFUSED_LOG" \ +CBM_BENCH_KEEP_RUNTIME=1 \ + "$ROOT/scripts/benchmark-index.sh" "$ENV_PROBE" keep-refused "$REPO" "$WORKDIR/results" \ + > "$WORKDIR/keep-refused.out" 2>&1 || refused_rc=$? +[[ "$refused_rc" -ne 0 ]] || fail "benchmark-index exited 0 after a refused index it was asked to keep" +for metric in setup-time total-time index-time; do + [[ -s "$WORKDIR/results/keep-refused/$metric.txt" ]] || + fail "benchmark-index did not record $metric.txt for a refused index" +done +assert_isolated "benchmark-index (keep, refused index)" "$REFUSED_LOG" +[[ ! -e "$REFUSED_HANDOFF" ]] || fail "benchmark-index left a handoff for an index that failed" + +# The same holds for a run that never reaches the index: one whose repository +# does not exist fails during setup, and an earlier run's handoff in the reused +# results directory must already be gone by then. +SETUP_HANDOFF="$WORKDIR/results/setup-failed/runtime-root.txt" +mkdir -p "${SETUP_HANDOFF%/*}" +echo 'CBM_BENCH_RUNTIME_ROOT=/stale/root/from/an/earlier/run' > "$SETUP_HANDOFF" +setup_rc=0 +CBM_CACHE_DIR="$CALLER_CACHE" \ +CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ +CBM_BENCH_ENV_PROBE="$WORKDIR/setup-failed-environment.log" \ +CBM_BENCH_KEEP_RUNTIME=1 \ + "$ROOT/scripts/benchmark-index.sh" "$ENV_PROBE" setup-failed "$WORKDIR/no-such-repo" "$WORKDIR/results" \ + > "$WORKDIR/setup-failed.out" 2>&1 || setup_rc=$? +[[ "$setup_rc" -ne 0 ]] || fail "benchmark-index exited 0 for a repository that does not exist" +[[ ! -e "$SETUP_HANDOFF" ]] || + fail "benchmark-index left an earlier handoff behind after a run that failed during setup" + +SEARCH_LOG="$WORKDIR/search-environment.log" +CBM_CACHE_DIR="$CALLER_CACHE" \ +CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ +CBM_BENCH_ENV_PROBE="$SEARCH_LOG" \ + "$ROOT/scripts/benchmark-search-graph.sh" "$ENV_PROBE" "$REPO" \ + > "$WORKDIR/search.out" 2>&1 || true +assert_isolated "benchmark-search-graph" "$SEARCH_LOG" + +# A refused index must name its cause. The fixture writes one line to stderr and +# exits non-zero; discarding it leaves "did not report a project" as the only +# thing an operator sees. +grep -q -- '--- index/parse stderr ---' "$WORKDIR/search.out" || + fail "benchmark-search-graph hid the index stderr behind its own message" +grep -q 'probe: index refused' "$WORKDIR/search.out" || + fail "benchmark-search-graph did not surface the cause of the index failure" + +# A private daemon that does not start fails either harness, and the product's +# own explanation has to reach the output: "did not start" alone leaves the +# operator with the symptom only. +for harness in benchmark-index benchmark-search-graph; do + if [[ "$harness" == benchmark-index ]]; then + harness_args=("$ENV_PROBE" start-refused "$REPO" "$WORKDIR/results") + else + harness_args=("$ENV_PROBE" "$REPO") + fi + start_rc=0 + CBM_CACHE_DIR="$CALLER_CACHE" \ + CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ + CBM_BENCH_ENV_PROBE="$WORKDIR/$harness-start-refused-environment.log" \ + CBM_BENCH_PROBE_START_REFUSED=1 \ + "$ROOT/scripts/$harness.sh" "${harness_args[@]}" \ + > "$WORKDIR/$harness-start-refused.out" 2>&1 || start_rc=$? + [[ "$start_rc" -ne 0 ]] || fail "$harness exited 0 although its private daemon did not start" + assert_isolated "$harness (daemon start refused)" "$WORKDIR/$harness-start-refused-environment.log" + grep -q 'probe: daemon start refused' "$WORKDIR/$harness-start-refused.out" || + fail "$harness did not surface why its private daemon did not start" +done + +# The request carrying the repository path has to be built as JSON. A path may +# legitimately contain a quote or a backslash — hand-built JSON turns that into +# a payload the server cannot parse, or one that means something else. NTFS +# rejects both characters in a path component, so this case is POSIX-only. +case "$(uname -s)" in +MINGW* | MSYS* | CYGWIN*) ;; +*) + QUIRKY_REPO="$WORKDIR/re\"po\\dir" + mkdir -p "$QUIRKY_REPO" + echo 'def bench(): return 1' > "$QUIRKY_REPO/bench.py" + QUIRKY_RESOLVED=$(cd "$QUIRKY_REPO" && pwd -P) + REQUEST_LOG="$WORKDIR/requests.log" + CBM_CACHE_DIR="$CALLER_CACHE" \ + CBM_RUNTIME_DIR="$CALLER_RUNTIME" \ + CBM_BENCH_ENV_PROBE="$WORKDIR/quirky-environment.log" \ + CBM_BENCH_REQUEST_LOG="$REQUEST_LOG" \ + "$ROOT/scripts/benchmark-search-graph.sh" "$ENV_PROBE" "$QUIRKY_REPO" \ + > "$WORKDIR/quirky.out" 2>&1 || true + python3 - "$REQUEST_LOG" "$QUIRKY_RESOLVED" <<'PY' || fail "benchmark-search-graph built an index request that is not valid JSON for a quoted path" +import json +import sys + +request_log, expected = sys.argv[1], sys.argv[2] +try: + lines = [line for line in open(request_log).read().splitlines() if line.strip()] +except OSError: + sys.exit("the search benchmark sent no index request") +if not lines: + sys.exit("the search benchmark sent no index request") +payload = json.loads(lines[0]) +if payload.get("repo_path") != expected: + sys.exit(f"repo_path is {payload.get('repo_path')!r}, expected {expected!r}") +PY + ;; +esac + +echo "PASS: benchmark harnesses isolate their daemon runtime and cache from the caller" diff --git a/tests/test_cli.c b/tests/test_cli.c index f716418ad..15255ff84 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -9449,6 +9450,143 @@ TEST(cli_hermes_stable_shell_context_contract) { PASS(); } +/* A fake HOME holding only ~/.hermes/config.yaml, so install and uninstall + * reach exactly the Hermes editors. */ +typedef struct { + char tmpdir[256]; + char config_path[640]; + char binary_path[640]; + char *saved_home; + char *saved_path; + char *saved_hermes; +} cli_hermes_cycle_t; + +static bool cli_hermes_cycle_open(cli_hermes_cycle_t *cycle, const char *initial_config) { + memset(cycle, 0, sizeof(*cycle)); + snprintf(cycle->tmpdir, sizeof(cycle->tmpdir), "/tmp/cli-hermes-cycle-XXXXXX"); + if (!cbm_mkdtemp(cycle->tmpdir)) { + cycle->tmpdir[0] = '\0'; + return false; + } + cycle->saved_home = save_test_env("HOME"); + cycle->saved_path = save_test_env("PATH"); + cycle->saved_hermes = save_test_env("HERMES_HOME"); + cbm_setenv("HOME", cycle->tmpdir, 1); + cbm_setenv("PATH", cycle->tmpdir, 1); + cbm_unsetenv("HERMES_HOME"); + + char hermes_dir[512]; + snprintf(hermes_dir, sizeof(hermes_dir), "%s/.hermes", cycle->tmpdir); + snprintf(cycle->config_path, sizeof(cycle->config_path), "%s/config.yaml", hermes_dir); +#ifdef _WIN32 + snprintf(cycle->binary_path, sizeof(cycle->binary_path), + "%s/.local/bin/codebase-memory-mcp.exe", cycle->tmpdir); +#else + snprintf(cycle->binary_path, sizeof(cycle->binary_path), "%s/.local/bin/codebase-memory-mcp", + cycle->tmpdir); +#endif + test_mkdirp(hermes_dir); + return write_test_file(cycle->config_path, initial_config) == 0; +} + +static void cli_hermes_cycle_close(cli_hermes_cycle_t *cycle) { + if (!cycle->tmpdir[0]) { + return; + } + restore_test_env("HOME", cycle->saved_home); + restore_test_env("PATH", cycle->saved_path); + restore_test_env("HERMES_HOME", cycle->saved_hermes); + test_rmdir_r(cycle->tmpdir); + cycle->tmpdir[0] = '\0'; +} + +/* Found while finishing #1862: when our hook was the only entry under + * `hooks:`, uninstall empties that mapping and the mapping editor writes it as + * `hooks: {}` (the same convention as `mcp_servers: {}`). The hook sequence + * editor then refused the file, so install -> uninstall -> install exited 1, + * and so did a repeated uninstall. The explicit empty mapping must be accepted: + * a repeated uninstall changes nothing and the reinstall restores the first + * install byte for byte, with exactly one owned hook. */ +TEST(cli_hermes_hook_reinstalls_after_uninstall_empties_hooks) { + cli_hermes_cycle_t cycle; + if (!cli_hermes_cycle_open(&cycle, "theme: solarized\n")) { + cli_hermes_cycle_close(&cycle); + FAIL("Hermes fake-HOME setup failed"); + } + char *argv[] = {"uninstall", "--yes"}; + int install_rc = cbm_install_agent_configs(cycle.tmpdir, cycle.binary_path, false, false); + char *installed = read_test_file_alloc(cycle.config_path); + int uninstall_rc = cli_test_cmd_uninstall(2, argv); + char *emptied = read_test_file_alloc(cycle.config_path); + int repeat_rc = cli_test_cmd_uninstall(2, argv); + char *repeated = read_test_file_alloc(cycle.config_path); + int reinstall_rc = cbm_install_agent_configs(cycle.tmpdir, cycle.binary_path, false, false); + char *reinstalled = read_test_file_alloc(cycle.config_path); + cli_hermes_cycle_close(&cycle); + + bool installed_once = install_rc == 0 && installed && + test_count_substring(installed, "pre_llm_call:") == 1U && + test_count_substring(installed, "hook-augment") == 1U; + bool emptied_clean = uninstall_rc == 0 && emptied && strstr(emptied, "theme: solarized\n") && + test_count_substring(emptied, "hooks: {}\n") == 1U && + !strstr(emptied, "pre_llm_call") && !strstr(emptied, "hook-augment"); + bool repeat_unchanged = repeat_rc == 0 && emptied && repeated && strcmp(repeated, emptied) == 0; + bool reinstalled_once = + reinstall_rc == 0 && installed && reinstalled && strcmp(reinstalled, installed) == 0; + free(installed); + free(emptied); + free(repeated); + free(reinstalled); + if (!installed_once) + FAIL("Hermes install must write exactly one owned pre_llm_call hook"); + if (!emptied_clean) + FAIL("uninstall must remove the owned hook and leave only the empty hooks: {} mapping"); + if (!repeat_unchanged) + FAIL("a repeated uninstall must accept hooks: {} and leave the file unchanged"); + if (!reinstalled_once) + FAIL("reinstall must accept hooks: {} and restore exactly one owned hook"); + PASS(); +} + +/* The same cycle with a user's own hook in the shared pre_llm_call sequence: + * the foreign hook survives untouched and uninstall restores the original + * file byte for byte. */ +TEST(cli_hermes_hook_cycle_keeps_foreign_pre_llm_hook) { + static const char original[] = "theme: solarized\n" + "mcp_servers: {}\n" + "hooks:\n" + " pre_llm_call:\n" + " - command: \"/usr/bin/user-hermes-hook\"\n"; + cli_hermes_cycle_t cycle; + if (!cli_hermes_cycle_open(&cycle, original)) { + cli_hermes_cycle_close(&cycle); + FAIL("Hermes fake-HOME setup failed"); + } + char *argv[] = {"uninstall", "--yes"}; + int install_rc = cbm_install_agent_configs(cycle.tmpdir, cycle.binary_path, false, false); + char *installed = read_test_file_alloc(cycle.config_path); + int uninstall_rc = cli_test_cmd_uninstall(2, argv); + char *restored = read_test_file_alloc(cycle.config_path); + int reinstall_rc = cbm_install_agent_configs(cycle.tmpdir, cycle.binary_path, false, false); + char *reinstalled = read_test_file_alloc(cycle.config_path); + cli_hermes_cycle_close(&cycle); + + bool merged = install_rc == 0 && installed && + test_count_substring(installed, "/usr/bin/user-hermes-hook") == 1U && + test_count_substring(installed, "pre_llm_call:") == 1U && + test_count_substring(installed, "hook-augment") == 1U; + bool restored_exactly = uninstall_rc == 0 && restored && strcmp(restored, original) == 0; + bool reinstalled_once = + reinstall_rc == 0 && installed && reinstalled && strcmp(reinstalled, installed) == 0; + free(installed); + free(restored); + free(reinstalled); + if (!merged || !restored_exactly || !reinstalled_once) + FAIL("a user's own pre_llm_call hook must survive install, uninstall and reinstall, and " + "uninstall must restore the original file byte for byte"); + PASS(); +} + #ifndef _WIN32 TEST(cli_detected_agent_summary_includes_registry_clients) { char tmpdir[256]; @@ -12677,9 +12815,91 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", wtdir); char *wt_out = cbm_hook_augment_lifecycle_json(input); bool deliberate = wt_out && wt_out[0] && strstr(wt_out, "no indexed graph project matched") && - !strstr(wt_out, "is indexed"); + strstr(wt_out, "Run index_repository") && + !strstr(wt_out, "ignore_worktrees is enabled") && !strstr(wt_out, "is indexed"); free(wt_out); + /* The hook must read the runtime store. With the key off the note still + * says to index; with it on, the same unindexed worktree must not. */ + cbm_config_t *cfg = cbm_config_open(cache); + bool configured = cfg && cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0; + cbm_config_close(cfg); + char *ignored_out = configured ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool honored = ignored_out && strstr(ignored_out, "ignore_worktrees is enabled") && + strstr(ignored_out, "do not run index_repository") && + !strstr(ignored_out, "is indexed"); + free(ignored_out); + + /* A symlink to a directory inside that worktree has no lexical .git. + * The hook must still follow the real path and honor ignore_worktrees. */ + char alias_target[600]; + snprintf(alias_target, sizeof(alias_target), "%s/nested", wtdir); + bool alias_target_ok = test_mkdirp(alias_target) == 0; + char alias[512]; + snprintf(alias, sizeof(alias), "%s/wt-alias", tmpdir); + bool alias_made = alias_target_ok && symlink(alias_target, alias) == 0; + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", + alias); + char *alias_out = alias_made ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool alias_ignored = alias_out && strstr(alias_out, "ignore_worktrees is enabled") && + strstr(alias_out, "do not run index_repository") && + !strstr(alias_out, "Run index_repository"); + free(alias_out); + + /* A linked worktree nested inside the indexed checkout must not inherit + * that parent graph once ignore_worktrees is on. */ + char insider[512]; + snprintf(insider, sizeof(insider), "%s/inside-wt", maindir); + snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree add -q \"%s\" -b agl7inside", maindir, + insider); + bool inside_made = configured && system(cmd) == 0; + char inside_sub[600]; + snprintf(inside_sub, sizeof(inside_sub), "%s/nested", insider); + bool inside_sub_made = inside_made && test_mkdirp(inside_sub) == 0; + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", + inside_sub); + char *inside_out = inside_sub_made ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool inside_ignored = inside_out && strstr(inside_out, "ignore_worktrees is enabled") && + strstr(inside_out, "do not run index_repository") && + !strstr(inside_out, "is indexed") && !strstr(inside_out, main_name); + free(inside_out); + + /* Indexing that nested worktree itself must still win over the parent. */ + char *inside_name = inside_made ? cbm_project_name_from_path(insider) : NULL; + bool inside_stored = false; + if (inside_name) { + char db_inside[900]; + snprintf(db_inside, sizeof(db_inside), "%s/%s.db", cache, inside_name); + cbm_store_t *si = cbm_store_open_path(db_inside); + inside_stored = si && cbm_store_upsert_project(si, inside_name, insider) == CBM_STORE_OK; + if (si) + cbm_store_close(si); + } + char *exact_out = inside_stored ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool exact_kept = exact_out && inside_name && strstr(exact_out, inside_name) && + strstr(exact_out, "is indexed") && + !strstr(exact_out, "ignore_worktrees is enabled"); + free(exact_out); + + /* An unindexed worktree nested inside that indexed worktree must not + * inherit the outer worktree's graph. */ + char child[600]; + snprintf(child, sizeof(child), "%s/child-wt", insider); + snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree add -q \"%s\" -b agl7child", maindir, + child); + bool child_made = inside_stored && system(cmd) == 0; + char child_sub[700]; + snprintf(child_sub, sizeof(child_sub), "%s/sub", child); + bool child_sub_made = child_made && test_mkdirp(child_sub) == 0; + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", + child_sub); + char *child_out = child_sub_made ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool child_ignored = child_out && inside_name && + strstr(child_out, "ignore_worktrees is enabled") && + strstr(child_out, "do not run index_repository") && + !strstr(child_out, "is indexed") && !strstr(child_out, inside_name); + free(child_out); + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", maindir); char *main_out = cbm_hook_augment_lifecycle_json(input); @@ -12688,6 +12908,7 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { restore_test_env("CBM_CACHE_DIR", saved_cache); free(main_name); + free(inside_name); snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree remove --force \"%s\" >/dev/null 2>&1", maindir, wtdir); (void)system(cmd); @@ -12695,10 +12916,110 @@ TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent) { if (!deliberate) FAIL("unindexed worktree must get a deliberate no-match notice, never a silent 0 bytes"); + if (!honored) + FAIL("ignore_worktrees in the runtime config must change the unindexed-worktree note"); + if (!alias_ignored) + FAIL("a symlink into an ignored worktree must not recommend index_repository"); + if (!inside_ignored) + FAIL("a nested linked worktree must not inherit the indexed parent graph"); + if (!exact_kept) + FAIL("an exact index of the nested worktree must still be reported"); + if (!child_ignored) + FAIL("a worktree nested inside an indexed worktree must not inherit that graph"); if (!main_ok) FAIL("the indexed main checkout must still resolve in the same matrix"); PASS(); } + +/* With ignore_worktrees unset, the lifecycle hook must do what it did before + * the key existed. It may read the key, but it must not create a runtime + * config store just to find the key unset, and it must not canonicalize the + * cwd or probe for a linked worktree. Turning the key on is the positive + * control: the same call then reaches the worktree gate exactly once. */ +TEST(cli_hook_ignore_worktrees_default_off_does_no_worktree_work) { + char tmpdir[256]; + char *raw = th_mktempdir("cbm_hook_wt_off"); + if (!raw) + FAIL("th_mktempdir returned NULL"); + snprintf(tmpdir, sizeof(tmpdir), "%s", raw); + char cache[512]; + char maindir[512]; + char wtdir[512]; + char store_path[600]; + snprintf(cache, sizeof(cache), "%s/cache", tmpdir); + snprintf(maindir, sizeof(maindir), "%s/checkout-main", tmpdir); + snprintf(wtdir, sizeof(wtdir), "%s/checkout-side", tmpdir); + snprintf(store_path, sizeof(store_path), "%s/" CBM_CONFIG_DB_FILENAME, cache); + test_mkdirp(cache); + test_mkdirp(maindir); + + char cmd[2048]; + snprintf(cmd, sizeof(cmd), + "cd \"%s\" && git init -q && git config user.email t@t && git config user.name t && " + ": > f.txt && git add f.txt && git commit -qm init && " + "git worktree add -q \"%s\" -b wtoff", + maindir, wtdir); + if (system(cmd) != 0) { + th_rmtree(tmpdir); + SKIP_PLATFORM("git worktree add unavailable"); + } + + char *saved_cache = save_test_env("CBM_CACHE_DIR"); + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char input[1024]; + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", wtdir); + struct stat st; + int gate_calls = 0; + cbm_hook_set_worktree_gate_counter_for_testing(&gate_calls); + bool store_absent_before = stat(store_path, &st) != 0; + char *off_out = cbm_hook_augment_lifecycle_json(input); + bool off_note = off_out && strstr(off_out, "Run index_repository") && + !strstr(off_out, "ignore_worktrees is enabled"); + free(off_out); + bool store_absent_after = stat(store_path, &st) != 0; + int off_gate_calls = gate_calls; + + /* A store that leaves the key unset may be read for the key, nothing more. */ + cbm_config_t *cfg = cbm_config_open(cache); + bool other_key_set = cfg && cbm_config_set(cfg, CBM_CONFIG_AUTO_WATCH, "true") == 0; + cbm_config_close(cfg); + gate_calls = 0; + char *unset_out = other_key_set ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool unset_note = unset_out && strstr(unset_out, "Run index_repository") && + !strstr(unset_out, "ignore_worktrees is enabled"); + free(unset_out); + int unset_gate_calls = gate_calls; + + cfg = cbm_config_open(cache); + bool enabled = cfg && cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0; + cbm_config_close(cfg); + gate_calls = 0; + char *on_out = enabled ? cbm_hook_augment_lifecycle_json(input) : NULL; + bool on_note = on_out && strstr(on_out, "ignore_worktrees is enabled"); + free(on_out); + int on_gate_calls = gate_calls; + cbm_hook_set_worktree_gate_counter_for_testing(NULL); + + restore_test_env("CBM_CACHE_DIR", saved_cache); + snprintf(cmd, sizeof(cmd), "cd \"%s\" && git worktree remove --force \"%s\" >/dev/null 2>&1", + maindir, wtdir); + (void)system(cmd); + th_rmtree(tmpdir); + + ASSERT_TRUE(store_absent_before); + ASSERT_TRUE(off_note); + if (!store_absent_after) + FAIL("a default-off lifecycle event must not create the runtime config store"); + ASSERT_EQ(off_gate_calls, 0); + ASSERT_TRUE(other_key_set); + ASSERT_TRUE(unset_note); + ASSERT_EQ(unset_gate_calls, 0); + ASSERT_TRUE(enabled); + ASSERT_TRUE(on_note); + ASSERT_EQ(on_gate_calls, 1); + PASS(); +} #endif TEST(cli_hook_session_sanitizes_untrusted_project_metadata) { @@ -15172,14 +15493,18 @@ TEST(cli_hook_augment_subagent_tier_router_contract) { } TEST(cli_hook_augment_subagent_no_project_guidance_is_read_only) { - const char *session = cbm_hook_no_project_index_guidance_for_testing("SessionStart"); - const char *subagent = cbm_hook_no_project_index_guidance_for_testing("SubagentStart"); + const char *session = cbm_hook_no_project_index_guidance_for_testing("SessionStart", false); + const char *subagent = cbm_hook_no_project_index_guidance_for_testing("SubagentStart", false); + const char *worktree = cbm_hook_no_project_index_guidance_for_testing("SessionStart", true); ASSERT_NOT_NULL(session); ASSERT_NOT_NULL(subagent); + ASSERT_NOT_NULL(worktree); ASSERT(strstr(session, "Run index_repository") != NULL); ASSERT(strstr(subagent, "Ask the parent agent to run index_repository") != NULL); ASSERT(strstr(subagent, "do not attempt graph mutation") != NULL); ASSERT(strstr(subagent, "Run index_repository") == NULL); + ASSERT(strstr(worktree, "ignore_worktrees is enabled") != NULL); + ASSERT(strstr(worktree, "do not run index_repository") != NULL); PASS(); } @@ -19282,6 +19607,19 @@ TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558) { PASS(); } +TEST(cli_ignore_worktrees_config_key_is_discoverable) { + bool listed = false; + for (size_t i = 0; i < cbm_cli_config_key_count_for_testing(); i++) { + const char *key = cbm_cli_config_key_at_for_testing(i); + if (key && strcmp(key, CBM_CONFIG_IGNORE_WORKTREES) == 0) { + listed = true; + break; + } + } + ASSERT_TRUE(listed); + PASS(); +} + TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554) { const cbm_skill_t *sk = cbm_get_skills(); ASSERT_NOT_NULL(sk); @@ -20165,6 +20503,7 @@ SUITE(cli) { RUN_TEST(cli_update_download_failure_does_not_quiesce_sessions); RUN_TEST(cli_update_already_current_does_not_quiesce_sessions); RUN_TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558); + RUN_TEST(cli_ignore_worktrees_config_key_is_discoverable); RUN_TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554); RUN_TEST(cli_external_manager_detection_needs_positive_evidence_issue1566); RUN_TEST(cli_install_managed_binary_config_path_issue2722); @@ -20351,6 +20690,8 @@ SUITE(cli) { RUN_TEST(cli_junie_current_durable_context_contract); RUN_TEST(cli_rovo_installs_documented_global_memory); RUN_TEST(cli_hermes_stable_shell_context_contract); + RUN_TEST(cli_hermes_hook_reinstalls_after_uninstall_empties_hooks); + RUN_TEST(cli_hermes_hook_cycle_keeps_foreign_pre_llm_hook); #ifndef _WIN32 RUN_TEST(cli_detected_agent_summary_includes_registry_clients); #endif @@ -20405,6 +20746,7 @@ SUITE(cli) { #ifndef _WIN32 RUN_TEST(cli_hook_worktree_cwd_resolves_own_indexed_project); RUN_TEST(cli_hook_unindexed_worktree_reports_no_match_not_silent); + RUN_TEST(cli_hook_ignore_worktrees_default_off_does_no_worktree_work); #endif RUN_TEST(cli_hook_session_sanitizes_untrusted_project_metadata); RUN_TEST(cli_hook_session_untrusted_label_covers_project_name_only); diff --git a/tests/test_config_yaml_edit.c b/tests/test_config_yaml_edit.c index 9efaa2e38..95bc89404 100644 --- a/tests/test_config_yaml_edit.c +++ b/tests/test_config_yaml_edit.c @@ -1998,6 +1998,129 @@ TEST(config_yaml_edit_nested_sequence_removes_only_exact_canonical_item) { PASS(); } +/* Found while finishing #1862: cbm_yaml_remove_mapping_entry writes a section + * it empties as `section: {}`, and the Hermes remover empties `hooks:` whenever + * our hook was its only entry. The sequence editor refused that explicit empty + * parent mapping, so the next install (and a repeated removal) failed. The + * primitives' own round trip must land back on the first insert. */ +TEST(config_yaml_edit_nested_sequence_reinserts_under_emptied_parent) { + const char *initial = "theme: x\n"; + yaml_fixture_t fixture; + ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0); + ASSERT_EQ(cbm_yaml_upsert_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, "id", + yaml_hook_identity, yaml_hook_canonical_item), + CBM_YAML_IDENTITY_EDIT_OK); + char *first = yaml_read_alloc(fixture.path); + ASSERT_NOT_NULL(first); + ASSERT_EQ(cbm_yaml_remove_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, "id", + yaml_hook_identity, yaml_hook_canonical_item), + CBM_YAML_IDENTITY_EDIT_OK); + ASSERT_EQ(cbm_yaml_remove_mapping_entry(fixture.path, "hooks", "pre_llm_call"), 0); + char *emptied = yaml_read_alloc(fixture.path); + ASSERT_NOT_NULL(emptied); + ASSERT_STR_EQ(emptied, "theme: x\nhooks: {}\n"); + free(emptied); + ASSERT_EQ(cbm_yaml_remove_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, "id", + yaml_hook_identity, yaml_hook_canonical_item), + CBM_YAML_IDENTITY_EDIT_OK); + ASSERT_EQ(cbm_yaml_upsert_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, "id", + yaml_hook_identity, yaml_hook_canonical_item), + CBM_YAML_IDENTITY_EDIT_OK); + char *again = yaml_read_alloc(fixture.path); + ASSERT_NOT_NULL(again); + ASSERT_STR_EQ(again, first); + free(again); + free(first); + th_cleanup(fixture.dir); + PASS(); +} + +/* An exact empty flow mapping on a PARENT of the sequence path is an empty + * mapping, the same `section: {}` the mapping-entry editor writes and accepts. + * Removal is a byte-identical no-op; upsert expands the header in place, + * keeping its comment, the line endings and every other byte. */ +TEST(config_yaml_edit_nested_sequence_expands_explicit_empty_parent_mapping) { + static const char item_lf[] = " pre_llm_call:\n" + " - id: \"codebase-memory-mcp\"\n" + " type: \"command\"\n" + " command: \"/opt/Codebase Memory/bin/cbm\"\n"; + static const char item_crlf[] = " pre_llm_call:\r\n" + " - id: \"codebase-memory-mcp\"\r\n" + " type: \"command\"\r\n" + " command: \"/opt/Codebase Memory/bin/cbm\"\r\n"; + struct { + const char *initial; + const char *header; + const char *item; + const char *tail; + } cases[] = { + {"theme: x\nhooks: {} # keep me\nafter: 1\n", "theme: x\nhooks: # keep me\n", item_lf, + "after: 1\n"}, + {"theme: x\r\nhooks: {}\r\nafter: 1\r\n", "theme: x\r\nhooks:\r\n", item_crlf, + "after: 1\r\n"}, + {"theme: x\nhooks: {}", "theme: x\nhooks:\n", item_lf, ""}, + {"hooks: {}\n# trailing note\n", "hooks:\n# trailing note\n", item_lf, ""}, + }; + for (size_t i = 0U; i < sizeof(cases) / sizeof(cases[0]); i++) { + char expected[1024]; + ASSERT(snprintf(expected, sizeof(expected), "%s%s%s", cases[i].header, cases[i].item, + cases[i].tail) > 0); + yaml_fixture_t fixture; + ASSERT_EQ(yaml_fixture_init(&fixture, cases[i].initial), 0); + ASSERT_EQ(cbm_yaml_remove_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, + "id", yaml_hook_identity, + yaml_hook_canonical_item), + CBM_YAML_IDENTITY_EDIT_OK); + char *unchanged = yaml_read_alloc(fixture.path); + ASSERT_NOT_NULL(unchanged); + ASSERT_STR_EQ(unchanged, cases[i].initial); + free(unchanged); + for (int pass = 0; pass < 2; pass++) { + ASSERT_EQ(cbm_yaml_upsert_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, + 2U, "id", yaml_hook_identity, + yaml_hook_canonical_item), + CBM_YAML_IDENTITY_EDIT_OK); + char *expanded = yaml_read_alloc(fixture.path); + ASSERT_NOT_NULL(expanded); + ASSERT_STR_EQ(expanded, expected); + free(expanded); + } + th_cleanup(fixture.dir); + } + PASS(); +} + +/* Only an exact `{}` on a parent with nothing nested beneath it is accepted. + * The sequence key itself holding a mapping, a non-empty flow mapping, a flow + * sequence where a mapping belongs, and content indented under an inline `{}` + * stay refused byte-identically by both upsert and removal. */ +TEST(config_yaml_edit_nested_sequence_still_refuses_non_empty_parent_flow) { + const char *cases[] = { + "hooks:\n pre_llm_call: {}\n", + "hooks: {pre_llm_call: []}\n", + "hooks: []\n", + "hooks: {}\n pre_llm_call:\n - id: \"codebase-memory-mcp\"\n", + }; + for (size_t i = 0U; i < sizeof(cases) / sizeof(cases[0]); i++) { + yaml_fixture_t fixture; + ASSERT_EQ(yaml_fixture_init(&fixture, cases[i]), 0); + ASSERT_EQ(cbm_yaml_upsert_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, + "id", yaml_hook_identity, + yaml_hook_canonical_item), + CBM_YAML_IDENTITY_EDIT_ERROR); + ASSERT_EQ(cbm_yaml_remove_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, + "id", yaml_hook_identity, + yaml_hook_canonical_item), + CBM_YAML_IDENTITY_EDIT_ERROR); + char *after = yaml_read_alloc(fixture.path); + ASSERT_NOT_NULL(after); + ASSERT_STR_EQ(after, cases[i]); + free(after); + th_cleanup(fixture.dir); + } + PASS(); +} + /* Uninstall must be idempotent against a config that was never written. Hermes * is detected by its binary on PATH, so a HOME without `.hermes/` reached the * YAML removers with an absent target — and they failed acquiring the lock @@ -2245,6 +2368,9 @@ SUITE(config_yaml_edit) { RUN_TEST(config_yaml_edit_nested_sequence_preserves_crlf); RUN_TEST(config_yaml_edit_nested_sequence_foreign_identity_is_preserved); RUN_TEST(config_yaml_edit_nested_sequence_removes_only_exact_canonical_item); + RUN_TEST(config_yaml_edit_nested_sequence_reinserts_under_emptied_parent); + RUN_TEST(config_yaml_edit_nested_sequence_expands_explicit_empty_parent_mapping); + RUN_TEST(config_yaml_edit_nested_sequence_still_refuses_non_empty_parent_flow); RUN_TEST(config_yaml_edit_nested_sequence_ignores_foreign_flow_and_block_scalars_issue2209); RUN_TEST(config_yaml_edit_nested_sequence_ambiguity_fails_byte_identically); RUN_TEST(config_yaml_edit_remove_on_absent_config_is_a_no_op); diff --git a/tests/test_daemon_frontend.c b/tests/test_daemon_frontend.c index b91705f15..aa49bf4c7 100644 --- a/tests/test_daemon_frontend.c +++ b/tests/test_daemon_frontend.c @@ -4,6 +4,7 @@ #include "test_framework.h" #include "test_helpers.h" +#include "daemon/application.h" #include "daemon/frontend.h" #include "daemon/ipc.h" #include "daemon/service.h" @@ -38,6 +39,8 @@ typedef struct { typedef struct { frontend_maintenance_fixture_t maintenance; char conflict_log[FRONTEND_TEST_PATH_CAP]; + char build_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + cbm_daemon_build_identity_t identity; cbm_daemon_runtime_service_t *service; cbm_daemon_runtime_client_t *client; } frontend_idle_fixture_t; @@ -45,6 +48,7 @@ typedef struct { typedef struct { cbm_daemon_runtime_client_t *client; cbm_version_cohort_manager_t *manager; + const cbm_daemon_frontend_session_config_t *session; FILE *input; FILE *output; int result; @@ -98,22 +102,22 @@ static cbm_daemon_runtime_application_status_t frontend_idle_request( return CBM_DAEMON_RUNTIME_APPLICATION_OK; } -static void frontend_idle_request_cancel( - void *context, cbm_daemon_runtime_application_session_t *session, - cbm_daemon_runtime_application_token_t request_token) { +static void frontend_idle_request_cancel(void *context, + cbm_daemon_runtime_application_session_t *session, + cbm_daemon_runtime_application_token_t request_token) { (void)context; (void)session; (void)request_token; } -static void frontend_idle_session_cancel( - void *context, cbm_daemon_runtime_application_session_t *session) { +static void frontend_idle_session_cancel(void *context, + cbm_daemon_runtime_application_session_t *session) { (void)context; (void)session; } -static void frontend_idle_session_close( - void *context, cbm_daemon_runtime_application_session_t *session) { +static void frontend_idle_session_close(void *context, + cbm_daemon_runtime_application_session_t *session) { (void)context; (void)session; } @@ -127,22 +131,21 @@ static uint64_t frontend_test_process_id(void) { } static bool frontend_idle_fixture_start(frontend_idle_fixture_t *fixture) { - static const char cache[] = - "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + static const char cache[] = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; memset(fixture, 0, sizeof(*fixture)); if (!frontend_maintenance_fixture_start(&fixture->maintenance, "idle-observer")) { return false; } - char build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; int log_written = snprintf(fixture->conflict_log, sizeof(fixture->conflict_log), "%s/conflicts.ndjson", fixture->maintenance.parent); if (log_written <= 0 || log_written >= (int)sizeof(fixture->conflict_log) || - !cbm_daemon_runtime_process_build_fingerprint(frontend_test_process_id(), build)) { + !cbm_daemon_runtime_process_build_fingerprint(frontend_test_process_id(), + fixture->build_fingerprint)) { return false; } - cbm_daemon_build_identity_t identity = { + fixture->identity = (cbm_daemon_build_identity_t){ .semantic_version = "2.4.0", - .build_fingerprint = build, + .build_fingerprint = fixture->build_fingerprint, .cache_fingerprint = cache, .protocol_abi = 3, .store_abi = 11, @@ -158,7 +161,7 @@ static bool frontend_idle_fixture_start(frontend_idle_fixture_t *fixture) { }; cbm_daemon_runtime_service_config_t config = { .endpoint = fixture->maintenance.endpoint, - .identity = identity, + .identity = fixture->identity, .conflict_log_path = fixture->conflict_log, .conflict_log_cap_bytes = 64U * 1024U, .max_clients = 2, @@ -169,10 +172,11 @@ static bool frontend_idle_fixture_start(frontend_idle_fixture_t *fixture) { }; fixture->service = cbm_daemon_runtime_service_start(&config); cbm_daemon_runtime_connect_result_t connect_result = {0}; - fixture->client = fixture->service - ? cbm_daemon_runtime_client_connect(fixture->maintenance.endpoint, &identity, - 30000, &connect_result) - : NULL; + fixture->client = + fixture->service + ? cbm_daemon_runtime_client_connect(fixture->maintenance.endpoint, &fixture->identity, + 30000, &connect_result) + : NULL; return fixture->client && connect_result.status == CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED; } @@ -212,8 +216,8 @@ static int frontend_test_close_fd(int fd) { static void *frontend_idle_run(void *opaque) { frontend_idle_run_t *run = opaque; - run->result = - cbm_daemon_frontend_mcp_run(run->client, run->manager, run->input, run->output); + run->result = cbm_daemon_frontend_mcp_run(run->client, run->manager, run->session, run->input, + run->output); return NULL; } #endif @@ -230,6 +234,9 @@ enum { * dev machine (see the index-supervisor calibration). */ FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS = 30000, FRONTEND_EOF_TEST_CATASTROPHIC_TIMEOUT_S = 90, + /* Recovery can spend most of the generic guard restarting an instrumented + * daemon before the clean-EOF case enters its deliberate 15s drain. */ + FRONTEND_RECOVERY_TEST_CATASTROPHIC_TIMEOUT_S = 120, FRONTEND_BACKPRESSURE_MESSAGE_BYTES = 2 * 1024 * 1024, FRONTEND_BACKPRESSURE_FRONTEND_TIMEOUT_S = 90, FRONTEND_BACKPRESSURE_DAEMON_TIMEOUT_S = 90, @@ -239,8 +246,23 @@ enum { * regression black-box: it must remain valid if the exact capacity changes * while still proving that overload cannot hide an already-pending EOF. */ FRONTEND_EOF_TEST_OVERFLOW_MESSAGES = 32, + /* Hold replacement context past the production frontend's 15 second + * no-progress EOF + drain so shutdown deterministically overlaps recovery. */ + FRONTEND_RECOVERY_EOF_RELEASE_MS = 16000, + /* Startup budget of the recovery bootstrap. It differs from the connect + * budget (FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS) so the recovery tests can + * tell which one the session replay waited on, and it is the larger one, + * as in production (1 s connect probe, 30 s startup). */ + FRONTEND_RECOVERY_TEST_STARTUP_TIMEOUT_MS = 45000, }; +typedef enum { + FRONTEND_RECOVERY_RETRY = 0, + FRONTEND_RECOVERY_CANCEL = 1, + FRONTEND_RECOVERY_CLEAN_EOF = 2, +} frontend_recovery_mode_t; + typedef struct { atomic_int requests; atomic_int session_cancels; @@ -268,6 +290,7 @@ typedef struct { typedef struct { int fd; bool overflow; + frontend_recovery_mode_t recovery_mode; frontend_eof_application_context_t *application; atomic_bool finished; atomic_bool succeeded; @@ -275,7 +298,9 @@ typedef struct { typedef struct { char conflict_log[FRONTEND_TEST_PATH_CAP]; + char build_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; cbm_daemon_ipc_endpoint_t *endpoint; + cbm_daemon_build_identity_t identity; cbm_version_cohort_manager_t *manager; cbm_daemon_runtime_service_t *service; cbm_daemon_runtime_client_t *client; @@ -337,16 +362,22 @@ static cbm_daemon_runtime_application_status_t frontend_eof_application_request( } /* Released: fall through and answer normally like every later item. */ } - if (request_length == 0) { + if (request_length == 0 || request[0] == CBM_DAEMON_APPLICATION_REQUEST_SET_CONTEXT) { return CBM_DAEMON_RUNTIME_APPLICATION_OK; } - uint8_t *response = malloc(request_length); + const uint8_t *response_source = request; + uint32_t response_length = request_length; + if (request[0] == CBM_DAEMON_APPLICATION_REQUEST_MCP) { + response_source++; + response_length--; + } + uint8_t *response = malloc(response_length); if (!response) { return CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; } - memcpy(response, request, request_length); + memcpy(response, response_source, response_length); *response_out = response; - *response_length_out = request_length; + *response_length_out = response_length; if (context->request_observed_fd >= 0) { const char marker = 'Q'; (void)write(context->request_observed_fd, &marker, 1); @@ -454,6 +485,96 @@ static void *frontend_eof_writer(void *opaque) { return NULL; } +static const char frontend_recovery_request[] = + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}\n"; +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS +static const char frontend_recovery_cancel[] = + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\"," + "\"params\":{\"requestId\":1}}\n"; +static const char frontend_recovery_followup[] = + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n"; +#endif + +static void *frontend_recovery_writer(void *opaque) { + frontend_eof_writer_t *writer = opaque; + bool ok = frontend_eof_write_all(writer->fd, frontend_recovery_request, + sizeof(frontend_recovery_request) - 1U); + uint64_t deadline = cbm_now_ms() + FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS; + if (writer->recovery_mode != FRONTEND_RECOVERY_RETRY) { + while (ok && + !atomic_load_explicit(&writer->application->first_request_started, + memory_order_acquire) && + cbm_now_ms() < deadline) { + cbm_usleep(1000); + } + ok = ok && atomic_load_explicit(&writer->application->first_request_started, + memory_order_acquire); + } +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + if (writer->recovery_mode == FRONTEND_RECOVERY_CANCEL) { + uint64_t cancellations = cbm_daemon_frontend_test_routed_cancellations(); + ok = ok && frontend_eof_write_all(writer->fd, frontend_recovery_cancel, + sizeof(frontend_recovery_cancel) - 1U); + /* Keep context blocked until the reader routes the cancellation. */ + while (ok && cbm_daemon_frontend_test_routed_cancellations() == cancellations && + cbm_now_ms() < deadline) { + cbm_usleep(1000); + } + ok = ok && cbm_daemon_frontend_test_routed_cancellations() > cancellations; + atomic_store_explicit(&writer->application->release_first_request, true, + memory_order_release); + ok = ok && frontend_eof_write_all(writer->fd, frontend_recovery_followup, + sizeof(frontend_recovery_followup) - 1U); + } +#endif + if (writer->recovery_mode == FRONTEND_RECOVERY_CLEAN_EOF) { + ok = close(writer->fd) == 0 && ok; + writer->fd = -1; + cbm_usleep(FRONTEND_RECOVERY_EOF_RELEASE_MS * 1000U); + atomic_store_explicit(&writer->application->release_first_request, true, + memory_order_release); + } + int expected_requests = writer->recovery_mode == FRONTEND_RECOVERY_CLEAN_EOF ? 1 : 2; + while (ok && + atomic_load_explicit(&writer->application->requests, memory_order_acquire) < + expected_requests && + cbm_now_ms() < deadline) { + cbm_usleep(1000); + } + ok = ok && atomic_load_explicit(&writer->application->requests, memory_order_acquire) == + expected_requests; + if (writer->fd >= 0) { + ok = close(writer->fd) == 0 && ok; + writer->fd = -1; + } + atomic_store_explicit(&writer->succeeded, ok, memory_order_release); + atomic_store_explicit(&writer->finished, true, memory_order_release); + return NULL; +} + +static cbm_daemon_runtime_service_t *frontend_eof_service_start(frontend_eof_fixture_t *fixture) { + cbm_daemon_runtime_application_callbacks_t callbacks = { + .context = &fixture->application, + .session_open = frontend_eof_application_session_open, + .request = frontend_eof_application_request, + .request_cancel = frontend_eof_application_request_cancel, + .session_cancel = frontend_eof_application_session_cancel, + .session_close = frontend_eof_application_session_close, + }; + cbm_daemon_runtime_service_config_t config = { + .endpoint = fixture->endpoint, + .identity = fixture->identity, + .conflict_log_path = fixture->conflict_log, + .conflict_log_cap_bytes = 64U * 1024U, + .max_clients = 4, + .lease_timeout_ms = 5000, + .request_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + .shutdown_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + .application = callbacks, + }; + return cbm_daemon_runtime_service_start(&config); +} + static bool frontend_eof_fixture_start(frontend_eof_fixture_t *fixture, const char *parent) { memset(fixture, 0, sizeof(*fixture)); atomic_init(&fixture->application.requests, 0); @@ -467,53 +588,42 @@ static bool frontend_eof_fixture_start(frontend_eof_fixture_t *fixture, const ch fixture->application.request_observed_fd = -1; fixture->application.session_cancel_fd = -1; char key[CBM_DAEMON_KEY_SIZE]; - char build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; int log_written = snprintf(fixture->conflict_log, sizeof(fixture->conflict_log), "%s/conflicts.ndjson", parent); if (log_written <= 0 || log_written >= (int)sizeof(fixture->conflict_log) || !cbm_daemon_rendezvous_key(key) || - !cbm_daemon_runtime_process_build_fingerprint((uint64_t)getpid(), build)) { + !cbm_daemon_runtime_process_build_fingerprint((uint64_t)getpid(), + fixture->build_fingerprint)) { return false; } fixture->endpoint = cbm_daemon_ipc_endpoint_new(key, parent); fixture->manager = fixture->endpoint ? cbm_version_cohort_manager_new(fixture->endpoint) : NULL; - cbm_daemon_build_identity_t identity = { + fixture->identity = (cbm_daemon_build_identity_t){ .semantic_version = "2.4.0", - .build_fingerprint = build, + .build_fingerprint = fixture->build_fingerprint, .cache_fingerprint = FRONTEND_TEST_CACHE, .protocol_abi = 3, .store_abi = 11, .feature_abi = 7, }; - cbm_daemon_runtime_application_callbacks_t callbacks = { - .context = &fixture->application, - .session_open = frontend_eof_application_session_open, - .request = frontend_eof_application_request, - .request_cancel = frontend_eof_application_request_cancel, - .session_cancel = frontend_eof_application_session_cancel, - .session_close = frontend_eof_application_session_close, - }; - cbm_daemon_runtime_service_config_t config = { - .endpoint = fixture->endpoint, - .identity = identity, - .conflict_log_path = fixture->conflict_log, - .conflict_log_cap_bytes = 64U * 1024U, - .max_clients = 4, - .lease_timeout_ms = 5000, - .request_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, - .shutdown_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, - .application = callbacks, - }; - fixture->service = fixture->manager ? cbm_daemon_runtime_service_start(&config) : NULL; + fixture->service = fixture->manager ? frontend_eof_service_start(fixture) : NULL; cbm_daemon_runtime_connect_result_t connect_result = {0}; fixture->client = fixture->service - ? cbm_daemon_runtime_client_connect(fixture->endpoint, &identity, + ? cbm_daemon_runtime_client_connect(fixture->endpoint, &fixture->identity, FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, &connect_result) : NULL; return fixture->client && connect_result.status == CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED; } +static bool frontend_eof_fixture_restart_service(frontend_eof_fixture_t *fixture) { + bool stopped = fixture->service && cbm_daemon_runtime_service_stop( + fixture->service, FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS); + bool freed = stopped && cbm_daemon_runtime_service_free(fixture->service); + fixture->service = freed ? frontend_eof_service_start(fixture) : NULL; + return stopped && freed && fixture->service; +} + static bool frontend_eof_fixture_finish(frontend_eof_fixture_t *fixture) { bool ok = true; if (fixture->client) { @@ -579,7 +689,21 @@ static int frontend_eof_child_run(const char *parent, bool overflow) { cbm_daemon_runtime_client_t *frontend_client = fixture.client; fixture.client = NULL; /* cbm_daemon_frontend_mcp_run consumes it. */ - int result = cbm_daemon_frontend_mcp_run(frontend_client, fixture.manager, input, output); + cbm_daemon_frontend_session_config_t session = { + .bootstrap = + { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = fixture.endpoint, + .identity = &fixture.identity, + .executable_path = "unused", + .connect_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + .startup_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + }, + .session_root = parent, + .tool_profile = CBM_MCP_TOOL_PROFILE_ALL, + }; + int result = + cbm_daemon_frontend_mcp_run(frontend_client, fixture.manager, &session, input, output); bool joined = cbm_thread_join(&writer_thread) == 0; bool writer_ok = atomic_load_explicit(&writer.finished, memory_order_acquire) && atomic_load_explicit(&writer.succeeded, memory_order_acquire); @@ -660,6 +784,119 @@ static bool frontend_eof_run_isolated(const char *tag, bool overflow) { return child_ok && cleaned; } +static int frontend_recovery_child_run(const char *parent, frontend_recovery_mode_t recovery_mode) { + (void)alarm(FRONTEND_RECOVERY_TEST_CATASTROPHIC_TIMEOUT_S); + frontend_eof_fixture_t fixture; + if (!frontend_eof_fixture_start(&fixture, parent)) { + (void)frontend_eof_fixture_finish(&fixture); + return 73; + } + atomic_store_explicit(&fixture.application.block_first_request, + recovery_mode != FRONTEND_RECOVERY_RETRY, memory_order_release); + cbm_daemon_runtime_client_t *stale_client = fixture.client; + fixture.client = NULL; + bool replacement_ready = frontend_eof_fixture_restart_service(&fixture); + int input_pipe[2] = {-1, -1}; + bool pipe_ready = replacement_ready && pipe(input_pipe) == 0; + FILE *input = pipe_ready ? fdopen(input_pipe[0], "rb") : NULL; + FILE *output = input ? tmpfile() : NULL; + frontend_eof_writer_t writer = { + .fd = input_pipe[1], + .recovery_mode = recovery_mode, + .application = &fixture.application, + }; + atomic_init(&writer.finished, false); + atomic_init(&writer.succeeded, false); + cbm_thread_t writer_thread; + bool writer_started = + output && cbm_thread_create(&writer_thread, 0, frontend_recovery_writer, &writer) == 0; + if (!writer_started && input_pipe[1] >= 0) { + (void)close(input_pipe[1]); + input_pipe[1] = -1; + } + bool request_ready = writer_started; + cbm_daemon_frontend_session_config_t session = { + .bootstrap = + { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = fixture.endpoint, + .identity = &fixture.identity, + .executable_path = "unused", + .connect_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + .startup_timeout_ms = FRONTEND_RECOVERY_TEST_STARTUP_TIMEOUT_MS, + }, + .session_root = parent, + .tool_profile = CBM_MCP_TOOL_PROFILE_ALL, + }; + int result = request_ready ? cbm_daemon_frontend_mcp_run(stale_client, fixture.manager, + &session, input, output) + : -1; + stale_client = NULL; /* the frontend consumes the stale or replacement handle */ + bool writer_joined = writer_started && cbm_thread_join(&writer_thread) == 0; + bool writer_ok = writer_joined && + atomic_load_explicit(&writer.finished, memory_order_acquire) && + atomic_load_explicit(&writer.succeeded, memory_order_acquire); + char response[512]; + memset(response, 0, sizeof(response)); + bool response_ready = result == 0 && fflush(output) == 0 && fseek(output, 0, SEEK_SET) == 0; + size_t response_length = response_ready ? fread(response, 1, sizeof(response) - 1U, output) : 0; + bool response_exact = false; + if (recovery_mode == FRONTEND_RECOVERY_CANCEL) { + response_exact = strstr(response, "\"id\":1") && strstr(response, "\"code\":-32800") && + strstr(response, frontend_recovery_followup); + } else if (recovery_mode == FRONTEND_RECOVERY_CLEAN_EOF) { + response_exact = response_length == 0; + } else { + response_exact = response_length == sizeof(frontend_recovery_request) - 1U && + memcmp(response, frontend_recovery_request, + sizeof(frontend_recovery_request) - 1U) == 0; + } + int requests = atomic_load_explicit(&fixture.application.requests, memory_order_acquire); + int expected_requests = recovery_mode == FRONTEND_RECOVERY_CLEAN_EOF ? 1 : 2; + bool input_closed = input && fclose(input) == 0; + bool output_closed = output && fclose(output) == 0; + bool fixture_closed = frontend_eof_fixture_finish(&fixture); +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + /* The replay restores what startup set within the startup budget. On the + * connect budget (a probe bound) a replacement daemon that answers slower + * than a probe would end an otherwise good recovery. Every mode replays. */ + if (cbm_daemon_frontend_test_replay_timeout_ms() != FRONTEND_RECOVERY_TEST_STARTUP_TIMEOUT_MS) { + return 75; + } +#endif + return replacement_ready && request_ready && result == 0 && response_exact && + requests == expected_requests && writer_ok && input_closed && output_closed && + fixture_closed + ? 0 + : 74; +} + +static bool frontend_recovery_run_isolated(frontend_recovery_mode_t recovery_mode) { + char parent[FRONTEND_TEST_PATH_CAP]; + int written = snprintf(parent, sizeof(parent), "%s/cbm-frontend-recovery-XXXXXX", cbm_tmpdir()); + if (written <= 0 || written >= (int)sizeof(parent) || !cbm_mkdtemp(parent)) { + return false; + } + pid_t child = fork(); + if (child == 0) { + (void)signal(SIGALRM, SIG_DFL); + _exit(frontend_recovery_child_run(parent, recovery_mode)); + } + int status = 0; + pid_t waited; + do { + waited = child > 0 ? waitpid(child, &status, 0) : -1; + } while (waited < 0 && errno == EINTR); + bool cleaned = th_rmtree(parent) == 0; + bool child_ok = child > 0 && waited == child && WIFEXITED(status) && WEXITSTATUS(status) == 0; + if (!child_ok && child > 0 && waited == child) { + (void)fprintf(stderr, "frontend recovery fixture failed: status=0x%x exit=%d signal=%d\n", + status, WIFEXITED(status) ? WEXITSTATUS(status) : -1, + WIFSIGNALED(status) ? WTERMSIG(status) : 0); + } + return child_ok && cleaned; +} + static void frontend_test_release_lease(cbm_version_cohort_lease_t **lease) { while (lease && *lease && cbm_version_cohort_lease_release(lease) != CBM_PRIVATE_FILE_LOCK_OK) { cbm_usleep(1000); @@ -920,7 +1157,20 @@ static int frontend_backpressure_frontend_run(const char *parent, int input_fd, return 81; } - int result = cbm_daemon_frontend_mcp_run(client, manager, input, output); + cbm_daemon_frontend_session_config_t session = { + .bootstrap = + { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = endpoint, + .identity = &identity, + .executable_path = "unused", + .connect_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + .startup_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + }, + .session_root = parent, + .tool_profile = CBM_MCP_TOOL_PROFILE_ALL, + }; + int result = cbm_daemon_frontend_mcp_run(client, manager, &session, input, output); bool joined = cbm_thread_join(&writer_thread) == 0; bool writer_ok = atomic_load_explicit(&writer.finished, memory_order_acquire) && atomic_load_explicit(&writer.succeeded, memory_order_acquire); @@ -1334,9 +1584,20 @@ TEST(daemon_frontend_idle_uses_one_maintenance_observer) { bool pipe_ready = fixture_ready && cbm_pipe(input_pipe) == 0; FILE *input = pipe_ready ? frontend_test_fdopen_read(input_pipe[0]) : NULL; FILE *output = input ? tmpfile() : NULL; + cbm_daemon_frontend_session_config_t session = { + .bootstrap = {.role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = fixture.maintenance.endpoint, + .identity = &fixture.identity, + .executable_path = "unused", + .connect_timeout_ms = 30000, + .startup_timeout_ms = 30000}, + .session_root = fixture.maintenance.parent, + .tool_profile = CBM_MCP_TOOL_PROFILE_ALL, + }; frontend_idle_run_t run = { .client = fixture.client, .manager = fixture.maintenance.manager, + .session = &session, .input = input, .output = output, .result = -1, @@ -1418,8 +1679,21 @@ TEST(daemon_frontend_maintenance_exits_while_stdio_reader_is_blocked) { if (!announced) { _exit(70); } + cbm_daemon_frontend_session_config_t session = { + .bootstrap = + { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = endpoint, + .identity = &identity, + .executable_path = "unused", + .connect_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + .startup_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + }, + .session_root = fixture.parent, + .tool_profile = CBM_MCP_TOOL_PROFILE_ALL, + }; int result = cbm_daemon_frontend_mcp_run((cbm_daemon_runtime_client_t *)(uintptr_t)1, - manager, input, output); + manager, &session, input, output); (void)result; _exit(71); } @@ -1633,6 +1907,29 @@ TEST(daemon_frontend_over_capacity_input_backpressures_without_loss) { PASS(); } +/* A request rejected before its application frame reaches the daemon is safe + * to replay. The frontend must replace its stale authenticated client, restore + * the session, reserve a token from the new connection, and return the one + * response without closing Codex's stdio transport. */ +TEST(daemon_frontend_recovers_one_unsent_request_after_daemon_replacement) { + ASSERT_TRUE(frontend_recovery_run_isolated(FRONTEND_RECOVERY_RETRY)); + PASS(); +} + +/* Context-replay cancellation must not strand a token or break the next request. */ +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS +TEST(daemon_frontend_recovery_cancellation_preserves_next_request) { + ASSERT_TRUE(frontend_recovery_run_isolated(FRONTEND_RECOVERY_CANCEL)); + PASS(); +} +#endif + +/* Clean EOF during context replay must preserve worker-owned client cleanup. */ +TEST(daemon_frontend_clean_eof_during_recovery_preserves_client_ownership) { + ASSERT_TRUE(frontend_recovery_run_isolated(FRONTEND_RECOVERY_CLEAN_EOF)); + PASS(); +} + /* Clean EOF is the normal MCP-session ownership boundary. Accepted work gets a * bounded drain opportunity; work still active at the deadline is cancelled * with that exact session, while the frontend itself reports a clean close. */ @@ -1691,6 +1988,11 @@ SUITE(daemon_frontend) { RUN_TEST(daemon_local_participant_monitor_cancels_then_bounds_active_operation); RUN_TEST(daemon_local_participant_monitor_allows_supervisor_containment_window); RUN_TEST(daemon_frontend_over_capacity_input_backpressures_without_loss); + RUN_TEST(daemon_frontend_recovers_one_unsent_request_after_daemon_replacement); +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + RUN_TEST(daemon_frontend_recovery_cancellation_preserves_next_request); +#endif + RUN_TEST(daemon_frontend_clean_eof_during_recovery_preserves_client_ownership); RUN_TEST(daemon_frontend_eof_drain_timeout_cancels_and_returns_success); RUN_TEST(daemon_frontend_stdout_backpressure_eof_fail_stops_and_cancels_session); RUN_TEST(daemon_frontend_stdout_backpressure_maintenance_stops_and_cancels_session); diff --git a/tests/test_daemon_runtime.c b/tests/test_daemon_runtime.c index 4a9350196..61488358c 100644 --- a/tests/test_daemon_runtime.c +++ b/tests/test_daemon_runtime.c @@ -908,7 +908,8 @@ static void *runtime_application_client_request_thread(void *opaque) { call->status = call->tagged ? cbm_daemon_runtime_client_application_request_tagged( call->client, call->request_token, call->request, call->request_length, - &call->response, &call->response_length, RUNTIME_TEST_TIMEOUT_MS) + &call->response, &call->response_length, NULL, + RUNTIME_TEST_TIMEOUT_MS) : cbm_daemon_runtime_client_application_request( call->client, call->request, call->request_length, &call->response, &call->response_length, RUNTIME_TEST_TIMEOUT_MS); @@ -3586,7 +3587,7 @@ TEST(daemon_runtime_presend_request_cancel_is_sticky_and_nonterminal) { cancel = cbm_daemon_runtime_client_application_cancel(client, request_token); cancelled_status = cbm_daemon_runtime_client_application_request_tagged( client, request_token, blocking_request, (uint32_t)sizeof(blocking_request), - &cancelled_response, &cancelled_response_length, RUNTIME_TEST_TIMEOUT_MS); + &cancelled_response, &cancelled_response_length, NULL, RUNTIME_TEST_TIMEOUT_MS); } if (cancelled_status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED) { next_status = cbm_daemon_runtime_client_application_request( @@ -3643,8 +3644,10 @@ TEST(daemon_runtime_allows_only_one_unstarted_application_token) { bool first_reserved = false; bool duplicate_reservation_rejected = false; bool first_exact = false; + bool first_sent = false; bool second_reserved = false; bool second_exact = false; + bool second_sent = false; bool closed = false; bool exited = false; @@ -3664,7 +3667,7 @@ TEST(daemon_runtime_allows_only_one_unstarted_application_token) { cbm_daemon_runtime_application_status_t status = cbm_daemon_runtime_client_application_request_tagged( client, first_token, first_request, (uint32_t)sizeof(first_request), &response, - &response_length, RUNTIME_TEST_TIMEOUT_MS); + &response_length, &first_sent, RUNTIME_TEST_TIMEOUT_MS); first_exact = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response_length == sizeof(first_request) && response && memcmp(response, first_request, sizeof(first_request)) == 0; @@ -3680,7 +3683,7 @@ TEST(daemon_runtime_allows_only_one_unstarted_application_token) { cbm_daemon_runtime_application_status_t status = cbm_daemon_runtime_client_application_request_tagged( client, second_token, second_request, (uint32_t)sizeof(second_request), &response, - &response_length, RUNTIME_TEST_TIMEOUT_MS); + &response_length, &second_sent, RUNTIME_TEST_TIMEOUT_MS); second_exact = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response_length == sizeof(second_request) && response && memcmp(response, second_request, sizeof(second_request)) == 0; @@ -3699,9 +3702,11 @@ TEST(daemon_runtime_allows_only_one_unstarted_application_token) { ASSERT_TRUE(first_reserved); ASSERT_TRUE(duplicate_reservation_rejected); ASSERT_TRUE(first_exact); + ASSERT_TRUE(first_sent); ASSERT_TRUE(second_reserved); ASSERT_EQ(second_token, first_token + 1U); ASSERT_TRUE(second_exact); + ASSERT_TRUE(second_sent); ASSERT_TRUE(closed); ASSERT_TRUE(exited); ASSERT_EQ(atomic_load(&context.requests), 2); @@ -3801,6 +3806,10 @@ TEST(daemon_runtime_close_begin_retains_storage_and_rejects_late_exchange) { uint8_t *response = NULL; uint32_t response_length = 0; cbm_daemon_runtime_application_status_t status = CBM_DAEMON_RUNTIME_APPLICATION_OK; + cbm_daemon_runtime_application_token_t request_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + bool token_reserved = false; + bool request_sent = true; bool close_begun = false; bool duplicate_begin_rejected = false; bool close_acknowledged = false; @@ -3811,6 +3820,8 @@ TEST(daemon_runtime_close_begin_retains_storage_and_rejects_late_exchange) { RUNTIME_TEST_TIMEOUT_MS, &result); } if (client) { + token_reserved = + cbm_daemon_runtime_client_application_token_reserve(client, &request_token); close_begun = cbm_daemon_runtime_client_close_begin(client); duplicate_begin_rejected = close_begun && !cbm_daemon_runtime_client_close_begin(client); } @@ -3818,9 +3829,9 @@ TEST(daemon_runtime_close_begin_retains_storage_and_rejects_late_exchange) { /* Deterministically models the frontend boundary where close begins * after a worker claims an item but before it enters the runtime API. * The retained allocation must reject the call without touching IPC. */ - status = cbm_daemon_runtime_client_application_request( - client, request, (uint32_t)sizeof(request), &response, &response_length, - RUNTIME_TEST_TIMEOUT_MS); + status = cbm_daemon_runtime_client_application_request_tagged( + client, request_token, request, (uint32_t)sizeof(request), &response, &response_length, + &request_sent, RUNTIME_TEST_TIMEOUT_MS); close_acknowledged = cbm_daemon_runtime_client_close_finish(client, RUNTIME_TEST_TIMEOUT_MS); client = NULL; @@ -3838,11 +3849,13 @@ TEST(daemon_runtime_close_begin_retains_storage_and_rejects_late_exchange) { runtime_test_fixture_finish(&fixture); ASSERT_TRUE(started); + ASSERT_TRUE(token_reserved); ASSERT_TRUE(close_begun); ASSERT_TRUE(duplicate_begin_rejected); ASSERT_EQ(status, CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR); ASSERT_NULL(response); ASSERT_EQ(response_length, 0); + ASSERT_FALSE(request_sent); ASSERT_TRUE(close_acknowledged); ASSERT_TRUE(exited); ASSERT_EQ(atomic_load(&context.opened), 1); diff --git a/tests/test_edge_structural.c b/tests/test_edge_structural.c index 4b91ae05f..67326dcd5 100644 --- a/tests/test_edge_structural.c +++ b/tests/test_edge_structural.c @@ -826,14 +826,31 @@ TEST(es_decorates_attribute_csharp) { * resolves in the registry. pass_usages.c handles this. * ══════════════════════════════════════════════════════════════════ */ -/* Python constructor syntax is a CALLS edge to the materialized Class node. */ +/* Python ClassName() with a user __init__ is a CALLS edge to that method. + * The Class node no longer receives the instantiation edge. A class with no + * user __init__ still targets the Class (lsp_constructor). */ TEST(es_usage_crossfile_python) { static const ES_LangFile f[] = { {"models.py", "class User:\n def __init__(self, name):\n self.name = name\n"}, {"main.py", "from .models import User\n\n\ndef create_user(name):\n return User(name)\n"}}; - ASSERT_EQ(es_exact_edge_by_name(f, 2, "CALLS", "create_user", "User"), 1); + ASSERT_EQ(es_exact_edge_by_name(f, 2, "CALLS", "create_user", "__init__"), 1); + ASSERT_EQ(es_exact_edge_by_name(f, 2, "CALLS", "create_user", "User"), 0); + PASS(); +} + +/* Issue #1642 repro: build() -> Embedder.__init__, not the Embedder class. */ +TEST(es_calls_python_constructor_init) { + static const ES_LangFile f[] = { + {"main.py", "class Embedder:\n" + " def __init__(self, model=\"x\"):\n" + " self.model = model\n" + "\n" + "def build():\n" + " return Embedder(model=\"y\")\n"}}; + ASSERT_EQ(es_exact_edge_by_name(f, 1, "CALLS", "build", "__init__"), 1); + ASSERT_EQ(es_exact_edge_by_name(f, 1, "CALLS", "build", "Embedder"), 0); PASS(); } @@ -982,6 +999,7 @@ SUITE(edge_structural) { /* ── FAMILY 7: USAGE cross-file ─────────────────────────── */ /* Uncertain: depends on USAGE extraction for each language. */ RUN_TEST(es_usage_crossfile_python); + RUN_TEST(es_calls_python_constructor_init); RUN_TEST(es_usage_crossfile_typescript); RUN_TEST(es_usage_crossfile_go); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 00547eb59..61dc1a1f4 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -898,6 +898,192 @@ TEST(php_function) { PASS(); } +/* #2000: inline HTML between `?>` and `` fell into an + * ERROR region and never reached the graph. */ +static const CBMCall *find_call_named(CBMFileResult *r, const char *callee) { + for (int i = 0; i < r->calls.count; i++) { + if (r->calls.items[i].callee_name && strcmp(r->calls.items[i].callee_name, callee) == 0) + return &r->calls.items[i]; + } + return NULL; +} + +static int php_def_line(CBMFileResult *r, const char *name) { + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].name, name) == 0) + return (int)r->defs.items[i].start_line; + } + return -1; +} + +TEST(php_inline_html_tail_reaches_graph_issue2000) { + CBMFileResult *r = extract("\n" + "
\n" + "

plain markup after the closing tag

\n" + "
\n" + "has_error); + ASSERT(has_def(r, "Function", "gamma")); + ASSERT(has_def(r, "Function", "delta")); + /* Lines are in original-file coordinates. */ + ASSERT_EQ(php_def_line(r, "gamma"), 2); + ASSERT_EQ(php_def_line(r, "delta"), 8); + const CBMCall *c = find_call_named(r, "gamma"); + ASSERT_NOT_NULL(c); + ASSERT_NOT_NULL(strstr(c->enclosing_func_qn, "delta")); + ASSERT_FALSE(r->parse_incomplete); + cbm_free_result(r); + PASS(); +} + +/* View-file shapes: echo tags keep their calls, and control structures that + * are opened in one PHP block and closed in a later one still parse. */ +TEST(php_inline_html_template_shapes_issue2000) { + CBMFileResult *r = extract("\n" + "<?= page_title($page) ?>\n" + "\n" + "\n" + "

Hello name?>

\n" + "\n" + " log in\n" + "\n" + "\n" + "
  • \n" + "\n" + "\n" + "\n", + CBM_LANG_PHP, "t", "view.php"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_call(r, "page_title")); + ASSERT(has_call(r, "render_item")); + ASSERT(has_def(r, "Function", "footer_links")); + ASSERT_EQ(php_def_line(r, "footer_links"), 12); + ASSERT(has_call(r, "nav_links")); + ASSERT_FALSE(r->parse_incomplete); + cbm_free_result(r); + PASS(); +} + +/* `?>` only leaves PHP mode from code or a one-line comment. Inside strings, + * heredoc, nowdoc and block comments it is text; `#[` is an attribute, not a + * comment; ` quoted';\n" + "$b = \"double ?> quoted \\\" still\";\n" + "$c = << body\n" + " EOT;\n" + "$d = <<<'NOW'\n" + "nowdoc ?> body\n" + "NOW;\n" + "/* block ?> comment */\n" + "#[Pure]\n" + "function one() { return 1; }\n" + "// line comment ends here ?>\n" + "\n" + "has_error); + ASSERT(has_def(r, "Function", "one")); + ASSERT(has_def(r, "Function", "two")); + ASSERT_EQ(php_def_line(r, "two"), 16); + ASSERT(has_call(r, "one")); + ASSERT_FALSE(r->parse_incomplete); + cbm_free_result(r); + PASS(); +} + +/* Markup is blanked, never parsed: code-shaped text outside the PHP tags must + * not produce definitions or calls, wherever it sits. */ +TEST(php_inline_html_markup_never_reaches_graph_issue2000) { + CBMFileResult *r = extract("

    function before() { return leakBefore(); }

    \n" + "\n" + "

    function fake() { return leak(); }

    \n" + "\n" + "function xmlFake() { leakXml(); }\n" + "\n" + "

    class Tail { function tailFake() { leakTail(); } }

    \n", + CBM_LANG_PHP, "t", "markup.php"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "real")); + ASSERT(has_def(r, "Function", "after")); + ASSERT(has_call(r, "real")); + ASSERT_FALSE(has_def_any(r, "before")); + ASSERT_FALSE(has_def_any(r, "fake")); + ASSERT_FALSE(has_def_any(r, "jsFake")); + ASSERT_FALSE(has_def_any(r, "xmlFake")); + ASSERT_FALSE(has_def_any(r, "Tail")); + ASSERT_FALSE(has_def_any(r, "tailFake")); + ASSERT_FALSE(has_call(r, "leak")); + ASSERT_FALSE(r->parse_incomplete); + cbm_free_result(r); + PASS(); +} + +/* The mask hands back the caller's buffer, uncopied, when nothing needs + * rewriting, and a same-length rewrite otherwise. The pointer alone cannot + * tell copy-on-write from an eager copy, so the arena's allocation total is + * sampled around each call: no bytes for `plain` and `quoted`, a copy for + * `mixed`. */ +TEST(php_inline_html_mask_copies_only_on_write_issue2000) { + CBMArena arena; + cbm_arena_init(&arena); + const char *plain = "';\n/* ?> */\n$h = <<\nEOT;\n"; + const char *mixed = "\nx\n"; + + size_t before = cbm_arena_total(&arena); + ASSERT(cbm_php_mask_inline_html(&arena, plain, (int)strlen(plain)) == plain); + ASSERT_EQ(cbm_arena_total(&arena), before); + + before = cbm_arena_total(&arena); + ASSERT(cbm_php_mask_inline_html(&arena, quoted, (int)strlen(quoted)) == quoted); + ASSERT_EQ(cbm_arena_total(&arena), before); + + before = cbm_arena_total(&arena); + const char *masked = cbm_php_mask_inline_html(&arena, mixed, (int)strlen(mixed)); + ASSERT(cbm_arena_total(&arena) > before); + ASSERT(masked != mixed); + ASSERT_EQ((int)strlen(masked), (int)strlen(mixed)); + ASSERT_STR_EQ(masked, "` (common at the end of older class files) is not a partial + * parse, and CRLF line endings keep their line numbers. */ +TEST(php_inline_html_trailing_close_tag_crlf_issue2000) { + CBMFileResult *r = extract("\r\n", + CBM_LANG_PHP, "t", "Repo.php"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Class", "Repo")); + ASSERT(has_def(r, "Method", "find")); + ASSERT_EQ(php_def_line(r, "find"), 3); + ASSERT_FALSE(r->parse_incomplete); + cbm_free_result(r); + PASS(); +} + /* --- Ruby --- */ TEST(ruby_class) { CBMFileResult *r = extract("class Animal\n def initialize(name)\n @name = name\n end\n " @@ -14802,6 +14988,12 @@ SUITE(extraction) { RUN_TEST(python_class_base_extracted_bare); RUN_TEST(php_class); RUN_TEST(php_function); + RUN_TEST(php_inline_html_tail_reaches_graph_issue2000); + RUN_TEST(php_inline_html_template_shapes_issue2000); + RUN_TEST(php_inline_html_close_tag_in_literals_issue2000); + RUN_TEST(php_inline_html_markup_never_reaches_graph_issue2000); + RUN_TEST(php_inline_html_mask_copies_only_on_write_issue2000); + RUN_TEST(php_inline_html_trailing_close_tag_crlf_issue2000); RUN_TEST(ruby_class); RUN_TEST(ruby_module); RUN_TEST(ruby_constructor_callee_is_constant_path); diff --git a/tests/test_git_context.c b/tests/test_git_context.c index c508e25f3..60bc572ca 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -10,7 +10,9 @@ * * The canonical_root tests shell out to `git`, so they SKIP_PLATFORM on * Windows (the CI shell there cannot init a repo via system()). The root-path - * existence test runs on every platform. + * existence test runs on every platform. The bounded worktree-status tests + * below shell out through cbm_popen instead, so they run on every platform + * that has git on PATH. * * Reproduce-first guard: canonical_root_subdir is the genuine RED-without-the-fix * guard — a repo indexed from a subdirectory yields a relative --git-common-dir @@ -346,6 +348,95 @@ TEST(canonical_root_linked_worktree) { #endif /* _WIN32 */ } +/* ── cbm_git_is_linked_worktree ─────────────────────────────────── */ +/* Detection backing the `ignore_worktrees` config gate. The predicate must be + * TRUE only for a linked worktree (`git worktree add`) — never for the main + * checkout, a plain directory, or a submodule. The submodule case is the one a + * naive "is .git a regular file?" check gets wrong: a submodule's .git is also a + * gitlink file, and it is separated here by the absence of a `commondir` entry + * in the pointed-at gitdir. */ + +TEST(is_linked_worktree_true_for_linked_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree test not implemented for Windows"); +#else + /* th_mktempdir() returns a static buffer — copy before the second call. */ + char main_tmp[256]; + char *raw = th_mktempdir("cbm_wt_main"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(main_tmp, raw, sizeof(main_tmp) - 1); + main_tmp[sizeof(main_tmp) - 1] = '\0'; + + char wt_tmp[256]; + raw = th_mktempdir("cbm_wt_linked"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(wt_tmp, raw, sizeof(wt_tmp) - 1); + wt_tmp[sizeof(wt_tmp) - 1] = '\0'; + th_rmtree(wt_tmp); /* git worktree add creates it */ + + if (make_git_repo(main_tmp) != 0) { + th_rmtree(main_tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + if (git_run(main_tmp, "branch wt-branch") != 0) { + th_rmtree(main_tmp); + FAIL("failed to create branch for worktree"); + } + char wt_cmd[1024]; + snprintf(wt_cmd, sizeof(wt_cmd), "worktree add \"%s\" wt-branch", wt_tmp); + if (git_run(main_tmp, wt_cmd) != 0) { + th_rmtree(wt_tmp); + th_rmtree(main_tmp); + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + + bool worktree_detected = cbm_git_is_linked_worktree(wt_tmp); + /* A subdirectory has no .git of its own. The predicate must still see the + * linked worktree above it, or ignore_worktrees misses nested sessions. */ + char nested[1024]; + snprintf(nested, sizeof(nested), "%s/nested/deeper", wt_tmp); + bool nested_ok = th_mkdir_p(nested) == 0; + bool nested_detected = nested_ok && cbm_git_is_linked_worktree(nested); + /* The MAIN checkout of the very same repo must NOT be flagged — otherwise + * enabling ignore_worktrees would stop indexing ordinary repositories. */ + bool main_detected = cbm_git_is_linked_worktree(main_tmp); + char main_nested[1024]; + snprintf(main_nested, sizeof(main_nested), "%s/src", main_tmp); + bool main_nested_ok = th_mkdir_p(main_nested) == 0; + bool main_nested_detected = main_nested_ok && cbm_git_is_linked_worktree(main_nested); + /* A worktree nested inside another must not share the parent's anchor. */ + char inner[1024]; + snprintf(inner, sizeof(inner), "%s/inner-wt", wt_tmp); + char inner_cmd[1200]; + snprintf(inner_cmd, sizeof(inner_cmd), "worktree add -b inner-branch \"%s\"", inner); + bool inner_added = git_run(main_tmp, inner_cmd) == 0; + char inner_sub[1200]; + snprintf(inner_sub, sizeof(inner_sub), "%s/sub", inner); + bool inner_sub_ok = inner_added && th_mkdir_p(inner_sub) == 0; + bool same_outer = cbm_git_same_linked_worktree(nested, wt_tmp); + bool same_inner = inner_sub_ok && cbm_git_same_linked_worktree(inner_sub, inner); + bool not_parent = inner_sub_ok && !cbm_git_same_linked_worktree(inner_sub, wt_tmp); + bool not_main = !cbm_git_same_linked_worktree(nested, main_tmp); + + git_run(main_tmp, "worktree prune"); + th_rmtree(main_tmp); + th_rmtree(wt_tmp); + + ASSERT(worktree_detected); + ASSERT(nested_ok); + ASSERT(nested_detected); + ASSERT(!main_detected); + ASSERT(main_nested_ok); + ASSERT(!main_nested_detected); + ASSERT(inner_added); + ASSERT(same_outer); + ASSERT(same_inner); + ASSERT(not_parent); + ASSERT(not_main); + PASS(); +#endif /* _WIN32 */ +} + TEST(git_context_ignores_inherited_repo_env) { #ifdef _WIN32 SKIP_PLATFORM("git-based env-isolation test not supported on Windows CI"); @@ -403,6 +494,68 @@ TEST(git_context_ignores_inherited_repo_env) { #endif /* _WIN32 */ } +TEST(is_linked_worktree_false_for_submodule_and_nongit) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree test not implemented for Windows"); +#else + char super_tmp[256]; + char *raw = th_mktempdir("cbm_wt_super"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(super_tmp, raw, sizeof(super_tmp) - 1); + super_tmp[sizeof(super_tmp) - 1] = '\0'; + + char child_tmp[256]; + raw = th_mktempdir("cbm_wt_child"); + if (!raw) FAIL("th_mktempdir returned NULL"); + strncpy(child_tmp, raw, sizeof(child_tmp) - 1); + child_tmp[sizeof(child_tmp) - 1] = '\0'; + + if (make_git_repo(super_tmp) != 0 || make_git_repo(child_tmp) != 0) { + th_rmtree(super_tmp); + th_rmtree(child_tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + + /* A plain directory that is not a git repo at all. */ + char plain[1024]; + snprintf(plain, sizeof(plain), "%s/plain", super_tmp); + if (th_mkdir_p(plain) != 0) { + th_rmtree(super_tmp); + th_rmtree(child_tmp); + FAIL("failed to create plain dir"); + } + bool plain_detected = cbm_git_is_linked_worktree(plain); + + /* file:// submodules are refused by default since the CVE-2022-39253 fix. */ + char sub_cmd[1024]; + snprintf(sub_cmd, sizeof(sub_cmd), + "-c protocol.file.allow=always submodule add -q \"%s\" subm", child_tmp); + int sub_rc = git_run(super_tmp, sub_cmd); + + char subm[1024]; + snprintf(subm, sizeof(subm), "%s/subm", super_tmp); + bool submodule_detected = sub_rc == 0 && cbm_git_is_linked_worktree(subm); + char sub_nested[1024]; + snprintf(sub_nested, sizeof(sub_nested), "%s/nested", subm); + bool sub_nested_made = sub_rc == 0 && th_mkdir_p(sub_nested) == 0; + bool sub_nested_detected = sub_nested_made && cbm_git_is_linked_worktree(sub_nested); + + th_rmtree(super_tmp); + th_rmtree(child_tmp); + + ASSERT(!plain_detected); + if (sub_rc != 0) { + SKIP_PLATFORM("git submodule add unavailable in this environment"); + } + /* A submodule gitlink has no commondir, including a subdirectory which + * must not walk into the superproject. */ + ASSERT(!submodule_detected); + ASSERT(sub_nested_made); + ASSERT(!sub_nested_detected); + PASS(); +#endif /* _WIN32 */ +} + TEST(githistory_ignores_inherited_repo_env) { #ifdef _WIN32 SKIP_PLATFORM("git-based env-isolation test not supported on Windows CI"); @@ -495,6 +648,312 @@ TEST(git_branch_resolve_matches_full_context) { #endif /* _WIN32 */ } +/* The freshness verdict reads this snapshot, so an inherited GIT_DIR / + * GIT_WORK_TREE pointing at a dirty decoy must not make a clean fixture + * report the decoy's changes as its own (#2003). */ +TEST(worktree_status_ignores_inherited_repo_env) { +#ifdef _WIN32 + SKIP_PLATFORM("git-based env-isolation test not supported on Windows CI"); +#else + char root[256]; + char *raw = th_mktempdir("cbm_gitenv_status"); + if (!raw) + FAIL("th_mktempdir returned NULL"); + snprintf(root, sizeof(root), "%s", raw); + if (make_env_isolation_tree(root) != 0) { + th_rmtree(root); + SKIP_PLATFORM("git not available to init a repo"); + } + char decoy[512], fixture[512], path[600]; + snprintf(decoy, sizeof(decoy), "%s/decoy", root); + snprintf(fixture, sizeof(fixture), "%s/fixture", root); + /* Dirty only the decoy: one tracked edit and one untracked file. */ + snprintf(path, sizeof(path), "%s/a.go", decoy); + th_write_file(path, "package a // dirty\n"); + snprintf(path, sizeof(path), "%s/untracked.go", decoy); + th_write_file(path, "package a\n"); + + git_env_snapshot_t snap; + git_env_hook_enter(&snap, decoy); + cbm_worktree_status_t st; + int rc = cbm_git_worktree_status(fixture, 4, &st); + git_env_hook_leave(&snap); + bool available = st.available; + int tracked = st.tracked_count; + int untracked = st.untracked_count; + cbm_git_worktree_status_free(&st); + th_rmtree(root); + + ASSERT_EQ(rc, 0); + ASSERT_TRUE(available); + ASSERT_EQ(tracked, 0); /* the fixture is clean; the decoy is not */ + ASSERT_EQ(untracked, 0); + PASS(); +#endif /* _WIN32 */ +} + +/* ── Bounded worktree status (BT-240 increment 3) ────────────────────── + * These run on EVERY platform, not just POSIX: they shell out through + * cbm_popen instead of system(), so they work whenever git is on PATH — + * Windows CI included. The + * core status parser is platform-neutral and must be exercised everywhere git + * is available; only a genuinely missing git skips. */ + +static int status_git_run(const char *dir, const char *args) { + char cmd[1024]; +#ifdef _WIN32 + const char *null_dev = "NUL"; +#else + const char *null_dev = "/dev/null"; +#endif + snprintf(cmd, sizeof(cmd), "git -C \"%s\" %s 2>%s", dir, args, null_dev); + FILE *fp = cbm_popen(cmd, "r"); + if (!fp) { + return -1; + } + char drain[256]; + while (fgets(drain, sizeof(drain), fp)) { + } + return cbm_pclose(fp); +} + +static int status_make_repo(const char *dir) { + if (th_mkdir_p(dir) != 0) return -1; + if (status_git_run(dir, "init -q") != 0) return -1; + if (status_git_run(dir, "config user.email test@example.com") != 0) return -1; + if (status_git_run(dir, "config user.name Test") != 0) return -1; + if (th_write_file(TH_PATH(dir, ".keep"), "") != 0) return -1; + if (status_git_run(dir, "add -A") != 0) return -1; + if (status_git_run(dir, "commit -qm init") != 0) return -1; + return 0; +} + +TEST(worktree_status_clean) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (status_make_repo(tmp) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, 8, &st); + if (rc != 0 || !st.available) { + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + FAIL("clean repo status not available"); + } + ASSERT_EQ(st.tracked_count, 0); + ASSERT_EQ(st.untracked_count, 0); + ASSERT_EQ(st.tracked_sample_count, 0); + ASSERT_EQ(st.untracked_sample_count, 0); + ASSERT_FALSE(st.tracked_truncated); + ASSERT_FALSE(st.untracked_truncated); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + +TEST(worktree_status_tracked_modified) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (status_make_repo(tmp) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(tmp, "tracked.txt"), "v1") != 0 || + status_git_run(tmp, "add -f -- tracked.txt") != 0 || + status_git_run(tmp, "commit -q -m add-tracked") != 0) { + th_rmtree(tmp); + FAIL("failed to commit tracked file"); + } + if (th_write_file(TH_PATH(tmp, "tracked.txt"), "v2") != 0) { + th_rmtree(tmp); + FAIL("failed to modify tracked file"); + } + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, 8, &st); + if (rc != 0 || !st.available) { + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + FAIL("status not available"); + } + ASSERT_EQ(st.tracked_count, 1); + ASSERT_EQ(st.untracked_count, 0); + ASSERT_EQ(st.tracked_sample_count, 1); + ASSERT_STR_EQ(st.tracked_paths[0], "tracked.txt"); + ASSERT_FALSE(st.tracked_truncated); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + +TEST(worktree_status_untracked) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (status_make_repo(tmp) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(tmp, "newfile.txt"), "n") != 0) { + th_rmtree(tmp); + FAIL("failed to create untracked file"); + } + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, 8, &st); + if (rc != 0 || !st.available) { + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + FAIL("status not available"); + } + ASSERT_EQ(st.untracked_count, 1); + ASSERT_EQ(st.tracked_count, 0); + ASSERT_EQ(st.untracked_sample_count, 1); + ASSERT_STR_EQ(st.untracked_paths[0], "newfile.txt"); + ASSERT_FALSE(st.untracked_truncated); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + +TEST(worktree_status_rename_counts_once) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (status_make_repo(tmp) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(tmp, "old.txt"), "o") != 0 || + th_write_file(TH_PATH(tmp, "other.txt"), "x") != 0 || + status_git_run(tmp, "add -A") != 0 || + status_git_run(tmp, "commit -qm base") != 0 || + status_git_run(tmp, "mv old.txt new.txt") != 0) { + th_rmtree(tmp); + FAIL("failed to stage a rename"); + } + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, 8, &st); + if (rc != 0 || !st.available) { + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + FAIL("status not available"); + } + ASSERT_EQ(st.tracked_count, 1); /* rename/copy counts ONCE, not twice */ + ASSERT_EQ(st.tracked_sample_count, 1); + ASSERT_STR_EQ(st.tracked_paths[0], "new.txt"); /* sampled NEW path */ + ASSERT_EQ(st.untracked_count, 0); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + +TEST(worktree_status_sample_cap_truncates) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (status_make_repo(tmp) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + enum { N_FILES = 5, SAMPLE_MAX = 2 }; + for (int i = 0; i < N_FILES; i++) { + char path[1024]; + snprintf(path, sizeof(path), "%s/u%d.txt", tmp, i); + if (th_write_file(path, "u") != 0) { + th_rmtree(tmp); + FAIL("failed to create untracked file"); + } + } + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, SAMPLE_MAX, &st); + if (rc != 0 || !st.available) { + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + FAIL("status not available"); + } + ASSERT_EQ(st.untracked_count, N_FILES); /* full stream counted... */ + ASSERT_EQ(st.untracked_sample_count, SAMPLE_MAX); /* ...samples bounded */ + ASSERT_TRUE(st.untracked_truncated); + ASSERT_EQ(st.tracked_count, 0); + ASSERT_FALSE(st.tracked_truncated); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + +TEST(worktree_status_mixed_tracked_and_untracked) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + if (status_make_repo(tmp) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(tmp, "tracked.txt"), "v1") != 0 || + status_git_run(tmp, "add -f -- tracked.txt") != 0 || + status_git_run(tmp, "commit -q -m add-tracked") != 0) { + th_rmtree(tmp); + FAIL("failed to commit tracked file"); + } + if (th_write_file(TH_PATH(tmp, "tracked.txt"), "v2") != 0 || + th_write_file(TH_PATH(tmp, "untracked.txt"), "n") != 0) { + th_rmtree(tmp); + FAIL("failed to prepare mixed worktree"); + } + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, 8, &st); + if (rc != 0 || !st.available) { + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + FAIL("status not available"); + } + ASSERT_EQ(st.tracked_count, 1); + ASSERT_EQ(st.untracked_count, 1); + ASSERT_STR_EQ(st.tracked_paths[0], "tracked.txt"); + ASSERT_STR_EQ(st.untracked_paths[0], "untracked.txt"); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + +/* Fail-closed contract: a non-repo directory must report unavailable with + * zero counts — never a silent "clean" tree. Does not need git on PATH. */ +TEST(worktree_status_unavailable_non_repo) { + char *tmp = th_mktempdir("cbm_wtstatus"); + if (!tmp) FAIL("th_mktempdir returned NULL"); + + cbm_worktree_status_t st = {0}; + int rc = cbm_git_worktree_status(tmp, 8, &st); + if (rc != 0) { + th_rmtree(tmp); + FAIL("status call returned an error"); + } + ASSERT_FALSE(st.available); + ASSERT_EQ(st.tracked_count, 0); + ASSERT_EQ(st.untracked_count, 0); + ASSERT_EQ(st.tracked_sample_count, 0); + ASSERT_EQ(st.untracked_sample_count, 0); + + cbm_git_worktree_status_free(&st); + th_rmtree(tmp); + PASS(); +} + /* ── Suite ──────────────────────────────────────────────────────── */ SUITE(git_context) { @@ -503,6 +962,16 @@ SUITE(git_context) { RUN_TEST(canonical_root_repo_root); RUN_TEST(canonical_root_subdir); RUN_TEST(canonical_root_linked_worktree); + RUN_TEST(is_linked_worktree_true_for_linked_worktree); + RUN_TEST(is_linked_worktree_false_for_submodule_and_nongit); RUN_TEST(git_context_ignores_inherited_repo_env); RUN_TEST(githistory_ignores_inherited_repo_env); + RUN_TEST(worktree_status_ignores_inherited_repo_env); + RUN_TEST(worktree_status_clean); + RUN_TEST(worktree_status_tracked_modified); + RUN_TEST(worktree_status_untracked); + RUN_TEST(worktree_status_rename_counts_once); + RUN_TEST(worktree_status_sample_cap_truncates); + RUN_TEST(worktree_status_mixed_tracked_and_untracked); + RUN_TEST(worktree_status_unavailable_non_repo); } diff --git a/tests/test_lsp_resolution_probe.c b/tests/test_lsp_resolution_probe.c index 91844f67e..6f18c215e 100644 --- a/tests/test_lsp_resolution_probe.c +++ b/tests/test_lsp_resolution_probe.c @@ -1837,16 +1837,62 @@ TEST(lrp_tsx_usage_import_type_with_decoy) { PASS(); } -/* Python: constructor syntax is an exact call to the materialized Class node. */ +/* Python: ClassName() with a user __init__ is an exact CALLS edge to that + * method. The Class node no longer receives the instantiation edge. */ TEST(lrp_python_usage_instantiation) { static const LRP_File f[] = { {"model.py", "class User:\n def __init__(self, name):\n self.name = name\n"}, {"main.py", "from .model import User\n\n\ndef create(name):\n return User(name)\n"}}; LRP_Proj lp; cbm_store_t *store = lrp_index(&lp, f, 2); - int exact = store ? lrp_exact_calls_by_name(store, lp.project, "create", "User") : -1; + int exact = store ? lrp_exact_calls_by_name(store, lp.project, "create", "__init__") : -1; + int class_edge = store ? lrp_exact_calls_by_name(store, lp.project, "create", "User") : -1; lrp_cleanup(&lp, store); ASSERT_EQ(exact, 1); + ASSERT_EQ(class_edge, 0); + PASS(); +} + +/* Python same-file subclasses in the graph: ClassName() binds only to an + * __init__ the class declares itself. B(A) declares none, so make_b keeps + * its CALLS edge on the class B and the inherited A.__init__ gets none. + * C(A) declares its own __init__, so make_c calls C.__init__, not C and not + * the inherited A.__init__. */ +TEST(lrp_python_subclass_constructor_own_init_only) { + static const LRP_File f[] = {{"main.py", "class A:\n" + " def __init__(self):\n" + " self.ready = True\n" + "\n" + "class B(A):\n" + " def go(self):\n" + " return 1\n" + "\n" + "class C(A):\n" + " def __init__(self):\n" + " self.ready = False\n" + "\n" + "def make_b():\n" + " return B()\n" + "\n" + "def make_c():\n" + " return C()\n"}}; + LRP_Proj lp; + cbm_store_t *store = lrp_index(&lp, f, 1); + const char *p = store ? lp.project : NULL; + int b_class = lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_b", "main.B"); + int b_base_init = + lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_b", "main.A.__init__"); + int c_own_init = + lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_c", "main.C.__init__"); + int c_class = lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_c", "main.C"); + int c_base_init = + lrp_exact_edge_by_qn_suffix(store, p, "CALLS", "main.make_c", "main.A.__init__"); + lrp_cleanup(&lp, store); + ASSERT_EQ(b_class, 1); + ASSERT_EQ(b_base_init, 0); + ASSERT_EQ(c_own_init, 1); + ASSERT_EQ(c_class, 0); + ASSERT_EQ(c_base_init, 0); PASS(); } @@ -1967,4 +2013,5 @@ SUITE(lsp_resolution_probe) { RUN_TEST(lrp_ts_usage_import_type_with_decoy); RUN_TEST(lrp_tsx_usage_import_type_with_decoy); RUN_TEST(lrp_python_usage_instantiation); + RUN_TEST(lrp_python_subclass_constructor_own_init_only); } diff --git a/tests/test_main.c b/tests/test_main.c index e10b8d869..1aafb14fd 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -1806,6 +1806,7 @@ extern void suite_php_lsp(void); extern void suite_cs_lsp(void); extern void suite_cs_lsp_bench(void); extern void suite_perl_lsp(void); +extern void suite_ruby_lsp(void); extern void suite_scope(void); extern void suite_type_rep(void); extern void suite_py_lsp(void); @@ -2535,6 +2536,7 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(cs_lsp); RUN_SELECTED_SUITE_PERF(cs_lsp_bench); RUN_SELECTED_SUITE(perl_lsp); + RUN_SELECTED_SUITE(ruby_lsp); RUN_SELECTED_SUITE(py_lsp); RUN_SELECTED_SUITE(kotlin_lsp); RUN_SELECTED_SUITE(kotlin_typed_calls); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 0f60ee69d..11be4581a 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -18,6 +18,7 @@ #include "test_framework.h" #include "test_helpers.h" #include +#include #include /* spawn-count hook — #845 in-process guard */ #include #include @@ -1214,6 +1215,57 @@ TEST(mcp_initialize_response) { PASS(); } +/* The lifecycle hook reads ignore_worktrees through this reader. It must never + * attach a store to the server (the daemon lends its session server) and never + * create the runtime store; a server with its own store answers from it. */ +TEST(mcp_ignore_worktrees_runtime_read_leaves_server_and_store_alone) { + char *raw = th_mktempdir("cbm_mcp_runtime_config"); + ASSERT_NOT_NULL(raw); + char cache[256]; + snprintf(cache, sizeof(cache), "%s", raw); + char own_dir[320]; + snprintf(own_dir, sizeof(own_dir), "%s/own", cache); + char store_path[400]; + snprintf(store_path, sizeof(store_path), "%s/" CBM_CONFIG_DB_FILENAME, cache); + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + cbm_mcp_server_t *server = cbm_mcp_server_new(NULL); + struct stat st; + bool absent_off = server && !cbm_mcp_ignore_worktrees_runtime_enabled(server); + bool still_absent = stat(store_path, &st) != 0; + + cbm_config_t *runtime = cbm_config_open(cache); + bool stored = runtime && cbm_config_set(runtime, CBM_CONFIG_IGNORE_WORKTREES, "true") == 0; + cbm_config_close(runtime); + bool runtime_on = server && cbm_mcp_ignore_worktrees_runtime_enabled(server); + bool server_untouched = server && !cbm_mcp_ignore_worktrees_enabled(server); + + cbm_config_t *own = cbm_config_open(own_dir); + if (server && own) { + cbm_mcp_server_set_config(server, own); + } + bool own_store_wins = server && own && !cbm_mcp_ignore_worktrees_runtime_enabled(server); + if (server) { + cbm_mcp_server_set_config(server, NULL); + } + cbm_mcp_server_free(server); + cbm_config_close(own); + restore_cache_dir(saved_copy); + free(saved_copy); + int removed = th_rmtree(cache); + + ASSERT(absent_off); + ASSERT(still_absent); + ASSERT(stored); + ASSERT(runtime_on); + ASSERT(server_untouched); + ASSERT(own_store_wins); + ASSERT_EQ(removed, 0); + PASS(); +} + TEST(mcp_tools_list) { char *json = cbm_mcp_tools_list(); ASSERT_NOT_NULL(json); @@ -7839,9 +7891,465 @@ TEST(tool_index_status_includes_git_metadata) { PASS(); } -/* ══════════════════════════════════════════════════════════════════ - * TOOL HANDLERS WITH DATA - * ══════════════════════════════════════════════════════════════════ */ +/* BT-240 RED: a live checkout SHA is not proof of the generation that produced + * graph content. Older databases lack an indexed-checkout identity, so verbose + * status must fail closed as unknown while exposing graph generation + * separately. */ +TEST(tool_index_status_fails_closed_without_indexed_checkout_identity) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", + "{\"project\":\"test-project\",\"verbose\":true,\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"freshness\"")); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"unknown\"")); + ASSERT_NOT_NULL(strstr(inner, "\"indexed_generation\"")); + ASSERT_NOT_NULL(strstr(inner, "\"indexed_checkout_sha\":null")); + ASSERT_NOT_NULL(strstr(inner, "indexed_checkout_unavailable")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* BT-240 lean-default guard: the freshness verdict is diagnostics — a + * report-only signal that never auto-indexes — so it must stay out of the + * common (non-verbose) status call. */ +TEST(tool_index_status_omits_freshness_by_default) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", "{\"project\":\"test-project\",\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"status\"")); + ASSERT_NULL(strstr(inner, "\"freshness\"")); + ASSERT_NULL(strstr(inner, "indexed_checkout_sha")); + /* BT-240 increment 3: the worktree-status snapshot is part of the + * verbose-only freshness block and must not leak into the default call. */ + ASSERT_NULL(strstr(inner, "status_available")); + ASSERT_NULL(strstr(inner, "tracked_changes")); + ASSERT_NULL(strstr(inner, "untracked_source")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* ── BT-240: freshness verdict against the recorded indexed checkout ── + * These shell out to git via cbm_popen (the same isolated spawn production + * git_context uses) rather than system(), so they run on every platform that + * has git on PATH — Windows CI included. A genuinely missing git is the only + * skip. */ + +static int mcp_git_run(const char *dir, const char *args) { + char cmd[1024]; +#ifdef _WIN32 + const char *null_dev = "NUL"; +#else + const char *null_dev = "/dev/null"; +#endif + snprintf(cmd, sizeof(cmd), "git -C \"%s\" %s 2>%s", dir, args, null_dev); + FILE *fp = cbm_popen(cmd, "r"); + if (!fp) { + return -1; + } + char drain[256]; + while (fgets(drain, sizeof(drain), fp)) { + } + return cbm_pclose(fp); +} + +static int mcp_make_git_repo(const char *dir) { + if (th_mkdir_p(dir) != 0) return -1; + if (mcp_git_run(dir, "init -q") != 0) return -1; + if (mcp_git_run(dir, "config user.email test@example.com") != 0) return -1; + if (mcp_git_run(dir, "config user.name Test") != 0) return -1; + if (th_write_file(TH_PATH(dir, ".keep"), "") != 0) return -1; + if (mcp_git_run(dir, "add -A") != 0) return -1; + if (mcp_git_run(dir, "commit -qm init") != 0) return -1; + return 0; +} + +/* Helper: build a server whose project root is the given git repo and whose + * coverage metadata records indexed_checkout_sha = sha (may be NULL). */ +static cbm_mcp_server_t *mcp_freshness_server(const char *repo, const char *sha) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (!srv) return NULL; + cbm_store_t *st = cbm_mcp_server_store(srv); + if (!st) { + cbm_mcp_server_free(srv); + return NULL; + } + cbm_mcp_server_set_project(srv, "fresh-project"); + cbm_store_upsert_project(st, "fresh-project", repo); + cbm_coverage_meta_t meta = { + .generation = "fresh-generation", + .index_mode = "fast", + .recorded_at = "2026-08-11T00:00:00Z", + .recording_status = "complete", + .coverage_version = 1, + .hash_records_complete = true, + .indexed_checkout_sha = sha, + }; + if (cbm_store_coverage_replace_ex(st, "fresh-project", NULL, 0, &meta) != CBM_STORE_OK) { + cbm_mcp_server_free(srv); + return NULL; + } + return srv; +} + +TEST(tool_index_status_freshness_verdict_current_when_indexed_checkout_matches) { + char *repo = th_mktempdir("cbm_fresh_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + if (mcp_make_git_repo(repo) != 0) { + th_rmtree(repo); + SKIP_PLATFORM("git not available to init a repo"); + } + cbm_git_context_t ctx = {0}; + if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) { + cbm_git_context_free(&ctx); + th_rmtree(repo); + SKIP_PLATFORM("git head not resolvable"); + } + char *head = strdup(ctx.head_sha); + cbm_git_context_free(&ctx); + + cbm_mcp_server_t *srv = mcp_freshness_server(repo, head); + if (!srv) { + free(head); + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", + "{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"current\"")); + ASSERT_NOT_NULL(strstr(inner, "indexed_checkout_current")); + ASSERT_NOT_NULL(strstr(inner, "\"indexed_checkout_sha\":\"")); + ASSERT_NOT_NULL(strstr(inner, "\"checkout_sha\":\"")); + ASSERT_NOT_NULL(strstr(inner, "\"recommended_action\":\"use_graph\"")); + ASSERT_NOT_NULL(strstr(inner, "\"status_available\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"tracked_changes\":")); + ASSERT_NOT_NULL(strstr(inner, "\"untracked_source\":")); + ASSERT_NOT_NULL(strstr(inner, "\"count\":0")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + free(head); + th_rmtree(repo); + PASS(); +} + +TEST(tool_index_status_freshness_verdict_stale_when_indexed_checkout_mismatches) { + char *repo = th_mktempdir("cbm_fresh_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + if (mcp_make_git_repo(repo) != 0) { + th_rmtree(repo); + SKIP_PLATFORM("git not available to init a repo"); + } + + /* A stale recorded identity that does not equal the live HEAD. */ + cbm_mcp_server_t *srv = mcp_freshness_server(repo, "0000000000000000000000000000000000000000"); + if (!srv) { + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", + "{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"stale\"")); + ASSERT_NOT_NULL(strstr(inner, "indexed_checkout_mismatch")); + ASSERT_NOT_NULL(strstr(inner, "\"indexed_checkout_sha\":\"0000000000000000000000000000000000000000\"")); + ASSERT_NOT_NULL(strstr(inner, "\"checkout_sha\":\"")); + ASSERT_NOT_NULL(strstr(inner, "\"recommended_action\":\"reindex_to_match_checkout\"")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + th_rmtree(repo); + PASS(); +} + +/* BT-240 increment 3: a modified tracked file makes the verdict stale even + * when the indexed SHA matches the live HEAD, with the sample surfaced. */ +TEST(tool_index_status_freshness_verdict_stale_when_tracked_changes) { + char *repo = th_mktempdir("cbm_fresh_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + if (mcp_make_git_repo(repo) != 0) { + th_rmtree(repo); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(repo, "tracked.txt"), "v1") != 0 || + mcp_git_run(repo, "add -f -- tracked.txt") != 0 || + mcp_git_run(repo, "commit -q -m add-tracked") != 0) { + th_rmtree(repo); + FAIL("failed to commit tracked file"); + } + + cbm_git_context_t ctx = {0}; + if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) { + cbm_git_context_free(&ctx); + th_rmtree(repo); + SKIP_PLATFORM("git head not resolvable"); + } + char *head = strdup(ctx.head_sha); + cbm_git_context_free(&ctx); + + if (th_write_file(TH_PATH(repo, "tracked.txt"), "v2") != 0) { + free(head); + th_rmtree(repo); + FAIL("failed to modify tracked file"); + } + + cbm_mcp_server_t *srv = mcp_freshness_server(repo, head); + if (!srv) { + free(head); + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", + "{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"stale\"")); + ASSERT_NOT_NULL(strstr(inner, "tracked_changes_present")); + ASSERT_NOT_NULL(strstr(inner, "\"tracked_changes\":")); + ASSERT_NOT_NULL(strstr(inner, "\"count\":1")); + ASSERT_NOT_NULL(strstr(inner, "\"paths\":[\"tracked.txt\"]")); + ASSERT_NOT_NULL(strstr(inner, "\"truncated\":false")); + ASSERT_NULL(strstr(inner, "indexed_checkout_current")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + free(head); + th_rmtree(repo); + PASS(); +} + +/* BT-240 increment 3: untracked sources prevent "current" — verdict is + * unknown (no stale reason), with the untracked_not_indexed reason. */ +TEST(tool_index_status_freshness_verdict_unknown_when_untracked_only) { + char *repo = th_mktempdir("cbm_fresh_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + if (mcp_make_git_repo(repo) != 0) { + th_rmtree(repo); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(repo, "newfile.txt"), "n") != 0) { + th_rmtree(repo); + FAIL("failed to create untracked file"); + } + + cbm_git_context_t ctx = {0}; + if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) { + cbm_git_context_free(&ctx); + th_rmtree(repo); + SKIP_PLATFORM("git head not resolvable"); + } + char *head = strdup(ctx.head_sha); + cbm_git_context_free(&ctx); + + cbm_mcp_server_t *srv = mcp_freshness_server(repo, head); + if (!srv) { + free(head); + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", + "{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"unknown\"")); + ASSERT_NOT_NULL(strstr(inner, "untracked_not_indexed")); + ASSERT_NOT_NULL(strstr(inner, "\"untracked_source\":")); + ASSERT_NOT_NULL(strstr(inner, "\"count\":1")); + ASSERT_NULL(strstr(inner, "indexed_checkout_current")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + free(head); + th_rmtree(repo); + PASS(); +} + +/* BT-240 increment 3: tracked changes win over untracked — both reasons are + * present but the verdict is stale, never unknown. */ +TEST(tool_index_status_freshness_verdict_stale_when_tracked_and_untracked) { + char *repo = th_mktempdir("cbm_fresh_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + if (mcp_make_git_repo(repo) != 0) { + th_rmtree(repo); + SKIP_PLATFORM("git not available to init a repo"); + } + if (th_write_file(TH_PATH(repo, "tracked.txt"), "v1") != 0 || + mcp_git_run(repo, "add -f -- tracked.txt") != 0 || + mcp_git_run(repo, "commit -q -m add-tracked") != 0) { + th_rmtree(repo); + FAIL("failed to commit tracked file"); + } + + cbm_git_context_t ctx = {0}; + if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) { + cbm_git_context_free(&ctx); + th_rmtree(repo); + SKIP_PLATFORM("git head not resolvable"); + } + char *head = strdup(ctx.head_sha); + cbm_git_context_free(&ctx); + + if (th_write_file(TH_PATH(repo, "tracked.txt"), "v2") != 0 || + th_write_file(TH_PATH(repo, "untracked.txt"), "n") != 0) { + free(head); + th_rmtree(repo); + FAIL("failed to prepare mixed worktree"); + } + + cbm_mcp_server_t *srv = mcp_freshness_server(repo, head); + if (!srv) { + free(head); + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", + "{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"stale\"")); + ASSERT_NOT_NULL(strstr(inner, "tracked_changes_present")); + ASSERT_NOT_NULL(strstr(inner, "untracked_not_indexed")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + free(head); + th_rmtree(repo); + PASS(); +} + +/* BT-240 increment 3: a non-git root with a recorded identity cannot prove + * current — status availability must be false and the verdict unknown. */ +TEST(tool_index_status_freshness_status_unavailable_non_git) { + char *repo = th_mktempdir("cbm_fresh_non_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + + /* Record an identity even though the root is not a git repository. */ + cbm_mcp_server_t *srv = mcp_freshness_server(repo, "0123456789012345678901234567890123456789"); + if (!srv) { + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", + "{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"status_available\":false")); + ASSERT_NOT_NULL(strstr(inner, "status_unavailable")); + ASSERT_NOT_NULL(strstr(inner, "\"verdict\":\"unknown\"")); + ASSERT_NULL(strstr(inner, "indexed_checkout_current")); + ASSERT_NOT_NULL(strstr(inner, "\"tracked_changes\":")); + ASSERT_NOT_NULL(strstr(inner, "\"untracked_source\":")); + ASSERT_NOT_NULL(strstr(inner, "\"count\":0")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + th_rmtree(repo); + PASS(); +} + +/* BT-240 increment 3: sample cap at the MCP layer — many untracked sources + * surface the true count, a bounded paths array and truncated=true. */ +TEST(tool_index_status_freshness_sample_cap_truncates) { + char *repo = th_mktempdir("cbm_fresh_git"); + if (!repo) FAIL("th_mktempdir returned NULL"); + if (mcp_make_git_repo(repo) != 0) { + th_rmtree(repo); + SKIP_PLATFORM("git not available to init a repo"); + } + enum { N_FILES = 17, MCP_SAMPLE_MAX = 16 }; + for (int i = 0; i < N_FILES; i++) { + char path[1024]; + snprintf(path, sizeof(path), "%s/u%02d.txt", repo, i); + if (th_write_file(path, "u") != 0) { + th_rmtree(repo); + FAIL("failed to create untracked file"); + } + } + + cbm_git_context_t ctx = {0}; + if (cbm_git_context_resolve(repo, &ctx) != 0 || !ctx.head_sha || !ctx.head_sha[0]) { + cbm_git_context_free(&ctx); + th_rmtree(repo); + SKIP_PLATFORM("git head not resolvable"); + } + char *head = strdup(ctx.head_sha); + cbm_git_context_free(&ctx); + + cbm_mcp_server_t *srv = mcp_freshness_server(repo, head); + if (!srv) { + free(head); + th_rmtree(repo); + FAIL("mcp_freshness_server failed"); + } + + char *resp = cbm_mcp_handle_tool( + srv, "index_status", + "{\"project\":\"fresh-project\",\"verbose\":true,\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"untracked_source\":")); + ASSERT_NOT_NULL(strstr(inner, "\"count\":17")); + ASSERT_NOT_NULL(strstr(inner, "\"truncated\":true")); + static const char EXPECTED_PATHS[] = + "\"paths\":[\"u00.txt\",\"u01.txt\",\"u02.txt\",\"u03.txt\",\"u04.txt\",\"u05.txt\"," + "\"u06.txt\",\"u07.txt\",\"u08.txt\",\"u09.txt\",\"u10.txt\",\"u11.txt\",\"u12.txt\"," + "\"u13.txt\",\"u14.txt\",\"u15.txt\"]"; + ASSERT_NOT_NULL(strstr(inner, EXPECTED_PATHS)); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + free(head); + th_rmtree(repo); + PASS(); +} /* #2816: stored recursion must remain visible even when the CALLS graph has * no self-edges. Removing metadata emission must fail these assertions. */ @@ -24818,6 +25326,291 @@ TEST(mcp_auto_watch_false_skips_watcher_on_connect) { PASS(); } +/* ═══════════════════════════════════════════════════════════════ + * ignore_worktrees — explicit index_repository on a linked worktree + * ═══════════════════════════════════════════════════════════════ */ + +/* An EXPLICIT index_repository call is refused only when ignore_worktrees is on + * AND repo_path is a linked worktree AND no per-call override was passed. The + * refusal must never fire for the MAIN checkout — that would break ordinary + * indexing for anyone enabling the key — and index_worktree=true must escape it. + * + * Default-off is the regression class the project guards hardest: with the key + * unset, a linked worktree still indexes exactly as today. + * + * Probe returns a bit set, or a negative fixture-setup code. */ +enum { + IGNORE_WT_REFUSED_WORKTREE = 1, /* expected when the key is on */ + IGNORE_WT_REFUSED_MAIN = 2, /* BUG if set */ + IGNORE_WT_REFUSED_OVERRIDE = 4, /* BUG if set */ +}; + +#ifndef _WIN32 +typedef struct { + char cache[256]; + char main_repo[512]; + char wt_repo[512]; +} ignore_wt_fixture_t; + +static void ignore_wt_fixture_cleanup(ignore_wt_fixture_t *fx) { + if (!fx) { + return; + } + if (fx->main_repo[0]) { + char prune[1024]; + snprintf(prune, sizeof(prune), "git -C \"%s\" worktree prune >/dev/null 2>&1", fx->main_repo); + (void)system(prune); + } + if (fx->cache[0]) { + th_rmtree(fx->cache); + } +} + +/* 0 = ready. -1 tmpdir, -2 mkdir, -3 git worktree fixture unavailable. */ +static int ignore_wt_fixture_setup(ignore_wt_fixture_t *fx) { + memset(fx, 0, sizeof(*fx)); + char *raw = th_mktempdir("cbm_ignorewt"); + if (!raw) { + return -1; + } + snprintf(fx->cache, sizeof(fx->cache), "%s", raw); + snprintf(fx->main_repo, sizeof(fx->main_repo), "%s/main", fx->cache); + snprintf(fx->wt_repo, sizeof(fx->wt_repo), "%s/wt", fx->cache); + if (th_mkdir_p(fx->main_repo) != 0) { + ignore_wt_fixture_cleanup(fx); + return -2; + } + + char cmd[2048]; + snprintf(cmd, sizeof(cmd), + "git -C \"%s\" init -q >/dev/null 2>&1 && " + "git -C \"%s\" config user.email t@example.com && " + "git -C \"%s\" config user.name T && touch \"%s/.keep\" && " + "git -C \"%s\" add .keep && git -C \"%s\" commit -q -m init && " + "git -C \"%s\" worktree add -q \"%s\" -b wtb", + fx->main_repo, fx->main_repo, fx->main_repo, fx->main_repo, fx->main_repo, + fx->main_repo, fx->main_repo, fx->wt_repo); + if (system(cmd) != 0) { + ignore_wt_fixture_cleanup(fx); + return -3; + } + return 0; +} + +static bool ignore_wt_refused(cbm_mcp_server_t *srv, const char *repo_path, bool override) { + char args[2048]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"%s}", repo_path, + override ? ",\"index_worktree\":true" : ""); + char *result = cbm_mcp_handle_tool(srv, "index_repository", args); + bool refused = result && strstr(result, "ignore_worktrees is enabled") != NULL; + free(result); + return refused; +} + +static int ignore_worktrees_index_probe(const char *ignore_value) { + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup < 0) { + return setup; + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", fx.cache, 1); + + int bits = -4; + cbm_config_t *cfg = cbm_config_open(fx.cache); + if (cfg) { + if (ignore_value) { + cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, ignore_value); + } + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (srv) { + cbm_mcp_server_set_config(srv, cfg); + bits = 0; + if (ignore_wt_refused(srv, fx.wt_repo, false)) { + bits |= IGNORE_WT_REFUSED_WORKTREE; + } + if (ignore_wt_refused(srv, fx.main_repo, false)) { + bits |= IGNORE_WT_REFUSED_MAIN; + } + if (ignore_wt_refused(srv, fx.wt_repo, true)) { + bits |= IGNORE_WT_REFUSED_OVERRIDE; + } + cbm_mcp_server_free(srv); + } + cbm_config_close(cfg); + } + + restore_cache_dir(saved_copy); + free(saved_copy); + ignore_wt_fixture_cleanup(&fx); + return bits; +} + +#ifdef CBM_ENABLE_TEST_SEAMS +static void ignore_wt_count_started(void *context) { + int *calls = context; + (*calls)++; +} + +/* Drive initialize → maybe_auto_index on a linked worktree. ignore_value NULL + * leaves the key unset (default-off). The count hook fires only if auto-index + * reaches discovery, so a skip for linked_worktree must leave it at 0. */ +static int ignore_wt_autoindex_count_calls(const char *session_root, const char *ignore_value) { + char *raw = th_mktempdir("cbm_ignorewt_auto"); + if (!raw) { + return -1; + } + char cache[256]; + snprintf(cache, sizeof(cache), "%s", raw); + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char old_cwd[1024]; + if (!cbm_getcwd(old_cwd, sizeof(old_cwd)) || cbm_chdir(session_root) != 0) { + restore_cache_dir(saved_copy); + free(saved_copy); + th_cleanup(cache); + return -2; + } + + int calls = -3; + cbm_config_t *cfg = cbm_config_open(cache); + cbm_mcp_server_t *srv = cfg ? cbm_mcp_server_new(NULL) : NULL; + if (srv) { + cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX, "true"); + /* Fail-closed on the actual count so this never launches an index thread. */ + cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX_LIMIT, "-1"); + if (ignore_value) { + cbm_config_set(cfg, CBM_CONFIG_IGNORE_WORKTREES, ignore_value); + } + cbm_mcp_server_set_config(srv, cfg); + calls = 0; + cbm_mcp_server_set_auto_index_count_test_hook(srv, ignore_wt_count_started, &calls); + char *response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1430,\"method\":\"initialize\",\"params\":{}}"); + free(response); + cbm_mcp_server_free(srv); + } + if (cfg) { + cbm_config_close(cfg); + } + + (void)cbm_chdir(old_cwd); + restore_cache_dir(saved_copy); + free(saved_copy); + th_cleanup(cache); + return calls; +} +#endif /* CBM_ENABLE_TEST_SEAMS */ +#endif /* !_WIN32 */ + +TEST(mcp_ignore_worktrees_gates_explicit_index_repository) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + int bits = ignore_worktrees_index_probe("true"); + if (bits == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (bits == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (bits < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + /* RED before the gate existed: nothing is ever refused. */ + ASSERT((bits & IGNORE_WT_REFUSED_WORKTREE) != 0); + ASSERT((bits & IGNORE_WT_REFUSED_MAIN) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_OVERRIDE) == 0); + PASS(); +#endif /* _WIN32 */ +} + +/* Default-off pin: key unset, linked worktree still indexes. The gate is + * opt-in; an unset key must be bit-for-bit today's behaviour. */ +TEST(mcp_ignore_worktrees_default_off_still_indexes_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + int bits = ignore_worktrees_index_probe(NULL); + if (bits == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (bits == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (bits < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + ASSERT((bits & IGNORE_WT_REFUSED_WORKTREE) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_MAIN) == 0); + ASSERT((bits & IGNORE_WT_REFUSED_OVERRIDE) == 0); + PASS(); +#endif /* _WIN32 */ +} + +#ifdef CBM_ENABLE_TEST_SEAMS +TEST(mcp_ignore_worktrees_skips_auto_index_on_linked_worktree) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (setup == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (setup < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + int calls = ignore_wt_autoindex_count_calls(fx.wt_repo, "true"); + ignore_wt_fixture_cleanup(&fx); + if (calls == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (calls < 0) { + FAIL("ignore_worktrees auto-index fixture failed"); + } + ASSERT_EQ(calls, 0); + PASS(); +#endif /* _WIN32 */ +} + +TEST(mcp_ignore_worktrees_default_off_auto_index_reaches_count) { +#ifdef _WIN32 + SKIP_PLATFORM("git worktree fixture not implemented for Windows"); +#else + ignore_wt_fixture_t fx; + int setup = ignore_wt_fixture_setup(&fx); + if (setup == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (setup == -2) { + FAIL("failed to create ignore_worktrees fixture directory"); + } + if (setup < 0) { + SKIP_PLATFORM("git worktree add unavailable (git 2.5+ required)"); + } + int calls = ignore_wt_autoindex_count_calls(fx.wt_repo, NULL); + ignore_wt_fixture_cleanup(&fx); + if (calls == -1) { + FAIL("th_mktempdir returned NULL"); + } + if (calls < 0) { + FAIL("ignore_worktrees auto-index fixture failed"); + } + ASSERT_EQ(calls, 1); + PASS(); +#endif /* _WIN32 */ +} +#endif /* CBM_ENABLE_TEST_SEAMS */ + /* ══════════════════════════════════════════════════════════════════ * #1466 / #713 — the auto_index_limit guard * @@ -27131,6 +27924,7 @@ SUITE(mcp) { /* MCP protocol helpers */ RUN_TEST(mcp_initialize_response); + RUN_TEST(mcp_ignore_worktrees_runtime_read_leaves_server_and_store_alone); RUN_TEST(mcp_tools_list); RUN_TEST(mcp_tools_help_list_matches_registry); RUN_TEST(mcp_tools_list_latest_metadata); @@ -27279,6 +28073,15 @@ SUITE(mcp) { RUN_TEST(tool_responses_carry_store_generation_issue2585); RUN_TEST(tool_check_index_coverage_surfaces_lookup_errors); RUN_TEST(tool_index_status_includes_git_metadata); + RUN_TEST(tool_index_status_fails_closed_without_indexed_checkout_identity); + RUN_TEST(tool_index_status_omits_freshness_by_default); + RUN_TEST(tool_index_status_freshness_verdict_current_when_indexed_checkout_matches); + RUN_TEST(tool_index_status_freshness_verdict_stale_when_indexed_checkout_mismatches); + RUN_TEST(tool_index_status_freshness_verdict_stale_when_tracked_changes); + RUN_TEST(tool_index_status_freshness_verdict_unknown_when_untracked_only); + RUN_TEST(tool_index_status_freshness_verdict_stale_when_tracked_and_untracked); + RUN_TEST(tool_index_status_freshness_status_unavailable_non_git); + RUN_TEST(tool_index_status_freshness_sample_cap_truncates); /* Tool handlers with validation */ RUN_TEST(tool_trace_path_recursion_metadata_issue2816); @@ -27532,6 +28335,13 @@ SUITE(mcp) { RUN_TEST(autoindex_limit_guards_non_git_root_issue713); RUN_TEST(autoindex_limit_admits_non_git_root_under_limit_issue713); RUN_TEST(autoindex_limit_guards_git_root_issue713); + /* ignore_worktrees gate */ + RUN_TEST(mcp_ignore_worktrees_gates_explicit_index_repository); + RUN_TEST(mcp_ignore_worktrees_default_off_still_indexes_worktree); +#ifdef CBM_ENABLE_TEST_SEAMS + RUN_TEST(mcp_ignore_worktrees_skips_auto_index_on_linked_worktree); + RUN_TEST(mcp_ignore_worktrees_default_off_auto_index_reaches_count); +#endif RUN_TEST(mcp_auto_index_in_process_fresh_project_uses_full_width); RUN_TEST(mcp_auto_index_in_process_existing_index_keeps_headroom); RUN_TEST(mcp_background_flag_fresh_project_uses_full_width); diff --git a/tests/test_php_lsp.c b/tests/test_php_lsp.c index 04ff1001d..7c0314a27 100644 --- a/tests/test_php_lsp.c +++ b/tests/test_php_lsp.c @@ -110,6 +110,65 @@ TEST(phplsp_local_method_via_typed_param) { PASS(); } +/* #2000: with no cached tree (a result spilled under the memory budget comes + * back without one) the cross-file pass re-parses the on-disk source itself. + * It must blank inline HTML exactly as the per-file extract does, or a file's + * tail would lose cross-file resolution only when that file spilled. */ +static int find_resolved_arr(const CBMResolvedCallArray *arr, const char *callerSub, + const char *calleeSub) { + for (int i = 0; i < arr->count; i++) { + const CBMResolvedCall *rc = &arr->items[i]; + if (rc->caller_qn && strstr(rc->caller_qn, callerSub) && rc->callee_qn && + strstr(rc->callee_qn, calleeSub)) + return i; + } + return -1; +} + +TEST(phplsp_cross_reparse_reads_past_inline_html_issue2000) { + const char *src = "\n" + "
    \n" + "

    plain markup after the closing tag

    \n" + "
    \n" + "send(); }\n"; + CBMLSPDef defs[] = { + {.qualified_name = "test.main.gamma", + .short_name = "gamma", + .label = "Function", + .def_module_qn = "test.main"}, + {.qualified_name = "test.main.delta", + .short_name = "delta", + .label = "Function", + .def_module_qn = "test.main"}, + {.qualified_name = "test.mailer.Mailer", + .short_name = "Mailer", + .label = "Class", + .def_module_qn = "test.mailer"}, + {.qualified_name = "test.mailer.Mailer.send", + .short_name = "send", + .label = "Method", + .def_module_qn = "test.mailer", + .receiver_type = "test.mailer.Mailer"}, + }; + const char *imp_names[] = {"Mailer"}; + const char *imp_qns[] = {"test.mailer.Mailer"}; + + CBMArena arena; + cbm_arena_init(&arena); + CBMResolvedCallArray out = {0}; + + cbm_run_php_lsp_cross(&arena, src, (int)strlen(src), "test.main", defs, 4, imp_names, imp_qns, + 1, NULL, &out); + + ASSERT_GTE(find_resolved_arr(&out, "main.delta", "Mailer.send"), 0); + + cbm_arena_destroy(&arena); + PASS(); +} + /* ── 3. Arrow function with typed parameter ───────────────────── */ TEST(phplsp_arrow_function_typed_param) { @@ -5841,6 +5900,7 @@ SUITE(php_lsp) { /* Phase 1 baseline regressions */ RUN_TEST(phplsp_local_method_via_new_assignment); RUN_TEST(phplsp_local_method_via_typed_param); + RUN_TEST(phplsp_cross_reparse_reads_past_inline_html_issue2000); RUN_TEST(phplsp_arrow_function_typed_param); RUN_TEST(phplsp_static_call_resolved); RUN_TEST(phplsp_self_and_parent); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 8a4c8a077..e43ad581c 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -2170,6 +2170,57 @@ TEST(pipeline_nix_scoped_binding_calls_resolve) { PASS(); } +/* #2000, end to end: functions declared after an inline-HTML block reach the + * store with their CALLS edges, in the same file and across files. */ +TEST(pipeline_php_inline_html_calls_resolve_issue2000) { + if (setup_test_repo() != 0) { + FAIL("failed to create temp dir"); + } + + static const struct { + const char *name; + const char *body; + } files[] = { + {"inline_html.php", "\n" + "
    \n" + "

    plain markup after the closing tag

    \n" + "
    \n" + "\n" + "\n" + "

    \n" + " Layout.__init__ */ ASSERT_TRUE(method[run]); /* .create() through the constructed type */ } PASS(); @@ -31538,6 +31590,7 @@ SUITE(pipeline) { /* Calls pass */ RUN_TEST(pipeline_calls_resolution); RUN_TEST(pipeline_nix_scoped_binding_calls_resolve); + RUN_TEST(pipeline_php_inline_html_calls_resolve_issue2000); RUN_TEST(pipeline_hcl_block_reference_resolves_to_its_block); RUN_TEST(pipeline_incremental_preserves_cross_file_calls); RUN_TEST(pipeline_incremental_drops_terraform_reads_of_a_moved_module); diff --git a/tests/test_py_lsp.c b/tests/test_py_lsp.c index 429fb6628..1aa5c806d 100644 --- a/tests/test_py_lsp.c +++ b/tests/test_py_lsp.c @@ -356,6 +356,193 @@ TEST(pylsp_constructor_call_returns_instance) { PASS(); } +/* Issue #1642: Embedder(...) must resolve to Embedder.__init__, and the + * textual callee leaf "Embedder" must still join that row. */ +TEST(pylsp_constructor_call_targets_init) { + const char *source = "class Embedder:\n" + " def __init__(self, model=\"x\"):\n" + " self.model = model\n" + "def build():\n" + " return Embedder(model=\"y\")\n"; + CBMFileResult *r = extract_py(source); + ASSERT_NOT_NULL(r); + int idx = require_resolved(r, "build", "Embedder.__init__"); + ASSERT_GTE(idx, 0); + if (idx >= 0) { + const CBMResolvedCall *rc = &r->resolved_calls.items[idx]; + ASSERT_STR_EQ(rc->strategy, "lsp_constructor_init"); + ASSERT_STR_EQ(rc->reason, "Embedder"); + ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0); + } + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->kind != CBM_RESOLVED_INVOCATION || !rc->caller_qn || !rc->callee_qn || + !strstr(rc->caller_qn, "build")) + continue; + /* One site, one target: the class node no longer receives the call. */ + ASSERT(strstr(rc->callee_qn, ".__init__") != NULL); + ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0); + } + + const CBMCall *carrier = NULL; + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *call = &r->calls.items[i]; + if (call->callee_name && strcmp(call->callee_name, "Embedder") == 0 && + call->enclosing_func_qn && strstr(call->enclosing_func_qn, "build")) { + carrier = call; + break; + } + } + ASSERT_NOT_NULL(carrier); + const CBMResolvedCall *joined = + cbm_pipeline_find_lsp_resolution(&r->resolved_calls, carrier, false); + ASSERT_NOT_NULL(joined); + ASSERT(strstr(joined->callee_qn, "Embedder.__init__") != NULL); + ASSERT_STR_EQ(joined->strategy, "lsp_constructor_init"); + cbm_free_result(r); + PASS(); +} + +/* Same-file subclasses: only an __init__ the class declares itself is a + * construction target. B(A) inherits A.__init__ but declares none, so B() + * stays on the class B (never A.__init__). C(A) declares its own __init__, + * so C() resolves to C.__init__ (never the inherited A.__init__). */ +static int count_build_rows(const CBMFileResult *r, const char *caller_leaf, + const char *callee_qn, const char *strategy) { + int n = 0; + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->kind != CBM_RESOLVED_INVOCATION || !rc->caller_qn || !rc->callee_qn || + !rc->strategy) + continue; + const char *dot = strrchr(rc->caller_qn, '.'); + const char *leaf = dot ? dot + 1 : rc->caller_qn; + if (strcmp(leaf, caller_leaf) == 0 && strcmp(rc->callee_qn, callee_qn) == 0 && + strcmp(rc->strategy, strategy) == 0) + n++; + } + return n; +} + +TEST(pylsp_constructor_same_file_subclass_own_init_only) { + CBMFileResult *r = extract_py("class A:\n" + " def __init__(self):\n" + " self.ready = True\n" + "class B(A):\n" + " def go(self):\n" + " return 1\n" + "class C(A):\n" + " def __init__(self):\n" + " self.ready = False\n" + "def build_b():\n" + " return B()\n" + "def build_c():\n" + " return C()\n"); + ASSERT_NOT_NULL(r); + /* B() keeps the class target; the inherited A.__init__ gets no row. */ + ASSERT_EQ(count_build_rows(r, "build_b", "test.main.B", "lsp_constructor"), 1); + ASSERT_EQ(find_resolved(r, "build_b", "__init__"), -1); + /* C() binds to the __init__ C declares itself. */ + int idx = require_resolved(r, "build_c", "test.main.C.__init__"); + ASSERT_GTE(idx, 0); + if (idx >= 0) { + const CBMResolvedCall *rc = &r->resolved_calls.items[idx]; + ASSERT_STR_EQ(rc->strategy, "lsp_constructor_init"); + ASSERT_STR_EQ(rc->reason, "C"); + } + ASSERT_EQ(find_resolved(r, "build_c", "test.main.A.__init__"), -1); + ASSERT_EQ(count_build_rows(r, "build_c", "test.main.C", "lsp_constructor"), 0); + cbm_free_result(r); + PASS(); +} + +/* No user __init__ (and no inherited one): the call stays on the class. */ +TEST(pylsp_constructor_without_init_stays_on_class) { + CBMFileResult *r = extract_py("class Bare:\n" + " def method(self):\n" + " return 1\n" + "def build():\n" + " return Bare()\n"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(find_resolved(r, "build", "__init__"), -1); + int idx = -1; + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->kind == CBM_RESOLVED_INVOCATION && rc->caller_qn && + strstr(rc->caller_qn, "build") && rc->callee_qn && strstr(rc->callee_qn, "Bare") && + !strstr(rc->callee_qn, "__init__")) { + idx = i; + break; + } + } + ASSERT_GTE(idx, 0); + if (idx >= 0) + ASSERT_STR_EQ(r->resolved_calls.items[idx].strategy, "lsp_constructor"); + cbm_free_result(r); + PASS(); +} + +/* f = Foo(); f() is a callable instance, not a constructor. It must not + * gain an edge to Foo.__init__. Foo() itself still does. */ +TEST(pylsp_callable_instance_call_skips_init) { + CBMFileResult *r = extract_py("class Foo:\n" + " def __init__(self):\n" + " pass\n" + " def __call__(self):\n" + " return 1\n" + "def use():\n" + " f = Foo()\n" + " return f()\n"); + ASSERT_NOT_NULL(r); + int init_rows = 0; + int class_rows = 0; + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->kind != CBM_RESOLVED_INVOCATION || !rc->caller_qn || !rc->callee_qn || + !strstr(rc->caller_qn, "use")) + continue; + if (strstr(rc->callee_qn, "__init__")) { + init_rows++; + ASSERT_STR_EQ(rc->strategy, "lsp_constructor_init"); + ASSERT_STR_EQ(rc->reason, "Foo"); + ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0); + } else if (strstr(rc->callee_qn, "Foo")) { + class_rows++; + ASSERT_STR_EQ(rc->strategy, "lsp_constructor"); + ASSERT_NULL(rc->reason); + } + } + ASSERT_EQ(init_rows, 1); + ASSERT_EQ(class_rows, 1); + cbm_free_result(r); + PASS(); +} + +/* builtins.object.__init__ is in the stdlib registry. Construction must not + * record a resolution to it. */ +TEST(pylsp_constructor_skips_builtin_init) { + CBMFileResult *r = extract_py("from builtins import object\n" + "def build():\n" + " return object()\n"); + ASSERT_NOT_NULL(r); + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (!rc->callee_qn) + continue; + ASSERT(strncmp(rc->callee_qn, "builtins.", 9) != 0 || + strstr(rc->callee_qn, ".__init__") == NULL); + } + int idx = find_resolved(r, "build", "builtins.object"); + ASSERT_GTE(idx, 0); + if (idx >= 0) { + const CBMResolvedCall *rc = &r->resolved_calls.items[idx]; + ASSERT_STR_EQ(rc->strategy, "lsp_constructor"); + ASSERT(strstr(rc->callee_qn, "__init__") == NULL); + } + cbm_free_result(r); + PASS(); +} + TEST(pylsp_method_via_inheritance) { CBMFileResult *r = extract_py( "class Base:\n" @@ -924,6 +1111,104 @@ TEST(pylsp_crossfile_inheritance) { PASS(); } +/* Imported ClassName() resolves to that class's __init__. Bare names are + * module-qualified during per-file registration, so the imported class has + * to come from the cross-file registry. */ +TEST(pylsp_crossfile_constructor_targets_init) { + const char *source = "from models import Embedder\n" + "def build():\n" + " return Embedder(model=\"y\")\n"; + + CBMLSPDef defs[2]; + memset(defs, 0, sizeof(defs)); + defs[0].qualified_name = "models.Embedder"; + defs[0].short_name = "Embedder"; + defs[0].label = "Class"; + defs[0].def_module_qn = "models"; + defs[0].lang = CBM_LANG_PYTHON; + defs[1].qualified_name = "models.Embedder.__init__"; + defs[1].short_name = "__init__"; + defs[1].label = "Method"; + defs[1].receiver_type = "models.Embedder"; + defs[1].def_module_qn = "models"; + defs[1].lang = CBM_LANG_PYTHON; + + const char *imp_names[] = {"Embedder"}; + const char *imp_qns[] = {"models.Embedder"}; + + CBMArena arena; + cbm_arena_init(&arena); + CBMTypeRegistry *reg = cbm_py_build_cross_registry(&arena, defs, 2); + ASSERT_NOT_NULL(reg); + CBMResolvedCallArray out = {0}; + cbm_run_py_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.main", reg, + imp_names, imp_qns, 1, NULL, &out, NULL); + + int idx = find_resolved_arr(&out, "build", "models.Embedder.__init__"); + ASSERT_GTE(idx, 0); + if (idx >= 0) { + ASSERT_STR_EQ(out.items[idx].strategy, "lsp_constructor_init"); + ASSERT_STR_EQ(out.items[idx].reason, "Embedder"); + ASSERT(strncmp(out.items[idx].callee_qn, "builtins.", 9) != 0); + } + cbm_arena_destroy(&arena); + PASS(); +} + +/* class B(A) where A's __init__ lives in another module and B declares no + * __init__ of its own. Construction binds only to an __init__ the class + * declares itself, so B() stays on the class B and the inherited + * base_mod.A.__init__ gets no row. */ +TEST(pylsp_crossfile_inherited_init_stays_on_class) { + const char *source = "from base_mod import A\n" + "class B(A):\n" + " def go(self):\n" + " return 1\n" + "def build():\n" + " return B()\n"; + + CBMLSPDef defs[3]; + memset(defs, 0, sizeof(defs)); + defs[0].qualified_name = "base_mod.A"; + defs[0].short_name = "A"; + defs[0].label = "Class"; + defs[0].def_module_qn = "base_mod"; + defs[0].lang = CBM_LANG_PYTHON; + defs[1].qualified_name = "base_mod.A.__init__"; + defs[1].short_name = "__init__"; + defs[1].label = "Method"; + defs[1].receiver_type = "base_mod.A"; + defs[1].def_module_qn = "base_mod"; + defs[1].lang = CBM_LANG_PYTHON; + defs[2].qualified_name = "test.main.B"; + defs[2].short_name = "B"; + defs[2].label = "Class"; + defs[2].def_module_qn = "test.main"; + defs[2].embedded_types = "base_mod.A"; + defs[2].lang = CBM_LANG_PYTHON; + + const char *imp_names[] = {"A"}; + const char *imp_qns[] = {"base_mod.A"}; + + CBMArena arena; + cbm_arena_init(&arena); + CBMTypeRegistry *reg = cbm_py_build_cross_registry(&arena, defs, 3); + ASSERT_NOT_NULL(reg); + CBMResolvedCallArray out = {0}; + cbm_run_py_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.main", reg, + imp_names, imp_qns, 1, NULL, &out, NULL); + + ASSERT_EQ(find_resolved_arr(&out, "build", "__init__"), -1); + int idx = find_resolved_arr(&out, "build", "test.main.B"); + ASSERT_GTE(idx, 0); + if (idx >= 0) { + ASSERT_STR_EQ(out.items[idx].callee_qn, "test.main.B"); + ASSERT_STR_EQ(out.items[idx].strategy, "lsp_constructor"); + } + cbm_arena_destroy(&arena); + PASS(); +} + TEST(pylsp_batch_two_files) { const char *src_a = "def helper():\n" @@ -5010,6 +5295,11 @@ SUITE(py_lsp) { RUN_TEST(pylsp_method_call_simple); RUN_TEST(pylsp_method_via_self); RUN_TEST(pylsp_constructor_call_returns_instance); + RUN_TEST(pylsp_constructor_call_targets_init); + RUN_TEST(pylsp_constructor_same_file_subclass_own_init_only); + RUN_TEST(pylsp_constructor_without_init_stays_on_class); + RUN_TEST(pylsp_callable_instance_call_skips_init); + RUN_TEST(pylsp_constructor_skips_builtin_init); RUN_TEST(pylsp_method_via_inheritance); RUN_TEST(pylsp_no_false_positive_on_unknown_method); /* Phases 7-8 — decorators, super(), multi-inheritance */ @@ -5028,6 +5318,8 @@ SUITE(py_lsp) { RUN_TEST(pylsp_cross_registry_interleaved_order); RUN_TEST(pylsp_crossfile_classmethod_on_class_issue228); RUN_TEST(pylsp_crossfile_inheritance); + RUN_TEST(pylsp_crossfile_constructor_targets_init); + RUN_TEST(pylsp_crossfile_inherited_init_stays_on_class); RUN_TEST(pylsp_batch_two_files); RUN_TEST(pylsp_from_import_alias_equal_module_leaf_targets_imported_member); RUN_TEST(pylsp_project_prefixed_direct_alias_same_tail_is_not_from_import_reference); diff --git a/tests/test_ruby_lsp.c b/tests/test_ruby_lsp.c new file mode 100644 index 000000000..c5499f78e --- /dev/null +++ b/tests/test_ruby_lsp.c @@ -0,0 +1,1566 @@ +/* + * test_ruby_lsp.c — Tests for the Ruby Light Semantic Pass. + * + * Coverage mirrors tests/test_perl_lsp.c / tests/test_php_lsp.c, exercising + * the foundational Ruby resolution scenarios: + * 1. QN contract (defs carry module_qn.ClassPath.method — the join key + * the resolver's emitted rows depend on) + * 2. Constructor typing + edge (a = Animal.new; a.speak) + * 3. Constructor edge targets CLASS (textual `Widget.new` -> "Widget") + * 4. Implicit/explicit self dispatch + * 5. Singleton (class) methods (def self.m; Animal.m) + * 6. Superclass chain lookup (class B < A) + * 7. include + prepend mixin lookup (prepend shadows the class's own) + * 8. extend mixin (class-side) lookup + * 9. super dispatch + * 10. Instance-variable typing (@thing = Foo.new; @thing.bar) + * 11. Chained call typing (Foo.new.bar) + * 12. Nested modules (lexical nesting + `A::B` references) + * 12b. Compact `class A::B` names (exact extractor spelling, scope) + * 13. ActiveRecord model typing (User.find(1).full_name) + * 14. Top-level function calls + * 15. Unresolvable receiver emits NO spurious edge (negative) + * 15b. Scope gates (def, params, blocks, reassignment) + * 16. send() / the whole dynamic-dispatch family emits NO edge (negative) + * 16c. Blocks that rebind self emit NO self edge (negative) + * Fail-closed ancestry (7c) and block-nested defs (7d) sit with mixins. + * + * Scope: every row here is SINGLE-FILE, driven through cbm_extract_file so it + * exercises the per-file resolver exactly as the extraction pipeline calls it. + * Cross-file resolution (cbm_run_ruby_lsp_cross, wired through + * pass_lsp_cross.c) is a separate tier and is covered by its own rows. + * + * The resolver populates result->resolved_calls with CBMResolvedCall rows. + * The pipeline joins a row to its caller def by EXACT QN equality, so every + * row that pins a class-path spelling (nested or compact) asserts the full + * caller and callee QN with strcmp ("test"/"main.rb" -> module QN test.main). + * Substring helpers remain for rows where only presence/absence matters. + */ +#include "test_framework.h" +#include "cbm.h" +#include "../src/pipeline/lsp_resolve.h" +#include "lsp/ruby_lsp.h" +#include "lsp/lsp_work.h" +#include +#include +#include + +/* ── Helpers (mirror test_perl_lsp.c) ──────────────────────────── */ + +static CBMFileResult *extract_ruby(const char *source) { + return cbm_extract_file(source, (int)strlen(source), CBM_LANG_RUBY, "test", "main.rb", 0, NULL, + NULL); +} + +static int find_resolved(const CBMFileResult *r, const char *callerSub, const char *calleeSub) { + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->caller_qn && strstr(rc->caller_qn, callerSub) && rc->callee_qn && + strstr(rc->callee_qn, calleeSub)) + return i; + } + return -1; +} + +static int require_resolved(const CBMFileResult *r, const char *callerSub, const char *calleeSub) { + int idx = find_resolved(r, callerSub, calleeSub); + if (idx < 0) { + printf(" MISSING resolved call: caller~%s -> callee~%s (have %d)\n", callerSub, calleeSub, + r->resolved_calls.count); + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + printf(" %s -> %s [%s %.2f]\n", rc->caller_qn ? rc->caller_qn : "(null)", + rc->callee_qn ? rc->callee_qn : "(null)", rc->strategy ? rc->strategy : "(null)", + rc->confidence); + } + } + return idx; +} + +static const CBMResolvedCall *find_resolved_with_strategy(const CBMFileResult *r, + const char *callerSub, + const char *calleeSub, + const char *strategy) { + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (!rc->caller_qn || !rc->callee_qn) + continue; + if (!strstr(rc->caller_qn, callerSub)) + continue; + if (!strstr(rc->callee_qn, calleeSub)) + continue; + if (strategy && (!rc->strategy || strcmp(rc->strategy, strategy) != 0)) + continue; + return rc; + } + return NULL; +} + +/* Exact-name match on BOTH ends. The pipeline joins a resolved row to its + * caller def by exact QN equality, so a row whose caller is spelled even + * slightly differently from the extractor's def is silently dropped — + * substring matching would hide exactly that failure. */ +static int find_resolved_exact(const CBMFileResult *r, const char *caller_qn, + const char *callee_qn) { + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->caller_qn && rc->callee_qn && strcmp(rc->caller_qn, caller_qn) == 0 && + strcmp(rc->callee_qn, callee_qn) == 0) + return i; + } + return -1; +} + +static void dump_resolved(const CBMFileResult *r) { + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + printf(" %s -> %s [%s %.2f]\n", rc->caller_qn ? rc->caller_qn : "(null)", + rc->callee_qn ? rc->callee_qn : "(null)", rc->strategy ? rc->strategy : "(null)", + rc->confidence); + } +} + +static int require_resolved_exact(const CBMFileResult *r, const char *caller_qn, + const char *callee_qn) { + int idx = find_resolved_exact(r, caller_qn, callee_qn); + if (idx < 0) { + printf(" MISSING exact resolved call: %s -> %s (have %d)\n", caller_qn, callee_qn, + r->resolved_calls.count); + dump_resolved(r); + } + return idx; +} + +/* Number of rows with exactly this caller and callee. */ +static int count_resolved_exact(const CBMFileResult *r, const char *caller_qn, + const char *callee_qn) { + int n = 0; + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->caller_qn && rc->callee_qn && strcmp(rc->caller_qn, caller_qn) == 0 && + strcmp(rc->callee_qn, callee_qn) == 0) + n++; + } + return n; +} + +static const CBMDefinition *find_def(const CBMFileResult *r, const char *label, const char *name) { + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (d->label && d->name && strcmp(d->label, label) == 0 && strcmp(d->name, name) == 0) + return d; + } + return NULL; +} + +/* ── 1. QN contract: defs weave the class path into method QNs ─── */ + +TEST(rubylsp_qn_contract) { + const char *src = "class Animal\n" + " def speak\n" + " 'woof'\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + const CBMDefinition *m = find_def(r, "Method", "speak"); + if (!m) { + printf(" no Method speak def; defs:\n"); + for (int i = 0; i < r->defs.count; i++) + printf(" [%s] %s qn=%s parent=%s\n", r->defs.items[i].label, r->defs.items[i].name, + r->defs.items[i].qualified_name, + r->defs.items[i].parent_class ? r->defs.items[i].parent_class : "(null)"); + } + ASSERT(m); + if (strcmp(m->qualified_name, "test.main.Animal.speak") != 0) { + printf(" QN scheme mismatch: got %s (resolver assumes module_qn.ClassPath.method)\n", + m->qualified_name); + } + ASSERT(strcmp(m->qualified_name, "test.main.Animal.speak") == 0); + cbm_free_result(r); + PASS(); +} + +/* ── 2. Constructor typing: a = Animal.new; a.speak ────────────── */ + +TEST(rubylsp_method_via_constructor_assignment) { + const char *src = "class Animal\n" + " def initialize(name)\n" + " @name = name\n" + " end\n" + " def speak\n" + " @name\n" + " end\n" + "end\n" + "class Runner\n" + " def run\n" + " a = Animal.new('rex')\n" + " a.speak\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "Runner.run", "Animal.speak") >= 0); + ASSERT(find_resolved_with_strategy(r, "Runner.run", "Animal.speak", "ruby_method_typed")); + cbm_free_result(r); + PASS(); +} + +/* ── 3. Constructor edge targets the CLASS node ─────────────────── */ + +TEST(rubylsp_constructor_edge_targets_class) { + const char *src = "class Widget\n" + " def initialize\n" + " end\n" + "end\n" + "class Maker\n" + " def build\n" + " Widget.new\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + const CBMResolvedCall *rc = + find_resolved_with_strategy(r, "Maker.build", "Widget", "ruby_constructor"); + ASSERT(rc); + /* Callee is the class QN (leaf "Widget"), matching the textual + * extractor's Widget.new -> "Widget" rewrite so the row joins. */ + ASSERT(strcmp(rc->callee_qn, "test.main.Widget") == 0); + ASSERT(rc->confidence >= CBM_LSP_CONFIDENCE_FLOOR); + cbm_free_result(r); + PASS(); +} + +/* ── 4. Implicit + explicit self dispatch ───────────────────────── */ + +TEST(rubylsp_self_dispatch) { + const char *src = "class Greeter\n" + " def greet\n" + " build_greeting('hi')\n" + " self.build_greeting('yo')\n" + " end\n" + " def build_greeting(word)\n" + " word\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "Greeter.greet", "Greeter.build_greeting") >= 0); + ASSERT(find_resolved_with_strategy(r, "Greeter.greet", "Greeter.build_greeting", + "ruby_self_dispatch")); + cbm_free_result(r); + PASS(); +} + +/* ── 5. Singleton (class) methods ───────────────────────────────── */ + +TEST(rubylsp_singleton_method_dispatch) { + const char *src = "class Registry\n" + " def self.register(key)\n" + " key\n" + " end\n" + "end\n" + "class App\n" + " def boot\n" + " Registry.register(:db)\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "App.boot", "Registry.register") >= 0); + ASSERT(find_resolved_with_strategy(r, "App.boot", "Registry.register", "ruby_class_method")); + cbm_free_result(r); + PASS(); +} + +/* Singleton methods must NOT satisfy instance dispatch (and vice versa). */ +TEST(rubylsp_singleton_instance_split) { + const char *src = "class Config\n" + " def self.load\n" + " 1\n" + " end\n" + "end\n" + "class App\n" + " def boot\n" + " c = Config.new\n" + " c.load\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* c.load is an INSTANCE call; Config only defines a singleton `load`. + * Zero-edge guarantee: no resolved row may bind them. */ + ASSERT(find_resolved(r, "App.boot", "Config.load") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 6. Superclass chain ────────────────────────────────────────── */ + +TEST(rubylsp_inheritance) { + const char *src = "class Base\n" + " def helper\n" + " 1\n" + " end\n" + "end\n" + "class Child < Base\n" + " def work\n" + " helper\n" + " self.helper\n" + " end\n" + "end\n" + "class Driver\n" + " def drive\n" + " c = Child.new\n" + " c.helper\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Inherited method resolves to Base.helper from both dispatch shapes. */ + ASSERT(require_resolved(r, "Child.work", "Base.helper") >= 0); + ASSERT(require_resolved(r, "Driver.drive", "Base.helper") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 7. include mixin ───────────────────────────────────────────── */ + +TEST(rubylsp_include_mixin) { + const char *src = "module Greetable\n" + " def greet\n" + " 'hello'\n" + " end\n" + "end\n" + "class Person\n" + " include Greetable\n" + " def hail\n" + " greet()\n" + " end\n" + "end\n" + "class Caller\n" + " def run\n" + " p = Person.new\n" + " p.greet\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "Person.hail", "Greetable.greet") >= 0); + ASSERT(require_resolved(r, "Caller.run", "Greetable.greet") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 7b. prepend mixin shadows the class's own method ───────────── */ + +/* Ruby's lookup order puts prepended modules AHEAD of the class itself, which + * is the whole point of `prepend` over `include`. Both an own method and a + * prepended one exist here, so a resolver that walked the class first would + * still emit an edge — just the wrong one. Pin the winner, not merely that + * something resolved. */ +TEST(rubylsp_prepend_mixin) { + const char *src = "module Audited\n" + " def save\n" + " 'audited'\n" + " end\n" + "end\n" + "class Record\n" + " prepend Audited\n" + " def save\n" + " 'plain'\n" + " end\n" + "end\n" + "class App\n" + " def run\n" + " r = Record.new\n" + " r.save\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* The prepended module wins. */ + ASSERT(require_resolved(r, "App.run", "Audited.save") >= 0); + /* And the shadowed own method must NOT also be emitted. */ + ASSERT(find_resolved(r, "App.run", "Record.save") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 7c. Unresolved ancestry fails closed ───────────────────────── */ + +/* Three cases where an ancestor the resolver cannot see could define (or + * shadow) the method being called. Each one has a tempting local answer — + * the class's own method, a superclass method, a same-named top-level def — + * and each answer may be wrong, so none may be emitted. */ +TEST(rubylsp_unresolved_ancestry_fails_closed) { + const char *src = /* (a) unresolved PREPEND blocks an own-method hit: the + * prepended module may shadow `save`. */ + "class Record\n" + " prepend UnseenAudit\n" + " def save\n" + " 1\n" + " end\n" + " def persist\n" + " self.save\n" + " end\n" + "end\n" + "class RecordUser\n" + " def run\n" + " r = Record.new\n" + " r.save\n" + " end\n" + "end\n" + /* (b) unresolved INCLUDE stops the walk before the superclass chain: + * the included module may define `helper` ahead of Base. */ + "class Base\n" + " def helper\n" + " 1\n" + " end\n" + "end\n" + "class Child < Base\n" + " include UnseenConcern\n" + " def work\n" + " self.helper\n" + " end\n" + "end\n" + "class ChildUser\n" + " def run\n" + " c = Child.new\n" + " c.helper\n" + " end\n" + "end\n" + /* (c) a bare call in a class with an unresolved SUPERCLASS must not + * fall through to a same-named top-level def. */ + "def util\n" + " 1\n" + "end\n" + "class Orphan < UnseenBase\n" + " def go\n" + " util()\n" + " end\n" + " def again\n" + " super\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* (a) */ + ASSERT(find_resolved(r, "Record.persist", "save") < 0); + ASSERT(find_resolved(r, "RecordUser.run", "save") < 0); + /* (b) */ + ASSERT(find_resolved(r, "Child.work", "helper") < 0); + ASSERT(find_resolved(r, "ChildUser.run", "helper") < 0); + /* (c) */ + ASSERT(find_resolved(r, "Orphan.go", "util") < 0); + ASSERT(find_resolved(r, "Orphan.again", "again") < 0); + cbm_free_result(r); + PASS(); +} + +/* Positive controls for the rule above: resolved ancestry still reaches the + * superclass and still falls through to a top-level def, and a hit found + * BEFORE the unseen ancestor in Ruby's lookup order is still emitted. */ +TEST(rubylsp_resolved_ancestry_still_resolves) { + const char *src = "module Seen\n" + " def greet\n" + " 1\n" + " end\n" + "end\n" + "def util\n" + " 1\n" + "end\n" + "class Base\n" + " def helper\n" + " 1\n" + " end\n" + "end\n" + "class Child < Base\n" + " include Seen\n" + " def work\n" + " self.helper\n" + " util()\n" + " end\n" + "end\n" + "class Mixed < UnseenBase\n" + " include UnseenConcern\n" + " include Seen\n" + " def own\n" + " 2\n" + " end\n" + " def work\n" + " self.own\n" + " self.greet\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved_exact(r, "test.main.Child.work", "test.main.Base.helper") >= 0); + ASSERT(require_resolved_exact(r, "test.main.Child.work", "test.main.util") >= 0); + /* Own method precedes includes and the superclass in lookup order. */ + ASSERT(require_resolved_exact(r, "test.main.Mixed.work", "test.main.Mixed.own") >= 0); + /* `include Seen` comes AFTER `include UnseenConcern`, so Seen is searched + * first and its hit is trustworthy. */ + ASSERT(require_resolved_exact(r, "test.main.Mixed.work", "test.main.Seen.greet") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 7d. Methods defined inside blocks are not members of the module ── */ + +/* ActiveSupport's `class_methods do … end` / `included do … end` define + * methods on whatever class includes the concern (class-side, for + * class_methods), never instance methods of the concern module itself. A + * resolver that registered them on the module would then find them through + * `include Concern` and emit `Foo.new.build` -> Concern.build — an edge to a + * def the extractor does not even emit. Same for `include X` written INSIDE + * such a block: it applies to the includer, not the concern. */ +TEST(rubylsp_block_defs_not_module_members) { + const char *src = "module Other\n" + " def other_m\n" + " 1\n" + " end\n" + "end\n" + "module Concern\n" + " class_methods do\n" + " def build\n" + " 1\n" + " end\n" + " end\n" + " included do\n" + " include Other\n" + " def injected\n" + " 2\n" + " end\n" + " end\n" + " def real\n" + " 3\n" + " end\n" + "end\n" + "class Foo\n" + " include Concern\n" + " def initialize\n" + " end\n" + "end\n" + "class User\n" + " def run\n" + " f = Foo.new\n" + " f.build\n" + " f.injected\n" + " f.other_m\n" + " f.real\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "User.run", "build") < 0); + ASSERT(find_resolved(r, "User.run", "injected") < 0); + ASSERT(find_resolved(r, "User.run", "other_m") < 0); + /* Positive control: a def directly in the module body IS a member. */ + ASSERT(require_resolved_exact(r, "test.main.User.run", "test.main.Concern.real") >= 0); + cbm_free_result(r); + PASS(); +} + +/* A def inside a top-level block (an RSpec group) has whatever `self` the + * DSL gave it: bare calls in its body emit nothing, and it is not a + * file-level function other top-level code can reach. */ +TEST(rubylsp_top_level_block_defs_fail_closed) { + const char *src = "def util\n" + " 1\n" + "end\n" + "describe 'x' do\n" + " def helper\n" + " util()\n" + " end\n" + "end\n" + "def caller_fn\n" + " helper()\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "helper", "util") < 0); + ASSERT(find_resolved(r, "caller_fn", "helper") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 8. extend mixin (class-side) ───────────────────────────────── */ + +TEST(rubylsp_extend_mixin) { + const char *src = "module Findable\n" + " def locate(id)\n" + " id\n" + " end\n" + "end\n" + "class Record\n" + " extend Findable\n" + "end\n" + "class App\n" + " def run\n" + " Record.locate(7)\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "App.run", "Findable.locate") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 9. super dispatch ──────────────────────────────────────────── */ + +TEST(rubylsp_super_dispatch) { + const char *src = "class Base\n" + " def setup\n" + " 1\n" + " end\n" + "end\n" + "class Child < Base\n" + " def setup\n" + " super\n" + " 2\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + const CBMResolvedCall *rc = + find_resolved_with_strategy(r, "Child.setup", "Base.setup", "ruby_method_super"); + if (!rc) + (void)require_resolved(r, "Child.setup", "Base.setup"); + ASSERT(rc); + cbm_free_result(r); + PASS(); +} + +/* ── 10. Instance-variable typing ───────────────────────────────── */ + +TEST(rubylsp_ivar_typing) { + const char *src = "class Engine\n" + " def start\n" + " 1\n" + " end\n" + "end\n" + "class Car\n" + " def initialize\n" + " @engine = Engine.new\n" + " end\n" + " def drive\n" + " @engine.start\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "Car.drive", "Engine.start") >= 0); + ASSERT(find_resolved_with_strategy(r, "Car.drive", "Engine.start", "ruby_ivar_method")); + cbm_free_result(r); + PASS(); +} + +/* Conflicting ivar assignments must suppress the type (zero-edge). */ +TEST(rubylsp_ivar_conflict_no_edge) { + const char *src = "class A\n" + " def go\n" + " 1\n" + " end\n" + "end\n" + "class B\n" + " def go\n" + " 2\n" + " end\n" + "end\n" + "class Holder\n" + " def initialize(flag)\n" + " @x = A.new\n" + " @x = B.new\n" + " end\n" + " def run\n" + " @x.go\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "Holder.run", ".go") < 0); + cbm_free_result(r); + PASS(); +} + +/* Any write to @x that is not a resolved constructor (or nil) must latch the + * conflict, the same way two different constructors do — a method result, a + * parameter, an unresolved constant's `.new`, a compound operator, or a + * multiple assignment. Otherwise `@x = Foo.new` in one method silently types + * every other method's `@x` even after it was reassigned to who-knows-what. */ +TEST(rubylsp_ivar_non_constructor_write_latches) { + const char *src = "class Engine\n" + " def start\n" + " 1\n" + " end\n" + "end\n" + "class ViaMethod\n" + " def initialize\n" + " @e = Engine.new\n" + " end\n" + " def reset\n" + " @e = compute\n" + " end\n" + " def go\n" + " @e.start\n" + " end\n" + "end\n" + "class ViaParam\n" + " def initialize(e)\n" + " @e = Engine.new\n" + " @e = e\n" + " end\n" + " def go\n" + " @e.start\n" + " end\n" + "end\n" + "class ViaUnknownCtor\n" + " def initialize\n" + " @e = Engine.new\n" + " @e = Mystery.new\n" + " end\n" + " def go\n" + " @e.start\n" + " end\n" + "end\n" + "class ViaCompound\n" + " def initialize\n" + " @e = Engine.new\n" + " @e += 1\n" + " end\n" + " def go\n" + " @e.start\n" + " end\n" + "end\n" + "class ViaMulti\n" + " def initialize(a)\n" + " @e = Engine.new\n" + " @e, @f = a\n" + " end\n" + " def go\n" + " @e.start\n" + " end\n" + "end\n" + "class ViaOrUntyped\n" + " def initialize\n" + " @e = Engine.new\n" + " end\n" + " def lazy\n" + " @e ||= build\n" + " end\n" + " def go\n" + " @e.start\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "ViaMethod.go", "Engine.start") < 0); + ASSERT(find_resolved(r, "ViaParam.go", "Engine.start") < 0); + ASSERT(find_resolved(r, "ViaUnknownCtor.go", "Engine.start") < 0); + ASSERT(find_resolved(r, "ViaCompound.go", "Engine.start") < 0); + ASSERT(find_resolved(r, "ViaMulti.go", "Engine.start") < 0); + ASSERT(find_resolved(r, "ViaOrUntyped.go", "Engine.start") < 0); + cbm_free_result(r); + PASS(); +} + +/* `@x ||= Const.new` is the memoised-constructor idiom and counts as a + * constructor assignment; a `nil` reset says nothing about the type. */ +TEST(rubylsp_ivar_or_assign_constructor_types) { + const char *src = "class Engine\n" + " def start\n" + " 1\n" + " end\n" + "end\n" + "class Car\n" + " def engine\n" + " @engine ||= Engine.new\n" + " end\n" + " def clear\n" + " @engine = nil\n" + " end\n" + " def drive\n" + " @engine.start\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved_exact(r, "test.main.Car.drive", "test.main.Engine.start") >= 0); + ASSERT(find_resolved_with_strategy(r, "Car.drive", "Engine.start", "ruby_ivar_method")); + cbm_free_result(r); + PASS(); +} + +/* ── 11. Chained calls ──────────────────────────────────────────── */ + +TEST(rubylsp_chained_constructor_call) { + const char *src = "class Builder\n" + " def finish\n" + " 1\n" + " end\n" + "end\n" + "class App\n" + " def run\n" + " Builder.new.finish\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved(r, "App.run", "Builder.finish") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 12. Nested modules + lexical nesting ───────────────────────── */ + +TEST(rubylsp_nested_modules) { + const char *src = "module Admin\n" + " class User\n" + " def self.lookup(id)\n" + " id\n" + " end\n" + " def name\n" + " 'n'\n" + " end\n" + " end\n" + " class Panel\n" + " def show\n" + " User.lookup(1)\n" + " u = User.new\n" + " u.name\n" + " end\n" + " end\n" + "end\n" + "class Outside\n" + " def probe\n" + " Admin::User.lookup(2)\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Exact spellings: lexically nested declarations join with '.', matching + * the extractor's `.` — a substring match would let + * a wrongly-spelled row pass. */ + /* Lexical reference from sibling class inside the module. */ + ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.User.lookup") >= + 0); + ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.User") >= 0); + ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.User.name") >= + 0); + /* Fully-qualified reference from outside. */ + ASSERT(require_resolved_exact(r, "test.main.Outside.probe", "test.main.Admin.User.lookup") >= + 0); + cbm_free_result(r); + PASS(); +} + +/* ── 12b. Compact `class A::B` names match the extractor byte-for-byte ── */ + +/* The structural extractor names a compact declaration with its RAW name text + * (`Admin::User`), and the pipeline joins a resolved row to its caller def by + * exact QN equality. A resolver that normalised the name to `Admin.User` would + * have every row from or to such a class dropped at the join — silently, since + * substring assertions would still pass. Pin the exact spelling on both ends, + * and pin that the extractor's def really is spelled that way. */ +TEST(rubylsp_compact_class_name_exact) { + const char *src = "class Admin::User\n" + " def initialize\n" + " end\n" + " def name\n" + " 'n'\n" + " end\n" + " def shout\n" + " self.name\n" + " end\n" + "end\n" + "class Outside\n" + " def probe\n" + " u = Admin::User.new\n" + " u.name\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* The extractor's contract this resolver must reproduce. */ + const CBMDefinition *m = find_def(r, "Method", "name"); + ASSERT(m); + ASSERT(strcmp(m->qualified_name, "test.main.Admin::User.name") == 0); + ASSERT(m->parent_class && strcmp(m->parent_class, "test.main.Admin::User") == 0); + /* Caller inside the compact class, callee inside it. */ + ASSERT(require_resolved_exact(r, "test.main.Admin::User.shout", "test.main.Admin::User.name") >= + 0); + /* Caller outside: constructor row lands on the class QN, typed call on + * the method QN — both in the extractor's spelling. */ + ASSERT(require_resolved_exact(r, "test.main.Outside.probe", "test.main.Admin::User") >= 0); + ASSERT(require_resolved_exact(r, "test.main.Outside.probe", "test.main.Admin::User.name") >= 0); + /* And the dotted spelling must NOT appear anywhere. */ + ASSERT(find_resolved(r, "Outside.probe", "Admin.User") < 0); + cbm_free_result(r); + PASS(); +} + +/* Compact declaration nested inside a lexical module: the extractor joins the + * enclosing class QN with the raw name, giving `Admin.Foo::Bar`. */ +TEST(rubylsp_compact_class_inside_module_exact) { + const char *src = "module Admin\n" + " class Foo::Bar\n" + " def m\n" + " 1\n" + " end\n" + " end\n" + " class Panel\n" + " def show\n" + " Foo::Bar.new.m\n" + " end\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + const CBMDefinition *m = find_def(r, "Method", "m"); + ASSERT(m); + ASSERT(strcmp(m->qualified_name, "test.main.Admin.Foo::Bar.m") == 0); + ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.Foo::Bar") >= + 0); + ASSERT(require_resolved_exact(r, "test.main.Admin.Panel.show", "test.main.Admin.Foo::Bar.m") >= + 0); + cbm_free_result(r); + PASS(); +} + +/* Inside `class Admin::User`, Ruby's lexical nesting is [Admin::User] only — + * `Admin` is not opened, so a bare `Helper` does NOT see Admin::Helper. A + * resolver that probed the dotted prefixes (Admin.User, Admin, "") would + * invent that constant and emit a wrong constructor edge. The explicit + * `Admin::Helper` still resolves. */ +TEST(rubylsp_compact_class_does_not_open_outer_scope) { + const char *src = "module Admin\n" + " class Helper\n" + " def initialize\n" + " end\n" + " def assist\n" + " 1\n" + " end\n" + " end\n" + "end\n" + "class Admin::User\n" + " def bare\n" + " h = Helper.new\n" + " h.assist\n" + " end\n" + " def explicit\n" + " h = Admin::Helper.new\n" + " h.assist\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Bare `Helper` inside the compact class: no edge at all. */ + ASSERT(find_resolved(r, "Admin::User.bare", "Helper") < 0); + /* Fully qualified reference resolves, in exact extractor spelling. */ + ASSERT(require_resolved_exact(r, "test.main.Admin::User.explicit", "test.main.Admin.Helper") >= + 0); + ASSERT(require_resolved_exact(r, "test.main.Admin::User.explicit", + "test.main.Admin.Helper.assist") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 13. ActiveRecord model typing ──────────────────────────────── */ + +TEST(rubylsp_activerecord_model_typing) { + const char *src = "class User < ApplicationRecord\n" + " def full_name\n" + " 'x'\n" + " end\n" + "end\n" + "class UsersController < ApplicationController\n" + " def show\n" + " u = User.find(1)\n" + " u.full_name\n" + " end\n" + " def index\n" + " User.where(active: true).first.full_name\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* User.find returns User (AR query typing); u.full_name resolves. */ + ASSERT(require_resolved(r, "UsersController.show", "User.full_name") >= 0); + /* Relation approximation keeps the model type through where/first. */ + ASSERT(require_resolved(r, "UsersController.index", "User.full_name") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 14. Top-level functions ────────────────────────────────────── */ + +TEST(rubylsp_top_level_function) { + const char *src = "def helper(x)\n" + " x\n" + "end\n" + "def run\n" + " helper(1)\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + const CBMResolvedCall *rc = + find_resolved_with_strategy(r, "main.run", "main.helper", "ruby_function_local"); + if (!rc) + (void)require_resolved(r, "main.run", "main.helper"); + ASSERT(rc); + cbm_free_result(r); + PASS(); +} + +/* ── 15. Negative: unresolvable receiver emits no edge ──────────── */ + +TEST(rubylsp_unknown_receiver_no_edge) { + const char *src = "class Safe\n" + " def process(payload)\n" + " payload.transform\n" + " mystery = fetch_thing\n" + " mystery.explode\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Neither the untyped parameter nor the unresolved local may produce + * a resolved row. */ + ASSERT(find_resolved(r, "Safe.process", "transform") < 0); + ASSERT(find_resolved(r, "Safe.process", "explode") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 15b. `def` is a scope gate ─────────────────────────────────── */ + +/* Ruby locals do not cross a `def`: a typed local in the enclosing body (or + * a sibling method) is invisible inside, and a parameter of the same name is + * a brand-new, untyped binding. A resolver that chained the method scope to + * its parent would type `a` from the outside and emit a wrong edge. */ +TEST(rubylsp_def_is_scope_gate) { + const char *src = "class Foo\n" + " def bar\n" + " 1\n" + " end\n" + "end\n" + "a = Foo.new\n" + "def top\n" + " a.bar\n" + "end\n" + "class Worker\n" + " def run\n" + " a = Foo.new\n" + " a.bar\n" + " helper(a)\n" + " end\n" + " def helper(a)\n" + " a.bar\n" + " end\n" + " def later\n" + " a.bar\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Positive control: the typed local inside its own method resolves. */ + ASSERT(require_resolved_exact(r, "test.main.Worker.run", "test.main.Foo.bar") >= 0); + /* Top-level `a` is not visible inside `def top`. */ + ASSERT(find_resolved(r, "main.top", "Foo.bar") < 0); + /* Parameter `a` is untyped, whatever the caller passed. */ + ASSERT(find_resolved(r, "Worker.helper", "Foo.bar") < 0); + /* Sibling method's local is not visible either. */ + ASSERT(find_resolved(r, "Worker.later", "Foo.bar") < 0); + cbm_free_result(r); + PASS(); +} + +/* Block parameters shadow the enclosing local for the block's extent only: + * `|a|` inside the block is untyped, and `a` after the block is still Foo. */ +TEST(rubylsp_block_param_shadows_local) { + const char *src = "class Foo\n" + " def bar\n" + " 1\n" + " end\n" + "end\n" + "class Worker\n" + " def run\n" + " a = Foo.new\n" + " [1].each { |a| a.bar }\n" + " [1].each do |x, (y, z), *rest; loc|\n" + " x.bar\n" + " y.bar\n" + " rest.bar\n" + " loc.bar\n" + " end\n" + " f = ->(a) { a.bar }\n" + " a.bar\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + /* Exactly ONE row: the `a.bar` after the blocks. Every `.bar` inside a + * block is on an untyped block parameter. */ + if (count_resolved_exact(r, "test.main.Worker.run", "test.main.Foo.bar") != 1) + dump_resolved(r); + ASSERT(count_resolved_exact(r, "test.main.Worker.run", "test.main.Foo.bar") == 1); + cbm_free_result(r); + PASS(); +} + +/* Reassigning a typed local to anything the resolver cannot type must drop + * the old type. `a = Foo.new; a = x; a.bar` is NOT a call on Foo. Covers + * plain reassignment, multiple assignment, `||=` and other compound forms. */ +TEST(rubylsp_untyped_reassignment_drops_type) { + const char *src = "class Foo\n" + " def bar\n" + " 1\n" + " end\n" + "end\n" + "class Worker\n" + " def plain(x)\n" + " a = Foo.new\n" + " a = x\n" + " a.bar\n" + " end\n" + " def multi(x)\n" + " a = Foo.new\n" + " a, b = x, 1\n" + " a.bar\n" + " end\n" + " def nested_multi(x)\n" + " a = Foo.new\n" + " (a, b), c = x, 1\n" + " a.bar\n" + " end\n" + " def splat_multi(x)\n" + " a = Foo.new\n" + " b, *a = x\n" + " a.bar\n" + " end\n" + " def or_assign(x)\n" + " a = Foo.new\n" + " a ||= x\n" + " a.bar\n" + " end\n" + " def compound(x)\n" + " a = Foo.new\n" + " a += x\n" + " a.bar\n" + " end\n" + " def in_block(x)\n" + " a = Foo.new\n" + " [1].each { a = x }\n" + " a.bar\n" + " end\n" + " def fresh_or_assign\n" + " a ||= Foo.new\n" + " a.bar\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "Worker.plain", "Foo.bar") < 0); + ASSERT(find_resolved(r, "Worker.multi", "Foo.bar") < 0); + ASSERT(find_resolved(r, "Worker.nested_multi", "Foo.bar") < 0); + ASSERT(find_resolved(r, "Worker.splat_multi", "Foo.bar") < 0); + ASSERT(find_resolved(r, "Worker.or_assign", "Foo.bar") < 0); + ASSERT(find_resolved(r, "Worker.compound", "Foo.bar") < 0); + /* Assignment inside a block rebinds the enclosing local (closure). */ + ASSERT(find_resolved(r, "Worker.in_block", "Foo.bar") < 0); + /* Positive control: `||=` on a fresh name with a typed RHS is a + * constructor assignment. */ + ASSERT(require_resolved_exact(r, "test.main.Worker.fresh_or_assign", "test.main.Foo.bar") >= 0); + cbm_free_result(r); + PASS(); +} + +/* ── 16. Negative: dynamic dispatch emits no edge ───────────────── */ + +TEST(rubylsp_send_no_edge) { + const char *src = "class Target\n" + " def hidden\n" + " 1\n" + " end\n" + "end\n" + "class Meta\n" + " def invoke\n" + " t = Target.new\n" + " t.send(:hidden)\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "Meta.invoke", "hidden") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 16b. Negative: the whole dynamic-dispatch family emits no edge ── */ + +/* `send` has its own row above. The zero-edge guarantee is advertised for the + * whole reflective family, so pin the rest of it too: a resolver that treated + * any of these as an ordinary call would invent an edge to `hidden`. + * + * `Meta` deliberately defines its OWN `hidden`: inside `instance_eval { }` + * self is the Target, not the Meta, so a resolver that dispatched the bare + * `hidden()` on the enclosing class would emit Meta.via_instance_eval -> + * Meta.hidden — a wrong edge that an empty Meta would never reveal. The + * bare calls are written with `()` because tree-sitter-ruby parses a lone + * `hidden` as an identifier, which the extractor never emits as a call. */ +TEST(rubylsp_dynamic_dispatch_family_no_edge) { + const char *src = "class Target\n" + " def hidden\n" + " 1\n" + " end\n" + "end\n" + "class Meta\n" + " def hidden\n" + " 0\n" + " end\n" + " def via_public_send\n" + " Target.new.public_send(:hidden)\n" + " end\n" + " def via_underscore_send\n" + " Target.new.__send__(:hidden)\n" + " end\n" + " def via_define_method\n" + " Target.define_method(:hidden) { 2 }\n" + " end\n" + " def via_instance_eval\n" + " Target.new.instance_eval { hidden() }\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "Meta.via_public_send", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_underscore_send", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_define_method", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_instance_eval", "hidden") < 0); + cbm_free_result(r); + PASS(); +} + +/* ── 16c. Negative: blocks that rebind `self` ───────────────────── */ + +/* instance_eval/exec, class_eval/exec, module_eval/exec, define_method and + * Class.new/Module.new run their block with a different `self`. Bare calls, + * `self.` calls and `super` inside them must not dispatch on the enclosing + * class. Every enclosing class here HAS the method being called, so the old + * behaviour would emit a wrong edge in each row. Receiver and argument + * expressions of the same call are ordinary and still resolve. */ +TEST(rubylsp_self_rebinding_blocks_no_edge) { + const char *src = "class Target\n" + " def hidden\n" + " 1\n" + " end\n" + "end\n" + "class Meta\n" + " def hidden\n" + " 0\n" + " end\n" + " def target\n" + " Target.new\n" + " end\n" + " def via_instance_exec\n" + " target().instance_exec(1) { |x| hidden(); self.hidden }\n" + " end\n" + " def via_class_eval\n" + " Target.class_eval do\n" + " hidden()\n" + " end\n" + " end\n" + " def via_class_exec\n" + " Target.class_exec { hidden() }\n" + " end\n" + " def via_module_eval\n" + " Target.module_eval { self.hidden }\n" + " end\n" + " def via_module_exec\n" + " Target.module_exec { hidden() }\n" + " end\n" + " def via_define_method_body\n" + " Target.define_method(:other) { hidden() }\n" + " end\n" + " def via_class_new\n" + " Class.new do\n" + " hidden()\n" + " def hidden\n" + " super\n" + " end\n" + " end\n" + " end\n" + " def via_module_new\n" + " Module.new { self.hidden }\n" + " end\n" + " def after_block\n" + " Target.new.instance_eval { hidden() }\n" + " self.hidden\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(find_resolved(r, "Meta.via_instance_exec", "hidden") < 0); + /* ...but the receiver expression of that same call still resolves. */ + ASSERT(require_resolved_exact(r, "test.main.Meta.via_instance_exec", "test.main.Meta.target") >= + 0); + ASSERT(find_resolved(r, "Meta.via_class_eval", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_class_exec", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_module_eval", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_module_exec", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_define_method_body", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_class_new", "hidden") < 0); + ASSERT(find_resolved(r, "Meta.via_module_new", "hidden") < 0); + /* The suppression ends with the block: the following statement is an + * ordinary self dispatch on Meta. */ + ASSERT(count_resolved_exact(r, "test.main.Meta.after_block", "test.main.Meta.hidden") == 1); + cbm_free_result(r); + PASS(); +} + +/* ── 17. Bounded resolver work ──────────────────────────────────── + * + * Measured with counters, never with a clock (O9): CBM_LSP_WORK + * (lsp_work.h, seam builds) for lookup work, and the result arena's + * cumulative allocation for bytes built. Each fixture is measured at n and + * 2n: linear work doubles (ratio about 2); anything worse lands above the + * gate of 3. */ + +static uint64_t rubylsp_counted_work(const char *src, size_t *arena_bytes) { + (void)cbm_lsp_work_take(); + CBMFileResult *r = extract_ruby(src); + uint64_t work = cbm_lsp_work_take(); + if (arena_bytes) + *arena_bytes = r ? cbm_arena_total(&r->arena) : 0; + if (r) + cbm_free_result(r); + return work; +} + +/* n modules where every module both prepends and includes the next one; + * the last defines one method. `Probe` reaches M0 through `include` + * (instance side) or `extend` (class side) and calls a method nobody + * defines, so the lookup must miss after searching every ancestor. */ +static char *rubylsp_mixin_chain_src(int n, bool class_side) { + size_t cap = (size_t)n * 96 + 256; + char *src = (char *)malloc(cap); + if (!src) + return NULL; + size_t off = 0; + for (int i = 0; i < n; i++) { + if (i + 1 < n) + off += (size_t)snprintf(src + off, cap - off, + "module M%d\n prepend M%d\n include M%d\nend\n", i, i + 1, + i + 1); + else + off += (size_t)snprintf(src + off, cap - off, + "module M%d\n def leaf\n 1\n end\nend\n", i); + } + if (class_side) + (void)snprintf(src + off, cap - off, + "class Probe\n extend M0\n def self.run\n nobody_defines_this()\n" + " end\nend\n"); + else + (void)snprintf(src + off, cap - off, + "class Probe\n include M0\n def run\n nobody_defines_this()\n" + " end\nend\n"); + return src; +} + +static uint64_t rubylsp_mixin_chain_work(int n, bool class_side) { + char *src = rubylsp_mixin_chain_src(n, class_side); + if (!src) + return 0; + uint64_t work = rubylsp_counted_work(src, NULL); + free(src); + return work; +} + +/* Review ask (a): Ruby linearizes ancestors, so a module reached through + * two paths (`prepend M; include M`, a diamond) is searched once. Without a + * visited set every level of the chain doubles the paths: 2^n visits + * (measured 4 -> 20,744 for n = 2..14). With it a miss costs one visit per + * module. Positive controls: the doubled pair still resolves through the + * prepend, and a diamond resolves to the shared module exactly once. */ +TEST(rubylsp_mixin_walk_visits_each_module_once) { + uint64_t inst_n = rubylsp_mixin_chain_work(6, false); + uint64_t inst_2n = rubylsp_mixin_chain_work(12, false); + uint64_t cls_n = rubylsp_mixin_chain_work(6, true); + uint64_t cls_2n = rubylsp_mixin_chain_work(12, true); + printf(" mixin chain work: instance n=6 %llu, n=12 %llu; class side n=6 %llu, n=12 %llu\n", + (unsigned long long)inst_n, (unsigned long long)inst_2n, (unsigned long long)cls_n, + (unsigned long long)cls_2n); + ASSERT(inst_n > 0 && cls_n > 0); + ASSERT(inst_2n <= 3 * inst_n); + ASSERT(cls_2n <= 3 * cls_n); + + const char *src = "module Shared\n" + " def hello\n" + " 1\n" + " end\n" + "end\n" + "module Left\n" + " include Shared\n" + "end\n" + "module Right\n" + " include Shared\n" + "end\n" + "module Twice\n" + " def twice\n" + " 2\n" + " end\n" + "end\n" + "class Diamond\n" + " include Left\n" + " include Right\n" + " prepend Twice\n" + " include Twice\n" + " def run\n" + " hello()\n" + " twice()\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(count_resolved_exact(r, "test.main.Diamond.run", "test.main.Shared.hello") == 1); + ASSERT(count_resolved_exact(r, "test.main.Diamond.run", "test.main.Twice.twice") == 1); + cbm_free_result(r); + PASS(); +} + +/* `class Probe; def run; S0::S1::...::S.new; end; end` */ +static char *rubylsp_deep_const_src(int n) { + size_t cap = (size_t)n * 12 + 128; + char *src = (char *)malloc(cap); + if (!src) + return NULL; + size_t off = (size_t)snprintf(src, cap, "class Probe\n def run\n "); + for (int i = 0; i < n; i++) + off += (size_t)snprintf(src + off, cap - off, i ? "::S%d" : "S%d", i); + (void)snprintf(src + off, cap - off, ".new\n end\nend\n"); + return src; +} + +static size_t rubylsp_deep_const_bytes(int n) { + char *src = rubylsp_deep_const_src(n); + if (!src) + return 0; + size_t bytes = 0; + (void)rubylsp_counted_work(src, &bytes); + free(src); + return bytes; +} + +/* Review ask (b): a constant path is built in one pass. Recursing once per + * `::` and re-formatting the growing prefix at every level costs one stack + * frame per segment and quadratic bytes; the loop has no recursion and + * writes the path once. The positive control pins the exact class QN for a + * deep path and for a leading `::`. */ +TEST(rubylsp_const_path_is_linear) { + size_t b_n = rubylsp_deep_const_bytes(800); + size_t b_2n = rubylsp_deep_const_bytes(1600); + printf(" const path arena bytes: n=800 %zu, n=1600 %zu\n", b_n, b_2n); + ASSERT(b_n > 0); + ASSERT(b_2n <= 3 * b_n); + + const char *src = "module L0\n" + " module L1\n" + " module L2\n" + " module L3\n" + " module L4\n" + " class Leaf\n" + " end\n" + " end\n" + " end\n" + " end\n" + " end\n" + "end\n" + "class Top\n" + " class Inner\n" + " end\n" + "end\n" + "class Caller\n" + " def deep\n" + " L0::L1::L2::L3::L4::Leaf.new\n" + " end\n" + " def rooted\n" + " ::Top::Inner.new\n" + " end\n" + "end\n"; + CBMFileResult *r = extract_ruby(src); + ASSERT(r); + ASSERT(require_resolved_exact(r, "test.main.Caller.deep", "test.main.L0.L1.L2.L3.L4.Leaf") >= + 0); + ASSERT(require_resolved_exact(r, "test.main.Caller.rooted", "test.main.Top.Inner") >= 0); + cbm_free_result(r); + PASS(); +} + +/* Review ask (c): the stdlib seed must survive allocation failure. A + * zero-initialised arena has no blocks and no grow size, so every + * allocation from it returns NULL. The seed must not write through a NULL + * return-type vector, and must not register an entry whose formatted name + * came back NULL (a NULL QN would reach strcmp in finalize and in every later + * lookup). */ +TEST(rubylsp_stdlib_seed_survives_alloc_failure) { + CBMArena reg_arena; + cbm_arena_init(®_arena); + CBMTypeRegistry reg; + cbm_registry_init(®, ®_arena); + CBMArena dead; + memset(&dead, 0, sizeof(dead)); + cbm_ruby_stdlib_register(®, &dead); + ASSERT(reg.type_count > 0); + ASSERT(reg.func_count > 0); /* Kernel built-ins carry literal names */ + for (int i = 0; i < reg.func_count; i++) { + ASSERT(reg.funcs[i].qualified_name != NULL); + ASSERT(reg.funcs[i].short_name != NULL); + } + cbm_registry_finalize(®); + ASSERT(cbm_registry_lookup_func(®, "puts") != NULL); + cbm_arena_destroy(®_arena); + PASS(); +} + +/* ── suite ──────────────────────────────────────────────────────── */ + +void suite_ruby_lsp(void) { + RUN_TEST(rubylsp_qn_contract); + RUN_TEST(rubylsp_method_via_constructor_assignment); + RUN_TEST(rubylsp_constructor_edge_targets_class); + RUN_TEST(rubylsp_self_dispatch); + RUN_TEST(rubylsp_singleton_method_dispatch); + RUN_TEST(rubylsp_singleton_instance_split); + RUN_TEST(rubylsp_inheritance); + RUN_TEST(rubylsp_include_mixin); + RUN_TEST(rubylsp_prepend_mixin); + RUN_TEST(rubylsp_unresolved_ancestry_fails_closed); + RUN_TEST(rubylsp_resolved_ancestry_still_resolves); + RUN_TEST(rubylsp_block_defs_not_module_members); + RUN_TEST(rubylsp_top_level_block_defs_fail_closed); + RUN_TEST(rubylsp_extend_mixin); + RUN_TEST(rubylsp_super_dispatch); + RUN_TEST(rubylsp_ivar_typing); + RUN_TEST(rubylsp_ivar_conflict_no_edge); + RUN_TEST(rubylsp_ivar_non_constructor_write_latches); + RUN_TEST(rubylsp_ivar_or_assign_constructor_types); + RUN_TEST(rubylsp_chained_constructor_call); + RUN_TEST(rubylsp_nested_modules); + RUN_TEST(rubylsp_compact_class_name_exact); + RUN_TEST(rubylsp_compact_class_inside_module_exact); + RUN_TEST(rubylsp_compact_class_does_not_open_outer_scope); + RUN_TEST(rubylsp_activerecord_model_typing); + RUN_TEST(rubylsp_top_level_function); + RUN_TEST(rubylsp_unknown_receiver_no_edge); + RUN_TEST(rubylsp_def_is_scope_gate); + RUN_TEST(rubylsp_block_param_shadows_local); + RUN_TEST(rubylsp_untyped_reassignment_drops_type); + RUN_TEST(rubylsp_send_no_edge); + RUN_TEST(rubylsp_dynamic_dispatch_family_no_edge); + RUN_TEST(rubylsp_self_rebinding_blocks_no_edge); + RUN_TEST(rubylsp_mixin_walk_visits_each_module_once); + RUN_TEST(rubylsp_const_path_is_linear); + RUN_TEST(rubylsp_stdlib_seed_survives_alloc_failure); +} diff --git a/tests/test_store_graph_digest.c b/tests/test_store_graph_digest.c index 64617a96e..1065e1339 100644 --- a/tests/test_store_graph_digest.c +++ b/tests/test_store_graph_digest.c @@ -683,6 +683,45 @@ TEST(store_graph_digest_concurrent_isolation_with_held_callback_gates) { cbm_store_close(a);cbm_store_close(b);PASS(); } +/* BT-240 (#1561): the indexed checkout is source identity, which the digest + * never binds, so it lives outside the digested tables. A store written through + * the real coverage API, with and without a checkout SHA, keeps passing the + * live-schema check (test-impact falls back to running everything otherwise), + * and recording or changing the SHA leaves the digest unchanged. */ +TEST(store_graph_digest_indexed_checkout_identity_is_not_bound) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "p", "/live"), CBM_STORE_OK); + cbm_coverage_meta_t meta = {.generation = "g", + .index_mode = "full", + .recorded_at = "t", + .recording_status = "complete", + .coverage_version = 1, + .hash_records_complete = true}; + cbm_store_graph_digest_t none = {0}, first = {0}, second = {0}; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "p", NULL, 0, &meta), CBM_STORE_OK); + bool none_ok = gd_p(s, &none); + meta.indexed_checkout_sha = "0123456789abcdef0123456789abcdef01234567"; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "p", NULL, 0, &meta), CBM_STORE_OK); + bool first_ok = gd_p(s, &first); + meta.indexed_checkout_sha = "89abcdef0123456789abcdef0123456789abcdef"; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "p", NULL, 0, &meta), CBM_STORE_OK); + bool second_ok = gd_p(s, &second); + cbm_coverage_meta_t got = {0}; + int got_rc = cbm_store_coverage_meta_get(s, "p", &got); + bool sha_kept = got_rc == CBM_STORE_OK && got.indexed_checkout_sha && + strcmp(got.indexed_checkout_sha, "89abcdef0123456789abcdef0123456789abcdef") == 0; + cbm_store_coverage_meta_clear(&got); + cbm_store_close(s); + ASSERT_TRUE(none_ok); + ASSERT_TRUE(first_ok); + ASSERT_TRUE(second_ok); + ASSERT_TRUE(sha_kept); + ASSERT_TRUE(gd_equal(&none, &first)); + ASSERT_TRUE(gd_equal(&first, &second)); + PASS(); +} + SUITE(store_graph_digest) { RUN_TEST(store_graph_digest_binds_unresolved_candidate_index); RUN_TEST(store_graph_digest_candidate_index_errors_are_not_legacy_absence); @@ -699,4 +738,5 @@ SUITE(store_graph_digest) { RUN_TEST(store_graph_digest_cancellation_large_blob_never_publishes_prefix); RUN_TEST(store_graph_digest_sql_errors_cleanup_no_mutation_and_close_discard); RUN_TEST(store_graph_digest_concurrent_isolation_with_held_callback_gates); + RUN_TEST(store_graph_digest_indexed_checkout_identity_is_not_bound); } diff --git a/tests/test_store_nodes.c b/tests/test_store_nodes.c index 5d005c00e..88b37882e 100644 --- a/tests/test_store_nodes.c +++ b/tests/test_store_nodes.c @@ -2706,6 +2706,75 @@ TEST(store_coverage_shadow_excludes_unresolved_calls) { PASS(); } +TEST(store_coverage_meta_indexed_checkout_sha_roundtrip_and_rollback) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "coverage-sha", "/tmp/coverage-sha"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_file_hash(s, "coverage-sha", "keep.c", "", 1, 1), CBM_STORE_OK); + cbm_coverage_row_t row = {.rel_path = "keep.c", .kind = "parse_partial", .detail = "1-2"}; + + /* NULL indexed_checkout_sha (non-git run) persists as NULL and reads back + * as no identity. */ + cbm_coverage_meta_t no_sha_meta = { + .generation = "gen-0", + .index_mode = "full", + .recording_status = "complete", + .coverage_version = 1, + .hash_records_complete = true, + }; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-sha", &row, 1, &no_sha_meta), + CBM_STORE_OK); + cbm_coverage_meta_t got = {0}; + ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-sha", &got), CBM_STORE_OK); + ASSERT_NULL(got.indexed_checkout_sha); + cbm_store_coverage_meta_clear(&got); + + /* Round-trip: git-run identity persists and can be overwritten by the next + * staged generation. */ + cbm_coverage_meta_t sha_meta = no_sha_meta; + sha_meta.generation = "gen-1"; + sha_meta.indexed_checkout_sha = "0123456789abcdef0123456789abcdef01234567"; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-sha", &row, 1, &sha_meta), + CBM_STORE_OK); + got = (cbm_coverage_meta_t){0}; + ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-sha", &got), CBM_STORE_OK); + ASSERT_STR_EQ(got.indexed_checkout_sha, "0123456789abcdef0123456789abcdef01234567"); + ASSERT_STR_EQ(got.generation, "gen-1"); + cbm_store_coverage_meta_clear(&got); + + cbm_coverage_meta_t new_meta = sha_meta; + new_meta.generation = "gen-2"; + new_meta.indexed_checkout_sha = "abcdef0123456789abcdef0123456789abcdef01"; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-sha", &row, 1, &new_meta), + CBM_STORE_OK); + got = (cbm_coverage_meta_t){0}; + ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-sha", &got), CBM_STORE_OK); + ASSERT_STR_EQ(got.indexed_checkout_sha, "abcdef0123456789abcdef0123456789abcdef01"); + ASSERT_STR_EQ(got.generation, "gen-2"); + cbm_store_coverage_meta_clear(&got); + + /* Rollback: a failed replace must retain the prior identity + generation. */ + ASSERT_EQ(cbm_store_exec(s, "CREATE TRIGGER fail_sha_missed_insert BEFORE INSERT ON nodes " + "WHEN NEW.project = 'coverage-sha::missed' " + "BEGIN SELECT RAISE(ABORT, 'forced sha shadow failure'); END;"), + CBM_STORE_OK); + cbm_coverage_meta_t failed_meta = new_meta; + failed_meta.generation = "gen-must-not-commit"; + failed_meta.indexed_checkout_sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + cbm_coverage_row_t failed_row = row; + failed_row.detail = "changed-to-force-shadow-rebuild"; + ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-sha", &failed_row, 1, &failed_meta), + CBM_STORE_ERR); + got = (cbm_coverage_meta_t){0}; + ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-sha", &got), CBM_STORE_OK); + ASSERT_STR_EQ(got.generation, "gen-2"); + ASSERT_STR_EQ(got.indexed_checkout_sha, "abcdef0123456789abcdef0123456789abcdef01"); + cbm_store_coverage_meta_clear(&got); + + cbm_store_close(s); + PASS(); +} + SUITE(store_nodes) { RUN_TEST(store_qn_suffix_is_literal); RUN_TEST(store_qn_suffix_is_unbounded); @@ -2716,6 +2785,7 @@ SUITE(store_nodes) { RUN_TEST(store_coverage_replace_rejects_invalid_row_arguments); RUN_TEST(store_coverage_replace_rolls_back_when_shadow_rebuild_fails); RUN_TEST(store_coverage_shadow_excludes_unresolved_calls); + RUN_TEST(store_coverage_meta_indexed_checkout_sha_roundtrip_and_rollback); RUN_TEST(sql_label_allowlists_match_cbm_label_is_type_like); RUN_TEST(sql_relation_labels_match_cbm_label_is_relation); RUN_TEST(store_open_memory);