mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Merge origin/main into feat/index-resource-worker
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
commit
6ab3653b85
66 files changed
+9207
-251
No files matched your search
@@ -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/*
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-*/
|
||||
|
||||
@@ -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-*/
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 */
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user