Merge origin/main into feat/index-resource-worker

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-11-03 18:09:48 +01:00
commit 6ab3653b85
66 files changed
+9207 -251

No files matched your search

+14 -14
View File
@@ -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/*
+5 -5
View File
@@ -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: |
+3 -3
View File
@@ -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
+14 -14
View File
@@ -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/
+8 -8
View File
@@ -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
+1 -1
View File
@@ -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-*/
+1 -1
View File
@@ -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-*/
+3 -3
View File
@@ -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: |
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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 ────────────────────────────────────────────
+3
View File
@@ -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)
+39
View File
@@ -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:
+19 -5
View File
@@ -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/<lang>/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
+18
View File
@@ -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 <?php`)
* is parsed with its markup blanked (#2000); the rewrite keeps the length and
* every line break, so offsets and line numbers still refer to the file on
* disk. Every other language parses the file as it is. */
static const char *extract_parse_bytes(CBMArena *a, CBMLanguage language, const char *source,
int source_len) {
if (language == CBM_LANG_PHP) {
return cbm_php_mask_inline_html(a, source, source_len);
}
return source;
}
/* Initial block for the per-file traversal scratch arena, chosen by measuring
* arena_grow on a 14k-file TypeScript tree: it fires on one file in 12,000 at
* both this size and at 1 MB, and on most files at 256 KB, where the two
@@ -3738,6 +3751,8 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C
return result;
}
source = extract_parse_bytes(a, language, source, source_len);
// Skip pathologically nested Perl before tree-sitter's recursive GLR stack
// merge overflows a small stack during the parse (see
// cbm_source_nesting_exceeds). Scoped to Perl: its ambiguous call grammar is
@@ -3951,6 +3966,9 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C
if (language == CBM_LANG_PERL) {
cbm_run_perl_lsp(a, result, source, source_len, root);
}
if (language == CBM_LANG_RUBY) {
cbm_run_ruby_lsp(a, result, source, source_len, root);
}
if (language == CBM_LANG_PYTHON) {
cbm_run_py_lsp(a, result, source, source_len, root);
}
+4
View File
@@ -1385,6 +1385,10 @@ void cbm_extract_definitions_without_module(CBMExtractCtx *ctx);
// per ref()/source() call. No-op unless the file parses as SQL and actually
// contains a dbt builtin call. Defined in extract_dbt.c.
void cbm_extract_dbt(CBMExtractCtx *ctx);
// PHP source with its inline-HTML regions blanked so the php_only grammar can
// parse past `?>` (#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);
@@ -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 <string.h>
#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 "<qn>.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");
}
+4
View File
@@ -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 <ctype.h>
@@ -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;
+22 -1
View File
@@ -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;
File diff suppressed because it is too large. Load diff
+176
View File
@@ -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
* "<module_qn>.Admin::User" while the lexically nested form yields
* "<module_qn>.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
* "<class_qn>.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 */
+2
View File
@@ -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"
+348
View File
@@ -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: `?> <div>...</div> <?php`. The vendored grammar is the
// tree-sitter-php `php_only` variant, which has no rule for `?>` 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 `<?php`, `<?=` or `<?` becomes spaces; after `<?=` the echoed
// expression is left as an expression statement, so its calls are kept
// - each `?>` 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 <stdbool.h>
#include <stddef.h>
#include <string.h>
enum {
PHP_TAG_OPEN_LEN = 5, /* <?php */
PHP_TAG_ECHO_LEN = 3, /* <?= */
PHP_TAG_SHORT_LEN = 2, /* <? */
PHP_HEREDOC_LEN = 3, /* <<< */
PHP_FIRST_NON_ASCII = 0x80,
PHP_NOT_FOUND = -1,
};
static const char PHP_HALT_COMPILER[] = "__halt_compiler";
enum { PHP_HALT_COMPILER_LEN = (int)sizeof(PHP_HALT_COMPILER) - SKIP_ONE };
/* True when the literal `lit` starts at s[i]. */
static bool php_at(const char *s, int len, int i, const char *lit) {
size_t n = strlen(lit);
return i >= 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. `<?php` needs whitespace or EOF after it
* and `<?=` is always a tag. A bare `<?` counts only when whitespace follows,
* so an `<?xml ...?>` 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 0;
}
int after = i + PHP_TAG_SHORT_LEN;
if (php_ieq(s, len, after, "php")) {
int end = i + PHP_TAG_OPEN_LEN;
return (end == len || php_is_space((unsigned char)s[end])) ? PHP_TAG_OPEN_LEN : 0;
}
if (after < len && s[after] == '=') {
return PHP_TAG_ECHO_LEN;
}
if (after < len && php_is_space((unsigned char)s[after])) {
return PHP_TAG_SHORT_LEN;
}
return 0;
}
static bool php_has_close_tag(const char *s, int len) {
for (int i = 0; i < len; 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 `<<<ID`, `<<<"ID"` or `<<<'ID'` at i. On
* success stores the label span and returns the index of the line break that
* ends the opener; returns PHP_NOT_FOUND when the bytes are not an opener. */
static int php_heredoc_opener(const char *s, int len, int i, int *label, int *label_len) {
int p = i + PHP_HEREDOC_LEN;
while (p < len && php_is_blank(s[p])) {
p++;
}
char quote = 0;
if (p < len && (s[p] == '\'' || s[p] == '"')) {
quote = s[p];
p++;
}
int start = p;
while (p < len && php_is_ident_char((unsigned char)s[p])) {
p++;
}
if (p == start || !php_is_ident_start((unsigned char)s[start])) {
return PHP_NOT_FOUND;
}
*label = start;
*label_len = p - start;
if (quote) {
if (p >= 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;
}
+107 -6
View File
@@ -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
# <results>/<lang>/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"
+59 -5
View File
@@ -3,18 +3,72 @@
# codebase-memory-mcp binary to measure the regex / LIKE pre-filter performance.
#
# Usage:
# scripts/benchmark-search-graph.sh <binary-path> <project-name>
# scripts/benchmark-search-graph.sh <binary-path> <repo-path>
#
# 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 <binary-path> <project-name>}"
PROJECT="${2:?Usage: $0 <binary-path> <project-name>}"
BINARY="${1:?Usage: $0 <binary-path> <repo-path>}"
REPO="${2:?Usage: $0 <binary-path> <repo-path>}"
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() {
+5 -1
View File
@@ -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
+3
View File
@@ -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"
+1
View File
@@ -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"},
+6 -1
View File
@@ -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"
+81 -6
View File
@@ -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) {
+69 -6
View File
@@ -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 <tool> --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) {
+33 -12
View File
@@ -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(
+3 -2
View File
@@ -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,
+175 -42
View File
@@ -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++) {
+30 -2
View File
@@ -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 <stdbool.h>
#include <stdio.h>
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 */
+10 -4
View File
@@ -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) {
+3 -2
View File
@@ -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
+403
View File
@@ -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: <path>" pointer out of a gitlink FILE at <path>/.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 <gitdir>/commondir; a submodule gitlink
* points at <super>/.git/modules/<name>, 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));
}
+55
View File
@@ -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 <root>/.git is a regular file holding a "gitdir: <path>" 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, <super>/.git/modules/<name>, 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
+41 -16
View File
@@ -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);
+224 -3
View File
@@ -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
+10
View File
@@ -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. */
+1
View File
@@ -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);
}
+5
View File
@@ -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,
+2
View File
@@ -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
+2
View File
@@ -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. */
+96 -3
View File
@@ -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) {
+5
View File
@@ -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
+26 -6
View File
@@ -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);
+296
View File
@@ -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:-<none>}'"
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:-<none>}"
[[ -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"
+345 -3
View File
@@ -27,6 +27,7 @@
#include <foundation/platform.h>
#include <foundation/private_file_lock.h>
#include <foundation/sha256.h>
#include <foundation/str_util.h>
#include <mcp/mcp.h>
#include <mcp/index_supervisor.h>
#include <pipeline/pipeline.h>
@@ -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);
+126
View File
@@ -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);
+354 -52
View File
@@ -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);
+20 -7
View File
@@ -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);
+20 -2
View File
@@ -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);
+192
View File
@@ -898,6 +898,192 @@ TEST(php_function) {
PASS();
}
/* #2000: inline HTML between `?>` and `<?php` is valid PHP, but the php_only
* grammar has no rule for it, so everything after the first `?>` 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("<?php\n"
"function gamma() { return 2; }\n"
"?>\n"
"<div class=\"box\">\n"
" <p>plain markup after the closing tag</p>\n"
"</div>\n"
"<?php\n"
"function delta() { return gamma(); }\n",
CBM_LANG_PHP, "t", "inline_html.php");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->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("<!DOCTYPE html>\n"
"<html><head><title><?= page_title($page) ?></title></head>\n"
"<body>\n"
"<?php if ($user): ?>\n"
" <p>Hello <?=$user->name?></p>\n"
"<?php else: ?>\n"
" <a href=\"/login\">log in</a>\n"
"<?php endif; ?>\n"
"<?php foreach ($items as $item) { ?>\n"
" <li><?php echo render_item($item); ?></li>\n"
"<?php } ?>\n"
"<?php function footer_links() { return nav_links(); } ?>\n"
"</body></html>\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; `<?xml` in markup is not an open tag. */
TEST(php_inline_html_close_tag_in_literals_issue2000) {
CBMFileResult *r = extract("<?php\n"
"$a = 'single ?> quoted';\n"
"$b = \"double ?> quoted \\\" still\";\n"
"$c = <<<EOT\n"
" heredoc ?> 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"
"<?xml version=\"1.0\"?><note/>\n"
"<?php\n"
"function two() { return one(); }\n",
CBM_LANG_PHP, "t", "literals.php");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->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("<p>function before() { return leakBefore(); }</p>\n"
"<?php\n"
"function real() { return 1; }\n"
"?>\n"
"<p>function fake() { return leak(); }</p>\n"
"<script>function jsFake() { leakScript(); }</script>\n"
"<?xml version=\"1.0\"?><x>function xmlFake() { leakXml(); }</x>\n"
"<?php\n"
"function after() { return real(); }\n"
"?>\n"
"<p>class Tail { function tailFake() { leakTail(); } }</p>\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 = "<?php\nfunction a() { return 1; }\n";
const char *quoted = "<?php\n$s = '?>';\n/* ?> */\n$h = <<<EOT\n?>\nEOT;\n";
const char *mixed = "<?php\nfunction a() {}\n?>\n<b>x</b>\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, "<?php\nfunction a() {}\n; \n \n");
cbm_arena_destroy(&arena);
PASS();
}
/* A trailing `?>` (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("<?php\r\n"
"class Repo {\r\n"
" public function find($id) { return $id; }\r\n"
"}\r\n"
"?>\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);
+470 -1
View File
@@ -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);
}
+49 -2
View File
@@ -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);
}
+2
View File
@@ -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);
+813 -3
View File
@@ -18,6 +18,7 @@
#include "test_framework.h"
#include "test_helpers.h"
#include <cli/cli.h>
#include <git/git_context.h>
#include <mcp/index_supervisor.h> /* spawn-count hook — #845 in-process guard */
#include <mcp/mcp.h>
#include <mcp/mcp_internal.h>
@@ -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);
+60
View File
@@ -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 = "<?php\n"
"function gamma() { return 2; }\n"
"?>\n"
"<div class=\"box\">\n"
" <p>plain markup after the closing tag</p>\n"
"</div>\n"
"<?php\n"
"function delta(Mailer $m) { return $m->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);
+55 -2
View File
@@ -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", "<?php\n"
"function gamma() { return 2; }\n"
"?>\n"
"<div class=\"box\">\n"
" <p>plain markup after the closing tag</p>\n"
"</div>\n"
"<?php\n"
"function delta() { return gamma(); }\n"},
{"price_helpers.php",
"<!-- shared helpers -->\n"
"<?php\n"
"function formatPrice($cents) { return number_format($cents / 100, 2); }\n"},
{"card_view.php", "<?php $title = 'Cards'; ?>\n"
"<h1><?= $title ?></h1>\n"
"<?php\n"
"function renderCard($item) { return formatPrice($item['cents']); }\n"},
};
for (size_t i = 0; i < sizeof(files) / sizeof(files[0]); i++) {
write_temp_file(g_tmpdir, files[i].name, files[i].body);
}
char db[512];
snprintf(db, sizeof(db), "%s/test_php_inline_html.db", g_tmpdir);
cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
cbm_store_t *s = cbm_store_open_path(db);
ASSERT_NOT_NULL(s);
const char *project = cbm_pipeline_project_name(p);
ASSERT(cross_file_call_exists(s, project, "delta", "gamma"));
ASSERT(cross_file_call_exists(s, project, "renderCard", "formatPrice"));
cbm_store_close(s);
cbm_pipeline_free(p);
teardown_test_repo();
PASS();
}
/* Terraform reference resolution, end to end.
*
* An HCL block names itself with its labels appended -- find_hcl_block_name
@@ -21140,8 +21191,9 @@ static int pyn_run_init_collision(int fillers) {
const char *project = cbm_pipeline_project_name(p);
imports[run] = pyn_edge(s, project, "tests.test_app.py.__file__",
"src.rowpkg.layout.Layout", "IMPORTS");
/* Layout defines __init__, so Layout("demo") targets it (#1642). */
calls[run] = pyn_edge(s, project, "tests.test_app.test_build",
"src.rowpkg.layout.Layout", "CALLS");
"src.rowpkg.layout.Layout.__init__", "CALLS");
method[run] = pyn_edge(s, project, "tests.test_app.test_build",
"src.rowpkg.layout.Layout.create", "CALLS");
cbm_store_close(s);
@@ -21160,7 +21212,7 @@ static int pyn_run_init_collision(int fillers) {
for (int run = 0; run < 2; run++) {
ASSERT_EQ(rc[run], 0);
ASSERT_TRUE(imports[run]); /* the class stays importable */
ASSERT_TRUE(calls[run]); /* Layout("demo") */
ASSERT_TRUE(calls[run]); /* Layout("demo") -> 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);
+292
View File
@@ -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);
File diff suppressed because it is too large. Load diff
+40
View File
@@ -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);
}
+70
View File
@@ -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);