diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5ace4600a..c6bbd96e1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,3 +4,9 @@ updates: directory: "/" schedule: interval: "weekly" + groups: + # init and analyze must run the same CodeQL Action version within a job, and + # upload-sarif is kept in step with them, so one PR bumps all three. + codeql-action: + patterns: + - "github/codeql-action*" diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 43c52b052..163376ab0 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -66,7 +66,8 @@ jobs: - name: Install deps (Ubuntu) if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev + 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 zlib1g-dev - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -119,7 +120,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANG64 path-type: inherit @@ -181,7 +182,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANGARM64 path-type: inherit @@ -250,7 +251,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev + 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 zlib1g-dev - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml index 741e3c438..bddcee7df 100644 --- a/.github/workflows/_lint.yml +++ b/.github/workflows/_lint.yml @@ -37,14 +37,16 @@ jobs: run: bash scripts/check-no-test-skips.sh - name: Install build deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev cmake + 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 zlib1g-dev cmake - name: Install LLVM 20 + timeout-minutes: 10 run: | wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-20 main" | sudo tee /etc/apt/sources.list.d/llvm-20.list - sudo apt-get update - sudo apt-get install -y clang-format-20 + 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 clang-format-20 - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: cppcheck-cache @@ -81,14 +83,16 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install build deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev + 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 zlib1g-dev - name: Install LLVM 21 (pinned analyzer toolchain) + timeout-minutes: 10 run: | wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list - sudo apt-get update - sudo apt-get install -y clang-tidy-21 + 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 clang-tidy-21 - name: Memory-analyzer gate run: scripts/ci/lint-mem.sh clang-tidy-21 diff --git a/.github/workflows/_memwaste.yml b/.github/workflows/_memwaste.yml index 09deb0c37..52ae8a8fb 100644 --- a/.github/workflows/_memwaste.yml +++ b/.github/workflows/_memwaste.yml @@ -37,7 +37,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 clang llvm + 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 zlib1g-dev python3 clang llvm - name: Event lane on this repository run: | # `cmd | tee` reports TEE's status: without this a failing lane @@ -66,7 +67,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 clang llvm + 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 zlib1g-dev python3 clang llvm - name: CPU scaling lane (src/ at 2x against 4x) # Report-only: the script exits 1 when it flags a super-linear function. continue-on-error: true @@ -93,7 +95,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 clang llvm + 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 zlib1g-dev python3 clang llvm - name: Access lane on this repository run: | # `cmd | tee` reports TEE's status: without this a failing lane @@ -146,7 +149,7 @@ jobs: timeout-minutes: 90 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANG64 path-type: inherit diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index 96c47d44f..ac4ca595d 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -144,8 +144,9 @@ jobs: - name: ClamAV scan (Linux) if: startsWith(matrix.os, 'ubuntu') + timeout-minutes: 10 run: | - sudo apt-get update -qq && sudo apt-get install -y -qq clamav > /dev/null 2>&1 + 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 -qq && sudo apt-get -o Acquire::Retries=$APT_RETRIES -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y -qq clamav > /dev/null 2>&1 # apt auto-starts the clamav-freshclam daemon, which holds a lock on # freshclam's log/db; stop it so the manual freshclam below can run # (else: "Failed to lock the log file ... Resource temporarily unavailable"). @@ -191,7 +192,7 @@ jobs: go-version-file: pkg/go/go.mod cache: false - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: ${{ matrix.arch == 'arm64' && 'CLANGARM64' || 'CLANG64' }} path-type: inherit diff --git a/.github/workflows/_soak.yml b/.github/workflows/_soak.yml index 63c3ec51e..c25a1988d 100644 --- a/.github/workflows/_soak.yml +++ b/.github/workflows/_soak.yml @@ -66,7 +66,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps (Linux) if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 git + 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 zlib1g-dev python3 git - name: Build if: ${{ !inputs.use_release_artifacts }} run: scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} @@ -118,7 +119,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANG64 path-type: inherit @@ -196,7 +197,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANGARM64 path-type: inherit @@ -283,7 +284,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install soak dependencies - run: sudo apt-get update && sudo apt-get install -y python3 git + 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 with: @@ -356,7 +358,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps (Linux) if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 git + 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 zlib1g-dev python3 git - name: Build (ASan) run: | SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer" @@ -386,7 +389,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANG64 path-type: inherit diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 55a8bf608..da7e0aab0 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -169,7 +169,8 @@ jobs: - name: Install deps (Ubuntu) if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev ccache + 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 zlib1g-dev ccache # Ubuntu 23.10+ ships kernel.apparmor_restrict_unprivileged_userns=1, which # blocks unshare(CLONE_NEWUSER) for unprivileged users. Without this, the @@ -370,14 +371,16 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev ccache + 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 zlib1g-dev ccache - name: Install LLVM 21 (pinned diagnostic toolchain) + timeout-minutes: 10 run: | wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list - sudo apt-get update - sudo apt-get install -y clang-21 + 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 clang-21 - name: Compiler cache (content-verified, restore) uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -528,7 +531,8 @@ jobs: - name: Install deps (Ubuntu) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev ccache + 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 clang zlib1g-dev ccache - name: Install deps (macOS) if: runner.os == 'macOS' @@ -590,7 +594,7 @@ jobs: # preflight script, so AV-interaction coverage lives there; runners run # without RTP by platform constraint. - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: ${{ matrix.msystem }} path-type: inherit @@ -742,7 +746,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANG64 path-type: inherit diff --git a/.github/workflows/bug-repro.yml b/.github/workflows/bug-repro.yml index e3e1a647a..357aec5b2 100644 --- a/.github/workflows/bug-repro.yml +++ b/.github/workflows/bug-repro.yml @@ -55,7 +55,8 @@ jobs: - name: Install deps (Ubuntu) if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev + 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 zlib1g-dev - name: Run bug-reproduction board if: ${{ github.event_name == 'push' || inputs.platforms == 'all' || inputs.platforms == matrix.group }} @@ -68,7 +69,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANG64 path-type: inherit diff --git a/.github/workflows/cache-warm.yml b/.github/workflows/cache-warm.yml index bca57b23b..661533238 100644 --- a/.github/workflows/cache-warm.yml +++ b/.github/workflows/cache-warm.yml @@ -36,7 +36,8 @@ jobs: - name: Install deps (Ubuntu) if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev ccache + 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 zlib1g-dev ccache - name: Install ccache (macOS) if: startsWith(matrix.os, 'macos') @@ -81,7 +82,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANG64 path-type: inherit diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f5cfb59d6..1a1c8d6fc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -95,7 +95,8 @@ jobs: - name: Install build dependencies if: ${{ matrix.language == 'c-cpp' }} - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev + 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 zlib1g-dev # PR c-cpp: compile the third-party objects (tree-sitter grammars, # sqlite, mimalloc, lz4, zstd, the tree-sitter runtime: ~1.3 GB of @@ -113,7 +114,7 @@ jobs: run: scripts/ci/codeql-retrace-vendored.sh "$RUNNER_TEMP/codeql-retrace.txt" - name: Initialize CodeQL - uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -128,7 +129,7 @@ jobs: run: scripts/build.sh --reuse-vendored --retrace-from "$RUNNER_TEMP/codeql-retrace.txt" - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/dry-run.yml b/.github/workflows/dry-run.yml index 463424092..c582716fe 100644 --- a/.github/workflows/dry-run.yml +++ b/.github/workflows/dry-run.yml @@ -142,7 +142,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev clang llvm libclang-rt-dev + 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 zlib1g-dev clang llvm libclang-rt-dev - name: Fuzz smoke (extract, cypher, config) run: scripts/fuzz.sh all --runs 20000 --seed 1 - name: Upload reproducers diff --git a/.github/workflows/fast-repro.yml b/.github/workflows/fast-repro.yml index 5f38c1772..be6228e48 100644 --- a/.github/workflows/fast-repro.yml +++ b/.github/workflows/fast-repro.yml @@ -26,7 +26,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev + 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 zlib1g-dev - name: test-repro (single platform, ASan; CBM_REPRO_ONLY filters suites) env: # Optionally narrow to specific suites for a fast targeted check, e.g. diff --git a/.github/workflows/nightly-soak.yml b/.github/workflows/nightly-soak.yml index 2bd5bc7e8..d4357aad6 100644 --- a/.github/workflows/nightly-soak.yml +++ b/.github/workflows/nightly-soak.yml @@ -45,7 +45,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev clang llvm libclang-rt-dev + 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 zlib1g-dev clang llvm libclang-rt-dev - name: Fuzz exploration (30 min per target) run: scripts/fuzz.sh all --max-total-time 1800 --seed 0 - name: Upload reproducers diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index aade2b93d..3b4ea807c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -67,8 +67,9 @@ jobs: fetch-depth: 0 persist-credentials: false - name: Build this checkout's engine + timeout-minutes: 20 run: | - sudo apt-get update && sudo apt-get install -y zlib1g-dev + 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 zlib1g-dev scripts/build.sh CC=gcc CXX=g++ - name: Select the tests id: select @@ -211,13 +212,14 @@ jobs: - name: Install deps (Ubuntu) if: matrix.os == 'ubuntu-latest' - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev ccache + 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 zlib1g-dev ccache - name: Install ccache (macOS) if: matrix.os == 'macos-15' run: command -v ccache >/dev/null 2>&1 || brew install ccache - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 if: matrix.os == 'windows-latest' with: msystem: CLANG64 @@ -318,8 +320,9 @@ jobs: run: scripts/ci/test-impact-shadow.sh --fetch-release "$RUNNER_TEMP/cbm-bin" - name: Build the binary from this checkout (download failed) if: ${{ steps.release.outcome == 'failure' }} + timeout-minutes: 20 run: | - sudo apt-get update && sudo apt-get install -y zlib1g-dev + 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 zlib1g-dev scripts/build.sh CC=gcc CXX=g++ mkdir -p "$RUNNER_TEMP/cbm-bin" cp build/c/codebase-memory-mcp "$RUNNER_TEMP/cbm-bin/" @@ -329,8 +332,9 @@ jobs: # released binary predates it, so the checkout is built either way. - name: Build this checkout's engine if: ${{ steps.release.outcome != 'failure' }} + timeout-minutes: 20 run: | - sudo apt-get update && sudo apt-get install -y zlib1g-dev + 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 zlib1g-dev scripts/build.sh CC=gcc CXX=g++ - name: Select with the team artifact (engine shadow) env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 707c69924..e4926260d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -257,7 +257,7 @@ jobs: # fail if its SBOM cannot be attested. An unattested SBOM is worse than # an absent one, because the artifact still ships looking complete. - name: Attest SBOM - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 env: NODE_OPTIONS: '--max-http-header-size=32768' with: diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index fb0a52bbc..d5a794173 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -26,13 +26,13 @@ jobs: persist-credentials: false - name: Run OpenSSF Scorecard - uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0 with: results_file: results.sarif results_format: sarif publish_results: true - name: Upload SARIF results - uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4 + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4 with: sarif_file: results.sarif diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 2de8e1b8e..62193d060 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -51,7 +51,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install deps (Linux) if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 git + 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 zlib1g-dev python3 git - name: Build (production path with explicit test seams) run: scripts/build.sh CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} TEST_SEAMS=1 - name: Smoke invariants @@ -69,7 +70,7 @@ jobs: timeout-minutes: 240 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANG64 path-type: inherit @@ -99,7 +100,7 @@ jobs: continue-on-error: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 with: msystem: CLANGARM64 path-type: inherit diff --git a/.github/workflows/test-impact-artifact.yml b/.github/workflows/test-impact-artifact.yml index 4db216ecd..49f70dd67 100644 --- a/.github/workflows/test-impact-artifact.yml +++ b/.github/workflows/test-impact-artifact.yml @@ -44,12 +44,13 @@ jobs: persist-credentials: false - name: Install build deps and LLVM 21 (coverage toolchain) + timeout-minutes: 10 run: | - sudo apt-get update && sudo apt-get install -y zlib1g-dev + 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 zlib1g-dev wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list - sudo apt-get update - sudo apt-get install -y clang-21 llvm-21 libclang-rt-21-dev + 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 clang-21 llvm-21 libclang-rt-21-dev - name: Build the production binary and the coverage runner run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 93596d06f..8263c54fa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,9 @@ Contributions are welcome. This guide covers setup, testing, and PR guidelines. ## Build from Source -**Prerequisites**: C compiler (gcc or clang), make, zlib, Git. Optional: Node.js 22+ (for graph UI). +**Build prerequisites**: C compiler (gcc or clang), make, zlib, Git. Optional: Node.js 22+ (for graph UI). + +**Contributor check prerequisites**: `scripts/test.sh` also requires `zip`, `python3`, and `curl`. `scripts/lint.sh` requires `cppcheck`, `clang-format`, and `clang-tidy`; `scripts/lint.sh --ci`, which is what CI runs, skips `clang-tidy`. ```bash git clone https://github.com/DeusData/codebase-memory-mcp.git diff --git a/README.md b/README.md index c4428e067..73e7e7f64 100644 --- a/README.md +++ b/README.md @@ -617,6 +617,8 @@ running an installer. +> **Contributor checks need a few extra tools.** `scripts/test.sh` requires `zip`, `python3`, and `curl`. `scripts/lint.sh` requires `cppcheck`, `clang-format`, and `clang-tidy`; `scripts/lint.sh --ci`, which is what CI runs, skips `clang-tidy`. + ```bash git clone https://github.com/DeusData/codebase-memory-mcp.git cd codebase-memory-mcp @@ -719,10 +721,10 @@ overwrite user-modified agents. | Agent | Activation | MCP config | Durable context / augmentation | |-------|------------|------------|--------------------------------| | Claude Code | Detected | `~/.claude.json` | Skill + three exact-tool graph agents; `SessionStart`, `SubagentStart`, non-blocking `PreToolUse` for `Grep`/`Glob`/`Bash`, post-`Read` coverage, and a drift note after repeated searches without a graph call | -| Codex CLI | Detected | `$CODEX_HOME/config.toml` | Managed `AGENTS.md` activation pointer, skill, three read-only agents; `SessionStart` + `SubagentStart`, `PreToolUse` graph lookup for `Bash` searches | +| Codex CLI | Detected | `$CODEX_HOME/config.toml` | Managed `AGENTS.md` activation pointer, `~/.agents/skills/codebase-memory/SKILL.md`, three read-only agents; `SessionStart` + `SubagentStart`, `PreToolUse` graph lookup for `Bash` searches | | Gemini CLI | Detected | `.gemini/settings.json` | `GEMINI.md`, three explicit read/graph-tool subagents; `BeforeTool`, `AfterTool` `read_file` coverage, and `SessionStart` | | Zed | Detected | platform `settings.json` (JSONC) | `AGENTS.md` + shared skill | -| OpenCode | Detected | `$OPENCODE_CONFIG` or resolved global config | `AGENTS.md`, skill, three deny-by-default read-only agents; plugin adds grep/glob graph lookup, post-`read` coverage, first-tool-result session context, and post-compaction reinjection | +| OpenCode | Detected | `$OPENCODE_CONFIG` or resolved global config | `AGENTS.md`, skill, three deny-by-default read-only agents; plugin adds grep/glob graph lookup, post-`read` coverage, first-tool-result session context, and post-compaction reinjection on OpenCode 1.18.x | | Antigravity | Detected | `.gemini/config/mcp_config.json` | `.gemini/GEMINI.md` | | Aider | Detected | none | `CONVENTIONS.md` via `.aider.conf.yml` | | KiloCode | Detected | `.config/kilo/kilo.jsonc` | Rule + three graph-tool subagents with deny-by-default permissions | @@ -768,6 +770,11 @@ For Codex, install keeps only a tiny managed activation pointer in global `$CODEX_HOME/AGENTS.md`; all detailed behavior lives in the installed `codebase-memory` skill. Fresh installs create the pointer, upgrades replace the legacy full managed block while preserving all user-owned bytes, and uninstall removes only the managed pointer. +Skill placement follows the cross-client user skill root and does not follow +`CODEX_HOME`. On upgrade, the installer migrates an installer-owned legacy +`$CODEX_HOME/skills/codebase-memory/SKILL.md` only after verifying the canonical +copy. Modified or foreign files are preserved; `--force` explicitly authorizes +replacement before migration. ### Sessions, compaction, and subagents @@ -958,6 +965,16 @@ mode. `index_status` keeps describing the published graph and its freshness. `manage_adr` query modes (`get` and `sections`) use the server's cached query store so they can proceed while a same-project reindex is running. If another process publishes a replacement store during reindexing, they can return the pre-publication ADR until idle eviction refreshes that cache. Updates remain serialized through the project mutation guard. +`trace_path` exposes the start nodes' stored `self_recursive` (direct recursion) +and `recursive` (direct recursion or a call-graph cycle) signals in both tree and +JSON output. Across multiple start nodes, a flag is `true` if any records true, +`false` if all record false, and `null` otherwise (including missing legacy +metadata). These are static-analysis signals, not counts of resolved self-calls. +Static `CALLS` indexing omits direct self-call edges; traces that include `CALLS` state +`self_call_policy: omitted`. Caller/callee totals still count graph-reachable +nodes, so a recursive function can have zero callees. Recursion metadata remains +visible on continuation pages and when an output budget prevents rows from fitting. + ## Graph Data Model ### Node Labels diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 1c4fb7194..1a38c6f51 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -289,8 +289,9 @@ typedef struct { * qualified_name (base QN = the first qn_sig_off bytes); 0 = no suffix. * Always 0 until a language enables its callable_identity mode. */ uint32_t qn_sig_off; - /* C# only: declared namespace of a TOP-LEVEL type (NULL for nested types, - * the global namespace and every other language). Per type, because the + /* C# only: declared namespace of a TOP-LEVEL type, "" for the global + * namespace (proven only by a clean parse), NULL for nested types, an + * unknown namespace and every other language. Per type, because the * file-level namespace_name records only a file's first namespace. */ const char *decl_namespace; CBMTestDefinitionRole test_role; /* NONE preserves legacy helper/test-file semantics */ diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index eba64888a..8fbba8961 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -8349,9 +8349,15 @@ static const char *cs_namespace_decl_name(CBMArena *a, TSNode ns, const char *so /* Declared namespace of a C# type declaration: every enclosing namespace * block (outer to inner) under an optional file-scoped namespace, which the * grammar may attach either as an ancestor or as a preceding sibling of the - * compilation unit's members. NULL = global namespace. The file-level - * namespace_name keeps only the FIRST namespace of a file, which mislabels - * every type of a multi-namespace file (#2120). */ + * compilation unit's members. "" = the global namespace, NULL = unknown. The + * file-level namespace_name keeps only the FIRST namespace of a file, which + * mislabels every type of a multi-namespace file (#2120). + * + * "No namespace found" proves the global namespace only for a clean parse: + * error recovery can dissolve a namespace declaration around a construct the + * grammar does not know (a `ref partial struct` with members does it), leaving + * the types after it parented straight under the compilation unit. Calling + * those global would let them hide every imported type of the same name. */ static const char *cs_type_decl_namespace(CBMArena *a, TSNode node, const char *source) { const char *acc = NULL; bool file_scoped = false; @@ -8385,7 +8391,10 @@ static const char *cs_type_decl_namespace(CBMArena *a, TSNode node, const char * } } } - return acc; + if (acc) { + return acc; + } + return ts_node_has_error(top) ? NULL : ""; } static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec) { @@ -9729,6 +9738,24 @@ static TSNode elixir_call_args(TSNode node) { return args; } +/* Combine one folded clause's metric with the function's value so far. A clause + * whose walk could not finish reports CBM_WALK_METRIC_UNAVAILABLE, and so does + * the folded function: a sum that skipped that clause would read low on exactly + * the largest functions. */ +static int elixir_fold_metric_sum(int acc, int clause) { + if (acc < 0 || clause < 0) { + return CBM_WALK_METRIC_UNAVAILABLE; + } + return acc + clause; +} + +static int elixir_fold_metric_max(int acc, int clause) { + if (acc < 0 || clause < 0) { + return CBM_WALK_METRIC_UNAVAILABLE; + } + return clause > acc ? clause : acc; +} + // Fold one more clause of an Elixir function into the def already pushed for // the previous clause. Every clause is its own `def` call, and an Elixir QN // carries neither module nor arity, so all of them compute one qualified name @@ -9807,9 +9834,10 @@ static TSNode elixir_call_args(TSNode node) { // name, where the pre-patch last-clause-wins record reported a publicly // callable name as unexported. Verified against the source in every one: the // new value is the correct one. +// `cx` is this clause's own complexity; a folded clause adds it to the function. // Returns true when the clause folded. static bool fold_elixir_clause(CBMExtractCtx *ctx, const char *qn, TSNode scope, TSNode node, - uint32_t end_line, bool is_exported) { + uint32_t end_line, bool is_exported, const cbm_complexity_t *cx) { if (!qn || ctx->result->defs.count <= 0 || ts_node_is_null(scope)) { return false; } @@ -9834,9 +9862,168 @@ static bool fold_elixir_clause(CBMExtractCtx *ctx, const char *qn, TSNode scope, prev->end_line = end_line; } prev->is_exported = prev->is_exported || is_exported; + /* One node now stands for every clause, so its metrics cover every clause + * rather than the first: branch and loop counts add up, the nesting depths + * take the deepest clause, and each clause past the first is one more + * dispatch decision. A multi-clause function with no branch in any body is + * still a decision tree over its heads. `lines` follows the widened span. */ + int cyclomatic = elixir_fold_metric_sum(prev->complexity, cx->cyclomatic); + prev->complexity = cyclomatic < 0 ? cyclomatic : cyclomatic + 1; + prev->cognitive = elixir_fold_metric_sum(prev->cognitive, cx->cognitive); + prev->loop_count = elixir_fold_metric_sum(prev->loop_count, cx->loop_count); + prev->loop_depth = elixir_fold_metric_max(prev->loop_depth, cx->loop_depth); + prev->max_access_depth = elixir_fold_metric_max(prev->max_access_depth, cx->max_access_depth); + prev->lines = (int)(prev->end_line - prev->start_line + TS_LINE_OFFSET); return true; } +/* True when node `n` spans exactly the text `lit`. Avoids an arena allocation + * per node in the complexity walk, which visits every node of every def. */ +static bool elixir_node_text_is(TSNode n, const char *source, const char *lit) { + if (ts_node_is_null(n)) { + return false; + } + uint32_t s = ts_node_start_byte(n); + uint32_t e = ts_node_end_byte(n); + size_t len = strlen(lit); + return (size_t)(e - s) == len && strncmp(source + s, lit, len) == 0; +} + +/* Elixir has no branching NODE TYPES. `if`, `unless`, `case`, `cond`, `with`, + * `for`, `try` and `receive` are ordinary `call` nodes told apart only by the + * identifier in callee position, and every case/cond/fn/rescue arm is a + * `stab_clause`. lang_specs.c cannot express that — the Elixir branch table is + * `{"call"}`, which would score every function call as a decision — and + * cbm_compute_complexity matches on node type, so the generic path is unusable + * and every Elixir function shipped complexity 0. + * + * Counted here: the branching construct itself, every `stab_clause`, the + * short-circuit operators, and `when` guards. Container AND arm are both + * counted, which is the convention the generic tables already use — Go counts + * `expression_switch_statement` plus each `expression_case`, Python counts + * `try_statement` plus each `except_clause`. Cognitive weighting is Campbell's, + * identical to the generic walk (+1 plus the current nesting depth). */ +static bool elixir_is_branch_call(TSNode call, const char *source) { + if (ts_node_child_count(call) == 0) { + return false; + } + TSNode callee = ts_node_child(call, 0); + if (ts_node_is_null(callee) || strcmp(ts_node_type(callee), "identifier") != 0) { + return false; + } + return elixir_node_text_is(callee, source, "if") || + elixir_node_text_is(callee, source, "unless") || + elixir_node_text_is(callee, source, "with") || + elixir_node_text_is(callee, source, "for") || + elixir_node_text_is(callee, source, "case") || + elixir_node_text_is(callee, source, "cond") || + elixir_node_text_is(callee, source, "try") || + elixir_node_text_is(callee, source, "receive"); +} + +/* A comprehension (`for x <- xs, do: ...`) is Elixir's only real loop node. + * Enum.map/reduce iterate too, but they are plain function calls; counting them + * would make loop_depth meaningless, so they are left out. */ +static bool elixir_is_loop_call(TSNode call, const char *source) { + if (ts_node_child_count(call) == 0) { + return false; + } + TSNode callee = ts_node_child(call, 0); + return !ts_node_is_null(callee) && strcmp(ts_node_type(callee), "identifier") == 0 && + elixir_node_text_is(callee, source, "for"); +} + +static bool elixir_is_branch_operator(TSNode node, const char *source) { + TSNode op = ts_node_child_by_field_name(node, TS_FIELD("operator")); + return elixir_node_text_is(op, source, "&&") || elixir_node_text_is(op, source, "||") || + elixir_node_text_is(op, source, "and") || elixir_node_text_is(op, source, "or") || + elixir_node_text_is(op, source, "when"); +} + +/* The pending frames live on the same growable stack as cbm_compute_complexity: + * a small on-stack first chunk that spills to the heap through + * cbm_walk_stack_reserve, so a wide body is never truncated and no thread pays + * for a large fixed array. If the stack cannot grow, every metric reports + * CBM_WALK_METRIC_UNAVAILABLE instead of a silently low count. */ +static void elixir_compute_complexity(TSNode node, const char *source, cbm_complexity_t *out) { + enum { ELIXIR_CX_STACK_INLINE = 512 }; + struct elixir_cx_frame { + TSNode node; + int bdepth; + int ldepth; + int adepth; + }; + struct elixir_cx_frame inline_stack[ELIXIR_CX_STACK_INLINE]; + struct elixir_cx_frame *stack = inline_stack; + int cap = ELIXIR_CX_STACK_INLINE; + memset(out, 0, sizeof(*out)); + int top = 0; + stack[top].node = node; + stack[top].bdepth = 0; + stack[top].ldepth = 0; + stack[top].adepth = 0; + top++; + while (top > 0) { + int cur = --top; + TSNode n = stack[cur].node; + int bdepth = stack[cur].bdepth; + int ldepth = stack[cur].ldepth; + int adepth = stack[cur].adepth; + const char *kind = ts_node_type(n); + bool is_branch = false; + if (strcmp(kind, "stab_clause") == 0) { + is_branch = true; + } else if (strcmp(kind, "call") == 0) { + is_branch = elixir_is_branch_call(n, source); + } else if (strcmp(kind, "binary_operator") == 0) { + is_branch = elixir_is_branch_operator(n, source); + } + int child_b = bdepth; + int child_l = ldepth; + /* `conn.assigns.user` nests as dot(dot(conn)), so consecutive `dot` + * nodes deepen the chain and anything else resets it. */ + int child_a = 0; + if (strcmp(kind, "dot") == 0) { + child_a = adepth + 1; + if (child_a > out->max_access_depth) { + out->max_access_depth = child_a; + } + } + if (is_branch) { + out->cyclomatic++; + out->cognitive += 1 + bdepth; + child_b = bdepth + 1; + } + if (strcmp(kind, "call") == 0 && elixir_is_loop_call(n, source)) { + out->loop_count++; + int d = ldepth + 1; + if (d > out->loop_depth) { + out->loop_depth = d; + } + child_l = d; + } + uint32_t nc = ts_node_child_count(n); + if (!cbm_walk_stack_reserve((void **)&stack, &cap, top + (int)nc, + sizeof(struct elixir_cx_frame), inline_stack, + "elixir_compute_complexity")) { + out->cyclomatic = CBM_WALK_METRIC_UNAVAILABLE; + out->cognitive = CBM_WALK_METRIC_UNAVAILABLE; + out->loop_count = CBM_WALK_METRIC_UNAVAILABLE; + out->loop_depth = CBM_WALK_METRIC_UNAVAILABLE; + out->max_access_depth = CBM_WALK_METRIC_UNAVAILABLE; + break; + } + for (int i = (int)nc - 1; i >= 0; i--) { + stack[top].node = ts_node_child(n, (uint32_t)i); + stack[top].bdepth = child_b; + stack[top].ldepth = child_l; + stack[top].adepth = child_a; + top++; + } + } + cbm_walk_stack_release(stack, inline_stack); +} + // Handle Elixir def/defp/defmacro — extract function definition. `scope` is the // module body the previous clause was extracted from, and is updated to this // def's own module body; see fold_elixir_clause for what it bounds. @@ -9878,7 +10065,9 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char uint32_t start_line = ts_node_start_point(node).row + TS_LINE_OFFSET; uint32_t end_line = ts_node_end_point(node).row + TS_LINE_OFFSET; bool is_exported = (strcmp(macro, "def") == 0 || strcmp(macro, "defmacro") == 0); - bool folded = fold_elixir_clause(ctx, qn, *scope, node, end_line, is_exported); + cbm_complexity_t cx; + elixir_compute_complexity(node, ctx->source, &cx); + bool folded = fold_elixir_clause(ctx, qn, *scope, node, end_line, is_exported, &cx); *scope = ts_node_parent(node); if (folded) { return; @@ -9893,6 +10082,19 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char def.start_line = start_line; def.end_line = end_line; def.is_exported = is_exported; + def.lines = (int)(def.end_line - def.start_line + TS_LINE_OFFSET); + def.complexity = cx.cyclomatic; + def.cognitive = cx.cognitive; + def.loop_count = cx.loop_count; + def.loop_depth = cx.loop_depth; + def.max_access_depth = cx.max_access_depth; + /* Fingerprint off the body, not the whole def call: including the head + * would make every clause of a name look alike. Elixir was the only + * language emitting Functions with no fingerprint at all, so it never + * appeared in similarity or duplicate results. Runs after `lines` — the + * AST profile reads it. */ + TSNode fp_body = cbm_find_child_by_kind(node, "do_block"); + compute_fingerprint(ctx, &def, ts_node_is_null(fp_body) ? node : fp_body); cbm_defs_push(&ctx->result->defs, a, def); } diff --git a/internal/cbm/lsp/cs_lsp.c b/internal/cbm/lsp/cs_lsp.c index 023a963af..266b846a3 100644 --- a/internal/cbm/lsp/cs_lsp.c +++ b/internal/cbm/lsp/cs_lsp.c @@ -51,6 +51,10 @@ #define CS_USING_INITIAL_CAP 16 #define CS_NAMESPACE_INITIAL_CAP 8 #define CS_LSP_PARENT_WALK_MAX 32 +/* Nested-type qualifier walk depth: one level per type nesting step of `A.B.C` + * (a namespace-qualified top-level type binds without recursing), so 8 covers + * real nesting while a cyclic alias such as `using A = A.B;` stops after 8. */ +#define CS_QUALIFIER_MAX_DEPTH 8 extern const TSLanguage *tree_sitter_c_sharp(void); @@ -257,11 +261,15 @@ void cs_lsp_init(CSLSPContext *ctx, CBMArena *arena, const char *source, int sou /* ── using management ───────────────────────────────────────────── */ -void cs_lsp_add_using(CSLSPContext *ctx, CBMCSUsingKind kind, const char *local_name, - const char *target_qn, bool is_global) { +/* Append a using directive; `in_namespace` marks one declared inside a + * namespace body (CBMCSUsing.in_namespace). */ +static void cs_add_using_scoped(CSLSPContext *ctx, CBMCSUsingKind kind, const char *local_name, + const char *target_qn, bool is_global, bool in_namespace) { if (!target_qn) return; - /* Dedupe by (kind, local_name, target). */ + /* Dedupe by (kind, local_name, target). The cross-file entry pre-loads + * the pipeline's import list without placement; the AST walk then finds + * the same directive and is the authority on where it was declared. */ for (int i = 0; i < ctx->using_count; i++) { CBMCSUsing *u = &ctx->usings[i]; if (u->kind != kind) @@ -272,6 +280,7 @@ void cs_lsp_add_using(CSLSPContext *ctx, CBMCSUsingKind kind, const char *local_ const char *b = u->local_name ? u->local_name : ""; if (strcmp(a, b) != 0) continue; + u->in_namespace = u->in_namespace || in_namespace; return; } if (ctx->using_count >= ctx->using_cap) { @@ -289,6 +298,12 @@ void cs_lsp_add_using(CSLSPContext *ctx, CBMCSUsingKind kind, const char *local_ slot->local_name = local_name ? cbm_arena_strdup(ctx->arena, local_name) : ""; slot->target_qn = cbm_arena_strdup(ctx->arena, target_qn); slot->is_global = is_global; + slot->in_namespace = in_namespace; +} + +void cs_lsp_add_using(CSLSPContext *ctx, CBMCSUsingKind kind, const char *local_name, + const char *target_qn, bool is_global) { + cs_add_using_scoped(ctx, kind, local_name, target_qn, is_global, false); } /* ── namespace stack ────────────────────────────────────────────── */ @@ -358,12 +373,29 @@ static const char *cs_try_type_qn(CSLSPContext *ctx, const char *qn) { return NULL; } +/* Append the file's using-namespace targets declared inside (`in_namespace`) + * or outside a namespace body. */ +static int cs_append_using_namespaces(const CSLSPContext *ctx, bool in_namespace, const char **out, + int n, int cap) { + for (int i = 0; i < ctx->using_count && n < cap; i++) { + const CBMCSUsing *u = &ctx->usings[i]; + if (u->kind == CBM_CS_USING_NAMESPACE && u->in_namespace == in_namespace && u->target_qn && + u->target_qn[0]) + out[n++] = u->target_qn; + } + return n; +} + /* Namespaces through which `qualifier` (the part of a type reference before * its simple name, NULL for a simple name) can reach a top-level type, in C# * lookup order: each enclosing namespace innermost outward, then the global - * namespace (qualified references only) or the file's `using` namespaces - * (simple names only -- a using-namespace directive does not import nested - * namespaces). Returns the count written to `out` (at most `cap`). */ + * namespace (qualified references only) or, for a simple name, the usings + * of the enclosing namespace bodies, the global namespace "" and last the + * compilation-unit usings (a using-namespace directive does not import nested + * namespaces). C# consults the members of the global namespace BEFORE the + * compilation unit's usings: a top-level type declared without a namespace + * hides an imported type of the same name. Returns the count written to + * `out` (at most `cap`). */ static int cs_visible_namespaces(CSLSPContext *ctx, const char *qualifier, const char **out, int cap) { int n = 0; @@ -378,12 +410,13 @@ static int cs_visible_namespaces(CSLSPContext *ctx, const char *qualifier, const out[n++] = qualifier; return n; } - for (int i = 0; i < ctx->using_count && n < cap; i++) { - const CBMCSUsing *u = &ctx->usings[i]; - if (u->kind == CBM_CS_USING_NAMESPACE && u->target_qn && u->target_qn[0]) - out[n++] = u->target_qn; - } - return n; + n = cs_append_using_namespaces(ctx, true, out, n, cap); + /* The global namespace (#2120 residual: such types used to be invisible + * to step 8b) ranks after every enclosing namespace and namespace-body + * using, and before the compilation-unit usings. */ + if (n < cap) + out[n++] = ""; + return cs_append_using_namespaces(ctx, false, out, n, cap); } /* Bind a (possibly namespace-qualified) type reference to the project type @@ -421,6 +454,24 @@ static const char *cs_resolve_by_declared_namespace(CSLSPContext *ctx, const cha } } } + /* No namespace-qualified hit: a qualified `A.B` may name a NESTED type — + * A a type in scope (resolved recursively, one segment shorter), B a + * member type of it. Nested QNs are the parent QN plus the simple name, + * which step 8b's namespace scan can never produce because nested types + * carry no namespace_qn (#2120 residual: `new Command.Result(...)`, + * `new Grain.Handler(dep).Handle(...)`). The walk is depth-bounded: an + * alias whose target names the alias itself (`using A = A.B;`, or a + * mutual pair) re-enters this path through step 8 with no end. */ + if (!best && qualifier && ctx->qualifier_depth < CS_QUALIFIER_MAX_DEPTH) { + ctx->qualifier_depth++; + const char *qtype = cs_resolve_type_name(ctx, qualifier); + ctx->qualifier_depth--; + if (qtype) { + const char *nested = cbm_arena_sprintf(ctx->arena, "%s.%s", qtype, simple); + if (cs_lookup_type_qn(ctx, nested)) + return nested; + } + } return best ? best->qualified_name : NULL; } @@ -610,10 +661,14 @@ const char *cs_resolve_type_name(CSLSPContext *ctx, const char *raw) { } /* Other top-level declarations of the type `t` names: same simple name, same - * declared namespace, different entry. Registration order; at most `cap`. */ + * declared namespace, different entry. Registration order; at most `cap`. + * A type in the global namespace ("") has no twins: without a namespace the + * same name across a repository is a different type per assembly (every test + * project's own `Program`), so pooling them would bind a member of one to an + * unrelated other, ahead of the type's own base classes. */ static int cs_namespace_twins(CSLSPContext *ctx, const CBMRegisteredType *t, const CBMRegisteredType **out, int cap) { - if (!ctx->registry || !t || !t->namespace_qn || !t->short_name) + if (!ctx->registry || !t || !t->namespace_qn || !t->namespace_qn[0] || !t->short_name) return 0; int n = 0; CBMTypeShortIter it; @@ -2797,12 +2852,19 @@ static void cs_process_type_decl(CSLSPContext *ctx, TSNode node) { static void cs_collect_imports(CSLSPContext *ctx, TSNode root) { /* Walk the entire tree once at top — using directives can appear at file - * scope or inside namespace blocks. */ - TSNode stack[256]; + * scope or inside namespace blocks. `in_ns` records per stacked node + * whether it sits inside a namespace declaration: a block namespace's + * body, or the compilation-unit siblings after a file-scoped namespace + * (the grammar may attach those either way). */ + enum { CS_IMPORT_WALK_MAX = 256 }; + TSNode stack[CS_IMPORT_WALK_MAX]; + bool in_ns[CS_IMPORT_WALK_MAX]; int top = 0; + in_ns[top] = false; stack[top++] = root; while (top > 0) { TSNode n = stack[--top]; + bool n_in_ns = in_ns[top]; if (ts_node_is_null(n)) continue; const char *k = ts_node_type(n); @@ -2886,20 +2948,23 @@ static void cs_collect_imports(CSLSPContext *ctx, TSNode root) { } if (target) { if (is_alias) { - cs_lsp_add_using(ctx, CBM_CS_USING_ALIAS, alias_name ? alias_name : "", target, - is_global); + cs_add_using_scoped(ctx, CBM_CS_USING_ALIAS, alias_name ? alias_name : "", + target, is_global, n_in_ns); } else if (is_static) { - cs_lsp_add_using(ctx, CBM_CS_USING_STATIC, "", target, is_global); + cs_add_using_scoped(ctx, CBM_CS_USING_STATIC, "", target, is_global, n_in_ns); } else { - cs_lsp_add_using(ctx, CBM_CS_USING_NAMESPACE, "", target, is_global); + cs_add_using_scoped(ctx, CBM_CS_USING_NAMESPACE, "", target, is_global, + n_in_ns); } } } /* Push children for traversal. We don't recurse into method bodies * (using directives can't appear there), but namespace bodies may * contain more usings. */ + bool child_in_ns = n_in_ns || strcmp(k, "namespace_declaration") == 0 || + strcmp(k, "file_scoped_namespace_declaration") == 0; uint32_t cnc = ts_node_child_count(n); - for (uint32_t i = 0; i < cnc && top < 256; i++) { + for (uint32_t i = 0; i < cnc && top < CS_IMPORT_WALK_MAX; i++) { TSNode c = ts_node_child(n, i); if (ts_node_is_null(c)) continue; @@ -2908,7 +2973,11 @@ static void cs_collect_imports(CSLSPContext *ctx, TSNode root) { strcmp(ck, "constructor_declaration") == 0 || strcmp(ck, "block") == 0) { continue; } + in_ns[top] = child_in_ns; stack[top++] = c; + /* A file-scoped namespace covers the siblings that follow it. */ + if (strcmp(ck, "file_scoped_namespace_declaration") == 0) + child_in_ns = true; } } } @@ -3749,12 +3818,16 @@ void cbm_run_cs_lsp(CBMArena *arena, CBMFileResult *result, const char *source, * type is not reachable by its simple name through a namespace). */ static const char *cs_top_level_namespace(const char *type_qn, const char *short_name, const char *module_qn, const char *namespace_name) { - if (!type_qn || !short_name || !module_qn || !namespace_name || !namespace_name[0]) + if (!type_qn || !short_name || !module_qn) return NULL; size_t mlen = strlen(module_qn); if (strncmp(type_qn, module_qn, mlen) != 0 || type_qn[mlen] != '.' || strcmp(type_qn + mlen + 1, short_name) != 0) return NULL; + /* Top-level C# type: its declared namespace, "" for the global namespace + * so step 8b can bind it, NULL when extraction could not tell (a parse + * error dissolved the namespace; see cs_type_decl_namespace). NULL stays + * invisible to step 8b, as do nested types. */ return namespace_name; } diff --git a/internal/cbm/lsp/cs_lsp.h b/internal/cbm/lsp/cs_lsp.h index 27cc233a5..4c47fc67d 100644 --- a/internal/cbm/lsp/cs_lsp.h +++ b/internal/cbm/lsp/cs_lsp.h @@ -61,6 +61,11 @@ typedef struct { const char *local_name; /* alias name; "" for non-alias */ const char *target_qn; /* dotted target QN */ bool is_global; /* `global using` */ + /* Declared inside a namespace body rather than at compilation-unit level. + * C# consults a namespace declaration's usings right after that + * namespace's own members, but compilation-unit usings only after the + * global namespace's members, so the two rank on opposite sides of it. */ + bool in_namespace; } CBMCSUsing; /* CSLSPContext — per-file type-evaluation state. */ @@ -111,6 +116,11 @@ typedef struct { /* Recursion guard for cs_eval_expr_type. */ int eval_depth; + /* Recursion guard for the nested-type qualifier walk of + * cs_resolve_type_name: resolving `A.B` resolves the qualifier `A` as a + * type first, and a `using A = A.B;` alias cycles back into it. */ + int qualifier_depth; + /* Debug mode (CBM_LSP_DEBUG env). */ bool debug; } CSLSPContext; diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 2a894ca55..993870217 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -1367,6 +1367,118 @@ fi echo "" echo "=== Phase 8: agent config install E2E ===" +# Keep Codex skill placement independent from the broad multi-client fixture: +# Warp and OpenHands share the canonical root and could otherwise mask a Codex +# regression by creating the expected file themselves. +CODEX_ONLY_HOME=$(smoke_mktemp_dir) +CODEX_ONLY_CONFIG="$CODEX_ONLY_HOME/vendor-codex" +CODEX_ONLY_INSTALL="$CODEX_ONLY_HOME/bin" +CODEX_ONLY_SKILL="$CODEX_ONLY_HOME/.agents/skills/codebase-memory/SKILL.md" +CODEX_ONLY_LEGACY="$CODEX_ONLY_CONFIG/skills/codebase-memory/SKILL.md" +mkdir -p "$CODEX_ONLY_CONFIG" "$CODEX_ONLY_INSTALL" +if [[ "$BINARY" == *.exe ]]; then + CODEX_ONLY_BIN="$CODEX_ONLY_INSTALL/codebase-memory-mcp.exe" +else + CODEX_ONLY_BIN="$CODEX_ONLY_INSTALL/codebase-memory-mcp" +fi +CODEX_ONLY_LOG=$(smoke_mktemp_file) +run_codex_only() { + local executable="$1" + shift + HOME="$CODEX_ONLY_HOME" \ + XDG_CONFIG_HOME="$CODEX_ONLY_HOME/.config" \ + APPDATA="$CODEX_ONLY_HOME/AppData/Roaming" \ + LOCALAPPDATA="$CODEX_ONLY_HOME/AppData/Local" \ + CODEX_HOME="$CODEX_ONLY_CONFIG" \ + "$executable" "$@" > "$CODEX_ONLY_LOG" 2>&1 +} +if ! run_codex_only "$BINARY" install -y --clients=codex --dir="$CODEX_ONLY_INSTALL"; then + echo "FAIL 8-codex-1: isolated Codex install failed" + cat "$CODEX_ONLY_LOG" + exit 1 +fi +if ! grep -q '^name: codebase-memory$' "$CODEX_ONLY_SKILL" 2>/dev/null || + ! grep -q 'trace_path' "$CODEX_ONLY_SKILL" 2>/dev/null || + [ -e "$CODEX_ONLY_LEGACY" ]; then + echo "FAIL 8-codex-1: Codex skill was not installed exclusively at the canonical path" + cat "$CODEX_ONLY_LOG" + exit 1 +fi + +CODEX_ONLY_HASH=$(smoke_file_sha256 "$CODEX_ONLY_SKILL") +if ! run_codex_only "$CODEX_ONLY_BIN" install --plan --clients=codex \ + --dir="$CODEX_ONLY_INSTALL" || + [ "$(smoke_file_sha256 "$CODEX_ONLY_SKILL")" != "$CODEX_ONLY_HASH" ]; then + echo "FAIL 8-codex-2: Codex install plan mutated the canonical skill" + cat "$CODEX_ONLY_LOG" + exit 1 +fi +if ! run_codex_only "$CODEX_ONLY_BIN" install --dry-run -y --clients=codex \ + --dir="$CODEX_ONLY_INSTALL" || + [ "$(smoke_file_sha256 "$CODEX_ONLY_SKILL")" != "$CODEX_ONLY_HASH" ]; then + echo "FAIL 8-codex-2: Codex dry-run mutated the canonical skill" + cat "$CODEX_ONLY_LOG" + exit 1 +fi + +mkdir -p "$(dirname "$CODEX_ONLY_LEGACY")" +mv "$CODEX_ONLY_SKILL" "$CODEX_ONLY_LEGACY" +if ! run_codex_only "$CODEX_ONLY_BIN" install -y --clients=codex \ + --dir="$CODEX_ONLY_INSTALL"; then + echo "FAIL 8-codex-3: isolated Codex legacy migration failed" + cat "$CODEX_ONLY_LOG" + exit 1 +fi +if ! grep -q '^name: codebase-memory$' "$CODEX_ONLY_SKILL" 2>/dev/null || + [ -e "$CODEX_ONLY_LEGACY" ]; then + echo "FAIL 8-codex-3: Codex legacy skill was not migrated to the canonical path" + cat "$CODEX_ONLY_LOG" + exit 1 +fi + +rm -f "$CODEX_ONLY_SKILL" +mkdir -p "$(dirname "$CODEX_ONLY_LEGACY")" +printf '%s\n' '---' 'name: codebase-memory' '---' 'User-owned legacy skill.' \ + > "$CODEX_ONLY_LEGACY" +CODEX_ONLY_FOREIGN_HASH=$(smoke_file_sha256 "$CODEX_ONLY_LEGACY") +if ! run_codex_only "$CODEX_ONLY_BIN" install -y --clients=codex \ + --dir="$CODEX_ONLY_INSTALL" || + [ -e "$CODEX_ONLY_SKILL" ] || + [ "$(smoke_file_sha256 "$CODEX_ONLY_LEGACY")" != "$CODEX_ONLY_FOREIGN_HASH" ]; then + echo "FAIL 8-codex-4: non-force install did not preserve a foreign legacy skill" + cat "$CODEX_ONLY_LOG" + exit 1 +fi +mkdir -p "$CODEX_ONLY_HOME/.openhands" +if ! run_codex_only "$CODEX_ONLY_BIN" install -y --clients=codex,openhands \ + --dir="$CODEX_ONLY_INSTALL" || + ! grep -q '^name: codebase-memory$' "$CODEX_ONLY_SKILL" 2>/dev/null || + [ "$(smoke_file_sha256 "$CODEX_ONLY_LEGACY")" != "$CODEX_ONLY_FOREIGN_HASH" ]; then + echo "FAIL 8-codex-5: a foreign Codex legacy skill blocked a shared-client install" + cat "$CODEX_ONLY_LOG" + exit 1 +fi +rm -f "$CODEX_ONLY_SKILL" +if ! run_codex_only "$CODEX_ONLY_BIN" install --force -y --clients=codex \ + --dir="$CODEX_ONLY_INSTALL" || + ! grep -q '^name: codebase-memory$' "$CODEX_ONLY_SKILL" 2>/dev/null || + [ -e "$CODEX_ONLY_LEGACY" ]; then + echo "FAIL 8-codex-6: force did not converge a foreign legacy skill" + cat "$CODEX_ONLY_LOG" + exit 1 +fi +CODEX_ONLY_HASH=$(smoke_file_sha256 "$CODEX_ONLY_SKILL") +if ! run_codex_only "$CODEX_ONLY_BIN" install -y --clients=codex \ + --dir="$CODEX_ONLY_INSTALL" || + [ "$(smoke_file_sha256 "$CODEX_ONLY_SKILL")" != "$CODEX_ONLY_HASH" ]; then + echo "FAIL 8-codex-7: repeated Codex install was not idempotent" + cat "$CODEX_ONLY_LOG" + exit 1 +fi +echo "OK: Codex skill placement, previews, migration, force, and idempotence" +smoke_rmtree "$CODEX_ONLY_HOME" +rm -f "$CODEX_ONLY_LOG" + # Set up an isolated HOME. Directory-only agents get only the root required for # detection; CLI-detected agents use stubs below so install must create their # config parents from scratch. @@ -1780,6 +1892,8 @@ fi CODEX_LIFECYCLE_HOME=$(smoke_mktemp_dir) CODEX_LIFECYCLE_ROOT="$CODEX_LIFECYCLE_HOME/.codex" CODEX_INSTRUCTIONS="$CODEX_LIFECYCLE_ROOT/AGENTS.md" +CODEX_LIFECYCLE_SKILL="$CODEX_LIFECYCLE_HOME/.agents/skills/codebase-memory/SKILL.md" +CODEX_LIFECYCLE_LEGACY_SKILL="$CODEX_LIFECYCLE_ROOT/skills/codebase-memory/SKILL.md" mkdir -p "$CODEX_LIFECYCLE_ROOT" CODEX_FRESH_LOG=$(smoke_mktemp_file) @@ -1793,7 +1907,8 @@ if [ ! -f "$CODEX_INSTRUCTIONS" ] || [ "$(smoke_file_sha256 "$CODEX_INSTRUCTIONS")" != \ "$(smoke_file_sha256 "$CODEX_POINTER_EXPECTED")" ] || ! grep -q '\[mcp_servers.codebase-memory-mcp\]' "$CODEX_LIFECYCLE_ROOT/config.toml" || - ! grep -q 'search_graph' "$CODEX_LIFECYCLE_ROOT/skills/codebase-memory/SKILL.md" || + ! grep -q 'search_graph' "$CODEX_LIFECYCLE_SKILL" || + [ -e "$CODEX_LIFECYCLE_LEGACY_SKILL" ] || [ ! -s "$CODEX_LIFECYCLE_ROOT/agents/codebase-memory-scout.toml" ] || [ ! -s "$CODEX_LIFECYCLE_ROOT/agents/codebase-memory.toml" ] || [ ! -s "$CODEX_LIFECYCLE_ROOT/agents/codebase-memory-auditor.toml" ] || @@ -1890,8 +2005,10 @@ if ! HOME="$CODEX_LIFECYCLE_HOME" \ fi if [ ! -f "$CODEX_INSTRUCTIONS" ] || [ "$(smoke_file_sha256 "$CODEX_INSTRUCTIONS")" != \ - "$(smoke_file_sha256 "$CODEX_EXPECTED_USER")" ]; then - echo "FAIL 8i: Codex uninstall did not remove only the activation pointer" + "$(smoke_file_sha256 "$CODEX_EXPECTED_USER")" ] || + [ -e "$CODEX_LIFECYCLE_SKILL" ] || + [ -e "$CODEX_LIFECYCLE_LEGACY_SKILL" ]; then + echo "FAIL 8i: Codex uninstall did not remove only installer-owned surfaces" exit 1 fi echo "OK 8i: isolated Codex activation-pointer lifecycle" diff --git a/src/cli/cli.c b/src/cli/cli.c index bccc4f183..587e25918 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -162,6 +162,74 @@ int cbm_cli_exit_status_after_maintenance(int exit_status, bool maintenance_canc return maintenance_cancelled && exit_status == EXIT_SUCCESS ? EXIT_FAILURE : exit_status; } +/* One override, parsed strictly: a typo must not silently disable a gate, so + * anything that is not a whole number falls back to the documented default. + * cbm_env_long is the project's strict reader for exactly this case: it + * refuses empty text, blanks, trailing junk and values too large for a long + * (strtol would clamp those to LONG_MAX, a ceiling no run can exceed), and it + * reads the Windows environment the same way every other setting does. */ +static long cli_gate_threshold(const char *name, long fallback) { + long value = 0; + return cbm_env_long(name, &value) ? value : fallback; +} + +/* The contract is opt-in. Unset, empty, or "0" leaves the historical 0/1 + * status untouched, so no existing caller sees a new code until it asks. */ +static bool cli_gate_enabled(void) { + const char *raw = getenv("CBM_GATE"); + return raw && raw[0] && strcmp(raw, "0") != 0; +} + +int cbm_cli_index_exit_status(const char *result, int base_status) { + if (!result || !cli_gate_enabled()) { + return base_status; + } + yyjson_doc *envelope = yyjson_read(result, strlen(result), 0); + if (!envelope) { + return base_status; + } + yyjson_val *root = yyjson_doc_get_root(envelope); + yyjson_val *content = yyjson_is_obj(root) ? yyjson_obj_get(root, "content") : NULL; + yyjson_val *first = yyjson_is_arr(content) ? yyjson_arr_get_first(content) : NULL; + const char *text = first ? yyjson_get_str(yyjson_obj_get(first, "text")) : NULL; + + int status = base_status; + yyjson_doc *payload = text ? yyjson_read(text, strlen(text), 0) : NULL; + yyjson_val *proot = payload ? yyjson_doc_get_root(payload) : NULL; + if (yyjson_is_obj(proot)) { + const char *state = yyjson_get_str(yyjson_obj_get(proot, "status")); + const char *reason = yyjson_get_str(yyjson_obj_get(proot, "reason")); + if (state && strcmp(state, "error") == 0) { + status = (reason && strcmp(reason, "target_unavailable") == 0) + ? CBM_CLI_EXIT_TARGET + : (base_status != CBM_CLI_EXIT_OK ? base_status : CBM_CLI_EXIT_FAILURE); + } else if (base_status == CBM_CLI_EXIT_OK) { + long unusable = (long)yyjson_get_int(yyjson_obj_get(proot, "parse_unusable_count")); + long partial = (long)yyjson_get_int(yyjson_obj_get(proot, "parse_partial_count")); + long files = (long)yyjson_get_int(yyjson_obj_get(proot, "files_indexed")); + long max_unusable = cli_gate_threshold("CBM_GATE_MAX_UNUSABLE", 0); + long max_partial_pct = cli_gate_threshold("CBM_GATE_MAX_PARTIAL_PCT", 10); + /* "degraded" is the pipeline's own verdict that the graph came out + * far smaller than the run expected — a quality failure by any + * reading, so it joins the two parse thresholds. */ + bool degraded = strcmp(state ? state : "", "degraded") == 0; + bool too_many_unusable = max_unusable >= 0 && unusable > max_unusable; + /* Integer arithmetic on purpose: a percentage compared through a + * double would make the threshold depend on rounding. */ + bool too_many_partial = + max_partial_pct >= 0 && files > 0 && partial * 100 > max_partial_pct * files; + if (degraded || too_many_unusable || too_many_partial) { + status = CBM_CLI_EXIT_QUALITY; + } + } + } + if (payload) { + yyjson_doc_free(payload); + } + yyjson_doc_free(envelope); + return status; +} + /* #1537. Two very different failures reached this one message: the cohort was * BUSY (real sessions are running — the reader can close them), or the * reservation failed outright (lock I/O, stale coordination state, permissions @@ -1533,6 +1601,232 @@ int cbm_replace_binary(const char *path, const unsigned char *data, int len, int /* Consolidated from 4 separate skills into 1 with progressive disclosure. * This embedded version is the single source of truth for the CLI installer. * Based on PR #81 by @gdilla — factual corrections applied. */ +static const char released_skill_content_v0_6_0[] = + "---\n" + "name: codebase-memory\n" + "description: Use the codebase knowledge graph for structural code queries. " + "Triggers on: explore the codebase, understand the architecture, what functions exist, " + "show me the structure, who calls this function, what does X call, trace the call chain, " + "find callers of, show dependencies, impact analysis, dead code, unused functions, " + "high fan-out, refactor candidates, code quality audit, graph query syntax, " + "Cypher query examples, edge types, how to use search_graph.\n" + "---\n" + "\n" + "# Codebase Memory — Knowledge Graph Tools\n" + "\n" + "Graph tools return precise structural results in ~500 tokens vs ~80K for grep.\n" + "\n" + "## Quick Decision Matrix\n" + "\n" + "| Question | Tool call |\n" + "|----------|----------|\n" + "| Who calls X? | `trace_path(direction=\"inbound\")` |\n" + "| What does X call? | `trace_path(direction=\"outbound\")` |\n" + "| Full call context | `trace_path(direction=\"both\")` |\n" + "| Find by name pattern | `search_graph(name_pattern=\"...\")` |\n" + "| Dead code | `search_graph(max_degree=0, exclude_entry_points=true)` |\n" + "| Cross-service edges | `query_graph` with Cypher |\n" + "| Impact of local changes | `detect_changes()` |\n" + "| Risk-classified trace | `trace_path(risk_labels=true)` |\n" + "| Text search | `search_code` or Grep |\n" + "\n" + "## Exploration Workflow\n" + "1. `list_projects` — check if project is indexed\n" + "2. `get_graph_schema` — understand node/edge types\n" + "3. `search_graph(label=\"Function\", name_pattern=\".*Pattern.*\")` — find code\n" + "4. `get_code_snippet(qualified_name=\"project.path.FuncName\")` — read source\n" + "\n" + "## Tracing Workflow\n" + "1. `search_graph(name_pattern=\".*FuncName.*\")` — discover exact name\n" + "2. `trace_path(function_name=\"FuncName\", direction=\"both\", depth=3)` — trace\n" + "3. `detect_changes()` — map git diff to affected symbols\n" + "\n" + "## Quality Analysis\n" + "- Dead code: `search_graph(max_degree=0, exclude_entry_points=true)`\n" + "- High fan-out: `search_graph(min_degree=10, relationship=\"CALLS\", " + "direction=\"outbound\")`\n" + "- High fan-in: `search_graph(min_degree=10, relationship=\"CALLS\", " + "direction=\"inbound\")`\n" + "\n" + "## 14 MCP Tools\n" + "`index_repository`, `index_status`, `list_projects`, `delete_project`,\n" + "`search_graph`, `search_code`, `trace_path`, `detect_changes`,\n" + "`query_graph`, `get_graph_schema`, `get_code_snippet`, `get_architecture`,\n" + "`manage_adr`, `ingest_traces`\n" + "\n" + "## Edge Types\n" + "CALLS, HTTP_CALLS, ASYNC_CALLS, IMPORTS, DEFINES, DEFINES_METHOD,\n" + "HANDLES, IMPLEMENTS, OVERRIDE, USAGE, FILE_CHANGES_WITH,\n" + "CONTAINS_FILE, CONTAINS_FOLDER, CONTAINS_PACKAGE\n" + "\n" + "## Cypher Examples (for query_graph)\n" + "```\n" + "MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, " + "r.confidence LIMIT 20\n" + "MATCH (f:Function) WHERE f.name =~ '.*Handler.*' RETURN f.name, f.file_path\n" + "MATCH (a)-[r:CALLS]->(b) WHERE a.name = 'main' RETURN b.name\n" + "```\n" + "\n" + "## Gotchas\n" + "1. `search_graph(relationship=\"HTTP_CALLS\")` filters nodes by degree — " + "use `query_graph` with Cypher to see actual edges.\n" + "2. `query_graph` has a 200-row cap — use `search_graph` with degree filters " + "for counting.\n" + "3. `trace_path` needs exact names — use `search_graph(name_pattern=...)` first.\n" + "4. `direction=\"outbound\"` misses cross-service callers — use " + "`direction=\"both\"`.\n" + "5. Results default to 10 per page — check `has_more` and use `offset`.\n"; + +#define CBM_RELEASED_SKILL_HEADER_UNQUOTED \ + "---\n" \ + "name: codebase-memory\n" \ + "description: Use the codebase knowledge graph for structural code queries. " \ + "Triggers on: explore the codebase, understand the architecture, what functions exist, " \ + "show me the structure, who calls this function, what does X call, trace the call chain, " \ + "find callers of, show dependencies, impact analysis, dead code, unused functions, " \ + "high fan-out, refactor candidates, code quality audit, graph query syntax, " \ + "Cypher query examples, edge types, how to use search_graph.\n" \ + "---\n" + +#define CBM_RELEASED_SKILL_HEADER_QUOTED \ + "---\n" \ + "name: codebase-memory\n" \ + "description: \"Use the codebase knowledge graph for structural code queries. " \ + "Triggers on: explore the codebase, understand the architecture, what functions exist, " \ + "show me the structure, who calls this function, what does X call, trace the call chain, " \ + "find callers of, show dependencies, impact analysis, dead code, unused functions, " \ + "high fan-out, refactor candidates, code quality audit, graph query syntax, " \ + "Cypher query examples, edge types, how to use search_graph.\"\n" \ + "---\n" + +#define CBM_RELEASED_SKILL_BODY_BEFORE_EDGE_TYPES \ + "\n" \ + "# Codebase Memory — Knowledge Graph Tools\n" \ + "\n" \ + "Graph tools return precise structural results in ~500 tokens vs ~80K for grep.\n" \ + "\n" \ + "## Quick Decision Matrix\n" \ + "\n" \ + "| Question | Tool call |\n" \ + "|----------|----------|\n" \ + "| Who calls X? | `trace_path(direction=\"inbound\")` |\n" \ + "| What does X call? | `trace_path(direction=\"outbound\")` |\n" \ + "| Full call context | `trace_path(direction=\"both\")` |\n" \ + "| Find by name pattern | `search_graph(name_pattern=\"...\")` |\n" \ + "| Dead code | `search_graph(max_degree=0, exclude_entry_points=true)` |\n" \ + "| Cross-service edges | `query_graph` with Cypher |\n" \ + "| Impact of local changes | `detect_changes()` |\n" \ + "| Risk-classified trace | `trace_path(risk_labels=true)` |\n" \ + "| Text search | `search_code` or Grep |\n" \ + "\n" \ + "## Exploration Workflow\n" \ + "1. `list_projects` — check if project is indexed\n" \ + "2. `get_graph_schema` — understand node/edge types\n" \ + "3. `search_graph(label=\"Function\", name_pattern=\".*Pattern.*\")` — find code\n" \ + "4. `get_code_snippet(qualified_name=\"project.path.FuncName\")` — read source\n" \ + "\n" \ + "## Tracing Workflow\n" \ + "1. `search_graph(name_pattern=\".*FuncName.*\")` — discover exact name\n" \ + "2. `trace_path(function_name=\"FuncName\", direction=\"both\", depth=3)` — trace\n" \ + "3. `detect_changes()` — map git diff to affected symbols\n" \ + "\n" \ + "## Evidence Tiers\n" \ + "- **Scout (Tier 1):** fast positive lookup with few graph calls and targeted source checks. " \ + "Treat results as provisional; never make absence, exhaustive, dead-code, or complete-impact " \ + "claims.\n" \ + "- **Verify (Tier 2, default):** task-directed searches, relevant trace directions, exact " \ + "snippets for material claims, and all relevant result pages.\n" \ + "- **Auditor (Tier 3):** bounded-scope full verification with a current graph generation, " \ + "complete relevant pagination, both call directions and broader relationships when material, " \ + "plus explicit unresolved limitations.\n" \ + "- **Every tier:** after candidate paths are known, call `check_index_coverage` once with " \ + "every " \ + "evidence path. For negative or exhaustive claims also include the relevant scopes. A clean " \ + "result means no recorded gap, not proof of completeness. For partial, skipped, excluded, " \ + "stale, pending, or unknown coverage, read/grep the reported ranges or scope before relying " \ + "on " \ + "the graph.\n" \ + "\n" \ + "## Sessions and Subagents\n" \ + "- At session start or after compaction, call `list_projects`/`index_status` before " \ + "structural exploration, then choose Scout, Verify, or Auditor for the task.\n" \ + "- Before delegating, query the graph and coverage in the parent. Pass the tier, exact " \ + "project, " \ + "generation/freshness, bounded scope, queries and pagination state, qualified symbols, " \ + "paths, " \ + "call-chain findings, coverage ranges/reasons, source fallback already performed, and " \ + "unresolved " \ + "questions to the child.\n" \ + "- Runtimes such as Hermes isolate child context: put those graph findings in the " \ + "`context` argument to `delegate_task`; do not assume the child inherits MCP access or " \ + "the parent's conversation.\n" \ + "- A child without MCP tools must not call or claim MCP access. It should work from the " \ + "supplied " \ + "evidence and use read/grep on exact source, especially every reported missed-coverage " \ + "range.\n" \ + "\n" \ + "## Quality Analysis\n" \ + "- Dead code: `search_graph(max_degree=0, exclude_entry_points=true)`\n" \ + "- High fan-out: `search_graph(min_degree=10, relationship=\"CALLS\", " \ + "direction=\"outbound\")`\n" \ + "- High fan-in: `search_graph(min_degree=10, relationship=\"CALLS\", " \ + "direction=\"inbound\")`\n" \ + "\n" \ + "## 15 MCP Tools\n" \ + "`index_repository`, `index_status`, `list_projects`, `delete_project`,\n" \ + "`search_graph`, `search_code`, `trace_path`, `detect_changes`,\n" \ + "`query_graph`, `get_graph_schema`, `get_code_snippet`, `get_architecture`,\n" \ + "`check_index_coverage`, `manage_adr`, `ingest_traces`\n" \ + "\n" \ + "## Edge Types\n" \ + "CALLS, HTTP_CALLS, ASYNC_CALLS, DATA_FLOWS, IMPORTS, DEFINES, DEFINES_METHOD,\n" + +#define CBM_RELEASED_SKILL_EDGE_TYPES_PRE_V0_10 \ + "HANDLES, IMPLEMENTS, OVERRIDE, USAGE, CONFIGURES, FILE_CHANGES_WITH,\n" + +#define CBM_RELEASED_SKILL_EDGE_TYPES_V0_10 \ + "HANDLES, IMPLEMENTS, OVERRIDE, USAGE, CALL_REFERENCE, CONFIGURES, FILE_CHANGES_WITH,\n" + +#define CBM_RELEASED_SKILL_BODY_AFTER_EDGE_TYPES \ + "SIMILAR_TO, SEMANTICALLY_RELATED, CONTAINS_FILE, CONTAINS_FOLDER,\n" \ + "CONTAINS_PACKAGE\n" \ + "\n" \ + "## Cypher Examples (for query_graph)\n" \ + "```\n" \ + "MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, " \ + "r.confidence LIMIT 20\n" \ + "MATCH (f:Function) WHERE f.name =~ '.*Handler.*' RETURN f.name, f.file_path\n" \ + "MATCH (a)-[r:CALLS]->(b) WHERE a.name = 'main' RETURN b.name\n" \ + "```\n" \ + "\n" \ + "## Gotchas\n" \ + "1. `search_graph(relationship=\"HTTP_CALLS\")` filters nodes by degree — " \ + "use `query_graph` with Cypher to see actual edges.\n" \ + "2. `query_graph` has a 100k row ceiling — add a Cypher `LIMIT` for broad queries " \ + "or use `search_graph` pagination.\n" \ + "3. `trace_path` needs exact names — use `search_graph(name_pattern=...)` first.\n" \ + "4. `direction=\"outbound\"` misses cross-service callers — use " \ + "`direction=\"both\"`.\n" \ + "5. `search_graph` results default to 50 per page — check `has_more` and use `offset`.\n" + +/* Exact payload generations shipped before the current skill text. */ +static const char released_skill_content_v0_9_1[] = + CBM_RELEASED_SKILL_HEADER_UNQUOTED CBM_RELEASED_SKILL_BODY_BEFORE_EDGE_TYPES + CBM_RELEASED_SKILL_EDGE_TYPES_PRE_V0_10 CBM_RELEASED_SKILL_BODY_AFTER_EDGE_TYPES; +static const char released_skill_content_v0_10_0[] = + CBM_RELEASED_SKILL_HEADER_UNQUOTED CBM_RELEASED_SKILL_BODY_BEFORE_EDGE_TYPES + CBM_RELEASED_SKILL_EDGE_TYPES_V0_10 CBM_RELEASED_SKILL_BODY_AFTER_EDGE_TYPES; +static const char released_skill_content_v0_10_3[] = + CBM_RELEASED_SKILL_HEADER_QUOTED CBM_RELEASED_SKILL_BODY_BEFORE_EDGE_TYPES + CBM_RELEASED_SKILL_EDGE_TYPES_V0_10 CBM_RELEASED_SKILL_BODY_AFTER_EDGE_TYPES; + +#undef CBM_RELEASED_SKILL_BODY_AFTER_EDGE_TYPES +#undef CBM_RELEASED_SKILL_EDGE_TYPES_V0_10 +#undef CBM_RELEASED_SKILL_EDGE_TYPES_PRE_V0_10 +#undef CBM_RELEASED_SKILL_BODY_BEFORE_EDGE_TYPES +#undef CBM_RELEASED_SKILL_HEADER_QUOTED +#undef CBM_RELEASED_SKILL_HEADER_UNQUOTED + static const char skill_content[] = "---\n" "name: codebase-memory\n" @@ -1654,6 +1948,19 @@ static const char skill_content[] = "`direction=\"both\"`.\n" "5. `search_graph` results default to 50 per page — check `has_more` and use `offset`.\n"; +/* Byte-exact consolidated skill payloads shipped by each release range. */ +static const char *const released_skill_contents[] = { + /* 0.6.0 through v0.9.0 */ + released_skill_content_v0_6_0, + /* v0.9.1-rc.1 */ + released_skill_content_v0_9_1, + /* v0.10.0 through v0.10.2 */ + released_skill_content_v0_10_0, + /* v0.10.3 through v0.10.8 */ + released_skill_content_v0_10_3, +}; +enum { RELEASED_SKILL_CONTENT_COUNT = 4 }; + static const char codex_instructions_content[] = "For structural codebase exploration, use the installed `codebase-memory` skill.\n"; @@ -1674,6 +1981,16 @@ const cbm_skill_t *cbm_get_skills(void) { return skills; } +#ifdef CBM_CLI_ENABLE_TEST_API +size_t cbm_cli_released_skill_content_count(void) { + return RELEASED_SKILL_CONTENT_COUNT; +} + +const char *cbm_cli_released_skill_content_at(size_t index) { + return index < RELEASED_SKILL_CONTENT_COUNT ? released_skill_contents[index] : NULL; +} +#endif + const char *cbm_get_codex_instructions(void) { return codex_instructions_content; } @@ -8498,7 +8815,7 @@ static void print_detected_agents(const cbm_detected_agents_t *a, const char *ho * behavior (it is the same code path with mutations disabled). */ typedef struct { char agent[CLI_BUF_32]; - char kind[CLI_BUF_32]; /* mcp_config | instructions | skills | hook */ + char kind[CLI_BUF_32]; /* mcp_config | instructions | skill | cleanup_skill | hook */ char path[CLI_BUF_1K]; } cbm_plan_entry_t; @@ -9004,6 +9321,270 @@ static void install_agent_skill(const char *label, const char *skills_dir, bool printf(" skill: %s (%d installed)\n", skill_path, installed); } +typedef enum { + CBM_CODEX_SKILL_ABSENT, + CBM_CODEX_SKILL_MANAGED, + CBM_CODEX_SKILL_UNOWNED, + CBM_CODEX_SKILL_UNSAFE, +} cbm_codex_skill_state_t; + +typedef struct { + char canonical_dir[CLI_BUF_1K]; + char legacy_dir[CLI_BUF_1K]; + char canonical_file[CLI_BUF_1K]; + char legacy_file[CLI_BUF_1K]; + bool same_dir; +} cbm_codex_skill_paths_t; + +static bool cbm_codex_skill_paths_init(const char *home, const char *config_dir, + cbm_codex_skill_paths_t *paths) { + if (!home || !home[0] || !config_dir || !config_dir[0] || !paths) { + return false; + } + memset(paths, 0, sizeof(*paths)); + int canonical_dir = + snprintf(paths->canonical_dir, sizeof(paths->canonical_dir), "%s/.agents/skills", home); + int legacy_dir = + snprintf(paths->legacy_dir, sizeof(paths->legacy_dir), "%s/skills", config_dir); + int canonical_file = snprintf(paths->canonical_file, sizeof(paths->canonical_file), + "%s/codebase-memory/SKILL.md", paths->canonical_dir); + int legacy_file = snprintf(paths->legacy_file, sizeof(paths->legacy_file), + "%s/codebase-memory/SKILL.md", paths->legacy_dir); + if (canonical_dir < 0 || (size_t)canonical_dir >= sizeof(paths->canonical_dir) || + legacy_dir < 0 || (size_t)legacy_dir >= sizeof(paths->legacy_dir) || canonical_file < 0 || + (size_t)canonical_file >= sizeof(paths->canonical_file) || legacy_file < 0 || + (size_t)legacy_file >= sizeof(paths->legacy_file)) { + return false; + } + paths->same_dir = cbm_json_mcp_paths_equal(paths->canonical_dir, paths->legacy_dir) || + cbm_same_file(paths->canonical_dir, paths->legacy_dir); + return true; +} + +static cbm_codex_skill_state_t cbm_codex_skill_state(const char *skills_dir, + const char *skill_file) { + char skill_dir[CLI_BUF_1K]; + int written = snprintf(skill_dir, sizeof(skill_dir), "%s/codebase-memory", skills_dir); + if (written < 0 || (size_t)written >= sizeof(skill_dir)) { + return CBM_CODEX_SKILL_UNSAFE; + } + + struct stat state; + errno = 0; +#ifndef _WIN32 + int result = lstat(skill_dir, &state); +#else + int result = stat(skill_dir, &state); +#endif + if (result != 0) { + return errno == ENOENT ? CBM_CODEX_SKILL_ABSENT : CBM_CODEX_SKILL_UNSAFE; + } + if (!S_ISDIR(state.st_mode)) { + return CBM_CODEX_SKILL_UNSAFE; + } + + errno = 0; +#ifndef _WIN32 + result = lstat(skill_file, &state); +#else + result = stat(skill_file, &state); +#endif + if (result != 0) { + return errno == ENOENT ? CBM_CODEX_SKILL_ABSENT : CBM_CODEX_SKILL_UNSAFE; + } + if (!S_ISREG(state.st_mode)) { + return CBM_CODEX_SKILL_UNSAFE; + } + + int ownership = cbm_text_owned_document_status( + skill_file, skill_content, released_skill_contents, RELEASED_SKILL_CONTENT_COUNT); + if (ownership == CLI_OK) { + return CBM_CODEX_SKILL_MANAGED; + } + return ownership == CLI_TRUE ? CBM_CODEX_SKILL_UNOWNED : CBM_CODEX_SKILL_UNSAFE; +} + +static void cbm_warn_codex_skill_conflict(const cbm_codex_skill_paths_t *paths, + const char *reason) { + (void)fprintf(stderr, + "warning: Codex skill migration skipped: %s; documented_location=%s legacy=%s\n", + reason, paths->canonical_file, paths->legacy_file); +} + +static bool cbm_refresh_codex_managed_skill(const cbm_codex_skill_paths_t *paths, bool dry_run) { + if (dry_run) { + return true; + } + int result = + cbm_text_migrate_owned_document(paths->canonical_file, skill_content, + released_skill_contents, RELEASED_SKILL_CONTENT_COUNT); + if (result == CLI_OK) { + return true; + } + record_agent_config_error(false, "Codex CLI", "skill_install", paths->canonical_file); + return false; +} + +static bool cbm_remove_codex_managed_skill(const char *skills_dir, const char *skill_file, + bool dry_run, bool uninstalling, const char *operation) { + cbm_codex_skill_state_t state = cbm_codex_skill_state(skills_dir, skill_file); + if (state == CBM_CODEX_SKILL_ABSENT) { + return true; + } + if (state != CBM_CODEX_SKILL_MANAGED) { + if (state == CBM_CODEX_SKILL_UNOWNED) { + printf(" preserved non-owned Codex skill: %s\n", skill_file); + } else if (dry_run) { + printf(" preserved unsafe Codex skill path: %s\n", skill_file); + } else { + record_agent_config_error(uninstalling, "Codex CLI", operation, skill_file); + } + return state == CBM_CODEX_SKILL_UNOWNED; + } + if (dry_run) { + printf(" Codex skill: %s (would remove)\n", skill_file); + return true; + } + int removed = cbm_text_remove_owned_document_any( + skill_file, skill_content, released_skill_contents, RELEASED_SKILL_CONTENT_COUNT); + if (removed == CLI_OK && + cbm_codex_skill_state(skills_dir, skill_file) == CBM_CODEX_SKILL_ABSENT) { + char skill_dir[CLI_BUF_1K]; + int written = snprintf(skill_dir, sizeof(skill_dir), "%s/codebase-memory", skills_dir); + if (written > 0 && (size_t)written < sizeof(skill_dir)) { + (void)cbm_remove_empty_directory(skill_dir, false); + } + return true; + } + record_agent_config_error(uninstalling, "Codex CLI", operation, skill_file); + return false; +} + +static void install_codex_skill(const char *home, const char *config_dir, bool force, + bool dry_run) { + cbm_codex_skill_paths_t paths; + if (!cbm_codex_skill_paths_init(home, config_dir, &paths)) { + if (!g_install_plan) { + record_agent_config_error(false, "Codex CLI", "skill_path", config_dir); + } + return; + } + + cbm_codex_skill_state_t canonical = + cbm_codex_skill_state(paths.canonical_dir, paths.canonical_file); + cbm_codex_skill_state_t legacy = + paths.same_dir ? canonical : cbm_codex_skill_state(paths.legacy_dir, paths.legacy_file); + + if (g_install_plan) { + if (canonical == CBM_CODEX_SKILL_ABSENT && + (paths.same_dir || legacy == CBM_CODEX_SKILL_ABSENT || + legacy == CBM_CODEX_SKILL_MANAGED)) { + plan_record("Codex CLI", "skill", paths.canonical_file); + } + if (!paths.same_dir && legacy == CBM_CODEX_SKILL_MANAGED && + (canonical == CBM_CODEX_SKILL_ABSENT || canonical == CBM_CODEX_SKILL_MANAGED)) { + plan_record("Codex CLI", "cleanup_skill", paths.legacy_file); + } + return; + } + + if (paths.same_dir) { + if (canonical == CBM_CODEX_SKILL_UNSAFE) { + record_agent_config_error(false, "Codex CLI", "skill_install", paths.canonical_file); + return; + } + if (!force && canonical == CBM_CODEX_SKILL_UNOWNED) { + printf(" preserved non-owned Codex skill: %s\n", paths.canonical_file); + return; + } + install_agent_skill("Codex CLI", paths.canonical_dir, force, dry_run); + if (!cbm_refresh_codex_managed_skill(&paths, dry_run)) { + return; + } + if (!dry_run && cbm_codex_skill_state(paths.canonical_dir, paths.canonical_file) != + CBM_CODEX_SKILL_MANAGED) { + record_agent_config_error(false, "Codex CLI", "skill_install", paths.canonical_file); + } + return; + } + + if (canonical == CBM_CODEX_SKILL_UNSAFE) { + if (dry_run) { + cbm_warn_codex_skill_conflict(&paths, "the canonical skill path is unsafe"); + } else { + record_agent_config_error(false, "Codex CLI", "skill_install", paths.canonical_file); + } + return; + } + if (legacy == CBM_CODEX_SKILL_UNSAFE) { + if (force && !dry_run) { + record_agent_config_error(false, "Codex CLI", "legacy_skill_migration", + paths.legacy_file); + } else { + cbm_warn_codex_skill_conflict(&paths, "the legacy skill path is unsafe"); + } + return; + } + + if (!force && legacy == CBM_CODEX_SKILL_UNOWNED) { + cbm_warn_codex_skill_conflict(&paths, "the legacy skill is not installer-owned"); + return; + } + if (!force && canonical == CBM_CODEX_SKILL_UNOWNED) { + if (legacy == CBM_CODEX_SKILL_MANAGED) { + cbm_warn_codex_skill_conflict(&paths, "the canonical skill is not installer-owned"); + } else { + printf(" preserved non-owned Codex skill: %s\n", paths.canonical_file); + } + return; + } + + bool remove_legacy = legacy == CBM_CODEX_SKILL_MANAGED; + if (force && legacy == CBM_CODEX_SKILL_UNOWNED) { + remove_legacy = true; + if (dry_run) { + printf(" legacy Codex skill: %s (would replace for migration)\n", paths.legacy_file); + } else { + int replaced = cbm_install_skills(paths.legacy_dir, true, false); + printf(" legacy Codex skill: %s (%d updated for migration)\n", paths.legacy_file, + replaced); + if (cbm_codex_skill_state(paths.legacy_dir, paths.legacy_file) != + CBM_CODEX_SKILL_MANAGED) { + record_agent_config_error(false, "Codex CLI", "legacy_skill_migration", + paths.legacy_file); + return; + } + } + } + + bool install_canonical = force || canonical == CBM_CODEX_SKILL_ABSENT; + if (install_canonical) { + install_agent_skill("Codex CLI", paths.canonical_dir, force, dry_run); + } else { + printf(" skill: %s (0 installed)\n", paths.canonical_file); + } + + if (dry_run) { + if (remove_legacy) { + printf(" legacy Codex skill: %s (would remove after canonical install)\n", + paths.legacy_file); + } + return; + } + if (!cbm_refresh_codex_managed_skill(&paths, false)) { + return; + } + if (cbm_codex_skill_state(paths.canonical_dir, paths.canonical_file) != + CBM_CODEX_SKILL_MANAGED) { + record_agent_config_error(false, "Codex CLI", "skill_install", paths.canonical_file); + return; + } + if (remove_legacy && cbm_remove_codex_managed_skill(paths.legacy_dir, paths.legacy_file, false, + false, "legacy_skill_cleanup")) { + printf(" legacy Codex skill: %s (removed)\n", paths.legacy_file); + } +} + /* Derive tier siblings only from the exact shipped Verify basename. This keeps * vendor-specific suffixes such as .agent.md, .toml, and .json intact. */ static int cbm_tiered_profile_path(const char *verify_path, cbm_graph_tier_t tier, char *output, @@ -9586,6 +10167,35 @@ static void install_devin_durable_context(const cbm_agent_registry_context_t *re static void install_generated_client_extension(const char *label, const char *path, const char *binary_path, char *(*generate)(const char *), bool dry_run) { + /* #2204: a module that already carries content but none of our markers is + * the user's. The generated header promises "remove the markers to take + * ownership", and appending our prelude to a foreign module produces a file + * the client cannot even parse — the prelude redeclares the user's own + * import and const names, and both sides declare a default export. Probe + * ownership before generating so the preview names the refusal instead of + * promising a write the run will not make. A probe failure (-1) falls + * through to the write, which reports it through the usual error path. */ + bool foreign = + cbm_text_managed_block_status(path, CBM_ADAPTER_MARKER_START, CBM_ADAPTER_MARKER_END) == 1; + if (foreign) { + /* The --plan receipt (#388) lists only writes the run will make, and + * its stdout carries the receipt JSON, so plan mode records nothing + * and prints nothing here. The dry run names the skip instead. */ + if (g_install_plan) { + return; + } + if (dry_run) { + printf(" extension: %s would be skipped — the file there is not ours (no " + "codebase-memory-mcp markers), so it is left untouched; delete it or add the " + "markers to let the installer manage it\n", + path); + } else { + printf(" extension: %s left untouched — the file there is not ours (no " + "codebase-memory-mcp markers)\n", + path); + } + return; + } if (g_install_plan) { plan_record(label, "extension", path); return; @@ -9903,12 +10513,10 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const char config_dir[CLI_BUF_1K]; char cp[CLI_BUF_1K]; char ip[CLI_BUF_1K]; - char skills_dir[CLI_BUF_1K]; char ap[CLI_BUF_1K]; cbm_codex_config_dir(home, config_dir, sizeof(config_dir)); snprintf(cp, sizeof(cp), "%s/config.toml", config_dir); snprintf(ip, sizeof(ip), "%s/AGENTS.md", config_dir); - snprintf(skills_dir, sizeof(skills_dir), "%s/skills", config_dir); snprintf(ap, sizeof(ap), "%s/agents/codebase-memory.toml", config_dir); /* A broken hook config must not keep legacy full guidance active. */ bool pointer_installed = install_codex_activation_pointer(ip, dry_run); @@ -9949,7 +10557,7 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const if (!g_install_plan) { report_codex_activation_pointer_install(ip, pointer_installed); } - install_agent_skill("Codex CLI", skills_dir, force, dry_run); + install_codex_skill(home, config_dir, force, dry_run); install_tiered_agent_profiles( (cbm_tiered_profile_set_t){ .label = "Codex CLI", @@ -11261,6 +11869,7 @@ static char *cbm_build_install_plan_json_options(const char *home, const char *b yyjson_mut_val *agent_files = yyjson_mut_arr(doc); yyjson_mut_val *prompt_files = yyjson_mut_arr(doc); yyjson_mut_val *hooks = yyjson_mut_arr(doc); + yyjson_mut_val *cleanup_actions = yyjson_mut_arr(doc); for (int i = 0; i < plan.count; i++) { cbm_plan_entry_t *e = &plan.items[i]; if (strcmp(e->kind, "mcp_config") == 0) { @@ -11279,6 +11888,14 @@ static char *cbm_build_install_plan_json_options(const char *home, const char *b } else if (strcmp(e->kind, "prompt") == 0) { yyjson_mut_arr_add_strcpy(doc, prompt_files, e->path); yyjson_mut_arr_add_strcpy(doc, instrs, e->path); + } else if (strcmp(e->kind, "cleanup_skill") == 0) { + yyjson_mut_val *cleanup = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, cleanup, "agent", e->agent); + yyjson_mut_obj_add_str(doc, cleanup, "kind", "skill"); + yyjson_mut_obj_add_str(doc, cleanup, "operation", + "remove_owned_legacy_copy_if_migrated"); + yyjson_mut_obj_add_strcpy(doc, cleanup, "path", e->path); + yyjson_mut_arr_add_val(cleanup_actions, cleanup); } else { yyjson_mut_arr_add_strcpy(doc, instrs, e->path); } @@ -11289,6 +11906,7 @@ static char *cbm_build_install_plan_json_options(const char *home, const char *b yyjson_mut_obj_add_val(doc, root, "agent_files_planned", agent_files); yyjson_mut_obj_add_val(doc, root, "prompt_files_planned", prompt_files); yyjson_mut_obj_add_val(doc, root, "hooks_planned", hooks); + yyjson_mut_obj_add_val(doc, root, "cleanup_actions_planned", cleanup_actions); yyjson_mut_obj_add_bool(doc, root, "writes_started", false); yyjson_mut_obj_add_bool(doc, root, "network_after_install", false); yyjson_mut_obj_add_str(doc, root, "next_safe_command", "codebase-memory-mcp install -y"); @@ -12014,6 +12632,40 @@ static void uninstall_agent_skill(const char *label, const char *skills_dir, boo printf(" %s skill: %d removed\n", label, removed); } +static void uninstall_codex_skills(const char *home, const char *config_dir, bool dry_run) { + cbm_codex_skill_paths_t paths; + if (!cbm_codex_skill_paths_init(home, config_dir, &paths)) { + if (!dry_run) { + record_agent_config_error(true, "Codex CLI", "skill_path", config_dir); + } + return; + } + + int removed = 0; + if (cbm_codex_skill_state(paths.canonical_dir, paths.canonical_file) == + CBM_CODEX_SKILL_MANAGED) { + if (cbm_remove_codex_managed_skill(paths.canonical_dir, paths.canonical_file, dry_run, true, + "skill_uninstall")) { + removed++; + } + } else { + (void)cbm_remove_codex_managed_skill(paths.canonical_dir, paths.canonical_file, dry_run, + true, "skill_uninstall"); + } + + if (!paths.same_dir && + cbm_codex_skill_state(paths.legacy_dir, paths.legacy_file) == CBM_CODEX_SKILL_MANAGED) { + if (cbm_remove_codex_managed_skill(paths.legacy_dir, paths.legacy_file, dry_run, true, + "legacy_skill_uninstall")) { + removed++; + } + } else if (!paths.same_dir) { + (void)cbm_remove_codex_managed_skill(paths.legacy_dir, paths.legacy_file, dry_run, true, + "legacy_skill_uninstall"); + } + printf(" Codex CLI skills: %d removed\n", removed); +} + static void uninstall_copilot_durable_context(const char *home, bool dry_run) { char config_dir[CLI_BUF_1K]; char hook_path[CLI_BUF_1K]; @@ -12435,13 +13087,11 @@ static void uninstall_cli_agents(const cbm_detected_agents_t *agents, const char char config_dir[CLI_BUF_1K]; char cp[CLI_BUF_1K]; char ip[CLI_BUF_1K]; - char skills_dir[CLI_BUF_1K]; char ap[CLI_BUF_1K]; char installed_binary[CLI_BUF_1K]; cbm_codex_config_dir(home, config_dir, sizeof(config_dir)); snprintf(cp, sizeof(cp), "%s/config.toml", config_dir); snprintf(ip, sizeof(ip), "%s/AGENTS.md", config_dir); - snprintf(skills_dir, sizeof(skills_dir), "%s/skills", config_dir); snprintf(ap, sizeof(ap), "%s/agents/codebase-memory.toml", config_dir); bool pointer_removed = uninstall_codex_activation_pointer(ip, dry_run); cbm_agent_installed_binary_path(home, installed_binary, sizeof(installed_binary)); @@ -12476,7 +13126,7 @@ static void uninstall_cli_agents(const cbm_detected_agents_t *agents, const char record_agent_config_error(true, "Codex CLI", "hook_uninstall", cp); } codex_toml_done: - uninstall_agent_skill("Codex CLI", skills_dir, dry_run); + uninstall_codex_skills(home, config_dir, dry_run); uninstall_tiered_agent_profiles( (cbm_tiered_profile_set_t){ .label = "Codex CLI", diff --git a/src/cli/cli.h b/src/cli/cli.h index da0e06517..52ace9003 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -82,6 +82,54 @@ bool cbm_cli_mcp_result_is_error(const char *result); * accepted cancellation into EXIT_FAILURE. */ int cbm_cli_exit_status_after_maintenance(int exit_status, bool maintenance_cancelled); +/* ── Index exit contract ────────────────────────────────────────── + * + * A freshness gate must not have to read anyone's account of itself: it reads + * the process status. `index_repository` therefore grades its own outcome. + * + * 0 indexed, quality at or above the thresholds + * 1 hard failure (pipeline fell over inside a reachable repository) + * 10 indexed, but below a quality threshold — a graph that exists and lies + * about how much of the tree it covers is worse than no graph + * 11 target unavailable: repo_path is absent, unreadable, or not a directory + * + * 1 keeps its historical meaning so existing `|| fail` scripts are unaffected; + * 10 and 11 are new and carve out cases that used to return 0 and 1. + * + * STABLE CONTRACT: these four values are public. CI pipelines branch on them, + * so they are never renumbered or reused for another outcome. 2 is + * deliberately not a verdict: the binary already returns 2 for a usage error + * (an invalid --tool-profile), and shells and CI tooling read 2 that way, so a + * verdict on 2 could not be told apart from a mistyped command line. + * + * The contract is opt-in: it applies only when CBM_GATE is set to a non-empty + * value other than "0". Without it the CLI exits exactly as before, so a + * caller that never asked for grading never sees a new code. + * + * Thresholds, both overridable by environment: + * CBM_GATE_MAX_UNUSABLE (default 0) absolute count of unparsable files + * CBM_GATE_MAX_PARTIAL_PCT (default 10) parse_partial_count / files_indexed + * A threshold set to a negative value disables that check. + * + * The two numbers differ on purpose and must not be collapsed into one: a file + * that did not parse at all is a defect and gets no tolerance, while partial + * parsing marks constructs this grammar does not cover — a property of language + * support, not of index quality, and normal at a few percent in a large tree. + * A gate its own repository cannot pass gets switched off, which protects + * nothing. */ +enum { + CBM_CLI_EXIT_OK = 0, + CBM_CLI_EXIT_FAILURE = 1, + CBM_CLI_EXIT_QUALITY = 10, + CBM_CLI_EXIT_TARGET = 11, +}; + +/* Grade an index_repository result envelope. `base_status` is what the + * ordinary isError mapping already produced; it is preserved unless the + * payload justifies a more specific code. Unparsable payloads change + * nothing — silence is never upgraded into a verdict. */ +int cbm_cli_index_exit_status(const char *result, int base_status); + /* ── Self-update: version comparison ──────────────────────────── */ /* Compare two semver strings (e.g. "0.2.1" vs "0.2.0"). @@ -134,6 +182,11 @@ typedef struct { /* Get the array of skill definitions. */ const cbm_skill_t *cbm_get_skills(void); +#ifdef CBM_CLI_ENABLE_TEST_API +size_t cbm_cli_released_skill_content_count(void); +const char *cbm_cli_released_skill_content_at(size_t index); +#endif + /* Install skills to skills_dir (e.g. ~/.claude/skills/). * If force is true, overwrite existing skills. * Returns count of skills written. */ diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c index cfd2719c7..f5747c4c8 100644 --- a/src/cli/client_adapter.c +++ b/src/cli/client_adapter.c @@ -427,9 +427,16 @@ char *cbm_client_adapter_opencode(const char *binary_path) { sb_append(&sb, "// OpenCode already reaches every tool over MCP; this module adds the\n" "// context surfaces other clients get through hook configuration: graph\n" "// lookup after grep/glob, index-coverage notes after read, session-start\n" - "// tier routing (carried on the first tool result of each session, since\n" - "// OpenCode documents no context-output lifecycle hook), and reinjection\n" - "// after compaction via the documented experimental surface.\n"); + "// tier routing (carried on the first tool result of each session), and\n" + "// on OpenCode 1.18.x reinjection after compaction.\n" + "//\n" + "// Two runtimes load this module:\n" + "// - OpenCode 1.18.x dispatches the hooks returned by server(ctx).\n" + "// - OpenCode 2 ignores server and calls setup(ctx), which registers the\n" + "// tool hook on the context's tool domain. The registration fails open,\n" + "// and the server() tool hook yields once it has registered. OpenCode 2\n" + "// gets no note after compaction: no compaction channel has been run on\n" + "// a real 2.x host yet.\n"); sb_append(&sb, "import { spawn } from 'node:child_process';\n\n"); sb_append(&sb, "const BIN = '"); sb_append(&sb, bin); @@ -481,64 +488,165 @@ char *cbm_client_adapter_opencode(const char *binary_path) { " });\n" "}\n\n"); + /* OpenCode 2 tool results are structured (Tool.Result: content is the + * string/parts surface the model reads, output is the tool's schema-typed + * value). Append to content first, then a string output; if neither text + * surface exists, leave the result untouched so the tool's own output is + * never hidden. A parts array gets a new text part only when it already + * holds a part: `content: []` is a valid result, and a part added there + * would be the note alone, so an empty array falls through to the string + * output (or the unchanged result). The caller assigns the returned result + * onto event.result. */ + sb_append(&sb, + "function appendResult(result, extra) {\n" + " if (!result || typeof result !== 'object') return result;\n" + " if (typeof result.content === 'string') {\n" + " return { ...result, content: result.content + '\\n' + extra };\n" + " }\n" + " if (Array.isArray(result.content) && result.content.length > 0) {\n" + " const content = result.content.slice();\n" + " for (let i = content.length - 1; i >= 0; i--) {\n" + " if (content[i] && content[i].type === 'text') {\n" + " content[i] = { ...content[i], text: content[i].text + '\\n' + extra };\n" + " return { ...result, content };\n" + " }\n" + " }\n" + " content.push({ type: 'text', text: extra });\n" + " return { ...result, content };\n" + " }\n" + " if (typeof result.output === 'string') {\n" + " return { ...result, output: result.output + '\\n' + extra };\n" + " }\n" + " return result;\n" + "}\n\n"); + + /* A host that dispatches both server() and setup() with a tool domain must + * not append the note twice: the server() tool hook yields once setup() + * registered the tool domain. */ + sb_append(&sb, "let toolHooksRegistered = false;\n\n"); + sb_append( - &sb, "export default {\n" - " id: 'codebase-memory-augment',\n" - " // V2 config loader: requires id + setup|effect; no tool domain yet\n" - " // at this point, so there is nothing to register here.\n" - " setup() {},\n" - " // Server runtime: reads default.server and dispatches the hooks it returns.\n" - " server: async (ctx) => {\n" - " const dir = ctx?.directory;\n" - " const seen = new Set();\n" - " const lifecycle = () =>\n" - " augment({ hook_event_name: 'SessionStart', cwd: dir });\n" - " return {\n" - " 'tool.execute.after': async (input, output) => {\n" - " if (typeof output?.output !== 'string') return;\n" - " const pieces = [];\n" - " const sid = input?.sessionID;\n" - " if (typeof sid === 'string' && !seen.has(sid)) {\n" - " seen.add(sid);\n" - " pieces.push(await lifecycle());\n" - " }\n" - " const args = input?.args ?? {};\n" - " const search =\n" - " input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' : null;\n" - " if (search) {\n" - " pieces.push(await augment({\n" - " hook_event_name: 'PreToolUse',\n" - " tool_name: search,\n" - " tool_input: args,\n" - " cwd: dir,\n" - " }));\n" - " } else if (input?.tool === 'read') {\n" - " const filePath = args.filePath ?? args.file_path ?? args.path;\n" - " if (typeof filePath === 'string' && filePath) {\n" - " pieces.push(await augment({\n" - " hook_event_name: 'PostToolUse',\n" - " tool_name: 'Read',\n" - " tool_input: { file_path: filePath },\n" - " cwd: dir,\n" - " }));\n" - " }\n" - " }\n" - " const extra = pieces.filter(Boolean).join('\\n');\n" - " if (extra) {\n" - " output.output += '\\n' + extra;\n" - " }\n" - " },\n" - " // Documented (experimental) compaction surface: output.context is the\n" - " // mutable array of context strings for the rebuilt session.\n" - " 'experimental.session.compacting': async (_input, output) => {\n" - " const note = await lifecycle();\n" - " if (note && Array.isArray(output?.context)) {\n" - " output.context.push(note);\n" - " }\n" - " },\n" - " };\n" - " },\n" - "};\n"); + &sb, + "export default {\n" + " id: 'codebase-memory-augment',\n" + " // OpenCode 1.18.x server runtime: reads default.server and dispatches\n" + " // the hooks it returns.\n" + " server: async (ctx) => {\n" + " const dir = ctx?.directory;\n" + " const seen = new Set();\n" + " const lifecycle = () =>\n" + " augment({ hook_event_name: 'SessionStart', cwd: dir });\n" + " return {\n" + " 'tool.execute.after': async (input, output) => {\n" + " if (toolHooksRegistered) return;\n" + " if (typeof output?.output !== 'string') return;\n" + " const pieces = [];\n" + " const sid = input?.sessionID;\n" + " if (typeof sid === 'string' && !seen.has(sid)) {\n" + " seen.add(sid);\n" + " pieces.push(await lifecycle());\n" + " }\n" + " const args = input?.args ?? {};\n" + " const search =\n" + " input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' : null;\n" + " if (search) {\n" + " pieces.push(await augment({\n" + " hook_event_name: 'PreToolUse',\n" + " tool_name: search,\n" + " tool_input: args,\n" + " cwd: dir,\n" + " }));\n" + " } else if (input?.tool === 'read') {\n" + " const filePath = args.filePath ?? args.file_path ?? args.path;\n" + " if (typeof filePath === 'string' && filePath) {\n" + " pieces.push(await augment({\n" + " hook_event_name: 'PostToolUse',\n" + " tool_name: 'Read',\n" + " tool_input: { file_path: filePath },\n" + " cwd: dir,\n" + " }));\n" + " }\n" + " }\n" + " const extra = pieces.filter(Boolean).join('\\n');\n" + " if (extra) {\n" + " output.output += '\\n' + extra;\n" + " }\n" + " },\n" + " // Documented (experimental) compaction surface: output.context is the\n" + " // mutable array of context strings for the rebuilt session.\n" + " 'experimental.session.compacting': async (_input, output) => {\n" + " const note = await lifecycle();\n" + " if (note && Array.isArray(output?.context)) {\n" + " output.context.push(note);\n" + " }\n" + " },\n" + " };\n" + " },\n" + " // OpenCode 2 runtime: default.server is ignored and setup(ctx) registers\n" + " // the tool hook on the context's tool domain. The registration fails\n" + " // open, so a missing domain cannot break the host.\n" + " async setup(ctx) {\n" + " const seen = new Set();\n" + " const dirs = new Map();\n" + " const dirFor = async (sessionID) => {\n" + " if (typeof sessionID !== 'string' || sessionID === '') return undefined;\n" + " if (dirs.has(sessionID)) return dirs.get(sessionID);\n" + " try {\n" + " const session = await ctx.session.get({ sessionID });\n" + " const dir = session?.location?.directory;\n" + " if (typeof dir === 'string' && dir !== '') {\n" + " dirs.set(sessionID, dir);\n" + " return dir;\n" + " }\n" + " } catch {}\n" + " return undefined;\n" + " };\n" + " const lifecycle = (dir) =>\n" + " augment({ hook_event_name: 'SessionStart', cwd: dir });\n" + " if (ctx?.tool?.hook) {\n" + " try {\n" + " await ctx.tool.hook('execute.after', async (event) => {\n" + " if (event?.status !== 'completed') return;\n" + " const dir = await dirFor(event?.sessionID);\n" + " if (!dir) return;\n" + " const pieces = [];\n" + " if (typeof event?.sessionID === 'string' && !seen.has(event.sessionID)) {\n" + " seen.add(event.sessionID);\n" + " pieces.push(await lifecycle(dir));\n" + " }\n" + " const args = event?.input ?? {};\n" + " const search =\n" + " event?.tool === 'grep' ? 'Grep' : event?.tool === 'glob' ? 'Glob' : null;\n" + " if (search) {\n" + " pieces.push(await augment({\n" + " hook_event_name: 'PreToolUse',\n" + " tool_name: search,\n" + " tool_input: args,\n" + " cwd: dir,\n" + " }));\n" + " } else if (event?.tool === 'read') {\n" + " const filePath = args.filePath ?? args.file_path ?? args.path;\n" + " if (typeof filePath === 'string' && filePath) {\n" + " pieces.push(await augment({\n" + " hook_event_name: 'PostToolUse',\n" + " tool_name: 'Read',\n" + " tool_input: { file_path: filePath },\n" + " cwd: dir,\n" + " }));\n" + " }\n" + " }\n" + " const extra = pieces.filter(Boolean).join('\\n');\n" + " if (extra) {\n" + " event.result = appendResult(event.result, extra);\n" + " }\n" + " });\n" + " toolHooksRegistered = true;\n" + " } catch {\n" + " // Fail open: the server() tool hook stays active.\n" + " }\n" + " }\n" + " },\n" + "};\n"); if (sb.failed) { free(sb.buf); diff --git a/src/cli/client_adapter.h b/src/cli/client_adapter.h index c1bfb652e..1eec93065 100644 --- a/src/cli/client_adapter.h +++ b/src/cli/client_adapter.h @@ -48,11 +48,18 @@ char *cbm_client_adapter_pi(const char *binary_path); * other clients get through their own native hook configuration. OpenCode has * no such configuration; a plugin module is its only extension point. * - * NOTE for maintainers: this hooks `tool.execute.after`, whose ability to - * modify a tool's output is NOT part of OpenCode's documented plugin contract - * (only `tool.execute.before`'s argument mutation is). If OpenCode changes it, - * augmentation stops silently — no error, no signal. That risk is accepted - * deliberately and recorded here so it is not rediscovered as a mystery. + * NOTE for maintainers: the module serves two loader generations from one + * default export. OpenCode 1.18.x dispatches the hooks returned by + * `server(ctx)`; its `tool.execute.after` ability to modify a tool's output is + * NOT part of the documented plugin contract (only `tool.execute.before`'s + * argument mutation is), so if that runtime changes, augmentation stops + * silently — no error, no signal. That risk is accepted deliberately for the + * server() path and recorded here so it is not rediscovered as a mystery. + * OpenCode 2 ignores `server` and calls `setup(ctx)`, which registers + * `execute.after` on the context's tool domain and fails open. It registers no + * `compaction` hook: pushing a plain object into `event.messages` would fail, + * if at all, inside OpenCode's own compaction where the plugin cannot catch it, + * so a note after compaction needs a real 2.x run first. * * Returns NULL on allocation failure or when binary_path is NULL/empty. */ char *cbm_client_adapter_opencode(const char *binary_path); diff --git a/src/cli/config_text_edit.c b/src/cli/config_text_edit.c index ad728f506..38bea7b4d 100644 --- a/src/cli/config_text_edit.c +++ b/src/cli/config_text_edit.c @@ -1020,6 +1020,42 @@ static int text_validate_markers(const char *begin_marker, const char *end_marke return TEXT_OK; } +/* A managed document as read from disk, with our block located in it. */ +typedef struct { + char *data; + size_t len; + size_t bom_len; + text_file_snapshot_t snapshot; + text_managed_region_t region; +} text_managed_document_t; + +/* The read-validate-scan step shared by the upsert and the read-only ownership + * probe, so both judge a file by the same rules. An absent file reads as an + * empty document. On success the caller owns doc->data and releases it; on + * failure nothing is left allocated. */ +static int text_read_managed_document(const char *file_path, const char *begin_marker, + size_t begin_len, const char *end_marker, size_t end_len, + text_managed_document_t *doc) { + doc->data = NULL; + doc->len = 0U; + doc->bom_len = 0U; + int status = text_read_file(file_path, &doc->data, &doc->len, &doc->snapshot); + if (status == TEXT_OK) { + status = text_validate_bytes(doc->data, doc->len, 1); + } + if (status == TEXT_OK) { + doc->bom_len = text_bom_length(doc->data, doc->len); + status = text_scan_managed_region(doc->data, doc->len, doc->bom_len, begin_marker, + begin_len, end_marker, end_len, &doc->region); + } + if (status != TEXT_OK) { + free(doc->data); + doc->data = NULL; + return TEXT_ERROR; + } + return TEXT_OK; +} + static int text_upsert_managed_block(const char *file_path, const char *begin_marker, const char *end_marker, const char *owned_content, size_t max_document_bytes) { @@ -1036,21 +1072,16 @@ static int text_upsert_managed_block(const char *file_path, const char *begin_ma return TEXT_ERROR; } - char *old_data = NULL; - size_t old_len = 0U; - text_file_snapshot_t snapshot; - if (text_read_file(file_path, &old_data, &old_len, &snapshot) != TEXT_OK || - text_validate_bytes(old_data, old_len, 1) != TEXT_OK) { - free(old_data); - return TEXT_ERROR; - } - size_t bom_len = text_bom_length(old_data, old_len); - text_managed_region_t region; - if (text_scan_managed_region(old_data, old_len, bom_len, begin_marker, begin_len, end_marker, - end_len, ®ion) != TEXT_OK) { - free(old_data); + text_managed_document_t doc; + int read_status = + text_read_managed_document(file_path, begin_marker, begin_len, end_marker, end_len, &doc); + if (read_status != TEXT_OK) { return TEXT_ERROR; } + char *old_data = doc.data; + size_t old_len = doc.len; + text_file_snapshot_t snapshot = doc.snapshot; + text_managed_region_t region = doc.region; text_buffer_t updated = {0}; int result = TEXT_ERROR; @@ -1070,6 +1101,7 @@ static int text_upsert_managed_block(const char *file_path, const char *begin_ma &snapshot); } } else { + size_t bom_len = doc.bom_len; const char *eol = NULL; size_t eol_len = 0U; text_detect_eol(old_data + bom_len, old_len - bom_len, &eol, &eol_len); @@ -1105,6 +1137,34 @@ int cbm_text_upsert_managed_block_limited(const char *file_path, const char *beg max_document_bytes); } +int cbm_text_managed_block_status(const char *file_path, const char *begin_marker, + const char *end_marker) { + size_t begin_len = 0U; + size_t end_len = 0U; + if (!text_valid_path(file_path) || + text_validate_markers(begin_marker, end_marker, &begin_len, &end_len) != TEXT_OK) { + return TEXT_ERROR; + } + text_managed_document_t doc; + int read_status = + text_read_managed_document(file_path, begin_marker, begin_len, end_marker, end_len, &doc); + if (read_status != TEXT_OK) { + return TEXT_ERROR; + } + int result = TEXT_OK; + if (!doc.region.present) { + for (size_t pos = doc.bom_len; pos < doc.len; pos++) { + char byte = doc.data[pos]; + if (byte != ' ' && byte != '\t' && byte != '\r' && byte != '\n') { + result = TEXT_UNOWNED; + break; + } + } + } + free(doc.data); + return result; +} + int cbm_text_remove_managed_block(const char *file_path, const char *begin_marker, const char *end_marker) { size_t begin_len = 0U; diff --git a/src/cli/config_text_edit.h b/src/cli/config_text_edit.h index 3f6e7c425..f2befb408 100644 --- a/src/cli/config_text_edit.h +++ b/src/cli/config_text_edit.h @@ -11,6 +11,16 @@ int cbm_text_upsert_managed_block(const char *file_path, const char *begin_marke int cbm_text_upsert_managed_block_limited(const char *file_path, const char *begin_marker, const char *end_marker, const char *owned_content, size_t max_document_bytes); +/* Read-only ownership probe for a managed block, used before writing a file a + * client auto-loads as a module. Returns 0 when the block may be written (no + * file, a file whose content is only whitespace, or a file already carrying our + * markers), 1 when the file carries content but none of our markers — the + * caller must leave the user's file alone and say so rather than append to it, + * because the generated block redeclares module-level names a hand-ported + * module also declares (#2204) — and -1 for unsafe state or I/O failure. Never + * writes, so a dry run may call it. */ +int cbm_text_managed_block_status(const char *file_path, const char *begin_marker, + const char *end_marker); int cbm_text_remove_managed_block(const char *file_path, const char *begin_marker, const char *end_marker); diff --git a/src/daemon/application.c b/src/daemon/application.c index eb9bf97f4..a5216846b 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -2146,6 +2146,9 @@ static bool application_index_args_normalize_defaults(yyjson_mut_val *root) { if (name && (!yyjson_mut_is_str(name) || yyjson_mut_get_len(name) == 0)) { (void)yyjson_mut_obj_remove_key(root, "name"); } + /* Automatic jobs carry _cbm_background, which picks the worker count and + * never what is indexed, so an explicit request joins them. */ + (void)yyjson_mut_obj_remove_key(root, "_cbm_background"); return true; } @@ -2559,10 +2562,10 @@ static char *application_auto_index_args(cbm_daemon_application_t *application, return NULL; } yyjson_mut_doc_set_root(document, root); - char *args = yyjson_mut_obj_add_strcpy(document, root, "repo_path", root_path) && - application_index_args_add_policy(application, document, root) - ? yyjson_mut_write(document, 0, NULL) - : NULL; + bool encoded = yyjson_mut_obj_add_strcpy(document, root, "repo_path", root_path) && + application_index_args_add_policy(application, document, root) && + yyjson_mut_obj_add_bool(document, root, "_cbm_background", true); + char *args = encoded ? yyjson_mut_write(document, 0, NULL) : NULL; yyjson_mut_doc_free(document); return args; } @@ -4423,7 +4426,8 @@ static int application_background_index(cbm_daemon_application_t *application, } yyjson_mut_doc_set_root(document, root); bool encoded = yyjson_mut_obj_add_strcpy(document, root, "repo_path", canonical_root) && - application_index_args_add_policy(application, document, root); + application_index_args_add_policy(application, document, root) && + yyjson_mut_obj_add_bool(document, root, "_cbm_background", true); char *default_project = cbm_project_name_from_path(canonical_root); bool custom_project = project_name[0] && (!default_project || strcmp(default_project, project_name) != 0); diff --git a/src/main.c b/src/main.c index c2f22ada9..8824a805f 100644 --- a/src/main.c +++ b/src/main.c @@ -1092,6 +1092,11 @@ static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_lo } else { exit_code = cli_print_mcp_result(result); } + /* One place for both presentations: a gate reading the status must get + * the same verdict whether or not the caller asked for --json. */ + if (tool_name && strcmp(tool_name, "index_repository") == 0) { + exit_code = cbm_cli_index_exit_status(result, exit_code); + } exit_code = cbm_cli_exit_status_after_maintenance(exit_code, maintenance_cancelled); if (cbm_index_worker_active()) { /* The supervisor protocol classifies the PROCESS, not the tool diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 7ab09f724..29685e4c8 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -10368,6 +10368,72 @@ static void trace_call_coverage(cbm_store_t *store, const char *project, cbm_ht_free(match.targets); } +enum { TRACE_RECURSION_FLAG_COUNT = 2 }; +typedef enum { + TRACE_RECURSION_UNKNOWN = -1, + TRACE_RECURSION_FALSE, + TRACE_RECURSION_TRUE, +} trace_recursion_flag_t; +static const char *const trace_recursion_keys[TRACE_RECURSION_FLAG_COUNT] = {"self_recursive", + "recursive"}; +typedef struct { + /* -1: unknown, 0: all roots record false, 1: at least one records true. */ + trace_recursion_flag_t flags[TRACE_RECURSION_FLAG_COUNT]; +} trace_recursion_t; + +static trace_recursion_t trace_recursion_metadata(const cbm_node_t *nodes, int node_count) { + trace_recursion_t metadata = {0}; + /* Use the same roots as bfs_union_same_name, rather than borrowing a + * callee's flag or selecting just one definition/stub. These are stored + * analysis signals, not evidence that a resolved self-call was dropped. */ + for (int i = 0; i < node_count; i++) { + const char *properties = nodes[i].properties_json; + yyjson_doc *doc = properties ? yyjson_read(properties, strlen(properties), 0) : NULL; + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + for (int j = 0; j < TRACE_RECURSION_FLAG_COUNT; j++) { + yyjson_val *value = yyjson_obj_get(root, trace_recursion_keys[j]); + if (yyjson_is_bool(value) && yyjson_get_bool(value)) { + metadata.flags[j] = TRACE_RECURSION_TRUE; + } else if (!yyjson_is_bool(value) && metadata.flags[j] != TRACE_RECURSION_TRUE) { + metadata.flags[j] = TRACE_RECURSION_UNKNOWN; + } + } + yyjson_doc_free(doc); + } + return metadata; +} + +static void trace_recursion_tree(cbm_sb_t *sb, const trace_recursion_t *metadata, + bool includes_calls) { + for (int i = 0; i < TRACE_RECURSION_FLAG_COUNT; i++) { + if (metadata->flags[i] == TRACE_RECURSION_UNKNOWN) { + cbm_sb_append(sb, trace_recursion_keys[i]); + cbm_sb_append(sb, ": null\n"); + } else { + cbm_tree_scalar_bool(sb, trace_recursion_keys[i], + metadata->flags[i] == TRACE_RECURSION_TRUE); + } + } + if (includes_calls) { + cbm_tree_scalar_str(sb, "self_call_policy", "omitted"); + } +} + +static void trace_recursion_json(yyjson_mut_doc *doc, yyjson_mut_val *root, + const trace_recursion_t *metadata, bool includes_calls) { + for (int i = 0; i < TRACE_RECURSION_FLAG_COUNT; i++) { + if (metadata->flags[i] == TRACE_RECURSION_UNKNOWN) { + yyjson_mut_obj_add_null(doc, root, trace_recursion_keys[i]); + } else { + yyjson_mut_obj_add_bool(doc, root, trace_recursion_keys[i], + metadata->flags[i] == TRACE_RECURSION_TRUE); + } + } + if (includes_calls) { + yyjson_mut_obj_add_str(doc, root, "self_call_policy", "omitted"); + } +} + static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { char *func_name = cbm_mcp_get_string_arg(args, "function_name"); char *project = get_project_arg(args); @@ -10722,6 +10788,7 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { .do_inbound = do_inbound}; trace_call_coverage(store, project, &coverage_scope, trace_includes_calls, &unresolved_out, &unresolved_in); + trace_recursion_t recursion = trace_recursion_metadata(nodes, node_count); const char *out_relation = unresolved_out ? "unknown" : (tr_out.truncated ? "gte" : "eq"); const char *in_relation = unresolved_in ? "unknown" : (tr_in.truncated ? "gte" : "eq"); @@ -10805,6 +10872,7 @@ render_trace_output:; if (mode) { cbm_tree_scalar_str(&sb, "mode", mode); } + trace_recursion_tree(&sb, &recursion, trace_includes_calls); /* Grouped tree is THE default; risk_labels/data_flow keep the flat * table (extra columns) in the same tree syntax. */ bool render_risk = risk_labels && emit_optional_fields; @@ -10888,6 +10956,7 @@ render_trace_output:; if (mode) { yyjson_mut_obj_add_str(doc, root, "mode", mode); } + trace_recursion_json(doc, root, &recursion, trace_includes_calls); if (do_outbound) { yyjson_mut_obj_add_int(doc, root, "callees_total", out_total); yyjson_mut_obj_add_str(doc, root, "callees_total_relation", out_relation); @@ -10955,6 +11024,7 @@ render_trace_output:; if (!trace_legacy_json) { cbm_sb_t floor; cbm_sb_init(&floor); + trace_recursion_tree(&floor, &recursion, trace_includes_calls); if (do_outbound) { cbm_tree_scalar_int(&floor, "callees_total", out_total); cbm_tree_scalar_str(&floor, "callees_total_relation", out_relation); @@ -10983,6 +11053,7 @@ render_trace_output:; yyjson_mut_doc *floor_doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *floor = yyjson_mut_obj(floor_doc); yyjson_mut_doc_set_root(floor_doc, floor); + trace_recursion_json(floor_doc, floor, &recursion, trace_includes_calls); if (do_outbound) { yyjson_mut_obj_add_int(floor_doc, floor, "callees_total", out_total); yyjson_mut_obj_add_str(floor_doc, floor, "callees_total_relation", @@ -11782,6 +11853,28 @@ static bool build_index_success_response(cbm_mcp_server_t *srv, yyjson_mut_doc * add_parse_partial_summary(doc, root, file_errors, file_error_count); add_parse_unusable_summary(doc, root, file_errors, file_error_count); } + /* Denominator for quality gates. parse_partial_count on its own cannot + * say whether sixty flagged files are a rounding error or half the + * repository; file_hashes holds exactly one row per indexed file. */ + if (store) { + cbm_file_hash_t *hashes = NULL; + int hash_count = 0; + if (cbm_store_get_file_hashes(store, project_name, &hashes, &hash_count) == CBM_STORE_OK) { + yyjson_mut_obj_add_int(doc, root, "files_indexed", hash_count); + /* The share, stated outright. A gate that only passes or fails + * cannot tell anyone HOW partial the parse was, and "the graph is + * fresh, but 6.2% of files parsed partially" is a usable hint + * where a bare verdict is not. Tenths of a percent are derived by + * integer division so the number never depends on rounding. */ + yyjson_mut_val *partial_val = yyjson_mut_obj_get(root, "parse_partial_count"); + if (partial_val && hash_count > 0) { + int partial = yyjson_mut_get_int(partial_val); + long tenths = (long)partial * 1000 / hash_count; + yyjson_mut_obj_add_real(doc, root, "parse_partial_pct", (double)tenths / 10.0); + } + cbm_store_free_file_hashes(hashes, hash_count); + } + } int nodes = 0; int edges = 0; bool degraded = false; @@ -12470,7 +12563,8 @@ static char *index_run_supervised_path(cbm_mcp_server_t *srv, const char *root_p * worker reads no config); unset, the worker's index mode decides. */ if (!yyjson_mut_obj_add_strcpy(doc, root, "repo_path", root_path) || !cbm_mcp_index_policy_add_to_args(doc, root, &policy) || - !cbm_mcp_index_doc_candidates_add_to_args(doc, root, srv ? srv->config : NULL)) { + !cbm_mcp_index_doc_candidates_add_to_args(doc, root, srv ? srv->config : NULL) || + !yyjson_mut_obj_add_bool(doc, root, "_cbm_background", true)) { yyjson_mut_doc_free(doc); return NULL; } @@ -12773,6 +12867,22 @@ static bool index_root_owner_resolve(const char *repo_path, char **owner_out, ch return false; } +/* Background CPU headroom is for a refresh of a graph that already serves. + * A project's first index keeps every core, even when it was started + * automatically: the caller is waiting on a graph that does not exist yet. + * An empty or unreadable file is not a committed index. Both sites that + * honour automatic indexing — handle_index_repository's _cbm_background + * flag and the in-process autoindex_thread — go through this so they + * cannot drift. */ +static bool project_has_committed_index(const char *project) { + if (!project || !project[0]) { + return false; + } + char db_path[CBM_SZ_1K]; + project_db_path(project, db_path, sizeof(db_path)); + return db_path[0] && project_db_is_servable(project, db_path); +} + /* The three heap strings handle_index_repository owns from * cbm_mcp_get_string_arg / resolved_repo_path_from_project_arg. One release * point keeps the dozen early-return paths in step; free(NULL) is a no-op, so @@ -12902,6 +13012,7 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { char *repo_path = cbm_mcp_get_string_arg(args, "repo_path"); char *mode_str = cbm_mcp_get_string_arg(args, "mode"); char *name_override = cbm_mcp_get_string_arg(args, "name"); + bool background = cbm_mcp_get_bool_arg(args, "_cbm_background"); cbm_normalize_path_sep(repo_path); if (!repo_path) { @@ -13074,6 +13185,10 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { return cbm_mcp_text_result("invalid project name", true); } free(name_override); + /* Headroom follows the final project name, decided before artifact + * bootstrap can create the first local copy of this index. */ + cbm_pipeline_set_background(p, background && + project_has_committed_index(cbm_pipeline_project_name(p))); cbm_pipeline_set_persistence(p, persistence); cbm_pipeline_set_resource_policy(p, &resource_policy); index_doc_candidates_apply(p, doc_candidates); @@ -13236,6 +13351,16 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { yyjson_mut_obj_add_strcpy(doc, root, "message", message); } else { yyjson_mut_obj_add_str(doc, root, "status", "error"); + /* A repository that is not there at all is a different failure from a + * pipeline that fell over inside one that is — the hint below reads + * identically for both, so callers could not tell them apart. The + * split is deliberately about the ROOT: an unreadable subtree is a + * pipeline failure, not a missing target. */ + cbm_path_info_t target = {0}; + bool target_reachable = + cbm_path_info_utf8(repo_path, &target) == CBM_PATH_INFO_OK && target.is_directory; + yyjson_mut_obj_add_str(doc, root, "reason", + target_reachable ? "pipeline_failed" : "target_unavailable"); /* #1665: a post-publish artifact export failure (read-only repo, etc.) * must not be blamed on the repository path. The pipeline snapshots the * export error of THIS run, so its presence names the phase exactly — @@ -21484,6 +21609,7 @@ static void *autoindex_thread(void *arg) { return NULL; } index_doc_candidates_apply(p, index_doc_candidates_choice(srv, NULL)); + cbm_pipeline_set_background(p, project_has_committed_index(cbm_pipeline_project_name(p))); /* Block until any concurrent pipeline finishes */ cbm_pipeline_lock(); diff --git a/src/pipeline/lsp_surface.c b/src/pipeline/lsp_surface.c index f8d43ff69..62c35f324 100644 --- a/src/pipeline/lsp_surface.c +++ b/src/pipeline/lsp_surface.c @@ -486,10 +486,12 @@ typedef struct { const CBMLSPDef *all_defs; const int *def_starts; cbm_lsp_surface_row_t *rows; + int file_count; + _Atomic int next_file; _Atomic bool failed; } surface_job_t; -static void surface_row_one(int i, void *arg) { +static void surface_row_build(int i, void *arg) { surface_job_t *job = (surface_job_t *)arg; if (atomic_load_explicit(&job->failed, memory_order_relaxed)) { return; @@ -529,6 +531,45 @@ static void surface_row_one(int i, void *arg) { } } +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS +static cbm_lsp_surface_row_observer_fn g_row_observer; +static void *g_row_observer_ctx; + +void cbm_lsp_surface_test_set_row_observer(cbm_lsp_surface_row_observer_fn observer, void *ctx) { + g_row_observer = observer; + g_row_observer_ctx = ctx; +} +#endif + +static void surface_row_one(int i, void *arg) { +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + if (g_row_observer) { + g_row_observer(i, true, g_row_observer_ctx); + } +#endif + surface_row_build(i, arg); +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + if (g_row_observer) { + g_row_observer(i, false, g_row_observer_ctx); + } +#endif +} + +/* The pool also runs work on its caller. Dispatch at most one coarse job per + * allowed participant, each stealing files, so the total active row builders + * cannot exceed the pipeline budget even when the caller takes a job. */ +static void surface_rows_worker(int worker, void *arg) { + (void)worker; + surface_job_t *job = arg; + for (;;) { + int i = atomic_fetch_add_explicit(&job->next_file, 1, memory_order_relaxed); + if (i >= job->file_count) { + break; + } + surface_row_one(i, job); + } +} + int cbm_lsp_surface_build_rows(const cbm_pipeline_ctx_t *ctx, const char *project, CBMFileResult **cache, const cbm_file_info_t *files, int file_count, const CBMLSPDef *all_defs, const int *def_starts, @@ -550,10 +591,16 @@ int cbm_lsp_surface_build_rows(const cbm_pipeline_ctx_t *ctx, const char *projec .all_defs = all_defs, .def_starts = def_starts, .rows = rows, + .file_count = file_count, }; + atomic_init(&job.next_file, 0); atomic_init(&job.failed, false); - cbm_parallel_for(file_count, surface_row_one, &job, - (cbm_parallel_for_opts_t){.max_workers = 0, .force_pthreads = false}); + int workers = cbm_pipeline_worker_count(ctx ? ctx->pipeline : NULL); + if (workers > file_count) { + workers = file_count; + } + cbm_parallel_for(workers, surface_rows_worker, &job, + (cbm_parallel_for_opts_t){.max_workers = workers, .force_pthreads = false}); if (atomic_load_explicit(&job.failed, memory_order_relaxed)) { cbm_store_free_lsp_surfaces(rows, file_count); /* untouched rows are all NULL */ return -1; diff --git a/src/pipeline/lsp_surface.h b/src/pipeline/lsp_surface.h index dc198bb65..1a73b3427 100644 --- a/src/pipeline/lsp_surface.h +++ b/src/pipeline/lsp_surface.h @@ -68,4 +68,11 @@ int cbm_lsp_surface_python_namespace_from_json(CBMArena *arena, const char *json */ int cbm_lsp_surface_python_change(const char *old_json, const char *new_json); +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS +/* Bracket each file callback, including skipped/error rows. Install only with + * no surface build running; clear after the observed build returns. */ +typedef void (*cbm_lsp_surface_row_observer_fn)(int index, bool entering, void *ctx); +void cbm_lsp_surface_test_set_row_observer(cbm_lsp_surface_row_observer_fn observer, void *ctx); +#endif + #endif /* CBM_PIPELINE_LSP_SURFACE_H */ diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 9cbe20c96..64bc57dba 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -81,6 +81,18 @@ static inline void *intptr_to_ptr(intptr_t v) { * Atomic spinlock: 0 = free, 1 = locked. */ static atomic_int g_pipeline_busy = 0; +#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API +static atomic_int g_last_worker_count = 0; + +void cbm_pipeline_worker_count_test_reset(void) { + atomic_store(&g_last_worker_count, 0); +} + +int cbm_pipeline_worker_count_test_last(void) { + return atomic_load(&g_last_worker_count); +} +#endif + #if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API static atomic_bool g_persist_test_fail_after_stage_dump = false; static atomic_bool g_persist_test_cancel_after_predump = false; @@ -204,6 +216,7 @@ struct cbm_pipeline { bool persistence; /* write .codebase-memory/graph.db.zst after indexing */ cbm_index_resource_policy_t resource_policy; cbm_index_resource_violation_t resource_violation; + bool background; /* reserve CPU headroom when no user is waiting */ /* Snapshot of the artifact export failure of THIS run (set only by * export_after_publish failure, zeroed at run start, cleared on success). @@ -955,6 +968,12 @@ const char *cbm_pipeline_export_error(const cbm_pipeline_t *p) { return p ? p->export_error : ""; } +void cbm_pipeline_set_background(cbm_pipeline_t *p, bool background) { + if (p) { + p->background = background; + } +} + bool cbm_pipeline_set_project_name(cbm_pipeline_t *p, const char *name) { if (!p || !name || !name[0]) { return false; @@ -1816,17 +1835,42 @@ void cbm_pipeline_set_committed_counts(cbm_pipeline_t *p, int nodes, int edges) } } +/* Two workers is the smallest width that takes the parallel route. */ +enum { PL_PARALLEL_MIN_WORKERS = 2 }; + +/* Background refreshes leave CPU headroom, but never drop below two workers + * while the foreground width is two or more. WHY: one worker sends the indexer + * down the sequential route, which still builds a different graph than the + * parallel route (fewer Route and HANDLES edges, churned CALLS). Until the two + * routes agree, an automatic refresh must not change the graph only because + * the host has two or fewer performance cores. */ +int cbm_pipeline_background_worker_width(int foreground, int headroom) { + if (foreground >= PL_PARALLEL_MIN_WORKERS && headroom < PL_PARALLEL_MIN_WORKERS) { + return PL_PARALLEL_MIN_WORKERS; + } + return headroom; +} + /* Effective worker count. The crash supervisor re-runs its worker single- * threaded (CBM_INDEX_SINGLE_THREAD=1) so a per-file marker can pin the EXACT * crasher; a parallel re-run would race the marker. Honour that override * everywhere the worker count drives the parallel/sequential decision, so the * whole extraction phase collapses to the deterministic sequential path. */ -static int effective_worker_count(bool initial) { +int cbm_pipeline_worker_count(const cbm_pipeline_t *p) { const char *st = getenv("CBM_INDEX_SINGLE_THREAD"); + int workers; if (st && st[0] == '1') { - return 1; + workers = 1; + } else if (p && p->background) { + workers = cbm_pipeline_background_worker_width(cbm_default_worker_count(true), + cbm_default_worker_count(false)); + } else { + workers = cbm_default_worker_count(true); } - return cbm_default_worker_count(initial); +#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API + atomic_store(&g_last_worker_count, workers); +#endif + return workers; } /* Resolve the DB path for this pipeline. Caller must free(). */ @@ -4181,7 +4225,7 @@ static int run_githistory(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx) { gh_compute_arg_t gh_arg = {.repo_path = ctx->repo_path, .result = &gh_result}; if (p->mode != CBM_MODE_FAST) { - if (effective_worker_count(true) > SKIP_ONE) { + if (cbm_pipeline_worker_count(p) > SKIP_ONE) { if (cbm_thread_create(&gh_thread, 0, gh_compute_thread_fn, &gh_arg) == 0) { gh_threaded = true; } @@ -4281,7 +4325,7 @@ static int run_structure_and_extraction(cbm_pipeline_t *p, cbm_pipeline_ctx_t *c return CBM_NOT_FOUND; } - int worker_count = p->frozen ? 1 : effective_worker_count(true); + int worker_count = p->frozen ? 1 : cbm_pipeline_worker_count(p); if (p->frozen) frozen_observe(p, 0, 1); CBM_PROF_START(t_extract_total); @@ -4412,7 +4456,7 @@ static int cbm_pipeline_run_staged(cbm_pipeline_t *p) { : mode_promoted ? cbm_pipeline_build_fresh_semantic_manifest(p, p->project_name, &baseline_manifest, &baseline_count) - : cbm_pipeline_build_semantic_manifest(p->project_name, p->repo_path, files, + : cbm_pipeline_build_semantic_manifest(p, p->project_name, p->repo_path, files, file_count, p->excluded_dirs, p->excluded_count, &p->git_ctx, p->userconfig, &baseline_manifest, &baseline_count); diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index 9e90b049c..a759b6821 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -71,6 +71,23 @@ bool cbm_pipeline_doc_candidates_enabled(const cbm_pipeline_t *p); /* Copy the exact discovery violation from the most recent run. */ void cbm_pipeline_get_resource_violation(const cbm_pipeline_t *p, cbm_index_resource_violation_t *violation); +/* Mark work that runs without a waiting user. Background pipelines reserve + * CPU headroom; foreground pipelines retain the initial-index all-core policy. */ +void cbm_pipeline_set_background(cbm_pipeline_t *p, bool background); + +/* Resolve the worker policy for this pipeline, including environment and + * crash-recovery overrides. */ +int cbm_pipeline_worker_count(const cbm_pipeline_t *p); + +/* Background width from the foreground width and the headroom width: the + * headroom, floored at two whenever the foreground width is two or more so an + * automatic refresh keeps the parallel route. */ +int cbm_pipeline_background_worker_width(int foreground, int headroom); + +#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API +void cbm_pipeline_worker_count_test_reset(void); +int cbm_pipeline_worker_count_test_last(void); +#endif /* Snapshot of the artifact export failure of the last cbm_pipeline_run, or "" * when the run succeeded / did not reach post-publish export. Used to diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index c246dbe4f..ac2c253af 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -479,9 +479,9 @@ static void *manifest_hash_worker(void *arg) { enum { MANIFEST_PARALLEL_MIN_FILES = 64 }; -int cbm_pipeline_build_semantic_manifest(const char *project, const char *repo_path, - const cbm_file_info_t *files, int file_count, - char **excluded_dirs, int excluded_count, +int cbm_pipeline_build_semantic_manifest(const cbm_pipeline_t *p, const char *project, + const char *repo_path, const cbm_file_info_t *files, + int file_count, char **excluded_dirs, int excluded_count, const cbm_git_context_t *git_ctx, const cbm_userconfig_t *userconfig, cbm_file_hash_t **out, int *out_count) { @@ -522,7 +522,7 @@ int cbm_pipeline_build_semantic_manifest(const char *project, const char *repo_p } struct timespec t_hash; cbm_clock_gettime(CLOCK_MONOTONIC, &t_hash); - int hash_workers = cbm_default_worker_count(true); + int hash_workers = cbm_pipeline_worker_count(p); if (rc == 0 && file_count >= MANIFEST_PARALLEL_MIN_FILES && hash_workers > SKIP_ONE) { char (*shas)[CBM_SHA256_HEX_LEN + 1] = malloc((size_t)file_count * sizeof(*shas)); int64_t *mtimes = calloc((size_t)file_count, sizeof(int64_t)); @@ -869,9 +869,9 @@ int cbm_pipeline_build_fresh_semantic_manifest(cbm_pipeline_t *p, const char *pr &fresh_ignored_total); } if (rc == 0) { - rc = cbm_pipeline_build_semantic_manifest(project, repo_path, fresh_files, fresh_file_count, - fresh_excluded, fresh_excluded_count, - &fresh_git_ctx, fresh_userconfig, out, out_count); + rc = cbm_pipeline_build_semantic_manifest( + p, project, repo_path, fresh_files, fresh_file_count, fresh_excluded, + fresh_excluded_count, &fresh_git_ctx, fresh_userconfig, out, out_count); } cbm_set_user_lang_config(previous_userconfig); cbm_git_context_free(&fresh_git_ctx); @@ -1855,7 +1855,8 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed * a full build takes, which is what makes its output converge. */ #define MIN_FILES_FOR_PARALLEL_INCR 50 - int worker_count = cbm_pipeline_is_frozen(ctx->pipeline) ? 1 : cbm_default_worker_count(true); + int worker_count = + cbm_pipeline_is_frozen(ctx->pipeline) ? 1 : cbm_pipeline_worker_count(ctx->pipeline); bool use_parallel = closure != NULL || (worker_count > SKIP_ONE && ci > MIN_FILES_FOR_PARALLEL_INCR); @@ -2339,7 +2340,7 @@ static int closure_probe_surfaces(cbm_pipeline_t *p, const char *project, _Atomic int64_t probe_ids; atomic_init(&probe_ids, cbm_gbuf_next_id(probe_gbuf)); rc = cbm_parallel_extract(&probe_ctx, probe_files, probe_count, cache, &probe_ids, - cbm_default_worker_count(true)); + cbm_pipeline_worker_count(p)); } if (rc == 0) { char **def_modules = @@ -2353,10 +2354,14 @@ static int closure_probe_surfaces(cbm_pipeline_t *p, const char *project, if (def_modules && def_starts) { defs = cbm_pxc_collect_all_defs(NULL, &probe_arena, cache, probe_files, probe_count, project, def_modules, &def_count, def_starts); - rc = def_count < 0 - ? CBM_PIPELINE_ABORT_PRESERVE_DB - : cbm_lsp_surface_build_rows(NULL, project, cache, probe_files, probe_count, - defs, def_starts, out_rows, out_count); + /* Extract left pipeline NULL so file errors are not recorded twice. + * Surface rows only borrow it for the background worker policy. */ + probe_ctx.pipeline = p; + rc = def_count < 0 ? CBM_PIPELINE_ABORT_PRESERVE_DB + : cbm_lsp_surface_build_rows(&probe_ctx, project, cache, probe_files, + probe_count, defs, def_starts, out_rows, + out_count); + probe_ctx.pipeline = NULL; } else { rc = -1; } @@ -3482,7 +3487,7 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char elig[elig_count++] = row; } } - int workers = cbm_default_worker_count(true); + int workers = cbm_pipeline_worker_count(p); if (workers < 1) { workers = 1; } diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index a4384bf49..86ac44cd7 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -1587,9 +1587,9 @@ bool cbm_pipeline_incremental_generation_exact(cbm_pipeline_t *p, cbm_store_t *s #define CBM_SEMANTIC_INPUT_GLOBAL_CONFIG CBM_SEMANTIC_INPUT_PREFIX "global-extension-config-v1" #define CBM_SEMANTIC_INPUT_PROJECT_CONFIG CBM_SEMANTIC_INPUT_PREFIX "project-extension-config-v1" -int cbm_pipeline_build_semantic_manifest(const char *project, const char *repo_path, - const cbm_file_info_t *files, int file_count, - char **excluded_dirs, int excluded_count, +int cbm_pipeline_build_semantic_manifest(const cbm_pipeline_t *p, const char *project, + const char *repo_path, const cbm_file_info_t *files, + int file_count, char **excluded_dirs, int excluded_count, const cbm_git_context_t *git_ctx, const cbm_userconfig_t *userconfig, cbm_file_hash_t **out, int *out_count); diff --git a/src/pipeline/worker_pool.c b/src/pipeline/worker_pool.c index 2943fd746..96611bf8d 100644 --- a/src/pipeline/worker_pool.c +++ b/src/pipeline/worker_pool.c @@ -22,9 +22,28 @@ enum { WP_TRUE = 1, WP_MIN = 1, WP_STEP = 1 }; /* CBM_WORKER_STACK_SIZE (8 MB per worker) lives in worker_pool.h. */ +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS +static cbm_parallel_spawn_observer_fn g_spawn_observer; +static void *g_spawn_observer_ctx; + +void cbm_parallel_test_set_spawn_observer(cbm_parallel_spawn_observer_fn observer, void *ctx) { + g_spawn_observer = observer; + g_spawn_observer_ctx = ctx; +} + +static void observe_spawn(int count, int helpers) { + if (g_spawn_observer) { + g_spawn_observer(count, helpers, g_spawn_observer_ctx); + } +} +#else +#define observe_spawn(count, helpers) ((void)0) +#endif + /* ── Serial fallback ─────────────────────────────────────────────── */ static void run_serial(int count, cbm_parallel_fn fn, void *ctx) { + observe_spawn(count, 0); for (int i = 0; i < count; i++) { fn(i, ctx); } @@ -103,6 +122,7 @@ static void run_pthreads_counted(int count, cbm_parallel_fn fn, void *ctx, int n } started++; } + observe_spawn(count, started); int main_done = 0; uint64_t main_ops_before = cbm_memev_thread_ops(); while (WP_TRUE) { @@ -164,6 +184,7 @@ static void run_pthreads(int count, cbm_parallel_fn fn, void *ctx, int nworkers) } } + observe_spawn(count, nworkers); /* Main thread also participates */ while (WP_TRUE) { int idx = atomic_fetch_add_explicit(&next_idx, WP_STEP, memory_order_relaxed); diff --git a/src/pipeline/worker_pool.h b/src/pipeline/worker_pool.h index 91697b961..49b3efd1f 100644 --- a/src/pipeline/worker_pool.h +++ b/src/pipeline/worker_pool.h @@ -36,4 +36,11 @@ typedef struct { * If count <= 1 or workers <= 1, runs single-threaded. */ void cbm_parallel_for(int count, cbm_parallel_fn fn, void *ctx, cbm_parallel_for_opts_t opts); +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS +/* Observe the completed spawn attempt before the caller takes work. Install + * only while no dispatch is running; clear after the observed dispatch joins. */ +typedef void (*cbm_parallel_spawn_observer_fn)(int count, int helpers, void *ctx); +void cbm_parallel_test_set_spawn_observer(cbm_parallel_spawn_observer_fn observer, void *ctx); +#endif + #endif /* CBM_WORKER_POOL_H */ diff --git a/tests/test_agent_clients.c b/tests/test_agent_clients.c index 0d57723a0..c92005f9f 100644 --- a/tests/test_agent_clients.c +++ b/tests/test_agent_clients.c @@ -1342,23 +1342,101 @@ TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction) { PASS(); } -/* #2077: OpenCode's V2 loader only reads the default export and needs an - * id plus a setup()/effect() function; the old named export had neither. */ +/* #2077/#2089/#2204: the two OpenCode runtimes read different entries of the + * default export. 1.18.x dispatches the hooks returned by `server(ctx)`; 2.x + * ignores `server` and calls `setup(ctx)`, whose context provides the tool hook + * domain. The registration fails open, and the server() tool hook yields once + * it is registered. OpenCode 2 adds no note to a compaction (pinned by the + * compaction test below). */ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp"); ASSERT_NOT_NULL(js); ASSERT_NOT_NULL(strstr(js, "export default {")); ASSERT_NOT_NULL(strstr(js, "id: 'codebase-memory-augment'")); - /* Hooks live under server(), which the server runtime reads; setup() - * stays empty since the V2 config loader has no tool domain yet. */ - ASSERT_NOT_NULL(strstr(js, "setup() {}")); + /* OpenCode 1.18.x server runtime. */ ASSERT_NOT_NULL(strstr(js, "server: async (ctx) => {")); - ASSERT_NULL(strstr(js, "async setup(ctx) {")); + /* OpenCode 2 registers through the setup context; the domains exist on + * 2.x, so setup is no longer the empty placeholder #2089 pinned. */ + ASSERT_NOT_NULL(strstr(js, "async setup(ctx) {")); + ASSERT_NOT_NULL(strstr(js, "ctx.tool.hook('execute.after'")); + /* content is the surface the model reads; prefer it over output, and never + * synthesize content that would hide the tool's own result. */ + const char *content_first = strstr(js, "if (typeof result.content === 'string') {"); + const char *output_fallback = strstr(js, "if (typeof result.output === 'string') {"); + ASSERT_NOT_NULL(content_first); + ASSERT_NOT_NULL(output_fallback); + ASSERT_TRUE(content_first < output_fallback); + ASSERT_NULL(strstr(js, "if (result.content === undefined)")); + /* The V2 hooks read the session id with optional chaining (fail open). */ + ASSERT_NOT_NULL(strstr(js, "event?.sessionID")); + /* The plugin instance's location is not the session's location in a + * multi-project server: resolve each session's directory. */ + ASSERT_NOT_NULL(strstr(js, "const dir = await dirFor(")); + ASSERT_NOT_NULL(strstr(js, "ctx.session.get({ sessionID })")); + ASSERT_NOT_NULL(strstr(js, "dirs.has(sessionID)")); + ASSERT_NOT_NULL(strstr(js, "if (!dir) return;")); + /* No fallback to the plugin instance's location: that is not the session's + * location in a multi-project server. */ + ASSERT_NULL(strstr(js, "ctx?.location?.directory")); + ASSERT_NULL(strstr(js, "pendingReinject")); + /* The registration fails open; the server() tool hook yields once it is in. */ + ASSERT_NOT_NULL(strstr(js, "toolHooksRegistered = true;")); + ASSERT_NOT_NULL(strstr(js, "if (toolHooksRegistered) return;")); + ASSERT_NOT_NULL(strstr(js, "catch {")); + ASSERT_NOT_NULL(strstr(js, "event.result = appendResult(event.result, extra);")); + /* The empty setup placeholder and the old named export must both be gone. */ + ASSERT_NULL(strstr(js, "setup() {}")); ASSERT_NULL(strstr(js, "export const CodebaseMemory")); free(js); PASS(); } +/* #2204: OpenCode 2 gets no note after compaction until a compaction channel has + * run on a real 2.x host. A plain object pushed into `event.messages` would be + * rejected, if at all, inside OpenCode's own compaction, where the plugin's + * try/catch cannot reach it, so an untested channel could break compaction for + * every OpenCode 2 user. The note must not reach the system prompt either: not + * through `event.system`, and not through a session instruction entry. The + * 1.18.x server() path keeps its documented compaction surface. */ +TEST(client_adapter_opencode_v2_adds_no_note_to_the_compaction) { + char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp"); + ASSERT_NOT_NULL(js); + ASSERT_NULL(strstr(js, "ctx.session.hook(")); + ASSERT_NULL(strstr(js, "event.messages")); + ASSERT_NULL(strstr(js, "sessionHooksRegistered")); + ASSERT_NULL(strstr(js, ".system")); + ASSERT_NULL(strstr(js, "instructions")); + ASSERT_NULL(strstr(js, "entry.put")); + ASSERT_NOT_NULL(strstr(js, "'experimental.session.compacting'")); + free(js); + PASS(); +} + +/* #2204: Tool.Result allows `content: []`. appendResult adds a text part to a + * parts array only when the array already holds a part; an empty array falls + * through to the string `output` (or the unchanged result), so the note never + * becomes the whole content of a tool result. */ +TEST(client_adapter_opencode_v2_append_result_never_creates_note_only_content) { + char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp"); + ASSERT_NOT_NULL(js); + const char *parts = + strstr(js, "if (Array.isArray(result.content) && result.content.length > 0) {"); + const char *add_part = strstr(js, "content.push({ type: 'text', text: extra });"); + const char *output_fallback = strstr(js, "if (typeof result.output === 'string') {"); + ASSERT_NOT_NULL(parts); + ASSERT_NOT_NULL(add_part); + ASSERT_NOT_NULL(output_fallback); + /* The only part-adding push sits inside the non-empty branch; the string + * output fallback, where `content: []` lands, comes after it. */ + ASSERT_TRUE(parts < add_part); + ASSERT_TRUE(add_part < output_fallback); + ASSERT_NULL(strstr(add_part + 1, "content.push(")); + /* No unguarded array branch is left. */ + ASSERT_NULL(strstr(js, "if (Array.isArray(result.content)) {")); + free(js); + PASS(); +} + /* augment() is awaited inline inside 'tool.execute.after', so a hook-augment * that never exits would stall every grep/glob/read completion in the host * session until the agent is restarted. The generated plugin must bound the @@ -1431,6 +1509,8 @@ SUITE(agent_clients) { RUN_TEST(client_adapter_opencode_sends_the_required_hook_event); RUN_TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction); RUN_TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077); + RUN_TEST(client_adapter_opencode_v2_adds_no_note_to_the_compaction); + RUN_TEST(client_adapter_opencode_v2_append_result_never_creates_note_only_content); RUN_TEST(client_adapter_opencode_bounds_a_hung_hook_augment_child); RUN_TEST(client_adapter_rejects_missing_binary_path); } diff --git a/tests/test_cli.c b/tests/test_cli.c index a5d327fc3..f716418ad 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -8294,7 +8294,6 @@ TEST(cli_durable_profiles_follow_current_vendor_paths) { strstr(plan, "\"instruction_files_planned\""); const char *const planned[] = { "/.claude/agents/codebase-memory.md", - "/vendor-codex/skills/codebase-memory/SKILL.md", "/vendor-codex/agents/codebase-memory.toml", "/.cursor/skills/codebase-memory/SKILL.md", "/.cursor/agents/codebase-memory.md", @@ -8457,13 +8456,12 @@ TEST(cli_durable_profiles_follow_current_vendor_paths) { free(profile); const char *const skill_files[] = { - "/skills/codebase-memory/SKILL.md", "/.cursor/skills/codebase-memory/SKILL.md", "/.config/opencode/skills/codebase-memory/SKILL.md", "/.factory/skills/codebase-memory/SKILL.md", "/.agents/skills/codebase-memory/SKILL.md", }; - const char *const skill_roots[] = {codex_home, tmpdir, tmpdir, tmpdir, tmpdir}; + const char *const skill_roots[] = {tmpdir, tmpdir, tmpdir, tmpdir}; for (size_t i = 0U; files_ok && i < sizeof(skill_files) / sizeof(skill_files[0]); i++) { snprintf(path, sizeof(path), "%s%s", skill_roots[i], skill_files[i]); files_ok = test_file_contains_all(path, graph_terms, 3); @@ -9574,6 +9572,127 @@ TEST(cli_dry_run_predicts_refused_hook_script_issue1387) { FAIL("dry-run must predict a refused hook-script rewrite (#1387)"); PASS(); } + +/* #2204: the OpenCode adapter is an auto-loaded module, and the generated block + * tells readers to "remove the markers to take ownership". A marker-less file + * with content is the user's: appending the block to a hand-ported module + * produces a file the client cannot even parse (the prelude redeclares the + * user's own import and const names, and both sides declare a default export). + * The installer must leave that file alone, and the dry run must say so instead + * of promising a write it will not make. A dry run never writes, so the byte + * check below follows a REAL install: that is the run that appended. */ +TEST(cli_extension_leaves_user_owned_module_untouched_issue2204) { + char home[256]; + char bin[320]; + if (!client_detect_fixture(home, sizeof(home), bin, sizeof(bin))) + FAIL("cbm_mkdtemp failed"); + + /* An `opencode` CLI on the isolated PATH marks OpenCode as installed. */ + char exe[512]; + snprintf(exe, sizeof(exe), "%s/opencode", bin); + if (write_test_file(exe, "#!/bin/sh\nexit 0\n") != 0 || chmod(exe, 0755) != 0) + FAIL("opencode fixture failed"); + + char plugins_dir[512]; + snprintf(plugins_dir, sizeof(plugins_dir), "%s/.config/opencode/plugins", home); + test_mkdirp(plugins_dir); + char plugin_path[768]; + snprintf(plugin_path, sizeof(plugin_path), "%s/cbm-augment.ts", plugins_dir); + const char foreign[] = "import { spawn } from 'node:child_process';\n" + "const BIN = '/opt/other/codebase-memory-mcp';\n" + "export default { id: 'codebase-memory-augment', setup() {} };\n"; + if (write_test_file(plugin_path, foreign) != 0) + FAIL("foreign module fixture failed"); + + client_detect_env_t saved = client_detect_env_isolate(bin); + FILE *capture = tmpfile(); + int saved_stdout = capture ? dup(STDOUT_FILENO) : -1; + bool redirected = false; + if (capture && saved_stdout >= 0) { + fflush(stdout); + redirected = dup2(fileno(capture), STDOUT_FILENO) >= 0; + } + long plan_output_start = -1; + long plan_output_end = -1; + char *plan = NULL; + if (redirected) { + (void)cbm_install_agent_configs(home, "/opt/other/codebase-memory-mcp", false, true); + fflush(stdout); + /* The --plan receipt (#388) replays the same path with writes off. It + * must not list a module the run leaves alone, and it must print + * nothing: stdout carries the receipt JSON. */ + plan_output_start = (long)lseek(STDOUT_FILENO, 0, SEEK_CUR); + plan = cbm_build_install_plan_json(home, "/opt/other/codebase-memory-mcp"); + fflush(stdout); + /* The real install, the run #2204 is about. */ + plan_output_end = (long)lseek(STDOUT_FILENO, 0, SEEK_CUR); + (void)cbm_install_agent_configs(home, "/opt/other/codebase-memory-mcp", false, false); + fflush(stdout); + (void)dup2(saved_stdout, STDOUT_FILENO); + } + if (saved_stdout >= 0) + close(saved_stdout); + + char bytes[512] = {0}; + FILE *file = cbm_fopen(plugin_path, "rb"); + size_t got = file ? fread(bytes, 1U, sizeof(bytes) - 1U, file) : 0U; + if (file) + fclose(file); + + /* Control: with the user's module gone the receipt does list the path, so + * the omission above cannot pass just because OpenCode went undetected. */ + char *control_plan = NULL; + if (unlink(plugin_path) == 0) + control_plan = cbm_build_install_plan_json(home, "/opt/other/codebase-memory-mcp"); + client_detect_env_restore(&saved); + + char output[16384] = {0}; + if (capture) { + rewind(capture); + size_t count = fread(output, 1, sizeof(output) - 1U, capture); + output[count] = '\0'; + fclose(capture); + } + + /* Each phase owns its slice of stdout: [0, start) dry run, [start, end) + * plan mode, [end, len) real install, so no phase passes on another's + * line. Checked back to front, cutting the buffer at each boundary. */ + bool sliced = plan_output_start >= 0 && plan_output_end >= plan_output_start && + (size_t)plan_output_end <= strlen(output); + bool reported = + sliced && strstr(output + plan_output_end, "cbm-augment.ts left untouched") != NULL; + if (sliced) + output[plan_output_end] = '\0'; + bool plan_silent = sliced && strstr(output + plan_output_start, "cbm-augment.ts") == NULL; + if (sliced) + output[plan_output_start] = '\0'; + bool warned = + sliced && strstr(output, "cbm-augment.ts") != NULL && strstr(output, "not ours") != NULL; + bool untouched = got == sizeof(foreign) - 1U && memcmp(bytes, foreign, got) == 0; + bool plan_omits = plan && strstr(plan, "cbm-augment.ts") == NULL; + bool control_lists = control_plan && strstr(control_plan, "cbm-augment.ts") != NULL; + free(plan); + free(control_plan); + + test_rmdir_r(home); + if (!redirected) + FAIL("stdout capture failed"); + if (!sliced) + FAIL("stdout capture lost a phase boundary"); + if (!warned) + FAIL("dry-run must predict an untouched user-owned OpenCode module (#2204)"); + if (!untouched) + FAIL("a real install must leave the user-owned OpenCode module byte-identical (#2204)"); + if (!reported) + FAIL("a real install must report the user-owned OpenCode module as left untouched (#2204)"); + if (!plan_omits) + FAIL("the install plan must not list a module the run leaves untouched (#2204)"); + if (!plan_silent) + FAIL("plan mode must keep stdout for the receipt JSON (#2204)"); + if (!control_lists) + FAIL("control: the install plan must list the module once it is not user-owned"); + PASS(); +} #endif TEST(cli_agent_client_registry_routes_plan_install_and_uninstall) { @@ -10983,12 +11102,21 @@ TEST(cli_codex_respects_codex_home) { char *json = cbm_build_install_plan_json(tmpdir, "/usr/local/bin/codebase-memory-mcp"); char expected_config[640]; snprintf(expected_config, sizeof(expected_config), "%s/config.toml", codex_home); + char expected_skill[640]; + char legacy_skill[640]; + snprintf(expected_skill, sizeof(expected_skill), "%s/.agents/skills/codebase-memory/SKILL.md", + tmpdir); + snprintf(legacy_skill, sizeof(legacy_skill), "%s/skills/codebase-memory/SKILL.md", codex_home); yyjson_doc *plan_doc = json ? yyjson_read(json, strlen(json), 0) : NULL; yyjson_val *plan_root = plan_doc ? yyjson_doc_get_root(plan_doc) : NULL; bool plans_config = test_json_string_array_contains(plan_root, "config_files_planned", expected_config); bool plans_instructions = test_json_string_array_contains( plan_root, "instruction_files_planned", expected_instructions); + bool plans_canonical_skill = + test_json_string_array_contains(plan_root, "skill_files_planned", expected_skill); + bool plans_legacy_skill = + test_json_string_array_contains(plan_root, "skill_files_planned", legacy_skill); bool plans_cleanup = json && strstr(json, "remove_managed_block_if_present") != NULL; char *instructions_after = read_test_file_alloc(expected_instructions); bool plan_preserved_user_file = @@ -11002,9 +11130,10 @@ TEST(cli_codex_respects_codex_home) { if (!agents.codex) FAIL("Codex detection must honor CODEX_HOME"); - if (!plans_config || !plans_instructions || plans_cleanup || !plan_preserved_user_file) + if (!plans_config || !plans_instructions || !plans_canonical_skill || plans_legacy_skill || + plans_cleanup || !plan_preserved_user_file) FAIL("Codex plan must include the managed activation pointer under CODEX_HOME without " - "mutating existing user content"); + "mutating existing user content, and use HOME/.agents for the skill"); PASS(); } @@ -11023,7 +11152,7 @@ TEST(cli_codex_install_uses_global_activation_pointer_issue1689) { snprintf(codex_home, sizeof(codex_home), "%s/.codex", tmpdir); snprintf(agents_path, sizeof(agents_path), "%s/AGENTS.md", codex_home); snprintf(config_path, sizeof(config_path), "%s/config.toml", codex_home); - snprintf(skill_path, sizeof(skill_path), "%s/skills/codebase-memory/SKILL.md", codex_home); + snprintf(skill_path, sizeof(skill_path), "%s/.agents/skills/codebase-memory/SKILL.md", tmpdir); snprintf(profile_path, sizeof(profile_path), "%s/agents/codebase-memory.toml", codex_home); #ifdef _WIN32 snprintf(binary_path, sizeof(binary_path), "%s/.local/bin/codebase-memory-mcp.exe", tmpdir); @@ -11128,7 +11257,6 @@ TEST(cli_codex_install_uses_global_activation_pointer_issue1689) { restore_test_env("PATH", saved_path); restore_test_env("CODEX_HOME", saved_codex); test_rmdir_r(tmpdir); - if (!fresh_pointer_installed || !other_surfaces_installed || !unowned_preserved || !managed_migrated_once || !marker_only_migrated || !malformed_preserved || !uninstall_preserved_foreign) @@ -11180,6 +11308,408 @@ TEST(cli_grok_respects_grok_home) { PASS(); } +static bool cli_test_file_equals(const char *path, const char *expected) { + char *data = read_test_file_alloc(path); + bool equal = data && strcmp(data, expected) == 0; + free(data); + return equal; +} + +TEST(cli_codex_skill_migrates_owned_legacy_to_agents_root) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-skill-migrate-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char codex_home[512]; + char legacy_root[640]; + char legacy_file[768]; + char sibling_file[768]; + char canonical_file[768]; + snprintf(codex_home, sizeof(codex_home), "%s/vendor-codex", tmpdir); + snprintf(legacy_root, sizeof(legacy_root), "%s/skills", codex_home); + snprintf(legacy_file, sizeof(legacy_file), "%s/codebase-memory/SKILL.md", legacy_root); + snprintf(sibling_file, sizeof(sibling_file), "%s/codebase-memory/notes.md", legacy_root); + snprintf(canonical_file, sizeof(canonical_file), "%s/.agents/skills/codebase-memory/SKILL.md", + tmpdir); + test_mkdirp(codex_home); + ASSERT_EQ(cbm_install_skills(legacy_root, false, false), CBM_SKILL_COUNT); + write_test_file(sibling_file, "user notes\n"); + + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_codex = save_test_env("CODEX_HOME"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_setenv("CODEX_HOME", codex_home, 1); + + int dry_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, true); + struct stat state; + bool dry_safe = dry_rc == 0 && stat(canonical_file, &state) != 0 && + cli_test_file_equals(legacy_file, cbm_get_skills()[0].content); + char *plan = cbm_build_install_plan_json(tmpdir, "/opt/codebase-memory-mcp"); + bool plan_ok = plan && strstr(plan, canonical_file) && strstr(plan, legacy_file) && + strstr(plan, "cleanup_actions_planned") && + strstr(plan, "remove_owned_legacy_copy_if_migrated"); + bool plan_safe = stat(canonical_file, &state) != 0 && + cli_test_file_equals(legacy_file, cbm_get_skills()[0].content); + free(plan); + + int install_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + bool migrated = + install_rc == 0 && cli_test_file_equals(canonical_file, cbm_get_skills()[0].content) && + stat(legacy_file, &state) != 0 && cli_test_file_equals(sibling_file, "user notes\n"); + + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CODEX_HOME", saved_codex); + test_rmdir_r(tmpdir); + + if (!dry_safe || !plan_ok || !plan_safe || !migrated) + FAIL("Codex owned legacy skill migration must be previewable, destination-first, and " + "sibling-safe"); + PASS(); +} + +TEST(cli_codex_skill_migrates_every_released_legacy_payload) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-skill-released-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char codex_home[512]; + char legacy_dir[768]; + char legacy_file[768]; + char canonical_file[768]; + snprintf(codex_home, sizeof(codex_home), "%s/.codex", tmpdir); + snprintf(legacy_dir, sizeof(legacy_dir), "%s/skills/codebase-memory", codex_home); + snprintf(legacy_file, sizeof(legacy_file), "%s/SKILL.md", legacy_dir); + snprintf(canonical_file, sizeof(canonical_file), "%s/.agents/skills/codebase-memory/SKILL.md", + tmpdir); + test_mkdirp(codex_home); + + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_codex = save_test_env("CODEX_HOME"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_setenv("CODEX_HOME", codex_home, 1); + cbm_cli_set_client_selection_for_testing("codex"); + + size_t released_count = cbm_cli_released_skill_content_count(); + bool migrated_all = released_count == 4U; + for (size_t i = 0U; migrated_all && i < released_count; i++) { + const char *released = cbm_cli_released_skill_content_at(i); + test_mkdirp(legacy_dir); + if (!released || write_test_file(legacy_file, released) != 0) { + migrated_all = false; + break; + } + (void)remove(canonical_file); + int rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + struct stat state; + migrated_all = rc == 0 && + cli_test_file_equals(canonical_file, cbm_get_skills()[0].content) && + stat(legacy_file, &state) != 0; + if (!migrated_all || write_test_file(canonical_file, released) != 0) { + migrated_all = false; + break; + } + rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + migrated_all = rc == 0 && cli_test_file_equals(canonical_file, cbm_get_skills()[0].content); + } + + cbm_cli_set_client_selection_for_testing(NULL); + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CODEX_HOME", saved_codex); + test_rmdir_r(tmpdir); + if (!migrated_all) + FAIL("Codex must migrate every released skill payload from legacy and canonical paths"); + PASS(); +} + +TEST(cli_codex_foreign_legacy_does_not_block_shared_clients) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-skill-shared-client-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + const char *foreign = "---\nname: codebase-memory\n---\nUser-owned legacy skill.\n"; + char codex_home[512]; + char legacy_dir[768]; + char legacy_file[768]; + char openhands_dir[512]; + char canonical_file[768]; + snprintf(codex_home, sizeof(codex_home), "%s/.codex", tmpdir); + snprintf(legacy_dir, sizeof(legacy_dir), "%s/skills/codebase-memory", codex_home); + snprintf(legacy_file, sizeof(legacy_file), "%s/SKILL.md", legacy_dir); + snprintf(openhands_dir, sizeof(openhands_dir), "%s/.openhands", tmpdir); + snprintf(canonical_file, sizeof(canonical_file), "%s/.agents/skills/codebase-memory/SKILL.md", + tmpdir); + test_mkdirp(legacy_dir); + test_mkdirp(openhands_dir); + write_test_file(legacy_file, foreign); + + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_codex = save_test_env("CODEX_HOME"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_setenv("CODEX_HOME", codex_home, 1); + + char *plan = cbm_build_install_plan_json(tmpdir, "/opt/codebase-memory-mcp"); + bool plan_keeps_shared_install = plan && strstr(plan, canonical_file) && + !strstr(plan, "remove_owned_legacy_copy_if_migrated"); + free(plan); + int install_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + int repeat_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + bool installed = install_rc == 0 && repeat_rc == 0 && + cli_test_file_equals(legacy_file, foreign) && + cli_test_file_equals(canonical_file, cbm_get_skills()[0].content); + + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CODEX_HOME", saved_codex); + test_rmdir_r(tmpdir); + if (!plan_keeps_shared_install || !installed) + FAIL("A foreign Codex legacy skill must not suppress another client's shared skill"); + PASS(); +} + +TEST(cli_codex_skill_preserves_foreign_legacy_until_force) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-skill-foreign-legacy-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + const char *foreign = "---\nname: codebase-memory\n---\nUser-owned legacy skill.\n"; + char codex_home[512]; + char legacy_dir[768]; + char legacy_file[768]; + char sibling_file[768]; + char canonical_file[768]; + snprintf(codex_home, sizeof(codex_home), "%s/vendor-codex", tmpdir); + snprintf(legacy_dir, sizeof(legacy_dir), "%s/skills/codebase-memory", codex_home); + snprintf(legacy_file, sizeof(legacy_file), "%s/SKILL.md", legacy_dir); + snprintf(sibling_file, sizeof(sibling_file), "%s/user.txt", legacy_dir); + snprintf(canonical_file, sizeof(canonical_file), "%s/.agents/skills/codebase-memory/SKILL.md", + tmpdir); + test_mkdirp(legacy_dir); + write_test_file(legacy_file, foreign); + write_test_file(sibling_file, "keep\n"); + + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_codex = save_test_env("CODEX_HOME"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_setenv("CODEX_HOME", codex_home, 1); + cbm_cli_set_client_selection_for_testing("codex"); + + int normal_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + struct stat state; + bool preserved = normal_rc == 0 && cli_test_file_equals(legacy_file, foreign) && + stat(canonical_file, &state) != 0; + int force_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", true, false); + bool forced = force_rc == 0 && + cli_test_file_equals(canonical_file, cbm_get_skills()[0].content) && + stat(legacy_file, &state) != 0 && cli_test_file_equals(sibling_file, "keep\n"); + + cbm_cli_set_client_selection_for_testing(NULL); + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CODEX_HOME", saved_codex); + test_rmdir_r(tmpdir); + if (!preserved || !forced) + FAIL("Codex must preserve foreign legacy content unless --force authorizes replacement"); + PASS(); +} + +TEST(cli_codex_skill_preserves_foreign_canonical_and_owned_legacy) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-skill-foreign-canonical-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + const char *foreign = "---\nname: codebase-memory\n---\nUser-owned canonical skill.\n"; + char codex_home[512]; + char legacy_root[640]; + char legacy_file[768]; + char canonical_dir[768]; + char canonical_file[768]; + snprintf(codex_home, sizeof(codex_home), "%s/vendor-codex", tmpdir); + snprintf(legacy_root, sizeof(legacy_root), "%s/skills", codex_home); + snprintf(legacy_file, sizeof(legacy_file), "%s/codebase-memory/SKILL.md", legacy_root); + snprintf(canonical_dir, sizeof(canonical_dir), "%s/.agents/skills/codebase-memory", tmpdir); + snprintf(canonical_file, sizeof(canonical_file), "%s/SKILL.md", canonical_dir); + test_mkdirp(codex_home); + ASSERT_EQ(cbm_install_skills(legacy_root, false, false), CBM_SKILL_COUNT); + test_mkdirp(canonical_dir); + write_test_file(canonical_file, foreign); + + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_codex = save_test_env("CODEX_HOME"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_setenv("CODEX_HOME", codex_home, 1); + + int normal_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + bool preserved = normal_rc == 0 && cli_test_file_equals(canonical_file, foreign) && + cli_test_file_equals(legacy_file, cbm_get_skills()[0].content); + int force_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", true, false); + struct stat state; + bool forced = force_rc == 0 && + cli_test_file_equals(canonical_file, cbm_get_skills()[0].content) && + stat(legacy_file, &state) != 0; + + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CODEX_HOME", saved_codex); + test_rmdir_r(tmpdir); + if (!preserved || !forced) + FAIL("Codex must preserve conflicting canonical bytes and its owned legacy copy until " + "--force"); + PASS(); +} + +TEST(cli_codex_skill_failed_destination_preserves_owned_legacy) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-skill-failed-destination-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char codex_home[512]; + char legacy_root[640]; + char legacy_file[768]; + char canonical_root[640]; + char blocked_path[768]; + snprintf(codex_home, sizeof(codex_home), "%s/vendor-codex", tmpdir); + snprintf(legacy_root, sizeof(legacy_root), "%s/skills", codex_home); + snprintf(legacy_file, sizeof(legacy_file), "%s/codebase-memory/SKILL.md", legacy_root); + snprintf(canonical_root, sizeof(canonical_root), "%s/.agents/skills", tmpdir); + snprintf(blocked_path, sizeof(blocked_path), "%s/codebase-memory", canonical_root); + test_mkdirp(codex_home); + ASSERT_EQ(cbm_install_skills(legacy_root, false, false), CBM_SKILL_COUNT); + test_mkdirp(canonical_root); + write_test_file(blocked_path, "not a directory\n"); + + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_codex = save_test_env("CODEX_HOME"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_setenv("CODEX_HOME", codex_home, 1); + int rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + bool safe = rc != 0 && cli_test_file_equals(blocked_path, "not a directory\n") && + cli_test_file_equals(legacy_file, cbm_get_skills()[0].content); + + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CODEX_HOME", saved_codex); + test_rmdir_r(tmpdir); + if (!safe) + FAIL("Codex migration must retain the owned legacy copy when canonical publication fails"); + PASS(); +} + +TEST(cli_codex_skill_uninstall_cleans_both_owned_locations) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-skill-uninstall-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char codex_home[512]; + char canonical_root[640]; + char legacy_root[640]; + char canonical_file[768]; + char legacy_file[768]; + char binary_dir[640]; + char binary_path[768]; + snprintf(codex_home, sizeof(codex_home), "%s/.codex", tmpdir); + snprintf(canonical_root, sizeof(canonical_root), "%s/.agents/skills", tmpdir); + snprintf(legacy_root, sizeof(legacy_root), "%s/skills", codex_home); + snprintf(canonical_file, sizeof(canonical_file), "%s/codebase-memory/SKILL.md", canonical_root); + snprintf(legacy_file, sizeof(legacy_file), "%s/codebase-memory/SKILL.md", legacy_root); + snprintf(binary_dir, sizeof(binary_dir), "%s/.local/bin", tmpdir); +#ifdef _WIN32 + snprintf(binary_path, sizeof(binary_path), "%s/codebase-memory-mcp.exe", binary_dir); +#else + snprintf(binary_path, sizeof(binary_path), "%s/codebase-memory-mcp", binary_dir); +#endif + test_mkdirp(codex_home); + test_mkdirp(binary_dir); + write_test_file(binary_path, "installed binary\n"); + ASSERT_EQ(cbm_install_skills(canonical_root, false, false), CBM_SKILL_COUNT); + ASSERT_EQ(cbm_install_skills(legacy_root, false, false), CBM_SKILL_COUNT); + + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_codex = save_test_env("CODEX_HOME"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_unsetenv("CODEX_HOME"); + char *argv[] = {"uninstall", "--yes"}; + int owned_rc = cli_test_cmd_uninstall(2, argv); + struct stat state; + bool owned_removed = + owned_rc == 0 && stat(canonical_file, &state) != 0 && stat(legacy_file, &state) != 0; + + const char *foreign = "---\nname: codebase-memory\n---\nUser-owned canonical skill.\n"; + write_test_file(binary_path, "installed binary\n"); + bool mixed_seeded = cbm_install_skills(canonical_root, false, false) == CBM_SKILL_COUNT && + cbm_install_skills(legacy_root, false, false) == CBM_SKILL_COUNT; + if (mixed_seeded) { + write_test_file(canonical_file, foreign); + } + int mixed_rc = mixed_seeded ? cli_test_cmd_uninstall(2, argv) : 1; + bool mixed_safe = mixed_rc == 0 && cli_test_file_equals(canonical_file, foreign) && + stat(legacy_file, &state) != 0; + + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CODEX_HOME", saved_codex); + test_rmdir_r(tmpdir); + if (!owned_removed || !mixed_safe) + FAIL("Codex uninstall must clean both owned roots while preserving foreign skill bytes"); + PASS(); +} + +TEST(cli_codex_skill_deduplicates_agents_codex_home) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-skill-same-root-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char codex_home[512]; + char skill_file[768]; + snprintf(codex_home, sizeof(codex_home), "%s/.agents", tmpdir); + snprintf(skill_file, sizeof(skill_file), "%s/skills/codebase-memory/SKILL.md", codex_home); + test_mkdirp(codex_home); + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_codex = save_test_env("CODEX_HOME"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_setenv("CODEX_HOME", codex_home, 1); + + int install_rc = cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + char *plan = cbm_build_install_plan_json(tmpdir, "/opt/codebase-memory-mcp"); + bool deduplicated = install_rc == 0 && + cli_test_file_equals(skill_file, cbm_get_skills()[0].content) && plan && + !strstr(plan, "remove_owned_legacy_copy_if_migrated"); + free(plan); + + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CODEX_HOME", saved_codex); + test_rmdir_r(tmpdir); + if (!deduplicated) + FAIL("Codex must treat HOME/.agents and CODEX_HOME as one skill root when they coincide"); + PASS(); +} + TEST(cli_gemini_session_hook_uses_json_for_all_sources) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-gemini-session-XXXXXX"); @@ -13806,7 +14336,7 @@ TEST(cli_codex_migrates_to_single_hook_representation) { int uninstall_rc = cli_test_cmd_uninstall(1, uninstall_argv); char skill_path[768]; char agent_path[768]; - snprintf(skill_path, sizeof(skill_path), "%s/skills/codebase-memory/SKILL.md", codex_dir); + snprintf(skill_path, sizeof(skill_path), "%s/.agents/skills/codebase-memory/SKILL.md", tmpdir); snprintf(agent_path, sizeof(agent_path), "%s/agents/codebase-memory.toml", codex_dir); struct stat state; hooks = read_test_file_alloc(hooks_path); @@ -13950,9 +14480,16 @@ TEST(cli_codex_preflight_reports_heading_and_reason) { char codex_dir[512]; char config_path[640]; char agents_path[640]; + char legacy_root[640]; + char legacy_file[768]; + char canonical_file[768]; snprintf(codex_dir, sizeof(codex_dir), "%s/.codex", tmpdir); snprintf(config_path, sizeof(config_path), "%s/config.toml", codex_dir); snprintf(agents_path, sizeof(agents_path), "%s/AGENTS.md", codex_dir); + snprintf(legacy_root, sizeof(legacy_root), "%s/skills", codex_dir); + snprintf(legacy_file, sizeof(legacy_file), "%s/codebase-memory/SKILL.md", legacy_root); + snprintf(canonical_file, sizeof(canonical_file), "%s/.agents/skills/codebase-memory/SKILL.md", + tmpdir); if (test_mkdirp(codex_dir) != 0) { test_rmdir_r(tmpdir); FAIL("failed to create Codex preflight fixture directory"); @@ -13965,6 +14502,10 @@ TEST(cli_codex_preflight_reports_heading_and_reason) { test_rmdir_r(tmpdir); FAIL("failed to write Codex preflight fixture config"); } + if (cbm_install_skills(legacy_root, false, false) != CBM_SKILL_COUNT) { + test_rmdir_r(tmpdir); + FAIL("failed to seed owned legacy Codex skill"); + } char *saved_home = save_test_env("HOME"); char *saved_path = save_test_env("PATH"); @@ -13972,6 +14513,7 @@ TEST(cli_codex_preflight_reports_heading_and_reason) { cbm_setenv("HOME", tmpdir, 1); cbm_setenv("PATH", tmpdir, 1); cbm_unsetenv("CODEX_HOME"); + cbm_cli_set_client_selection_for_testing("codex"); FILE *capture = tmpfile(); int saved_stdout = capture ? dup(STDOUT_FILENO) : -1; @@ -14006,8 +14548,11 @@ TEST(cli_codex_preflight_reports_heading_and_reason) { } char *after = read_test_file_alloc(config_path); char *agents_after = read_test_file_alloc(agents_path); + struct stat state; bool unchanged = after && strcmp(after, ambiguous) == 0 && agents_after && - strcmp(agents_after, test_codex_activation_block) == 0; + strcmp(agents_after, test_codex_activation_block) == 0 && + stat(canonical_file, &state) != 0 && + cli_test_file_equals(legacy_file, cbm_get_skills()[0].content); bool diagnostic = strstr(output, "Codex CLI:\nerror: agent_config agent=Codex CLI op=hook_preflight path=") != NULL && @@ -14015,6 +14560,7 @@ TEST(cli_codex_preflight_reports_heading_and_reason) { free(after); free(agents_after); + cbm_cli_set_client_selection_for_testing(NULL); restore_test_env("HOME", saved_home); restore_test_env("PATH", saved_path); restore_test_env("CODEX_HOME", saved_codex); @@ -19221,6 +19767,335 @@ TEST(cli_update_yes_keeps_every_index_issue2200) { PASS(); } +/* ── index_repository exit contract ────────────────────────────── + * + * cbm_cli_index_exit_status grades a result envelope into a process exit + * code, because a freshness gate reads process status rather than a tool's + * account of itself. The six outcomes were measured by hand against the + * built binary when the contract landed; measured once is not pinned, and + * a code that moves silently is exactly what the contract exists to stop. */ + +/* Build the envelope the CLI actually receives: the payload travels as a + * JSON string inside content[0].text. Escaping it by hand in every test + * would put the test's own escaping on trial instead of the grader. */ +static char *cli_index_envelope(const char *payload) { + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + if (!doc) { + return NULL; + } + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + yyjson_mut_val *content = yyjson_mut_arr(doc); + yyjson_mut_val *item = yyjson_mut_obj(doc); + yyjson_mut_obj_add_str(doc, item, "text", payload); + yyjson_mut_arr_append(content, item); + yyjson_mut_obj_add_val(doc, root, "content", content); + char *json = yyjson_mut_write(doc, 0, NULL); + yyjson_mut_doc_free(doc); + return json; +} + +/* Grade one payload. Returns -1 only if the envelope could not be built, a + * value no exit code uses, so a setup failure cannot pass for a verdict. */ +static int cli_index_grade(const char *payload, int base_status) { + char *envelope = cli_index_envelope(payload); + if (!envelope) { + return -1; + } + int status = cbm_cli_index_exit_status(envelope, base_status); + free(envelope); + return status; +} + +typedef struct { + char *gate; + char *unusable; + char *partial_pct; +} cli_gate_env_t; + +/* A test that means to exercise the documented defaults must not inherit + * whatever the developer happens to have exported. The contract is opt-in, + * so the gate is switched on here; the one test about it being off unsets + * CBM_GATE itself. */ +static cli_gate_env_t cli_gate_env_clear(void) { + cli_gate_env_t saved = {save_test_env("CBM_GATE"), save_test_env("CBM_GATE_MAX_UNUSABLE"), + save_test_env("CBM_GATE_MAX_PARTIAL_PCT")}; + cbm_setenv("CBM_GATE", "1", 1); + cbm_unsetenv("CBM_GATE_MAX_UNUSABLE"); + cbm_unsetenv("CBM_GATE_MAX_PARTIAL_PCT"); + return saved; +} + +static void cli_gate_env_restore(cli_gate_env_t saved) { + restore_test_env("CBM_GATE", saved.gate); + restore_test_env("CBM_GATE_MAX_UNUSABLE", saved.unusable); + restore_test_env("CBM_GATE_MAX_PARTIAL_PCT", saved.partial_pct); +} + +/* The verdict codes are a public contract: CI pipelines branch on the + * numbers, so a renumbering must fail here rather than in someone's + * pipeline. Neither verdict may sit on 2, which the binary already returns + * for a usage error and which shells and CI tooling read that way. */ +TEST(cli_index_exit_codes_are_a_stable_contract) { + ASSERT_EQ(CBM_CLI_EXIT_OK, 0); + ASSERT_EQ(CBM_CLI_EXIT_FAILURE, 1); + ASSERT_EQ(CBM_CLI_EXIT_QUALITY, 10); + ASSERT_EQ(CBM_CLI_EXIT_TARGET, 11); + PASS(); +} + +/* Without CBM_GATE the CLI exits exactly as it always did: a payload that + * would grade 10 or 11 with the gate on keeps the base status. "0" and the + * empty string count as off, so an exported CBM_GATE=0 is not a surprise. */ +TEST(cli_index_exit_gate_off_keeps_base_status) { + cli_gate_env_t saved = cli_gate_env_clear(); + const char *below_quality = "{\"status\":\"ok\",\"files_indexed\":10," + "\"parse_partial_count\":5,\"parse_unusable_count\":1}"; + const char *missing_target = "{\"status\":\"error\",\"reason\":\"target_unavailable\"}"; + + /* Positive control: with the gate on both payloads DO change the status, + * so the unchanged codes below are the switch working, not a dead check. */ + int quality_on = cli_index_grade(below_quality, CBM_CLI_EXIT_OK); + int target_on = cli_index_grade(missing_target, CBM_CLI_EXIT_FAILURE); + + cbm_unsetenv("CBM_GATE"); + int quality_unset = cli_index_grade(below_quality, CBM_CLI_EXIT_OK); + int target_unset = cli_index_grade(missing_target, CBM_CLI_EXIT_FAILURE); + + cbm_setenv("CBM_GATE", "0", 1); + int quality_zero = cli_index_grade(below_quality, CBM_CLI_EXIT_OK); + + cbm_setenv("CBM_GATE", "", 1); + int quality_empty = cli_index_grade(below_quality, CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + + ASSERT_EQ(quality_on, CBM_CLI_EXIT_QUALITY); + ASSERT_EQ(target_on, CBM_CLI_EXIT_TARGET); + ASSERT_EQ(quality_unset, CBM_CLI_EXIT_OK); + ASSERT_EQ(target_unset, CBM_CLI_EXIT_FAILURE); + ASSERT_EQ(quality_zero, CBM_CLI_EXIT_OK); + ASSERT_EQ(quality_empty, CBM_CLI_EXIT_OK); + PASS(); +} + +/* A clean index keeps the code it always had: the contract adds verdicts, + * it does not make previously good runs start failing. */ +TEST(cli_index_exit_clean_run_stays_zero) { + cli_gate_env_t saved = cli_gate_env_clear(); + int status = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":4," + "\"parse_partial_count\":0,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(status, CBM_CLI_EXIT_OK); + PASS(); +} + +/* A file that did not parse at all gets no tolerance — the default is zero, + * so one such file is already a quality failure. */ +TEST(cli_index_exit_unusable_file_is_a_quality_failure) { + cli_gate_env_t saved = cli_gate_env_clear(); + int status = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":5," + "\"parse_partial_count\":0,\"parse_unusable_count\":1}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(status, CBM_CLI_EXIT_QUALITY); + PASS(); +} + +/* Partial parsing is graded as a share, not a count: 20 of 100 files is + * twice the default ceiling. */ +TEST(cli_index_exit_partial_above_threshold_is_a_quality_failure) { + cli_gate_env_t saved = cli_gate_env_clear(); + int status = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":100," + "\"parse_partial_count\":20,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(status, CBM_CLI_EXIT_QUALITY); + PASS(); +} + +/* The boundary itself passes. The comparison is strict and integral on + * purpose: at exactly the documented ceiling a double could round either + * way, and a threshold whose verdict depends on rounding is not a + * threshold. 10 of 100 is the ceiling, and the ceiling is allowed. */ +TEST(cli_index_exit_partial_exactly_at_threshold_passes) { + cli_gate_env_t saved = cli_gate_env_clear(); + int at_ceiling = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":100," + "\"parse_partial_count\":10,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_OK); + /* One file more is over it, which proves the case above is the boundary + * and not simply a check that never fires. */ + int over_ceiling = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":100," + "\"parse_partial_count\":11,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(at_ceiling, CBM_CLI_EXIT_OK); + ASSERT_EQ(over_ceiling, CBM_CLI_EXIT_QUALITY); + PASS(); +} + +/* "degraded" is the pipeline's own verdict that the graph came out far + * smaller than the run expected. It carries no parse counts, so it has to + * be graded on the status alone. */ +TEST(cli_index_exit_degraded_status_is_a_quality_failure) { + cli_gate_env_t saved = cli_gate_env_clear(); + int status = cli_index_grade("{\"status\":\"degraded\",\"files_indexed\":40," + "\"parse_partial_count\":0,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(status, CBM_CLI_EXIT_QUALITY); + PASS(); +} + +/* An absent target and a pipeline that fell over inside a reachable + * repository used to share code 1, which is the whole reason the contract + * was written: the caller could not tell "wrong path" from "broken run". */ +TEST(cli_index_exit_separates_missing_target_from_broken_run) { + cli_gate_env_t saved = cli_gate_env_clear(); + int target = cli_index_grade("{\"status\":\"error\",\"reason\":\"target_unavailable\"}", + CBM_CLI_EXIT_FAILURE); + int pipeline = cli_index_grade("{\"status\":\"error\",\"reason\":\"pipeline_failed\"}", + CBM_CLI_EXIT_FAILURE); + /* An error the mapping had not already flagged still has to land on a + * failure code rather than fall through as success. */ + int unflagged = cli_index_grade("{\"status\":\"error\",\"reason\":\"pipeline_failed\"}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(target, CBM_CLI_EXIT_TARGET); + ASSERT_EQ(pipeline, CBM_CLI_EXIT_FAILURE); + ASSERT_EQ(unflagged, CBM_CLI_EXIT_FAILURE); + PASS(); +} + +/* Silence is never upgraded into a verdict. A payload the grader cannot + * read says nothing about index quality, and inventing a 10 from it would + * fail runs for the crime of an unexpected response shape. */ +TEST(cli_index_exit_never_upgrades_silence) { + cli_gate_env_t saved = cli_gate_env_clear(); + ASSERT_EQ(cbm_cli_index_exit_status(NULL, CBM_CLI_EXIT_OK), CBM_CLI_EXIT_OK); + ASSERT_EQ(cbm_cli_index_exit_status("", CBM_CLI_EXIT_OK), CBM_CLI_EXIT_OK); + ASSERT_EQ(cbm_cli_index_exit_status("not json at all", CBM_CLI_EXIT_OK), CBM_CLI_EXIT_OK); + /* A well-formed envelope carrying no content, and one whose text is not + * itself JSON — both are shapes a future response could take. */ + ASSERT_EQ(cbm_cli_index_exit_status("{\"content\":[]}", CBM_CLI_EXIT_OK), CBM_CLI_EXIT_OK); + ASSERT_EQ(cli_index_grade("plain text, not a payload", CBM_CLI_EXIT_OK), CBM_CLI_EXIT_OK); + /* And the same shapes must not erase a failure already established. */ + ASSERT_EQ(cbm_cli_index_exit_status(NULL, CBM_CLI_EXIT_FAILURE), CBM_CLI_EXIT_FAILURE); + cli_gate_env_restore(saved); + PASS(); +} + +/* Grading only ever makes a code more specific. A base failure survives a + * payload that looks perfectly healthy, because the transport already knew + * something the payload does not say. */ +TEST(cli_index_exit_does_not_downgrade_a_failing_base) { + cli_gate_env_t saved = cli_gate_env_clear(); + int status = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":9," + "\"parse_partial_count\":0,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_FAILURE); + cli_gate_env_restore(saved); + ASSERT_EQ(status, CBM_CLI_EXIT_FAILURE); + PASS(); +} + +/* Both thresholds are overridable, and both are read at grading time. */ +TEST(cli_index_exit_thresholds_read_the_environment) { + cli_gate_env_t saved = cli_gate_env_clear(); + const char *twenty_percent = "{\"status\":\"ok\",\"files_indexed\":100," + "\"parse_partial_count\":20,\"parse_unusable_count\":0}"; + const char *three_unusable = "{\"status\":\"ok\",\"files_indexed\":50," + "\"parse_partial_count\":0,\"parse_unusable_count\":3}"; + + /* Positive control: at the defaults both payloads fail, so a pass below + * is the override working and not the check being absent. */ + int partial_default = cli_index_grade(twenty_percent, CBM_CLI_EXIT_OK); + int unusable_default = cli_index_grade(three_unusable, CBM_CLI_EXIT_OK); + + cbm_setenv("CBM_GATE_MAX_PARTIAL_PCT", "50", 1); + cbm_setenv("CBM_GATE_MAX_UNUSABLE", "5", 1); + int partial_raised = cli_index_grade(twenty_percent, CBM_CLI_EXIT_OK); + int unusable_raised = cli_index_grade(three_unusable, CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + + ASSERT_EQ(partial_default, CBM_CLI_EXIT_QUALITY); + ASSERT_EQ(unusable_default, CBM_CLI_EXIT_QUALITY); + ASSERT_EQ(partial_raised, CBM_CLI_EXIT_OK); + ASSERT_EQ(unusable_raised, CBM_CLI_EXIT_OK); + PASS(); +} + +/* A negative ceiling switches its own check off. This is the documented + * escape hatch for a repository whose grammars are known to be thin. */ +TEST(cli_index_exit_negative_threshold_disables_the_check) { + cli_gate_env_t saved = cli_gate_env_clear(); + cbm_setenv("CBM_GATE_MAX_PARTIAL_PCT", "-1", 1); + cbm_setenv("CBM_GATE_MAX_UNUSABLE", "-1", 1); + int status = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":100," + "\"parse_partial_count\":99,\"parse_unusable_count\":7}", + CBM_CLI_EXIT_OK); + /* Disabling the parse checks must not disable the pipeline's own + * verdict: "degraded" is not a threshold and has no off switch. */ + int degraded = cli_index_grade("{\"status\":\"degraded\",\"files_indexed\":10," + "\"parse_partial_count\":0,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(status, CBM_CLI_EXIT_OK); + ASSERT_EQ(degraded, CBM_CLI_EXIT_QUALITY); + PASS(); +} + +/* A typo must not silently disable a gate. Anything that is not a whole + * number falls back to the documented default, so "1O" (letter O) fails the + * run it would have failed anyway instead of quietly waving it through. */ +TEST(cli_index_exit_unreadable_threshold_falls_back_to_default) { + cli_gate_env_t saved = cli_gate_env_clear(); + const char *twenty_percent = "{\"status\":\"ok\",\"files_indexed\":100," + "\"parse_partial_count\":20,\"parse_unusable_count\":0}"; + /* The numeric-looking values carry 50, not the default 10: a reader that + * wrongly accepted one would raise the ceiling above the 20% payload and + * pass the run, so every refusal is visible. " 10" could not show that, + * and plain strtol does accept a leading blank. The last one overflows + * long: strtol clamps it to LONG_MAX with the whole string consumed, a + * ceiling no run could exceed. */ + const char *unreadable[] = { + "abc", "1O", "50pct", " 50", "50 ", "", "5e1", "50.0", "99999999999999999999999"}; + for (size_t i = 0; i < sizeof(unreadable) / sizeof(unreadable[0]); i++) { + cbm_setenv("CBM_GATE_MAX_PARTIAL_PCT", unreadable[i], 1); + int status = cli_index_grade(twenty_percent, CBM_CLI_EXIT_OK); + if (status != CBM_CLI_EXIT_QUALITY) { + printf(" unreadable threshold \"%s\" gave exit %d\n", unreadable[i], status); + } + ASSERT_EQ(status, CBM_CLI_EXIT_QUALITY); + } + /* A value that DOES read still takes effect, so the loop above is about + * unreadable text and not about the override being ignored outright. */ + cbm_setenv("CBM_GATE_MAX_PARTIAL_PCT", "50", 1); + int readable = cli_index_grade(twenty_percent, CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(readable, CBM_CLI_EXIT_OK); + PASS(); +} + +/* A share needs a denominator. Without files_indexed the percentage cannot + * be computed at all, and a run must not be failed on a number nobody + * could work out. */ +TEST(cli_index_exit_partial_without_denominator_is_not_graded) { + cli_gate_env_t saved = cli_gate_env_clear(); + int no_denominator = cli_index_grade("{\"status\":\"ok\",\"parse_partial_count\":20}", + CBM_CLI_EXIT_OK); + int zero_denominator = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":0," + "\"parse_partial_count\":20}", + CBM_CLI_EXIT_OK); + /* An unusable file is an absolute count and still grades without one. */ + int unusable_still_graded = cli_index_grade("{\"status\":\"ok\",\"parse_unusable_count\":1}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(no_denominator, CBM_CLI_EXIT_OK); + ASSERT_EQ(zero_denominator, CBM_CLI_EXIT_OK); + ASSERT_EQ(unusable_still_graded, CBM_CLI_EXIT_QUALITY); + PASS(); +} + SUITE(cli) { if (!th_secure_runtime_parent_new(g_cli_suite_runtime_parent, sizeof(g_cli_suite_runtime_parent), "cli-suite")) { @@ -19481,6 +20356,7 @@ SUITE(cli) { #endif #ifndef _WIN32 RUN_TEST(cli_dry_run_predicts_refused_hook_script_issue1387); + RUN_TEST(cli_extension_leaves_user_owned_module_untouched_issue2204); #endif RUN_TEST(cli_agent_client_registry_routes_plan_install_and_uninstall); RUN_TEST(cli_registry_installs_kimi_rovo_amp_durable_context); @@ -19498,6 +20374,14 @@ SUITE(cli) { RUN_TEST(cli_codex_respects_codex_home); RUN_TEST(cli_grok_respects_grok_home); RUN_TEST(cli_codex_install_uses_global_activation_pointer_issue1689); + RUN_TEST(cli_codex_skill_migrates_owned_legacy_to_agents_root); + RUN_TEST(cli_codex_skill_migrates_every_released_legacy_payload); + RUN_TEST(cli_codex_foreign_legacy_does_not_block_shared_clients); + RUN_TEST(cli_codex_skill_preserves_foreign_legacy_until_force); + RUN_TEST(cli_codex_skill_preserves_foreign_canonical_and_owned_legacy); + RUN_TEST(cli_codex_skill_failed_destination_preserves_owned_legacy); + RUN_TEST(cli_codex_skill_uninstall_cleans_both_owned_locations); + RUN_TEST(cli_codex_skill_deduplicates_agents_codex_home); RUN_TEST(cli_gemini_session_hook_uses_json_for_all_sources); RUN_TEST(cli_gemini_installs_dedicated_graph_subagent); RUN_TEST(cli_antigravity_does_not_imply_gemini); @@ -19713,6 +20597,22 @@ SUITE(cli) { RUN_TEST(cli_optional_argument_tool_never_reads_stream_stdin); RUN_TEST(cli_stdin_kind_classifies_file_and_pipe); RUN_TEST(cli_stdin_args_gate_tracks_tool_schema_issue1359); + + /* index_repository exit contract */ + RUN_TEST(cli_index_exit_codes_are_a_stable_contract); + RUN_TEST(cli_index_exit_gate_off_keeps_base_status); + RUN_TEST(cli_index_exit_clean_run_stays_zero); + RUN_TEST(cli_index_exit_unusable_file_is_a_quality_failure); + RUN_TEST(cli_index_exit_partial_above_threshold_is_a_quality_failure); + RUN_TEST(cli_index_exit_partial_exactly_at_threshold_passes); + RUN_TEST(cli_index_exit_degraded_status_is_a_quality_failure); + RUN_TEST(cli_index_exit_separates_missing_target_from_broken_run); + RUN_TEST(cli_index_exit_never_upgrades_silence); + RUN_TEST(cli_index_exit_does_not_downgrade_a_failing_base); + RUN_TEST(cli_index_exit_thresholds_read_the_environment); + RUN_TEST(cli_index_exit_negative_threshold_disables_the_check); + RUN_TEST(cli_index_exit_unreadable_threshold_falls_back_to_default); + RUN_TEST(cli_index_exit_partial_without_denominator_is_not_graded); cbm_cli_set_activation_runtime_parent_for_test(NULL); test_rmdir_r(g_cli_suite_runtime_parent); g_cli_suite_runtime_parent[0] = '\0'; diff --git a/tests/test_config_text_edit.c b/tests/test_config_text_edit.c index 4ce474853..45331d89c 100644 --- a/tests/test_config_text_edit.c +++ b/tests/test_config_text_edit.c @@ -725,6 +725,44 @@ TEST(config_text_write_owned_document_if_unchanged_rejects_stale_snapshot) { PASS(); } +/* #2204: the generated OpenCode module must not be appended to a file the user + * owns. The probe separates "may be written" (absent, whitespace-only, or + * already carrying our markers) from "the user's file" (content, no markers) so + * the installer can leave it alone instead of producing an unparseable hybrid. */ +TEST(config_text_managed_block_status_reports_foreign_module) { + char dir[CTE_PATH_CAP]; + char path[CTE_PATH_CAP]; + const char whitespace[] = " \n\t\n"; + const char foreign[] = "import { spawn } from 'node:child_process';\n" + "export default { id: 'codebase-memory-augment', setup() {} };\n"; + const char unbalanced[] = CTE_BEGIN "\nowned\n"; + ASSERT_EQ(cte_fixture(dir, sizeof(dir), path, sizeof(path)), 0); + + /* No file yet, an empty file, and a whitespace-only file are all writable. */ + ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 0); + ASSERT_EQ(cte_write_bytes(path, "", 0U), 0); + ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 0); + ASSERT_EQ(cte_write_bytes(path, whitespace, sizeof(whitespace) - 1U), 0); + ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 0); + + /* Our own block refreshes in place, so it is writable too. */ + ASSERT_EQ(cbm_text_upsert_managed_block(path, CTE_BEGIN, CTE_END, "owned"), 0); + ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 0); + + /* A hand-ported module with no markers is the user's: refuse, never append. */ + ASSERT_EQ(cte_write_bytes(path, foreign, sizeof(foreign) - 1U), 0); + ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 1); + + /* Unbalanced markers and malformed marker arguments are unsafe states. */ + ASSERT_EQ(cte_write_bytes(path, unbalanced, sizeof(unbalanced) - 1U), 0); + ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), -1); + ASSERT_EQ(cbm_text_managed_block_status(path, "same", "same"), -1); + ASSERT_EQ(cbm_text_managed_block_status(NULL, CTE_BEGIN, CTE_END), -1); + + th_cleanup(dir); + PASS(); +} + SUITE(config_text_edit) { RUN_TEST(config_text_managed_insert_preserves_bom_comments_and_is_idempotent); RUN_TEST(config_text_managed_replace_preserves_crlf_surroundings); @@ -732,6 +770,7 @@ SUITE(config_text_edit) { RUN_TEST(config_text_managed_remove_preserves_user_bytes); RUN_TEST(config_text_managed_malformed_markers_fail_closed); RUN_TEST(config_text_managed_rejects_unsafe_markers_and_owned_content); + RUN_TEST(config_text_managed_block_status_reports_foreign_module); RUN_TEST(config_text_owned_document_write_remove_exact_only); RUN_TEST(config_text_owned_document_migrates_exact_releases_only); RUN_TEST(config_text_rejects_invalid_utf8_and_controls); diff --git a/tests/test_cs_lsp.c b/tests/test_cs_lsp.c index 6c111ca2d..c6a58c474 100644 --- a/tests/test_cs_lsp.c +++ b/tests/test_cs_lsp.c @@ -1226,6 +1226,174 @@ TEST(cslsp_ordinary_same_leaf_calls_join_by_exact_site) { PASS(); } +/* A qualified type name resolves its qualifier as a TYPE when no namespace + * binds it (nested types, #2120). A using alias whose target is qualified by + * the alias itself (`using Loop = Loop.Inner;`) or by a mutual alias re-enters + * that walk through the alias step forever: each `Loop` resolves the target + * `Loop.Inner`, whose qualifier is `Loop` again. The walk is depth-bounded; + * the file must still finish and resolve its ordinary calls. */ +TEST(cslsp_cyclic_alias_qualifier_terminates) { + const char *src = "using Loop = Loop.Inner;\n" + "using Ping = Pong.Inner;\n" + "using Pong = Ping.Inner;\n" + "namespace App {\n" + " public class Svc { public void Go() { } }\n" + " public class Runner {\n" + " public void Run(Loop a, Ping b) {\n" + " var x = new Loop();\n" + " var y = new Ping.Leaf();\n" + " var s = new Svc();\n" + " s.Go();\n" + " }\n" + " }\n" + "}\n"; + CBMFileResult *r = extract_cs(src); + ASSERT(r); + ASSERT(require_resolved(r, "Runner.Run", "Svc.Go") >= 0); + cbm_free_result(r); + PASS(); +} + +static const CBMDefinition *find_def_named(const CBMFileResult *r, const char *name) { + for (int i = 0; i < r->defs.count; i++) { + if (r->defs.items[i].name && strcmp(r->defs.items[i].name, name) == 0) + return &r->defs.items[i]; + } + return NULL; +} + +/* A top-level C# type records its declared namespace; "" means the global + * namespace and is claimed only on a clean parse. The vendored grammar does + * not parse a `ref partial struct` with members, and error recovery dissolves + * the enclosing namespace, so the types after it surface with no namespace + * ancestor. Those must not read as global (they would hide every imported + * type of the same name); unknown (NULL) keeps them out of namespace binding + * exactly as before global-namespace binding existed. */ +TEST(cslsp_decl_namespace_global_needs_clean_parse) { + CBMFileResult *clean = extract_cs("public class Loose { }\n" + "namespace Acme.Lib { public class Kept { } }\n"); + ASSERT(clean); + const CBMDefinition *loose = find_def_named(clean, "Loose"); + const CBMDefinition *kept = find_def_named(clean, "Kept"); + ASSERT(loose && kept); + ASSERT(loose->decl_namespace && strcmp(loose->decl_namespace, "") == 0); + ASSERT(kept->decl_namespace && strcmp(kept->decl_namespace, "Acme.Lib") == 0); + cbm_free_result(clean); + + CBMFileResult *damaged = extract_cs("namespace Acme.Lib\n" + "{\n" + " public ref partial struct Handler\n" + " {\n" + " public void Append(string value) { }\n" + " }\n" + " public static partial class Gadget { }\n" + "}\n"); + ASSERT(damaged); + const CBMDefinition *gadget = find_def_named(damaged, "Gadget"); + ASSERT(gadget); + if (gadget->decl_namespace && !gadget->decl_namespace[0]) + printf(" Gadget after a parse error claims the global namespace\n"); + ASSERT(!gadget->decl_namespace || gadget->decl_namespace[0]); + cbm_free_result(damaged); + PASS(); +} + +static bool has_resolved_row(const CBMResolvedCallArray *out, const char *caller_qn, + const char *callee_qn) { + for (int i = 0; i < out->count; i++) { + const CBMResolvedCall *rc = &out->items[i]; + if (rc->caller_qn && rc->callee_qn && strcmp(rc->caller_qn, caller_qn) == 0 && + strcmp(rc->callee_qn, callee_qn) == 0) + return true; + } + return false; +} + +/* `partial` pieces of one namespaced type are separate registry entries, so + * a member declared on another piece still binds (#2120). Types declared in + * the GLOBAL namespace carry the empty namespace, and the same simple name + * there is a different type per assembly across a repository (each test + * project's own `Helper`). They must never pool as twins: the twin probe runs + * before the base-class walk, so pooling bound `h.Stop()` on ToolA's Helper + * to the unrelated ToolB Helper instead of ToolA's own base ToolABase. Runs + * the production Tier-2 C# registry builder over cross-file defs. */ +TEST(cslsp_global_namespace_types_are_not_partial_twins) { + CBMArena arena; + cbm_arena_init(&arena); + CBMLSPDef defs[] = { + {.qualified_name = "p.ToolA.Helper.Helper", + .short_name = "Helper", + .label = "Class", + .def_module_qn = "p.ToolA.Helper", + .embedded_types = "p.ToolA.Base.ToolABase", + .lang = CBM_LANG_CSHARP}, + {.qualified_name = "p.ToolA.Helper.Helper.Start", + .short_name = "Start", + .label = "Method", + .receiver_type = "p.ToolA.Helper.Helper", + .def_module_qn = "p.ToolA.Helper", + .lang = CBM_LANG_CSHARP}, + {.qualified_name = "p.ToolA.Base.ToolABase", + .short_name = "ToolABase", + .label = "Class", + .def_module_qn = "p.ToolA.Base", + .lang = CBM_LANG_CSHARP}, + {.qualified_name = "p.ToolA.Base.ToolABase.Stop", + .short_name = "Stop", + .label = "Method", + .receiver_type = "p.ToolA.Base.ToolABase", + .def_module_qn = "p.ToolA.Base", + .lang = CBM_LANG_CSHARP}, + {.qualified_name = "p.ToolB.Helper.Helper", + .short_name = "Helper", + .label = "Class", + .def_module_qn = "p.ToolB.Helper", + .lang = CBM_LANG_CSHARP}, + {.qualified_name = "p.ToolB.Helper.Helper.Stop", + .short_name = "Stop", + .label = "Method", + .receiver_type = "p.ToolB.Helper.Helper", + .def_module_qn = "p.ToolB.Helper", + .lang = CBM_LANG_CSHARP}, + }; + CBMTypeRegistry *reg = + cbm_cs_build_cross_registry(&arena, defs, (int)(sizeof(defs) / sizeof(defs[0]))); + ASSERT_NOT_NULL(reg); + /* No namespace anywhere: both Helpers and the base live in the global + * namespace. The caller sits in ToolA's Helper file, so `new Helper()` + * binds ToolA's Helper through the same-module step, independent of + * registration order. */ + const char *src = "public class Helper : ToolABase { public void Start() { } }\n" + "public class Caller {\n" + " public void Go() {\n" + " var h = new Helper();\n" + " h.Start();\n" + " h.Stop();\n" + " }\n" + "}\n"; + CBMResolvedCallArray out = {0}; + cbm_run_cs_lsp_cross_with_registry(&arena, src, (int)strlen(src), "p.ToolA.Helper", reg, NULL, + 0, NULL, &out); + const char *caller = "p.ToolA.Helper.Caller.Go"; + bool start_own = has_resolved_row(&out, caller, "p.ToolA.Helper.Helper.Start"); + bool stop_base = has_resolved_row(&out, caller, "p.ToolA.Base.ToolABase.Stop"); + bool stop_twin = has_resolved_row(&out, caller, "p.ToolB.Helper.Helper.Stop"); + if (!start_own || !stop_base || stop_twin) { + printf(" start_own=%d stop_base=%d stop_unrelated_global_twin=%d\n", start_own, stop_base, + stop_twin); + for (int i = 0; i < out.count; i++) { + printf(" %s -> %s [%s]\n", out.items[i].caller_qn ? out.items[i].caller_qn : "?", + out.items[i].callee_qn ? out.items[i].callee_qn : "?", + out.items[i].strategy ? out.items[i].strategy : "?"); + } + } + cbm_arena_destroy(&arena); + ASSERT_TRUE(start_own); + ASSERT_TRUE(stop_base); + ASSERT_FALSE(stop_twin); + PASS(); +} + /* ── Suite ─────────────────────────────────────────────────── */ SUITE(cs_lsp) { @@ -1290,4 +1458,7 @@ SUITE(cs_lsp) { RUN_TEST(cslsp_stringbuilder_chain); RUN_TEST(cslsp_httpclient_chain); RUN_TEST(cslsp_ordinary_same_leaf_calls_join_by_exact_site); + RUN_TEST(cslsp_cyclic_alias_qualifier_terminates); + RUN_TEST(cslsp_global_namespace_types_are_not_partial_twins); + RUN_TEST(cslsp_decl_namespace_global_needs_clean_parse); } diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index 7dc68db66..ad8d98441 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -1662,6 +1662,7 @@ typedef struct { * itself instead, which is the edge a reader on another thread needs. */ atomic_bool args_captured[APP_FAKE_MAX_ATTEMPTS]; size_t memory_budgets[APP_FAKE_MAX_ATTEMPTS]; + bool background_requests[APP_FAKE_MAX_ATTEMPTS]; } app_fake_worker_context_t; typedef struct { @@ -1729,6 +1730,8 @@ static int app_fake_worker_start(void *opaque, const char *args_json, size_t mem args_json ? args_json : ""); atomic_store(&context->args_captured[worker->attempt], true); context->memory_budgets[worker->attempt] = memory_budget_bytes; + context->background_requests[worker->attempt] = + cbm_mcp_get_bool_arg(args_json, "_cbm_background"); if (marker_file) { (void)snprintf(context->marker_paths[worker->attempt], APP_TEST_PATH_CAP, "%s", marker_file); @@ -2546,6 +2549,7 @@ TEST(daemon_application_initialize_coalesces_auto_index_for_full_sessions) { ASSERT_TRUE(first_initialized); ASSERT_TRUE(first_owned); ASSERT_TRUE(auto_policy_propagated); + ASSERT_TRUE(fake.background_requests[0]); ASSERT_TRUE(second_initialized); ASSERT_TRUE(coalesced); ASSERT_TRUE(restricted_disconnect_kept_job); @@ -4287,6 +4291,7 @@ TEST(daemon_application_request_cancel_preserves_persistent_watch_and_session) { ASSERT_TRUE(thread_started); ASSERT_TRUE(subscribed); ASSERT_TRUE(worker_started); + ASSERT_FALSE(fixture.fake.background_requests[0]); ASSERT_TRUE(request_returned); ASSERT_TRUE(thread_joined); ASSERT_EQ(request.status, CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED); @@ -4584,6 +4589,7 @@ TEST(daemon_application_final_cancel_drains_admitted_watcher_job) { ASSERT_TRUE(fixture_ready); ASSERT_TRUE(thread_started); ASSERT_TRUE(worker_started); + ASSERT_TRUE(fixture.fake.background_requests[0]); ASSERT_TRUE(job_active); ASSERT_EQ(watches_after_cancel, 0); ASSERT_TRUE(thread_joined); @@ -7292,8 +7298,9 @@ typedef struct { uint32_t context_length; } app_async_fixture_t; -static bool app_async_fixture_init(app_async_fixture_t *fixture, const char *label, - bool permanent) { +/* stored_config is optional; a test that needs auto_index passes its own. */ +static bool app_async_fixture_init_with_config(app_async_fixture_t *fixture, const char *label, + bool permanent, cbm_config_t *stored_config) { memset(fixture, 0, sizeof(*fixture)); app_fake_worker_context_init(&fixture->fake); fixture->ops = (cbm_daemon_application_worker_ops_t){ @@ -7304,7 +7311,8 @@ static bool app_async_fixture_init(app_async_fixture_t *fixture, const char *lab .log_path = app_fake_worker_log_path, .destroy = app_fake_worker_destroy, }; - cbm_daemon_application_config_t config = {.worker_ops = &fixture->ops}; + cbm_daemon_application_config_t config = {.config = stored_config, + .worker_ops = &fixture->ops}; fixture->application = cbm_daemon_application_new(&config); if (!fixture->application) { return false; @@ -7325,6 +7333,11 @@ static bool app_async_fixture_init(app_async_fixture_t *fixture, const char *lab &fixture->context_length); } +static bool app_async_fixture_init(app_async_fixture_t *fixture, const char *label, + bool permanent) { + return app_async_fixture_init_with_config(fixture, label, permanent, NULL); +} + static cbm_daemon_runtime_application_session_t *app_async_session(app_async_fixture_t *fixture, cbm_daemon_client_id_t id) { cbm_daemon_runtime_application_session_t *session = app_test_open(&fixture->callbacks, id); @@ -7702,6 +7715,78 @@ TEST(daemon_application_cut_short_sync_index_advice_reaches_next_call) { * the daemon's only live session is refused async (the daemon would stop and * cancel the job as soon as the command exits), while status stays allowed and * a long-lived MCP session on the same temporary daemon remains a valid host. */ +/* Automatic indexing (session auto-index, watcher re-index) marks its job + * _cbm_background so the worker leaves CPU headroom. The marker picks the + * worker count, never what is indexed, so an explicit index_repository for the + * same root still JOINS the automatic job already running for it, the contract + * the repo_path fold above restored, instead of being refused as an options + * conflict. The worker is held, so the join is observed while the job runs. */ +TEST(daemon_application_explicit_index_joins_running_auto_index_job) { + app_env_backup_t cache_environment; + bool cache_saved = app_env_backup_capture(&cache_environment, "CBM_CACHE_DIR"); + char cache[APP_TEST_PATH_CAP]; + (void)snprintf(cache, sizeof(cache), "%s/cbm-app-auto-join-cache-XXXXXX", cbm_tmpdir()); + bool cache_ready = + cache_saved && cbm_mkdtemp(cache) != NULL && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + cbm_config_t *stored_config = cache_ready ? cbm_config_open(cache) : NULL; + bool config_ready = stored_config && + cbm_config_set(stored_config, CBM_CONFIG_AUTO_INDEX, "true") == 0 && + cbm_config_set(stored_config, CBM_CONFIG_AUTO_WATCH, "false") == 0; + app_async_fixture_t fixture; + bool setup = config_ready && + app_async_fixture_init_with_config(&fixture, "auto-join", true, stored_config); + + cbm_daemon_runtime_application_session_t *automatic = + setup ? app_test_open(&fixture.callbacks, 7801) : NULL; + bool auto_started = automatic && + app_test_initialize_profile(&fixture.callbacks, automatic, fixture.root, + CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL) && + app_wait_for_atomic_int(&fixture.fake.starts, 1); + /* args_json is published by the args_captured release store; the + * background_requests slot is written after it, so read the args. */ + bool auto_marked = auto_started && + app_wait_for_atomic_bool(&fixture.fake.args_captured[0], true) && + cbm_mcp_get_bool_arg(fixture.fake.args_json[0], "_cbm_background"); + + cbm_daemon_runtime_application_session_t *explicit_session = + auto_marked ? app_async_session(&fixture, 7802) : NULL; + char *joined = + explicit_session ? app_async_call_now(&fixture, explicit_session, ",\"async\":true") : NULL; + int starts_while_running = atomic_load(&fixture.fake.starts); + size_t active_while_running = + setup ? cbm_daemon_application_active_jobs(fixture.application) : 0; + + if (explicit_session) { + fixture.callbacks.session_close(fixture.callbacks.context, explicit_session); + } + if (automatic) { + fixture.callbacks.session_cancel(fixture.callbacks.context, automatic); + fixture.callbacks.session_close(fixture.callbacks.context, automatic); + } + bool stopped = !setup || app_async_fixture_finish(&fixture); + cbm_config_close(stored_config); + bool cleaned = !cache_ready || th_rmtree(cache) == 0; + bool cache_restored = app_env_backup_restore(&cache_environment); + + ASSERT_TRUE(cache_ready); + ASSERT_TRUE(config_ready); + ASSERT_TRUE(setup); + ASSERT_TRUE(auto_started); + ASSERT_TRUE(auto_marked); + ASSERT_NOT_NULL(explicit_session); + ASSERT_NOT_NULL(joined); + ASSERT_FALSE(app_async_contains(joined, "different options")); + ASSERT_FALSE(app_async_contains(joined, "\"isError\":true")); + ASSERT_TRUE(app_async_contains(joined, "\"joined\":true")); + ASSERT_EQ(starts_while_running, 1); + ASSERT_EQ(active_while_running, 1); + ASSERT_TRUE(stopped); + ASSERT_TRUE(cleaned); + ASSERT_TRUE(cache_restored); + free(joined); + PASS(); +} + TEST(daemon_application_async_refused_for_lone_one_shot_client_of_temporary_daemon) { app_async_fixture_t fixture; bool setup = app_async_fixture_init(&fixture, "async-refuse", false); @@ -8031,6 +8116,7 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_async_and_status_validation_errors); RUN_TEST(daemon_application_cancelled_sync_index_reply_offers_async); RUN_TEST(daemon_application_cut_short_sync_index_advice_reaches_next_call); + RUN_TEST(daemon_application_explicit_index_joins_running_auto_index_job); RUN_TEST(daemon_application_async_refused_for_lone_one_shot_client_of_temporary_daemon); RUN_TEST(daemon_application_unnamed_status_finds_async_job_of_root_owner_issue2134); RUN_TEST(daemon_application_unnamed_status_keeps_path_derived_key_issue2134); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 40d0482c3..fe0359bab 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -2758,6 +2758,165 @@ TEST(elixir_channels_pubsub_and_handle_in) { PASS(); } +/* Elixir control flow is `call` nodes and `stab_clause` arms, not branch node + * TYPES, so the generic complexity walk scored every Elixir function 0 and the + * fingerprint was never computed at all — no similarity or duplicate detection. */ +TEST(elixir_complexity_and_fingerprint) { + CBMFileResult *r = extract("defmodule Cx do\n" + " def classify(x) do\n" + " case x do\n" + " 0 -> :zero\n" + " n when n > 0 -> :pos\n" + " _ -> :neg\n" + " end\n" + " end\n" + " def plain(a), do: a\n" + " def summarize(orders, currency) do\n" + " totals = Enum.map(orders, fn order -> order.amount * order.rate end)\n" + " count = length(totals)\n" + " sum = Enum.reduce(totals, 0, fn value, acc -> acc + value end)\n" + " average = if count > 0, do: sum / count, else: 0\n" + " label = String.upcase(currency) <> \" total\"\n" + " %{label: label, sum: sum, count: count, average: average}\n" + " end\n" + " def tally(items, unit) do\n" + " values = Enum.map(items, fn item -> item.price * item.qty end)\n" + " n = length(values)\n" + " total = Enum.reduce(values, 0, fn v, a -> a + v end)\n" + " mean = if n > 0, do: total / n, else: 0\n" + " tag = String.upcase(unit) <> \" total\"\n" + " %{tag: tag, total: total, n: n, mean: mean}\n" + " end\n" + "end\n", + CBM_LANG_ELIXIR, "t", "cx.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + CBMDefinition *cls = NULL; + CBMDefinition *plain = NULL; + CBMDefinition *summarize = NULL; + CBMDefinition *tally = NULL; + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].name, "classify") == 0) + cls = &r->defs.items[i]; + if (strcmp(r->defs.items[i].name, "plain") == 0) + plain = &r->defs.items[i]; + if (strcmp(r->defs.items[i].name, "summarize") == 0) + summarize = &r->defs.items[i]; + if (strcmp(r->defs.items[i].name, "tally") == 0) + tally = &r->defs.items[i]; + } + /* A body past the MinHash size gate gets a fingerprint (Elixir used to emit + * none at all); a one-expression body stays below the gate and gets none. */ + ASSERT_NOT_NULL(summarize); + ASSERT(summarize->fingerprint_k > 0); + ASSERT_NOT_NULL(summarize->fingerprint); + /* Duplicate detection is what the fingerprint exists for: the same body + * shape under other function and variable names gets the same fingerprint. */ + ASSERT_NOT_NULL(tally); + ASSERT_EQ(summarize->fingerprint_k, tally->fingerprint_k); + ASSERT_NOT_NULL(tally->fingerprint); + ASSERT(memcmp(summarize->fingerprint, tally->fingerprint, + (size_t)summarize->fingerprint_k * sizeof(summarize->fingerprint[0])) == 0); + ASSERT_NOT_NULL(cls); + /* The `case` itself + its 3 arms + the second arm's own `when` guard. */ + ASSERT_EQ(5, cls->complexity); + ASSERT(cls->cognitive > 0); + ASSERT(cls->lines > 0); + /* A straight-line body has no decisions but still gets a line count — a + * language must not become nonzero everywhere just by being measured. */ + ASSERT_NOT_NULL(plain); + ASSERT_EQ(0, plain->complexity); + ASSERT(plain->lines > 0); + ASSERT_EQ(0, plain->fingerprint_k); + cbm_free_result(r); + PASS(); +} + +/* The clauses of a multi-clause function fold into one Function node, so that + * node's metrics must cover every clause, not just the first: branch, cognitive + * and loop counts add up, loop_depth and max_access_depth take the deepest + * clause, every clause past the first is one more dispatch decision, and + * `lines` is the folded span. `single` repeats the second clause on its own as + * the per-clause reference: it is not folded, so it carries no dispatch charge. */ +TEST(elixir_folded_clause_metrics_cover_every_clause) { + CBMFileResult *r = extract("defmodule Route do\n" /* 1 */ + " def pick(0), do: Map.new()\n" /* 2 */ + " def pick(n) when n > 0 do\n" /* 3 */ + " for x <- [n], do: Map.new([{x, x}])\n" /* 4 */ + " end\n" /* 5 */ + " def pick(rows) do\n" /* 6 */ + " for row <- rows, do: (for x <- row, do: x)\n" /* 7 */ + " end\n" /* 8 */ + " def single(n) when n > 0 do\n" /* 9 */ + " for x <- [n], do: Map.new([{x, x}])\n" /* 10 */ + " end\n" /* 11 */ + "end\n", /* 12 */ + CBM_LANG_ELIXIR, "t", "route.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(1, count_defs_named(r, "Function", "pick")); + const CBMDefinition *one = elixir_first_def(r, "single"); + ASSERT_NOT_NULL(one); + /* `when` + `for`, each at nesting 0; one comprehension; Map.new is one dot */ + ASSERT_EQ(2, one->complexity); + ASSERT_EQ(2, one->cognitive); + ASSERT_EQ(1, one->loop_count); + ASSERT_EQ(1, one->loop_depth); + ASSERT_EQ(1, one->max_access_depth); + ASSERT_EQ(3, one->lines); + + const CBMDefinition *pick = elixir_first_def(r, "pick"); + ASSERT_NOT_NULL(pick); + ASSERT_EQ(2, (int)pick->start_line); + ASSERT_EQ(8, (int)pick->end_line); + /* clauses 0 + 2 + 2, plus 2 for the two clauses past the first */ + ASSERT_EQ(6, pick->complexity); + /* 0 + 2 + (1 + 2): the inner comprehension sits one level deep */ + ASSERT_EQ(5, pick->cognitive); + ASSERT_EQ(3, pick->loop_count); + /* the deepest clause, not the sum of the clauses (that would be 3) */ + ASSERT_EQ(2, pick->loop_depth); + /* two clauses with one dot each: the deepest is 1, the sum would be 2 */ + ASSERT_EQ(1, pick->max_access_depth); + ASSERT_EQ(7, pick->lines); + cbm_free_result(r); + PASS(); +} + +/* The Elixir complexity walk keeps its pending nodes on the growable stack + * cbm_compute_complexity uses. A fixed 4096-frame stack dropped the FIRST + * children of any wider body (they are pushed last-to-first), so the largest + * functions silently under-reported. This body is wider than that old cap. */ +enum { ELIXIR_WIDE_STMTS = 5000 }; + +TEST(elixir_complexity_wide_body_exceeds_old_stack_cap) { + static const char head[] = "defmodule Wide do\n def wide(x) do\n"; + static const char if_stmt[] = " if x, do: :a\n"; + static const char for_stmt[] = " for y <- x, do: y\n"; + static const char tail[] = " end\nend\n"; + size_t cap = sizeof(head) + (size_t)ELIXIR_WIDE_STMTS * sizeof(for_stmt) + sizeof(tail); + char *src = malloc(cap); + ASSERT_NOT_NULL(src); + size_t pos = (size_t)snprintf(src, cap, "%s", head); + for (int i = 0; i < ELIXIR_WIDE_STMTS; i++) { + pos += (size_t)snprintf(src + pos, cap - pos, "%s", (i % 2 == 0) ? if_stmt : for_stmt); + } + snprintf(src + pos, cap - pos, "%s", tail); + CBMFileResult *r = extract(src, CBM_LANG_ELIXIR, "t", "wide.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMDefinition *d = elixir_first_def(r, "wide"); + ASSERT_NOT_NULL(d); + /* every `if` and every `for` is one branching call at nesting depth 0 */ + ASSERT_EQ(ELIXIR_WIDE_STMTS, d->complexity); + ASSERT_EQ(ELIXIR_WIDE_STMTS, d->cognitive); + ASSERT_EQ(ELIXIR_WIDE_STMTS / 2, d->loop_count); + ASSERT_EQ(1, d->loop_depth); + cbm_free_result(r); + free(src); + PASS(); +} + /* --- Haskell --- */ TEST(haskell_function) { CBMFileResult *r = extract("add :: Int -> Int -> Int\nadd x y = x + y\n\nmultiply :: Int -> " @@ -14909,6 +15068,9 @@ SUITE(extraction) { RUN_TEST(extract_elixir_guarded_def_head_scope); RUN_TEST(extract_elixir_guarded_def_head_guard_usages); RUN_TEST(elixir_channels_pubsub_and_handle_in); + RUN_TEST(elixir_complexity_and_fingerprint); + RUN_TEST(elixir_folded_clause_metrics_cover_every_clause); + RUN_TEST(elixir_complexity_wide_body_exceeds_old_stack_cap); RUN_TEST(haskell_function); RUN_TEST(haskell_infix_definition_name); RUN_TEST(ocaml_function); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index c2c9803cc..cdc3c5541 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -7843,6 +7843,200 @@ TEST(tool_index_status_includes_git_metadata) { * TOOL HANDLERS WITH DATA * ══════════════════════════════════════════════════════════════════ */ +/* #2816: stored recursion must remain visible even when the CALLS graph has + * no self-edges. Removing metadata emission must fail these assertions. */ +TEST(tool_trace_path_recursion_metadata_issue2816) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "recursion-proj"; + cbm_mcp_server_set_project(srv, proj); + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/recursion"), CBM_STORE_OK); + const struct { + const char *name; + const char *props; + const char *self; + const char *recursive; + } cases[] = { + {"countdown", "{\"self_recursive\":true,\"recursive\":true}", "true", "true"}, + {"visit", "{\"self_recursive\":true,\"recursive\":true}", "true", "true"}, + {"mutual", "{\"self_recursive\":false,\"recursive\":true}", "false", "true"}, + {"leaf", "{\"self_recursive\":false,\"recursive\":false}", "false", "false"}, + {"legacy", "{}", "null", "null"}, + {"partial", "{\"self_recursive\":true}", "true", "null"}, + {"invalid", "{\"self_recursive\":\"true\",\"recursive\":1}", "null", "null"}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + char qn[128]; + snprintf(qn, sizeof(qn), "%s.%s", proj, cases[i].name); + cbm_node_t n = {.project = proj, + .label = i == 1 ? "Method" : "Function", + .name = cases[i].name, + .qualified_name = qn, + .file_path = "recursion.ts", + .start_line = 1, + .end_line = 5, + .properties_json = cases[i].props}; + ASSERT_GT(cbm_store_upsert_node(st, &n), 0); + for (int json = 0; json < 2; json++) { + for (int inbound = 0; inbound < 2; inbound++) { + char args[512]; + snprintf(args, sizeof(args), + "{\"project\":\"%s\",\"function_name\":\"%s\"," + "\"direction\":\"%s\",\"format\":\"%s\"}", + proj, cases[i].name, inbound ? "inbound" : "outbound", + json ? "json" : "tree"); + char *response = cbm_mcp_handle_tool(srv, "trace_path", args); + char *inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + char expected[128]; + snprintf(expected, sizeof(expected), + json ? "\"self_recursive\":%s" : "self_recursive: %s\n", cases[i].self); + ASSERT_NOT_NULL(strstr(inner, expected)); + snprintf(expected, sizeof(expected), json ? "\"recursive\":%s" : "recursive: %s\n", + cases[i].recursive); + ASSERT_NOT_NULL(strstr(inner, expected)); + ASSERT_NOT_NULL(strstr(inner, json ? "\"self_call_policy\":\"omitted\"" + : "self_call_policy: omitted\n")); + snprintf(expected, sizeof(expected), json ? "\"%s_total\":0" : "%s_total: 0\n", + inbound ? "callers" : "callees"); + ASSERT_NOT_NULL(strstr(inner, expected)); + free(inner); + free(response); + } + } + } + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_trace_path_recursion_union_and_edges_issue2816) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "recursion-union"; + cbm_mcp_server_set_project(srv, proj); + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/recursion-union"), CBM_STORE_OK); + cbm_node_t def = {.project = proj, + .label = "Function", + .name = "countdown", + .qualified_name = "recursion-union.impl.countdown", + .file_path = "impl.ts", + .start_line = 1, + .end_line = 10, + .properties_json = "{\"self_recursive\":false,\"recursive\":false}"}; + int64_t def_id = cbm_store_upsert_node(st, &def); + ASSERT_GT(def_id, 0); + cbm_node_t stub = def; + stub.qualified_name = "recursion-union.stub.countdown"; + stub.file_path = "stub.d.ts"; + stub.end_line = 1; + stub.properties_json = "{\"self_recursive\":true,\"recursive\":true}"; + ASSERT_GT(cbm_store_upsert_node(st, &stub), 0); + cbm_node_t leaf = def; + leaf.name = "leaf"; + leaf.qualified_name = "recursion-union.impl.leaf"; + leaf.properties_json = "{\"self_recursive\":true,\"recursive\":true}"; + int64_t leaf_id = cbm_store_upsert_node(st, &leaf); + ASSERT_GT(leaf_id, 0); + cbm_edge_t edge = {.project = proj, .source_id = def_id, .target_id = leaf_id, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &edge), 0); + const char *expected_flags[] = {"true", "null", "false", "true"}; + for (int state = 0; state < 4; state++) { + def.properties_json = state == 3 ? "{\"self_recursive\":true,\"recursive\":true}" + : "{\"self_recursive\":false,\"recursive\":false}"; + stub.properties_json = state == 0 ? "{\"self_recursive\":true,\"recursive\":true}" + : state == 2 ? "{\"self_recursive\":false,\"recursive\":false}" + : "{}"; + ASSERT_GT(cbm_store_upsert_node(st, &def), 0); + ASSERT_GT(cbm_store_upsert_node(st, &stub), 0); + for (int json = 0; json < 2; json++) { + char args[512]; + snprintf(args, sizeof(args), + "{\"project\":\"%s\",\"function_name\":\"countdown\"," + "\"direction\":\"outbound\",\"format\":\"%s\"}", + proj, json ? "json" : "tree"); + char *response = cbm_mcp_handle_tool(srv, "trace_path", args); + char *inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, json ? "\"callees_total\":1" : "callees_total: 1\n")); + ASSERT_NOT_NULL(strstr(inner, json ? "\"leaf\"" : ".leaf")); + char expected[128]; + snprintf(expected, sizeof(expected), + json ? "\"self_recursive\":%s" : "self_recursive: %s\n", + expected_flags[state]); + ASSERT_NOT_NULL(strstr(inner, expected)); + snprintf(expected, sizeof(expected), json ? "\"recursive\":%s" : "recursive: %s\n", + expected_flags[state]); + ASSERT_NOT_NULL(strstr(inner, expected)); + free(inner); + free(response); + } + } + /* Recursion is root metadata, but the omission policy concerns CALLS only. */ + char *response = + cbm_mcp_handle_tool(srv, "trace_path", + "{\"project\":\"recursion-union\",\"function_name\":\"countdown\"," + "\"edge_types\":[\"IMPORTS\"],\"format\":\"json\"}"); + char *inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"self_recursive\":true")); + ASSERT_NULL(strstr(inner, "self_call_policy")); + free(inner); + free(response); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_trace_path_recursion_survives_budget_floor_issue2816) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "recursion-budget"; + cbm_mcp_server_set_project(srv, proj); + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/recursion-budget"), CBM_STORE_OK); + char huge[4096]; + memset(huge, 'x', sizeof(huge) - 1); + huge[sizeof(huge) - 1] = '\0'; + cbm_node_t n = {.project = proj, + .label = "Function", + .name = "countdown", + .qualified_name = "recursion-budget.countdown", + .file_path = "recursion.ts", + .start_line = 1, + .end_line = 10, + .properties_json = "{\"self_recursive\":true,\"recursive\":true}"}; + int64_t root_id = cbm_store_upsert_node(st, &n); + ASSERT_GT(root_id, 0); + n.name = huge; + n.qualified_name = huge; + int64_t callee_id = cbm_store_upsert_node(st, &n); + ASSERT_GT(callee_id, 0); + cbm_edge_t edge = { + .project = proj, .source_id = root_id, .target_id = callee_id, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &edge), 0); + for (int json = 0; json < 2; json++) { + char args[512]; + snprintf(args, sizeof(args), + "{\"project\":\"%s\",\"function_name\":\"countdown\"," + "\"direction\":\"outbound\",\"format\":\"%s\",\"max_output_tokens\":128}", + proj, json ? "json" : "tree"); + char *response = cbm_mcp_handle_tool(srv, "trace_path", args); + char *inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "output_budget")); + ASSERT_NULL(strstr(inner, huge)); + ASSERT_NOT_NULL(strstr(inner, json ? "\"self_recursive\":true" : "self_recursive: true\n")); + ASSERT_NOT_NULL(strstr(inner, json ? "\"recursive\":true" : "recursive: true\n")); + ASSERT_NOT_NULL(strstr(inner, json ? "\"self_call_policy\":\"omitted\"" + : "self_call_policy: omitted\n")); + free(inner); + free(response); + } + cbm_mcp_server_free(srv); + PASS(); +} + TEST(tool_trace_call_path_not_found) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); @@ -8540,7 +8734,8 @@ TEST(tool_trace_cursor_survives_output_budget_increase) { .qualified_name = "trace-budget-cursor.hub", .file_path = "src/hub.c", .start_line = 1, - .end_line = 2}; + .end_line = 2, + .properties_json = "{\"self_recursive\":true,\"recursive\":true}"}; cbm_node_t short_node = {.project = project, .label = "Function", .name = "short_row", @@ -8580,6 +8775,9 @@ TEST(tool_trace_cursor_survives_output_budget_increase) { "\"format\":\"json\"}"); char *inner = extract_text_content(response); ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"self_recursive\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"recursive\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"self_call_policy\":\"omitted\"")); ASSERT_NOT_NULL(strstr(inner, "short_row")); yyjson_doc *doc = yyjson_read(inner, strlen(inner), 0); ASSERT_NOT_NULL(doc); @@ -8600,6 +8798,9 @@ TEST(tool_trace_cursor_survives_output_budget_increase) { response = cbm_mcp_handle_tool(srv, "trace_path", args); inner = extract_text_content(response); ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"self_recursive\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"recursive\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"self_call_policy\":\"omitted\"")); ASSERT_NULL(strstr(inner, "cursor_params_mismatch")); ASSERT_NULL(strstr(inner, long_qn)); doc = yyjson_read(inner, strlen(inner), 0); @@ -8619,6 +8820,9 @@ TEST(tool_trace_cursor_survives_output_budget_increase) { response = cbm_mcp_handle_tool(srv, "trace_path", args); inner = extract_text_content(response); ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"self_recursive\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"recursive\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"self_call_policy\":\"omitted\"")); ASSERT_NULL(strstr(inner, "cursor_params_mismatch")); ASSERT_NULL(strstr(inner, "short_row")); doc = yyjson_read(inner, strlen(inner), 0); @@ -24436,6 +24640,161 @@ TEST(autoindex_limit_guards_git_root_issue713) { PASS(); } +/* A servable project row is a committed index. user_version stays 0, so a + * later run cannot take the incremental no-op and skip recording workers. */ +static bool mcp_publish_committed_index(const char *root) { + char *project = root ? cbm_project_name_from_path(root) : NULL; + cbm_store_t *store = project ? cbm_store_open(project) : NULL; + bool published = store && cbm_store_upsert_project(store, project, root) == CBM_STORE_OK; + cbm_store_close(store); + free(project); + return published; +} + +#ifdef CBM_ENABLE_TEST_SEAMS +typedef struct { + const char *root; + bool published; +} mcp_committed_seed_t; + +/* maybe_auto_index returns before the thread when .db already + * exists. Publish the committed index from the count hook, which runs after + * that check and before autoindex_thread chooses a width. */ +static void mcp_autoindex_publish_committed(void *context) { + mcp_committed_seed_t *seed = context; + if (seed) { + seed->published = mcp_publish_committed_index(seed->root); + } +} +#endif + +/* First index, including one started automatically, uses every core. Headroom + * applies only once a servable project database already exists. + * automatic: in-process auto-index (initialize → autoindex_thread). + * Otherwise index_repository with _cbm_background, the daemon/watcher path. */ +static int mcp_assert_inprocess_worker_policy(bool committed, bool automatic) { + char cache[256]; + char repo[512]; + (void)snprintf(cache, sizeof(cache), "%s/cbm-worker-policy-%d%d-XXXXXX", cbm_tmpdir(), + committed ? 1 : 0, automatic ? 1 : 0); + bool cache_ready = cbm_mkdtemp(cache) != NULL; + (void)snprintf(repo, sizeof(repo), "%s/repo", cache); + char source[640]; + (void)snprintf(source, sizeof(source), "%s/main.py", repo); + bool repo_ready = cache_ready && th_mkdir_p(repo) == 0 && + th_write_file(source, "def background_index():\n return True\n") == 0; + + mcp_test_env_backup_t environment[] = { + {.name = "CBM_CACHE_DIR"}, + {.name = "CBM_WORKERS"}, + {.name = "CBM_INDEX_SINGLE_THREAD"}, + }; + bool environment_saved = true; + for (size_t i = 0; i < sizeof(environment) / sizeof(environment[0]); i++) { + const char *value = getenv(environment[i].name); + environment[i].present = value != NULL; + environment[i].value = value ? strdup(value) : NULL; + environment_saved = environment_saved && (!value || environment[i].value); + } + bool environment_ready = environment_saved && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0 && + cbm_unsetenv("CBM_WORKERS") == 0 && + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD") == 0; + + bool seeded = !committed; + char old_cwd[CBM_SZ_4K] = {0}; + bool cwd_ready = true; + cbm_config_t *config = NULL; + bool config_ready = true; + cbm_mcp_server_t *server = NULL; + char *response = NULL; + if (automatic) { + cwd_ready = repo_ready && environment_ready && cbm_getcwd(old_cwd, sizeof(old_cwd)) && + cbm_chdir(repo) == 0; + config = cwd_ready ? cbm_config_open(cache) : NULL; + config_ready = config && cbm_config_set(config, CBM_CONFIG_AUTO_INDEX, "true") == 0 && + cbm_config_set(config, CBM_CONFIG_AUTO_WATCH, "false") == 0; + cbm_pipeline_worker_count_test_reset(); + server = config_ready ? cbm_mcp_server_new(NULL) : NULL; + if (server) { + cbm_mcp_server_set_config(server, config); +#ifdef CBM_ENABLE_TEST_SEAMS + mcp_committed_seed_t seed = {.root = repo, .published = !committed}; + if (committed) { + cbm_mcp_server_set_auto_index_count_test_hook( + server, mcp_autoindex_publish_committed, &seed); + } +#endif + response = cbm_mcp_server_handle( + server, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"); +#ifdef CBM_ENABLE_TEST_SEAMS + seeded = seed.published; +#endif + cbm_mcp_server_free(server); /* joins the automatic index thread */ + } + } else { + if (committed && repo_ready && environment_ready) { + seeded = mcp_publish_committed_index(repo); + } + cbm_pipeline_worker_count_test_reset(); + server = repo_ready && environment_ready && seeded ? cbm_mcp_server_new(NULL) : NULL; + if (server) { + char repo_json[512]; + char args[CBM_SZ_1K]; + (void)snprintf(repo_json, sizeof(repo_json), "%s", repo); + cbm_normalize_path_sep(repo_json); + (void)snprintf(args, sizeof(args), "{\"_cbm_background\":true,\"repo_path\":\"%s\"}", + repo_json); + response = cbm_mcp_handle_tool(server, "index_repository", args); + cbm_mcp_server_free(server); + } + } + int selected_workers = cbm_pipeline_worker_count_test_last(); + bool server_ready = server != NULL; + bool response_ready = response != NULL; + + free(response); + cbm_config_close(config); + if (automatic && cwd_ready) { + (void)cbm_chdir(old_cwd); + } + /* Capture while CBM_WORKERS is still unset. Restoring first makes the + * expected count follow the lane override instead of this policy. */ + int expected = committed ? cbm_pipeline_background_worker_width(cbm_default_worker_count(true), + cbm_default_worker_count(false)) + : cbm_default_worker_count(true); + mcp_test_restore_env(environment, sizeof(environment) / sizeof(environment[0])); + bool cleaned = !cache_ready || th_rmtree(cache) == 0; + + ASSERT_TRUE(cache_ready); + ASSERT_TRUE(repo_ready); + ASSERT_TRUE(environment_saved); + ASSERT_TRUE(environment_ready); + ASSERT_TRUE(cwd_ready); + ASSERT_TRUE(config_ready); + ASSERT_TRUE(seeded); + ASSERT_TRUE(server_ready); + ASSERT_TRUE(response_ready); + ASSERT_EQ(selected_workers, expected); + ASSERT_TRUE(cleaned); + PASS(); +} + +TEST(mcp_auto_index_in_process_fresh_project_uses_full_width) { + return mcp_assert_inprocess_worker_policy(false, true); +} + +TEST(mcp_auto_index_in_process_existing_index_keeps_headroom) { + return mcp_assert_inprocess_worker_policy(true, true); +} + +TEST(mcp_background_flag_fresh_project_uses_full_width) { + return mcp_assert_inprocess_worker_policy(false, false); +} + +TEST(mcp_background_flag_existing_index_keeps_headroom) { + return mcp_assert_inprocess_worker_policy(true, false); +} + /* ══════════════════════════════════════════════════════════════════ * #853 — auto_watch=false must ALSO gate the SUPERVISED fresh-index * watcher registration (keystone × #849 merge interaction) @@ -26542,6 +26901,9 @@ SUITE(mcp) { RUN_TEST(tool_index_status_includes_git_metadata); /* Tool handlers with validation */ + RUN_TEST(tool_trace_path_recursion_metadata_issue2816); + RUN_TEST(tool_trace_path_recursion_union_and_edges_issue2816); + RUN_TEST(tool_trace_path_recursion_survives_budget_floor_issue2816); RUN_TEST(tool_trace_call_path_not_found); RUN_TEST(tool_call_invalid_project_name_leaves_no_corrupt_litter_issue1425); RUN_TEST(tool_trace_missing_function_name); @@ -26787,6 +27149,10 @@ 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); + 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); + RUN_TEST(mcp_background_flag_existing_index_keeps_headroom); RUN_TEST(search_code_context_window_is_bounded); RUN_TEST(search_code_context_extreme_values_answer_normally); RUN_TEST(tool_query_graph_max_rows_above_ceiling_is_capped); diff --git a/tests/test_parallel.c b/tests/test_parallel.c index b73946cf3..4b67ea200 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -3026,6 +3026,311 @@ TEST(parallel_csharp_same_name_class_binds_by_namespace) { PASS(); } +/* #2120 residual, reported on a real repo: step 8b binds only namespace- + * qualified TOP-LEVEL types. A qualified `A.B` whose qualifier A is a TYPE + * (nested-type constructor `new Command.Result(...)`, chained-constructor + * receiver `new Grain.Handler(dep).Handle(...)`) and a top-level type with NO + * namespace declaration (global namespace, e.g. a `[Inject]`-consumed service) + * both fell through to the short-name fallback, where the prefix score against + * a path-derived QN is 0 for every variant — registration order picked the + * target. Decoy files are registered FIRST so the old registry-order behavior + * binds every check to the decoy and the test fails. */ +TEST(parallel_csharp_nested_type_and_global_namespace_binding) { + static const char decoy_src[] = + "namespace Acme.Layouts.Cqrs;\n" + "public class GrainCommand {\n" + " public class Handler {\n" + " public int Handle() => 9;\n" + " }\n" + " public class Result {\n" + " public Result(bool ok) { }\n" + " public int Code() => 9;\n" + " }\n" + "}\n"; + static const char partner_src[] = + "namespace Acme.Partner.Cqrs;\n" + "public class GrainCommand {\n" + " public class Handler {\n" + " private readonly int _v;\n" + " public Handler(int v) { _v = v; }\n" + " public int Handle() => _v;\n" + " }\n" + " public class Result {\n" + " public Result(bool ok) { }\n" + " public int Code() => 1;\n" + " }\n" + "}\n"; + static const char backoffice_src[] = + "namespace Acme.BackOffice.Cqrs;\n" + "public class GrainCommand {\n" + " public class Result {\n" + " public Result(bool ok) { }\n" + " public int Code() => 2;\n" + " }\n" + "}\n"; + static const char nested_user_src[] = + "using Acme.Partner.Cqrs;\n" + "namespace Acme.App;\n" + "public class NestedUser {\n" + " public int Run() {\n" + " var r = new GrainCommand.Result(true);\n" + " return r.Code();\n" + " }\n" + "}\n"; + static const char chained_user_src[] = + "using Acme.Partner.Cqrs;\n" + "namespace Acme.App;\n" + "public class ChainedUser {\n" + " public int Run() {\n" + " return new GrainCommand.Handler(3).Handle();\n" + " }\n" + "}\n"; + static const char other_update_src[] = + "namespace Acme.Other.Web.Services;\n" + "public class UpdateService {\n" + " public string Check() => \"other\";\n" + "}\n"; + /* No namespace declaration: the global namespace. */ + static const char global_update_src[] = + "public class UpdateService {\n" + " public string Check() => \"global\";\n" + "}\n"; + static const char boot_src[] = + "namespace Acme.App;\n" + "public class Boot {\n" + " public string Boot1() {\n" + " var u = new UpdateService();\n" + " return u.Check();\n" + " }\n" + "}\n"; + /* The lookup-order probes: the SAME simple name is importable from + * Acme.Other.Web.Services AND declared in the global namespace. C# looks + * a simple name up namespace by namespace, innermost outward; at each one + * it checks that namespace's members, then the usings of the namespace + * declaration enclosing the reference. The global namespace comes last, + * and its "declaration" is the compilation unit, so: + * - a compilation-unit `using` (ImportUser) is consulted only AFTER the + * global namespace's members: the global UpdateService wins; + * - a `using` inside the namespace body (NsUsingUser) is consulted + * before any outer namespace: the imported UpdateService wins. */ + static const char import_user_src[] = + "using Acme.Other.Web.Services;\n" + "namespace Acme.App;\n" + "public class ImportUser {\n" + " public string Run2() {\n" + " var u = new UpdateService();\n" + " return u.Check();\n" + " }\n" + "}\n"; + static const char ns_using_user_src[] = + "namespace Acme.App {\n" + " using Acme.Other.Web.Services;\n" + " public class NsUsingUser {\n" + " public string Run3() {\n" + " var u = new UpdateService();\n" + " return u.Check();\n" + " }\n" + " }\n" + "}\n"; + const char *project = "cbm_cs_nested"; + static const char *rels[] = { + "src/Decoy/Layouts.cs", + "src/Decoy/OtherUpdate.cs", + "src/Partner/Cqrs/GrainCommand.cs", + "src/BackOffice/Cqrs/GrainCommand.cs", + "src/Globals/UpdateService.cs", + "src/App/NestedUser.cs", + "src/App/ChainedUser.cs", + "src/App/Boot.cs", + "src/App/ImportUser.cs", + "src/App/NsUsingUser.cs", + }; + const char *srcs[] = {decoy_src, other_update_src, partner_src, + backoffice_src, global_update_src, nested_user_src, + chained_user_src, boot_src, import_user_src, + ns_using_user_src}; + enum { N_FILES = 10, N_CHECKS = 12 }; + + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_par_cs_nested_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("mkdtemp failed"); + } + char paths[N_FILES][512]; + cbm_file_info_t files[N_FILES] = {0}; + for (int i = 0; i < N_FILES; i++) { + snprintf(paths[i], sizeof(paths[i]), "%s/%s", tmpdir, rels[i]); + if (th_write_file(paths[i], srcs[i]) != 0) { + th_rmtree(tmpdir); + FAIL("failed to write C# nested-type fixture"); + } + files[i].path = paths[i]; + files[i].rel_path = (char *)rels[i]; + files[i].language = CBM_LANG_CSHARP; + } + + cbm_gbuf_t *graphs[2] = {run_sequential_with_lsp_cross(project, tmpdir, files, N_FILES), NULL}; + g_harness_cs_registry = true; + graphs[1] = run_parallel(project, tmpdir, files, N_FILES, 2); + g_harness_cs_registry = false; + ASSERT_NOT_NULL(graphs[0]); + ASSERT_NOT_NULL(graphs[1]); + + const char *pf_code = "cbm_cs_nested.src.Partner.Cqrs.GrainCommand.GrainCommand." + "Result.Code"; + const char *pf_handle = "cbm_cs_nested.src.Partner.Cqrs.GrainCommand.GrainCommand." + "Handler.Handle"; + const char *bo_code = "cbm_cs_nested.src.BackOffice.Cqrs.GrainCommand.GrainCommand." + "Result.Code"; + const char *decoy_code = "cbm_cs_nested.src.Decoy.Layouts.GrainCommand.GrainCommand." + "Result.Code"; + const char *decoy_handle = "cbm_cs_nested.src.Decoy.Layouts.GrainCommand.GrainCommand." + "Handler.Handle"; + const char *global_check = "cbm_cs_nested.src.Globals.UpdateService.UpdateService.Check"; + const char *other_check = "cbm_cs_nested.src.Decoy.OtherUpdate.UpdateService.Check"; + bool ok[2][N_CHECKS]; + bool all = true; + for (int g = 0; g < 2; g++) { + cbm_gbuf_t *gb = graphs[g]; + ok[g][0] = has_edge_from_callable_to_qn(gb, "NestedUser.Run", pf_code, "CALLS"); + ok[g][1] = !has_edge_from_callable_to_qn(gb, "NestedUser.Run", bo_code, "CALLS") && + !has_edge_from_callable_to_qn(gb, "NestedUser.Run", decoy_code, "CALLS"); + /* The ctor EDGE itself joins through the pipeline's textual carrier and + * is out of scope here (see PR notes); what must hold is that the + * nested type RESOLUTION is right, proven by the Code()/Handle() edges + * above, and that no ctor edge binds to a variant the caller cannot + * see: neither the BackOffice nor the registry-first decoy. */ + ok[g][2] = !has_edge_from_callable_to_qn( + gb, "NestedUser.Run", + "cbm_cs_nested.src.BackOffice.Cqrs.GrainCommand.GrainCommand.Result", + "CALLS") && + !has_edge_from_callable_to_qn( + gb, "NestedUser.Run", + "cbm_cs_nested.src.BackOffice.Cqrs.GrainCommand.GrainCommand." + "Result.Result", + "CALLS"); + ok[g][3] = !has_edge_from_callable_to_qn(gb, "NestedUser.Run", + "cbm_cs_nested.src.Decoy.Layouts.GrainCommand." + "GrainCommand.Result.Result", + "CALLS"); + ok[g][4] = has_edge_from_callable_to_qn(gb, "ChainedUser.Run", pf_handle, "CALLS"); + ok[g][5] = !has_edge_from_callable_to_qn(gb, "ChainedUser.Run", decoy_handle, "CALLS"); + ok[g][6] = has_edge_from_callable_to_qn(gb, "Boot.Boot1", global_check, "CALLS"); + ok[g][7] = !has_edge_from_callable_to_qn(gb, "Boot.Boot1", other_check, "CALLS"); + ok[g][8] = has_edge_from_callable_to_qn(gb, "ImportUser.Run2", global_check, "CALLS"); + ok[g][9] = !has_edge_from_callable_to_qn(gb, "ImportUser.Run2", other_check, "CALLS"); + ok[g][10] = has_edge_from_callable_to_qn(gb, "NsUsingUser.Run3", other_check, "CALLS"); + ok[g][11] = !has_edge_from_callable_to_qn(gb, "NsUsingUser.Run3", global_check, "CALLS"); + for (int k = 0; k < N_CHECKS; k++) { + /* The ctor-edge checks 2/3 gate the parallel path only. In the + * sequential path the `new GrainCommand.Result(true)` edge is + * still drawn by the textual ctor carrier, not by the resolved + * type (the out-of-scope carrier join above); on current main it + * lands on the BackOffice Result already with the nested-type + * resolution alone. The diagnostic below still prints both. */ + if (g == 0 && (k == 2 || k == 3)) + continue; + all = all && ok[g][k]; + } + } + if (!all) { + for (int g = 0; g < 2; g++) { + printf(" C# nested/global diagnostic %s: nested_code=%d/%d nested_ctor=%d/%d " + "chained=%d/%d global_ns=%d/%d cu_using=%d/%d ns_using=%d/%d\n", + g == 0 ? "sequential" : "parallel", ok[g][0], ok[g][1], ok[g][2], ok[g][3], + ok[g][4], ok[g][5], ok[g][6], ok[g][7], ok[g][8], ok[g][9], ok[g][10], + ok[g][11]); + } + } + cbm_gbuf_free(graphs[0]); + cbm_gbuf_free(graphs[1]); + th_rmtree(tmpdir); + ASSERT_TRUE(all); + PASS(); +} + +/* A type extracted with no namespace is global only if its file parsed + * cleanly. The vendored grammar does not parse a `ref partial struct` with + * members (reference assemblies are full of them), and error recovery + * dissolves the namespace around it, so the static class after it surfaces + * with no namespace. Read as global, it outranked the compilation-unit + * `using` and took every `Gadget.Spin()` call from the real, visible type; + * on dotnet/runtime that moved 5,313 CALLS from implementation files into + * reference assemblies. The damaged file is registered FIRST so neither the + * old registry order nor the global rank can explain a pass. */ +TEST(parallel_csharp_parse_damaged_namespace_is_not_global) { + static const char damaged_src[] = "namespace Acme.Lib\n" + "{\n" + " public ref partial struct Handler\n" + " {\n" + " public void Append(string value) { }\n" + " }\n" + " public static partial class Gadget {\n" + " public static int Spin() => 9;\n" + " }\n" + "}\n"; + static const char real_src[] = "namespace Acme.Lib;\n" + "public static class Gadget {\n" + " public static int Spin() => 1;\n" + "}\n"; + static const char user_src[] = "using Acme.Lib;\n" + "namespace Acme.App;\n" + "public class GadgetUser {\n" + " public int Run() {\n" + " return Gadget.Spin();\n" + " }\n" + "}\n"; + const char *project = "cbm_cs_damaged"; + static const char *rels[] = {"src/Ref/Lib.cs", "src/Lib/Gadget.cs", "src/App/GadgetUser.cs"}; + const char *srcs[] = {damaged_src, real_src, user_src}; + enum { N_FILES = 3 }; + + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_par_cs_damaged_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("mkdtemp failed"); + } + char paths[N_FILES][512]; + cbm_file_info_t files[N_FILES] = {0}; + for (int i = 0; i < N_FILES; i++) { + snprintf(paths[i], sizeof(paths[i]), "%s/%s", tmpdir, rels[i]); + if (th_write_file(paths[i], srcs[i]) != 0) { + th_rmtree(tmpdir); + FAIL("failed to write C# damaged-namespace fixture"); + } + files[i].path = paths[i]; + files[i].rel_path = (char *)rels[i]; + files[i].language = CBM_LANG_CSHARP; + } + + cbm_gbuf_t *graphs[2] = {run_sequential_with_lsp_cross(project, tmpdir, files, N_FILES), NULL}; + g_harness_cs_registry = true; + graphs[1] = run_parallel(project, tmpdir, files, N_FILES, 2); + g_harness_cs_registry = false; + ASSERT_NOT_NULL(graphs[0]); + ASSERT_NOT_NULL(graphs[1]); + + const char *real_spin = "cbm_cs_damaged.src.Lib.Gadget.Gadget.Spin"; + const char *damaged_spin = "cbm_cs_damaged.src.Ref.Lib.Gadget.Spin"; + bool ok[2][2]; + for (int g = 0; g < 2; g++) { + ok[g][0] = has_edge_from_callable_to_qn(graphs[g], "GadgetUser.Run", real_spin, "CALLS"); + ok[g][1] = !has_edge_from_callable_to_qn(graphs[g], "GadgetUser.Run", damaged_spin, + "CALLS"); + } + bool all = ok[0][0] && ok[0][1] && ok[1][0] && ok[1][1]; + if (!all) { + printf(" C# damaged-namespace diagnostic: sequential real=%d no_damaged=%d " + "parallel real=%d no_damaged=%d\n", + ok[0][0], ok[0][1], ok[1][0], ok[1][1]); + } + cbm_gbuf_free(graphs[0]); + cbm_gbuf_free(graphs[1]); + th_rmtree(tmpdir); + ASSERT_TRUE(all); + PASS(); +} + /* External Kotlin protocol targets must not borrow a project method merely * because the final Class.method segments agree. Raw assertions prove that the * iterator/destructuring carriers and their stdlib semantic rows really exist; @@ -5694,6 +5999,8 @@ SUITE(parallel) { RUN_TEST(parallel_typescript_import_namespace_exact_parity); RUN_TEST(parallel_tsx_import_namespace_exact_parity); RUN_TEST(parallel_csharp_same_name_class_binds_by_namespace); + RUN_TEST(parallel_csharp_nested_type_and_global_namespace_binding); + RUN_TEST(parallel_csharp_parse_damaged_namespace_is_not_global); RUN_TEST(parallel_kotlin_external_protocol_does_not_use_project_class_method_tail); RUN_TEST(parallel_kotlin_nonbinary_operator_carriers_reach_graph); RUN_TEST(parallel_rust_cross_crate_worker_receives_workspace_manifest); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 4ad6d513c..44f642e2b 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -13,6 +13,7 @@ #include "lsp/c_lsp.h" #include "lsp/py_lsp.h" #include "pipeline/lsp_surface.h" +#include "pipeline/worker_pool.h" #include "pipeline/pass_lsp_cross.h" #include "pipeline/pipeline_internal.h" #include "pipeline/helm_template.h" @@ -122,6 +123,269 @@ TEST(pipeline_create_free) { PASS(); } +TEST(pipeline_background_worker_policy_preserves_overrides) { + const char *old_workers = getenv("CBM_WORKERS"); + char *saved_workers = old_workers ? strdup(old_workers) : NULL; + const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + char *saved_single = old_single ? strdup(old_single) : NULL; + cbm_unsetenv("CBM_WORKERS"); + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + + cbm_pipeline_t *foreground = cbm_pipeline_new("/some/path", NULL, CBM_MODE_FULL); + cbm_pipeline_t *background = cbm_pipeline_new("/some/path", NULL, CBM_MODE_FULL); + ASSERT_NOT_NULL(foreground); + ASSERT_NOT_NULL(background); + cbm_pipeline_set_background(background, true); + + int foreground_default = cbm_pipeline_worker_count(foreground); + int background_default = cbm_pipeline_worker_count(background); + bool defaults_match = foreground_default == cbm_default_worker_count(true) && + background_default == cbm_pipeline_background_worker_width( + cbm_default_worker_count(true), + cbm_default_worker_count(false)) && + foreground_default >= 1 && background_default >= 1 && + (foreground_default < 2 || background_default >= 2); + + /* A pinned single worker leaves no parallel route to preserve. */ + cbm_setenv("CBM_WORKERS", "1", 1); + bool env_override = + cbm_pipeline_worker_count(foreground) == 1 && cbm_pipeline_worker_count(background) == 1; + cbm_setenv("CBM_WORKERS", "3", 1); + env_override = env_override && cbm_pipeline_worker_count(foreground) == 3 && + cbm_pipeline_worker_count(background) == 3; + + cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); + bool recovery_override = cbm_pipeline_worker_count(foreground) == 1 && + cbm_pipeline_worker_count(background) == 1; + + cbm_pipeline_free(foreground); + cbm_pipeline_free(background); + saved_workers ? cbm_setenv("CBM_WORKERS", saved_workers, 1) : cbm_unsetenv("CBM_WORKERS"); + saved_single ? cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1) + : cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + free(saved_workers); + free(saved_single); + + ASSERT_TRUE(defaults_match); + ASSERT_TRUE(env_override); + ASSERT_TRUE(recovery_override); + PASS(); +} + +/* An automatic refresh keeps CPU headroom but never falls to one worker, the + * sequential route, while the foreground width could run in parallel. Each + * host shape is given as the two widths cbm_default_worker_count() derives + * from it (foreground: every core; headroom: performance cores minus one, at + * least one), so the verdict does not depend on the machine running it. */ +TEST(pipeline_background_worker_width_keeps_parallel_route) { + /* One core: foreground 1, headroom 1. Nothing to preserve. */ + ASSERT_EQ(cbm_pipeline_background_worker_width(1, 1), 1); + /* Two performance cores: headroom alone would be 1. */ + ASSERT_EQ(cbm_pipeline_background_worker_width(2, 1), 2); + /* Two performance cores beside efficiency cores: same floor. */ + ASSERT_EQ(cbm_pipeline_background_worker_width(8, 1), 2); + /* Larger hosts keep their headroom unchanged. */ + ASSERT_EQ(cbm_pipeline_background_worker_width(3, 2), 2); + ASSERT_EQ(cbm_pipeline_background_worker_width(4, 3), 3); + ASSERT_EQ(cbm_pipeline_background_worker_width(18, 5), 5); + PASS(); +} + +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS +enum { SURFACE_BUDGET_FILES = 5 }; + +typedef struct { + atomic_int expected; + atomic_int arrivals; + atomic_int live; + atomic_int peak; + atomic_bool released; + atomic_int visits[SURFACE_BUDGET_FILES]; + int helpers; +} surface_budget_probe_t; + +static void surface_budget_spawned(int count, int helpers, void *arg) { + surface_budget_probe_t *probe = arg; + probe->helpers = helpers; + int participants = helpers + 1; + atomic_store(&probe->expected, count < participants ? count : participants); +} + +static void surface_budget_row(int index, bool entering, void *arg) { + surface_budget_probe_t *probe = arg; + if (!entering) { + atomic_fetch_sub(&probe->live, 1); + return; + } + atomic_fetch_add(&probe->visits[index], 1); + int live = atomic_fetch_add(&probe->live, 1) + 1; + int peak = atomic_load(&probe->peak); + while (peak < live && !atomic_compare_exchange_weak(&probe->peak, &peak, live)) {} + atomic_fetch_add(&probe->arrivals, 1); + /* No timing window: hold the first wave until every possible participant + * has entered. The post-spawn observation accounts for partial thread + * creation and serial fallback, so neither can strand this gate. The test + * runner's watchdog bounds a broken dispatch without making time a verdict. */ + while (!atomic_load(&probe->released)) { + int expected = atomic_load(&probe->expected); + if (expected > 0 && atomic_load(&probe->arrivals) >= expected) { + atomic_store(&probe->released, true); + } + } +} + +TEST(pipeline_surface_respects_total_worker_budget) { + CBMFileResult results[SURFACE_BUDGET_FILES] = {0}; + CBMFileResult *cache[SURFACE_BUDGET_FILES]; + cbm_file_info_t files[SURFACE_BUDGET_FILES] = { + {.rel_path = "a.go", .language = CBM_LANG_GO}, + {.rel_path = "b.go", .language = CBM_LANG_GO}, + {.rel_path = "c.go", .language = CBM_LANG_GO}, + {.rel_path = "d.go", .language = CBM_LANG_GO}, + {.rel_path = "e.go", .language = CBM_LANG_GO}, + }; + surface_budget_probe_t probe = {0}; + for (int i = 0; i < SURFACE_BUDGET_FILES; i++) { + cache[i] = &results[i]; + atomic_init(&probe.visits[i], 0); + } + cache[1] = NULL; /* The skipped row must not change stable file ordering. */ + atomic_init(&probe.expected, 0); + atomic_init(&probe.arrivals, 0); + atomic_init(&probe.live, 0); + atomic_init(&probe.peak, 0); + atomic_init(&probe.released, false); + const char *old_workers = getenv("CBM_WORKERS"); + char *saved_workers = old_workers ? strdup(old_workers) : NULL; + const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + char *saved_single = old_single ? strdup(old_single) : NULL; + cbm_setenv("CBM_WORKERS", "2", 1); + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + cbm_pipeline_t *pipeline = cbm_pipeline_new("/some/path", NULL, CBM_MODE_FULL); + cbm_pipeline_set_background(pipeline, true); + cbm_pipeline_ctx_t ctx = {.pipeline = pipeline}; + cbm_lsp_surface_row_t *rows = NULL; + int count = 0; + cbm_parallel_test_set_spawn_observer(surface_budget_spawned, &probe); + cbm_lsp_surface_test_set_row_observer(surface_budget_row, &probe); + int rc = cbm_lsp_surface_build_rows(&ctx, "budget", cache, files, SURFACE_BUDGET_FILES, NULL, + NULL, &rows, &count); + int parallel_peak = atomic_load(&probe.peak); + int parallel_helpers = probe.helpers; + atomic_store(&probe.expected, 0); + atomic_store(&probe.arrivals, 0); + atomic_store(&probe.peak, 0); + atomic_store(&probe.released, false); + cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); + cbm_lsp_surface_row_t *serial_rows = NULL; + int serial_count = 0; + int serial_rc = cbm_lsp_surface_build_rows(&ctx, "budget", cache, files, SURFACE_BUDGET_FILES, + NULL, NULL, &serial_rows, &serial_count); + cbm_lsp_surface_test_set_row_observer(NULL, NULL); + cbm_parallel_test_set_spawn_observer(NULL, NULL); + bool same = rc == 0 && serial_rc == 0 && rows && serial_rows && + count == SURFACE_BUDGET_FILES - 1 && count == serial_count; + for (int i = 0; same && i < count; i++) { + same = strcmp(rows[i].rel_path, files[i < 1 ? i : i + 1].rel_path) == 0 && + strcmp(rows[i].defs_json, serial_rows[i].defs_json) == 0 && + strcmp(rows[i].surface_sha, serial_rows[i].surface_sha) == 0; + } + cbm_store_free_lsp_surfaces(rows, count); + cbm_store_free_lsp_surfaces(serial_rows, serial_count); + cbm_pipeline_free(pipeline); + saved_workers ? cbm_setenv("CBM_WORKERS", saved_workers, 1) : cbm_unsetenv("CBM_WORKERS"); + saved_single ? cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1) + : cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + free(saved_workers); + free(saved_single); + + ASSERT_EQ(rc, 0); + ASSERT_EQ(serial_rc, 0); + ASSERT_TRUE(same); + ASSERT_GTE(parallel_helpers, 1); /* A serial setup is not a concurrency repro. */ + ASSERT_EQ(probe.helpers, 0); + ASSERT_EQ(atomic_load(&probe.live), 0); + for (int i = 0; i < SURFACE_BUDGET_FILES; i++) { + ASSERT_EQ(atomic_load(&probe.visits[i]), 2); + } + ASSERT_EQ(atomic_load(&probe.peak), 1); + ASSERT_EQ(parallel_peak, 2); + PASS(); +} +#endif + +TEST(pipeline_background_policy_preserves_index_results) { + ASSERT_EQ(setup_test_repo(), 0); + /* 64 fillers plus setup_test_repo's three files exceed MIN_FILES_FOR_PARALLEL + * (50). Below that threshold both policies take the sequential path, so the + * comparison stays green even with the background policy removed. */ + for (int i = 0; i < 64; i++) { + char name[32]; + char body[96]; + (void)snprintf(name, sizeof(name), "pad%02d.go", i); + (void)snprintf(body, sizeof(body), "package main\n\nfunc Pad%02d() int {\n\treturn %d\n}\n", + i, i); + if (th_write_file(TH_PATH(g_tmpdir, name), body) != 0) { + teardown_test_repo(); + FAIL("failed to pad fixture past MIN_FILES_FOR_PARALLEL"); + } + } + + /* An inherited CBM_WORKERS pin makes both runs use the same count. Drop it + * so this compares an all-core index against a headroom index. */ + const char *old_workers = getenv("CBM_WORKERS"); + char *saved_workers = old_workers ? strdup(old_workers) : NULL; + const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + char *saved_single = old_single ? strdup(old_single) : NULL; + if ((old_workers && !saved_workers) || (old_single && !saved_single)) { + free(saved_workers); + free(saved_single); + teardown_test_repo(); + FAIL("failed to save worker env"); + } + cbm_unsetenv("CBM_WORKERS"); + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + + char foreground_db[512]; + char background_db[512]; + (void)snprintf(foreground_db, sizeof(foreground_db), "%s/foreground.db", g_tmpdir); + (void)snprintf(background_db, sizeof(background_db), "%s/background.db", g_tmpdir); + + cbm_pipeline_t *foreground = cbm_pipeline_new(g_tmpdir, foreground_db, CBM_MODE_FULL); + cbm_pipeline_t *background = cbm_pipeline_new(g_tmpdir, background_db, CBM_MODE_FULL); + int foreground_rc = -1; + int background_rc = -1; + int foreground_nodes = -1; + int foreground_edges = -1; + int background_nodes = -1; + int background_edges = -1; + bool created = foreground && background; + if (created) { + cbm_pipeline_set_background(background, true); + foreground_rc = cbm_pipeline_run(foreground); + background_rc = cbm_pipeline_run(background); + cbm_pipeline_get_committed_counts(foreground, &foreground_nodes, &foreground_edges); + cbm_pipeline_get_committed_counts(background, &background_nodes, &background_edges); + } + + cbm_pipeline_free(foreground); + cbm_pipeline_free(background); + teardown_test_repo(); + saved_workers ? cbm_setenv("CBM_WORKERS", saved_workers, 1) : cbm_unsetenv("CBM_WORKERS"); + saved_single ? cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1) + : cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + free(saved_workers); + free(saved_single); + + ASSERT_TRUE(created); + ASSERT_EQ(foreground_rc, 0); + ASSERT_EQ(background_rc, 0); + ASSERT_GT(foreground_nodes, 0); + ASSERT_EQ(background_nodes, foreground_nodes); + ASSERT_EQ(background_edges, foreground_edges); + PASS(); +} + TEST(pipeline_null_repo) { cbm_pipeline_t *p = cbm_pipeline_new(NULL, NULL, CBM_MODE_FULL); ASSERT_NULL(p); @@ -9344,8 +9608,8 @@ TEST(pipeline_semantic_manifest_rejects_non_directory_root) { cbm_file_hash_t *manifest = NULL; int manifest_count = -1; - int rc = cbm_pipeline_build_semantic_manifest("manifest-fail-closed", root_path, NULL, 0, NULL, - 0, NULL, NULL, &manifest, &manifest_count); + int rc = cbm_pipeline_build_semantic_manifest(NULL, "manifest-fail-closed", root_path, NULL, 0, + NULL, 0, NULL, NULL, &manifest, &manifest_count); cbm_pipeline_free_semantic_manifest(manifest, manifest_count > 0 ? manifest_count : 0); th_rmtree(tmp); @@ -9373,7 +9637,7 @@ TEST(pipeline_semantic_manifest_skips_unreadable_subdirectory) { cbm_file_hash_t *manifest = NULL; int manifest_count = -1; - int rc = cbm_pipeline_build_semantic_manifest("manifest-skip-locked", tmp, NULL, 0, NULL, 0, + int rc = cbm_pipeline_build_semantic_manifest(NULL, "manifest-skip-locked", tmp, NULL, 0, NULL, 0, NULL, NULL, &manifest, &manifest_count); bool found_sibling_gitignore = false; @@ -30985,6 +31249,12 @@ SUITE(pipeline) { RUN_TEST(pipeline_lock_release_allows_contender); /* Lifecycle */ RUN_TEST(pipeline_create_free); + RUN_TEST(pipeline_background_worker_policy_preserves_overrides); + RUN_TEST(pipeline_background_worker_width_keeps_parallel_route); +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + RUN_TEST(pipeline_surface_respects_total_worker_budget); +#endif + RUN_TEST(pipeline_background_policy_preserves_index_results); RUN_TEST(pipeline_null_repo); RUN_TEST(pipeline_free_null); RUN_TEST(pipeline_cancel);