diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 000000000..f44b5f3c2 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "codebase-memory", + "description": "The codebase-memory-mcp code knowledge graph server, as a Claude Code plugin.", + "owner": { + "name": "DeusData", + "url": "https://github.com/DeusData" + }, + "plugins": [ + { + "name": "codebase-memory", + "source": "./plugin", + "description": "Codebase knowledge graph for AI agents: sub-ms queries, 99% fewer tokens." + } + ] +} 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 47b5b17b5..152cca737 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -41,7 +41,9 @@ jobs: goarch: arm64 cc: gcc cxx: g++ - - os: macos-14 + # The macOS minimum (14.0) is pinned in Makefile.cbm, not inherited + # from this runner's SDK, so both darwin binaries still start on macOS 14. + - os: macos-15 goos: darwin goarch: arm64 cc: cc @@ -64,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: @@ -248,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 174490db6..72701ca7a 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 @@ -115,7 +118,7 @@ jobs: event-macos: if: ${{ inputs.mode == 'full' }} - runs-on: macos-14 + runs-on: macos-15 timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index 1a4d4eb57..ed1022b71 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -33,7 +33,7 @@ jobs: CORE_UNIX='[ {"os":"ubuntu-latest","goos":"linux","goarch":"amd64"}, {"os":"ubuntu-24.04-arm","goos":"linux","goarch":"arm64"}, - {"os":"macos-14","goos":"darwin","goarch":"arm64"}, + {"os":"macos-15","goos":"darwin","goarch":"arm64"}, {"os":"macos-15-intel","goos":"darwin","goarch":"amd64"} ]' # Broad legs reuse existing goos/goarch artifacts on additional OS @@ -47,9 +47,10 @@ jobs: # (ubuntu-22.04 / 22.04-arm). So the dynamic broad legs stay on # forward-compatible OSes only (macOS); running the dynamic binary on # ubuntu-22.04 would fail Phase 1 (glibc too old), not a real regression. - BROAD_UNIX='[ - {"os":"macos-15","goos":"darwin","goarch":"arm64"} - ]' + # Empty since the move off macos-14 (retired 2026-11-02): the darwin-arm64 + # artifact is now built AND smoked on macos-15, the core leg, which was + # this broad leg. + BROAD_UNIX='[]' CORE_WIN='[{"os":"windows-latest","arch":"amd64"}]' # windows-11-arm now runs the NATIVE arm64 binary (build-windows-arm64), # not the x86_64 binary under emulation — we smoke the artifact we ship. @@ -143,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"). diff --git a/.github/workflows/_soak.yml b/.github/workflows/_soak.yml index abf365f54..39f9e76b1 100644 --- a/.github/workflows/_soak.yml +++ b/.github/workflows/_soak.yml @@ -44,7 +44,7 @@ jobs: goarch: arm64 cc: gcc cxx: g++ - - os: macos-14 + - os: macos-15 goos: darwin goarch: arm64 cc: cc @@ -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 }} @@ -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: @@ -335,7 +337,7 @@ jobs: goarch: arm64 cc: gcc cxx: g++ - - os: macos-14 + - os: macos-15 goos: darwin goarch: arm64 cc: cc @@ -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" diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 39baa516c..501a82b20 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -57,18 +57,21 @@ jobs: run: | # Every CORE leg is tagged with the lane (scripts/ci/select-lanes.sh) # it serves; the tag is what lane selection filters on below. + # The lane names unix-macos14 and tsan-mac are kept as lane keys + # (select-lanes.sh, ci-ok and the lane contracts key on them); both + # run on macos-15 because GitHub retires the macos-14 image on 2026-11-02. CORE_UNIX='[ {"os":"ubuntu-latest","cc":"gcc","cxx":"g++","lane":"unix-x86"}, {"os":"ubuntu-24.04-arm","cc":"gcc","cxx":"g++","lane":"unix-arm64"}, - {"os":"macos-14","cc":"cc","cxx":"c++","lane":"unix-macos14"}, + {"os":"macos-15","cc":"cc","cxx":"c++","lane":"unix-macos14"}, {"os":"macos-15-intel","cc":"cc","cxx":"c++","lane":"unix-macos-intel"} ]' # Broad matrix legs are REQUIRED gates (no optional/continue-on-error # escape hatches): the release dry run must be all-green, every platform. + # macos-15 is the CORE macOS arm64 leg now, so it is not repeated here. BROAD_UNIX='[ {"os":"ubuntu-22.04","cc":"gcc","cxx":"g++"}, - {"os":"ubuntu-22.04-arm","cc":"gcc","cxx":"g++"}, - {"os":"macos-15","cc":"cc","cxx":"c++"} + {"os":"ubuntu-22.04-arm","cc":"gcc","cxx":"g++"} ]' # Each Windows leg pins the msys2 environment + package arch to the # RUNNER architecture so the build is native, never emulated: @@ -92,7 +95,7 @@ jobs: TSAN='[ {"os":"ubuntu-latest","cc":"clang","cxx":"clang++","lane":"tsan-x86"}, {"os":"ubuntu-24.04-arm","cc":"clang","cxx":"clang++","lane":"tsan-arm"}, - {"os":"macos-14","cc":"cc","cxx":"c++","lane":"tsan-mac"} + {"os":"macos-15","cc":"cc","cxx":"c++","lane":"tsan-mac"} ]' # Lane selection. "all" (dry run, release) keeps every leg and drops # the tags, so those matrices -- and their job names -- stay exactly @@ -166,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 @@ -367,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 @@ -525,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' diff --git a/.github/workflows/bug-repro.yml b/.github/workflows/bug-repro.yml index 2910606d0..3d10e42ab 100644 --- a/.github/workflows/bug-repro.yml +++ b/.github/workflows/bug-repro.yml @@ -40,7 +40,7 @@ jobs: group: linux cc: gcc cxx: g++ - - os: macos-14 + - os: macos-15 group: macos cc: cc cxx: c++ @@ -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 }} diff --git a/.github/workflows/cache-warm.yml b/.github/workflows/cache-warm.yml index ff15fbb1a..9951cd49e 100644 --- a/.github/workflows/cache-warm.yml +++ b/.github/workflows/cache-warm.yml @@ -27,7 +27,7 @@ jobs: include: - { os: ubuntu-latest, cc: gcc, cxx: g++ } - { os: ubuntu-24.04-arm, cc: gcc, cxx: g++ } - - { os: macos-14, cc: cc, cxx: c++ } + - { os: macos-15, cc: cc, cxx: c++ } - { os: macos-15-intel, cc: cc, cxx: c++ } runs-on: ${{ matrix.os }} timeout-minutes: 60 @@ -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') 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 81a838e80..e20ac274a 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 @@ -175,7 +176,7 @@ jobs: cat "$RUNNER_TEMP/selection.txt" >> "$GITHUB_OUTPUT" # pr-smoke's legs, each tagged with its lane (kept in the job name, # where ci-ok reads it back); the job gets the selected ones. - SMOKE='[{"os":"ubuntu-latest","lane":"smoke-ubuntu"},{"os":"macos-14","lane":"smoke-mac"},{"os":"windows-latest","lane":"smoke-win"}]' + SMOKE='[{"os":"ubuntu-latest","lane":"smoke-ubuntu"},{"os":"macos-15","lane":"smoke-mac"},{"os":"windows-latest","lane":"smoke-win"}]' LANES=$(sed -n 's/^lanes=//p' "$RUNNER_TEMP/selection.txt") echo "smoke={\"include\":$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$SMOKE")}" >> "$GITHUB_OUTPUT" # The CodeQL legs, tagged the same way (codeql.yml's full matrix has @@ -211,10 +212,11 @@ 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-14' + if: matrix.os == 'macos-15' run: command -v ccache >/dev/null 2>&1 || brew install ccache - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 @@ -254,7 +256,7 @@ jobs: SMOKE_REQUIRE_WRAPPERS: "1" - name: Build prod + smoke (macOS) - if: matrix.os == 'macos-14' + if: matrix.os == 'macos-15' run: | scripts/build.sh CC=cc CXX=c++ codesign --sign - --force build/c/codebase-memory-mcp @@ -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..dfb8ae59d 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -33,6 +33,6 @@ jobs: 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 e9b4f9357..66e62ff54 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -39,9 +39,6 @@ jobs: - os: ubuntu-24.04-arm cc: gcc cxx: g++ - - os: macos-14 # arm64 - cc: cc - cxx: c++ - os: macos-15 # arm64 cc: cc cxx: c++ @@ -54,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 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/.gitignore b/.gitignore index 1533c93e0..c388fa360 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ Thumbs.db # MCP config (user-local, generated by install command) .mcp.json +# ...except the Claude Code plugin's server definition, which is published +!plugin/.mcp.json # MCP Registry auth tokens .mcpregistry_* diff --git a/Makefile.cbm b/Makefile.cbm index e89c4a0cc..fdca89b07 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -18,8 +18,24 @@ # that use their own *_CFLAGS — and so it survives a command-line `CC=` override. # ARCHFLAGS is empty on Linux/Windows and for native direct `make` invocations, # leaving CC/CXX unchanged there. -override CC := $(CC) $(ARCHFLAGS) -override CXX := $(CXX) $(ARCHFLAGS) +# +# Minimum macOS version (Apple targets only). Without one, clang takes the +# minimum from the SDK it compiles against (LC_BUILD_VERSION minos), so a +# release built on a macos-15 runner would refuse to start on macOS 14. 14.0 +# keeps every macOS that could run a release built on the macos-14 runner +# (retired 2026-11-02). A flag folded into the drivers like ARCHFLAGS, not an +# exported MACOSX_DEPLOYMENT_TARGET: it reaches every compile AND link on every +# build path (build.sh, test.sh, direct make) and is part of each command line. +# Detected from the compiler's target, not the host, so a cross compiler run +# on a Mac is left untouched. +MACOS_MIN_VERSION := 14.0 +IS_APPLE_TARGET := $(shell echo | $(CC) $(ARCHFLAGS) -dM -E - 2>/dev/null | grep -q '__APPLE__' && echo yes || echo no) +MACOS_MIN_FLAGS := +ifeq ($(IS_APPLE_TARGET),yes) +MACOS_MIN_FLAGS := -mmacosx-version-min=$(MACOS_MIN_VERSION) +endif +override CC := $(CC) $(ARCHFLAGS) $(MACOS_MIN_FLAGS) +override CXX := $(CXX) $(ARCHFLAGS) $(MACOS_MIN_FLAGS) # ── Common flags ───────────────────────────────────────────────── @@ -680,6 +696,26 @@ MIMALLOC_CFLAGS_TEST = -std=c11 -g -O1 -w \ -Ivendored/mimalloc/include \ -Ivendored/mimalloc/src \ -DMI_OVERRIDE=0 $(MIMALLOC_WIN_DEFINE) +# MI_WIN_INIT_USE_FLS (MinGW TEST build only) is an EXPERIMENT (R32) for the +# Windows test leg's nondeterministic crash: 1-3 suites die in their first +# test with 0xC0000005 and no sanitizer report, seen only on trees with +# mimalloc 3.5.3. 3.5.3 is the first version whose loader hooks are live on +# MinGW: TLS callbacks in .CRT$XLB/.CRT$XLY run _mi_auto_process_init under the +# loader lock and _mi_thread_done on every thread detach. With FLS, +# src/prim/windows/prim.c compiles none of those hooks; process init runs once, +# from the constructor in src/prim/prim.c (no double init), and thread-done +# goes through a fiber-local destructor. MI_DEBUG=1 is what FLS needs to run at +# all: the first thread init (src/init.c) associates the main theap before the +# FLS key exists, which trips an internal assertion in prim.c and aborts every +# MI_DEBUG=2 process at startup. Level 1 keeps the fill patterns, padding and +# double-free checks and drops only the internal assertions. The production +# allocator (MIMALLOC_CFLAGS) is unchanged. Drop or promote this when the +# experiment ends. +MIMALLOC_WIN_TEST_DEFINE := +ifeq ($(IS_MINGW),yes) +MIMALLOC_WIN_TEST_DEFINE := -DMI_WIN_INIT_USE_FLS=1 -DMI_DEBUG=1 +MIMALLOC_CFLAGS_TEST += $(MIMALLOC_WIN_TEST_DEFINE) +endif # sqlite3 (vendored amalgamation — compiled ourselves for ASan instrumentation) # SQLITE_ENABLE_FTS5: enables the FTS5 full-text search extension used by the diff --git a/README.md b/README.md index 1a83289ee..a0e222d45 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ codebase-memory-mcp config set auto_index true `auto_index` is off by default. When enabled, new projects are indexed automatically on first connection. The configurable file limit is `config set auto_index_limit 50000`. Previously indexed projects can be watched through `auto_watch` (on by default), subject to its global setting and session ownership. -Each initialized, long-lived MCP session can register its root project when it connects and any target from a successful `index_repository` call, including custom project names and roots in another worktree. While `auto_watch` is enabled, these watches belong to the registering sessions and remain while at least one owning session is connected; closing the last owner removes the watch. An async index registers its target only after success and while its requesting session remains connected. CLI indexing and failed MCP indexes do not register watches. Global `auto_watch` controls registration and removes all watches on the next refresh or registration request when set to `false`. `index_status` reports this in its `watch` object: `watched`, and either the poll cadence and last scan time or the `reason` it is not watched. +Each initialized, long-lived MCP session can register its root project when it connects and any target from a successful `index_repository` call, including custom project names and roots in another worktree. While `auto_watch` is enabled, these watches belong to the registering sessions and remain while at least one owning session is connected; closing the last owner removes the watch. Under a daemon started with `daemon start` a watch outlives its last owning session: the project stays watched and is re-indexed in the background when it changes, until that daemon stops, the project is deleted, its directory disappears, or `auto_watch` is set to `false`. An async index registers its target only after success and while its requesting session remains connected. CLI indexing and failed MCP indexes do not register watches. Global `auto_watch` controls registration and removes all watches on the next refresh or registration request when set to `false`. `index_status` reports this in its `watch` object: `watched`, and either the poll cadence and last scan time or the `reason` it is not watched. Watcher registration is controlled globally by `auto_watch` (default `true`). On the next refresh or registration request, `true` allows MCP sessions to register their root on connect and their successful `index_repository` targets; `false` removes all watches. CLI indexing and failed MCP indexes do not add watches. @@ -578,6 +578,30 @@ Working in a clone? Use `.` in place of the flake URL, e.g. `nix run .#codebase- You: "Install this MCP server: https://github.com/DeusData/codebase-memory-mcp" ``` +### Install via Claude Code plugin + +This repository is also a Claude Code plugin marketplace, so you can find and +add the server from inside Claude Code: + +```bash +claude plugin marketplace add DeusData/codebase-memory-mcp +claude plugin install codebase-memory@codebase-memory +``` + +The plugin registers the MCP server only. It starts the server with +`npx -y codebase-memory-mcp@`, pinned to the current release; the first +start downloads that release's binary through the npm package, which verifies +it against the release checksums. The skill, the three graph agents and the +context hooks are not part of the plugin: they come from +`codebase-memory-mcp install`. + +Use one or the other. `install` registers the same server for Claude Code, so +with both you run it twice, under two tool namespaces +(`mcp__codebase-memory-mcp__*` and +`mcp__plugin_codebase-memory_codebase-memory-mcp__*`). Choose `install` for the +full Claude Code setup; choose the plugin to add just the server without +running an installer. + ### Build from Source
@@ -695,10 +719,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 | @@ -744,6 +768,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 @@ -880,9 +909,9 @@ Tools requiring arguments accept JSON piped on stdin. Tools with only optional a | Tool | Description | |------|-------------| -| `index_repository` | Index a repository into the graph. A successful call from an active MCP session registers the target for auto-sync while that session remains connected, subject to the global `auto_watch` setting; CLI calls do not register watches. Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). | +| `index_repository` | Index a repository into the graph. A successful call from an active MCP session registers the target for auto-sync while that session remains connected (under `daemon start`, also after it disconnects), subject to the global `auto_watch` setting; CLI calls do not register watches. Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). | | `list_projects` | List indexed projects with paging; use `detail: "stats"` for node/edge counts and database sizes. | -| `delete_project` | Remove a project and all its graph data. | +| `delete_project` | Remove a project and all its graph data, including its ADR sidecar (`.adr.db`). | | `index_status` | Check indexing status of a project. | | `check_index_coverage` | Check whether exact paths or a scope are indexed and fresh. A clean result means no recorded gap, not proof of completeness. | @@ -934,6 +963,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/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f2ebf78eb..aa4a72d81 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -89,7 +89,7 @@ Current keys: | `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. | | `auto_watch` | `true` | Global switch for MCP session watch registration. On the next refresh or registration request, `true` allows session roots and successful `index_repository` targets to be watched; `false` removes all watches. The background watcher can still run with no registered projects. | | `doc_candidates` | `auto` | Score documentation sections against code to store doc↔code candidates (the possible targets of a section, and the sections that may describe a function or file, each with a judged probability). Unset (`auto`), the index mode decides: on for `full` indexes (the default mode), off for `moderate` and `fast`. Set `true` or `false` to choose for every mode; `config reset doc_candidates` returns to `auto`. The scoring grows with sections × functions: under a second up to ~100k functions, ~15 s at ~400k, ~3 minutes on the Linux kernel. Off stores no candidates and leaves the rest of the graph unchanged; on in `fast` mode stores candidates without adding `fast`'s skipped `SIMILAR_TO` / `SEMANTICALLY_RELATED` edges. `index_repository` also takes `doc_candidates` for a single index, which wins over this setting. Applies from the next index. | -| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Active MCP sessions can register their root project and successful explicit index targets; `index_status` reports the current state in its `watch` object. | +| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Active MCP sessions can register their root project and successful explicit index targets; under `daemon start` those watches outlive the session until the daemon stops, the project is deleted, its directory disappears, or `auto_watch` is set to `false`. `index_status` reports the current state in its `watch` object. | | `watch_non_git` | `false` | Also poll project roots that are **not git repositories**. By default the watcher only follows git projects, so a project indexed from a plain directory is never refreshed after its first index — reindex it manually with `index_repository`. Set `true` to poll such roots on the same adaptive cadence with a file-tree scan: the indexer's own discovery walk (same skip lists, `.gitignore` and `.cbmignore` rules) hashed over each file's path, size and mtime. Any change reindexes once; paths the indexer skips (including cbm's own `.codebase-memory/` output) never trigger. The first poll after the daemon starts reindexes each such project once, since nothing records which tree state the index holds. The scan walks the whole tree every poll, so it costs more than git polling on very large trees. Read once when the daemon starts, like `watcher_enabled`. | | `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. | | `index_max_source_mb` | `off` | Optional maximum accepted source size in MiB in one discovery run. | diff --git a/graph-ui/package-lock.json b/graph-ui/package-lock.json index 794e775c0..c20a98c36 100644 --- a/graph-ui/package-lock.json +++ b/graph-ui/package-lock.json @@ -32,7 +32,7 @@ "playwright": "1.62.1", "three-stdlib": "2.36.1", "typescript": "5.9.3", - "vite": "6.4.4", + "vite": "6.4.5", "vitest": "4.1.12" } }, @@ -5277,9 +5277,9 @@ } }, "node_modules/vite": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", - "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", + "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/graph-ui/package.json b/graph-ui/package.json index 7d3b542b8..0fcf6ab3e 100644 --- a/graph-ui/package.json +++ b/graph-ui/package.json @@ -62,7 +62,7 @@ "playwright": "1.62.1", "three-stdlib": "2.36.1", "typescript": "5.9.3", - "vite": "6.4.4", + "vite": "6.4.5", "vitest": "4.1.12" }, "overrides": { diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index 2a5ed4c5c..5c2762126 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -3925,7 +3925,7 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C uint64_t lsp_start = now_ns(); if (!result->lsp_skipped) { if (language == CBM_LANG_GO) { - cbm_run_go_lsp(a, result, source, source_len, root); + cbm_run_go_lsp(a, result, source, source_len, root, rel_path); } if (language == CBM_LANG_C || language == CBM_LANG_CPP || language == CBM_LANG_CUDA) { if (!cbm_run_c_lsp_with_test_owners(a, result, source, source_len, root, diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index dd1559c1b..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 */ @@ -298,9 +299,10 @@ typedef struct { * are bound to the owning result's source identity before cross-file use. */ uint32_t test_name_start_byte, test_name_end_byte; uint32_t test_body_start_byte, test_body_end_byte; - /* Documents only: facts a document definition adds to its node's - * properties, as a NULL-terminated key, value, key, value, ... list (an - * ADR's id, status, date, deciders, title). Keys are fixed literals; the + /* Facts a definition adds to its node's properties, as a NULL-terminated + * key, value, key, value, ... list: a document's (an ADR's id, status, + * date, deciders, title), or CBM_PROP_GENERATED on a member the compiler + * writes and the source never spells. Keys are fixed literals; the * values are escaped where the properties are written. Tail field: * zero-init stays valid. */ const char **extra_props; @@ -321,6 +323,13 @@ typedef struct { const char *alias_target; } CBMDefinition; +/* extra_props key of a generated member, one the compiler writes and the + * source never spells: a Java record's implicit accessor carries + * `generated: record`. The node keeps the property, and the pipeline registers + * such a definition by qualified name only, so no short-name guess can land on + * it (pipeline.h cbm_def_props_generated). */ +#define CBM_PROP_GENERATED "generated" + /* Argument captured from a call expression */ typedef struct { const char *expr; // raw expression text ("payload.info", "MY_URL", "'hello'") @@ -855,6 +864,8 @@ typedef struct { typedef struct CBMFileResult { CBMArena arena; // owns local memory; composites may also retain child arenas below + uint64_t markdown_collection_steps; // collector cursor moves/node visits, not parser internals + uint64_t markdown_name_checks; // heading-name lookup/group checks, excluding sorting CBMDefArray defs; CBMCallArray calls; CBMImportArray imports; @@ -1066,6 +1077,9 @@ typedef struct { * POD section index. */ void *doc_memo; void *doc_pod_index; + /* Markdown: the settled names of the file's headings (extract_defs.c), + * NULL until the first heading; allocated in `scratch`. */ + void *md_headings; const cbm_test_declarations_t *test_declarations; /* borrowed for this call only */ bool test_declarations_raw_source; struct CBMTestDefinitionMatch *test_definition_matches; /* traversal scratch */ diff --git a/internal/cbm/diagram.c b/internal/cbm/diagram.c index cdd3785f4..23d5bf518 100644 --- a/internal/cbm/diagram.c +++ b/internal/cbm/diagram.c @@ -1225,9 +1225,11 @@ static bool dg_word_at(dg_str_t s, const char *word) { } /* An operand of a PlantUML line: `[Comp]`, `(use case)`, `:actor:`, `"text"`, - * an id, or `[*]`; its id in *id; the position after it (i when none). */ + * an id, or `[*]`; its id in *id; the position after it (i and an empty *id + * when none, as for the diagram edge in `A ->: x` or `[-> A: x`). */ static size_t dg_puml_operand(dg_str_t s, size_t i, dg_str_t *id, dg_str_t *label) { i = dg_skip_ws(s, i); + *id = dg_s(NULL, 0); *label = dg_s(NULL, 0); if (i + CBM_SZ_3 <= s.n && memcmp(s.p + i, "[*]", CBM_SZ_3) == 0) { *id = dg_s(s.p + i, CBM_SZ_3); diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index 28fbf66a2..979af928c 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -718,6 +718,66 @@ static bool elixir_call_head_in(TSNode call, const char *source, const char *con call_node_text_in(ts_node_child(call, 0), source, heads); } +/* A definition's head declares a name; it is never a call to it. The head under + * a guard is not the def's first argument -- `def f(x) when g` parses its whole + * head as a `when` binary_operator -- so the plain node comparison below stopped + * recognising it, and the inner `f(x)` was recorded as a call to `f`. Under the + * widened span that phantom lands inside the function's own node, and cbm.c + * decides self-recursion by line containment, so an ordinary two-clause guarded + * function reported itself recursive. `recursive` is a queryable node property + * and seeds the cycle detection in pass_complexity, so that is a load-bearing + * signal, not a cosmetic one. The suppression therefore travels with the fold + * rather than following it. + * + * The head comes from cbm_elixir_def_head_is (helpers.c) rather than a private + * peel here, because three files read this same node and all three have to + * agree on which node is the head, or a node one of them is treating as a + * definition name is recorded by another as a reference to that very name: + * - extract_defs.c, extract_elixir_func_def, unwraps it to NAME the + * definition. It and this file disagreeing mints the phantom above. + * - extract_usages.c, is_elixir_def_binding, treats an identifier inside that + * head as a binding occurrence rather than a reference, which is what keeps + * a head suppressed here from re-emerging as a USAGE edge: a suppression in + * one extractor of the unified walk only REMOVES a phantom if the others + * also decline the node. The two files ask different questions of the same + * helper -- this one whether the node IS the head or a `when` wrapper above + * it, that one whether the node sits INSIDE the head -- so there is one + * definition of the head and they cannot drift apart. That containment + * stops at the head rather than covering the whole `when` operator, which + * is deliberate in the other direction: only the parameters being BOUND are + * excluded, and a parameter READ in the guard is a reference, exactly as + * the same read in the body is. + * - extract_unified.c, compute_elixir_func_qn, resolves a def's QN to open + * the function's call scope and reads the head through the same helper. + * Handed the `when` operator instead it returns NULL, no scope is pushed, + * and every call in a guarded body is sourced from the File rather than + * from the enclosing Function. That failure is independent of the two above + * because it decides edge SOURCE, not edge existence. + * + * Suppressing the head does not only delete edges; it moves metadata on edges + * that SURVIVE, and that is worth stating because a caller reading an edge's + * `line` will see a different number than before. `edges` is UNIQUE on + * (source, target, type), so where the def-head phantom happened to be the + * dedup survivor for a pair, deleting it promotes some other row for that same + * pair and the surviving edge takes that row's `line`. + * + * Measured by indexing one 970-file Elixir lib tree with the binary built from + * this commit's parent and with this one, and diffing the `edges` rows out of + * the two SQLite stores keyed on (source QN, target QN, type): 2,436 edges go + * and NONE is added -- 2,104 CALLS and 332 USAGE. Of the 119,696 edges that + * survive, 1,054 record a different `line`. + * + * The typespec attributes -- `@spec f(t) :: u` and the @callback / @type family + * -- put the declared name in a `call` node too, and are deliberately NOT + * suppressed here. A def head can be, because extract_usages.c already treats a + * def's first argument as a binding. Nothing there treats a typespec subject as + * one, so declining it in this walk would not remove its phantom, it would + * RELABEL it: handle_usages reaches the bare identifier handle_calls just + * declined and mints a USAGE onto the same function, which under + * UNIQUE(source, target, type) is a separate row that no longer collides with + * the real call it used to hide behind, and which pass_importance then counts. + * A typespec phantom has to be removed before any extractor sees it, by + * skipping the whole subtree in the unified walk. */ static bool elixir_call_is_definition_role(TSNode node, const char *source) { static const char *const structural_heads[] = {"def", "defp", "defmacro", "defmodule", NULL}; static const char *const function_heads[] = {"def", "defp", "defmacro", NULL}; @@ -735,7 +795,7 @@ static bool elixir_call_is_definition_role(TSNode node, const char *source) { TSNode signature = ts_node_named_child_count(arguments) > 0 ? ts_node_named_child(arguments, 0) : arguments; - return ts_node_eq(signature, node); + return cbm_elixir_def_head_is(signature, node); } return false; } @@ -757,7 +817,23 @@ static bool call_node_is_definition_container(CBMLanguage lang, TSNode node, con if (lang == CBM_LANG_AGDA && strcmp(kind, "expr") == 0) { return agda_expr_is_definition_role(node); } - return lang == CBM_LANG_ELIXIR && strcmp(kind, "call") == 0 && + /* A guarded head is a `when` binary_operator, not a `call`, and Elixir's + * call node types include binary_operator -- so the head reaches this walk + * and must be able to answer that it is a definition. A paren-less clause + * (`def f when g`) has no inner call at all, so the operator node itself + * reaches extract_callee_name and, with no callee of its own, takes that + * function's last resort -- the first identifier child -- minting a phantom + * CALLS edge onto the very function being defined. + * + * That last resort overrides a deliberate NULL for every Elixir + * binary_operator extract_scripting_callee declines (`=`, `<-`, `->`, `\\`, + * `::`, `when`), so `def g(c \\ 2)` still emits a phantom `c` and + * `e = target(d)` a phantom `e`. That is the pre-existing behaviour for + * those operators and is untouched here: only a `when` operator is + * admitted, so an operator definition's own head (`def a + b`) stays an + * ordinary node, as it was before guards were handled at all. */ + return lang == CBM_LANG_ELIXIR && + (strcmp(kind, "call") == 0 || cbm_elixir_is_when_guard(node)) && elixir_call_is_definition_role(node, source); } @@ -4557,8 +4633,14 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML call.first_string_arg = route_path_with_group_prefix(ctx, node, call.callee_name, call.first_string_arg); /* A slashless registration's string action would only be - * guessed at (#1146): mint its Route without a handler. */ - if (call.first_string_arg && call.first_string_arg[0] == '/' && + * guessed at (#1146): mint its Route without a handler. A Go + * ServeMux pattern names its method or host before the slash + * ("POST /v1/x", #2537) and keeps its handler. */ + if (call.first_string_arg && + (call.first_string_arg[0] == '/' || + (ctx->language == CBM_LANG_GO && + cbm_service_pattern_mux_route_path(call.callee_name, call.first_string_arg) != + NULL)) && !(laravel_uri && php_route_action_is_string(node))) { call.second_arg_name = extract_handler_arg(ctx, args); } diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 7797152a5..76571c1c7 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -230,6 +230,7 @@ static void extract_class_fields(CBMExtractCtx *ctx, TSNode class_node, const ch const CBMLangSpec *spec); static TSNode find_class_body(TSNode class_node, CBMLanguage lang); static void extract_enum_members(CBMExtractCtx *ctx, TSNode node, const char *class_qn); +static void extract_record_accessors(CBMExtractCtx *ctx, TSNode node, const char *class_qn); static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec); // --- Helpers --- @@ -7060,6 +7061,24 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec } } + /* Go allows any number of init() functions per package — even several in + * one file — and they all run at start-up. On the flat QN they all + * collided and the graph upsert kept ONE node per package, silently + * dropping the rest (#1910). Disambiguate with the #495 cfg-twin pattern: + * fold the file basename and the init's per-file ordinal into the QN. + * The ordinal (not the line) keeps the QN stable when code is edited + * above the function — the QN is node identity, and a line suffix would + * churn nodes on every unrelated insertion. Nothing ever joins on the + * plain QN — calling init explicitly is illegal in Go — and the + * call-scope side (compute_func_qn in extract_unified.c) and the Go LSP + * (process_function in lsp/go_lsp.c) name it through the same + * cbm_go_init_qn, so init-body calls keep their attribution. */ + if (ctx->language == CBM_LANG_GO && strcmp(def.label, "Function") == 0 && + strcmp(name, "init") == 0) { + def.qualified_name = + cbm_go_init_qn(a, def.qualified_name, ctx->rel_path, node, ctx->source); + } + // C++/CUDA: out-of-line method definition (`Foo::bar` in a .cc/.cpp). The // class body in the header is declaration-only, so without this the // definition is recorded as a free Function. Promote it to a Method whose QN @@ -7399,13 +7418,15 @@ static const char *extract_markdown_section_body(CBMExtractCtx *ctx, TSNode head /* Push a config-language definition that carries prose. push_simple_class_def * delegates here with no docstring. */ -static void push_simple_class_def_doc(CBMExtractCtx *ctx, TSNode node, char *name, - const char *label, const char *docstring) { +/* Push a definition whose qualified name ends with `qn_name` (already QN-safe) + * rather than with its name. */ +static void push_class_def_qn(CBMExtractCtx *ctx, TSNode node, char *name, const char *qn_name, + const char *label, const char *docstring) { CBMArena *a = ctx->arena; CBMDefinition def; memset(&def, 0, sizeof(def)); def.name = name; - def.qualified_name = cbm_fqn_compute(a, ctx->project, ctx->rel_path, qn_safe_segment(a, name)); + def.qualified_name = cbm_fqn_compute(a, ctx->project, ctx->rel_path, qn_name); def.label = label; def.file_path = ctx->rel_path; def.start_line = ts_node_start_point(node).row + TS_LINE_OFFSET; @@ -7415,6 +7436,11 @@ static void push_simple_class_def_doc(CBMExtractCtx *ctx, TSNode node, char *nam cbm_defs_push(&ctx->result->defs, a, def); } +static void push_simple_class_def_doc(CBMExtractCtx *ctx, TSNode node, char *name, + const char *label, const char *docstring) { + push_class_def_qn(ctx, node, name, qn_safe_segment(ctx->arena, name), label, docstring); +} + static void push_simple_class_def(CBMExtractCtx *ctx, TSNode node, char *name, const char *label) { push_simple_class_def_doc(ctx, node, name, label, NULL); } @@ -7486,6 +7512,49 @@ static char *extract_atx_heading_text(CBMArena *a, TSNode node, const char *sour return NULL; } +/* Setext paragraphs include block-container markers between lines. Keep the + * whole inline text, excluding only the parser's continuation spans. */ +static char *extract_setext_heading_text(CBMArena *a, TSNode node, const char *source) { + TSNode content = ts_node_child_by_field_name(node, TS_FIELD("heading_content")); + if (ts_node_is_null(content)) { + return NULL; + } + TSNode inline_node = cbm_find_child_by_kind(content, "inline"); + if (!ts_node_is_null(inline_node)) { + content = inline_node; + } + uint32_t start = ts_node_start_byte(content); + uint32_t end = ts_node_end_byte(content); + char *text = (char *)cbm_arena_alloc(a, (size_t)(end - start) + SKIP_ONE); + if (!text) { + return NULL; + } + size_t written = 0; + uint32_t previous = start; + TSTreeCursor cursor = ts_tree_cursor_new(content); + for (;;) { + TSNode child = ts_tree_cursor_current_node(&cursor); + if (strcmp(ts_node_type(child), "block_continuation") == 0) { + uint32_t marker_start = ts_node_start_byte(child); + uint32_t marker_end = ts_node_end_byte(child); + memcpy(text + written, source + previous, marker_start - previous); + written += marker_start - previous; + previous = marker_end; + } else if (ts_tree_cursor_goto_first_child(&cursor)) { + continue; + } + while (!ts_tree_cursor_goto_next_sibling(&cursor)) { + if (!ts_tree_cursor_goto_parent(&cursor)) { + ts_tree_cursor_delete(&cursor); + memcpy(text + written, source + previous, end - previous); + written += end - previous; + text[written] = '\0'; + return text; + } + } + } +} + // Trim trailing whitespace/newlines from a heading name in-place. static char *trim_heading_name(char *name) { if (!name || !name[0]) { @@ -7507,13 +7576,425 @@ static char *extract_markdown_heading_name(CBMArena *a, TSNode node, const char if (strcmp(kind, "atx_heading") == 0) { name = extract_atx_heading_text(a, node, source); } else { - if (ts_node_child_count(node) > 0) { - name = cbm_node_text(a, ts_node_child(node, 0), source); - } + name = extract_setext_heading_text(a, node, source); } return trim_heading_name(name); } +/* #2586: a heading name that repeats in a file (`## Implementation` under two + * parents) gave its Sections one qualified name, so the graph kept one node + * and the references under them fell back to the File. A repeated heading's + * qualified name therefore takes the names of the headings above it when + * those chains differ (`Service-A.Implementation`). Unique titles and repeats + * with only one parent chain keep their bare names. A heading text may itself + * hold dots, so a qualified name can equal another heading's final name (`# Service-A. + * Implementation`): the qualified one then takes `~2`, `~3`, ... until no + * other heading of the file has that name. Headings that repeat under the + * same parents too still share a name, as before. All final names are + * settled once per file, on the first heading. */ +typedef struct md_heading { + uint32_t start; /* the heading node's first byte */ + uint32_t scope_start; /* enclosing block container, excluding section wrappers */ + uint32_t scope_end; + int level; /* ATX and Setext heading rank, 1–6 */ + struct md_heading *parent; + const char *seg; /* the heading's own QN-safe name */ + const char *key; /* repeated only: the chain's segments, '\n'-joined */ + const char *qn_name; /* the final QN segment */ + bool qualified; /* the name actually changed by adding parents */ + struct md_heading *chain; /* first heading with this identity in a collision run */ +} md_heading_t; + +typedef struct { + md_heading_t *v; /* in document order */ + int count; + uint64_t *checks; +} md_headings_t; + +/* The QN-safe name of a heading node, or NULL. */ +static const char *md_heading_segment(CBMExtractCtx *ctx, CBMArena *a, TSNode node) { + const char *kind = ts_node_type(node); + if (!is_markdown_heading_kind(kind)) { + return NULL; + } + char *name = extract_markdown_heading_name(a, node, kind, ctx->source); + return name ? qn_safe_segment(a, name) : NULL; +} + +enum { MD_HEADING_LEVELS = 6 }; + +/* Read the vendored grammar's explicit markers, including Setext headings: + * unlike ATX headings, those are not nested in section nodes by the parser. */ +static int md_heading_level(TSNode node) { + static const char *const atx_markers[MD_HEADING_LEVELS] = { + "atx_h1_marker", "atx_h2_marker", "atx_h3_marker", + "atx_h4_marker", "atx_h5_marker", "atx_h6_marker", + }; + uint32_t n = ts_node_child_count(node); + for (uint32_t i = 0; i < n; i++) { + const char *kind = ts_node_type(ts_node_child(node, i)); + for (int level = 0; level < MD_HEADING_LEVELS; level++) { + if (strcmp(kind, atx_markers[level]) == 0) { + return level + SKIP_ONE; + } + } + if (strcmp(kind, "setext_h1_underline") == 0) { + return 1; + } + if (strcmp(kind, "setext_h2_underline") == 0) { + return PAIR_LEN; + } + } + return 0; +} + +/* Scope frames follow cursor descent/ascent. Reuse popped frames so temporary + * storage grows with nesting depth, not with the number of blocks. */ +typedef struct md_scope { + uint32_t start; + uint32_t end; + int depth; + struct md_scope *previous; +} md_scope_t; + +/* Visit every heading, including empty structural headings: count them (v + * NULL), or fill v. Never ask ts_node_parent to rediscover the path from the + * root: its preceding-sibling scan makes flat documents quadratic. */ +static int md_collect_headings(CBMExtractCtx *ctx, CBMArena *a, md_heading_t *v, int cap) { + int n = 0; + int depth = 0; + md_scope_t *scope = NULL; + md_scope_t *spare = NULL; + uint64_t *steps = &ctx->result->markdown_collection_steps; + if (ts_node_is_null(ctx->root)) { + return 0; + } + TSTreeCursor cur = ts_tree_cursor_new(ctx->root); + for (;;) { + (*steps)++; + TSNode node = ts_tree_cursor_current_node(&cur); + const char *kind = ts_node_type(node); + if (is_markdown_heading_kind(kind)) { + if (!v) { + n++; + } else if (n < cap) { + const char *seg = md_heading_segment(ctx, a, node); + v[n++] = (md_heading_t){.start = ts_node_start_byte(node), + .scope_start = scope ? scope->start : 0, + .scope_end = scope ? scope->end : 0, + .level = md_heading_level(node), + .seg = seg ? seg : "", + .qn_name = seg ? seg : ""}; + } + } else { + (*steps)++; + if (ts_tree_cursor_goto_first_child(&cur)) { + if (v && strcmp(kind, "section") != 0) { + md_scope_t *frame = spare; + if (frame) { + spare = frame->previous; + } else { + frame = (md_scope_t *)cbm_arena_alloc(a, sizeof(*frame)); + } + if (!frame) { + ts_tree_cursor_delete(&cur); + return -1; + } + *frame = (md_scope_t){.start = ts_node_start_byte(node), + .end = ts_node_end_byte(node), + .depth = depth, + .previous = scope}; + scope = frame; + } + depth++; + continue; + } + } + for (;;) { + (*steps)++; + if (ts_tree_cursor_goto_next_sibling(&cur)) { + break; + } + (*steps)++; + if (!ts_tree_cursor_goto_parent(&cur)) { + ts_tree_cursor_delete(&cur); + return n; + } + depth--; + if (scope && scope->depth == depth) { + md_scope_t *previous = scope->previous; + scope->previous = spare; + spare = scope; + scope = previous; + } + } + } +} + +static int md_by_seg(const void *x, const void *y) { + return strcmp((*(const md_heading_t *const *)x)->seg, (*(const md_heading_t *const *)y)->seg); +} + +static int md_by_name(const void *x, const void *y) { + const md_heading_t *p = *(const md_heading_t *const *)x; + const md_heading_t *q = *(const md_heading_t *const *)y; + int c = strcmp(p->qn_name, q->qn_name); + return c ? c : (p->start > q->start) - (p->start < q->start); +} + +static int md_by_scope(const void *x, const void *y) { + const md_heading_t *p = *(const md_heading_t *const *)x; + const md_heading_t *q = *(const md_heading_t *const *)y; + if (p->scope_start != q->scope_start) { + return (p->scope_start > q->scope_start) - (p->scope_start < q->scope_start); + } + if (p->scope_end != q->scope_end) { + return (p->scope_end > q->scope_end) - (p->scope_end < q->scope_end); + } + return (p->start > q->start) - (p->start < q->start); +} + +/* Heading levels define hierarchy for both syntaxes. Each block container + * gets its own six-slot stack, so headings inside quotes or list items cannot + * become parents of headings outside them. Sorting by container and document + * position also restores the outer stack after a nested container ends. */ +static void md_assign_parents(md_heading_t **by, int n) { + qsort(by, (size_t)n, sizeof(*by), md_by_scope); + for (int i = 0; i < n;) { + int j = i; + md_heading_t *parents[MD_HEADING_LEVELS] = {0}; + while (j < n && by[j]->scope_start == by[i]->scope_start && + by[j]->scope_end == by[i]->scope_end) { + md_heading_t *h = by[j++]; + if (h->level < 1 || h->level > MD_HEADING_LEVELS) { + continue; + } + for (int k = h->level - PAIR_LEN; k >= 0; k--) { + if (parents[k]) { + h->parent = parents[k]; + break; + } + } + parents[h->level - SKIP_ONE] = h; + for (int k = h->level; k < MD_HEADING_LEVELS; k++) { + parents[k] = NULL; + } + } + i = j; + } +} + +/* A repeated heading's chain: dot-joined for its QN and '\n'-joined for + * identity, since a QN-safe segment has no whitespace. At most six headings. */ +static bool md_heading_chain(CBMArena *a, md_heading_t *h) { + const char *dotted = h->seg; + const char *key = h->seg; + for (const md_heading_t *parent = h->parent; parent; parent = parent->parent) { + if (!parent->seg[0]) { + continue; /* unnamed headings reset levels but contribute no name */ + } + dotted = cbm_arena_sprintf(a, "%s.%s", parent->seg, dotted); + key = cbm_arena_sprintf(a, "%s\n%s", parent->seg, key); + if (!dotted || !key) { + return false; + } + } + h->qn_name = dotted; + h->key = key; + return true; +} + +/* The immutable, sorted initial names reserve both bare headings and parent + * chains before any suffix is assigned. Generated names from different bases + * cannot coincide: removing the final ~N recovers their distinct bases. */ +static bool md_name_taken(md_headings_t *hs, const char *const *reserved, int reserved_count, + const char *name) { + int lo = 0; + int hi = reserved_count; + while (lo < hi) { + int mid = lo + (hi - lo) / PAIR_LEN; + (*hs->checks)++; + int c = strcmp(reserved[mid], name); + if (c == 0) { + return true; + } + if (c < 0) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + return false; +} + +static int md_by_key(const void *x, const void *y) { + const md_heading_t *p = *(const md_heading_t *const *)x; + const md_heading_t *q = *(const md_heading_t *const *)y; + int c = strcmp(p->key ? p->key : "", q->key ? q->key : ""); + return c ? c : (p->start > q->start) - (p->start < q->start); +} + +static int md_by_start(const void *x, const void *y) { + const md_heading_t *p = *(const md_heading_t *const *)x; + const md_heading_t *q = *(const md_heading_t *const *)y; + return (p->start > q->start) - (p->start < q->start); +} + +/* Unchanged bare headings keep their names, including inseparable repeats. + * Otherwise the first chain keeps the name. Group identities once, then assign + * one suffix per chain in document order; no repeated scans of earlier chains + * and no restarting the suffix search at ~2. */ +static bool md_split_run(CBMArena *a, md_headings_t *hs, const char *const *reserved, + int reserved_count, md_heading_t **run, int n) { + const char *base = run[0]->qn_name; + const char *keep = run[0]->key; + for (int i = 0; i < n; i++) { + if (!run[i]->qualified) { + keep = NULL; + } + } + qsort(run, (size_t)n, sizeof(*run), md_by_key); + for (int i = 0; i < n;) { + int j = i + SKIP_ONE; + while (j < n) { + (*hs->checks)++; + if (strcmp(run[i]->key ? run[i]->key : "", run[j]->key ? run[j]->key : "") != 0) { + break; + } + j++; + } + for (int k = i; k < j; k++) { + run[k]->chain = run[i]; + } + i = j; + } + qsort(run, (size_t)n, sizeof(*run), md_by_start); + int suffix = PAIR_LEN; + for (int i = 0; i < n; i++) { + md_heading_t *h = run[i]; + if (!h->qualified || h->chain != h || (keep && strcmp(h->key, keep) == 0)) { + continue; + } + const char *name; + do { + name = cbm_arena_sprintf(a, "%s~%d", base, suffix++); + if (!name) { + return false; + } + } while (md_name_taken(hs, reserved, reserved_count, name)); + h->qn_name = name; + } + for (int i = 0; i < n; i++) { + if (run[i]->qualified) { + run[i]->qn_name = run[i]->chain->qn_name; + } + } + return true; +} + +static bool md_settle_names(CBMArena *a, md_headings_t *hs) { + int n = hs->count; + md_heading_t **by = (md_heading_t **)cbm_arena_alloc(a, (size_t)n * sizeof(*by)); + const char **reserved = (const char **)cbm_arena_alloc(a, (size_t)n * sizeof(*reserved)); + if (!by || !reserved) { + return false; + } + for (int i = 0; i < n; i++) { + by[i] = &hs->v[i]; + } + md_assign_parents(by, n); + /* Empty headings are structural events only, never reserved names. */ + n = 0; + for (int i = 0; i < hs->count; i++) { + if (hs->v[i].seg[0]) { + by[n++] = &hs->v[i]; + } + } + qsort(by, (size_t)n, sizeof(*by), md_by_seg); + for (int i = 0; i < n;) { + int j = i + SKIP_ONE; + while (j < n && strcmp(by[j]->seg, by[i]->seg) == 0) { + j++; + } + if (j - i > SKIP_ONE) { + bool distinct = false; + for (int k = i; k < j; k++) { + if (!md_heading_chain(a, by[k])) { + return false; + } + (*hs->checks)++; + distinct |= strcmp(by[k]->key, by[i]->key) != 0; + } + for (int k = i; k < j; k++) { + if (!distinct) { + by[k]->qn_name = by[k]->seg; + } + by[k]->qualified = strcmp(by[k]->qn_name, by[k]->seg) != 0; + } + } + i = j; + } + qsort(by, (size_t)n, sizeof(*by), md_by_name); + for (int i = 0; i < n; i++) { + reserved[i] = by[i]->qn_name; + } + for (int i = 0; i < n;) { + int j = i + SKIP_ONE; + while (j < n && strcmp(reserved[j], reserved[i]) == 0) { + j++; + } + if (j - i > SKIP_ONE && !md_split_run(a, hs, reserved, n, by + i, j - i)) { + return false; + } + i = j; + } + return true; +} + +static const md_headings_t *md_headings(CBMExtractCtx *ctx) { + if (ctx->md_headings) { + return (const md_headings_t *)ctx->md_headings; + } + CBMArena *a = doc_scratch(ctx); + md_headings_t *hs = (md_headings_t *)cbm_arena_alloc(a, sizeof(*hs)); + if (!hs) { + return NULL; + } + hs->checks = &ctx->result->markdown_name_checks; + int cap = md_collect_headings(ctx, a, NULL, 0); + hs->v = cap > 0 ? (md_heading_t *)cbm_arena_alloc(a, (size_t)cap * sizeof(md_heading_t)) : NULL; + hs->count = hs->v ? md_collect_headings(ctx, a, hs->v, cap) : 0; + if (hs->count < 0) { + hs->count = 0; + } + if (hs->count > SKIP_ONE && !md_settle_names(a, hs)) { + hs->count = 0; /* every heading keeps its own name, as before */ + } + ctx->md_headings = hs; + return hs; +} + +/* The QN segment of a heading: its own name unless the file's names say + * otherwise. */ +static const char *md_heading_qn_name(CBMExtractCtx *ctx, TSNode heading, const char *seg) { + const md_headings_t *hs = md_headings(ctx); + uint32_t start = ts_node_start_byte(heading); + int lo = 0; + int hi = hs ? hs->count : 0; + while (lo < hi) { + int mid = lo + ((hi - lo) / PAIR_LEN); + if (hs->v[mid].start < start) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + if (hs && lo < hs->count && hs->v[lo].start == start && strcmp(hs->v[lo].seg, seg) == 0) { + return hs->v[lo].qn_name; + } + return seg; +} + // INI: extract section name from section node. static char *find_ini_section_name(CBMArena *a, TSNode node, const char *source) { uint32_t nc = ts_node_child_count(node); @@ -7615,7 +8096,10 @@ static bool extract_config_class_def(CBMExtractCtx *ctx, TSNode node, const char return false; } - if (name && name[0]) { + if (name && name[0] && ctx->language == CBM_LANG_MARKDOWN) { + const char *seg = qn_safe_segment(a, name); + push_class_def_qn(ctx, node, name, md_heading_qn_name(ctx, node, seg), label, docstring); + } else if (name && name[0]) { push_simple_class_def_doc(ctx, node, name, label, docstring); } return true; @@ -7856,9 +8340,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; @@ -7892,7 +8382,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) { @@ -8317,6 +8810,10 @@ static void emit_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *s extract_enum_members(ctx, node, class_qn); } + if (ctx->language == CBM_LANG_JAVA && strcmp(kind, "record_declaration") == 0) { + extract_record_accessors(ctx, node, class_qn); + } + // Extract methods inside the class int methods_from = ctx->result->defs.count; extract_class_methods(ctx, node, class_qn, spec); @@ -9226,8 +9723,297 @@ static TSNode elixir_call_args(TSNode node) { return args; } -// Handle Elixir def/defp/defmacro — extract function definition. -static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char *macro) { +/* 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 +// and collide on a single graph node. cbm_gbuf_upsert_node breaks a same-QN +// collision by keeping the LARGEST start_line, so the survivor was the last +// clause in the file and get_code_snippet returned that clause as the whole +// function: `def admin?(%__MODULE__{role: :admin}), do: true` / +// `def admin?(%__MODULE__{}), do: false` read back as a predicate that always +// returns false. One node per function is right, so widen its span instead. +// +// Two bounds keep the widened span honest, and a clause folds only if it clears +// both: +// - Same module body. `scope` is the do_block the previous clause was written +// in, so a nested `defmodule` cannot merge with its parent: the QN carries +// no module, so `Outer.run` and `Outer.Inner.run` compute the same name and +// are array-adjacent when Inner is declared just above Outer's own clause. +// - Same group. Only the immediately preceding EXTRACTED definition is a +// candidate, so a def of another name between two same-named defs ends the +// group. That is a bound this code imposes, not a guarantee Elixir gives: +// the compiler only WARNS ("clauses with the same name and arity should be +// grouped together"), it warns per name AND arity while this QN is +// arity-free, and non-contiguous clauses do compile. Split clauses +// therefore keep separate nodes, which is the pre-existing behaviour. +// What the group bound does NOT exclude is a non-definition construct +// between two clauses: `@doc` / `@spec` are unary_operators, `use` / +// `alias` / `describe` are ordinary calls, and a `defimpl` block's inner +// defs are not extracted -- none of them push a def, so anything of that +// kind written between two clauses of one function ends up inside the +// widened span, and that is not rare. Measured by indexing one 970-file +// Elixir lib tree with the binary built from this commit's parent and with +// this one and diffing the `nodes` rows out of the two SQLite stores: 6,996 +// Function nodes widen their span, none is added, none is lost and none +// narrows. Reading each widened span for a module-body construct written at +// the module's own two-space indent, which no clause body reaches, 169 of +// the 6,996 cover such a line. Counting each node once per kind, 123 cover +// a typespec attribute (@spec/@type/@typep/@opaque/@callback/ +// @macrocallback), 100 an @doc or @typedoc, 46 a bare comment line, 14 an +// @impl, and none a `use`, `alias`, `import`, `require` or a module-level +// `quote`. Swallowed typespecs are the largest attribute kind, not an +// absent one. +// +// The widened span also admits a phantom call that a narrower span kept out, +// which is why the head suppression in extract_calls.c travels with this fold +// rather than after it. cbm.c flags self-recursion by finding the innermost +// Function whose [start_line, end_line] contains a recorded call whose short +// name matches the function's own, so any pre-existing phantom between the +// first and the last clause becomes a self-edge as soon as the span covers it. +// That cost is real and is measured rather than asserted away. Same corpus, +// reading each flagged node's own source span for any line that names it as a +// call, a capture or a pipe target, counting a one-line clause's body after +// `, do:` as such a line: self_recursive Function nodes go 129 -> 430, and the +// ones carrying no self-call form anywhere in their span go 6 -> 48. The lines +// responsible are the typespec heads the widened span now covers -- `@spec +// f(t) :: u` puts the declared name in a `call` node, and the unified walk +// reads it as code -- a separate defect with a separate fix, not something this +// fold can close. +// +// Skipping the typespec subtrees, later in this stack, closes that half: the +// same method then reports 389 self_recursive Function nodes and 8 with no +// self-call form, against 129 and 6 on the base. All 8 are the pre-existing +// phantom a local variable sharing the function's name mints (`defp slug(value) +// do slug = ...`), 6 of them already present on the base; the other 2 are +// guarded clauses that only have a node of their own to be flagged on because +// of this stack. +// +// What the fold and the head suppression together remove is 2,436 edges, none +// of them added back: see elixir_call_is_definition_role in extract_calls.c. +// +// A clause whose macro differs (`def` foo/1 beside `defp` foo/2) still folds, +// because the arity-free QN already puts both on one node; is_exported is then +// the OR over the folded clauses, so a name any clause exports stays exported. +// That OR changes a recorded flag, which the node diff confirms and which is +// worth stating rather than leaving to be discovered: on the same corpus +// exactly 6 Function nodes change is_exported, all false -> true and none the +// other way. Each is a `def` clause written above a `defp` clause of the same +// 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, const cbm_complexity_t *cx) { + if (!qn || ctx->result->defs.count <= 0 || ts_node_is_null(scope)) { + return false; + } + TSNode here = ts_node_parent(node); + if (ts_node_is_null(here) || !ts_node_eq(here, scope)) { + return false; + } + CBMDefinition *prev = &ctx->result->defs.items[ctx->result->defs.count - 1]; + if (!prev->label || strcmp(prev->label, "Function") != 0 || !prev->qualified_name || + strcmp(prev->qualified_name, qn) != 0) { + return false; + } + /* Only end_line moves. prev->start_line is already the minimum, so this + * takes no start_line argument: a clause folds only into the def pushed + * for the immediately preceding extracted clause of the same do_block, and + * extract_elixir_call pushes a do_block's children onto its stack in + * reverse index order so they pop in source order. A `start_line < + * prev->start_line` guard here would be unreachable; if that traversal + * ever stops being source-ordered, this is the line that has to change + * with it. */ + if (end_line > prev->end_line) { + 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. +static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char *macro, + TSNode *scope) { CBMArena *a = ctx->arena; TSNode args = elixir_call_args(node); if (ts_node_is_null(args)) { @@ -9239,6 +10025,16 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char return; } + // `def name(args) when guard` parses the whole head as a `when` + // binary_operator, so the name lives on its left operand rather than + // directly under the call. Without unwrapping it, every guarded clause is + // dropped, and a function whose clauses ALL carry guards never appears in + // the graph at all -- silently, since a missing definition is not an error. + // The unwrap lives in helpers.c because it peels only `when`: an operator + // definition (`def a + b`) is a binary_operator head too, and unwrapping + // that one would name the function after its own left parameter. + first_arg = cbm_elixir_def_head_unwrap_guard(first_arg); + const char *fk = ts_node_type(first_arg); char *name = NULL; if (strcmp(fk, "call") == 0 && ts_node_child_count(first_arg) > 0) { @@ -9250,15 +10046,40 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char return; } + const char *qn = cbm_fqn_compute(a, ctx->project, ctx->rel_path, name); + 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); + 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; + } + CBMDefinition def; memset(&def, 0, sizeof(def)); def.name = name; - def.qualified_name = cbm_fqn_compute(a, ctx->project, ctx->rel_path, name); + def.qualified_name = qn; def.label = "Function"; def.file_path = ctx->rel_path; - def.start_line = ts_node_start_point(node).row + TS_LINE_OFFSET; - def.end_line = ts_node_end_point(node).row + TS_LINE_OFFSET; - def.is_exported = (strcmp(macro, "def") == 0 || strcmp(macro, "defmacro") == 0); + 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); } @@ -9295,6 +10116,10 @@ static TSNode emit_elixir_module_class(CBMExtractCtx *ctx, TSNode cur) { // without recursion between extract_elixir_call ↔ extract_elixir_module_def. static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec) { (void)spec; + /* Module body the last extracted clause was written in; see + * fold_elixir_clause. Null until the first def, so nothing folds into a + * def left over from a previous top-level call node. */ + TSNode def_scope = {0}; TSNodeStack stack; ts_nstack_init(&stack, ctx, CBM_SZ_64); ts_nstack_push(&stack, node); @@ -9317,12 +10142,17 @@ static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSp if (strcmp(macro, "def") == 0 || strcmp(macro, "defp") == 0 || strcmp(macro, "defmacro") == 0) { - extract_elixir_func_def(ctx, cur, macro); + extract_elixir_func_def(ctx, cur, macro, &def_scope); } else if (strcmp(macro, "if") == 0 || strcmp(macro, "unless") == 0) { /* A compile-time if/unless in a module body defines functions * under a condition: its do and else branches hold variants of the * same definitions (graph_buffer.c, "Definition variants"). The - * grammar nests the else_block inside the do_block. */ + * grammar nests the else_block inside the do_block. + * The clause fold composes with this: fold_elixir_clause only + * folds a clause into one written in the same block, and each + * branch is its own block, so the clauses of one branch fold into + * one definition while the branches stay separate definitions that + * the graph buffer merges as variants of one identity. */ TSNode branch = cbm_find_child_by_kind(cur, "do_block"); for (int pass = 0; pass < 2 && !ts_node_is_null(branch); pass++) { uint32_t bc = ts_node_child_count(branch); @@ -9642,6 +10472,121 @@ static void extract_enum_members(CBMExtractCtx *ctx, TSNode node, const char *cl } } +/* Whether the record body declares this accessor itself (JLS §8.10.3 lets a + * record override any of them), so the implicit one is not emitted twice. */ +static bool record_declares_accessor(CBMExtractCtx *ctx, TSNode node, const char *comp_name) { + TSNode body = find_class_body(node, ctx->language); + if (ts_node_is_null(body)) { + return false; + } + uint32_t mc = ts_node_named_child_count(body); + for (uint32_t mi = 0; mi < mc; mi++) { + TSNode member = ts_node_named_child(body, mi); + if (strcmp(ts_node_type(member), "method_declaration") != 0) { + continue; + } + TSNode mname = ts_node_child_by_field_name(member, TS_FIELD("name")); + if (ts_node_is_null(mname)) { + continue; + } + char *name = cbm_node_text(ctx->arena, mname, ctx->source); + if (!name || strcmp(name, comp_name) != 0) { + continue; + } + /* Same name, no parameters — that is the accessor. */ + TSNode params = ts_node_child_by_field_name(member, TS_FIELD("parameters")); + if (ts_node_is_null(params)) { + return true; + } + /* Comments are named extras, not parameters. */ + bool has_parameters = false; + uint32_t pc = ts_node_named_child_count(params); + for (uint32_t pi = 0; pi < pc; pi++) { + if (!ts_node_is_extra(ts_node_named_child(params, pi))) { + has_parameters = true; + break; + } + } + if (!has_parameters) { + return true; + } + } + return false; +} + +/* Java records: the compiler writes one accessor per component, so `p.x()` + * calls a method that appears nowhere in the source. Without a definition for + * it the call has nothing to resolve to and falls through to the project-wide + * name registry, which binds it to the first `x` in the tree — typically an + * unrelated class's private field. Emitting the accessors makes the record's + * own API visible, exactly as the language defines it (JLS §8.10.3). */ +static void extract_record_accessors(CBMExtractCtx *ctx, TSNode node, const char *class_qn) { + CBMArena *a = ctx->arena; + TSNode params = ts_node_child_by_field_name(node, TS_FIELD("parameters")); + if (ts_node_is_null(params)) { + return; + } + uint32_t pc = ts_node_named_child_count(params); + for (uint32_t pi = 0; pi < pc; pi++) { + TSNode comp = ts_node_named_child(params, pi); + bool spread = strcmp(ts_node_type(comp), "spread_parameter") == 0; + if (!spread && strcmp(ts_node_type(comp), "formal_parameter") != 0) { + continue; + } + TSNode cname = ts_node_child_by_field_name(comp, TS_FIELD("name")); + TSNode ctype = ts_node_child_by_field_name(comp, TS_FIELD("type")); + if (spread) { + /* Java varargs have an unnamed type child and a nested declarator. */ + TSNode decl = cbm_find_child_by_kind(comp, "variable_declarator"); + if (ts_node_is_null(decl)) { + continue; + } + cname = ts_node_child_by_field_name(decl, TS_FIELD("name")); + uint32_t nc = ts_node_named_child_count(comp); + for (uint32_t ci = 0; ci < nc; ci++) { + TSNode child = ts_node_named_child(comp, ci); + if (!ts_node_is_extra(child) && strcmp(ts_node_type(child), "modifiers") != 0) { + ctype = child; + break; + } + } + } + if (ts_node_is_null(cname)) { + continue; + } + char *comp_name = cbm_node_text(a, cname, ctx->source); + if (!comp_name || !comp_name[0] || record_declares_accessor(ctx, node, comp_name)) { + continue; + } + CBMDefinition adef; + memset(&adef, 0, sizeof(adef)); + adef.name = comp_name; + adef.qualified_name = cbm_arena_sprintf(a, "%s.%s", class_qn, comp_name); + adef.label = "Method"; + adef.file_path = ctx->rel_path; + adef.start_line = ts_node_start_point(comp).row + TS_LINE_OFFSET; + adef.end_line = ts_node_end_point(comp).row + TS_LINE_OFFSET; + adef.lines = 1; + adef.parent_class = class_qn; + adef.is_exported = true; + if (!ts_node_is_null(ctype)) { + const char *type = cbm_node_text(a, ctype, ctx->source); + adef.return_type = spread && type ? cbm_arena_sprintf(a, "%s[]", type) : type; + } + /* The compiler writes this member and the source never spells it: its + * node says so, and the pipeline registers it by qualified name only, + * so no short-name guess can land on it. */ + const char **kv = (const char **)cbm_arena_alloc(a, CBM_SZ_3 * sizeof(char *)); + if (kv) { + kv[0] = CBM_PROP_GENERATED; + kv[SKIP_ONE] = "record"; + kv[PAIR_LEN] = NULL; + } + adef.extra_props = kv; + cbm_defs_push(&ctx->result->defs, a, adef); + } +} + /* Resolve the identifier node from a destructure pattern child. * pair_pattern → value field; shorthand/identifier → itself; others → first named child. */ static TSNode destructure_ident(TSNode pat_child) { diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index c42ccebb6..857a43ea1 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -603,6 +603,11 @@ static const char *compute_elixir_func_qn(CBMExtractCtx *ctx, TSNode node) { if (ts_node_is_null(first_arg)) { return NULL; } + /* A guard wraps the whole head in a `when` operator, so the head naming the + * function is its left operand. Left wrapped, this returns NULL and the def + * opens NO function scope: every call in a guarded body then sources to the + * FILE node and the function itself reports no outgoing edges. */ + first_arg = cbm_elixir_def_head_unwrap_guard(first_arg); const char *fk = ts_node_type(first_arg); char *name = NULL; if (strcmp(fk, "call") == 0 && ts_node_child_count(first_arg) > 0) { @@ -1133,6 +1138,23 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan } } + /* Go init(): the def extractor folds the file basename and the init's + * per-file ordinal into the QN so every init in a package survives the + * upsert (#1910, the #495 cfg-twin pattern; ordinal, not line, so the QN + * is stable under edits above the function). Name it through the same + * cbm_go_init_qn, or init-body calls fall back to File-node attribution + * (calls_find_source). */ + if (ctx->language == CBM_LANG_GO && strcmp(name, "init") == 0 && + strcmp(ts_node_type(node), "function_declaration") == 0) { + const char *base_qn = cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, + name, ctx->language); + /* NULL only on allocation failure: fall through to the flat QN, as + * the receiver branch above does. */ + if (base_qn) { + return cbm_go_init_qn(ctx->arena, base_qn, ctx->rel_path, node, ctx->source); + } + } + /* Nix: a binding's own attrpath contributes scope (`a.b.fn = …`), and the def * extractor bakes it into the def QN. Compose it identically here — otherwise * an in-body call sources to a QN one or more segments short of the def, and @@ -2137,6 +2159,111 @@ static bool is_unified_trivia_node(TSNode node) { return ts_node_is_extra(node); } +static bool unified_node_text_equals(const CBMExtractCtx *ctx, TSNode node, const char *expected) { + if (ts_node_is_null(node) || !ctx->source || !expected) { + return false; + } + uint32_t start = ts_node_start_byte(node); + uint32_t end = ts_node_end_byte(node); + size_t len = strlen(expected); + return end >= start && (size_t)(end - start) == len && + memcmp(ctx->source + start, expected, len) == 0; +} + +/* An Elixir module attribute parses as `unary_operator(@, call(, args))`, + * so the typespec family reaches the unified walk in the shape real code has: + * `@spec foo(t) :: t` puts `foo(t)` in the same `call` node an invocation would + * occupy, and `@type entry :: String.t()` does the same to a type name. The + * walk then reads a declaration as code and mints reference edges from it. + * + * Measured on a fixture of three typespec lines (`@type state :: map()`, `@spec + * fetch(opts) :: state`, `@spec load(User.t()) :: :ok`) beside `def state`, + * `def opts` and a remote `MyApp.User.t/0`: seven edges, every one phantom and + * every one landing on a Function. CALLS onto each specified function; + * CALLS + USAGE + WRITES onto `state`, the WRITES asserting a mutation that + * does not exist; and USAGE onto `opts` and onto `MyApp.User.t/0`, because a + * bare or remote TYPE name resolves to the same-named FUNCTION. On a codebase + * whose convention is @spec on every public function, fan_in therefore never + * reaches 0 and "which exported functions nothing calls" is unanswerable. + * + * The whole subtree is skipped, not just the specified name. What that costs is + * not uniform across the six heads, and both halves were measured by indexing + * one fixture repo -- a declaring file per head plus the file each one reaches + * -- with the pristine binary and with this one: + * + * spec, type, typep, opaque. A type reference resolves onto a FUNCTION or + * onto nothing, never onto the type it names, because a type declaration + * mints no node. `@spec build(MyApp.User.t())` where that module's `t` is a + * `@type` produced no edge in either build, and `@type ext :: Ecto.Schema.t()` + * naming a module outside the repo produced none either. The references that + * do resolve land on a same-named Function, cross-file included -- `USAGE + * lib/decl_type.ex -> Function type_target @lib/remote_type.ex`, and the + * @typep and @opaque equivalents. A Function standing in for a Type is the + * pollution rather than a record of it, so nothing correct is lost here. + * + * callback, macrocallback. The declared name IS a function name, so it + * resolves onto an implementation elsewhere in the corpus: pristine yields + * `CALLS lib/decl_callback.ex -> Function callback_fun @lib/impl_callback.ex` + * and the @macrocallback equivalent, both cross-file, and this build yields + * neither. That edge does point at a real Behaviour-to-Impl relationship, and + * nothing replaces it -- `@behaviour MyBehaviour` mints no edge of its own, + * so after this change nothing in the graph links a behaviour to its + * implementations. It is deleted anyway because it is name resolution and not + * a behaviour model: on a fixture with one @callback and three same-named + * `handle_it/1` defs, the single edge landed on the one module that + * implements nothing, leaving both real implementors at fan_in 0. Recording + * that relationship correctly means minting it from `@behaviour`, which is a + * separate change and not a reason to keep an arbitrary edge. + * + * The other accepted cost: `unquote(...)` inside a typespec does execute at + * compile time, so `@type t :: unquote(build_type())` loses its genuine + * `build_type` call edge along with the phantoms. + * + * Only this family is skipped. Every other attribute VALUE is ordinary + * compile-time code -- `@timeout Application.compile_env(:app, :timeout)` + * really does call compile_env -- so those subtrees are walked exactly as + * before, and their attribute NAME is still emitted as a callee: `@behaviour + * GenServer`, `@timeout 5_000` and `@doc "x"` each still mint one phantom CALLS + * onto a same-named Function. That half of the defect class is untouched here + * and needs its own fix. + * + * This subtree skip is one of two mechanisms, and neither subsumes the other. + * It removes the phantom a typespec LINE mints; the def-head phantom is removed + * separately, by the calls walk recognising a `when`-wrapped head as a + * declaration (elixir_call_is_definition_role, extract_calls.c). A function + * carrying both an @spec and a `when` clause needs both: with only this one it + * keeps the def-head reference, with only the other it keeps the @spec one. + * `extract_elixir_typespec_attribute_is_not_code` pins that by asserting 0 + * references onto such a function, so removing either mechanism fails it. + * + * Measured over forge-symphony-graph/lib (970 .ex files) by classifying the + * source line every CALLS/USAGE/WRITES edge records: a build without this skip + * anchors 4,565 CALLS edges on a typespec-attribute line, and this build + * anchors 0 -- with no USAGE or WRITES appearing in their place, which is what + * a relabel rather than a removal would look like. Total USAGE edges fall from + * 25,393 to 23,044 over the same corpus; they do not rise. */ +static bool is_elixir_typespec_attribute(const CBMExtractCtx *ctx, TSNode node) { + static const char *const typespec_heads[] = { + "spec", "callback", "macrocallback", "type", "typep", "opaque", NULL}; + if (ctx->language != CBM_LANG_ELIXIR || strcmp(ts_node_type(node), "unary_operator") != 0 || + !unified_node_text_equals(ctx, ts_node_child_by_field_name(node, TS_FIELD("operator")), + "@")) { + return false; + } + TSNode operand = ts_node_child_by_field_name(node, TS_FIELD("operand")); + if (ts_node_is_null(operand) || strcmp(ts_node_type(operand), "call") != 0 || + ts_node_child_count(operand) == 0) { + return false; + } + TSNode head = ts_node_child(operand, 0); + for (const char *const *name = typespec_heads; *name; name++) { + if (unified_node_text_equals(ctx, head, *name)) { + return true; + } + } + return false; +} + // JS/TS `export_statement` appears in import_node_types so re-exports // (`export { X } from './m'`) are treated as an import boundary. But it also // wraps exported *declarations* (`export function f(cfg: Config) {}`), and @@ -2738,7 +2865,8 @@ void cbm_extract_unified(CBMExtractCtx *ctx) { break; } bool trivia = is_unified_trivia_node(node); - if (!trivia) { + bool typespec = is_elixir_typespec_attribute(ctx, node); + if (!trivia && !typespec) { /* Trivia consumes no semantic state. Scope expiry may be deferred * until the next code-bearing node; pop restores the displaced * tuple and push applies the new frame's effect, both O(1) -- @@ -2771,7 +2899,7 @@ void cbm_extract_unified(CBMExtractCtx *ctx) { * asking the cursor to construct a child iterator for every one of * hundreds of thousands of flat comment siblings. Structured extras * still descend normally. */ - if ((!trivia || ts_node_child_count(node) > 0) && + if (!typespec && (!trivia || ts_node_child_count(node) > 0) && ts_tree_cursor_goto_first_child(&cursor)) { depth++; continue; diff --git a/internal/cbm/extract_usages.c b/internal/cbm/extract_usages.c index a009b7964..bd49b759b 100644 --- a/internal/cbm/extract_usages.c +++ b/internal/cbm/extract_usages.c @@ -617,6 +617,11 @@ static bool is_elixir_def_binding(CBMExtractCtx *ctx, TSNode node) { TSNode signature = ts_node_named_child_count(arguments) > 0 ? ts_node_named_child(arguments, 0) : arguments; + /* A guard makes that signature the whole `when` operator, which spans the + * guard expression as well as the head. Unwrapped to the head, only the + * parameters being BOUND are excluded here; a parameter READ inside the + * guard stays a usage, as the same read in the body already is. */ + signature = cbm_elixir_def_head_unwrap_guard(signature); return node_contains(head, node) || node_contains(signature, node); } return false; diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index 9e310bc6c..796a9a4ef 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -1382,6 +1382,93 @@ const char *cbm_nix_qn_name(CBMArena *a, TSNode func_node, const char *source, c return scope ? cbm_arena_sprintf(a, "%s.%s", scope, name) : name; } +/* ── Elixir def-head guards ─────────────────────────────────── */ + +/* The guard sits on the head, not beside it: `def name(args) when guard` parses + * as a single `when` binary_operator whose left operand is `name(args)`. The + * operator field is an exact anonymous token, so the check is the token type + * rather than the node kind — `def a + b` is a binary_operator head too, and + * unwrapping it would name the function after its left parameter. */ +bool cbm_elixir_is_when_guard(TSNode node) { + if (ts_node_is_null(node) || strcmp(ts_node_type(node), "binary_operator") != 0) { + return false; + } + TSNode op = ts_node_child_by_field_name(node, TS_FIELD("operator")); + return !ts_node_is_null(op) && strcmp(ts_node_type(op), "when") == 0; +} + +/* One `when` level off a def's first argument, or the node unchanged. + * + * There is deliberately no named-child fallback for a missing `left`. An infix + * operator cannot reduce without a left operand, so tree-sitter-elixir never + * builds a `when` binary_operator that lacks one: measured over every guarded + * shape in the suite plus a corpus of truncated heads (`def when true`, + * `def f(x) when`, `def f(x) when when is_x(x)`, `def when() when when`), + * `left` was present on all 66 unwraps, ERROR-recovered parses included, and + * every one had the left operand as named child 0. Where the left operand was + * genuinely absent the grammar produced no `when` operator at all -- it read + * `when` as the head identifier -- so the unwrap is never entered. A + * named-child fallback would therefore be dead on a well-formed tree and, on + * the hypothetical malformed one, would hand back named child 0 without + * knowing it is the left operand rather than the guard expression: naming the + * function after its own guard. Returning the node unchanged degrades to the + * pre-guard behaviour instead, which is the safe direction. */ +static TSNode elixir_unwrap_one_guard(TSNode first_arg) { + if (!cbm_elixir_is_when_guard(first_arg)) { + return first_arg; + } + TSNode lhs = ts_node_child_by_field_name(first_arg, TS_FIELD("left")); + return ts_node_is_null(lhs) ? first_arg : lhs; +} + +/* The head under every guard a def's first argument carries, or first_arg + * unchanged when it carries none. Elixir admits more than one guard on a + * clause -- `def f(x) when is_atom(x) when is_binary(x)` -- and + * tree-sitter-elixir nests those RIGHT-associatively, measured on that exact + * source: `when(f(x), when(is_atom(x), is_binary(x)))`. The declared head is + * therefore the outer operator's left operand and one peel reaches it. The + * peel still runs to a fixed point: the extra iteration is one token compare, + * and it is what makes the helper's contract "the head under EVERY guard" + * rather than "the head under the first one", which is the property the three + * walks rely on. + * + * Left wrapped, the defs walk drops the clause, the unified walk opens no + * function scope (so every call in a guarded body sources to the FILE node), + * the calls walk stops recognising the head and emits it as an invocation of + * the very function being defined, and the usages walk swallows every + * identifier in the guard as part of the binding. Four walks read this same + * argument, which is why the unwrap lives here rather than in any one of + * them. */ +TSNode cbm_elixir_def_head_unwrap_guard(TSNode first_arg) { + for (;;) { + TSNode next = elixir_unwrap_one_guard(first_arg); + if (ts_node_eq(next, first_arg)) { + return first_arg; + } + first_arg = next; + } +} + +/* True when `node` is the head a def declares, or any of the `when` guard + * operators wrapping it. The calls walk needs the whole chain, not just its + * ends: each member reaches that walk by its own route -- `def f(x) when g` + * arrives as the inner `f(x)` call, `def f when g` has no inner call at all and + * the operator itself falls through to extract_callee_name's first-identifier + * last resort -- and a member it fails to recognise as a declaration is emitted + * as an invocation of the function being defined. */ +bool cbm_elixir_def_head_is(TSNode signature, TSNode node) { + for (;;) { + if (ts_node_eq(signature, node)) { + return true; + } + TSNode next = elixir_unwrap_one_guard(signature); + if (ts_node_eq(next, signature)) { + return false; + } + signature = next; + } +} + static const char *func_node_name(CBMArena *a, TSNode func_node, const char *source, CBMLanguage lang) { // Wolfram: set_delayed_top/set_top/set_delayed/set — LHS is apply(user_symbol("f"), ...) @@ -2330,6 +2417,65 @@ int cbm_classify_string(const char *str, int len) { return NOT_FOUND; } +/* 1-based source-order ordinal of a top-level Go `init` function_declaration + * among the file's `init`s. Unlike a line number, the ordinal is stable under + * edits above the function — QNs are node identity, and a line-based suffix + * would churn nodes on every unrelated insertion (the #495 cfg suffix and the + * GoogleTest derivation are both edit-stable for the same reason). + * One cursor pass over the top-level declarations: ts_node_named_child(root, i) + * is O(i), so an indexed loop is O(n^2) per init on a wide file, and generated + * Go (.pb.go, tables) carries thousands of top-level declarations next to its + * init(). */ +static int go_init_ordinal(TSNode fn_node, const char *source) { + TSNode root = fn_node; + for (TSNode p = ts_node_parent(root); !ts_node_is_null(p); p = ts_node_parent(p)) { + root = p; + } + int ordinal = 0; + TSTreeCursor cur = ts_tree_cursor_new(root); + if (ts_tree_cursor_goto_first_child(&cur)) { + do { + TSNode child = ts_tree_cursor_current_node(&cur); + if (strcmp(ts_node_type(child), "function_declaration") != 0) { + continue; + } + TSNode name = ts_node_child_by_field_name(child, TS_FIELD("name")); + if (ts_node_is_null(name)) { + continue; + } + uint32_t start = ts_node_start_byte(name); + if (ts_node_end_byte(name) - start != 4 || memcmp(source + start, "init", 4) != 0) { + continue; + } + ordinal++; + if (ts_node_eq(child, fn_node)) { + break; + } + } while (ts_tree_cursor_goto_next_sibling(&cur)); + } + ts_tree_cursor_delete(&cur); + return ordinal; +} + +/* Qualified name of a Go `init` function (#1910). Go allows any number of + * init() per package — even several in one file — so the flat `pkg.init` + * collides and the graph upsert keeps one node. The QN folds in the file + * basename and the init's per-file ordinal: `pkg.init#file.go:N`. Every side + * that names an init calls this one function — the definition extractor, the + * call-scope side (compute_func_qn) and the Go LSP's enclosing-function QN — + * so the three cannot drift. A NULL base QN or path keeps base_qn as is. */ +const char *cbm_go_init_qn(CBMArena *a, const char *base_qn, const char *rel_path, TSNode fn_node, + const char *source) { + if (!base_qn || !rel_path) { + return base_qn; + } + const char *base = strrchr(rel_path, '/'); + base = base ? base + 1 : rel_path; + const char *qn = + cbm_arena_sprintf(a, "%s#%s:%d", base_qn, base, go_init_ordinal(fn_node, source)); + return qn ? qn : base_qn; +} + /* Flatten a JS/TS `template_string` node into plain text (issue #1006). * String fragments are kept verbatim; each ${...} substitution becomes the * "{}" placeholder so client URLs built from template literals share the diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 3ffa80bc4..2e6b669fe 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -157,6 +157,26 @@ const char *cbm_nix_binding_scope_qn(CBMExtractCtx *ctx, TSNode node, const char // attrpath and an enclosing attrset compose into one qualified name. const char *cbm_nix_qn_name(CBMArena *a, TSNode func_node, const char *source, const char *name); +// ── Elixir def-head guards ── +// `def f(x) when g` parses its WHOLE head as a `when` binary_operator, so the +// `f(x)` call naming the function is the operator's left operand, and more than +// one guard (`when a when b`) nests those operators right-associatively, so the +// head stays the outermost operator's left operand in either shape. Four +// walks read that same first argument — the defs walk names the function from +// it, the unified walk opens the function's call scope from it, the calls walk +// tells a head apart from an invocation by it, and the usages walk excludes the +// head's identifiers from the usage set by it — so a private copy in one of +// them makes the rest disagree about what a guarded clause is. +// cbm_elixir_is_when_guard answers "is this node a guarded head"; the unwrap +// returns the head under every guard, or the node unchanged when it carries +// none; cbm_elixir_def_head_is answers whether a node is that head or one of +// the guard operators above it, which is the question the calls walk asks. +// All three test the operator token, so an operator definition (`def a + b`) is +// left alone rather than mis-read as a guard. +bool cbm_elixir_is_when_guard(TSNode node); +TSNode cbm_elixir_def_head_unwrap_guard(TSNode first_arg); +bool cbm_elixir_def_head_is(TSNode signature, TSNode node); + // Resolve a function/method definition node's NAME node across all ~130 grammars // (generic `name` field, arrow→declarator, C/C++ declarator chain, plus the many // per-language quirks: Fortran subroutine, SCSS mixin, SQL create_function, R, @@ -287,6 +307,13 @@ bool cbm_is_module_level(TSNode node, CBMLanguage lang); // node being classified. bool cbm_is_module_level_p(TSNode parent, CBMLanguage lang); +/* QN of a Go `init` function_declaration (#1910): base_qn + "#:", + * N = its 1-based ordinal among the file's top-level `init`s. The ONE formula + * for the def extractor, compute_func_qn and the Go LSP's enclosing-function + * QN. A NULL base_qn or rel_path returns base_qn unchanged. */ +const char *cbm_go_init_qn(CBMArena *a, const char *base_qn, const char *rel_path, TSNode fn_node, + const char *source); + // --- FQN computation --- // Compute qualified name: project.rel_path_parts.name 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/internal/cbm/lsp/go_lsp.c b/internal/cbm/lsp/go_lsp.c index dd9572747..3f053218d 100644 --- a/internal/cbm/lsp/go_lsp.c +++ b/internal/cbm/lsp/go_lsp.c @@ -1994,6 +1994,8 @@ static void process_function(GoLSPContext *ctx, TSNode func_node) { // resolver. Since #1909 receiver-qualified Go method QNs, that shared // form is package_qn.ReceiverType.method for methods (the ONE-formula // contract: cbm_go_receiver_type_name) and package_qn.func for functions. + // A package may hold any number of init() functions, so each one is + // package_qn.init#: (#1910, cbm_go_init_qn — the same formula). ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->package_qn, func_name); if (strcmp(ts_node_type(func_node), "method_declaration") == 0) { TSNode recv = ts_node_child_by_field_name(func_node, "receiver", 8); @@ -2004,6 +2006,9 @@ static void process_function(GoLSPContext *ctx, TSNode func_node) { recv_type, func_name); } } + } else if (strcmp(func_name, "init") == 0) { + ctx->enclosing_func_qn = cbm_go_init_qn(ctx->arena, ctx->enclosing_func_qn, ctx->rel_path, + func_node, ctx->source); } // Push function scope @@ -2261,7 +2266,7 @@ static void go_registry_with_stdlib(CBMTypeRegistry *reg, CBMArena *arena) { // --- Entry point: build registry from file defs + run LSP --- void cbm_run_go_lsp(CBMArena *arena, CBMFileResult *result, const char *source, int source_len, - TSNode root) { + TSNode root, const char *rel_path) { CBMTypeRegistry reg; cbm_registry_init(®, arena); @@ -2619,6 +2624,7 @@ void cbm_run_go_lsp(CBMArena *arena, CBMFileResult *result, const char *source, // Phase 2: Build LSP context and run GoLSPContext lsp_ctx; go_lsp_init(&lsp_ctx, arena, source, source_len, ®, module_qn, &result->resolved_calls); + lsp_ctx.rel_path = rel_path; // Add imports for (int i = 0; i < result->imports.count; i++) { @@ -3239,9 +3245,9 @@ static void extract_type_params_from_ast(CBMArena *arena, CBMTypeRegistry *reg, extern const TSLanguage *tree_sitter_go(void); void cbm_run_go_lsp_cross(CBMArena *arena, const char *source, int source_len, - const char *module_qn, CBMLSPDef *defs, int def_count, - const char **import_names, const char **import_qns, int import_count, - TSTree *cached_tree, CBMResolvedCallArray *out) { + const char *module_qn, const char *rel_path, CBMLSPDef *defs, + int def_count, const char **import_names, const char **import_qns, + int import_count, TSTree *cached_tree, CBMResolvedCallArray *out) { if (!source || source_len <= 0) return; @@ -3505,6 +3511,7 @@ void cbm_run_go_lsp_cross(CBMArena *arena, const char *source, int source_len, // 4. Build LSP context and run GoLSPContext ctx; go_lsp_init(&ctx, arena, source, source_len, ®, module_qn, out); + ctx.rel_path = rel_path; for (int i = 0; i < import_count; i++) { if (import_names[i] && import_qns[i]) { @@ -3647,10 +3654,10 @@ CBMTypeRegistry *cbm_go_build_cross_registry(CBMArena *arena, CBMLSPDef *defs, i } void cbm_run_go_lsp_cross_with_registry(CBMArena *arena, const char *source, int source_len, - const char *module_qn, CBMTypeRegistry *reg, - const char **import_names, const char **import_qns, - int import_count, TSTree *cached_tree, - CBMResolvedCallArray *out) { + const char *module_qn, const char *rel_path, + CBMTypeRegistry *reg, const char **import_names, + const char **import_qns, int import_count, + TSTree *cached_tree, CBMResolvedCallArray *out) { if (!source || source_len <= 0 || !reg) return; @@ -3676,6 +3683,7 @@ void cbm_run_go_lsp_cross_with_registry(CBMArena *arena, const char *source, int GoLSPContext ctx; go_lsp_init(&ctx, arena, source, source_len, reg, module_qn, out); + ctx.rel_path = rel_path; for (int i = 0; i < import_count; i++) { if (import_names[i] && import_qns[i]) { go_lsp_add_import(&ctx, import_names[i], import_qns[i]); @@ -3825,8 +3833,8 @@ void cbm_batch_go_lsp_cross(CBMArena *arena, CBMBatchGoLSPFile *files, int file_ // Delegate to existing per-file function cbm_run_go_lsp_cross(&file_arena, file->source, file->source_len, file->module_qn, - file->defs, file->def_count, file->import_names, file->import_qns, - file->import_count, file->cached_tree, &file_out); + file->rel_path, file->defs, file->def_count, file->import_names, + file->import_qns, file->import_count, file->cached_tree, &file_out); // Copy results to output arena (must outlive per-file arena) if (file_out.count > 0) { diff --git a/internal/cbm/lsp/go_lsp.h b/internal/cbm/lsp/go_lsp.h index 9e742a715..37a0b787a 100644 --- a/internal/cbm/lsp/go_lsp.h +++ b/internal/cbm/lsp/go_lsp.h @@ -22,6 +22,7 @@ typedef struct { // Current function context const char* enclosing_func_qn; const char* package_qn; // module QN for this file + const char* rel_path; // repo-relative file path: init() QN suffix (#1910); NULL = flat // Output: resolved calls accumulate here CBMResolvedCallArray* resolved_calls; @@ -63,8 +64,10 @@ const CBMRegisteredFunc* go_lookup_field_or_method(GoLSPContext* ctx, // Entry point: build registry from file's own defs + run LSP resolution. // Called from cbm_extract_file() after definitions and imports are extracted. +// rel_path is the file's repo-relative path: it names the file's init() +// functions exactly as the def extractor does (cbm_go_init_qn, #1910). void cbm_run_go_lsp(CBMArena* arena, CBMFileResult* result, - const char* source, int source_len, TSNode root); + const char* source, int source_len, TSNode root, const char* rel_path); // Register Go stdlib types and functions into a registry. // Auto-generated by scripts/gen-go-stdlib.go. @@ -112,11 +115,14 @@ typedef struct { // Re-parses source with tree-sitter internally. // defs includes both file-local and cross-file definitions. // import_names/import_qns are parallel arrays of length import_count. +// rel_path: the file's repo-relative path, for its init() QNs (#1910); NULL +// leaves init callers flat (they then miss the caller join). // Results written to out (arena-allocated). void cbm_run_go_lsp_cross( CBMArena* arena, const char* source, int source_len, const char* module_qn, + const char* rel_path, CBMLSPDef* defs, int def_count, const char** import_names, const char** import_qns, int import_count, TSTree* cached_tree, // NULL = parse internally @@ -135,6 +141,7 @@ void cbm_run_go_lsp_cross_with_registry( CBMArena* arena, const char* source, int source_len, const char* module_qn, + const char* rel_path, // see cbm_run_go_lsp_cross CBMTypeRegistry* reg, // pre-built, finalized, READ-ONLY const char** import_names, const char** import_qns, int import_count, TSTree* cached_tree, // NULL = parse internally @@ -160,6 +167,7 @@ typedef struct { const char* source; int source_len; const char* module_qn; + const char* rel_path; // repo-relative path (init() QNs, #1910); NULL = flat TSTree* cached_tree; // from TSTree caching (NULL = parse internally) CBMLSPDef* defs; // combined file-local + cross-file defs int def_count; diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index f71529a9d..74c1980e2 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -6179,10 +6179,36 @@ static void py_split_field_defs(CBMArena *arena, const char *field_defs, const c *types_out = types; } +/* Qualified names of reg->types[0..noted), for a caller that registers into a + * registry it does not finalize until the end (cbm_py_build_cross_registry): + * that registry's own lookup scans every type. qns == NULL probes the + * registry instead. */ +typedef struct { + CBMHashTable *qns; + int noted; +} PyKnownTypes; + +/* Note the types added since the last call. Run after every add_type, so an + * add that failed silently cannot desync set and registry. If the set cannot + * grow it is dropped and probes fall back to the registry. */ +static void py_note_known_types(PyKnownTypes *known, const CBMTypeRegistry *reg) { + for (; known && known->qns && known->noted < reg->type_count; known->noted++) { + const char *qn = reg->types[known->noted].qualified_name; + if (!qn || cbm_ht_has(known->qns, qn)) + continue; + cbm_ht_set(known->qns, qn, (void *)(uintptr_t)(known->noted + 1)); + if (!cbm_ht_has(known->qns, qn)) { + cbm_ht_free(known->qns); + known->qns = NULL; + } + } +} + /* Build a registry from CBMLSPDef[] supplied by the caller — covers both - * the source file's own defs and cross-file referenced defs. */ + * the source file's own defs and cross-file referenced defs. known is NULL + * except for the shared Tier-2 build. */ static bool py_register_lsp_defs(CBMArena *arena, CBMArena *idx_arena, CBMTypeRegistry *reg, - CBMLSPDef *defs, int def_count) { + CBMLSPDef *defs, int def_count, PyKnownTypes *known) { /* Pass 1: types only — the method pass probes the registry per Method def * (receiver auto-registration), which is a LINEAR scan pre-finalize: * O(methods x types) per file (same quadratic as php_register_lsp_defs; @@ -6206,6 +6232,7 @@ static bool py_register_lsp_defs(CBMArena *arena, CBMArena *idx_arena, CBMTypeRe } py_split_field_defs(arena, d->field_defs, &rt.field_names, &rt.field_types); cbm_registry_add_type(reg, rt); + py_note_known_types(known, reg); } } /* idx_arena == NULL skips the mid-build finalize: the tier-2 builder @@ -6259,13 +6286,17 @@ static bool py_register_lsp_defs(CBMArena *arena, CBMArena *idx_arena, CBMTypeRe if (strcmp(d->label, "Method") == 0 && d->receiver_type && d->receiver_type[0]) { rf.receiver_type = d->receiver_type; /* borrowed */ - if (!cbm_registry_lookup_type(reg, rf.receiver_type)) { + bool recv_known = known && known->qns + ? cbm_ht_has(known->qns, rf.receiver_type) + : cbm_registry_lookup_type(reg, rf.receiver_type) != NULL; + if (!recv_known) { CBMRegisteredType auto_t; memset(&auto_t, 0, sizeof(auto_t)); auto_t.qualified_name = rf.receiver_type; const char *dot = strrchr(d->receiver_type, '.'); auto_t.short_name = dot ? dot + 1 : rf.receiver_type; /* borrowed substring */ cbm_registry_add_type(reg, auto_t); + py_note_known_types(known, reg); } } cbm_registry_add_func(reg, rf); @@ -6277,7 +6308,7 @@ static bool py_register_lsp_defs(CBMArena *arena, CBMArena *idx_arena, CBMTypeRe #ifdef CBM_ENABLE_TEST_SEAMS bool cbm_py_lsp_test_register_defs(CBMArena *arena, CBMTypeRegistry *reg, CBMLSPDef *defs, int def_count) { - return py_register_lsp_defs(arena, NULL, reg, defs, def_count); + return py_register_lsp_defs(arena, NULL, reg, defs, def_count, NULL); } #endif @@ -6774,7 +6805,7 @@ CBMLSPStatus cbm_run_py_lsp_cross_status(CBMArena *arena, const char *source, in py_lsp_init_for_stage(&ctx, arena, source, source_len, ®, module_qn, out, "cross"); if (ctx.eval_failure) goto cross_cleanup; - if (!py_register_lsp_defs(arena, &idx_arena, ®, defs, def_count)) { + if (!py_register_lsp_defs(arena, &idx_arena, ®, defs, def_count, NULL)) { ctx.eval_failure = CBM_LSP_SCOPE_FAILED; goto cross_cleanup; } @@ -6813,15 +6844,27 @@ CBMTypeRegistry *cbm_py_build_cross_registry(CBMArena *arena, CBMLSPDef *defs, i cbm_registry_init(reg, arena); cbm_python_stdlib_register(reg, arena); + /* The registry is not finalized until the end, so probing it for each + * Method's receiver scanned every type: O(methods x types), single-threaded + * (~54 s of lsp_cross_prepare on a large Python tree). Probe a set of + * the registered type QNs instead; registration order is unchanged. Sized + * for the stdlib seed, not def_count (the mixed all-language count). NULL + * on allocation failure: the registry probe runs as before. */ + PyKnownTypes known = {cbm_ht_create((uint32_t)reg->type_count), 0}; + py_note_known_types(&known, reg); + /* Filter to Python defs only — defs[] is mixed-language all_defs. */ for (int i = 0; i < def_count; i++) { CBMLSPDef *d = &defs[i]; if (d->lang != CBM_LANG_PYTHON) continue; /* Reuse the existing register fn on a single-def slice (n=1 inline). */ - if (!py_register_lsp_defs(arena, NULL, reg, d, 1)) + if (!py_register_lsp_defs(arena, NULL, reg, d, 1, &known)) { + cbm_ht_free(known.qns); return NULL; /* caller owns the shared arena and other registries */ + } } + cbm_ht_free(known.qns); py_mark_ambiguous_callable_bindings(reg, arena); cbm_registry_build_type_qn_order(reg, arena); diff --git a/internal/cbm/service_patterns.c b/internal/cbm/service_patterns.c index 7d428b8e3..ee9c6d5f9 100644 --- a/internal/cbm/service_patterns.c +++ b/internal/cbm/service_patterns.c @@ -46,6 +46,25 @@ static const lib_pattern_t http_libraries[] = { * case-sensitive, so without this entry `Axios.get(url)` fell through to * the `.get` route-suffix fallback and became a route registration. */ {"Axios", CBM_SVC_HTTP, NULL}, + /* Angular HttpClient (#2768). The receiver rows end in a dot, like "gin.": + * a bare "this.http" / "$http" would also match this.httpAdapter.get + * (NestJS route registration) and $httpBackend.expectGET (an AngularJS test + * double), because an uppercase letter after an id counts as CamelCase + * glue. A chain wrapped before the verb keeps its line break in the callee + * text ("this.http\n .post", prettier's style), so each receiver also + * has a line-break twin: LF, and CR for CRLF files. Java/C# HttpClient is + * already listed below. */ + {"@angular/common/http", CBM_SVC_HTTP, NULL}, + {"angular.common.http", CBM_SVC_HTTP, NULL}, + {"this.http.", CBM_SVC_HTTP, NULL}, + {"this.http\n", CBM_SVC_HTTP, NULL}, + {"this.http\r", CBM_SVC_HTTP, NULL}, + {"httpClient.", CBM_SVC_HTTP, NULL}, + {"httpClient\n", CBM_SVC_HTTP, NULL}, + {"httpClient\r", CBM_SVC_HTTP, NULL}, + {"$http.", CBM_SVC_HTTP, NULL}, + {"$http\n", CBM_SVC_HTTP, NULL}, + {"$http\r", CBM_SVC_HTTP, NULL}, {"superagent", CBM_SVC_HTTP, NULL}, {"needle", CBM_SVC_HTTP, NULL}, {"node-fetch", CBM_SVC_HTTP, NULL}, @@ -1183,6 +1202,65 @@ const char *cbm_service_pattern_route_method(const char *callee_name) { return NULL; } +static bool mux_is_blank(char ch) { + return ch == ' ' || ch == '\t'; +} + +const char *cbm_service_pattern_mux_split(const char *pattern, char *method, size_t method_sz) { + if (method && method_sz > 0) { + method[0] = '\0'; + } + if (!pattern || pattern[0] == '/') { + return NULL; + } + /* METHOD: Go cuts it at the first space or tab and trims the rest. */ + const char *rest = pattern; + size_t method_len = 0; + while (pattern[method_len] >= 'A' && pattern[method_len] <= 'Z') { + method_len++; + } + if (method_len > 0 && mux_is_blank(pattern[method_len])) { + rest = pattern + method_len; + while (mux_is_blank(*rest)) { + rest++; + } + } else { + method_len = 0; + } + /* HOST: everything before the path's slash, with no blank or line break. */ + const char *slash = rest; + while (*slash && *slash != '/' && !mux_is_blank(*slash) && *slash != '\n' && *slash != '\r') { + slash++; + } + if (*slash != '/' || (method_len == 0 && slash == rest)) { + return NULL; + } + if (method_len > 0 && method) { + if (method_len >= method_sz) { + method[0] = '\0'; + return NULL; + } + memcpy(method, pattern, method_len); + method[method_len] = '\0'; + } + return slash; +} + +const char *cbm_service_pattern_mux_route_path(const char *callee_name, const char *pattern) { + static const char *const mux_callees[] = {".Handle", ".HandleFunc"}; + if (!callee_name || !pattern || strstr(pattern, "://") != NULL) { + return NULL; + } + size_t clen = strlen(callee_name); + for (size_t i = 0; i < sizeof(mux_callees) / sizeof(mux_callees[0]); i++) { + size_t slen = strlen(mux_callees[i]); + if (clen >= slen && strcmp(callee_name + clen - slen, mux_callees[i]) == 0) { + return cbm_service_pattern_mux_split(pattern, NULL, 0); + } + } + return NULL; +} + const char *cbm_service_pattern_broker(const char *resolved_qn) { if (!resolved_qn) { return NULL; diff --git a/internal/cbm/service_patterns.h b/internal/cbm/service_patterns.h index f06c85571..0a182568e 100644 --- a/internal/cbm/service_patterns.h +++ b/internal/cbm/service_patterns.h @@ -84,6 +84,25 @@ const char *cbm_service_pattern_http_method(const char *callee_name); * Returns NULL if not a known route registration method. */ const char *cbm_service_pattern_route_method(const char *callee_name); +/* Go 1.22+ net/http ServeMux pattern "[METHOD ][HOST]/[PATH]" (#2537): + * "POST /v1/x", "GET api.example.com/v1/x", "api.example.com/v1/x". Returns a + * pointer to PATH's leading slash inside `pattern`, or NULL when the pattern + * names neither a method nor a host before a slash (a plain "/v1/x" is NULL + * too: there is nothing to split). METHOD is a run of uppercase letters ended + * by spaces or tabs; it is copied to `method` (NUL-terminated, "" when the + * pattern names none) when `method` is non-NULL; a method that does not fit + * `method_sz` then makes the pattern NULL. The grammar is Go's: callers gate + * on the file language. */ +const char *cbm_service_pattern_mux_split(const char *pattern, char *method, size_t method_sz); + +/* The path of a ServeMux registration's pattern (#2537): only a callee ending + * ".Handle" or ".HandleFunc" registers one, and never with a URL ("://"). + * Another route-verb call names no pattern: `store.Get("users/123")` is no + * host "users" + path "/123", `client.Get("https://...")` no host "https:". + * The split of cbm_service_pattern_mux_split for such a registration, else + * NULL. The grammar is Go's: callers gate on the file language. */ +const char *cbm_service_pattern_mux_route_path(const char *callee_name, const char *pattern); + /* Get the broker name for an async QN (e.g., "pubsub" from a Pub/Sub QN). * Returns NULL if not an async pattern. */ const char *cbm_service_pattern_broker(const char *resolved_qn); diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json new file mode 100644 index 000000000..0960a2b71 --- /dev/null +++ b/plugin/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "codebase-memory", + "version": "0.12.1", + "description": "Codebase knowledge graph for AI agents: sub-ms queries, 99% fewer tokens.", + "author": { + "name": "DeusData", + "url": "https://github.com/DeusData" + } +} diff --git a/plugin/.mcp.json b/plugin/.mcp.json new file mode 100644 index 000000000..07fbe13c1 --- /dev/null +++ b/plugin/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "codebase-memory-mcp": { + "type": "stdio", + "command": "npx", + "args": ["-y", "codebase-memory-mcp@0.12.1"] + } + } +} diff --git a/scripts/memory-core-baseline.txt b/scripts/memory-core-baseline.txt index 6eeabfa44..d9f632e7c 100644 --- a/scripts/memory-core-baseline.txt +++ b/scripts/memory-core-baseline.txt @@ -74,9 +74,9 @@ src/pipeline/pass_semantic.c 3 src/pipeline/pass_tests.c 3 src/pipeline/pass_usages.c 4 src/pipeline/path_alias.c 40 -src/pipeline/pipeline.c 132 +src/pipeline/pipeline.c 129 src/pipeline/pipeline_delta.c 19 -src/pipeline/pipeline_incremental.c 140 +src/pipeline/pipeline_incremental.c 130 src/pipeline/registry.c 17 src/pipeline/worker_pool.c 2 src/store/store.c 354 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 3db818c28..587e25918 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -20,6 +20,7 @@ #include "daemon/runtime.h" #include "daemon/version_cohort.h" #include "foundation/compat.h" +#include "foundation/compat_fs.h" #include "foundation/platform.h" #include "foundation/constants.h" #include "foundation/json_writer.h" @@ -161,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 @@ -1532,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" @@ -1653,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"; @@ -1673,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; } @@ -2718,12 +3036,27 @@ int cbm_remove_zed_mcp_owned(const char *binary_path, const char *config_path) { /* ── Agent detection ──────────────────────────────────────────── */ static bool dir_exists(const char *path) { - struct stat st; + cbm_path_info_t info = {0}; + if (cbm_path_info_utf8(path, &info) != CBM_PATH_INFO_OK) { + return false; + } + if (info.is_directory) { + return true; + } #ifndef _WIN32 - return lstat(path, &st) == 0 && S_ISDIR(st.st_mode); -#else - return stat(path, &st) == 0 && S_ISDIR(st.st_mode); + if (info.is_symlink) { + uid_t self = geteuid(); + struct stat link_state; + if (lstat(path, &link_state) != 0 || !S_ISLNK(link_state.st_mode) || + link_state.st_uid != self) { + return false; + } + struct stat target_state; + return stat(path, &target_state) == 0 && S_ISDIR(target_state.st_mode) && + target_state.st_uid == self; + } #endif + return false; } /* Resolve the Claude Code config dir. @@ -7467,6 +7800,13 @@ static const char *get_cache_dir(const char *home_dir) { return cbm_resolve_cache_dir(); } +/* A per-project ADR sidecar (".adr.db") sits in the cache beside its index + * but is not itself an index — exclude it from listing/counting. */ +static bool cli_is_adr_sidecar(const char *name, size_t len) { + const size_t ext = sizeof(".adr.db") - 1; + return len >= ext && strcmp(name + len - ext, ".adr.db") == 0; +} + int cbm_list_indexes(const char *home_dir) { const char *cache_dir = get_cache_dir(home_dir); if (!cache_dir) { @@ -7481,6 +7821,7 @@ int cbm_list_indexes(const char *home_dir) { int count = 0; cbm_dirent_t *ent; while ((ent = cbm_readdir(d)) != NULL) { + /* cbm_is_project_index_db already excludes the ".adr.db" sidecar. */ if (cbm_is_project_index_db(ent->name)) { printf(" %s/%s\n", cache_dir, ent->name); count++; @@ -7504,9 +7845,18 @@ int cbm_remove_indexes(const char *home_dir) { int count = 0; cbm_dirent_t *ent; while ((ent = cbm_readdir(d)) != NULL) { - if (cbm_is_project_index_db(ent->name)) { - char path[CLI_BUF_1K]; - snprintf(path, sizeof(path), "%s/%s", cache_dir, ent->name); + bool is_index = cbm_is_project_index_db(ent->name); + /* A ".adr.db" ADR sidecar is excluded from cbm_is_project_index_db, + * but a full index wipe must still remove it (and any orphan whose index + * is already gone) — otherwise the next re-index of that name inherits a + * stale ADR. It appears as its own dirent, so remove it directly. */ + bool is_adr = cli_is_adr_sidecar(ent->name, strlen(ent->name)); + if (!is_index && !is_adr) { + continue; + } + char path[CLI_BUF_1K]; + snprintf(path, sizeof(path), "%s/%s", cache_dir, ent->name); + if (is_index) { /* Also remove .db.tmp if present */ char tmp_path[CLI_FIELD_1040]; snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path); @@ -7520,6 +7870,11 @@ int cbm_remove_indexes(const char *home_dir) { * its .db. Sidecars are not indexes, so count is unchanged. */ cbm_remove_db_sidecars(path); cbm_remove_db_sidecars(tmp_path); + } else { + /* ADR sidecar: remove it and its own -wal/-shm/-journal; not an + * index, so count is unchanged. */ + cbm_unlink(path); + cbm_remove_db_sidecars(path); } } cbm_closedir(d); @@ -8460,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; @@ -8966,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, @@ -9548,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; @@ -9865,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); @@ -9911,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", @@ -10757,7 +11403,8 @@ static int count_db_indexes(const char *home) { int count = 0; cbm_dirent_t *ent; while ((ent = cbm_readdir(d)) != NULL) { - if (cbm_is_project_index_db(ent->name)) { + if (cbm_is_project_index_db(ent->name) && + !cli_is_adr_sidecar(ent->name, strlen(ent->name))) { count++; } } @@ -10991,7 +11638,20 @@ void cbm_cli_set_client_selection_for_testing(const char *spec) { /* Detect the running binary's path at runtime. The return value distinguishes * OS-reported identity (safe ownership evidence) from the ~/.local/bin fallback * used only as an install copy source. */ +#ifdef CBM_CLI_ENABLE_TEST_API +static const char *g_cli_self_path_override_for_test = NULL; +void cbm_cli_set_self_path_override_for_test(const char *self_path) { + g_cli_self_path_override_for_test = self_path; +} +#endif + static bool cbm_detect_self_path(char *buf, size_t buf_sz, const char *home) { +#ifdef CBM_CLI_ENABLE_TEST_API + if (g_cli_self_path_override_for_test) { + snprintf(buf, buf_sz, "%s", g_cli_self_path_override_for_test); + return true; + } +#endif buf[0] = '\0'; bool exact = false; #ifdef _WIN32 @@ -11209,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) { @@ -11227,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); } @@ -11237,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"); @@ -11253,6 +11923,7 @@ char *cbm_build_install_plan_json(const char *home, const char *binary_path) { typedef struct { const char *bin_target; + const char *config_binary; const char *bin_dir; const char *home; const char *shell_rc; @@ -11277,6 +11948,8 @@ static int cli_install_activate(void *opaque) { !activation->shell_rc) { return CLI_TRUE; } + const char *config_binary = + activation->config_binary ? activation->config_binary : activation->bin_target; char previous_managed_binary[CLI_BUF_1K] = {0}; bool previous_managed_binary_exact = cbm_detect_self_path( previous_managed_binary, sizeof(previous_managed_binary), activation->home); @@ -11325,10 +11998,13 @@ static int cli_install_activate(void *opaque) { * including same-binary and non-force installs. */ int agent_config_rc = CLI_OK; if (!activation->skip_config) { + const char *prev = previous_managed_binary_exact ? previous_managed_binary : NULL; + if (config_binary != activation->bin_target && + (!prev || strcmp(prev, config_binary) == 0)) { + prev = activation->bin_target; + } agent_config_rc = cbm_install_agent_configs_with_previous( - activation->home, activation->bin_target, - previous_managed_binary_exact ? previous_managed_binary : NULL, activation->force, - activation->dry_run); + activation->home, config_binary, prev, activation->force, activation->dry_run); } if (agent_config_rc != CLI_OK) { cli_activation_transaction_finalize_committed_or_fail_stop( @@ -11475,11 +12151,40 @@ int cbm_cmd_install(int argc, char **argv) { return CLI_TRUE; } + char self_path[CLI_BUF_1K] = {0}; + bool self_path_exact = cbm_detect_self_path(self_path, sizeof(self_path), home); + + /* #1566: a binary owned by mise/Homebrew/nix is not ours to relocate. Infer + * it, and let either flag override the inference in either direction. */ + bool externally_managed = + !force_binary && cli_binary_is_externally_managed(self_path, self_path_exact, bin_dir); + bool explicit_skip_binary = skip_binary; + if (externally_managed) { + skip_binary = true; + } + + /* NOT stat(): on Windows it goes through the ANSI code page, so an + * extended-length or non-ASCII target reports "absent" and a non-force + * install silently overwrites bytes the user asked to keep. */ + cbm_path_info_t target_status; + bool target_exists = cbm_path_info_utf8(bin_target, &target_status) == 0; + bool same_binary = cbm_same_file(self_path, bin_target); + + /* #2722: when a binary is managed externally (mise, brew, nix, etc.), + * do not write the non-existent default target into agent configurations. + * Use the running managed binary path instead. */ + const char *config_binary = bin_target; + if (externally_managed && self_path_exact && self_path[0]) { + config_binary = self_path; + } else if (skip_binary && !target_exists && self_path_exact && self_path[0]) { + config_binary = self_path; + } + /* --plan: emit the machine-readable install receipt and exit WITHOUT * mutating anything (no config writes, no index deletion, no network) so * an agent can inspect exactly what install would touch first (#388). */ if (plan) { - char *json = cbm_build_install_plan_json_options(home, bin_target, skip_config); + char *json = cbm_build_install_plan_json_options(home, config_binary, skip_config); if (!json) { (void)fprintf(stderr, "error: failed to build install plan\n"); return CLI_TRUE; @@ -11501,16 +12206,9 @@ int cbm_cmd_install(int argc, char **argv) { printf("codebase-memory-mcp install %s\n\n", CBM_VERSION); - char self_path[CLI_BUF_1K] = {0}; - bool self_path_exact = cbm_detect_self_path(self_path, sizeof(self_path), home); - - /* #1566: a binary owned by mise/Homebrew/nix is not ours to relocate. Infer - * it, and let either flag override the inference in either direction. */ - bool externally_managed = - !force_binary && cli_binary_is_externally_managed(self_path, self_path_exact, bin_dir); - if (externally_managed || skip_binary) { + if (externally_managed || explicit_skip_binary) { const char *manager = cli_external_manager_name(self_path); - if (skip_binary) { + if (explicit_skip_binary) { printf("Skipping binary placement (--skip-binary); configuring agents only.\n\n"); } else { printf("Binary is managed elsewhere%s%s:\n %s\n" @@ -11518,15 +12216,8 @@ int cbm_cmd_install(int argc, char **argv) { " (use --force-binary to install a copy into %s anyway)\n\n", manager ? " by " : "", manager ? manager : "", self_path, bin_dir); } - skip_binary = true; } - /* NOT stat(): on Windows it goes through the ANSI code page, so an - * extended-length or non-ASCII target reports "absent" and a non-force - * install silently overwrites bytes the user asked to keep. */ - cbm_path_info_t target_status; - bool target_exists = cbm_path_info_utf8(bin_target, &target_status) == 0; - bool same_binary = cbm_same_file(self_path, bin_target); bool do_copy = !skip_binary && !same_binary && (!target_exists || force); /* (#607) Default: preserve existing indexes. `--reset-indexes` opts into @@ -11693,6 +12384,7 @@ int cbm_cmd_install(int argc, char **argv) { #endif cli_install_activation_t activation = { .bin_target = bin_target, + .config_binary = config_binary, .bin_dir = bin_dir, .home = home, .shell_rc = shell_rc, @@ -11940,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]; @@ -12361,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)); @@ -12402,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 4d32873ca..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. */ @@ -250,6 +303,8 @@ void cbm_cli_set_client_selection_for_testing(const char *spec); * not recognisably foreign. Detection must be positive evidence — see the * implementation comment. */ const char *cbm_cli_external_manager_name_for_testing(const char *self_path); +/* #2722: override runtime binary self-path detection for testing. */ +void cbm_cli_set_self_path_override_for_test(const char *self_path); /* #1558: expose the config-key table so a test can prove a key is discoverable * (`config list`/`set` both walk it). */ size_t cbm_cli_config_key_count_for_testing(void); 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/cli/hook_augment.c b/src/cli/hook_augment.c index c161f1dbf..ddee6aaa0 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -1707,6 +1707,12 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root scope = "Compaction"; } const char *tier = ha_active_tier(root, event); + /* SubagentStart carries no tool list, so agents defined without MCP tools + * (e.g. tools: Read, Grep, Glob, Bash) still get this; point them at the CLI. */ + const char *cli_hint = strcmp(event, "SubagentStart") == 0 + ? " The same tools are also available from the shell: " + "`codebase-memory-mcp cli --help` lists each tool's flags." + : ""; if (project) { char safe_project[HA_METADATA_CAP]; ha_sanitize_metadata(project, safe_project, sizeof(safe_project)); @@ -1723,8 +1729,8 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root "reported missed lines directly and qualify conclusions. For structural " "code discovery use search_graph, then trace_path, then get_code_snippet; " "use query_graph or get_architecture for broader structure. Use grep, glob, " - "and file reads for literals, configs, non-code files, and verification.", - scope, safe_project, tier); + "and file reads for literals, configs, non-code files, and verification.%s", + scope, safe_project, tier, cli_hint); } else { const char *index_guidance = ha_no_project_index_guidance(event); snprintf(context, sizeof(context), @@ -1735,8 +1741,8 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root "call check_index_coverage for every file relied on; if incomplete, read the " "reported missed lines directly and qualify conclusions. Use search_graph, " "trace_path, and get_code_snippet first; use grep for " - "literals, configs, non-code files, and verification.", - scope, index_guidance, tier); + "literals, configs, non-code files, and verification.%s", + scope, index_guidance, tier, cli_hint); } free(project); if (dialect == HA_DIALECT_COPILOT) { diff --git a/src/daemon/application.c b/src/daemon/application.c index 0026e8839..a5216846b 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -196,11 +196,11 @@ struct cbm_daemon_application_index_record { cbm_daemon_application_index_record_t *next; }; -/* A watcher-triggered physical job is owned by the exact live sessions that - * currently subscribe to its project/root watch. The callback waiting for the - * job is only a storage waiter; it is deliberately not an ownership - * subscription, so the worker is cancelled when the last matching session - * disconnects even while unrelated daemon sessions remain alive. */ +/* A watcher-triggered physical job is normally owned by the exact live + * sessions subscribed to its project/root watch. In permanent daemon mode the + * watcher itself additionally owns the physical job so background indexing can + * continue while no client session is connected. The callback remains a + * storage waiter in both modes and releases daemon ownership on completion. */ struct cbm_daemon_application_watch_job_subscription { cbm_daemon_application_session_t *session; cbm_daemon_application_job_t *job; @@ -564,8 +564,26 @@ static bool application_watches_disabled_locked(cbm_daemon_application_t *applic return true; } -static void application_release_session_watch_locked(cbm_daemon_application_session_t *session) { +static void application_release_session_watch_locked(cbm_daemon_application_session_t *session, + bool session_ending) { application_watch_job_unsubscribe_session_locked(session, NULL); + + /* + * A permanent daemon owns the physical project watch independently of + * individual client sessions. Session disconnect releases only that + * session's logical subscription; the watcher remains available for + * background change detection while the daemon generation is alive. + * + * Non-permanent applications retain the historical session-scoped + * lifecycle and remove the physical watch after the final subscriber. + * A release decided by workspace policy (session_ending false: the + * session's root is no longer allowed) is not a disconnect and removes + * the watch in both modes. Daemon shutdown and auto_watch=false remove + * persistent watches through their own paths; a deleted project's watch + * ends at its next watcher callback + * (application_permanent_watch_orphaned_locked). + */ + bool daemon_keeps_watch = session_ending && session->application->permanent; while (session->watches) { application_watch_membership_t *membership = session->watches; cbm_daemon_application_watch_t *watch = membership->watch; @@ -573,7 +591,7 @@ static void application_release_session_watch_locked(cbm_daemon_application_sess cbm_free(CBM_MEM_CLASS_OTHER, membership); if (watch->subscribers > 0) { watch->subscribers--; - if (watch->subscribers == 0) { + if (!daemon_keeps_watch && watch->subscribers == 0) { application_remove_watch_locked(session->application, watch); } } @@ -721,7 +739,7 @@ static void application_refresh_watch_locked(cbm_daemon_application_session_t *s return; } if (!application_session_workspace_allowed(session, "watch")) { - application_release_session_watch_locked(session); + application_release_session_watch_locked(session, false); return; } application_register_watch_locked(session, cbm_mcp_server_session_project(session->mcp), @@ -806,7 +824,9 @@ static const char *application_watch_strategy_name(cbm_watcher_strategy_t strate /* index_status watch visibility (#2167). The physical watcher is the truth for * "watched"; session roots and successful explicit targets stay watched while - * at least one owning MCP session remains open. Takes the + * at least one owning MCP session remains open (under `daemon start`, also + * after the last one closes, until the daemon stops, the project is deleted, + * its root disappears, or auto_watch is switched off). Takes the * application mutex and the watcher lock one after the other, never nested. */ static void application_watch_status(void *context, const char *project, cbm_mcp_watch_status_t *out) { @@ -2126,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; } @@ -2539,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; } @@ -3649,7 +3672,7 @@ static void application_session_cancel(void *context, reap_update || (session->background_eligible && application->update_thread_started && application->update_owners == 0); application_index_watch_unsubscribe_locked(session, NULL); - application_release_session_watch_locked(session); + application_release_session_watch_locked(session, true); bool final_live_session = newly_cancelled; for (cbm_daemon_application_session_t *other = application->sessions; final_live_session && other; other = other->next) { @@ -3709,7 +3732,7 @@ static void application_session_close(void *context, reap_update || (session->background_eligible && application->update_thread_started && application->update_owners == 0); application_index_watch_unsubscribe_locked(session, NULL); - application_release_session_watch_locked(session); + application_release_session_watch_locked(session, true); cbm_mutex_unlock(&application->mutex); application_auto_index_cancel_join(application, join_auto_index); application_jobs_reap_completed(application); @@ -4256,11 +4279,27 @@ static void application_watch_encode_index_args_locked(const cbm_daemon_applicat *args = encoded ? yyjson_mut_write(document, 0, NULL) : NULL; } +/* Under `daemon start` a watch outlives its last session, so delete_project + * from another session or the CLI can remove the project's DB while nothing + * owns the watch any more: daemon MCP sessions carry no watcher pointer and + * never unwatch it. Re-indexing such an orphan would resurrect the deleted + * project. A full reindex also unlinks the DB while it rebuilds it, so a + * missing DB means "deleted" only while no job for the project is running. + * Caller holds application->mutex. */ +static bool application_permanent_watch_orphaned_locked(cbm_daemon_application_t *application, + const cbm_daemon_application_watch_t *watch, + const char *project_key) { + return application->permanent && watch->subscribers == 0 && + !application_find_active_job_locked(application, project_key) && + !application_regular_db_exists(watch->project); +} + static cbm_daemon_application_job_t *application_background_index_admit( cbm_daemon_application_t *application, const char *project_name, const char *canonical_root, const char *project_key, yyjson_mut_doc *document, yyjson_mut_val *root, char **args, - bool require_live_watch, int *no_job_result) { + bool require_live_watch, bool *watcher_owns_job, int *no_job_result) { application_job_subscribe_status_t subscribe_status = APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE; + *watcher_owns_job = false; application_jobs_reap_completed(application); cbm_mutex_lock(&application->mutex); if (require_live_watch) { @@ -4268,9 +4307,15 @@ static cbm_daemon_application_job_t *application_background_index_admit( } cbm_daemon_application_watch_t *watch = require_live_watch ? application_find_watch_locked(application, project_name) : NULL; - bool watch_live = - !require_live_watch || (watch && watch->subscribers > 0 && - application_watch_root_matches(watch->root, canonical_root)); + if (watch && application_permanent_watch_orphaned_locked(application, watch, project_key)) { + /* End the orphan the way a session refresh ends a deleted project's + * watch; the callback below becomes a harmless skip. */ + application_remove_watch_locked(application, watch); + watch = NULL; + } + bool watch_live = !require_live_watch || + (watch && application_watch_root_matches(watch->root, canonical_root) && + (watch->subscribers > 0 || application->permanent)); if (require_live_watch && watch_live) { application_watch_encode_index_args_locked(watch, document, root, args); } @@ -4284,16 +4329,28 @@ static cbm_daemon_application_job_t *application_background_index_admit( if (job && require_live_watch) { watch_subscriptions_ok = application_watch_job_subscribe_sessions_locked( application, watch, job, &watch_owner_count); - if (watch_subscriptions_ok && watch_owner_count > 0) { - job->watcher_waiters++; - } else if (!watch_subscriptions_ok) { + + if (!watch_subscriptions_ok) { subscribe_status = APPLICATION_JOB_SUBSCRIBE_ALLOCATION_FAILED; - } - /* application_job_subscribe_locked() lends the caller one ordinary - * subscriber. A watcher callback is only a storage waiter: exact live - * session subscriptions above own the physical work. */ - application_job_unsubscribe_locked(job); - if (!watch_subscriptions_ok || watch_owner_count == 0) { + application_job_unsubscribe_locked(job); + job = NULL; + } else if (application->permanent) { + /* + * The ordinary subscription acquired above is retained as daemon + * ownership. This allows the physical watcher to run indexing even + * while no client session currently owns the project watch. + */ + *watcher_owns_job = true; + job->watcher_waiters++; + } else if (watch_owner_count > 0) { + /* + * Historical non-permanent behavior: exact live sessions own the + * physical work; the watcher callback itself is only a waiter. + */ + job->watcher_waiters++; + application_job_unsubscribe_locked(job); + } else { + application_job_unsubscribe_locked(job); job = NULL; } } @@ -4318,7 +4375,7 @@ static cbm_daemon_application_job_t *application_background_index_admit( static int application_background_index_wait(cbm_daemon_application_t *application, cbm_daemon_application_job_t *job, - bool require_live_watch) { + bool require_live_watch, bool watcher_owns_job) { bool successful = false; bool cancelled = false; for (;;) { @@ -4331,6 +4388,9 @@ static int application_background_index_wait(cbm_daemon_application_t *applicati if (job->watcher_waiters > 0) { job->watcher_waiters--; } + if (watcher_owns_job) { + application_job_unsubscribe_locked(job); + } } else { application_job_unsubscribe_locked(job); } @@ -4366,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); @@ -4387,13 +4448,15 @@ static int application_background_index(cbm_daemon_application_t *application, } int no_job_result = APPLICATION_BACKGROUND_INDEX_FAILED; + bool watcher_owns_job = false; cbm_daemon_application_job_t *job = application_background_index_admit( application, project_name, canonical_root, project_key, document, root, &args, - require_live_watch, &no_job_result); + require_live_watch, &watcher_owns_job, &no_job_result); free(project_key); free(args); yyjson_mut_doc_free(document); - return job ? application_background_index_wait(application, job, require_live_watch) + return job ? application_background_index_wait(application, job, require_live_watch, + watcher_owns_job) : no_job_result; } diff --git a/src/discover/discover.h b/src/discover/discover.h index f09766b1d..65e5556e0 100644 --- a/src/discover/discover.h +++ b/src/discover/discover.h @@ -65,8 +65,9 @@ CBMLanguage cbm_disambiguate_res(const char *path); CBMLanguage cbm_disambiguate_d(const char *path); /* Disambiguate .inc files by reading first 4KB of content. - * Returns CBM_LANG_OBJECTSCRIPT_ROUTINE if it looks like an ObjectScript - * include (a "ROUTINE " header), otherwise CBM_LANG_BITBAKE. + * Returns CBM_LANG_CPP for explicit C-family code, or + * CBM_LANG_OBJECTSCRIPT_ROUTINE for ObjectScript markers / a lone #define. + * BitBake recipe syntax and unknown content retain CBM_LANG_BITBAKE. * On read failure, defaults to CBM_LANG_BITBAKE. */ CBMLanguage cbm_disambiguate_inc(const char *path); diff --git a/src/discover/language.c b/src/discover/language.c index 4292e2900..bfd76a070 100644 --- a/src/discover/language.c +++ b/src/discover/language.c @@ -1573,38 +1573,150 @@ CBMLanguage cbm_disambiguate_cls(const char *path) { return lang_cls_text(buf); } -/* Disambiguate .inc files: shared by BitBake include fragments and - * InterSystems ObjectScript include (macro) files. ObjectScript .inc files are - * predominantly macro definitions ("#define NAME ..." / "#def1arg NAME ..."); - * some also carry a "ROUTINE " header. The macro-preprocessor directives - * are the strongest signal because that is the primary content of an .inc file, - * whereas BitBake uses '#' only for "# comment" lines (always '#' + space). - * We therefore match ObjectScript preprocessor directives ('#' immediately - * followed by 'def'/';'), which BitBake never produces. Defaults to BitBake on - * any doubt (preserves existing behaviour). */ +/* A complete leading token, followed by whitespace or punctuation, within a + * single line. In particular #include_extra is not #include. */ +static const char *lang_inc_word(const char *line, const char *end, const char *word) { + size_t n = strlen(word); + if ((size_t)(end - line) < n || strncmp(line, word, n) != 0 || + (line + n < end && (isalnum((unsigned char)line[n]) || line[n] == '_'))) { + return NULL; + } + line += n; + while (line < end && isspace((unsigned char)*line)) { + line++; + } + return line; +} + +/* Recipes can embed C in quoted strings or shell/Python tasks. Recognize the + * recipe's own syntax before inspecting those bodies as standalone C code. */ +static bool lang_inc_recipe_line(const char *line, const char *end) { + static const char *const words[] = {"require", "include", "inherit", "addtask", "python", NULL}; + for (int i = 0; words[i]; i++) { + const char *tail = lang_inc_word(line, end, words[i]); + if (tail && tail > line + strlen(words[i])) { + return true; + } + } + if ((size_t)(end - line) >= SLEN("do_") && strncmp(line, "do_", SLEN("do_")) == 0) { + return true; + } + const char *p = line; + while (p < end && (isalnum((unsigned char)*p) || *p == '_' || *p == ':' || *p == '[' || + *p == ']' || *p == '-' || *p == '.')) { + p++; + } + if (p == line) { + return false; + } + while (p < end && isspace((unsigned char)*p)) { + p++; + } + return p < end && (*p == '=' || ((*p == '?' || *p == '+' || *p == ':' || *p == '.') && + p + SKIP_ONE < end && p[SKIP_ONE] == '=')); +} + +/* Skip only leading whitespace and C comments, keeping block-comment state + * across lines. Recipe strings and bodies are handled before looking for C. */ +static const char *lang_inc_code(const char *line, const char *end, bool *block_comment) { + while (line < end) { + if (*block_comment) { + const char *close = strstr(line, "*/"); + if (!close || close >= end) { + return end; + } + line = close + SLEN("*/"); + *block_comment = false; + continue; + } + if (isspace((unsigned char)*line)) { + line++; + continue; + } + if ((size_t)(end - line) >= SLEN("/*") && strncmp(line, "/*", SLEN("/*")) == 0) { + *block_comment = true; + line += SLEN("/*"); + continue; + } + break; + } + return line; +} + +static bool lang_inc_c_code(const char *line, const char *end) { + static const char *const words[] = { + "extern", "static", "typedef", "namespace", "template", + "struct", "class", "int", "void", NULL, + }; + for (int i = 0; words[i]; i++) { + const char *tail = lang_inc_word(line, end, words[i]); + if (tail && tail < end && *tail != '=') { + return true; + } + } + const char *tail = lang_inc_word(line, end, "if"); + return tail && tail < end && *tail == '('; +} + +static bool lang_inc_directive(const char *line, const char *end, bool *c_family, + bool *objectscript_define) { + line += SKIP_ONE; /* '#' */ + while (line < end && isspace((unsigned char)*line)) { + line++; + } + if (lang_inc_word(line, end, "def1arg") || (line < end && *line == ';')) { + return true; /* Explicit ObjectScript directive. */ + } + if (lang_inc_word(line, end, "define")) { + *objectscript_define = true; + } + const char *tail = lang_inc_word(line, end, "include"); + if (tail && tail < end && (*tail == '"' || *tail == '<')) { + *c_family = true; + } + return false; +} + +/* .inc is shared by C/C++, BitBake and ObjectScript. Select C++ for explicit + * C-family code (including C fragments); do not infer an enclosing include + * context. A lone #define and unknown content retain their existing defaults. */ static CBMLanguage lang_inc_text(const char *buf) { + bool c_family = false; + bool objectscript_define = false; + bool block_comment = false; const char *line = buf; while (*line) { - /* ObjectScript include header: a line beginning "ROUTINE ". */ - if (strncmp(line, "ROUTINE ", SLEN("ROUTINE ")) == 0 && - isupper((unsigned char)line[SLEN("ROUTINE ")])) { + const char *end = strchr(line, '\n'); + if (!end) { + end = line + strlen(line); + } + const char *code = lang_inc_code(line, end, &block_comment); + line = *end ? end + SKIP_ONE : end; + if (code == end) { + continue; + } + const char *tail = lang_inc_word(code, end, "ROUTINE"); + if (tail && tail < end && isupper((unsigned char)*tail)) { return CBM_LANG_OBJECTSCRIPT_ROUTINE; } - /* ObjectScript macro directives — the primary content of .inc files. - * "#define"/"#def1arg" (macro defs) and "#;" (line comment). BitBake's - * only '#' use is "# comment" (hash + space), so these never collide. */ - if (strncmp(line, "#define", SLEN("#define")) == 0 || - strncmp(line, "#def1arg", SLEN("#def1arg")) == 0 || - strncmp(line, "#;", SLEN("#;")) == 0) { - return CBM_LANG_OBJECTSCRIPT_ROUTINE; + if (*code == '#') { + if (lang_inc_directive(code, end, &c_family, &objectscript_define)) { + return CBM_LANG_OBJECTSCRIPT_ROUTINE; + } + continue; } - const char *nl = strchr(line, '\n'); - if (!nl) { - break; + if ((size_t)(end - code) >= SLEN("//") && strncmp(code, "//", SLEN("//")) == 0) { + continue; } - line = nl + SKIP_ONE; + if (!c_family && lang_inc_recipe_line(code, end)) { + return CBM_LANG_BITBAKE; + } + c_family = c_family || lang_inc_c_code(code, end); } - return CBM_LANG_BITBAKE; + if (c_family) { + return CBM_LANG_CPP; + } + return objectscript_define ? CBM_LANG_OBJECTSCRIPT_ROUTINE : CBM_LANG_BITBAKE; } CBMLanguage cbm_disambiguate_inc(const char *path) { @@ -1775,7 +1887,7 @@ CBMLanguage cbm_language_classify_with(const cbm_userconfig_t *config, const cha /* ObjectScript UDL, Apex or a Visual Basic 6 class module */ lang = readable ? lang_cls_text(buf) : CBM_LANG_APEX; } else if (strcmp(ext, ".inc") == 0) { - /* BitBake or an ObjectScript include */ + /* C/C++, BitBake or an ObjectScript include */ lang = readable ? lang_inc_text(buf) : CBM_LANG_BITBAKE; } else if (cfc) { /* script or tag dialect */ diff --git a/src/foundation/str_util.c b/src/foundation/str_util.c index 00c6b4e68..76b30deb2 100644 --- a/src/foundation/str_util.c +++ b/src/foundation/str_util.c @@ -317,7 +317,9 @@ bool cbm_is_internal_cache_db(const char *filename) { bool cbm_is_project_index_db(const char *filename) { static const char db_ext[] = ".db"; + static const char adr_ext[] = ".adr.db"; const size_t ext_len = sizeof(db_ext) - 1; + const size_t adr_len = sizeof(adr_ext) - 1; if (!filename) { return false; } @@ -325,6 +327,12 @@ bool cbm_is_project_index_db(const char *filename) { if (len <= ext_len || strcmp(filename + len - ext_len, db_ext) != 0) { return false; } + /* A per-project ADR sidecar (".adr.db") ends in ".db" but is not itself + * an index — exclude it from every index scan/count (MCP loops + the CLI's + * cbm_list_indexes / count_db_indexes all gate on this helper). */ + if (len >= adr_len && strcmp(filename + len - adr_len, adr_ext) == 0) { + return false; + } return !cbm_is_internal_cache_db(filename); } 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 1fc5f4588..29685e4c8 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -729,7 +729,9 @@ static const tool_def_t TOOLS[] = { {"search_code", "Graph-ranked text search: compact symbols, full bounded source, or file paths.", "{\"type\":\"object\",\"properties\":{\"pattern\":{\"type\":\"string\"},\"project\":{\"type\":" - "\"string\"},\"file_pattern\":{\"type\":\"string\"},\"path_filter\":{\"type\":\"string\"}," + "\"string\"},\"file_pattern\":{\"type\":\"string\",\"description\":\"Glob on file name; " + "with '/', on repo-relative path (** = any dirs).\"}," + "\"path_filter\":{\"type\":\"string\",\"description\":\"Regex on repo-relative path.\"}," "\"mode\":{\"type\":\"string\"," "\"enum\":[\"compact\",\"full\",\"files\"],\"default\":\"compact\"}," "\"context\":{\"type\":\"integer\",\"default\":0,\"minimum\":0,\"maximum\":200}," @@ -767,7 +769,7 @@ static const tool_def_t TOOLS[] = { "\"offset\":{\"type\":\"integer\",\"default\":0,\"minimum\":0}," "\"metadata_only\":{\"type\":\"boolean\",\"default\":false," "\"description\":\"Compatibility: omit counts, size, and branch.\"}}}"}, - {"delete_project", "Delete a project from the index", + {"delete_project", "Delete a project from the index, including its ADR store", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":[" "\"project\"]}"}, @@ -4034,6 +4036,8 @@ enum { * and then joining/filtering/re-ranking those, we bound all work to O(N) where * N = BM25_INNER_LIMIT rather than the full match set size. */ BM25_INNER_LIMIT = 2000, + /* SQLite reads a negative LIMIT as "no limit": rank every candidate. */ + BM25_INNER_ALL = -1, /* Longest label spliced into the MATCH pre-filter; real labels are short * identifiers, so anything longer simply skips the pre-filter. */ BM25_LABEL_MAX = 64, @@ -4389,6 +4393,96 @@ static char *bm25_render(const bm25_output_row_t *rows, int returned, int total, return json; } +/* The MATCH expression and the filters the ranked and the count query share. + * One binder for both keeps them on identical parameters. */ +typedef struct { + const char *fts_query; + const char *project; + const char *file_like; + const char *label; + const char *kind; + const char *ns; + double label_weight; +} bm25_filters_t; + +static void bm25_bind_opt_text(sqlite3_stmt *st, int idx, const char *value) { + if (value && value[0]) { + sqlite3_bind_text(st, idx, value, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); + } else { + sqlite3_bind_null(st, idx); + } +} + +static void bm25_bind_filters(sqlite3_stmt *st, const bm25_filters_t *f) { + sqlite3_bind_text(st, BM25_BIND_QUERY, f->fts_query, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); + sqlite3_bind_text(st, BM25_BIND_PROJECT, f->project, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); + bm25_bind_opt_text(st, BM25_BIND_FILE, f->file_like); + bm25_bind_opt_text(st, BM25_BIND_LABEL, f->label); + bm25_bind_opt_text(st, BM25_BIND_KIND, f->kind); + bm25_bind_opt_text(st, BM25_BIND_NS, f->ns); + sqlite3_bind_double(st, BM25_BIND_LABEL_WEIGHT, f->label_weight); +} + +/* The top-candidate window is a performance ceiling, not an exact-total + * boundary. Probe one candidate beyond it so a broad query never presents a + * window-local count as the complete match count. The probe shares the + * label-scoped MATCH but is otherwise global to the FTS table; saturation is + * therefore conservatively reported even when later project/path filters + * might discard the hidden candidates. */ +static bool bm25_window_saturated(sqlite3 *db, const char *fts_query) { + enum { PROBE_BIND_OFFSET = 2 }; + const char *probe_sql = "SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH ?1 " + "ORDER BY bm25(nodes_fts), rowid LIMIT 1 OFFSET ?2"; + sqlite3_stmt *probe = NULL; + if (sqlite3_prepare_v2(db, probe_sql, BM25_SQL_AUTO_LEN, &probe, NULL) != SQLITE_OK) { + return true; + } + sqlite3_bind_text(probe, BM25_BIND_QUERY, fts_query, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); + sqlite3_bind_int(probe, PROBE_BIND_OFFSET, BM25_INNER_LIMIT); + bool saturated = sqlite3_step(probe) != SQLITE_DONE; + sqlite3_finalize(probe); + return saturated; +} + +/* Count the candidates that pass the filters among the top `inner_limit` + * (BM25_INNER_ALL: among all of them), with the ranked query's subquery shape + * so FTS5 drives the scan here too. */ +static int bm25_count_candidates(sqlite3 *db, const bm25_filters_t *f, int inner_limit) { + const char *count_sql = + "SELECT COUNT(*) FROM (" + " SELECT fts.rowid FROM (" + " SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH ?1" + " ORDER BY " BM25_WEIGHTS " LIMIT ?3" + " ) fts " + " JOIN nodes n ON n.id = fts.rowid " + " WHERE n.project = ?2 " + /* MIRRORS the ranked query's filter verbatim — same weights, same + * label exclusions. Changing one alone reports a total that does + * not describe the rows returned. */ + " AND " BM25_UNLABELLED_EXCLUSION " " + " AND (?6 IS NULL OR n.file_path LIKE ?6)" + " AND (?7 IS NULL OR n.label = ?7)" + " AND (?9 IS NULL OR (CASE WHEN json_valid(n.properties) " + " THEN json_extract(n.properties,'$.kind') END) = ?9)" + " AND (?10 IS NULL OR (CASE WHEN json_valid(n.properties) " + " THEN json_extract(n.properties,'$.obj_key') END) " + " LIKE ('%/%/' || replace(replace(replace(?10,'\\','\\\\'),'%','\\%')," + " '_','\\_') || '/%') ESCAPE '\\')" + ")"; + int total = 0; + sqlite3_stmt *cs = NULL; + if (sqlite3_prepare_v2(db, count_sql, BM25_SQL_AUTO_LEN, &cs, NULL) != SQLITE_OK) { + return 0; + } + bm25_bind_filters(cs, f); + sqlite3_bind_int(cs, BM25_BIND_LIMIT, inner_limit); + if (sqlite3_step(cs) == SQLITE_ROW) { + total = sqlite3_column_int(cs, 0); + } + sqlite3_finalize(cs); + return total; +} + /* Run the BM25 full-text search path and return the JSON result string. * Returns NULL if FTS5 is unavailable or the query produced no usable tokens, * in which case the caller falls back to the regex-based search path. */ @@ -4476,117 +4570,39 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu free(file_like); return NULL; } - sqlite3_bind_text(stmt, BM25_BIND_QUERY, fts_query, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); - sqlite3_bind_text(stmt, BM25_BIND_PROJECT, project, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); + const bm25_filters_t filters = {.fts_query = fts_query, + .project = project, + .file_like = file_like, + .label = label, + .kind = kind, + .ns = ns, + .label_weight = label_weight}; + bm25_bind_filters(stmt, &filters); sqlite3_bind_int(stmt, BM25_BIND_LIMIT, limit > 0 ? limit : BM25_DEFAULT_LIMIT); sqlite3_bind_int(stmt, BM25_BIND_OFFSET, offset > 0 ? offset : 0); - sqlite3_bind_int(stmt, BM25_BIND_INNER, BM25_INNER_LIMIT); - if (file_like) { - sqlite3_bind_text(stmt, BM25_BIND_FILE, file_like, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); - } else { - sqlite3_bind_null(stmt, BM25_BIND_FILE); - } - if (label && label[0]) { - sqlite3_bind_text(stmt, BM25_BIND_LABEL, label, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); - } else { - sqlite3_bind_null(stmt, BM25_BIND_LABEL); - } sqlite3_bind_text(stmt, BM25_BIND_EXACT, query, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); - if (kind && kind[0]) { - sqlite3_bind_text(stmt, BM25_BIND_KIND, kind, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); - } else { - sqlite3_bind_null(stmt, BM25_BIND_KIND); - } - if (ns && ns[0]) { - sqlite3_bind_text(stmt, BM25_BIND_NS, ns, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); - } else { - sqlite3_bind_null(stmt, BM25_BIND_NS); - } - sqlite3_bind_double(stmt, BM25_BIND_LABEL_WEIGHT, label_weight); - /* Count hits within the same inner-limit window — capped at BM25_INNER_LIMIT. - * Uses the identical subquery structure so the FTS5 early-exit applies here too. */ - int total = 0; - { - const char *count_sql = - "SELECT COUNT(*) FROM (" - " SELECT fts.rowid FROM (" - " SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH ?1" - " ORDER BY " BM25_WEIGHTS " LIMIT ?3" - " ) fts " - " JOIN nodes n ON n.id = fts.rowid " - " WHERE n.project = ?2 " - /* MIRRORS the ranked query's filter verbatim — same weights, same - * label exclusions. Changing one alone reports a total that does - * not describe the rows returned. */ - " AND " BM25_UNLABELLED_EXCLUSION " " - " AND (?6 IS NULL OR n.file_path LIKE ?6)" - " AND (?7 IS NULL OR n.label = ?7)" - " AND (?9 IS NULL OR (CASE WHEN json_valid(n.properties) " - " THEN json_extract(n.properties,'$.kind') END) = ?9)" - " AND (?10 IS NULL OR (CASE WHEN json_valid(n.properties) " - " THEN json_extract(n.properties,'$.obj_key') END) " - " LIKE ('%/%/' || replace(replace(replace(?10,'\\','\\\\'),'%','\\%')," - " '_','\\_') || '/%') ESCAPE '\\')" - ")"; - sqlite3_stmt *cs = NULL; - if (sqlite3_prepare_v2(db, count_sql, BM25_SQL_AUTO_LEN, &cs, NULL) == SQLITE_OK) { - sqlite3_bind_text(cs, BM25_BIND_QUERY, fts_query, BM25_SQL_AUTO_LEN, - MCP_SQLITE_TRANSIENT); - sqlite3_bind_text(cs, BM25_BIND_PROJECT, project, BM25_SQL_AUTO_LEN, - MCP_SQLITE_TRANSIENT); - sqlite3_bind_int(cs, BM25_BIND_LIMIT, BM25_INNER_LIMIT); - sqlite3_bind_double(cs, BM25_BIND_LABEL_WEIGHT, label_weight); - if (file_like) { - sqlite3_bind_text(cs, BM25_BIND_FILE, file_like, BM25_SQL_AUTO_LEN, - MCP_SQLITE_TRANSIENT); - } else { - sqlite3_bind_null(cs, BM25_BIND_FILE); - } - if (label && label[0]) { - sqlite3_bind_text(cs, BM25_BIND_LABEL, label, BM25_SQL_AUTO_LEN, - MCP_SQLITE_TRANSIENT); - } else { - sqlite3_bind_null(cs, BM25_BIND_LABEL); - } - if (kind && kind[0]) { - sqlite3_bind_text(cs, BM25_BIND_KIND, kind, BM25_SQL_AUTO_LEN, - MCP_SQLITE_TRANSIENT); - } else { - sqlite3_bind_null(cs, BM25_BIND_KIND); - } - if (ns && ns[0]) { - sqlite3_bind_text(cs, BM25_BIND_NS, ns, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT); - } else { - sqlite3_bind_null(cs, BM25_BIND_NS); - } - if (sqlite3_step(cs) == SQLITE_ROW) { - total = sqlite3_column_int(cs, 0); - } - sqlite3_finalize(cs); - } - } - - /* The top-candidate window is a performance ceiling, not an exact-total - * boundary. Probe one candidate beyond it so a broad query never presents - * a window-local count as the complete match count. The probe shares the - * label-scoped MATCH but is otherwise global to the FTS table; saturation - * is therefore conservatively reported even when later project/path - * filters might discard the hidden candidates. */ - bool candidate_window_saturated = true; - { - const char *probe_sql = "SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH ?1 " - "ORDER BY bm25(nodes_fts), rowid LIMIT 1 OFFSET ?2"; - sqlite3_stmt *probe = NULL; - if (sqlite3_prepare_v2(db, probe_sql, BM25_SQL_AUTO_LEN, &probe, NULL) == SQLITE_OK) { - sqlite3_bind_text(probe, BM25_BIND_QUERY, fts_query, BM25_SQL_AUTO_LEN, - MCP_SQLITE_TRANSIENT); - sqlite3_bind_int(probe, BM25_BIND_PROJECT, BM25_INNER_LIMIT); - int probe_step = sqlite3_step(probe); - candidate_window_saturated = probe_step != SQLITE_DONE; - sqlite3_finalize(probe); - } + /* file_pattern, kind and namespace cannot be put into the MATCH the way + * the label is, so they filter AFTER the candidate window. When more + * candidates exist beyond it, a narrow filter can find none of its rows in + * the top BM25_INNER_LIMIT and the search answered 0 rows although + * matching nodes exist. A saturated window under such a filter -- or one + * that held no row passing the filters at all -- is therefore ranked over + * every candidate instead: the FTS-first subquery plan stays, the extra + * work is linear in the token's match count (the saturation probe already + * orders every match), and the total becomes exact. The decision depends + * only on the query, never on the page, so offset pages stay consistent. + * Unfiltered and label-only queries that find rows keep the window. */ + bool saturated = bm25_window_saturated(db, fts_query); + bool post_window_filter = file_like || (kind && kind[0]) || (ns && ns[0]); + int inner_limit = saturated && post_window_filter ? BM25_INNER_ALL : BM25_INNER_LIMIT; + int total = bm25_count_candidates(db, &filters, inner_limit); + if (saturated && total == 0 && inner_limit != BM25_INNER_ALL) { + inner_limit = BM25_INNER_ALL; + total = bm25_count_candidates(db, &filters, inner_limit); } + bool candidate_window_saturated = saturated && inner_limit != BM25_INNER_ALL; + sqlite3_bind_int(stmt, BM25_BIND_INNER, inner_limit); int row_cap = limit > 0 ? limit : BM25_DEFAULT_LIMIT; bm25_output_row_t *rows = @@ -7869,7 +7885,9 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { return result; } -/* delete_project: just erase the .db file (and WAL/SHM). */ +/* delete_project: erase the .db file (and WAL/SHM) plus the ADR sidecar + * ".adr.db" (and its WAL/SHM/journal), so deleting a project removes its + * ADR store with it. */ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) { char *name = get_project_arg(args); if (!name) { @@ -7908,18 +7926,37 @@ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) { const char *error_detail = NULL; bool is_error = false; + /* ADR sidecar lives beside the graph DB as ".adr.db" — remove it (and + * its own WAL/SHM/journal) whenever the project is deleted. Sized as the + * graph path plus the ".adr.db" suffix. */ + char adr_path[CBM_SZ_1K + sizeof(".adr.db")]; + int adr_n = snprintf(adr_path, sizeof(adr_path), "%s.adr.db", path); + bool have_adr_path = adr_n > 0 && (size_t)adr_n < sizeof(adr_path); + if (exists) { int rc = cbm_unlink(path); (void)cbm_unlink(wal); (void)cbm_unlink(shm); if (rc == 0) { status = "deleted"; + /* Only drop the ADR store once the graph DB is actually gone, so a + * failed graph delete leaves the project (and its ADR) intact. */ + if (have_adr_path) { + (void)cbm_unlink(adr_path); + (void)cbm_remove_db_sidecars(adr_path); + } } else { status = "delete_failed"; error_detail = strerror(errno); is_error = true; } } else { + /* No graph DB, but a sidecar could still linger (e.g. ADR written then + * the index deleted out of band) — best-effort clean it up. */ + if (have_adr_path) { + (void)cbm_unlink(adr_path); + (void)cbm_remove_db_sidecars(adr_path); + } is_error = true; } @@ -8280,12 +8317,10 @@ static int arch_compute_cycles(cbm_store_t *store, const char *project, int64_t *scanned_edges = ecount; scc_graph_t g; if (!scc_build(src, tgt, ecount, &g)) { - free(src); - free(tgt); + cbm_store_free_call_edges(src, tgt); return CBM_STORE_OK; /* no edges = no cycles, not an error */ } - free(src); - free(tgt); + cbm_store_free_call_edges(src, tgt); int *comp = malloc((size_t)g.nverts * sizeof(int)); if (!comp) { @@ -10333,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); @@ -10687,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"); @@ -10770,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; @@ -10853,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); @@ -10920,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); @@ -10948,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", @@ -11747,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; @@ -12435,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; } @@ -12738,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 @@ -12867,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) { @@ -13039,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); @@ -13201,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 — @@ -14716,6 +14876,7 @@ typedef struct { uint64_t enrich_ms; uint64_t elapsed_ms; bool include_phase_timings; + bool hint_path_filter; /* a search with a '/' file_pattern returned no matches */ } search_metrics_t; #ifdef CBM_ENABLE_TEST_SEAMS @@ -14810,6 +14971,47 @@ static int search_result_cmp(const void *a, const void *b) { return 0; } +#define SEARCH_CODE_PATH_HINT \ + "no results; a file_pattern with '/' must match the whole repo-relative path (e.g. " \ + "src/**/*.c); to scope by folder you can also use path_filter (regex on the " \ + "repo-relative path, e.g. ^src/cli/)" + +/* A file_pattern containing '/' is matched against the whole repo-relative path, like + * search_graph's file_pattern: '*' and '**' match any run of characters including '/', a + * '**' followed by '/' also matches zero directories, '?' matches one character, and a + * pattern without wildcards is a substring match. Portable (no fnmatch on Windows) and + * linear: every '*' is equivalent, so one backtrack point suffices. */ +static bool search_code_path_glob_match(const char *pat, const char *path) { + if (!strpbrk(pat, "*?")) { + return strstr(path, pat) != NULL; + } + const char *star_pat = NULL; + const char *star_path = NULL; + for (;;) { + if (*pat == '*') { + const char *run = pat; + while (*pat == '*') { + pat++; + } + if (pat - run > SKIP_ONE && *pat == '/') { + pat++; + } + star_pat = pat; + star_path = path; + } else if (*path && (*pat == '?' || *pat == *path)) { + pat++; + path++; + } else if (!*pat && !*path) { + return true; + } else if (star_pat && *star_path) { + pat = star_pat; + path = ++star_path; + } else { + return false; + } + } +} + /* Moving an arbitrary file_pattern ahead of Select-String is not generally results-preserving: * the current Windows path applies PowerShell -like to the full MatchInfo.Path, while POSIX * delegates glob semantics to grep --include. Restrict the Windows optimization to plain suffix @@ -15432,6 +15634,9 @@ static char *assemble_search_output_toon(search_result_t *sr, int sr_count, grep "(not as alternation). Pass regex=true for 'foo|bar' to mean " "'foo OR bar'."); } + if (metrics->hint_path_filter) { + cbm_tree_scalar_str(&sb, "hint", SEARCH_CODE_PATH_HINT); + } if (metrics->elapsed_ms >= SEARCH_SLOW_MS) { cbm_tree_scalar_str(&sb, "warning_slow", "search was slow; narrow file_pattern/path_filter or use a more " @@ -15671,6 +15876,9 @@ static char *assemble_search_output( if (yyjson_mut_arr_size(warnings) > 0) { yyjson_mut_obj_add_val(doc, root_obj, "warnings", warnings); } + if (metrics->hint_path_filter) { + yyjson_mut_obj_add_str(doc, root_obj, "hint", SEARCH_CODE_PATH_HINT); + } char *json = yy_doc_to_str(doc); if (json) { @@ -16448,6 +16656,17 @@ static bool write_scoped_filelist(cbm_mcp_server_t *srv, const char *project, co continue; } } + /* A file_pattern with '/' is matched here on every platform; the basename + * filters below (and the Windows prefilter, which rejects '/') skip it. */ + bool path_glob = file_pattern && strchr(file_pattern, '/'); + if (path_glob) { +#ifdef _WIN32 + cbm_normalize_path_sep(indexed_files[fi]); +#endif + if (!search_code_path_glob_match(file_pattern, indexed_files[fi])) { + continue; + } + } #ifndef _WIN32 /* GNU grep's --include is unavailable in BusyBox grep. Filter the * canonical list before xargs instead, preserving grep's basename @@ -16455,7 +16674,7 @@ static bool write_scoped_filelist(cbm_mcp_server_t *srv, const char *project, co * on GNU userland. Windows keeps its PowerShell -like filter. */ const char *basename = strrchr(indexed_files[fi], '/'); basename = basename ? basename + SKIP_ONE : indexed_files[fi]; - if (file_pattern && fnmatch(file_pattern, basename, 0) != 0) { + if (file_pattern && !path_glob && fnmatch(file_pattern, basename, 0) != 0) { continue; } #endif @@ -17473,8 +17692,8 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { bool scan_in_process = scoped && !srv->search_scan_command_override; #ifdef _WIN32 scan_in_process = - scan_in_process && - (!file_pattern || cbm_search_code_file_pattern_can_prefilter(file_pattern)); + scan_in_process && (!file_pattern || strchr(file_pattern, '/') || + cbm_search_code_file_pattern_can_prefilter(file_pattern)); #endif mcp_scan_cause_t scan_cause; if (scan_in_process) { @@ -17646,6 +17865,7 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { directory_output = directory_limit; } bool warn_literal_pipe = pat_has_pipe && !use_regex; + metrics.hint_path_filter = file_pattern && strchr(file_pattern, '/') && gm_count == 0; /* Every render below re-checks each result's file against the root; one * containment for the request keeps that to one resolution per file. */ root_containment_t request_containment; @@ -20718,9 +20938,10 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { } } - /* ADRs are stored in the SQLite store (project_summaries), the SAME - * backend the UI /api/adr endpoints use — so writes via the MCP tool and - * the UI are visible to each other (#256). */ + /* ADRs are stored in the per-project ADR sidecar DB (".adr.db"), the + * SAME backend the UI /api/adr endpoints use (via cbm_store_adr_*) — so + * writes via the MCP tool and the UI are visible to each other (#256), and + * a reindex of the graph DB never disturbs them. */ store_recovery_status_t recovery_status = STORE_RECOVERY_NONE; cbm_store_t *resolved = resolve_store_internal(srv, project, mutation_held, !write_request, &recovery_status, true); @@ -21388,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/artifact.c b/src/pipeline/artifact.c index 0d6ef3010..f3b6098ef 100644 --- a/src/pipeline/artifact.c +++ b/src/pipeline/artifact.c @@ -884,13 +884,54 @@ static void artifact_snapshot_tmp_close(artifact_snapshot_tmp_t *tmp) { tmp->dir[0] = '\0'; } +/* Make the snapshot's project_summaries carry exactly the current ADR — and + * nothing stale. ADRs now live in the ".adr.db" sidecar, but a legacy + * pre-sidecar row can still linger in the graph DB and gets copied into the + * snapshot by VACUUM INTO; incremental/closure-repair clones keep carrying it. + * So ALWAYS clear project_summaries first, then insert only when an ADR is + * present (`adr` is NULL when the sidecar has none). Otherwise a no-ADR export + * would ship the stale row and a teammate's import would restore a decision + * record that was deleted. Best-effort: on any failure the artifact simply + * omits the ADR, as before the sidecar existed. */ +static void snapshot_inject_adr(const char *snapshot_path, const cbm_adr_t *adr) { + sqlite3 *db = NULL; + if (sqlite3_open_v2(snapshot_path, &db, SQLITE_OPEN_READWRITE, NULL) != SQLITE_OK) { + sqlite3_close(db); + return; + } + sqlite3_exec(db, + "CREATE TABLE IF NOT EXISTS project_summaries (" + " project TEXT PRIMARY KEY, summary TEXT NOT NULL, source_hash TEXT NOT NULL," + " created_at TEXT NOT NULL, updated_at TEXT NOT NULL);", + NULL, NULL, NULL); + /* Drop any stale row (a deleted ADR must not resurrect through the export). */ + sqlite3_exec(db, "DELETE FROM project_summaries;", NULL, NULL, NULL); + if (adr && adr->project && adr->content) { + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, + "INSERT INTO project_summaries " + "(project, summary, source_hash, created_at, updated_at) " + "VALUES (?1, ?2, '', ?3, ?4);", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, adr->project, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, adr->content, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, adr->created_at ? adr->created_at : "", -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, adr->updated_at ? adr->updated_at : "", -1, SQLITE_TRANSIENT); + sqlite3_step(st); + sqlite3_finalize(st); + } + } + sqlite3_close(db); +} + /* Prepare a stripped DB copy for best-quality export. - * VACUUM INTO → (optionally) drop indexes → VACUUM. Returns malloc'd buffer - * or NULL. VACUUM INTO runs on BOTH quality levels: it is the consistent - * snapshot — the store runs in WAL mode, so raw main-file bytes miss - * committed transactions still in the -wal and can be mid-checkpoint torn - * (#895). Only the index-stripping is BEST-only. */ -static char *prepare_snapshot_db(const char *db_path, size_t *out_size, bool strip_indexes) { + * VACUUM INTO → (optionally) drop indexes → VACUUM → (optionally) inject the + * ADR. Returns malloc'd buffer or NULL. VACUUM INTO runs on BOTH quality + * levels: it is the consistent snapshot — the store runs in WAL mode, so raw + * main-file bytes miss committed transactions still in the -wal and can be + * mid-checkpoint torn (#895). Only the index-stripping is BEST-only. */ +static char *prepare_snapshot_db(const char *db_path, size_t *out_size, bool strip_indexes, + const cbm_adr_t *adr) { artifact_snapshot_tmp_t tmp; if (!artifact_snapshot_tmp_open(&tmp)) { artifact_export_fail("prepare_snapshot_dir", cbm_tmpdir(), "private_tmpdir_failed", errno); @@ -935,6 +976,11 @@ static char *prepare_snapshot_db(const char *db_path, size_t *out_size, bool str } } + /* Carry the ADR (from the sidecar) into the snapshot so the exported + * artifact ships the team's decision record. After the strip/VACUUM, so the + * row is not dropped. */ + snapshot_inject_adr(tmp_path, adr); + /* Reopened by path rather than held on a descriptor across VACUUM INTO, * because sqlite owns the create. The private directory is what makes that * safe: an attacker who cannot enter it cannot swap the file underneath. */ @@ -980,15 +1026,28 @@ int cbm_artifact_export(const char *db_path, const char *repo_path, const char * char *db_data = NULL; int compression_level = ART_ZSTD_FAST; + /* Fetch the ADR from the sidecar (read-only; does not create it) so the + * snapshot can carry it — the team-shared artifact used to include the ADR + * via project_summaries, and the sidecar must not silently break that. */ + cbm_adr_t adr = {0}; + bool have_adr = false; + cbm_store_t *adr_src = cbm_store_open_path_query(db_path); + if (adr_src) { + have_adr = cbm_store_adr_get(adr_src, project_name, &adr) == CBM_STORE_OK; + cbm_store_close(adr_src); + } + const cbm_adr_t *adr_arg = have_adr ? &adr : NULL; + if (quality == CBM_ARTIFACT_BEST) { compression_level = ART_ZSTD_BEST; - db_data = prepare_snapshot_db(db_path, &db_size, true); + db_data = prepare_snapshot_db(db_path, &db_size, true, adr_arg); } else { /* FAST keeps zstd-3 and its indexes, but still snapshots via * VACUUM INTO: the raw main-file bytes of a live WAL store are a * torn copy (#895). */ - db_data = prepare_snapshot_db(db_path, &db_size, false); + db_data = prepare_snapshot_db(db_path, &db_size, false, adr_arg); } + cbm_store_adr_free(&adr); if (!db_data || db_size == 0) { free(db_data); @@ -1071,6 +1130,28 @@ int cbm_artifact_export(const char *db_path, const char *repo_path, const char * /* ── Import ──────────────────────────────────────────────────────── */ +/* Restore the imported ADR (carried in the graph DB's project_summaries) into + * the per-project sidecar so it arrives with the shared artifact — even for a + * teammate who already indexed the project (a marked sidecar with no ADR), + * which the marker-gated migrate-once would skip. INSERT OR IGNORE means a + * local ADR is never overwritten. Best-effort: the graph DB is already + * installed when this runs, so a failed restore never fails the import, but it + * is logged with the store's reason so a team ADR that did not arrive is + * diagnosable instead of silently missing. */ +static void artifact_import_restore_adr(const char *cache_db_path) { + cbm_store_t *restore = cbm_store_open_path_existing(cache_db_path); + if (!restore) { + cbm_log_warn("artifact.import", "err", "adr_restore", "detail", "reopen_imported_db", + "path", cache_db_path); + return; + } + if (cbm_store_adr_restore_from_graph_if_absent(restore) != CBM_STORE_OK) { + cbm_log_warn("artifact.import", "err", "adr_restore", "detail", cbm_store_error(restore), + "path", cache_db_path); + } + cbm_store_close(restore); +} + int cbm_artifact_import(const char *repo_path, const char *cache_db_path) { if (!repo_path || !cache_db_path) { return CBM_NOT_FOUND; @@ -1198,6 +1279,8 @@ int cbm_artifact_import(const char *repo_path, const char *cache_db_path) { cbm_unlink(wal); cbm_unlink(shm); + artifact_import_restore_adr(cache_db_path); + cbm_log_info("artifact.import", "db", cache_db_path, "size_mb", itoa_buf((int)((size_t)dlen / ART_BYTES_PER_MB))); diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 419f29e26..371cb5536 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -1281,10 +1281,18 @@ static inline const cbm_gbuf_node_t *cbm_pipeline_lsp_target_node_policy( if (strcmp(cand->label, "Function") != 0 && strcmp(cand->label, "Method") != 0) { continue; } - if (!unique_callable) { - unique_callable = cand; - } else if (unique_callable->id != cand->id) { - callable_ambiguous = true; + /* A generated member (a Java record's implicit accessor) is registered + * by qualified name only: it joins through its owner's Class.method + * tail below, never on its leaf name alone -- `Objects.nonNull(x)` is + * no call of some record's nonNull(). Nor does it make a same-named + * callable ambiguous: the leaf fallback sees the graph as if it were + * not there. */ + if (!cbm_node_props_generated(cand->properties_json)) { + if (!unique_callable) { + unique_callable = cand; + } else if (unique_callable->id != cand->id) { + callable_ambiguous = true; + } } if (!cbm_pipeline_qn_class_method_tail_eq(cand->qualified_name, callee_tail)) { continue; 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/pass_calls.c b/src/pipeline/pass_calls.c index b9cc89c5b..a673e7539 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -577,6 +577,22 @@ cbm_svc_kind_t cbm_pipeline_external_import_service(cbm_external_import_index_t return CBM_SVC_NONE; } +/* The path a route registration's first argument names: the argument itself + * when it opens with a slash, or in Go the path of a ServeMux pattern that + * names a method or host first ("POST /v1/x", #2537; Handle / HandleFunc + * only). NULL when it names no path. MUST match pass_parallel.c + * find_route_path_in_args. */ +static const char *route_reg_path(const CBMCall *call, CBMLanguage lang) { + const char *arg = call->first_string_arg; + if (!arg) { + return NULL; + } + if (arg[0] == '/') { + return arg; + } + return cbm_pipeline_mux_route_path(call, lang); +} + /* Handle a route registration call: create Route node + HANDLES edge. * route_mount is the framework mount of the registering file ("/api" for a * Laravel 11+ `withRouting(api: ...)` file, #1146) or "". */ @@ -584,10 +600,11 @@ static bool handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *ca const cbm_gbuf_node_t *source_node, const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count, const char *route_mount, CBMLanguage lang) { - const char *method = cbm_service_pattern_route_method(call->callee_name); + char mux_method[CBM_SZ_16]; + const char *method = cbm_pipeline_route_reg_method(call, lang, mux_method, sizeof(mux_method)); char mounted[CBM_SZ_256]; const char *path = - cbm_laravel_mount_route(route_mount, call->first_string_arg, mounted, sizeof(mounted)); + cbm_laravel_mount_route(route_mount, route_reg_path(call, lang), mounted, sizeof(mounted)); char route_qn[CBM_ROUTE_QN_SIZE]; char cpath[CBM_SZ_256]; snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method ? method : "ANY", @@ -824,7 +841,7 @@ static bool emit_classified_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call, bool suppress_plain_calls, const char *route_mount, CBMLanguage lang) { cbm_svc_kind_t svc = cbm_service_pattern_match(res->qualified_name); - if (svc == CBM_SVC_ROUTE_REG && call->first_string_arg && call->first_string_arg[0] == '/') { + if (svc == CBM_SVC_ROUTE_REG && route_reg_path(call, lang) != NULL) { return handle_route_registration(ctx, call, source, module_qn, imp_keys, imp_vals, imp_count, route_mount, lang); } @@ -924,8 +941,8 @@ static bool calls_emit_route_call(cbm_pipeline_ctx_t *ctx, const CBMCall *call, const char **imp_keys, const char **imp_vals, int imp_count, const char *route_mount, CBMLanguage lang, CBMCallEvidence *evidence) { - if (cbm_service_pattern_route_method(call->callee_name) == NULL || !call->first_string_arg || - call->first_string_arg[0] != '/') { + if (cbm_service_pattern_route_method(call->callee_name) == NULL || + route_reg_path(call, lang) == NULL) { return false; } evidence->calls_emitted |= handle_route_registration( @@ -997,6 +1014,11 @@ static bool calls_emit_lsp_target(cbm_pipeline_ctx_t *ctx, const CBMCall *call, if (!target_node || source_node->id == target_node->id) { return false; } + /* LSP textual fallbacks can also name data, as with static imports. The + * call is handled: no edge, and no registry fallback after it. */ + if (cbm_java_suppress_call_to_data_member(lang == CBM_LANG_JAVA, target_node->label)) { + return true; + } cbm_resolution_t res = {0}; /* Keep the canonical graph QN, including project prefixes added by fallback. */ res.qualified_name = target_node->qualified_name; @@ -1333,6 +1355,20 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM return calls_emit_route_call(ctx, call, source_node, module_qn, imp_keys, imp_vals, imp_count, route_mount, lang, evidence); } + /* A Java call never targets data — see cbm_java_suppress_call_to_data_member. + * The language gate lives here, as with the guards above, and MUST match + * pass_parallel.c exactly or the two resolvers diverge. */ + if (cbm_java_suppress_call_to_data_member(lang == CBM_LANG_JAVA, target_node->label)) { + return 0; + } + /* A bare Go `init(...)` calls a local closure or parameter: never a + * package init (unreferable in Go) and never a same-named method found + * by name alone — see cbm_go_suppress_bare_init_bind. MUST match + * pass_parallel.c exactly. */ + if (cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, call->is_method, call->callee_name, + res.strategy, target_node->label)) { + return 0; + } evidence->candidate_qn = target_node->qualified_name; evidence->calls_emitted |= emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index 1f57d1c25..830cacfe1 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -720,6 +720,16 @@ static cr_match_result_t match_http_routes(cbm_store_t *src_store, const char *s char method[CBM_SZ_32] = {0}; json_str_prop(props, "url_path", url_path, sizeof(url_path)); json_str_prop(props, "method", method, sizeof(method)); + if (!method[0]) { + char callee[CBM_SZ_256] = {0}; + json_str_prop(props, "callee", callee, sizeof(callee)); + if (callee[0]) { + const char *m = cbm_service_pattern_http_method(callee); + if (m) { + snprintf(method, sizeof(method), "%s", m); + } + } + } if (!url_path[0]) { continue; } diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index 852627535..7c7aa438f 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -509,10 +509,15 @@ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const * (helpers.c): callables + type-like containers (INHERITS/IMPLEMENTS/method/ * field resolution), Variable/Field (READS/WRITES resolution), and Table/View * (SQL FROM/JOIN lineage). pass_parallel.c and pipeline_incremental.c seed - * through the same predicate, so the three registries cannot diverge. */ + * through the same predicate, so the three registries cannot diverge. + * A generated member is registered by qualified name only, at all three. */ if (node_id > 0 && cbm_label_is_registry_symbol(def->label)) { - cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, def->label, - lang, module_prefix_len); + if (cbm_def_props_generated(def->extra_props)) { + cbm_registry_add_qn_only(ctx->registry, def->qualified_name, def->label); + } else { + cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, + def->label, lang, module_prefix_len); + } } char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__"); const cbm_gbuf_node_t *file_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn); diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index a057f9a7b..a0a769fd1 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -2082,14 +2082,15 @@ static CBMRustLSPDef *pxc_lspdefs_to_rust(CBMArena *arena, const CBMLSPDef *defs void cbm_pxc_run_one(CBMLanguage lang, CBMFileResult *r, const char *source, int source_len, const char *module_qn, CBMLSPDef *defs, int def_count, const char **imp_names, const char **imp_qns, int imp_count) { - cbm_pxc_run_one_with_php_bindings(lang, r, source, source_len, module_qn, defs, def_count, + cbm_pxc_run_one_with_php_bindings(lang, r, source, source_len, module_qn, NULL, defs, def_count, imp_names, imp_qns, imp_count, NULL); } void cbm_pxc_run_one_with_php_bindings(CBMLanguage lang, CBMFileResult *r, const char *source, - int source_len, const char *module_qn, CBMLSPDef *defs, - int def_count, const char **imp_names, const char **imp_qns, - int imp_count, const CBMPHPImportBindings *bindings) { + int source_len, const char *module_qn, const char *rel_path, + CBMLSPDef *defs, int def_count, const char **imp_names, + const char **imp_qns, int imp_count, + const CBMPHPImportBindings *bindings) { TSTree *tree = r->cached_tree; /* may be NULL — LSP re-parses then */ CBMArena scratch; @@ -2101,8 +2102,8 @@ void cbm_pxc_run_one_with_php_bindings(CBMLanguage lang, CBMFileResult *r, const switch (lang) { case CBM_LANG_GO: - cbm_run_go_lsp_cross(&scratch, source, source_len, module_qn, defs, def_count, imp_names, - imp_qns, imp_count, tree, &out); + cbm_run_go_lsp_cross(&scratch, source, source_len, module_qn, rel_path, defs, def_count, + imp_names, imp_qns, imp_count, tree, &out); break; case CBM_LANG_C: case CBM_LANG_CPP: @@ -2296,9 +2297,9 @@ void cbm_pxc_dispatch_file_with_php_bindings( * lsp_unresolved entries -- no parse, no AST walk -- then the * AST walk against the overlay (chained to the sealed base). */ cbm_go_fast_resolve_qualified_calls(result, prebuilt, imp_keys, imp_vals, imp_count); - cbm_run_go_lsp_cross_with_registry(&scratch, source, source_len, def_module, &overlay, - imp_keys, imp_vals, imp_count, result->cached_tree, - &out); + cbm_run_go_lsp_cross_with_registry(&scratch, source, source_len, def_module, rel, + &overlay, imp_keys, imp_vals, imp_count, + result->cached_tree, &out); used_prebuilt = true; break; case CBM_LANG_PYTHON: { @@ -2432,7 +2433,7 @@ void cbm_pxc_dispatch_file_with_php_bindings( pxc_append_synthetic_calls(&result->arena, &result->calls, &synthetic_calls); pxc_scratch_give(PXC_SCRATCH_DISPATCH, &scratch); } else { - cbm_pxc_run_one_with_php_bindings(lang, result, source, source_len, def_module, + cbm_pxc_run_one_with_php_bindings(lang, result, source, source_len, def_module, rel, file_defs, file_def_count, imp_keys, imp_vals, imp_count, bindings); } @@ -2444,8 +2445,9 @@ void cbm_pxc_dispatch_file_with_php_bindings( cbm_pxc_run_one_ts(result, source, source_len, def_module, file_defs, file_def_count, imp_keys, imp_vals, imp_count, js, jsx, dts); } else { - cbm_pxc_run_one_with_php_bindings(lang, result, source, source_len, def_module, file_defs, - file_def_count, imp_keys, imp_vals, imp_count, bindings); + cbm_pxc_run_one_with_php_bindings(lang, result, source, source_len, def_module, rel, + file_defs, file_def_count, imp_keys, imp_vals, imp_count, + bindings); } free(filtered); } diff --git a/src/pipeline/pass_lsp_cross.h b/src/pipeline/pass_lsp_cross.h index bcbe30425..51964bc41 100644 --- a/src/pipeline/pass_lsp_cross.h +++ b/src/pipeline/pass_lsp_cross.h @@ -216,10 +216,14 @@ void cbm_pxc_run_one(CBMLanguage lang, CBMFileResult *r, const char *source, int const char *module_qn, CBMLSPDef *defs, int def_count, const char **imp_names, const char **imp_qns, int imp_count); +/* rel_path: the file's repo-relative path. Go needs it to name the file's + * init() functions as the def extractor does (#1910); cbm_pxc_run_one passes + * NULL (Go init() callers then stay flat). */ void cbm_pxc_run_one_with_php_bindings(CBMLanguage lang, CBMFileResult *r, const char *source, - int source_len, const char *module_qn, CBMLSPDef *defs, - int def_count, const char **imp_names, const char **imp_qns, - int imp_count, const CBMPHPImportBindings *bindings); + int source_len, const char *module_qn, const char *rel_path, + CBMLSPDef *defs, int def_count, const char **imp_names, + const char **imp_qns, int imp_count, + const CBMPHPImportBindings *bindings); /* TS / JS / JSX / TSX variant with explicit dialect flags. */ void cbm_pxc_run_one_ts(CBMFileResult *r, const char *source, int source_len, const char *module_qn, diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 15733d5b4..e02d68c67 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -1811,10 +1811,15 @@ static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *d return 0; } /* Registry membership is defined ONCE by cbm_label_is_registry_symbol - * (helpers.c) — see pass_definitions.c for the per-label rationale. */ + * (helpers.c) — see pass_definitions.c for the per-label rationale, and + * for the generated member registered by qualified name only. */ if (cbm_label_is_registry_symbol(def->label)) { - cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, def->label, - lang, module_prefix_len); + if (cbm_def_props_generated(def->extra_props)) { + cbm_registry_add_qn_only(ctx->registry, def->qualified_name, def->label); + } else { + cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, + def->label, lang, module_prefix_len); + } (*reg_entries)++; } char marked[CBM_SZ_4K]; @@ -2267,13 +2272,42 @@ static bool is_route_path_shaped(const char *val) { return val && val[0] == '/' && !cbm_service_pattern_is_comment_text(val); } -static const char *find_route_path_in_args(const CBMCall *call, const char **out_handler) { +const char *cbm_pipeline_mux_route_path(const CBMCall *call, CBMLanguage lang) { + const char *arg = call->first_string_arg; + if (lang != CBM_LANG_GO || !arg || cbm_service_pattern_is_comment_text(arg)) { + return NULL; + } + return cbm_service_pattern_mux_route_path(call->callee_name, arg); +} + +const char *cbm_pipeline_route_reg_method(const CBMCall *call, CBMLanguage lang, char *buf, + size_t buf_sz) { + const char *method = cbm_service_pattern_route_method(call->callee_name); + if (method && strcmp(method, "ANY") != 0) { + return method; + } + if (cbm_pipeline_mux_route_path(call, lang) && + cbm_service_pattern_mux_split(call->first_string_arg, buf, buf_sz) && buf[0] != '\0') { + return buf; + } + return method; +} + +static const char *find_route_path_in_args(const CBMCall *call, const char **out_handler, + CBMLanguage lang) { *out_handler = NULL; /* 1. First string arg starting with / */ if (is_route_path_shaped(call->first_string_arg)) { *out_handler = call->second_arg_name; return call->first_string_arg; } + /* 1b. Go ServeMux pattern with a method or host before the path (#2537), + * read for Handle / HandleFunc only. MUST match pass_calls.c route_reg_path. */ + const char *mux_path = cbm_pipeline_mux_route_path(call, lang); + if (mux_path) { + *out_handler = call->second_arg_name; + return mux_path; + } /* 2. Keyword args (prefix=, path=, route=, etc.) */ const char *found = NULL; for (int ai = 0; ai < call->arg_count && !found; ai++) { @@ -2433,7 +2467,8 @@ static bool emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sou const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf, const char **ik, const char **iv, int ic, const char *route_mount, CBMLanguage lang) { - const char *method = cbm_service_pattern_route_method(call->callee_name); + char mux_method[CBM_SZ_16]; + const char *method = cbm_pipeline_route_reg_method(call, lang, mux_method, sizeof(mux_method)); char mounted[CBM_SZ_256]; route_path = cbm_laravel_mount_route(route_mount, route_path, mounted, sizeof(mounted)); char rqn[CBM_ROUTE_QN_SIZE]; @@ -2586,19 +2621,37 @@ static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, if (!cbm_service_pattern_is_http_route_literal(norm, call->callee_name)) { continue; } + const char *method = cbm_service_pattern_http_method(call->callee_name); + const char *prefix = method ? method : "ANY"; char route_qn[CBM_ROUTE_QN_SIZE]; char cpath[CBM_SZ_256]; - snprintf(route_qn, sizeof(route_qn), "__route__ANY__%s", + snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", prefix, cbm_route_canon_path(norm, cpath, sizeof(cpath))); - int64_t route_id = cbm_gbuf_upsert_node(gbuf, "Route", norm, route_qn, "", 0, 0, - "{\"source\":\"arg_url\"}"); + char route_props[CBM_SZ_256]; + if (method) { + snprintf(route_props, sizeof(route_props), "{\"source\":\"arg_url\",\"method\":\"%s\"}", + method); + } else { + snprintf(route_props, sizeof(route_props), "{\"source\":\"arg_url\"}"); + } + int64_t route_id = + cbm_gbuf_upsert_node(gbuf, "Route", norm, route_qn, "", 0, 0, route_props); char esc_c[CBM_SZ_256]; char esc_n[CBM_SZ_256]; cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name); cbm_json_escape(esc_n, sizeof(esc_n), norm); - char eprops[CBM_SZ_512]; - snprintf(eprops, sizeof(eprops), - "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\"}", esc_c, esc_n); + /* Two escaped 255-byte strings plus the fixed keys and the verb: 1K + * never truncates the JSON (512 could, once the method was added). */ + char eprops[CBM_SZ_1K]; + if (method) { + snprintf( + eprops, sizeof(eprops), + "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\",\"method\":\"%s\"}", + esc_c, esc_n, method); + } else { + snprintf(eprops, sizeof(eprops), + "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\"}", esc_c, esc_n); + } cbm_gbuf_insert_edge(gbuf, source->id, route_id, "HTTP_CALLS", eprops); break; } @@ -2819,7 +2872,7 @@ static bool emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, if (svc == CBM_SVC_ROUTE_REG) { const char *handler_ref = NULL; - const char *route_path = find_route_path_in_args(call, &handler_ref); + const char *route_path = find_route_path_in_args(call, &handler_ref, lang); if (route_path) { return emit_route_registration(gbuf, source, call, route_path, handler_ref, module_qn, registry, main_gbuf, imp_keys, imp_vals, imp_count, @@ -2883,7 +2936,7 @@ static bool emit_xlang_refused_route(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *so return false; } const char *handler_ref = NULL; - const char *route_path = find_route_path_in_args(call, &handler_ref); + const char *route_path = find_route_path_in_args(call, &handler_ref, lang); if (route_path) { return emit_route_registration(gbuf, source, call, route_path, handler_ref, module_qn, registry, main_gbuf, imp_keys, imp_vals, imp_count, @@ -2950,6 +3003,29 @@ static const cbm_gbuf_node_t *find_source_node(const cbm_gbuf_t *gbuf, const cha /* Field type hint resolution for obj.Method() with multiple candidates. * Strips C# field prefixes (_ / m_), capitalizes to get type name, and * checks if TypeName.Method or ITypeName.Method exists among candidates. */ +/* #1927: the hint may only bind a candidate whose OWNING dot-segment — the + * segment immediately before the method name — EQUALS the hinted type. The + * previous raw strstr() accepted the hinted name anywhere in the QN, so a + * single-letter receiver (`f.Close()` → hint "F") matched nearly every + * candidate ("F" ⊂ "…FileStore.Close", the method name included) and rebound + * a call whose resolution had already failed to an arbitrary project symbol + * at PP_FIELD_HINT_CONF. Segment equality keeps the intended + * variable-named-after-its-type case (`repo.Find()` → `Repo.Find`, + * lrp_go_s8_field_type_hint) and picks the candidate whose owner actually + * carries the name instead of whichever QN contains the letters. */ +static bool fth_owner_segment_is(const char *candidate_qn, const char *type_name) { + const char *last_dot = strrchr(candidate_qn, '.'); + if (!last_dot || last_dot == candidate_qn) { + return false; + } + const char *seg_start = last_dot; + while (seg_start > candidate_qn && seg_start[-1] != '.') { + seg_start--; + } + size_t seg_len = (size_t)(last_dot - seg_start); + return seg_len > 0 && strncmp(seg_start, type_name, seg_len) == 0 && type_name[seg_len] == '\0'; +} + static void try_field_type_hint(resolve_ctx_t *rc, cbm_resolution_t *res, const char *callee_name, int64_t source_id) { if (!res->qualified_name || res->candidate_count <= SKIP_ONE) { @@ -2989,7 +3065,8 @@ static void try_field_type_hint(resolve_ctx_t *rc, cbm_resolution_t *res, const int cand_count = 0; cbm_registry_find_by_name(rc->registry, method, &cands, &cand_count); for (int ci = 0; ci < cand_count; ci++) { - if (strstr(cands[ci], type_name) || strstr(cands[ci], iface_name)) { + if (fth_owner_segment_is(cands[ci], type_name) || + fth_owner_segment_is(cands[ci], iface_name)) { const cbm_gbuf_node_t *better = cbm_gbuf_find_by_qn(rc->main_gbuf, cands[ci]); if (better && better->id != source_id) { res->qualified_name = cands[ci]; @@ -3618,6 +3695,19 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB imp_keys, imp_vals, imp_count, route_mount, lang); continue; } + if (target_node && source_node->id != target_node->id && + cbm_java_suppress_call_to_data_member(lang == CBM_LANG_JAVA, target_node->label)) { + /* Same guard as pass_calls.c — a Java call never targets a + * Variable or a Field. */ + continue; + } + if (target_node && source_node->id != target_node->id && + cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, call->is_method, call->callee_name, + res.strategy, target_node->label)) { + /* Same guard as pass_calls.c — a bare Go `init` is a local name, + * never a package init or a method found by name alone. */ + continue; + } if (!target_node || source_node->id == target_node->id) { /* HTTP/ASYNC calls to an EXTERNAL client library (`requests.get(url)`) * resolve to an unindexed QN (target_node == NULL), but their edge @@ -3794,6 +3884,12 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, if (tgt && cbm_suppress_bare_field_ref(lang, usage->is_member_access, tgt->label)) { continue; } + /* A bare Go `init` names a local (cbm_go_suppress_bare_init_bind). + * Mirrors the sequential twin (pass_usages.c). */ + if (tgt && cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, usage->is_member_access, + usage->ref_name, res.strategy, tgt->label)) { + continue; + } if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow || cbm_pipeline_node_is_callable_target(tgt))) { continue; @@ -3922,6 +4018,12 @@ static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFi if (cbm_suppress_bare_field_ref(lang, rw->is_member_access, tgt->label)) { continue; } + /* A bare Go `init` names a local (cbm_go_suppress_bare_init_bind). + * Mirrors the sequential twin (pass_usages.c). */ + if (cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, rw->is_member_access, rw->var_name, + res.strategy, tgt->label)) { + continue; + } const char *etype = rw->is_write ? "WRITES" : "READS"; cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, tgt->id, etype, "{}"); } diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index daf917379..7af626b2d 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -2604,6 +2604,15 @@ static const cbm_gbuf_node_t *resolve_import_strategies( if (import_candidate_is_python_local(ctx->gbuf, cand)) { continue; } + /* Nor is a generated member (a Java record's implicit + * accessor, an instance method no import can name). It is + * registered by qualified name only, and a package segment + * that merely spells it (`javax.crypto.spec` -> spec()) + * must not bind the import -- or every use of the + * imported name -- to it. */ + if (cbm_node_props_generated(cand->properties_json)) { + continue; + } if (source_file_qn && cand->qualified_name && strcmp(cand->qualified_name, source_file_qn) == 0) { continue; /* self */ diff --git a/src/pipeline/pass_usages.c b/src/pipeline/pass_usages.c index b51573deb..e29290c74 100644 --- a/src/pipeline/pass_usages.c +++ b/src/pipeline/pass_usages.c @@ -288,6 +288,12 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res if (tgt && cbm_suppress_bare_field_ref(lang, usage->is_member_access, tgt->label)) { continue; } + /* A bare Go `init` names a local, never a package init or an init + * method (cbm_go_suppress_bare_init_bind). Mirrors pass_parallel.c. */ + if (tgt && cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, usage->is_member_access, + usage->ref_name, res.strategy, tgt->label)) { + continue; + } if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow || cbm_pipeline_node_is_callable_target(tgt))) { continue; @@ -420,6 +426,13 @@ static int resolve_rw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result if (cbm_suppress_bare_field_ref(lang, rw->is_member_access, tgt->label)) { continue; } + /* `init := func(...)`: a bare Go `init` names a local, never a package + * init or an init method (cbm_go_suppress_bare_init_bind). Mirrors + * pass_parallel.c. */ + if (cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, rw->is_member_access, rw->var_name, + res.strategy, tgt->label)) { + continue; + } const char *edge_type = rw->is_write ? "WRITES" : "READS"; cbm_gbuf_insert_edge(ctx->gbuf, src->id, tgt->id, edge_type, "{}"); diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 2727e41d1..64bc57dba 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -81,11 +81,22 @@ 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; static atomic_bool g_persist_test_cancel_after_destination_prepare = false; -static atomic_bool g_persist_test_fail_adr_capture = false; static cbm_pipeline_test_hook_fn g_persist_test_before_final_manifest = NULL; static void *g_persist_test_before_final_manifest_userdata = NULL; static cbm_pipeline_test_hook_fn g_persist_test_after_stage_created = NULL; @@ -103,10 +114,6 @@ void cbm_pipeline_incremental_test_cancel_after_destination_prepare_once(void) { atomic_store(&g_persist_test_cancel_after_destination_prepare, true); } -void cbm_pipeline_incremental_test_fail_adr_capture_once(void) { - atomic_store(&g_persist_test_fail_adr_capture, true); -} - void cbm_pipeline_incremental_test_before_final_manifest_once(cbm_pipeline_test_hook_fn hook, void *userdata) { g_persist_test_before_final_manifest = hook; @@ -162,7 +169,6 @@ void cbm_pipeline_persist_test_reset_faults(void) { atomic_store(&g_persist_test_fail_after_stage_dump, false); atomic_store(&g_persist_test_cancel_after_predump, false); atomic_store(&g_persist_test_cancel_after_destination_prepare, false); - atomic_store(&g_persist_test_fail_adr_capture, false); g_persist_test_before_final_manifest = NULL; g_persist_test_before_final_manifest_userdata = NULL; g_persist_test_after_stage_created = NULL; @@ -210,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). @@ -277,10 +284,6 @@ struct cbm_pipeline { bool live_noop; const char *live_db_path; /* borrowed from cbm_pipeline_run */ - /* ADR (project_summaries) captured before a full-reindex DB delete, so it - * can be restored after the rebuild. NULL when no ADR existed. Issue #516. */ - char *saved_adr; - /* Per-file LSP surfaces serialized at the collect_all_defs seam (the only * moment the result cache is alive), persisted by dump_and_persist_hashes * so the closure-repair incremental route can early-cutoff on surface @@ -965,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; @@ -1472,9 +1481,6 @@ void cbm_pipeline_free(cbm_pipeline_t *p) { p->file_errors_count = 0; p->file_errors_cap = 0; free(p->branch_qn); - free(p->saved_adr); /* freed here too: error paths can exit before the - * restore in dump_and_persist_hashes runs. Issue #516. */ - p->saved_adr = NULL; cbm_store_free_lsp_surfaces(p->surface_rows, p->surface_row_count); p->surface_rows = NULL; p->surface_row_count = 0; @@ -1829,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(). */ @@ -3139,38 +3170,39 @@ static int run_parallel_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx, return check_cancel(p) ? CBM_NOT_FOUND : 0; } -static int capture_existing_adr(cbm_pipeline_t *p, const char *db_path) { -#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API - if (atomic_exchange(&g_persist_test_fail_adr_capture, false)) { - return CBM_PIPELINE_ABORT_PRESERVE_DB; +static size_t stage_root_length(const char *path); + +/* Move a legacy ADR row (pre-sidecar, still in the graph DB's project_summaries) + * into the ".adr.db" sidecar BEFORE a reindex unlinks the old generation — + * otherwise the row is lost on an upgrade whose first action is a full/format + * reindex. Read-only on the graph DB (migration writes only the sidecar, on its + * own connection). One-time via the sidecar's marker; a no-op when there is no + * legacy row. Returns true on success (safe to delete), false to preserve. */ +static bool adr_migrate_before_delete(const char *db_path) { + /* `db_path` in routing is a staging copy; the live generation (and the + * sidecar the reader looks for) is keyed to the FINAL path — the staging + * suffix stripped. Migrate against that, so the ADR lands in + * ".adr.db", not a discarded stage's sidecar. */ + size_t root_len = stage_root_length(db_path); + char final_path[4096]; + if (root_len >= sizeof(final_path)) { + return false; } -#endif - cbm_store_t *adr_store = cbm_store_open_path_query(db_path); - if (!adr_store) { - return CBM_PIPELINE_ABORT_PRESERVE_DB; + memcpy(final_path, db_path, root_len); + final_path[root_len] = '\0'; + /* The live final DB must exist to carry a legacy ADR forward; if it does + * not (a first index), there is nothing to migrate — succeed. But + * cbm_store_open_path_query returns NULL on an open *failure* too, not only + * a missing file, so only proceed when the file genuinely does not exist; + * a real open failure must preserve-and-abort rather than publish over a + * legacy ADR it never moved. */ + cbm_store_t *mig = cbm_store_open_path_query(final_path); + if (!mig) { + return !cbm_file_exists(final_path); } - cbm_adr_t existing = {0}; - int adr_rc = cbm_store_adr_get(adr_store, p->project_name, &existing); - if (adr_rc == CBM_STORE_NOT_FOUND) { - cbm_store_close(adr_store); - free(p->saved_adr); - p->saved_adr = NULL; - return 0; - } - if (adr_rc != CBM_STORE_OK || !existing.content) { - cbm_store_adr_free(&existing); - cbm_store_close(adr_store); - return CBM_PIPELINE_ABORT_PRESERVE_DB; - } - char *saved = strdup(existing.content); - cbm_store_adr_free(&existing); - cbm_store_close(adr_store); - if (!saved) { - return CBM_PIPELINE_ABORT_PRESERVE_DB; - } - free(p->saved_adr); - p->saved_adr = saved; - return 0; + int rc = cbm_store_adr_migrate_once(mig); + cbm_store_close(mig); + return rc == CBM_STORE_OK; } static int remove_stage_files(const char *stage_path); @@ -3276,11 +3308,19 @@ static int try_incremental_or_delete_db(cbm_pipeline_t *p, cbm_file_info_t *file cbm_log_info("pipeline.route", "path", "format_change_reindex", "stored_format", itoa_buf(fmt)); p->format_migration = true; - int adr_rc = capture_existing_adr(p, db_path); + /* Carry a legacy ADR into the sidecar before deleting the old graph DB; + * preserve and abort if that fails, so the decision record is never lost + * to a rebuild. The sidecar itself is untouched by cbm_remove_db_sidecars + * (it strips only -wal/-shm/-journal). */ + if (!adr_migrate_before_delete(db_path)) { + cbm_log_warn("pipeline.route", "reason", "adr_migrate_failed"); + free(db_path); + return CBM_PIPELINE_ABORT_PRESERVE_DB; + } (void)cbm_unlink(db_path); (void)cbm_remove_db_sidecars(db_path); free(db_path); - return adr_rc != 0 ? adr_rc : CBM_PIPELINE_FORCE_FULL_REINDEX; + return CBM_PIPELINE_FORCE_FULL_REINDEX; } cbm_log_info("pipeline.route", "path", "incremental_manifest"); @@ -3293,9 +3333,13 @@ static int try_incremental_or_delete_db(cbm_pipeline_t *p, cbm_file_info_t *file * no-op and successful-incremental routes -- the pipeline reports success * while every later reader finds no store. */ if (rc == CBM_PIPELINE_FORCE_FULL_REINDEX) { - int adr_rc = capture_existing_adr(p, db_path); - if (adr_rc != 0) { - rc = adr_rc; + /* Carry a legacy ADR into the sidecar before deleting the old graph DB; + * preserve and abort if that fails. The sidecar survives the delete + * (cbm_remove_db_sidecars strips only -wal/-shm/-journal). */ + if (!adr_migrate_before_delete(db_path)) { + cbm_log_warn("pipeline.route", "reason", "adr_migrate_failed"); + free(db_path); + return CBM_PIPELINE_ABORT_PRESERVE_DB; } (void)cbm_unlink(db_path); (void)cbm_remove_db_sidecars(db_path); @@ -3817,10 +3861,8 @@ static int publish_staged_impl(char *stage_path, const cbm_pipeline_generation_t cbm_store_upsert_lsp_surface_batch(store, generation->surface_rows, generation->surface_row_count) == CBM_STORE_OK; } - if (ok && generation->adr_content) { - ok = cbm_store_adr_store(store, generation->project, generation->adr_content) == - CBM_STORE_OK; - } + /* ADRs are NOT written here: they live in the ".adr.db" sidecar, which + * this rebuild never touches, so there is nothing to re-apply. */ /* Doc -> code candidates belong to the generation too: replaced inside * the staging store (the table is created here), so a reader never sees * candidates of another graph. */ @@ -4108,7 +4150,6 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas .cancelled = p->cancelled, .manifest = manifest, .manifest_count = manifest_count, - .adr_content = p->saved_adr, .coverage = cov, .coverage_count = cov_count, .coverage_meta = @@ -4162,8 +4203,6 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas cbm_log_warn("index.ignored_capped", "stored", itoa_buf(p->ignored_count), "total", itoa_buf(p->ignored_total)); } - free(p->saved_adr); - p->saved_adr = NULL; free(db_path); return 0; @@ -4186,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; } @@ -4286,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); @@ -4417,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 9fd6946f1..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 @@ -278,6 +295,24 @@ void cbm_registry_add_lang_in_module(cbm_registry_t *r, const char *name, const char *qualified_name, const char *label, CBMLanguage lang, size_t module_prefix_len); +/* Register a symbol for exact (qualified-name) lookups only: it is never + * offered to the short-name strategies (unique_name, suffix_match, + * field_type_hint, fuzzy). For a generated member (cbm_def_props_generated), + * which the source never spells, so only a type-aware resolver that knows its + * owner may bind it. The three graph registration sites route such a symbol + * here instead of cbm_registry_add_lang_in_module. */ +void cbm_registry_add_qn_only(cbm_registry_t *r, const char *qualified_name, const char *label); + +/* True when a definition's extra_props (key, value, ..., NULL) carry the + * generated-member key (cbm.h CBM_PROP_GENERATED), e.g. a Java record's + * implicit accessor. Pure; unit-tested in test_registry.c. */ +bool cbm_def_props_generated(const char *const *extra_props); + +/* The stored-node twin of cbm_def_props_generated, for the incremental + * registry seed: true when a node's properties JSON carries that key as a + * string property. Pure; unit-tested in test_registry.c. */ +bool cbm_node_props_generated(const char *properties_json); + /* True when code in `caller` can call a symbol defined in `target` by bare * name: same language, same interop family (JS/TS + script hosts, C/C++/CUDA/ * ObjC, JVM, .NET, ...; the table lives in registry.c), or Swift with the C @@ -441,6 +476,16 @@ bool cbm_suppress_external_import_shadow(const char *callee_name, const char *st bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *strategy, double confidence); +/* A bare Go `init` (a call or reference that is not the member half of a + * selector) is always a local name: Go forbids referring to a package init + * function, and a method or field needs a selector. True when such a name + * resolved onto a Function (every Go Function named init is a package init; + * any registry strategy) or onto a Method or Field found by a weak short-name + * strategy. lsp_* answers are kept. Go only. Pure; unit-tested in + * test_registry.c; wired in pass_calls.c, pass_usages.c and pass_parallel.c. */ +bool cbm_go_suppress_bare_init_bind(bool is_go, bool is_member, const char *name, + const char *strategy, const char *target_label); + /* Import-binding counterpart (#2127). True when the callee's root identifier * is bound by an import of this file whose module chain contradicts the * resolved target (`from unittest.mock import patch; patch()` must not bind a @@ -512,6 +557,13 @@ bool cbm_c_member_rule_file(CBMLanguage lang, const char *rel_path); * a member); NULL when none or several do. */ const char *cbm_registry_unique_field_qn(const cbm_registry_t *r, const char *member_name); +/* A Java call expression never names data: `p.x()` is a method invocation + * whatever `x` spells elsewhere in the project. Drops a CALLS bind whose + * target is a Variable or Field. Java only — Kotlin properties and C function + * pointers are callable names that are not methods. Pure; unit-tested in + * test_registry.c. */ +bool cbm_java_suppress_call_to_data_member(bool is_java, const char *target_label); + /* Get the label of a qualified name, or NULL if not found. */ const char *cbm_registry_label_of(const cbm_registry_t *r, const char *qn); diff --git a/src/pipeline/pipeline_delta.c b/src/pipeline/pipeline_delta.c index 895cded93..ca1dbab46 100644 --- a/src/pipeline/pipeline_delta.c +++ b/src/pipeline/pipeline_delta.c @@ -590,6 +590,39 @@ static char *delta_owner_props(const char *project, const char *name, const char return out; } +/* `props` (a JSON object) with a generated member's mark (cbm.h + * CBM_PROP_GENERATED) added, or NULL when `generated` is NULL/empty + * (CBM_MEM_CLASS_OTHER; the caller frees). */ +static char *delta_generated_props(const char *props, const char *generated) { + if (!generated || !generated[0]) { + return NULL; + } + size_t pl = props ? strlen(props) : 0; + bool has_members = pl > PAIR_LEN && props[0] == '{' && props[pl - SKIP_ONE] == '}'; + size_t cap = pl + PAIR_LEN * strlen(generated) + CBM_SZ_32; + char *out = (char *)cbm_alloc(CBM_MEM_CLASS_OTHER, cap); + if (!out) { + return NULL; + } + size_t k = 0; + if (has_members) { + memcpy(out, props, pl - SKIP_ONE); + k = pl - SKIP_ONE; + out[k++] = ','; + } else { + out[k++] = '{'; + } + k += (size_t)snprintf(out + k, cap - k, "\"%s\":\"", CBM_PROP_GENERATED); + for (const char *p = generated; *p && k + CBM_SZ_3 < cap; p++) { + if (*p == '"' || *p == '\\') { + out[k++] = '\\'; + } + out[k++] = *p; + } + snprintf(out + k, cap - k, "\"}"); + return out; +} + int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *gbuf) { sqlite3 *db = cbm_store_get_db(store); if (!db) { @@ -634,7 +667,12 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g * goes by -- a Go method's QN has no receiver, a class-body variable's QN * no class. A document naming `Order.Reset` binds such a node through * its owner (doc_links_pdf.c), so a re-resolved document needs it too. - * Only where owner + name differs from the QN (delta_owner_props). */ + * Only where owner + name differs from the QN (delta_owner_props). + * + * And a generated member's mark (a Java record's implicit accessor, a + * Method node): the registry seed reads it to register the member by + * qualified name only, as the full build does (cbm_node_props_generated); + * without it the proxy came back into the short-name index. */ sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2( db, @@ -646,7 +684,10 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g " start_line, end_line," " CASE WHEN label IN ('Method','Variable')" " AND instr(properties, '\"parent_class\"') > 0" - " THEN json_extract(properties, '$.parent_class') END" + " THEN json_extract(properties, '$.parent_class') END," + " CASE WHEN label = 'Method'" + " AND instr(properties, '\"" CBM_PROP_GENERATED "\"') > 0" + " THEN json_extract(properties, '$." CBM_PROP_GENERATED "') END" " FROM nodes" " WHERE project = ?1 AND label NOT IN" " ('Macro','Comment','Section','Branch','Commit','Tag')" @@ -670,13 +711,16 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g int start_line = sqlite3_column_int(stmt, 6); int end_line = sqlite3_column_int(stmt, 7); const char *parent = (const char *)sqlite3_column_text(stmt, 8); + const char *generated = (const char *)sqlite3_column_text(stmt, 9); char *owner_props = parent ? delta_owner_props(project, name, qn, parent, client_props) : NULL; + const char *props = owner_props ? owner_props : (client_props ? client_props : "{}"); + char *generated_props = delta_generated_props(props, generated); /* Pin the gbuf id to the database id: proxies ARE their rows. */ cbm_gbuf_set_next_id(gbuf, id); - int64_t got = - cbm_gbuf_upsert_node(gbuf, label, name, qn, fp ? fp : "", start_line, end_line, - owner_props ? owner_props : (client_props ? client_props : "{}")); + int64_t got = cbm_gbuf_upsert_node(gbuf, label, name, qn, fp ? fp : "", start_line, + end_line, generated_props ? generated_props : props); + cbm_free(CBM_MEM_CLASS_OTHER, generated_props); cbm_free(CBM_MEM_CLASS_OTHER, owner_props); if (got != id) { /* A QN collision inside the preseed set would silently split diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index d15bd7349..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); @@ -1353,6 +1353,12 @@ static void registry_visitor(const cbm_gbuf_node_t *node, void *userdata) { if (!incr_label_is_registry_symbol(node->label)) { return; } + /* A generated member stays out of the short-name index here as in the + * full index (pass_definitions.c); its node carries the mark. */ + if (cbm_node_props_generated(node->properties_json)) { + cbm_registry_add_qn_only(seed->registry, node->qualified_name, node->label); + return; + } CBMLanguage lang = cbm_registry_synthetic_path_language(node->file_path); if (lang == CBM_LANG_COUNT && seed->lang_by_path && node->file_path) { void *v = cbm_ht_get(seed->lang_by_path, node->file_path); @@ -1849,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); @@ -2232,8 +2239,7 @@ typedef struct { static int dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *project, atomic_int *cancelled, const cbm_file_hash_t *manifest, - int manifest_count, const char *adr_content, - const cbm_coverage_row_t *cov, int cov_count, + int manifest_count, const cbm_coverage_row_t *cov, int cov_count, const cbm_coverage_meta_t *meta_template, const cbm_lsp_surface_row_t *surface_rows, int surface_row_count, legacy_doc_rows_t doc, const cbm_doc_candidate_t *doc_candidates, @@ -2247,7 +2253,6 @@ static int dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *p .cancelled = cancelled, .manifest = manifest, .manifest_count = manifest_count, - .adr_content = adr_content, .coverage = cov, .coverage_count = cov_count, .coverage_meta = meta_template ? *meta_template : (cbm_coverage_meta_t){0}, @@ -2335,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 = @@ -2349,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; } @@ -3478,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; } @@ -3834,7 +3843,6 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char .cancelled = cbm_pipeline_cancelled_ptr(p), .manifest = manifest, .manifest_count = manifest_count, - .adr_content = NULL, /* the clone already carries the ADR rows */ .coverage = cov, .coverage_count = cov_n, .coverage_meta = @@ -4227,43 +4235,8 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil return CBM_NOT_FOUND; } - char *saved_adr = NULL; - cbm_adr_t existing_adr = {0}; - int adr_rc = cbm_store_adr_get(store, project, &existing_adr); - if (adr_rc == CBM_STORE_OK) { - bool had_adr_content = existing_adr.content != NULL; - if (had_adr_content) { - saved_adr = strdup(existing_adr.content); - } - cbm_store_adr_free(&existing_adr); - if (had_adr_content && !saved_adr) { - cbm_gbuf_free(existing); - free(changed_files); - for (int i = 0; i < deleted_count; i++) { - free(deleted[i]); - } - free(deleted); - free_mode_skipped(mode_skipped, mode_skipped_count); - cbm_store_free_coverage(old_cov, old_cov_count); - cbm_store_close(store); - closure_plan_free(&closure_plan); - return CBM_PIPELINE_ABORT_PRESERVE_DB; - } - } else if (adr_rc != CBM_STORE_NOT_FOUND) { - cbm_store_adr_free(&existing_adr); - cbm_gbuf_free(existing); - free(changed_files); - for (int i = 0; i < deleted_count; i++) { - free(deleted[i]); - } - free(deleted); - free_mode_skipped(mode_skipped, mode_skipped_count); - cbm_store_free_coverage(old_cov, old_cov_count); - cbm_store_close(store); - closure_plan_free(&closure_plan); - return CBM_PIPELINE_ABORT_PRESERVE_DB; - } - + /* ADRs live in the ".adr.db" sidecar, which the delta clone/patch/rename + * never touches, so there is nothing to capture or re-apply here. */ legacy_doc_t legacy_doc; legacy_doc_load(&legacy_doc, store, project, changed_files, ci, deleted, deleted_count); cbm_store_close(store); @@ -4423,7 +4396,6 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil incr_free_edge_capture(&edge_cap); cbm_store_free_coverage(old_cov, old_cov_count); free_mode_skipped(mode_skipped, mode_skipped_count); - free(saved_adr); cbm_gbuf_free(existing); cbm_arena_destroy(&decl_arena); legacy_doc_free(&legacy_doc); @@ -4545,7 +4517,6 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil free(cov); cbm_store_free_coverage(old_cov, old_cov_count); free_mode_skipped(mode_skipped, mode_skipped_count); - free(saved_adr); cbm_gbuf_free(existing); cbm_arena_destroy(&decl_arena); cbm_doclinks_free_rows(doc_rows, doc_row_count); @@ -4583,12 +4554,12 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil * rehydrated project, so this run's rows are complete. */ int doc_cand_n = 0; const cbm_doc_candidate_t *doc_cands = cbm_pipeline_doc_candidates(p, &doc_cand_n); + /* ADRs are not captured/re-applied here (sidecar store), so no adr argument. */ int persist_rc = dump_and_persist(existing, db_path, project, cbm_pipeline_cancelled_ptr(p), - manifest, manifest_count, saved_adr, cov, cov_n, - &coverage_meta, NULL, 0, doc_out, doc_cands, doc_cand_n); + manifest, manifest_count, cov, cov_n, &coverage_meta, NULL, 0, + doc_out, doc_cands, doc_cand_n); cbm_doclinks_free_rows(doc_rows, doc_row_count); cbm_pipeline_free_semantic_manifest(manifest, manifest_count); - free(saved_adr); free(cov); cbm_store_free_coverage(old_cov, old_cov_count); free_mode_skipped(mode_skipped, mode_skipped_count); diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 80485c7e8..86ac44cd7 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -797,6 +797,22 @@ bool cbm_pipeline_http_client_call_url(const cbm_gbuf_t *gbuf, const char *proje * behind both passes' HTTP_CALLS / ASYNC_CALLS props (pass_parallel.c). */ void cbm_pipeline_close_call_props(char *props, int n, const CBMCall *call, bool with_line); +/* Go 1.22 ServeMux registrations may name a method and a host before the path + * ("POST /v1/x", "GET api.example.com/v1/x", #2537). For a Go `Handle` / + * `HandleFunc` call, the path inside its first argument; NULL for any other + * language or callee (`store.Get("users/123")`), for a URL argument, and for an + * argument that is no such pattern (a plain "/v1/x" included). The one reader + * of that grammar behind both passes (pass_parallel.c). */ +const char *cbm_pipeline_mux_route_path(const CBMCall *call, CBMLanguage lang); + +/* The method of a route registration's Route: the verb its callee names + * (`router.GET`), else the method its Go ServeMux pattern names + * (`mux.HandleFunc("POST /v1/x", h)`, #2537), else the callee's own answer + * (ANY or NULL). `buf` (CBM_SZ_16) holds a pattern method. Shared by both + * passes so their Routes stay byte-identical (pass_parallel.c). */ +const char *cbm_pipeline_route_reg_method(const CBMCall *call, CBMLanguage lang, char *buf, + size_t buf_sz); + /* Per-file index of the names a file's imports bind onto EXTERNAL modules: * local name -> the binding import. Built lazily, on the first call that * asks, from the file's extraction imports and its IMPORTS edges; one hash @@ -1571,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); @@ -1663,8 +1679,13 @@ int cbm_pipeline_build_fresh_semantic_manifest(cbm_pipeline_t *p, const char *pr * 11: Python local defs and classes bind through their enclosing lexical * scope, including app-factory route handlers. Published version 10 * predates this resolution, so unchanged files rebuild once on upgrade. + * 12: repeated ATX and Setext Markdown headings use parent-qualified Section + * names only when distinct parent chains separate them. Unchanged bare names + * are preserved and dotted-name collisions are disambiguated before section + * references resolve. Old indices rebuild once so Section nodes, MENTIONS + * and diagram owners agree. * The unresolved-call coverage threshold remains independent. */ -enum { CBM_SEMANTIC_INDEX_VERSION = 11 }; +enum { CBM_SEMANTIC_INDEX_VERSION = 12 }; typedef struct { cbm_gbuf_t *gbuf; @@ -1673,7 +1694,6 @@ typedef struct { atomic_int *cancelled; const cbm_file_hash_t *manifest; int manifest_count; - const char *adr_content; const cbm_coverage_row_t *coverage; int coverage_count; cbm_coverage_meta_t coverage_meta; @@ -1939,7 +1959,6 @@ void cbm_pipeline_incremental_test_force_legacy_partial_once(void); void cbm_pipeline_incremental_test_fail_after_stage_dump_once(void); void cbm_pipeline_incremental_test_cancel_after_predump_once(void); void cbm_pipeline_incremental_test_cancel_after_destination_prepare_once(void); -void cbm_pipeline_incremental_test_fail_adr_capture_once(void); typedef void (*cbm_pipeline_test_hook_fn)(void *userdata); void cbm_pipeline_incremental_test_before_final_manifest_once(cbm_pipeline_test_hook_fn hook, void *userdata); diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index a88091dc2..997d00194 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -1015,6 +1015,46 @@ bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *s return confidence < CONF_UNIQUE_NAME; } +bool cbm_go_suppress_bare_init_bind(bool is_go, bool is_member, const char *name, + const char *strategy, const char *target_label) { + /* The Go spec gives `init` no binding in the package block: the + * identifier declares init functions and nothing else, and "init + * functions cannot be referred to from anywhere in a program". A method + * or a field named init is reached only through a selector (`x.init()`, + * `e.init`), which arrives here as is_member and is left alone. So a + * BARE `init` is always a local name: a closure (`init := + * func(ctx) {...}; init(ctx)`, 22 kubernetes e2e storage suites), a + * parameter (`func sum(init func() ref.Val)`), or the `package init` + * clause of a directory named init. None of them is a project node. + * + * Every Go Function named init is a package init (no other package-level + * `init` can be declared), so that target is refused for every registry + * strategy. Before #1915 the flat QN made same_module bind the bare name + * to its own package's init; after it, the per-file QN misses there and + * the name fallback binds unrelated init methods instead. A Method or + * Field namesake is refused when only the name found it (the shared weak + * drop-list), which is how every such edge was minted. lsp_* answers are + * untouched: the sequential resolver emits them before its registry + * guards run, so refusing one here would split the two resolvers. + * + * Go-gated: elsewhere `init` is an ordinary name. A bare `init()` in a + * Java, C#, C++, Kotlin or Ruby method calls the class's own method, and + * a C, Python or JS `init` function is called by its bare name. */ + if (!is_go || is_member || !name || strcmp(name, "init") != 0 || !target_label) { + return false; + } + if (strategy && strncmp(strategy, "lsp_", strlen("lsp_")) == 0) { + return false; + } + if (strcmp(target_label, "Function") == 0) { + return true; + } + if (strcmp(target_label, "Method") == 0 || strcmp(target_label, "Field") == 0) { + return weak_short_name_strategy(strategy); + } + return false; +} + /* Import-binding counterpart of the parameter guard above (#2127). An import * binds the identifier for the whole module; when that import is external it * never materializes, and the chain falls through to project-wide short-name @@ -1370,6 +1410,30 @@ bool cbm_c_member_binds_by_owner(bool is_c, bool is_member_access) { return is_c && is_member_access; } +bool cbm_java_suppress_call_to_data_member(bool is_java, const char *target_label) { + /* A Java call expression never names data. `p.x()` is a method invocation + * whatever `x` spells elsewhere in the tree, so a CALLS edge into a + * Variable or a Field is a spelling collision and not a call. + * + * The bind is reached when the type-aware resolver finds no method to + * return: a record's accessors are implicit, so `record Point(double x, + * double y, double z)` declares no `x()` for java_lookup_method to find, + * and every `p.x()` falls through to the short-name registry — which + * matches the first `x` anywhere in the project, typically an unrelated + * class's private field. Measured on a 600-file Java tree (2026-09-22): + * 278 of 2444 CALLS edges landed on fields that way, and the callers of + * one four-field class absorbed every record read in the project. + * + * Java-gated, like the Go guard above, because the veto is only sound + * where no callable name can be anything but a method: Kotlin properties + * hold function values (`val f: () -> Unit; f()`) and C has function + * pointers, so both legitimately call through data. */ + if (!is_java || !target_label) { + return false; + } + return strcmp(target_label, "Variable") == 0 || strcmp(target_label, "Field") == 0; +} + /* ── Lifecycle ──────────────────────────────────────────────────── */ cbm_registry_t *cbm_registry_new(void) { @@ -1629,6 +1693,32 @@ void cbm_registry_add_lang_in_module(cbm_registry_t *r, const char *name, } } +/* A generated member (a Java record's implicit accessor) is a definition the + * source never spells: no call names it except through its owner's type. The + * exact map holds it for the LSP targets and the import/same-module lookups, + * as it holds a Python function's locals; the by-name index does not, so no + * unique_name / suffix_match / field_type_hint / fuzzy guess can land on it. */ +void cbm_registry_add_qn_only(cbm_registry_t *r, const char *qualified_name, const char *label) { + if (!r || !qualified_name || !label) { + return; + } + (void)registry_add_exact(r, qualified_name, label); +} + +bool cbm_def_props_generated(const char *const *extra_props) { + for (const char *const *kv = extra_props; kv && kv[0] && kv[SKIP_ONE]; kv += PAIR_LEN) { + if (strcmp(kv[0], CBM_PROP_GENERATED) == 0) { + return true; + } + } + return false; +} + +bool cbm_node_props_generated(const char *properties_json) { + /* The key as the definition passes write it: "generated":"". */ + return properties_json && strstr(properties_json, "\"" CBM_PROP_GENERATED "\":\"") != NULL; +} + /* ── Lookup ──────────────────────────────────────────────────────── */ bool cbm_registry_exists(const cbm_registry_t *r, const char *qn) { 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/src/store/store.c b/src/store/store.c index 56248eeec..ed06236a9 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -183,6 +183,15 @@ struct cbm_store { sqlite3_stmt *stmt_get_file_hashes; sqlite3_stmt *stmt_delete_file_hash; sqlite3_stmt *stmt_delete_file_hashes; + + /* ADRs live in a per-project sidecar DB (".adr.db"), NOT in the + * graph DB, so a reindex that rebuilds/replaces the graph DB never touches + * them (removing the capture/re-apply race entirely). The read-write handle + * is lazily opened on the first ADR write/migration and cached for the + * store's lifetime; NULL for a :memory: store, where ADRs stay in the graph + * DB (never file-swapped, no race). Reads open a short-lived read-only + * handle so a query-only cache is never mutated. */ + sqlite3 *adr_db; }; /* ── Helpers ────────────────────────────────────────────────────── */ @@ -195,6 +204,13 @@ static void store_set_error_sqlite(cbm_store_t *s, const char *prefix) { snprintf(s->errbuf, sizeof(s->errbuf), "%s: %s", prefix, sqlite3_errmsg(s->db)); } +/* Like store_set_error_sqlite but reads the message from an explicit + * connection — used for the ADR sidecar DB, whose errors do not live on + * s->db. */ +static void store_set_error_sqlite_on(cbm_store_t *s, sqlite3 *db, const char *prefix) { + snprintf(s->errbuf, sizeof(s->errbuf), "%s: %s", prefix, sqlite3_errmsg(db)); +} + static int exec_sql(cbm_store_t *s, const char *sql) { if (!s || !s->db) { return CBM_STORE_ERR; @@ -1530,6 +1546,11 @@ void cbm_store_close(cbm_store_t *s) { * Prevents ASan false-positive leaks from sqlite3 internal state. */ sqlite3_close_v2(s->db); cbm_graph_mem_release(s->graph_mem); + /* ADR sidecar: checkpoint + close its own connection (file stores only). */ + if (s->adr_db) { + (void)sqlite3_wal_checkpoint_v2(s->adr_db, NULL, SQLITE_CHECKPOINT_PASSIVE, NULL, NULL); + sqlite3_close_v2(s->adr_db); + } safe_str_free(&s->db_path); free(s); } @@ -4165,10 +4186,18 @@ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edge bind_text(stmt, SKIP_ONE, project); sqlite3_bind_int(stmt, 2, max_edges > 0 ? max_edges + SKIP_ONE : CBM_NOT_FOUND); + /* Both arrays are STORE-class memory-core blocks: the caller releases them + * with cbm_store_free_call_edges, the only free that matches. */ int cap = ST_INIT_CAP_16; int n = 0; - int64_t *src = malloc((size_t)cap * sizeof(int64_t)); - int64_t *tgt = malloc((size_t)cap * sizeof(int64_t)); + int64_t *src = cbm_alloc(CBM_MEM_CLASS_STORE, (size_t)cap * sizeof(int64_t)); + int64_t *tgt = cbm_alloc(CBM_MEM_CLASS_STORE, (size_t)cap * sizeof(int64_t)); + if (!src || !tgt) { + sqlite3_finalize(stmt); + cbm_store_free_call_edges(src, tgt); + store_set_error(s, "fetch_call_edges: out of memory"); + return CBM_STORE_ERR; + } int scan_rc; while ((scan_rc = sqlite3_step(stmt)) == SQLITE_ROW) { if (max_edges > 0 && n >= max_edges) { @@ -4178,9 +4207,23 @@ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edge break; } if (n >= cap) { + size_t grown_bytes = (size_t)cap * ST_GROWTH * sizeof(int64_t); + int64_t *grown_src = cbm_realloc(CBM_MEM_CLASS_STORE, src, grown_bytes); + if (grown_src) { + src = grown_src; + } + int64_t *grown_tgt = + grown_src ? cbm_realloc(CBM_MEM_CLASS_STORE, tgt, grown_bytes) : NULL; + if (grown_tgt) { + tgt = grown_tgt; + } + if (!grown_src || !grown_tgt) { + sqlite3_finalize(stmt); + cbm_store_free_call_edges(src, tgt); + store_set_error(s, "fetch_call_edges: out of memory"); + return CBM_STORE_ERR; + } cap *= ST_GROWTH; - src = safe_realloc(src, (size_t)cap * sizeof(int64_t)); - tgt = safe_realloc(tgt, (size_t)cap * sizeof(int64_t)); } src[n] = sqlite3_column_int64(stmt, 0); tgt[n] = sqlite3_column_int64(stmt, 1); @@ -4188,8 +4231,7 @@ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edge } sqlite3_finalize(stmt); if (scan_rc != SQLITE_DONE && scan_rc != SQLITE_ROW) { - free(src); - free(tgt); + cbm_store_free_call_edges(src, tgt); store_set_error_sqlite(s, "fetch_call_edges scan"); return CBM_STORE_ERR; } @@ -4199,6 +4241,11 @@ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edge return CBM_STORE_OK; } +void cbm_store_free_call_edges(int64_t *src, int64_t *tgt) { + cbm_free(CBM_MEM_CLASS_STORE, src); + cbm_free(CBM_MEM_CLASS_STORE, tgt); +} + int cbm_store_find_edges_by_source(cbm_store_t *s, int64_t source_id, cbm_edge_t **out, int *count) { bind_id_t b = {source_id}; @@ -10647,6 +10694,10 @@ int cbm_louvain(const int64_t *nodes, int node_count, const cbm_louvain_edge_t * return cbm_leiden(nodes, node_count, edges, edge_count, 1.0, out, out_count); } +void cbm_leiden_free(cbm_louvain_result_t *result) { + cbm_free(CBM_MEM_CLASS_OTHER, result); /* cbm_leiden allocates OTHER */ +} + /* ── Architecture: community clusters via Leiden ───────────────── */ enum { @@ -11893,7 +11944,260 @@ void cbm_adr_sections_free(cbm_adr_sections_t *s) { memset(s, 0, sizeof(*s)); } +/* ── ADR sidecar store ────────────────────────────────────────────── + * ADRs are kept in a per-project sidecar SQLite DB (".adr.db"), so a + * reindex that deletes/replaces the graph DB never disturbs them (no capture, + * re-read or re-apply, no lock). adr_conn() returns the connection ADR ops run + * on: the sidecar for a file-backed store (opened, schema-created and migrated + * once, then cached), or the graph DB for a :memory: store (never file-swapped, + * so there is no race to avoid there). */ + +/* The sidecar's on-disk path (".adr.db"), routed through + * cbm_path_for_file_api so a long path gets the Windows \\?\ prefix (like + * store_open_internal). Returns false for a :memory: store or an over-long + * path. `raw` receives the plain path for existence checks/unlinks; `api` + * receives the file-API form used to open. */ +static bool adr_sidecar_path(const cbm_store_t *s, char *raw, size_t raw_sz, char *api, + size_t api_sz) { + if (!s->db_path) { + return false; + } + int n = snprintf(raw, raw_sz, "%s.adr.db", s->db_path); + if (n <= 0 || (size_t)n >= raw_sz) { + return false; + } + return cbm_path_for_file_api(raw, api, api_sz); +} + +/* Match the graph store's locking so cross-process ADR access waits rather than + * failing: WAL + a 10 s busy timeout (SQLite's own lock wait, not a hand-rolled + * poll). Best-effort: a read-only sidecar rejects journal_mode, which is fine. */ +static void adr_configure(sqlite3 *db) { + (void)sqlite3_exec(db, "PRAGMA busy_timeout = 10000;", NULL, NULL, NULL); + (void)sqlite3_exec(db, "PRAGMA journal_mode = WAL;", NULL, NULL, NULL); +} + +static int adr_ensure_schema(sqlite3 *db) { + /* project_summaries has the same shape as the graph DB's, so migration is a + * plain row copy and the cbm_adr_t mapping is unchanged. adr_meta carries a + * one-time "migrated" marker so the legacy copy runs exactly once and can + * never resurrect a row deleted after migration. */ + int rc = sqlite3_exec(db, + "CREATE TABLE IF NOT EXISTS project_summaries (" + " project TEXT PRIMARY KEY," + " summary TEXT NOT NULL," + " source_hash TEXT NOT NULL," + " created_at TEXT NOT NULL," + " updated_at TEXT NOT NULL" + ");" + "CREATE TABLE IF NOT EXISTS adr_meta (" + " key TEXT PRIMARY KEY," + " value TEXT NOT NULL" + ");", + NULL, NULL, NULL); + return (rc == SQLITE_OK) ? CBM_STORE_OK : CBM_STORE_ERR; +} + +/* True once the one-time legacy migration has been recorded in the sidecar. */ +static bool adr_migrated(sqlite3 *adb) { + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(adb, "SELECT 1 FROM adr_meta WHERE key='migrated' LIMIT 1;", + CBM_NOT_FOUND, &st, NULL) != SQLITE_OK) { + return false; + } + bool done = sqlite3_step(st) == SQLITE_ROW; + sqlite3_finalize(st); + return done; +} + +/* Copy every ADR row from the graph DB's project_summaries into the sidecar with + * INSERT OR IGNORE (so a row already present locally is never overwritten). Must + * run inside a transaction on the sidecar connection. Returns OK/ERR; does not + * touch the `migrated` marker. */ +static int adr_copy_rows(cbm_store_t *s, sqlite3 *adb) { + sqlite3_stmt *probe = NULL; + if (sqlite3_prepare_v2( + s->db, + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_summaries' LIMIT 1;", + CBM_NOT_FOUND, &probe, NULL) != SQLITE_OK) { + return CBM_STORE_ERR; + } + bool has_legacy = sqlite3_step(probe) == SQLITE_ROW; + sqlite3_finalize(probe); + if (!has_legacy) { + return CBM_STORE_OK; /* no source table -> nothing to copy */ + } + + sqlite3_stmt *sel = NULL; + if (sqlite3_prepare_v2(s->db, + "SELECT project, summary, source_hash, created_at, updated_at " + "FROM project_summaries;", + CBM_NOT_FOUND, &sel, NULL) != SQLITE_OK) { + return CBM_STORE_ERR; + } + sqlite3_stmt *ins = NULL; + if (sqlite3_prepare_v2(adb, + "INSERT OR IGNORE INTO project_summaries " + "(project, summary, source_hash, created_at, updated_at) " + "VALUES (?1, ?2, ?3, ?4, ?5);", + CBM_NOT_FOUND, &ins, NULL) != SQLITE_OK) { + sqlite3_finalize(sel); + return CBM_STORE_ERR; + } + int rc = SQLITE_DONE; + while ((rc = sqlite3_step(sel)) == SQLITE_ROW) { + for (int c = 0; c < ST_COL_5; c++) { + bind_text(ins, c + 1, (const char *)sqlite3_column_text(sel, c)); + } + int irc = sqlite3_step(ins); + sqlite3_reset(ins); + sqlite3_clear_bindings(ins); + if (irc != SQLITE_DONE) { + sqlite3_finalize(sel); + sqlite3_finalize(ins); + return CBM_STORE_ERR; + } + } + sqlite3_finalize(sel); + sqlite3_finalize(ins); + return (rc == SQLITE_DONE) ? CBM_STORE_OK : CBM_STORE_ERR; +} + +/* Copy any legacy ADR rows from the graph DB's project_summaries into the + * sidecar (INSERT OR IGNORE, so a local row is never clobbered), then set the + * one-time marker. The marker check, copy and marker insert run in ONE + * BEGIN IMMEDIATE transaction on the sidecar, so two processes migrating at + * once cannot re-copy a row the other just deleted. Returns CBM_STORE_ERR on + * any failure (transaction rolled back) so callers can fail closed and preserve + * the old generation. No-op once the marker is set. */ +static int adr_copy_legacy_once(cbm_store_t *s, sqlite3 *adb) { + if (adr_migrated(adb)) { + return CBM_STORE_OK; /* fast path: already migrated, no lock needed */ + } + if (sqlite3_exec(adb, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) { + return CBM_STORE_ERR; + } + /* Re-check under the write lock: another process may have migrated between + * the fast-path check and acquiring the lock. */ + if (adr_migrated(adb)) { + (void)sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL); + return CBM_STORE_OK; + } + if (adr_copy_rows(s, adb) != CBM_STORE_OK) { + goto fail; + } + if (sqlite3_exec(adb, "INSERT OR IGNORE INTO adr_meta (key, value) VALUES ('migrated','1');", + NULL, NULL, NULL) != SQLITE_OK) { + goto fail; + } + if (sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) { + goto fail; + } + return CBM_STORE_OK; + +fail: + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); + return CBM_STORE_ERR; +} + +/* Restore ADRs from the graph DB into the sidecar for any project the sidecar has + * no row for, WITHOUT overwriting a local one, regardless of the `migrated` + * marker. This is what import needs: an artifact carries its ADR in the graph + * DB's project_summaries, and a teammate who already indexed the project (so the + * sidecar is marked, but has no ADR) must still receive it. INSERT OR IGNORE + * guarantees a local ADR is never clobbered; running ungated by the marker is + * what distinguishes this from the one-time legacy migration. It never writes + * the marker: `adb` comes from adr_conn_write, which hands out a sidecar handle + * only after adr_copy_legacy_once committed it, and nothing deletes it. On + * failure the store error names the step (the import logs it). */ +static int adr_restore_if_absent(cbm_store_t *s, sqlite3 *adb) { + if (sqlite3_exec(adb, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr restore begin"); + return CBM_STORE_ERR; + } + if (adr_copy_rows(s, adb) != CBM_STORE_OK) { + store_set_error(s, "adr restore copy failed"); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); + return CBM_STORE_ERR; + } + if (sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr restore commit"); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; +} + +/* Open (create) the read-write sidecar, configure locking, ensure the schema, + * run the one-time legacy migration, and cache the handle. NULL + error set on + * failure (fail closed). :memory: stores return the graph DB (no race there). */ +static sqlite3 *adr_conn_write(cbm_store_t *s) { + if (!s || !s->db) { + return NULL; + } + if (!s->db_path) { + return s->db; /* :memory: store — ADRs stay in the graph DB */ + } + if (s->adr_db) { + return s->adr_db; + } + char raw[4096]; + char api[4096]; + if (!adr_sidecar_path(s, raw, sizeof(raw), api, sizeof(api))) { + store_set_error(s, "adr sidecar path too long"); + return NULL; + } + sqlite3 *adb = NULL; + int flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE; + if (sqlite3_open_v2(api, &adb, flags, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr sidecar open"); + sqlite3_close_v2(adb); + return NULL; + } + adr_configure(adb); + if (adr_ensure_schema(adb) != CBM_STORE_OK) { + store_set_error_sqlite_on(s, adb, "adr sidecar schema"); + sqlite3_close_v2(adb); + return NULL; + } + if (adr_copy_legacy_once(s, adb) != CBM_STORE_OK) { + store_set_error(s, "adr sidecar legacy migration failed"); + sqlite3_close_v2(adb); + return NULL; + } + s->adr_db = adb; + return s->adr_db; +} + +int cbm_store_adr_migrate_once(cbm_store_t *s) { + if (!s || !s->db) { + return CBM_STORE_ERR; + } + if (!s->db_path) { + return CBM_STORE_OK; /* :memory: — ADRs already in the graph DB, nothing to move */ + } + return adr_conn_write(s) ? CBM_STORE_OK : CBM_STORE_ERR; +} + +int cbm_store_adr_restore_from_graph_if_absent(cbm_store_t *s) { + if (!s || !s->db) { + return CBM_STORE_ERR; + } + if (!s->db_path) { + return CBM_STORE_OK; /* :memory: — ADR already lives in the graph DB */ + } + sqlite3 *adb = adr_conn_write(s); + if (!adb) { + return CBM_STORE_ERR; + } + return adr_restore_if_absent(s, adb); +} + int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content) { + sqlite3 *adb = adr_conn_write(s); + if (!adb) { + return CBM_STORE_ERR; + } char now[CBM_SZ_32]; iso_now(now, sizeof(now)); @@ -11903,8 +12207,8 @@ int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content "ON CONFLICT(project) DO UPDATE SET summary=excluded.summary, " "updated_at=excluded.updated_at"; sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_store"); + if (sqlite3_prepare_v2(adb, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_store"); return CBM_STORE_ERR; } bind_text(stmt, SKIP_ONE, project); @@ -11916,22 +12220,17 @@ int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content return (rc == SQLITE_DONE) ? CBM_STORE_OK : CBM_STORE_ERR; } -int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { - if (!s || !s->db || !project || !out) { - return CBM_STORE_ERR; - } - memset(out, 0, sizeof(*out)); - - /* ADR storage was added after the original graph schema. A readable - * legacy generation without project_summaries has no ADR to preserve; it - * is not a read failure and must remain replaceable by a full reindex. */ +/* Read one ADR row from an already-open connection into `out`. Returns + * CBM_STORE_OK / CBM_STORE_NOT_FOUND / CBM_STORE_ERR. Never writes. */ +static int adr_read_row(cbm_store_t *s, sqlite3 *adb, const char *project, cbm_adr_t *out) { + /* A readable legacy generation without project_summaries has no ADR; that is + * NOT_FOUND, not a read failure. */ sqlite3_stmt *table_stmt = NULL; int rc = sqlite3_prepare_v2( - s->db, - "SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_summaries' LIMIT 1;", + adb, "SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_summaries' LIMIT 1;", CBM_NOT_FOUND, &table_stmt, NULL); if (rc != SQLITE_OK) { - store_set_error_sqlite(s, "adr_get table probe"); + store_set_error_sqlite_on(s, adb, "adr_get table probe"); return CBM_STORE_ERR; } rc = sqlite3_step(table_stmt); @@ -11940,26 +12239,26 @@ int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { return CBM_STORE_NOT_FOUND; } if (rc != SQLITE_ROW || sqlite3_finalize(table_stmt) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_get table probe step"); + store_set_error_sqlite_on(s, adb, "adr_get table probe step"); return CBM_STORE_ERR; } const char *sql = "SELECT project, summary, created_at, updated_at FROM project_summaries " "WHERE project=?1"; sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_get"); + if (sqlite3_prepare_v2(adb, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_get"); return CBM_STORE_ERR; } if (bind_text(stmt, SKIP_ONE, project) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_get bind"); + store_set_error_sqlite_on(s, adb, "adr_get bind"); sqlite3_finalize(stmt); return CBM_STORE_ERR; } rc = sqlite3_step(stmt); if (rc != SQLITE_ROW) { if (rc != SQLITE_DONE) { - store_set_error_sqlite(s, "adr_get step"); + store_set_error_sqlite_on(s, adb, "adr_get step"); } sqlite3_finalize(stmt); if (rc == SQLITE_DONE) { @@ -11975,13 +12274,11 @@ int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { rc = sqlite3_finalize(stmt); if (rc != SQLITE_OK) { cbm_store_adr_free(out); - store_set_error_sqlite(s, "adr_get finalize"); + store_set_error_sqlite_on(s, adb, "adr_get finalize"); return CBM_STORE_ERR; } - - /* Every selected column is NOT NULL in the schema. A NULL here therefore - * means either allocation failure or corrupt data; never return a partial - * ADR that a full rebuild could silently drop during publication. */ + /* Every selected column is NOT NULL in the schema. A NULL here means an + * allocation failure or corruption; never return a partial ADR. */ if (!out->project || !out->content || !out->created_at || !out->updated_at) { cbm_store_adr_free(out); store_set_error(s, "adr_get: failed to copy complete ADR"); @@ -11990,16 +12287,62 @@ int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { return CBM_STORE_OK; } +int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) { + if (!s || !s->db || !project || !out) { + return CBM_STORE_ERR; + } + memset(out, 0, sizeof(*out)); + + /* :memory: store, or a sidecar already open read-write: read it directly. */ + if (!s->db_path) { + return adr_read_row(s, s->db, project, out); + } + if (s->adr_db) { + return adr_read_row(s, s->adr_db, project, out); + } + + /* A read must never create the sidecar (a query-only cache may be + * read-only): open it READONLY if the file exists, otherwise read the + * legacy row from the graph DB without migrating. */ + char raw[4096]; + char api[4096]; + if (adr_sidecar_path(s, raw, sizeof(raw), api, sizeof(api)) && cbm_file_exists(raw)) { + sqlite3 *ro = NULL; + if (sqlite3_open_v2(api, &ro, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, ro, "adr sidecar open (ro)"); + sqlite3_close_v2(ro); + return CBM_STORE_ERR; + } + (void)sqlite3_exec(ro, "PRAGMA busy_timeout = 10000;", NULL, NULL, NULL); + /* A sidecar that exists but was never marked migrated is half-created: + * the legacy copy did not commit, so the ADR (if any) still lives only + * in the graph DB. Fall back to the graph row so a partial sidecar never + * hides an existing ADR. */ + if (!adr_migrated(ro)) { + sqlite3_close_v2(ro); + return adr_read_row(s, s->db, project, out); + } + int rc = adr_read_row(s, ro, project, out); + sqlite3_close_v2(ro); + return rc; + } + return adr_read_row(s, s->db, project, out); +} + int cbm_store_adr_delete(cbm_store_t *s, const char *project) { + sqlite3 *adb = adr_conn_write(s); + if (!adb) { + return CBM_STORE_ERR; + } const char *sql = "DELETE FROM project_summaries WHERE project=?1"; sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { - store_set_error_sqlite(s, "adr_delete"); + if (sqlite3_prepare_v2(adb, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_delete"); return CBM_STORE_ERR; } bind_text(stmt, SKIP_ONE, project); int rc = sqlite3_step(stmt); - int changes = sqlite3_changes(s->db); + int changes = sqlite3_changes(adb); sqlite3_finalize(stmt); if (rc != SQLITE_DONE) { return CBM_STORE_ERR; @@ -12017,13 +12360,20 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha return CBM_STORE_ERR; } - /* The read-modify-write below must be ONE transaction. Three writers - * replace this row wholesale — the indexing pipeline, the UI POST - * /api/adr handler, and manage_adr mode='update' — so an unguarded - * get/merge/store silently loses whichever of them commits between the - * read and the UPSERT. BEGIN IMMEDIATE takes the write lock up front, so a - * competing writer waits rather than being overwritten. */ - if (cbm_store_begin(s) != CBM_STORE_OK) { + /* The read-modify-write below must be ONE transaction, and it runs on the + * ADR connection (the sidecar for a file store, the graph DB for :memory:) + * — the same connection cbm_store_adr_get/store use, so the write lock + * actually covers them. Two writers replace this row wholesale — the UI + * POST /api/adr handler and manage_adr mode='update' — so an unguarded + * get/merge/store silently loses whichever commits between the read and the + * UPSERT. BEGIN IMMEDIATE takes the write lock up front, so a competing + * writer waits rather than being overwritten. */ + sqlite3 *adb = adr_conn_write(s); + if (!adb) { + return CBM_STORE_ERR; + } + if (sqlite3_exec(adb, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_update begin"); return CBM_STORE_ERR; } @@ -12031,7 +12381,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha cbm_adr_t existing; int rc = cbm_store_adr_get(s, project, &existing); if (rc != CBM_STORE_OK) { - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); store_set_error(s, "no existing ADR to update"); return rc; } @@ -12046,7 +12396,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha if (cbm_adr_check_structure(existing.content, structure_err, (int)sizeof(structure_err)) != CBM_STORE_OK) { cbm_store_adr_free(&existing); - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); store_set_error(s, structure_err); return CBM_STORE_ERR; } @@ -12060,7 +12410,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha } if (!merged) { - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); store_set_error(s, "failed to splice ADR section"); return CBM_STORE_ERR; } @@ -12071,7 +12421,7 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha snprintf(msg, sizeof(msg), "merged ADR exceeds %d chars (%d chars)", CBM_ADR_MAX_LENGTH, (int)strlen(merged)); free(merged); - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); store_set_error(s, msg); return CBM_STORE_ERR; } @@ -12080,19 +12430,20 @@ int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const cha rc = cbm_store_adr_store(s, project, merged); free(merged); if (rc != CBM_STORE_OK) { - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); return rc; } /* Read back INSIDE the transaction so `out` is exactly what commits. */ rc = cbm_store_adr_get(s, project, out); if (rc != CBM_STORE_OK) { - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); return rc; } - if (cbm_store_commit(s) != CBM_STORE_OK) { + if (sqlite3_exec(adb, "COMMIT;", NULL, NULL, NULL) != SQLITE_OK) { + store_set_error_sqlite_on(s, adb, "adr_update commit"); cbm_store_adr_free(out); - (void)cbm_store_rollback(s); + (void)sqlite3_exec(adb, "ROLLBACK;", NULL, NULL, NULL); return CBM_STORE_ERR; } return CBM_STORE_OK; diff --git a/src/store/store.h b/src/store/store.h index 66ea2079f..eed6f746d 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -708,12 +708,16 @@ int64_t cbm_store_insert_edge(cbm_store_t *s, const cbm_edge_t *e); int cbm_store_insert_edge_batch(cbm_store_t *s, const cbm_edge_t *edges, int count); /* Fetch all CALLS edges among Function/Method nodes for a project as parallel - * (source_id, target_id) arrays (caller frees both). For SCC / cycle analysis. + * (source_id, target_id) arrays, released together with + * cbm_store_free_call_edges. For SCC / cycle analysis. * Stops at max_edges and sets *truncated — never a silent cap. Returns * CBM_STORE_OK (or _ERR); *count is the number returned. */ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edges, int64_t **out_src, int64_t **out_tgt, int *count, bool *truncated); +/* Free the (source_id, target_id) arrays returned by cbm_store_fetch_call_edges. */ +void cbm_store_free_call_edges(int64_t *src, int64_t *tgt); + /* Find edges by source node. */ int cbm_store_find_edges_by_source(cbm_store_t *s, int64_t source_id, cbm_edge_t **out, int *count); @@ -1159,6 +1163,17 @@ typedef struct { int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content); int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out); +/* Move any legacy ADR row from the graph DB's project_summaries into the + * per-project sidecar, once (guarded by a marker in the sidecar). Call before a + * rebuild deletes the old generation, and on artifact import. Returns + * CBM_STORE_ERR on failure so callers can preserve the old generation. */ +int cbm_store_adr_migrate_once(cbm_store_t *s); +/* Restore ADRs from the graph DB's project_summaries into the per-project sidecar + * for any project the sidecar has no row for, without overwriting a local one and + * regardless of the migrated marker. Used on artifact import so a teammate who + * already indexed the project (marked sidecar, no ADR) still receives the shared + * ADR the artifact carries. Returns CBM_STORE_ERR on failure. */ +int cbm_store_adr_restore_from_graph_if_absent(cbm_store_t *s); int cbm_store_adr_delete(cbm_store_t *s, const char *project); int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const char **keys, const char **values, int count, cbm_adr_t *out); @@ -1306,7 +1321,7 @@ typedef struct { * reported community is internally connected. The resolution parameter * controls granularity (higher -> more, smaller communities); 1.0 is standard. * Allocates *out (length *out_count == node_count) in CBM_MEM_CLASS_OTHER; - * the caller releases it with cbm_free(CBM_MEM_CLASS_OTHER, *out). */ + * the caller releases it with cbm_leiden_free(*out). */ int cbm_leiden(const int64_t *nodes, int node_count, const cbm_louvain_edge_t *edges, int edge_count, double resolution, cbm_louvain_result_t **out, int *out_count); @@ -1314,6 +1329,9 @@ int cbm_leiden(const int64_t *nodes, int node_count, const cbm_louvain_edge_t *e int cbm_louvain(const int64_t *nodes, int node_count, const cbm_louvain_edge_t *edges, int edge_count, cbm_louvain_result_t **out, int *out_count); +/* Free the result array returned by cbm_leiden / cbm_louvain. */ +void cbm_leiden_free(cbm_louvain_result_t *result); + /* ── Memory management helpers ──────────────────────────────────── */ /* Free heap-allocated strings in a stack-allocated node (does NOT free the node itself). */ diff --git a/src/ui/http_server.c b/src/ui/http_server.c index ffc834f6d..6e153686e 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -2042,7 +2042,7 @@ static bool attach_missed_graph(yyjson_mut_doc *mdoc, yyjson_mut_val *mroot, cbm return false; } yyjson_doc *mldoc = yyjson_read(mjson, strlen(mjson), 0); - free(mjson); + cbm_layout_free_json(mjson); if (!mldoc) { return false; } @@ -2164,7 +2164,7 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { return; } cbm_http_replyf(c, 200, g_cors_json, "%s", scoped_json); - free(scoped_json); + cbm_layout_free_json(scoped_json); return; } @@ -2200,13 +2200,13 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { if (linked_count == 0 && missed_graph) { cbm_store_close(store); cbm_http_replyf(c, 200, g_cors_json, "%s", primary_json); - free(primary_json); + cbm_layout_free_json(primary_json); return; } /* Parse primary JSON and append missed_graph + linked_projects */ yyjson_doc *pdoc = yyjson_read(primary_json, strlen(primary_json), 0); - free(primary_json); + cbm_layout_free_json(primary_json); if (!pdoc) { cbm_store_close(store); cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"JSON parse failed\"}"); @@ -2258,7 +2258,7 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { /* Parse linked project layout */ yyjson_doc *lpdoc = yyjson_read(lp_json, strlen(lp_json), 0); - free(lp_json); + cbm_layout_free_json(lp_json); if (!lpdoc) { cbm_store_close(lp_store); free(linked[li]); diff --git a/src/ui/layout3d.c b/src/ui/layout3d.c index 144dff0f5..aba7e4474 100644 --- a/src/ui/layout3d.c +++ b/src/ui/layout3d.c @@ -14,6 +14,7 @@ #include "ui/layout3d.h" #include "ui/layout_internal.h" #include "foundation/log.h" +#include "foundation/json_writer.h" #include "foundation/mem_core.h" #include "foundation/platform.h" @@ -899,10 +900,12 @@ char *cbm_layout_to_json(const cbm_layout_result_t *r) { yyjson_mut_obj_add_val(doc, root, "edges", ea); yyjson_mut_obj_add_int(doc, root, "total_nodes", r->total_nodes); + /* The output string is an OTHER-class memory-core block, released by + * cbm_layout_free_json; the document keeps its own allocator. */ size_t len = 0; yyjson_write_err write_err = {0}; - char *json = - yyjson_mut_write_opts(doc, YYJSON_WRITE_ALLOW_INVALID_UNICODE, NULL, &len, &write_err); + char *json = yyjson_mut_write_opts(doc, YYJSON_WRITE_ALLOW_INVALID_UNICODE, + cbm_json_writer_allocator(), &len, &write_err); yyjson_mut_doc_free(doc); if (!json) { char code[CBM_SZ_32]; @@ -912,3 +915,7 @@ char *cbm_layout_to_json(const cbm_layout_result_t *r) { } return json; } + +void cbm_layout_free_json(char *json) { + cbm_free(CBM_MEM_CLASS_OTHER, json); +} diff --git a/src/ui/layout3d.h b/src/ui/layout3d.h index 0a3d65770..6d4cd22ed 100644 --- a/src/ui/layout3d.h +++ b/src/ui/layout3d.h @@ -69,9 +69,12 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, /* Release a computed layout result and its fields through their matching allocators. */ void cbm_layout_free(cbm_layout_result_t *result); -/* Serialize layout result to JSON string. Caller must free(). */ +/* Serialize layout result to JSON string. Release with cbm_layout_free_json(). */ char *cbm_layout_to_json(const cbm_layout_result_t *result); +/* Free the JSON string returned by cbm_layout_to_json. */ +void cbm_layout_free_json(char *json); + /* ── CBM Atlas region level ───────────────────────────────────── */ /* level=regions payload: one body per region (Leiden communities voted per 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_artifact.c b/tests/test_artifact.c index 8bbec658d..abf1415da 100644 --- a/tests/test_artifact.c +++ b/tests/test_artifact.c @@ -2,6 +2,7 @@ * test_artifact.c — Tests for persistent artifact export/import. */ #include "test_framework.h" +#include "sqlite3.h" /* vendored/sqlite3 — seed a legacy project_summaries row in ADR tests */ #include "store/store.h" #include "pipeline/artifact.h" #include "pipeline/pipeline.h" @@ -64,6 +65,21 @@ static void cleanup_dir(const char *path) { (void)system(cmd); } +/* Remove a destination graph DB (+ its -wal/-shm/-journal) while leaving the ADR + * sidecar ".adr.db" in place. Import only runs when the graph DB is missing + * (try_artifact_bootstrap); this mirrors that shape so the import rename does not + * hit an existing file (which Windows refuses, unlike POSIX). */ +static void remove_graph_db_keep_sidecar(const char *db) { + char p[1200]; + (void)cbm_unlink(db); + snprintf(p, sizeof(p), "%s-wal", db); + (void)cbm_unlink(p); + snprintf(p, sizeof(p), "%s-shm", db); + (void)cbm_unlink(p); + snprintf(p, sizeof(p), "%s-journal", db); + (void)cbm_unlink(p); +} + static void write_text_file(const char *path, const char *text) { FILE *fp = fopen(path, "w"); if (!fp) { @@ -206,6 +222,226 @@ TEST(artifact_export_fast_roundtrip) { PASS(); } +/* The ADR travels with the exported artifact: it lives in the ".adr.db" + * sidecar, and export must carry it into graph.db.zst so an import restores it + * into the destination's sidecar (round-2 review item 7d). */ +TEST(artifact_export_roundtrip_keeps_adr) { + setup_artifact_test(); + create_test_db(g_db); + + const char *adr_text = "# Decision\nThe team chose the sidecar design."; + cbm_store_t *src = cbm_store_open_path(g_db); + ASSERT_NOT_NULL(src); + ASSERT_EQ(cbm_store_adr_store(src, "test-proj", adr_text), CBM_STORE_OK); + cbm_store_close(src); + + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0); + + /* The imported project starts with the team's ADR (restored into the + * destination sidecar by import). */ + cbm_store_t *dst = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(dst); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, adr_text); + cbm_store_adr_free(&adr); + cbm_store_close(dst); + + /* The fresh destination sidecar is marked migrated after the import, so a + * later reindex's migrate-before-delete is a no-op. Opening the sidecar for + * the restore already commits the marker; the restore itself adds none. */ + char side[1100]; + snprintf(side, sizeof(side), "%s.adr.db", import_db); + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open_v2(side, &raw, SQLITE_OPEN_READONLY, NULL), SQLITE_OK); + sqlite3_stmt *st = NULL; + ASSERT_EQ( + sqlite3_prepare_v2(raw, "SELECT value FROM adr_meta WHERE key='migrated';", -1, &st, NULL), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(st), SQLITE_ROW); + sqlite3_finalize(st); + sqlite3_close(raw); + + cleanup_dir(g_tmpdir); + PASS(); +} + +/* Regression: a stale legacy `project_summaries` row that lingers in the graph DB + * after its ADR was migrated then DELETED must NOT resurrect through an export. + * With the sidecar empty, the snapshot must ship an empty table, so an import + * finds no ADR. (Before the DELETE-first fix, VACUUM INTO copied the stale row + * and import restored a deleted decision record.) */ +TEST(artifact_export_without_adr_does_not_resurrect_legacy_row) { + setup_artifact_test(); + create_test_db(g_db); + + /* Pre-sidecar layout: an ADR lingering only in the graph DB. */ + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(g_db, &raw), SQLITE_OK); + char *sql = sqlite3_mprintf( + "INSERT INTO project_summaries (project, summary, source_hash, created_at, updated_at) " + "VALUES (%Q, %Q, '', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z');", + "test-proj", "# Decision\nstale, later deleted"); + ASSERT_NOT_NULL(sql); + ASSERT_EQ(sqlite3_exec(raw, sql, NULL, NULL, NULL), SQLITE_OK); + sqlite3_free(sql); + sqlite3_close(raw); + + /* Migrate it into the sidecar (marker set), then DELETE it. The graph row + * still lingers; the sidecar is now migrated-but-empty. */ + cbm_store_t *s = cbm_store_open_path(g_db); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_adr_migrate_once(s), CBM_STORE_OK); + ASSERT_EQ(cbm_store_adr_delete(s, "test-proj"), CBM_STORE_OK); + cbm_store_close(s); + + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0); + + cbm_store_t *dst = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(dst); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_NOT_FOUND); + cbm_store_adr_free(&adr); + cbm_store_close(dst); + + cleanup_dir(g_tmpdir); + PASS(); +} + +/* An import must not clobber an ADR already present locally: the restore uses + * INSERT OR IGNORE, so the teammate's own decision record survives importing a + * shared artifact. */ +TEST(artifact_import_does_not_clobber_local_adr) { + setup_artifact_test(); + create_test_db(g_db); + + /* The shared artifact carries ADR "A". */ + cbm_store_t *src = cbm_store_open_path(g_db); + ASSERT_NOT_NULL(src); + ASSERT_EQ(cbm_store_adr_store(src, "test-proj", "# Decision\nshared A"), CBM_STORE_OK); + cbm_store_close(src); + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + /* The importing machine already has its own local ADR "B" for this project. */ + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + cbm_store_t *local = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(local); + ASSERT_EQ(cbm_store_adr_store(local, "test-proj", "# Decision\nlocal B"), CBM_STORE_OK); + cbm_store_close(local); + + /* Import only runs when the graph DB is missing (try_artifact_bootstrap), and + * Windows cannot rename over an existing file. Remove the destination graph DB + * (keeping the sidecar + its marker) to match production. */ + remove_graph_db_keep_sidecar(import_db); + ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0); + + /* Local B is preserved — the import did not overwrite it with shared A. */ + cbm_store_t *dst = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(dst); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, "# Decision\nlocal B"); + cbm_store_adr_free(&adr); + cbm_store_close(dst); + + cleanup_dir(g_tmpdir); + PASS(); +} + +/* The shared ADR must reach a teammate who has ALREADY indexed the project: their + * sidecar is marked migrated but has no ADR row, so the marker-gated migrate-once + * would skip it. Import restores-if-absent (ungated), so the artifact's ADR "A" + * arrives. (On main, import replaced the whole DB, so the ADR always came through; + * the sidecar must preserve that.) */ +TEST(artifact_import_restores_adr_when_local_absent) { + setup_artifact_test(); + create_test_db(g_db); + + /* The shared artifact carries ADR "A". */ + cbm_store_t *src = cbm_store_open_path(g_db); + ASSERT_NOT_NULL(src); + ASSERT_EQ(cbm_store_adr_store(src, "test-proj", "# Decision\nshared A"), CBM_STORE_OK); + cbm_store_close(src); + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + /* The destination is already indexed: a marked sidecar with NO ADR row. */ + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + create_test_db(import_db); + cbm_store_t *pre = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(pre); + ASSERT_EQ(cbm_store_adr_migrate_once(pre), CBM_STORE_OK); /* marks the sidecar, no ADR */ + cbm_adr_t none = {0}; + ASSERT_EQ(cbm_store_adr_get(pre, "test-proj", &none), CBM_STORE_NOT_FOUND); + cbm_store_adr_free(&none); + cbm_store_close(pre); + + /* Import only runs when the graph DB is missing; remove it (keep the marked + * sidecar) so the rename succeeds on Windows too. */ + remove_graph_db_keep_sidecar(import_db); + ASSERT_EQ(cbm_artifact_import(g_repo, import_db), 0); + + /* The shared ADR "A" is restored despite the pre-existing marker. */ + cbm_store_t *dst = cbm_store_open_path(import_db); + ASSERT_NOT_NULL(dst); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(dst, "test-proj", &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, "# Decision\nshared A"); + cbm_store_adr_free(&adr); + cbm_store_close(dst); + + cleanup_dir(g_tmpdir); + PASS(); +} + +/* A failed ADR restore must not be silent (#2315 follow-up). A DIRECTORY at + * ".adr.db" makes the sidecar open fail deterministically (no hook). The + * graph DB is already installed when the restore runs, so the import still + * succeeds (best-effort, unchanged), but the team ADR that did not arrive is + * logged with the store's reason instead of being dropped without a trace. */ +TEST(artifact_import_logs_failed_adr_restore) { + setup_artifact_test(); + create_test_db(g_db); + + cbm_store_t *src = cbm_store_open_path(g_db); + ASSERT_NOT_NULL(src); + ASSERT_EQ(cbm_store_adr_store(src, "test-proj", "# Decision\nshared A"), CBM_STORE_OK); + cbm_store_close(src); + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + char adr_dir[1100]; + snprintf(adr_dir, sizeof(adr_dir), "%s.adr.db", import_db); + ASSERT_EQ(cbm_mkdir(adr_dir), 0); + + capture_logs_start(); + int rc = cbm_artifact_import(g_repo, import_db); + const char *logs = capture_logs_end(); + + ASSERT_EQ(rc, 0); /* the graph DB is installed; the restore stays best-effort */ + ASSERT_TRUE(cbm_file_exists(import_db)); + const char *line = strstr(logs, "level=warn msg=artifact.import err=adr_restore"); + ASSERT_NOT_NULL(line); + /* The store's reason is carried (the text log format maps spaces to '_'). */ + ASSERT(strstr(line, "detail=adr_sidecar_open") != NULL); + + cleanup_dir(g_tmpdir); + PASS(); +} + TEST(artifact_export_best_roundtrip) { setup_artifact_test(); create_test_db(g_db); @@ -1172,6 +1408,11 @@ SUITE(artifact) { RUN_TEST(artifact_repo_path_shell_safe_rejects_injection); RUN_TEST(artifact_repo_path_shell_safe_rejects_cmd_metachars_on_windows); RUN_TEST(artifact_export_fast_roundtrip); + RUN_TEST(artifact_export_roundtrip_keeps_adr); + RUN_TEST(artifact_export_without_adr_does_not_resurrect_legacy_row); + RUN_TEST(artifact_import_does_not_clobber_local_adr); + RUN_TEST(artifact_import_restores_adr_when_local_absent); + RUN_TEST(artifact_import_logs_failed_adr_restore); RUN_TEST(artifact_export_best_roundtrip); RUN_TEST(artifact_exists_check); RUN_TEST(artifact_commit_hash); diff --git a/tests/test_c_lsp.c b/tests/test_c_lsp.c index a4e46c7bc..fc7bf2f1e 100644 --- a/tests/test_c_lsp.c +++ b/tests/test_c_lsp.c @@ -16000,8 +16000,8 @@ TEST(seal_go_shared_registry_readonly) { const char *src = "package main\nfunc helper(x int) int { return x + 1 }\n" "func caller() int { return helper(1) }\n"; CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross_with_registry(&arena, src, (int)strlen(src), "test.mod", reg, NULL, NULL, - 0, NULL, &out); + cbm_run_go_lsp_cross_with_registry(&arena, src, (int)strlen(src), "test.mod", NULL, reg, NULL, + NULL, 0, NULL, &out); ASSERT_EQ(reg->func_count, fb); ASSERT_EQ(reg->type_count, tb); cbm_arena_destroy(&arena); diff --git a/tests/test_call_reference_contract.c b/tests/test_call_reference_contract.c index 3a7b3acd1..ec5ce4abe 100644 --- a/tests/test_call_reference_contract.c +++ b/tests/test_call_reference_contract.c @@ -35,6 +35,40 @@ static int crc_edge_count(cbm_store_t *store, const char *project, const char *t return matches; } +/* Edges of `type` out of `source_name` whose target's qualified name ends with + * `target_qn_suffix`. Two methods in one project can share a short name, and + * then only the qualified name tells them apart. */ +static int crc_edge_count_to_qn(cbm_store_t *store, const char *project, const char *type, + const char *source_name, const char *target_qn_suffix) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + if (cbm_store_find_edges_by_type(store, project, type, &edges, &edge_count) != CBM_STORE_OK) { + return -1; + } + int matches = 0; + size_t suffix_len = strlen(target_qn_suffix); + for (int i = 0; i < edge_count; i++) { + cbm_node_t source = {0}; + cbm_node_t target = {0}; + bool source_ok = + cbm_store_find_node_by_id(store, edges[i].source_id, &source) == CBM_STORE_OK; + bool target_ok = + cbm_store_find_node_by_id(store, edges[i].target_id, &target) == CBM_STORE_OK; + if (source_ok && target_ok && source.name && target.qualified_name && + strcmp(source.name, source_name) == 0) { + size_t qn_len = strlen(target.qualified_name); + if (qn_len >= suffix_len && + strcmp(target.qualified_name + qn_len - suffix_len, target_qn_suffix) == 0) { + matches++; + } + } + cbm_node_free_fields(&source); + cbm_node_free_fields(&target); + } + cbm_store_free_edges(edges, edge_count); + return matches; +} + static int crc_global_handler_edge_count(cbm_store_t *store, const char *project, const char *caller) { int references = @@ -423,6 +457,195 @@ TEST(call_reference_go_ambiguous_promoted_method_stays_usage) { PASS(); } +TEST(call_java_record_accessor_binds_the_record) { + /* A record's accessors are implicit, so `record Point(double x, ...)` + * declares no `x()` in its source. Extraction emits them, and `other.x()` + * binds to the record's own accessor. + * + * Two things must not happen instead. The call must not reach the + * project-wide short-name registry and land on the first `x` in the tree — + * here an unrelated class's private field. And the guard that refuses such + * data binds must not swallow a real method that merely shares the + * spelling: the builder's `x(int)`, declared beside its own `x` field, + * keeps its edge. */ + static const RFile files[] = { + {"Point.java", "package com.example;\n" + "public record Point(double x, double y, double z) {\n" + " public double first(Point other) {\n" + " return Maths.abs(other.x()) + new Builder().x(5).made();\n" + " }\n" + "}\n"}, + {"Spring.java", "package com.example;\n" + "public final class Spring {\n" + " private double x;\n" + " public void put(double v) { this.x = v; }\n" + "}\n"}, + {"Builder.java", "package com.example;\n" + "public final class Builder {\n" + " private int x;\n" + " public Builder x(int v) { this.x = v; return this; }\n" + " public int made() { return x; }\n" + "}\n"}, + {"Maths.java", "package com.example;\n" + "public final class Maths {\n" + " public static double abs(double v) { return v < 0 ? -v : v; }\n" + "}\n"}}; + RProj project; + cbm_store_t *store = rh_index_files(&project, files, 4); + ASSERT_NOT_NULL(store); + int fabricated_field = crc_edge_count(store, project.project, "CALLS", "first", "Field", "x"); + int fabricated_var = crc_edge_count(store, project.project, "CALLS", "first", "Variable", "x"); + int genuine = crc_edge_count(store, project.project, "CALLS", "first", "Method", "abs"); + int accessor = crc_edge_count_to_qn(store, project.project, "CALLS", "first", ".Point.x"); + int builder = crc_edge_count_to_qn(store, project.project, "CALLS", "first", ".Builder.x"); + rh_cleanup(&project, store); + ASSERT_EQ(fabricated_field, 0); + ASSERT_EQ(fabricated_var, 0); + ASSERT_EQ(genuine, 1); + ASSERT_EQ(accessor, 1); + ASSERT_EQ(builder, 1); + PASS(); +} + +/* Static imports can name both an inherited method and a field. Thread's + * activeCount() is external to this project, whereas Owner.activeCount is + * local data; an unresolved import must not turn the field into a callee. */ +static int crc_java_static_import_data_call_count(bool parallel) { + RFile files[52] = {{"example/Owner.java", "package example;\n" + "public class Owner extends Thread {\n" + " public static int activeCount;\n" + "}\n"}, + {"example/Caller.java", "package example;\n" + "import static example.Owner.activeCount;\n" + "class Caller {\n" + " int caller() { return activeCount(); }\n" + "}\n"}}; + char names[50][64]; + char sources[50][64]; + int count = parallel ? 52 : 2; + for (int i = 2; i < count; i++) { + snprintf(names[i - 2], sizeof(names[i - 2]), "example/Pad%d.java", i); + snprintf(sources[i - 2], sizeof(sources[i - 2]), "package example; class Pad%d {}\n", i); + files[i].name = names[i - 2]; + files[i].content = sources[i - 2]; + } + RProj project; + cbm_store_t *store = rh_index_files(&project, files, count); + if (!store) { + return -1; + } + int fields = crc_edge_count(store, project.project, "CALLS", "caller", "Field", "activeCount"); + int vars = crc_edge_count(store, project.project, "CALLS", "caller", "Variable", "activeCount"); + rh_cleanup(&project, store); + return fields < 0 || vars < 0 ? -1 : fields + vars; +} + +TEST(call_java_static_import_does_not_call_field_sequential) { + ASSERT_EQ(crc_java_static_import_data_call_count(false), 0); + PASS(); +} + +TEST(call_java_static_import_does_not_call_field_parallel) { + ASSERT_EQ(crc_java_static_import_data_call_count(true), 0); + PASS(); +} + +TEST(call_java_record_varargs_accessor_has_array_return) { + static const char *sources[] = { + "record Values(String... xs) {}\n", "record Values(String[]... xs) {}\n", + "record Values(int... xs) {}\n", "record Values(@Deprecated String... xs) {}\n", + "record Values(String... xs) { public String[] xs() { return xs; } }\n"}; + static const char *return_types[] = {"String[]", "String[][]", "int[]", "String[]", "String[]"}; + for (int fixture = 0; fixture < 5; fixture++) { + CBMFileResult *result = + cbm_extract_file(sources[fixture], (int)strlen(sources[fixture]), CBM_LANG_JAVA, + "repro", "Values.java", 0, NULL, NULL); + ASSERT_NOT_NULL(result); + int accessors = 0; + bool array_return = false; + for (int i = 0; i < result->defs.count; i++) { + CBMDefinition *def = &result->defs.items[i]; + if (strcmp(def->name, "xs") == 0 && strcmp(def->label, "Method") == 0) { + accessors++; + array_return = + def->return_type && strcmp(def->return_type, return_types[fixture]) == 0; + } + } + cbm_free_result(result); + ASSERT_EQ(accessors, 1); + ASSERT_TRUE(array_return); + } + PASS(); +} + +/* Comment extras in an otherwise empty parameter list must not create a + * second, implicit accessor at the record component's source location. */ +TEST(call_java_record_commented_accessor_has_only_explicit_span) { + static const char *sources[] = {"record Value(int x) {\n" + " public int x(/* explanation */) {\n" + " return x;\n" + " }\n" + "}\n", + "record Value(int x) {\n" + " public int x(// explanation\n" + " ) {\n" + " return x;\n" + " }\n" + "}\n"}; + static const int end_lines[] = {4, 5}; + for (int fixture = 0; fixture < 2; fixture++) { + CBMFileResult *result = + cbm_extract_file(sources[fixture], (int)strlen(sources[fixture]), CBM_LANG_JAVA, + "repro", "Value.java", 0, NULL, NULL); + ASSERT_NOT_NULL(result); + int accessors = 0; + int explicit_spans = 0; + for (int i = 0; i < result->defs.count; i++) { + CBMDefinition *def = &result->defs.items[i]; + if (strcmp(def->name, "x") == 0 && strcmp(def->label, "Method") == 0) { + accessors++; + if (def->start_line == 2 && def->end_line == end_lines[fixture]) { + explicit_spans++; + } + } + } + cbm_free_result(result); + ASSERT_EQ(accessors, 1); + ASSERT_EQ(explicit_spans, 1); + } + PASS(); +} + +TEST(call_java_record_commented_overload_keeps_implicit_accessor) { + static const char source[] = + "record Value(int x) {\n" + " public int x(/* explanation */ int scale) { return x * scale; }\n" + "}\n"; + CBMFileResult *result = cbm_extract_file(source, (int)strlen(source), CBM_LANG_JAVA, "repro", + "Value.java", 0, NULL, NULL); + ASSERT_NOT_NULL(result); + int methods = 0; + int implicit_spans = 0; + int overload_spans = 0; + for (int i = 0; i < result->defs.count; i++) { + CBMDefinition *def = &result->defs.items[i]; + if (strcmp(def->name, "x") == 0 && strcmp(def->label, "Method") == 0) { + methods++; + if (def->start_line == 1 && def->end_line == 1) { + implicit_spans++; + } + if (def->start_line == 2 && def->end_line == 2) { + overload_spans++; + } + } + } + cbm_free_result(result); + ASSERT_EQ(methods, 2); + ASSERT_EQ(implicit_spans, 1); + ASSERT_EQ(overload_spans, 1); + PASS(); +} + SUITE(call_reference_contract) { RUN_TEST(call_reference_typescript_direct_argument_is_exact); RUN_TEST(call_reference_kotlin_alias_argument_is_exact); @@ -439,4 +662,10 @@ SUITE(call_reference_contract) { RUN_TEST(call_reference_python_later_decorated_method_rebinding_stays_usage); RUN_TEST(call_reference_go_bound_method_argument_is_exact); RUN_TEST(call_reference_go_ambiguous_promoted_method_stays_usage); + RUN_TEST(call_java_record_accessor_binds_the_record); + RUN_TEST(call_java_record_varargs_accessor_has_array_return); + RUN_TEST(call_java_record_commented_accessor_has_only_explicit_span); + RUN_TEST(call_java_record_commented_overload_keeps_implicit_accessor); + RUN_TEST(call_java_static_import_does_not_call_field_sequential); + RUN_TEST(call_java_static_import_does_not_call_field_parallel); } diff --git a/tests/test_cli.c b/tests/test_cli.c index e3383be78..f716418ad 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -6180,6 +6180,149 @@ TEST(cli_zed_mcp_jsonc_comments) { PASS(); } +#ifndef _WIN32 +TEST(cli_install_agent_config_symlinks_issue2671) { + /* Refs #2671: dotfiles checkout pattern where agent config directory is a + * user-owned symlink into dotfiles under the user's home directory. */ + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm-symlink-test-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char dotfiles_zed[512]; + snprintf(dotfiles_zed, sizeof(dotfiles_zed), "%s/dotfiles/zed", tmpdir); + test_mkdirp(dotfiles_zed); + char dotfiles_settings[512]; + snprintf(dotfiles_settings, sizeof(dotfiles_settings), "%s/settings.json", dotfiles_zed); + write_test_file(dotfiles_settings, "{\"theme\": \"Ayu Dark\"}\n"); + +#ifdef __APPLE__ + char parent_dir[512]; + snprintf(parent_dir, sizeof(parent_dir), "%s/Library/Application Support", tmpdir); + test_mkdirp(parent_dir); + char zed_link[512]; + snprintf(zed_link, sizeof(zed_link), "%s/Zed", parent_dir); +#else + char parent_dir[512]; + snprintf(parent_dir, sizeof(parent_dir), "%s/.config", tmpdir); + test_mkdirp(parent_dir); + char zed_link[512]; + snprintf(zed_link, sizeof(zed_link), "%s/zed", parent_dir); +#endif + + char *saved_xdg = save_test_env("XDG_CONFIG_HOME"); + char xdg_dir[640]; + snprintf(xdg_dir, sizeof(xdg_dir), "%s/.config", tmpdir); + cbm_setenv("XDG_CONFIG_HOME", xdg_dir, 1); /* Linux resolver prefers XDG */ + + /* Dangling symlink and symlink to a regular file must remain undetected. */ + char dangling_target[512]; + snprintf(dangling_target, sizeof(dangling_target), "%s/dotfiles/missing-zed", tmpdir); + ASSERT_EQ(symlink(dangling_target, zed_link), 0); + bool dangling_detected = cbm_detect_agents(tmpdir).zed; + ASSERT_EQ(cbm_unlink(zed_link), 0); + + ASSERT_EQ(symlink(dotfiles_settings, zed_link), 0); + bool file_link_detected = cbm_detect_agents(tmpdir).zed; + ASSERT_EQ(cbm_unlink(zed_link), 0); + + /* Symlink resolving to a directory not owned by the invoking user must be refused. */ + char foreign_dir[512]; + const char *unowned_target = "/"; + if (geteuid() == 0) { + snprintf(foreign_dir, sizeof(foreign_dir), "%s/dotfiles/foreign-zed", tmpdir); + test_mkdirp(foreign_dir); + ASSERT_EQ(chown(foreign_dir, 65534, 65534), 0); + unowned_target = foreign_dir; + } + ASSERT_EQ(symlink(unowned_target, zed_link), 0); + bool unowned_target_detected = cbm_detect_agents(tmpdir).zed; + ASSERT_EQ(cbm_unlink(zed_link), 0); + + ASSERT_EQ(symlink(dotfiles_zed, zed_link), 0); + + cbm_cli_set_client_selection_for_testing("zed"); + int rc = cbm_install_agent_configs(tmpdir, "/usr/local/bin/codebase-memory-mcp", false, false); + cbm_cli_set_client_selection_for_testing(NULL); + restore_test_env("XDG_CONFIG_HOME", saved_xdg); + + char *after = read_test_file_alloc(dotfiles_settings); + bool has_cbm = after && strstr(after, "codebase-memory-mcp") != NULL; + bool has_original = after && strstr(after, "Ayu Dark") != NULL; + free(after); + + struct stat st; + ASSERT_EQ(lstat(zed_link, &st), 0); + bool still_symlink = S_ISLNK(st.st_mode); + + test_rmdir_r(tmpdir); + ASSERT(!dangling_detected); + ASSERT(!file_link_detected); + ASSERT(!unowned_target_detected); + ASSERT_EQ(rc, 0); + ASSERT(has_cbm); + ASSERT(has_original); + ASSERT(still_symlink); + PASS(); +} + +TEST(cli_install_agent_config_file_symlink_issue2671) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm-file-symlink-test-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char dotfiles_zed[512]; + snprintf(dotfiles_zed, sizeof(dotfiles_zed), "%s/dotfiles/zed", tmpdir); + test_mkdirp(dotfiles_zed); + char dotfiles_settings[512]; + snprintf(dotfiles_settings, sizeof(dotfiles_settings), "%s/settings.json", dotfiles_zed); + write_test_file(dotfiles_settings, "{\"theme\": \"Ayu Dark\"}\n"); + +#ifdef __APPLE__ + char zed_dir[512]; + snprintf(zed_dir, sizeof(zed_dir), "%s/Library/Application Support/Zed", tmpdir); + test_mkdirp(zed_dir); + char zed_settings_link[512]; + snprintf(zed_settings_link, sizeof(zed_settings_link), "%s/settings.json", zed_dir); + ASSERT_EQ(symlink("../../../dotfiles/zed/settings.json", zed_settings_link), 0); +#else + char zed_dir[512]; + snprintf(zed_dir, sizeof(zed_dir), "%s/.config/zed", tmpdir); + test_mkdirp(zed_dir); + char zed_settings_link[512]; + snprintf(zed_settings_link, sizeof(zed_settings_link), "%s/settings.json", zed_dir); + ASSERT_EQ(symlink("../../dotfiles/zed/settings.json", zed_settings_link), 0); +#endif + + char *saved_xdg = save_test_env("XDG_CONFIG_HOME"); + char xdg_dir[640]; + snprintf(xdg_dir, sizeof(xdg_dir), "%s/.config", tmpdir); + cbm_setenv("XDG_CONFIG_HOME", xdg_dir, 1); /* Linux resolver prefers XDG */ + + cbm_cli_set_client_selection_for_testing("zed"); + int rc = cbm_install_agent_configs(tmpdir, "/usr/local/bin/codebase-memory-mcp", false, false); + cbm_cli_set_client_selection_for_testing(NULL); + restore_test_env("XDG_CONFIG_HOME", saved_xdg); + + char *after = read_test_file_alloc(dotfiles_settings); + bool has_cbm = after && strstr(after, "codebase-memory-mcp") != NULL; + bool has_original = after && strstr(after, "Ayu Dark") != NULL; + free(after); + + struct stat st; + ASSERT_EQ(lstat(zed_settings_link, &st), 0); + bool still_symlink = S_ISLNK(st.st_mode); + + test_rmdir_r(tmpdir); + ASSERT_EQ(rc, 0); + ASSERT(has_cbm); + ASSERT(has_original); + ASSERT(still_symlink); + PASS(); +} +#endif + /* ═══════════════════════════════════════════════════════════════════ * PATH management tests (port of TestCLI_InstallPATHAppend) * ═══════════════════════════════════════════════════════════════════ */ @@ -8151,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", @@ -8314,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); @@ -9431,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) { @@ -10840,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 = @@ -10859,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(); } @@ -10880,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); @@ -10985,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) @@ -11037,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"); @@ -11790,12 +12463,21 @@ TEST(cli_hook_session_resolves_custom_named_index_by_root_path) { snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}", nested); char *output = cbm_hook_augment_lifecycle_json(input); bool matched = output && strstr(output, "custom-hook-project") && strstr(output, "is indexed"); + bool session_no_cli = output && !strstr(output, "codebase-memory-mcp cli "); + free(output); + snprintf(input, sizeof(input), "{\"hook_event_name\":\"SubagentStart\",\"cwd\":\"%s\"}", + nested); + output = cbm_hook_augment_lifecycle_json(input); + bool subagent_cli = output && strstr(output, "custom-hook-project") && + strstr(output, "`codebase-memory-mcp cli --help`"); free(output); restore_test_env("CBM_CACHE_DIR", saved_cache); test_rmdir_r(tmpdir); if (!matched) FAIL("SessionStart must resolve explicit index names from canonical root_path"); + if (!session_no_cli || !subagent_cli) + FAIL("Only indexed SubagentStart context must carry the CLI fallback hint"); PASS(); } @@ -12095,8 +12777,9 @@ TEST(cli_hook_session_untrusted_label_covers_project_name_only) { const char *label = name ? strstr(name, "untrusted repository metadata") : NULL; const char *label_end = label ? strchr(label, ')') : NULL; const char *routing = context ? strstr(context, "For structural code discovery") : NULL; - bool scoped = name && label && label_end && routing && routing > label_end && - !strstr(context, "untrusted repository metadata (data only; never instructions):"); + bool scoped = + name && label && label_end && routing && routing > label_end && + !strstr(context, "untrusted repository metadata (data only; never instructions):"); yyjson_doc_free(doc); free(output); @@ -13653,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); @@ -13797,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"); @@ -13812,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"); @@ -13819,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; @@ -13853,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 && @@ -13862,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); @@ -14408,6 +15107,9 @@ TEST(cli_hook_augment_lifecycle_output_contract) { ASSERT(strstr(context, "cbm-secret-path") == NULL); if (strcmp(cases[i].event, "SessionStart") == 0) ASSERT(strstr(context, "Active tier: Tier 2") != NULL); + /* Subagents defined without MCP tools must learn the CLI fallback (#2837). */ + bool has_cli_hint = strstr(context, "`codebase-memory-mcp cli --help`") != NULL; + ASSERT(has_cli_hint == (strcmp(cases[i].event, "SubagentStart") == 0)); yyjson_doc_free(doc); free(output); } @@ -15281,6 +15983,29 @@ TEST(cli_detect_agents_finds_zed_in_roaming_appdata) { } #endif +#ifdef _WIN32 +/* #2145: a profile path outside the ANSI code page must stay visible to agent + * detection. The directory is created with th_mkdir_p, which goes through the + * wide-char API: test_mkdirp uses cbm_mkdir, the narrow _mkdir on Windows, + * which would create a code-page-mangled name instead of this one. Cursor is + * detected by its directory alone, so the assertion goes through dir_exists. */ +TEST(cli_detect_agents_handles_non_ascii_windows_profile) { + char home[1024]; + snprintf(home, sizeof(home), "%s/cbm-用户-profile", cbm_tmpdir()); + test_rmdir_r(home); + char cursor_dir[1024]; + snprintf(cursor_dir, sizeof(cursor_dir), "%s/.cursor", home); + if (th_mkdir_p(cursor_dir) != 0) + FAIL("could not create non-ASCII Windows profile path"); + + cbm_detected_agents_t agents = cbm_detect_agents(home); + test_rmdir_r(home); + if (!agents.cursor) + FAIL("agent detection must support non-ASCII Windows profile paths"); + PASS(); +} +#endif + TEST(cli_detect_agents_finds_antigravity) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-XXXXXX"); @@ -18637,6 +19362,65 @@ TEST(cli_external_manager_detection_needs_positive_evidence_issue1566) { PASS(); } +/* #2722: when a binary is managed externally (e.g. mise, brew, nix), `install` + * skips placing a binary at ~/.local/bin, but must write the running managed + * binary path into agent configuration files instead of the non-existent + * default target. */ +TEST(cli_install_managed_binary_config_path_issue2722) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-install-managed-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + char *old_home = NULL; + char *old_cache = NULL; + cli_activation_save_env(&old_home, &old_cache); + cbm_setenv("HOME", tmpdir, 1); + char cache_dir[512]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + + test_mark_claude_code_installed(tmpdir); + + const char *managed_path = + "/Users/x/.local/share/mise/installs/cbm/0.12.1/bin/codebase-memory-mcp"; + cbm_cli_set_self_path_override_for_test(managed_path); + + cli_activation_fake_t fake = { + .mutation_reserve_result = 1, + }; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + + char *argv[] = {"--clients=claude"}; + int rc = cli_test_cmd_install(1, argv); + + cbm_cli_set_activation_ops_for_test(NULL); + cbm_cli_set_self_path_override_for_test(NULL); + + char bin_target[512]; + snprintf(bin_target, sizeof(bin_target), "%s/.local/bin/codebase-memory-mcp", tmpdir); + struct stat target_st; + bool target_created = stat(bin_target, &target_st) == 0; + + char claude_json_path[512]; + snprintf(claude_json_path, sizeof(claude_json_path), "%s/.claude.json", tmpdir); + char *claude_json = read_test_file_alloc(claude_json_path); + bool has_managed_path = claude_json && strstr(claude_json, managed_path) != NULL; + bool has_target_path = + claude_json && strstr(claude_json, ".local/bin/codebase-memory-mcp") != NULL; + + free(claude_json); + cli_activation_restore_env(old_home, old_cache); + test_rmdir_r(tmpdir); + + ASSERT_EQ(rc, 0); + ASSERT_FALSE(target_created); + ASSERT_TRUE(has_managed_path); + ASSERT_FALSE(has_target_path); + PASS(); +} + /* #1558: `install` configured EVERY detected client, so a user who wanted * Claude and Codex had to revert OpenCode and Cursor by hand — and the next * install silently recreated them. The selector restricts it. @@ -18983,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")) { @@ -19054,6 +20167,7 @@ SUITE(cli) { RUN_TEST(cli_ui_config_keys_are_discoverable_and_settable_issue1558); RUN_TEST(cli_skill_frontmatter_scalars_with_colons_are_quoted_issue1554); RUN_TEST(cli_external_manager_detection_needs_positive_evidence_issue1566); + RUN_TEST(cli_install_managed_binary_config_path_issue2722); RUN_TEST(cli_clients_selector_vocabulary_is_complete_and_strict_issue1558); RUN_TEST(cli_clients_selector_filters_registry_installs_issue1798); RUN_TEST(cli_update_accepts_retired_variant_flags_issue1544); @@ -19154,6 +20268,10 @@ SUITE(cli) { RUN_TEST(cli_zed_mcp_preserves_settings); RUN_TEST(cli_zed_mcp_uninstall); RUN_TEST(cli_zed_mcp_jsonc_comments); +#ifndef _WIN32 + RUN_TEST(cli_install_agent_config_symlinks_issue2671); + RUN_TEST(cli_install_agent_config_file_symlink_issue2671); +#endif /* PATH management (3 tests) */ RUN_TEST(cli_ensure_path_append); @@ -19238,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); @@ -19255,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); @@ -19351,6 +20478,7 @@ SUITE(cli) { #endif #ifdef _WIN32 RUN_TEST(cli_detect_agents_finds_zed_in_roaming_appdata); + RUN_TEST(cli_detect_agents_handles_non_ascii_windows_profile); #endif RUN_TEST(cli_detect_agents_finds_antigravity); RUN_TEST(cli_detect_agents_finds_kilocode); @@ -19469,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_conditional_variants.c b/tests/test_conditional_variants.c index 5d5aa1716..a8ced098c 100644 --- a/tests/test_conditional_variants.c +++ b/tests/test_conditional_variants.c @@ -402,6 +402,45 @@ TEST(cv_elixir_compile_time_if) { PASS(); } +/* A multi-clause function under a compile-time `if`. Each branch's clauses + * fold into one definition spanning them (the clause fold), and the two + * branches stay two variants of one identity: exactly the spans 5-6 and 8-9, + * neither one span per clause (no fold) nor one span over both branches (a + * fold across the branch boundary). */ +TEST(cv_elixir_multi_clause_under_if) { + static const cv_file_t files[] = {{"pick.ex", "defmodule Pick do\n" /* 1 */ + " def alpha(_), do: :ok\n" /* 2 */ + " def beta(_), do: :ok\n" /* 3 */ + " if Code.ensure_loaded?(:win32) do\n" /* 4 */ + " def pick(0), do: alpha(0)\n" /* 5 */ + " def pick(n), do: alpha(n)\n" /* 6 */ + " else\n" /* 7 */ + " def pick(0), do: beta(0)\n" /* 8 */ + " def pick(n), do: beta(n)\n" /* 9 */ + " end\n" /* 10 */ + " def caller, do: pick(1)\n" /* 11 */ + "end\n"}}; /* 12 */ + cv_proj_t p; + ASSERT_TRUE(cv_index(&p, files, 1)); + cv_result_t r = cv_measure(&p, "pick", "alpha", "beta", "caller"); + const char *span_do = + "SELECT COUNT(*) FROM nodes n, json_each(n.properties,'$.variants') v " + "WHERE n.project=?1 AND n.name=?2 AND n.label IN " CV_DEF_LABELS + " AND json_extract(v.value,'$.start_line')=5 AND json_extract(v.value,'$.end_line')=6"; + const char *span_else = + "SELECT COUNT(*) FROM nodes n, json_each(n.properties,'$.variants') v " + "WHERE n.project=?1 AND n.name=?2 AND n.label IN " CV_DEF_LABELS + " AND json_extract(v.value,'$.start_line')=8 AND json_extract(v.value,'$.end_line')=9"; + int do_spans = cv_count(&p, span_do, "pick", NULL); + int else_spans = cv_count(&p, span_else, "pick", NULL); + cv_cleanup(&p); + ASSERT_TRUE(cv_expect("elixir_multi_clause", r)); + ASSERT_EQ(r.variant_spans, 2); + ASSERT_EQ(do_spans, 1); + ASSERT_EQ(else_spans, 1); + PASS(); +} + /* Verilog: the identity only. Module instantiation produces no CALLS edge in * this graph even without any `ifdef (a general Verilog gap, not a variant * one), so the body and caller edges are not this suite's to assert. */ @@ -798,6 +837,7 @@ SUITE(conditional_variants) { RUN_TEST(cv_pascal_ifdef); RUN_TEST(cv_julia_static_if); RUN_TEST(cv_elixir_compile_time_if); + RUN_TEST(cv_elixir_multi_clause_under_if); RUN_TEST(cv_verilog_ifdef); RUN_TEST(cv_c_platform_files); RUN_TEST(cv_c_platform_directories); 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_cross_repo.c b/tests/test_cross_repo.c index 9d318f8c0..0bf14c68b 100644 --- a/tests/test_cross_repo.c +++ b/tests/test_cross_repo.c @@ -944,10 +944,201 @@ TEST(cross_repo_caller_without_local_handler_keeps_cross_http_issue1459) { PASS(); } +static bool cross_repo_seed_server_route(const cross_repo_fixture_t *fixture, + const char *target_project, const char *method, + const char *route_path, const char *handler_name) { + char target_path[512]; + if (!cross_repo_project_path(fixture, target_project, target_path, sizeof(target_path))) { + return false; + } + cbm_store_t *target = cbm_store_open_path(target_path); + if (!target) { + return false; + } + bool ok = cbm_store_upsert_project(target, target_project, fixture->cache) == CBM_STORE_OK; + char target_route_qn[256]; + char handler_qn[256]; + char route_name[128]; + snprintf(target_route_qn, sizeof(target_route_qn), "__route__%s__%s", method, route_path); + snprintf(handler_qn, sizeof(handler_qn), "%s.%s", target_project, handler_name); + snprintf(route_name, sizeof(route_name), "%s %s", method, route_path); + + cbm_node_t target_route = {.project = target_project, + .label = "Route", + .name = route_name, + .qualified_name = target_route_qn, + .file_path = "server.java"}; + cbm_node_t handler = {.project = target_project, + .label = "Function", + .name = handler_name, + .qualified_name = handler_qn, + .file_path = "server.java"}; + int64_t target_route_id = ok ? cbm_store_upsert_node(target, &target_route) : 0; + int64_t handler_id = ok ? cbm_store_upsert_node(target, &handler) : 0; + cbm_edge_t handles = {.project = target_project, + .source_id = handler_id, + .target_id = target_route_id, + .type = "HANDLES"}; + ok = ok && target_route_id > 0 && handler_id > 0 && cbm_store_insert_edge(target, &handles) > 0; + cbm_store_close(target); + return ok; +} + +static bool cross_repo_seed_client_call(const cross_repo_fixture_t *fixture, + const char *source_project, const char *caller_name, + const char *callee_name, const char *url_path, + const char *method) { + char source_path[512]; + if (!cross_repo_project_path(fixture, source_project, source_path, sizeof(source_path))) { + return false; + } + cbm_store_t *source = cbm_store_open_path(source_path); + if (!source) { + return false; + } + bool ok = cbm_store_upsert_project(source, source_project, fixture->cache) == CBM_STORE_OK; + char caller_qn[256]; + char local_route_qn[256]; + char edge_props[512]; + snprintf(caller_qn, sizeof(caller_qn), "%s.%s", source_project, caller_name); + snprintf(local_route_qn, sizeof(local_route_qn), "__route__%s__%s", method ? method : "ANY", + url_path); + if (method) { + snprintf(edge_props, sizeof(edge_props), + "{\"callee\":\"%s\",\"url_path\":\"%s\",\"method\":\"%s\"}", callee_name, + url_path, method); + } else { + snprintf(edge_props, sizeof(edge_props), + "{\"callee\":\"%s\",\"url_path\":\"%s\"}", callee_name, url_path); + } + cbm_node_t caller = {.project = source_project, + .label = "Function", + .name = caller_name, + .qualified_name = caller_qn, + .file_path = "item.service.ts"}; + cbm_node_t local_route = {.project = source_project, + .label = "Route", + .name = url_path, + .qualified_name = local_route_qn, + .file_path = "item.service.ts"}; + int64_t caller_id = ok ? cbm_store_upsert_node(source, &caller) : 0; + int64_t local_route_id = ok ? cbm_store_upsert_node(source, &local_route) : 0; + cbm_edge_t http_call = {.project = source_project, + .source_id = caller_id, + .target_id = local_route_id, + .type = "HTTP_CALLS", + .properties_json = edge_props}; + ok = ok && caller_id > 0 && local_route_id > 0 && cbm_store_insert_edge(source, &http_call) > 0; + cbm_store_close(source); + return ok; +} + +static int cross_repo_count_edges_like(const cross_repo_fixture_t *fixture, const char *project, + const char *edge_type, const char *pattern) { + char path[512]; + if (!cross_repo_project_path(fixture, project, path, sizeof(path))) { + return -1; + } + cbm_store_t *store = cbm_store_open_path_query(path); + if (!store) { + return -1; + } + sqlite3_stmt *s = NULL; + int count = -1; + if (sqlite3_prepare_v2( + cbm_store_get_db(store), + "SELECT COUNT(*) FROM edges WHERE project = ?1 AND type = ?2 AND properties LIKE ?3", + -1, &s, NULL) == SQLITE_OK) { + sqlite3_bind_text(s, 1, project, -1, SQLITE_STATIC); + sqlite3_bind_text(s, 2, edge_type, -1, SQLITE_STATIC); + sqlite3_bind_text(s, 3, pattern, -1, SQLITE_STATIC); + if (sqlite3_step(s) == SQLITE_ROW) { + count = sqlite3_column_int(s, 0); + } + sqlite3_finalize(s); + } + cbm_store_close(store); + return count; +} + +/* #2768: Angular HttpClient calls must match cross-repo routes with exact HTTP verbs. + * When client makes GET requests to /items/list, /items/42, and /items/job/10, + * and the server defines overlapping routes: + * DELETE /items/{id} (seeded first in DB) + * GET /items/{id} (seeded second in DB) + * POST /items/{parentId}/{id} (seeded first in DB) + * GET /items/job/{jobId} (seeded second in DB) + * the fuzzy route matcher must enforce method compatibility, linking the GET calls + * to their respective GET endpoints and never to DELETE or POST endpoints. + * getItem and getJob are seeded WITHOUT a method property (an edge written by an + * index that did not record the verb): the matcher must infer GET from the + * callee's verb suffix, or the method-less fuzzy match lands on the DELETE / POST + * route seeded first. getItems keeps its explicit "GET". */ +TEST(cross_repo_angular_http_client_exact_verb_matching_issue2768) { + cross_repo_fixture_t fixture; + bool setup = cross_repo_fixture_begin(&fixture) && + /* Target server with overlapping endpoints */ + cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/list", + "handle_list_items") && + cross_repo_seed_server_route(&fixture, "api-2768", "DELETE", "/items/{}", + "handle_delete_item") && + cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/{}", + "handle_get_item") && + cross_repo_seed_server_route(&fixture, "api-2768", "POST", "/items/{}/{}", + "handle_post_nested_item") && + cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/job/{}", + "handle_get_job") && + /* Angular client making GET requests */ + cross_repo_seed_client_call(&fixture, "angular-2768", "getItems", + "this.http.get", "/items/list", "GET") && + cross_repo_seed_client_call(&fixture, "angular-2768", "getItem", + "this.http.get", "/items/42", NULL) && + cross_repo_seed_client_call(&fixture, "angular-2768", "getJob", + "this.http.get", "/items/job/10", NULL); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed issue 2768 fixture"); + } + + const char *target = "api-2768"; + cbm_cross_repo_result_t fwd = cbm_cross_repo_match("angular-2768", &target, 1); + ASSERT_FALSE(fwd.failed); + ASSERT_EQ(fwd.http_edges, 3); + + /* Assert that client CROSS_HTTP_CALLS edges are linked to GET handlers, not DELETE or POST */ + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_delete_item%"), + 0); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_post_nested_item%"), + 0); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_list_items%"), + 1); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_get_item%"), + 1); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_get_job%"), + 1); + + /* Reverse pass */ + const char *consumer = "angular-2768"; + cbm_cross_repo_result_t rev = cbm_cross_repo_match("api-2768", &consumer, 1); + ASSERT_FALSE(rev.failed); + ASSERT_EQ(rev.http_edges, 3); + + cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&fwd); + cbm_cross_repo_result_free(&rev); + PASS(); +} + SUITE(cross_repo) { RUN_TEST(cross_repo_caller_local_handler_blocks_cross_http_issue1459); RUN_TEST(cross_repo_methodless_call_to_local_get_route_blocks_cross_http_issue1459); RUN_TEST(cross_repo_caller_without_local_handler_keeps_cross_http_issue1459); + RUN_TEST(cross_repo_angular_http_client_exact_verb_matching_issue2768); RUN_TEST(cross_repo_accepts_project_with_missed_shadow_row_issue1609); RUN_TEST(cross_repo_null_target_fails_without_dereference); RUN_TEST(cross_repo_wildcard_keeps_projects_containing_internal_tokens); 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 c12c6f022..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); @@ -2822,6 +2826,122 @@ TEST(daemon_application_sensitive_root_blocks_watch_but_preserves_controls) { PASS(); } +/* Under `daemon start` a session that ENDS leaves its watch to the daemon, but + * a workspace-policy release is not a disconnect: once the session's root is + * no longer allowed (here its approval is revoked), the next request's + * refresh must still end the watch in a permanent generation too. */ +TEST(daemon_application_permanent_workspace_release_still_ends_watch) { + app_env_backup_t cache_environment; + app_env_backup_t home_environment; + bool environments_saved = app_env_backup_capture(&cache_environment, "CBM_CACHE_DIR") && + app_env_backup_capture(&home_environment, "HOME"); + char sensitive_raw[APP_TEST_PATH_CAP]; + char cache_raw[APP_TEST_PATH_CAP]; + (void)snprintf(sensitive_raw, sizeof(sensitive_raw), + "%s/cbm-app-permanent-release-home-XXXXXX", cbm_tmpdir()); + (void)snprintf(cache_raw, sizeof(cache_raw), "%s/cbm-app-permanent-release-cache-XXXXXX", + cbm_tmpdir()); + bool dirs_ok = cbm_mkdtemp(sensitive_raw) != NULL && cbm_mkdtemp(cache_raw) != NULL; + char sensitive[APP_TEST_PATH_CAP] = {0}; + char cache[APP_TEST_PATH_CAP] = {0}; + bool canonical = dirs_ok && cbm_canonical_path(sensitive_raw, sensitive, sizeof(sensitive)) && + cbm_canonical_path(cache_raw, cache, sizeof(cache)); + bool environments_set = environments_saved && canonical && + cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0 && + cbm_setenv("HOME", sensitive, 1) == 0; + cbm_config_t *stored_config = environments_set ? cbm_config_open(cache) : NULL; + bool config_ready = stored_config && + cbm_config_set(stored_config, CBM_CONFIG_AUTO_INDEX, "false") == 0 && + cbm_config_set(stored_config, CBM_CONFIG_AUTO_WATCH, "true") == 0; + char *project = canonical ? cbm_project_name_from_path(sensitive) : NULL; + char db_path[APP_TEST_PATH_CAP] = {0}; + if (project) { + (void)snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + } + bool db_ready = config_ready && project && app_test_create_empty_file(db_path); + /* The root is $HOME, so only an explicit sensitive approval allows it. */ + char grant_error[APP_TEST_PATH_CAP]; + bool approved = db_ready && cbm_workspace_grant_add(cache, sensitive, sensitive, true, false, + grant_error, sizeof(grant_error)); + char grant_path[APP_TEST_PATH_CAP] = {0}; + bool grant_path_ok = + approved && cbm_workspace_grant_path(cache, grant_path, sizeof(grant_path)); + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *watcher = cbm_watcher_new(store, app_test_index_noop, NULL); + cbm_daemon_application_config_t config = { + .watcher = watcher, + .config = stored_config, + }; + cbm_daemon_application_t *application = + grant_path_ok && watcher ? cbm_daemon_application_new(&config) : NULL; + if (application) { + cbm_daemon_application_set_permanent(application, true); + } + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = + application ? app_test_open(&callbacks, 4036) : NULL; + + bool initialized = app_test_initialize_profile(&callbacks, session, sensitive, + CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); + int watch_while_approved = watcher ? cbm_watcher_watch_count(watcher) : -1; + + /* Revoke the approval. Every request ends with a synchronous watch + * refresh, so one ping is the point where the policy is re-read. */ + bool revoked = initialized && cbm_unlink(grant_path) == 0; + uint8_t *ping = NULL; + uint32_t ping_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + bool pinged = revoked && + app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":4136,\"method\":\"ping\"}", + &ping, &ping_length) && + app_test_request(&callbacks, session, ping, ping_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + free(ping); + int watch_after_revoke = watcher ? cbm_watcher_watch_count(watcher) : -1; + + if (session) { + callbacks.session_cancel(callbacks.context, session); + callbacks.session_close(callbacks.context, session); + } + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + if (watcher) { + cbm_watcher_stop(watcher); + cbm_watcher_free(watcher); + } + cbm_store_close(store); + cbm_config_close(stored_config); + free(project); + (void)th_rmtree(sensitive); + (void)th_rmtree(cache); + bool cache_restored = app_env_backup_restore(&cache_environment); + bool home_restored = app_env_backup_restore(&home_environment); + + ASSERT_TRUE(environments_saved); + ASSERT_TRUE(dirs_ok); + ASSERT_TRUE(canonical); + ASSERT_TRUE(environments_set); + ASSERT_TRUE(config_ready); + ASSERT_TRUE(db_ready); + ASSERT_TRUE(approved); + ASSERT_TRUE(grant_path_ok); + ASSERT_TRUE(initialized); + ASSERT_EQ(watch_while_approved, 1); + ASSERT_TRUE(revoked); + ASSERT_TRUE(pinged); + /* A policy release, not a disconnect: the permanent watch ends as well. */ + ASSERT_EQ(watch_after_revoke, 0); + ASSERT_TRUE(stopped); + ASSERT_TRUE(cache_restored); + ASSERT_TRUE(home_restored); + PASS(); +} + TEST(daemon_application_programmatic_index_injects_resource_policy) { char *root = th_mktempdir("cbm_app_policy_root"); char *cache = th_mktempdir("cbm_app_policy_cache"); @@ -4171,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); @@ -4468,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); @@ -4588,6 +4710,252 @@ TEST(daemon_application_watcher_job_follows_exact_live_watch_owners) { PASS(); } + +TEST(daemon_application_permanent_watch_survives_disconnect_and_indexes_without_session) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init(&fixture, 47); + + if (fixture_ready) { + cbm_daemon_application_set_permanent(fixture.application, true); + } + + int watch_before_disconnect = + fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + + if (fixture.session) { + fixture.callbacks.session_cancel(fixture.callbacks.context, fixture.session); + } + + int watch_after_cancel = + fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + + if (fixture.session) { + fixture.callbacks.session_close(fixture.callbacks.context, fixture.session); + fixture.session = NULL; + } + + int watch_after_close = + fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + + /* + * No client session remains at this point. A permanent daemon-owned + * physical watch must nevertheless be allowed to launch its background + * indexing job. + */ + app_watcher_index_thread_t request = { + .application = fixture.application, + .project = fixture.project, + .root = fixture.root, + .pause_before_subscribe = false, + .result = -1, + }; + atomic_init(&request.ready, false); + atomic_init(&request.proceed, true); + atomic_init(&request.done, false); + + cbm_thread_t thread; + bool thread_started = + fixture_ready && + watch_after_close == 1 && + cbm_thread_create(&thread, 0, app_watcher_index_thread, &request) == 0; + + bool worker_started = + thread_started && app_wait_for_atomic_int(&fixture.fake.starts, 1); + + int watch_during_job = + fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + + /* + * The fake worker deliberately waits until the test releases it. + * Let the daemon-owned watcher job finish normally. + */ + if (worker_started) { + atomic_store(&fixture.fake.allow_completion, true); + } + + bool request_done = + thread_started && app_wait_for_atomic_bool(&request.done, true); + + bool thread_joined = + request_done && cbm_thread_join(&thread) == 0; + + int watch_after_job = + fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + + int destroys_before_cleanup = + fixture_ready ? atomic_load(&fixture.fake.destroys) : -1; + + bool cleaned = app_watch_race_fixture_finish(&fixture); + + ASSERT_TRUE(fixture_ready); + ASSERT_EQ(watch_before_disconnect, 1); + + /* Permanent mode: disconnect must not unwatch the project. */ + ASSERT_EQ(watch_after_cancel, 1); + ASSERT_EQ(watch_after_close, 1); + + /* No session exists, yet the watcher still owns background indexing. */ + ASSERT_TRUE(thread_started); + ASSERT_TRUE(worker_started); + ASSERT_EQ(watch_during_job, 1); + + ASSERT_TRUE(request_done); + ASSERT_TRUE(thread_joined); + ASSERT_EQ(request.result, 0); + ASSERT_EQ(destroys_before_cleanup, 1); + + /* Completing the background job must not consume the persistent watch. */ + ASSERT_EQ(watch_after_job, 1); + + ASSERT_TRUE(cleaned); + PASS(); +} + +/* A permanent daemon keeps a project watched after its last session closes. + * delete_project from another session or the CLI removes the project's DB but + * owns no watch, so the watch itself must notice: a later change must end the + * orphaned watch, never re-create the deleted index. */ +TEST(daemon_application_permanent_watch_ends_when_project_db_is_deleted) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init(&fixture, 48); + if (fixture_ready) { + cbm_daemon_application_set_permanent(fixture.application, true); + /* Should a job be admitted, it completes at once: the verdict is + * read from the start counter, never from a timeout. */ + atomic_store(&fixture.fake.allow_completion, true); + } + if (fixture.session) { + fixture.callbacks.session_cancel(fixture.callbacks.context, fixture.session); + fixture.callbacks.session_close(fixture.callbacks.context, fixture.session); + fixture.session = NULL; + } + int watch_after_close = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + bool db_removed = fixture_ready && cbm_unlink(fixture.db_path) == 0; + int result = db_removed ? cbm_daemon_application_watcher_index(fixture.project, fixture.root, + fixture.application) + : -2; + int starts = fixture_ready ? atomic_load(&fixture.fake.starts) : -1; + int watch_after_index = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + bool cleaned = app_watch_race_fixture_finish(&fixture); + + ASSERT_TRUE(fixture_ready); + ASSERT_EQ(watch_after_close, 1); + ASSERT_TRUE(db_removed); + /* A harmless skip: nothing admitted, the deleted index stays deleted. */ + ASSERT_EQ(result, 1); + ASSERT_EQ(starts, 0); + /* The orphaned physical watch is torn down. */ + ASSERT_EQ(watch_after_index, 0); + ASSERT_TRUE(cleaned); + PASS(); +} + +/* The second watcher callback either joins the running job (its retained + * ownership is the job's second subscriber) or returns. Both states latch, so + * whichever happens decides the wait; the deadline only catches "neither". */ +static bool app_wait_for_watcher_join_or_return(cbm_daemon_application_t *application, + const char *project, atomic_bool *returned) { + uint64_t deadline = cbm_now_ms() + APP_TEST_TIMEOUT_MS; + while (cbm_now_ms() < deadline) { + if (cbm_daemon_application_job_subscribers(application, project) == 2 || + atomic_load(returned)) { + return true; + } + cbm_usleep(1000); + } + return false; +} + +/* A full reindex unlinks the project's DB while it rebuilds it, so a missing + * DB alone does not mean the project was deleted. While the permanent watch's + * own job runs, a second change in its root must join that job and keep the + * watch; once the job ends, both callbacks hand their ownership back. */ +TEST(daemon_application_permanent_watch_kept_while_its_reindex_runs) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init(&fixture, 49); + if (fixture_ready) { + cbm_daemon_application_set_permanent(fixture.application, true); + } + if (fixture.session) { + fixture.callbacks.session_cancel(fixture.callbacks.context, fixture.session); + fixture.callbacks.session_close(fixture.callbacks.context, fixture.session); + fixture.session = NULL; + } + int watch_after_close = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + + app_watcher_index_thread_t first = { + .application = fixture.application, + .project = fixture.project, + .root = fixture.root, + .pause_before_subscribe = false, + .result = -1, + }; + atomic_init(&first.ready, false); + atomic_init(&first.proceed, true); + atomic_init(&first.done, false); + app_watcher_index_thread_t second = { + .application = fixture.application, + .project = fixture.project, + .root = fixture.root, + .pause_before_subscribe = false, + .result = -1, + }; + atomic_init(&second.ready, false); + atomic_init(&second.proceed, true); + atomic_init(&second.done, false); + + /* The fake worker keeps the first job running until allow_completion. */ + cbm_thread_t first_thread; + bool first_started = fixture_ready && watch_after_close == 1 && + cbm_thread_create(&first_thread, 0, app_watcher_index_thread, &first) == 0; + bool job_running = first_started && app_wait_for_atomic_int(&fixture.fake.starts, 1); + bool db_removed = job_running && cbm_unlink(fixture.db_path) == 0; + cbm_thread_t second_thread; + bool second_started = + db_removed && cbm_thread_create(&second_thread, 0, app_watcher_index_thread, &second) == 0; + bool settled = second_started && app_wait_for_watcher_join_or_return( + fixture.application, fixture.project, &second.done); + size_t subscribers_while_running = + fixture_ready ? cbm_daemon_application_job_subscribers(fixture.application, fixture.project) + : 0; + bool second_returned_while_running = atomic_load(&second.done); + int watch_while_running = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + + if (fixture_ready) { + atomic_store(&fixture.fake.allow_completion, true); + } + bool first_joined = first_started && app_wait_for_atomic_bool(&first.done, true) && + cbm_thread_join(&first_thread) == 0; + bool second_joined = second_started && app_wait_for_atomic_bool(&second.done, true) && + cbm_thread_join(&second_thread) == 0; + int starts = fixture_ready ? atomic_load(&fixture.fake.starts) : -1; + size_t subscribers_after = + fixture_ready ? cbm_daemon_application_job_subscribers(fixture.application, fixture.project) + : SIZE_MAX; + int watch_after = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1; + bool cleaned = app_watch_race_fixture_finish(&fixture); + + ASSERT_TRUE(fixture_ready); + ASSERT_EQ(watch_after_close, 1); + ASSERT_TRUE(job_running); + ASSERT_TRUE(db_removed); + ASSERT_TRUE(settled); + /* The second change joined the running job instead of ending the watch. */ + ASSERT_EQ(subscribers_while_running, 2); + ASSERT_FALSE(second_returned_while_running); + ASSERT_EQ(watch_while_running, 1); + ASSERT_TRUE(first_joined); + ASSERT_TRUE(second_joined); + ASSERT_EQ(first.result, 0); + ASSERT_EQ(second.result, 0); + ASSERT_EQ(starts, 1); + /* Each callback released the ownership it retained at admission. */ + ASSERT_EQ(subscribers_after, 0); + ASSERT_EQ(watch_after, 1); + ASSERT_TRUE(cleaned); + PASS(); +} + TEST(daemon_application_late_watcher_session_owns_active_watcher_job) { app_watch_race_fixture_t fixture; bool fixture_ready = app_watch_race_fixture_init(&fixture, 45); @@ -6930,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){ @@ -6942,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; @@ -6963,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); @@ -7340,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); @@ -7660,6 +8107,7 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_initialize_coalesces_auto_index_for_full_sessions); RUN_TEST(daemon_application_sensitive_root_blocks_auto_index_but_preserves_controls); RUN_TEST(daemon_application_sensitive_root_blocks_watch_but_preserves_controls); + RUN_TEST(daemon_application_permanent_workspace_release_still_ends_watch); RUN_TEST(daemon_application_ui_index_accepts_non_ascii_directory); RUN_TEST(daemon_application_index_args_compare_repo_path_separator_equivalently); RUN_TEST(daemon_application_async_index_returns_before_completion_and_status_polls); @@ -7668,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); @@ -7693,6 +8142,9 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_stale_watcher_callback_is_rejected_at_job_admission); RUN_TEST(daemon_application_final_cancel_drains_admitted_watcher_job); RUN_TEST(daemon_application_watcher_job_follows_exact_live_watch_owners); + RUN_TEST(daemon_application_permanent_watch_survives_disconnect_and_indexes_without_session); + RUN_TEST(daemon_application_permanent_watch_ends_when_project_db_is_deleted); + RUN_TEST(daemon_application_permanent_watch_kept_while_its_reindex_runs); RUN_TEST(daemon_application_late_watcher_session_owns_active_watcher_job); RUN_TEST(daemon_application_watch_roots_match_windows_separators_only); RUN_TEST(daemon_application_async_completion_disables_all_watches); diff --git a/tests/test_diagrams.c b/tests/test_diagrams.c index 574f27148..05d1f8323 100644 --- a/tests/test_diagrams.c +++ b/tests/test_diagrams.c @@ -329,6 +329,42 @@ TEST(diagrams_plantuml) { PASS(); } +/* #2818: a sequence message to or from the diagram edge (`A ->: x`, `A <-: x`, + * `[-> A: x`) and a declaration with an unclosed bracket have no operand id; + * they used to read an uninitialized one and crash the index worker. The + * declaration sits in its own diagram: a `class` line makes a diagram a class + * diagram, where `->` is an arrow rather than a sequence message. */ +TEST(diagrams_plantuml_edge_messages) { + CBMFileResult *r = dg_file("@startuml\n" + "participant Alice\n" + "Alice ->: out\n" + "Alice <-: in\n" + "[-> Alice: from the edge\n" + "Alice -> Bob : hello\n" + "@enduml\n" + "@startuml\n" + "class [Unclosed\n" + "@enduml\n", + "docs/edge.puml"); + ASSERT_NOT_NULL(r); + ASSERT_STR_EQ(dg_prop(dg_def(r, "edge#diagram-1"), "diagram_kind"), "sequence"); + ASSERT_STR_EQ(dg_prop(dg_def(r, "edge#diagram-2"), "diagram_kind"), "class"); + ASSERT_NOT_NULL(dg_def(r, "edge#diagram-1.Alice")); + ASSERT_NOT_NULL(dg_def(r, "edge#diagram-1.Bob")); + ASSERT_EQ(r->diagram_arrows.count, 1); + ASSERT_EQ(dg_arrows(r, "#diagram-1.Alice", "#diagram-1.Bob", "message", "hello"), 1); + int elements = 0; + for (int i = 0; i < r->defs.count; i++) { + const char *label = r->defs.items[i].label; + if (label && strcmp(label, "DiagramElement") == 0) { + elements++; + } + } + ASSERT_EQ(elements, 2); + cbm_free_result(r); + PASS(); +} + TEST(diagrams_dot) { CBMFileResult *r = dg_file("digraph G {\n" @@ -1101,6 +1137,7 @@ SUITE(diagrams) { RUN_TEST(diagrams_mermaid_kinds); RUN_TEST(diagrams_other_fences); RUN_TEST(diagrams_plantuml); + RUN_TEST(diagrams_plantuml_edge_messages); RUN_TEST(diagrams_dot); RUN_TEST(diagrams_d2); RUN_TEST(diagrams_drawio); diff --git a/tests/test_doc_links_md.c b/tests/test_doc_links_md.c index e93e3669c..b4014f0e5 100644 --- a/tests/test_doc_links_md.c +++ b/tests/test_doc_links_md.c @@ -179,6 +179,14 @@ TEST(doc_links_md_repeated_headings) { "See `app/three.py`.\n"; /* 13 */ CBMFileResult *r = dm_extract(src, CBM_LANG_MARKDOWN, "CHANGES.md"); ASSERT_NOT_NULL(r); + int fixes = 0; + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].name, "Fixes") == 0) { + ASSERT_STR_EQ(r->defs.items[i].qualified_name, "p.CHANGES.Fixes"); + fixes++; + } + } + ASSERT_EQ(fixes, 2); const CBMDocLink *one = md_token(r, "app/one.py"); const CBMDocLink *two = md_token(r, "app/two.py"); const CBMDocLink *three = md_token(r, "app/three.py"); @@ -193,6 +201,427 @@ TEST(doc_links_md_repeated_headings) { PASS(); } +/* Unchanged bare names have priority even when the bare title repeats. */ +TEST(doc_links_md_repeated_bare_name_priority) { + const char *sources[] = { + "# A.H\n# A.H\n# A\n## H\n# B\n## H\n", + "# A\n## H\n# B\n## H\n# A.H\n# A.H\n", + }; + for (size_t k = 0; k < sizeof(sources) / sizeof(sources[0]); k++) { + CBMFileResult *r = dm_extract(sources[k], CBM_LANG_MARKDOWN, "names.md"); + ASSERT_NOT_NULL(r); + int bare = 0; + int qualified = 0; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (strcmp(d->name, "A.H") == 0) { + ASSERT_STR_EQ(d->qualified_name, "p.names.A.H"); + bare++; + } else if (strcmp(d->name, "H") == 0 && + strcmp(d->qualified_name, "p.names.A.H~2") == 0) { + qualified++; + } + } + ASSERT_EQ(bare, 2); + ASSERT_EQ(qualified, 1); + cbm_free_result(r); + } + PASS(); +} + +TEST(doc_links_md_heading_suffix_reservations) { + CBMFileResult *r = dm_extract("# A.H\n# A.H~2\n# A.H~3\n" + "# A\n## H\n# B\n## H\n", + CBM_LANG_MARKDOWN, "names.md"); + ASSERT_NOT_NULL(r); + int moved = 0; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (strcmp(d->name, "A.H~2") == 0) + ASSERT_STR_EQ(d->qualified_name, "p.names.A.H~2"); + if (strcmp(d->name, "A.H~3") == 0) + ASSERT_STR_EQ(d->qualified_name, "p.names.A.H~3"); + if (strcmp(d->qualified_name, "p.names.A.H~4") == 0) + moved++; + } + ASSERT_EQ(moved, 1); + cbm_free_result(r); + PASS(); +} + +/* A suffix must also leave room for a parent-qualified name in another run. */ +TEST(doc_links_md_generated_name_reservations) { + const char *src = "# A.H\n# A\n## H\n## H~2\n# B\n## H\n## H~2\n"; + CBMFileResult *r = dm_extract(src, CBM_LANG_MARKDOWN, "names.md"); + ASSERT_NOT_NULL(r); + int moved = 0; + int reserved = 0; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (strcmp(d->qualified_name, "p.names.A.H~3") == 0) { + ASSERT_STR_EQ(d->name, "H"); + moved++; + } + if (strcmp(d->qualified_name, "p.names.A.H~2") == 0) { + ASSERT_STR_EQ(d->name, "H~2"); + reserved++; + } + } + ASSERT_EQ(moved, 1); + ASSERT_EQ(reserved, 1); + cbm_free_result(r); + PASS(); +} + +TEST(doc_links_md_distinct_chains_same_dotted_name) { + CBMFileResult *r = + dm_extract("# A.B\n## C\n# A\n## B\n### C\n### C\n", CBM_LANG_MARKDOWN, "names.md"); + ASSERT_NOT_NULL(r); + int kept = 0; + int moved = 0; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (strcmp(d->name, "C") != 0) + continue; + if (d->start_line == 2) { + ASSERT_STR_EQ(d->qualified_name, "p.names.A.B.C"); + kept++; + } else { + ASSERT_STR_EQ(d->qualified_name, "p.names.A.B.C~2"); + moved++; + } + } + ASSERT_EQ(kept, 1); + ASSERT_EQ(moved, 2); + cbm_free_result(r); + PASS(); +} + +TEST(doc_links_md_setext_parent_names) { + CBMFileResult *r = + dm_extract("A\n===\nH\n---\nB\n===\nH\n---\n", CBM_LANG_MARKDOWN, "names.md"); + ASSERT_NOT_NULL(r); + int children = 0; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (strcmp(d->name, "H") == 0) { + ASSERT_STR_EQ(d->qualified_name, d->start_line == 3 ? "p.names.A.H" : "p.names.B.H"); + children++; + } + } + ASSERT_EQ(children, 2); + cbm_free_result(r); + PASS(); +} + +/* Paragraph continuation markers are not part of a Setext heading title. */ +TEST(doc_links_md_quoted_setext_heading_text) { + const char *sources[] = {"First\nSecond\n---\n", "> First\n> Second\n> ---\n", "> H\n> ---\n"}; + for (size_t k = 0; k < sizeof(sources) / sizeof(sources[0]); k++) { + CBMFileResult *r = dm_extract(sources[k], CBM_LANG_MARKDOWN, "names.md"); + ASSERT_NOT_NULL(r); + int headings = 0; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (strcmp(d->label, "Section") != 0) + continue; + /* Display names use the first line; the QN must retain every line. */ + ASSERT_STR_EQ(d->name, k == 2 ? "H" : "First"); + ASSERT_STR_EQ(d->qualified_name, k == 2 ? "p.names.H" : "p.names.First-Second"); + headings++; + } + ASSERT_EQ(headings, 1); + cbm_free_result(r); + } + PASS(); +} + +/* Setext and ATX markers share hierarchy; block containers retain their own + * stacks even when a quote is visited between two outer headings. */ +TEST(doc_links_md_mixed_heading_container_scope) { + const char *src = "# Outside\n\nH\n---\n\n" + "> # Quote\n>\n> H\n> ---\n\n" + "H\n---\n\n# Later\n\nH\n---\n"; + CBMFileResult *r = dm_extract(src, CBM_LANG_MARKDOWN, "names.md"); + ASSERT_NOT_NULL(r); + int outside = 0; + int quote = 0; + int later = 0; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (strcmp(d->name, "H") != 0) + continue; + if (strcmp(d->qualified_name, "p.names.Outside.H") == 0) + outside++; + if (strcmp(d->qualified_name, "p.names.Quote.H") == 0) + quote++; + if (strcmp(d->qualified_name, "p.names.Later.H") == 0) + later++; + } + ASSERT_EQ(outside, 2); + ASSERT_EQ(quote, 1); + ASSERT_EQ(later, 1); + cbm_free_result(r); + PASS(); +} + +/* An unnamed H1 still ends A's scope; it must not create an empty Section. */ +TEST(doc_links_md_empty_heading_resets_parent) { + CBMFileResult *r = dm_extract("# A\n## H\n#\n## H\n", CBM_LANG_MARKDOWN, "names.md"); + ASSERT_NOT_NULL(r); + int children = 0; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (strcmp(d->label, "Section") == 0) { + ASSERT_TRUE(d->name && d->name[0]); + } + if (strcmp(d->name, "H") == 0) { + ASSERT_STR_EQ(d->qualified_name, d->start_line == 2 ? "p.names.A.H" : "p.names.H"); + children++; + } + } + ASSERT_EQ(children, 2); + cbm_free_result(r); + PASS(); +} + +/* This measures the collector's actual cursor moves/node visits, excluding + * hidden parser work. The former ts_node_parent root scans are established by + * runtime-source review, not retrospectively measured by this counter. */ +static uint64_t md_flat_collection_work(int n) { + size_t cap = (size_t)n * 32 + 1; + char *src = malloc(cap); + if (!src) + return 0; + size_t used = 0; + for (int i = 0; i < n; i++) { + used += (size_t)snprintf(src + used, cap - used, "# H%d\n", i); + } + CBMFileResult *r = dm_extract(src, CBM_LANG_MARKDOWN, "names.md"); + free(src); + uint64_t steps = r ? r->markdown_collection_steps : 0; + cbm_free_result(r); + return steps; +} + +TEST(doc_links_md_flat_collection_work) { + uint64_t small = md_flat_collection_work(128); + uint64_t large = md_flat_collection_work(256); + ASSERT_TRUE(small > 0); + ASSERT_TRUE(large < small * 3); + PASS(); +} + +/* Count name lookup/group checks, never elapsed time. Independent collision groups must + * not search every heading; a single group must not rescan assigned chains + * or restart its suffix search at ~2 for each new chain. */ +static uint64_t md_collision_work(int n, bool one_base) { + size_t cap = (size_t)n * 256 + 1; + char *src = malloc(cap); + if (!src) + return 0; + size_t used = 0; + for (int k = 0; k < n; k++) { + if (!one_base) { + used += (size_t)snprintf(src + used, cap - used, + "# P%d\n## H%d\n# Q%d\n## H%d\n# P%d.H%d\n", k, k, k, k, k, k); + } else { + /* 276 distinct partitions of 25 dotted components into three + * parent segments, all with the same final dotted name. */ + int ordinal = 0; + int first = 1; + int second = 2; + for (int i = 1; i < 24; i++) { + for (int j = i + 1; j < 25; j++) { + if (ordinal++ == k) { + first = i; + second = j; + } + } + } + used += (size_t)snprintf(src + used, cap - used, "# a"); + for (int i = 1; i < 25; i++) { + used += (size_t)snprintf(src + used, cap - used, + i == first ? "\n## a" + : i == second ? "\n### a" + : ".a"); + } + used += (size_t)snprintf(src + used, cap - used, "\n#### H\n"); + } + } + CBMFileResult *r = dm_extract(src, CBM_LANG_MARKDOWN, "names.md"); + free(src); + uint64_t work = r ? r->markdown_name_checks : 0; + cbm_free_result(r); + return work; +} + +TEST(doc_links_md_independent_collision_work) { + uint64_t small = md_collision_work(128, false); + uint64_t large = md_collision_work(256, false); + ASSERT_TRUE(small > 0); + ASSERT_TRUE(large < small * 3); + PASS(); +} + +TEST(doc_links_md_shared_collision_work) { + uint64_t small = md_collision_work(128, true); + uint64_t large = md_collision_work(256, true); + ASSERT_TRUE(small > 0); + ASSERT_TRUE(large < small * 3); + PASS(); +} + +/* MENTIONS edges from the Section whose qualified name ends with `src` to the + * node whose qualified name ends with `tgt`. */ +static int rep_edge(const char *db, const char *src, const char *tgt) { + char props[512]; + int n = 0; + dm_edge(db, src, tgt, props, sizeof(props), &n); + return n; +} + +/* #2586: two `## Implementation` headings under different parents had one + * qualified name, so the graph kept one Section (the last) and the references + * under both went to the File. A repeated heading is now qualified by the + * headings above it; a unique heading keeps its name alone. */ +TEST(doc_links_md_repeated_headings_by_parent_issue2586) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_rep_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + th_write_file(TH_PATH(tmp, "docs/repeated.md"), "# Service A\n" + "\n" + "## Implementation\n" + "\n" + "Uses `src/ledger.py::balance`.\n" + "\n" + "# Service B\n" + "\n" + "## Implementation\n" + "\n" + "Uses `src/ledger.py::post_entry`.\n" + "\n" + "```mermaid\n" + "graph LR\n" + " Ledger --> Balance\n" + "```\n" + "\n" + "## Notes\n" + "\n" + "See `src/ledger.py`.\n"); + th_write_file(TH_PATH(tmp, "src/ledger.py"), "def post_entry(ledger, amount):\n" + " ledger.append(amount)\n" + " return balance(ledger)\n" + "\n" + "\n" + "def balance(ledger):\n" + " return sum(ledger)\n"); + char db[512]; + snprintf(db, sizeof(db), "%s/md.db", tmp); + ASSERT_EQ(dm_index(tmp, db, NULL), 0); + + /* two Sections, each qualified by its parent heading */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND name = 'Implementation' AND file_path = 'docs/repeated.md'"), + 2); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-A.Implementation' " + "AND start_line = 3"), + 1); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-B.Implementation' " + "AND start_line = 9"), + 1); + /* unique headings keep their names */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' AND " + "(qualified_name LIKE '%.repeated.Service-A' OR " + "qualified_name LIKE '%.repeated.Notes')"), + 2); + + /* each reference from its own Section, none from the File */ + ASSERT_EQ(rep_edge(db, "Service-A.Implementation", "ledger.balance"), 1); + ASSERT_EQ(rep_edge(db, "Service-B.Implementation", "ledger.post_entry"), 1); + ASSERT_EQ(rep_edge(db, "Service-A.Implementation", "ledger.post_entry"), 0); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id " + "WHERE e.type = 'MENTIONS' AND s.label = 'File' " + "AND s.file_path = 'docs/repeated.md'"), + 0); + + /* the diagram under the second heading is that Section's */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id " + "JOIN nodes t ON t.id = e.target_id WHERE e.type = 'DEFINES' " + "AND t.label = 'Diagram' " + "AND s.qualified_name LIKE '%.Service-B.Implementation'"), + 1); + + th_cleanup(tmp); + PASS(); +} + +/* #2586 review: a heading text may hold dots, so `# Service-A.Implementation` + * named the same as the qualified `## Implementation` under `# Service A`. + * The unique heading keeps its name; the qualified one takes `~2`. Five + * headings, five Sections, every reference from its own. */ +TEST(doc_links_md_repeated_heading_name_collision_issue2586) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_col_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + th_write_file(TH_PATH(tmp, "docs/repeated.md"), "# Service A\n" + "\n" + "## Implementation\n" + "\n" + "Uses `src/ledger.py::balance`.\n" + "\n" + "# Service B\n" + "\n" + "## Implementation\n" + "\n" + "Uses `src/ledger.py::post_entry`.\n" + "\n" + "# Service-A.Implementation\n" + "\n" + "See `src/ledger.py`.\n"); + th_write_file(TH_PATH(tmp, "src/ledger.py"), "def post_entry(ledger, amount):\n" + " ledger.append(amount)\n" + " return balance(ledger)\n" + "\n" + "\n" + "def balance(ledger):\n" + " return sum(ledger)\n"); + char db[512]; + snprintf(db, sizeof(db), "%s/md.db", tmp); + ASSERT_EQ(dm_index(tmp, db, NULL), 0); + + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND file_path = 'docs/repeated.md'"), + 5); + /* the unique heading keeps its name; the qualified one moves aside */ + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-A.Implementation' " + "AND start_line = 13"), + 1); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-A.Implementation~2' " + "AND start_line = 3"), + 1); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' " + "AND qualified_name LIKE '%.repeated.Service-B.Implementation' " + "AND start_line = 9"), + 1); + + /* each reference from its own Section, none from the File */ + ASSERT_EQ(rep_edge(db, "Service-A.Implementation~2", "ledger.balance"), 1); + ASSERT_EQ(rep_edge(db, "Service-B.Implementation", "ledger.post_entry"), 1); + ASSERT_EQ(rep_edge(db, "repeated.Service-A.Implementation", "ledger.py.__file__"), 1); + ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id " + "WHERE e.type = 'MENTIONS' AND s.label = 'File' " + "AND s.file_path = 'docs/repeated.md'"), + 0); + + th_cleanup(tmp); + PASS(); +} + typedef struct { const char *text; CBMDocLinkMdShape shape; @@ -1142,6 +1571,19 @@ SUITE(doc_links_md) { dm_ship_held_families(); RUN_TEST(doc_links_md_extract_tokens); RUN_TEST(doc_links_md_repeated_headings); + RUN_TEST(doc_links_md_repeated_bare_name_priority); + RUN_TEST(doc_links_md_heading_suffix_reservations); + RUN_TEST(doc_links_md_generated_name_reservations); + RUN_TEST(doc_links_md_distinct_chains_same_dotted_name); + RUN_TEST(doc_links_md_setext_parent_names); + RUN_TEST(doc_links_md_quoted_setext_heading_text); + RUN_TEST(doc_links_md_mixed_heading_container_scope); + RUN_TEST(doc_links_md_empty_heading_resets_parent); + RUN_TEST(doc_links_md_flat_collection_work); + RUN_TEST(doc_links_md_independent_collision_work); + RUN_TEST(doc_links_md_shared_collision_work); + RUN_TEST(doc_links_md_repeated_headings_by_parent_issue2586); + RUN_TEST(doc_links_md_repeated_heading_name_collision_issue2586); RUN_TEST(doc_links_md_classify_span); RUN_TEST(doc_links_md_resolve); RUN_TEST(doc_links_md_member_not_defined_issue2587); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 9b6612517..29fd1cc3c 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -100,6 +100,47 @@ static int count_defs_named(CBMFileResult *r, const char *label, const char *nam return count; } +/* Count calls to `callee` whose enclosing scope is exactly `scope_qn`. A call the + * scope walk failed to place carries the FILE or module QN here, so a bare count + * of the callee passes just as happily with every call homed on the file. */ +static int count_calls_from(CBMFileResult *r, const char *callee, const char *scope_qn) { + int count = 0; + for (int i = 0; i < r->calls.count; i++) { + if (r->calls.items[i].callee_name && r->calls.items[i].enclosing_func_qn && + strcmp(r->calls.items[i].callee_name, callee) == 0 && + strcmp(r->calls.items[i].enclosing_func_qn, scope_qn) == 0) { + count++; + } + } + return count; +} + +/* Count usages of `ref` attributed to exactly `scope_qn`, and to any scope. A + * def head is a BINDING, not a usage, so the pair separates two different + * mistakes: too few rows means a read was swallowed by the binding, too many + * means a binding site was counted as a read. */ +static int count_usages_from(CBMFileResult *r, const char *ref, const char *scope_qn) { + int count = 0; + for (int i = 0; i < r->usages.count; i++) { + if (r->usages.items[i].ref_name && r->usages.items[i].enclosing_func_qn && + strcmp(r->usages.items[i].ref_name, ref) == 0 && + strcmp(r->usages.items[i].enclosing_func_qn, scope_qn) == 0) { + count++; + } + } + return count; +} + +static int count_usages_named(CBMFileResult *r, const char *ref) { + int count = 0; + for (int i = 0; i < r->usages.count; i++) { + if (r->usages.items[i].ref_name && strcmp(r->usages.items[i].ref_name, ref) == 0) { + count++; + } + } + return count; +} + static int count_calls_named(CBMFileResult *r, const char *callee) { int count = 0; for (int i = 0; i < r->calls.count; i++) { @@ -251,6 +292,29 @@ TEST(extract_c_macros_issue375) { PASS(); } +TEST(extract_cpp_inc_linkage_and_callback) { + CBMFileResult *r = + extract("#define SAMPLE_CALLBACK(type) type\n" + "extern \"C\" __declspec(dllexport) int sample_export() { return 0; }\n" + "static SAMPLE_CALLBACK(int) sample_callback() { return sample_export(); }\n", + CBM_LANG_CPP, "sample", "runtime/sample.inc"); + ASSERT_NOT_NULL(r); + ASSERT_TRUE(has_def(r, "Function", "sample_export")); + ASSERT_TRUE(has_def(r, "Function", "sample_callback")); + ASSERT_FALSE(has_def_any(r, "extern")); + cbm_free_result(r); + + /* A statement include has no enclosing function in the file. Keep any + * syntax coverage diagnostic rather than inventing a callable owner. */ + r = extract("#ifdef ENABLE_SAMPLE\nif (sample_check()) return 1;\n#endif\n", CBM_LANG_CPP, + "sample", "runtime/guard.inc"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(has_def_any(r, "extern")); + ASSERT_FALSE(has_def(r, "Function", "if")); + cbm_free_result(r); + PASS(); +} + TEST(extract_cpp_macros_issue375) { CBMFileResult *r = extract("#define MAX(a, b) ((a) > (b) ? (a) : (b))\n" "#define PI 3.14159\n" @@ -1873,6 +1937,730 @@ TEST(elixir_function) { PASS(); } +/* ── Elixir def-head guard helpers (internal/cbm/helpers.c) ───────── + * + * `def name(args) when guard` parses its WHOLE head as one `when` + * binary_operator whose left operand is the `name(args)` call, and Elixir + * admits more than one guard on a clause, which tree-sitter-elixir nests + * right-associatively. Four walks read that same first argument and each asks a + * different question of it: the defs and call-scope walks want the head UNDER + * every guard, the usages walk wants it so that a parameter READ by the guard + * stays outside the binding, and the calls walk wants to know whether the node + * it is visiting IS that head or one of the guard operators above it. + * + * That last question is why cbm_elixir_def_head_is exists beside the unwrap, + * and it is exercised here on the AST rather than through cbm_extract_file + * because the two routes it has to answer for are indistinguishable from + * outside: `def f(x) when g` reaches the calls walk as the inner `f(x)` call, + * which the unwrap reaches, while `def f when g` has no inner call at all and + * reaches it as the `when` operator itself, which the unwrap does not. A test + * that only drives one walk sees whichever of the two that walk happens to hit. + * + * This file's helpers change no extraction behaviour on their own; the walks + * that consume them are separate changes. */ + +enum { ELIXIR_HEAD_PROBE_MAX = 16, ELIXIR_HEAD_STACK_MAX = 256 }; + +static bool head_probe_text_is(const char *source, TSNode node, const char *want) { + if (ts_node_is_null(node)) { + return false; + } + uint32_t start = ts_node_start_byte(node); + uint32_t end = ts_node_end_byte(node); + size_t len = strlen(want); + return end >= start && (size_t)(end - start) == len && memcmp(source + start, want, len) == 0; +} + +/* The first argument of every `def`/`defp` call under `root`, in source order. + * That argument is what all four walks read, so it is what the helpers take. */ +typedef struct { + TSNode signature[ELIXIR_HEAD_PROBE_MAX]; + int count; +} elixir_head_probe_t; + +static void collect_elixir_def_signatures(TSNode root, const char *source, + elixir_head_probe_t *out) { + TSNode stack[ELIXIR_HEAD_STACK_MAX]; + int top = 0; + stack[top++] = root; + while (top > 0) { + TSNode cur = stack[--top]; + uint32_t cc = ts_node_child_count(cur); + if (cc > 1 && strcmp(ts_node_type(cur), "call") == 0 && + (head_probe_text_is(source, ts_node_child(cur, 0), "def") || + head_probe_text_is(source, ts_node_child(cur, 0), "defp")) && + out->count < ELIXIR_HEAD_PROBE_MAX) { + TSNode args = ts_node_child(cur, 1); + if (!ts_node_is_null(args) && ts_node_child_count(args) > 0) { + out->signature[out->count++] = ts_node_child(args, 0); + } + } + for (int i = (int)cc - 1; i >= 0 && top < ELIXIR_HEAD_STACK_MAX; i--) { + stack[top++] = ts_node_child(cur, (uint32_t)i); + } + } +} + +TEST(elixir_def_head_is_covers_the_head_and_every_guard_above_it) { + const char *source = "defmodule Heads do\n" + " def plain(x), do: x\n" + " def guarded(x) when is_binary(x), do: x\n" + " def bare when true, do: :ok\n" + " def twice(x) when is_atom(x) when is_binary(x), do: x\n" + " def a + b, do: {a, b}\n" + "end\n"; + const TSLanguage *language = cbm_ts_language(CBM_LANG_ELIXIR); + ASSERT_NOT_NULL(language); + TSParser *parser = ts_parser_new(); + ASSERT_NOT_NULL(parser); + ASSERT_TRUE(ts_parser_set_language(parser, language)); + TSTree *tree = ts_parser_parse_string(parser, NULL, source, (uint32_t)strlen(source)); + ASSERT_NOT_NULL(tree); + + elixir_head_probe_t probe; + probe.count = 0; + collect_elixir_def_signatures(ts_tree_root_node(tree), source, &probe); + ASSERT_EQ(5, probe.count); + + /* Unguarded: nothing to peel, and the head is the whole first argument. + * The name node inside it is NOT the head -- the calls walk compares the + * node it is visiting, and the `plain` identifier is one of those. */ + TSNode plain = probe.signature[0]; + ASSERT_FALSE(cbm_elixir_is_when_guard(plain)); + ASSERT_TRUE(ts_node_eq(cbm_elixir_def_head_unwrap_guard(plain), plain)); + ASSERT_TRUE(cbm_elixir_def_head_is(plain, plain)); + ASSERT_FALSE(cbm_elixir_def_head_is(plain, ts_node_child(plain, 0))); + + /* One guard: the first argument is the operator, the head is its left + * operand, and BOTH answer true -- the operator is the node the paren-less + * route hands the calls walk, the inner call is the node the peel route + * hands it. The guard expression answers false, or the walk would suppress + * a real call written inside a guard. */ + TSNode guarded = probe.signature[1]; + ASSERT_TRUE(cbm_elixir_is_when_guard(guarded)); + TSNode guarded_head = cbm_elixir_def_head_unwrap_guard(guarded); + ASSERT_FALSE(ts_node_eq(guarded_head, guarded)); + ASSERT_STR_EQ("call", ts_node_type(guarded_head)); + ASSERT_TRUE(head_probe_text_is(source, guarded_head, "guarded(x)")); + ASSERT_TRUE(cbm_elixir_def_head_is(guarded, guarded)); + ASSERT_TRUE(cbm_elixir_def_head_is(guarded, guarded_head)); + TSNode guard_expr = ts_node_child_by_field_name(guarded, TS_FIELD("right")); + ASSERT_FALSE(ts_node_is_null(guard_expr)); + ASSERT_TRUE(head_probe_text_is(source, guard_expr, "is_binary(x)")); + ASSERT_FALSE(cbm_elixir_def_head_is(guarded, guard_expr)); + + /* Paren-less: the head under the guard is a bare identifier, so there is no + * inner call for the peel route to produce. This is the shape that makes + * cbm_elixir_def_head_is necessary: the only node the calls walk ever sees + * for this clause is the operator. */ + TSNode bare = probe.signature[2]; + ASSERT_TRUE(cbm_elixir_is_when_guard(bare)); + TSNode bare_head = cbm_elixir_def_head_unwrap_guard(bare); + ASSERT_STR_EQ("identifier", ts_node_type(bare_head)); + ASSERT_TRUE(head_probe_text_is(source, bare_head, "bare")); + ASSERT_TRUE(cbm_elixir_def_head_is(bare, bare)); + ASSERT_TRUE(cbm_elixir_def_head_is(bare, bare_head)); + + /* Two guards on one clause. tree-sitter-elixir nests them + * RIGHT-associatively -- `when(twice(x), when(is_atom(x), is_binary(x)))`, + * which is what this asserts -- so the head is still the outer operator's + * left operand and the peel reaches it in one step. The repeated peel is + * therefore a fixed point rather than a chain walk on this grammar; the + * nodes that must answer true are the operator and the head. */ + TSNode twice = probe.signature[3]; + ASSERT_TRUE(cbm_elixir_is_when_guard(twice)); + TSNode second_guard = ts_node_child_by_field_name(twice, TS_FIELD("right")); + ASSERT_TRUE(cbm_elixir_is_when_guard(second_guard)); + TSNode twice_head = cbm_elixir_def_head_unwrap_guard(twice); + ASSERT_TRUE(head_probe_text_is(source, twice_head, "twice(x)")); + ASSERT_TRUE(cbm_elixir_def_head_is(twice, twice)); + ASSERT_TRUE(cbm_elixir_def_head_is(twice, twice_head)); + /* The second guard is a `when` operator too, and it is NOT on the head + * chain: a walk that treated any `when` above the head as a declaration + * would suppress the calls written inside it. */ + ASSERT_FALSE(cbm_elixir_def_head_is(twice, second_guard)); + + /* An operator definition's head is a binary_operator too, and unwrapping it + * would name the function after its own left parameter. Only the `when` + * token admits the peel, so `def a + b` is returned unchanged and its left + * operand is not a head. */ + TSNode op_def = probe.signature[4]; + ASSERT_STR_EQ("binary_operator", ts_node_type(op_def)); + ASSERT_FALSE(cbm_elixir_is_when_guard(op_def)); + ASSERT_TRUE(ts_node_eq(cbm_elixir_def_head_unwrap_guard(op_def), op_def)); + ASSERT_TRUE(cbm_elixir_def_head_is(op_def, op_def)); + TSNode op_lhs = ts_node_child_by_field_name(op_def, TS_FIELD("left")); + ASSERT_TRUE(head_probe_text_is(source, op_lhs, "a")); + ASSERT_FALSE(cbm_elixir_def_head_is(op_def, op_lhs)); + + ts_tree_delete(tree); + ts_parser_delete(parser); + PASS(); +} + +/* `def name(args) when guard` parses its whole head as a `when` + * binary_operator, so the name sits on the operator's left operand rather than + * directly under the call. The def extractor accepted only `call` and + * `identifier` there, so every guarded clause was dropped -- and a function + * whose clauses ALL carry guards never reached the graph at all, silently. + * Guards are ordinary Elixir: on one real 2.9k-function codebase this hid 216 + * of 270 all-guarded functions, and every call edge touching them. */ +TEST(extract_elixir_guarded_def_head) { + CBMFileResult *r = extract("defmodule Guarded do\n" + " def plain(x), do: x\n" + " def guarded(x) when is_binary(x), do: x\n" + " def guarded(x) when is_list(x), do: x\n" + " def multi(x, y) when is_binary(x) and is_integer(y), do: {x, y}\n" + " defp priv_guarded(x) when is_atom(x), do: x\n" + " def bare when true, do: :ok\n" + " def a + b, do: {a, b}\n" + " def c - d when is_integer(c), do: {c, d}\n" + " def multi_guard(x) when is_atom(x) when is_binary(x), do: x\n" + "end\n", + CBM_LANG_ELIXIR, "t", "guarded.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "plain")); + ASSERT(has_def(r, "Function", "guarded")); + ASSERT(has_def(r, "Function", "multi")); + ASSERT(has_def(r, "Function", "priv_guarded")); + ASSERT(has_def(r, "Function", "bare")); + + /* Elixir admits more than one guard on a clause. tree-sitter-elixir nests + * those `when` operators right-associatively, so the declared head stays + * the outermost operator's left operand -- one node either way, and the + * peel must not stop short of it or return the guard. */ + ASSERT(has_def(r, "Function", "multi_guard")); + ASSERT_EQ(count_defs_named(r, "Function", "multi_guard"), 1); + + /* An operator definition is a binary_operator head too, but it is not a + * guard: unwrapping it names the function after its own LEFT PARAMETER. + * `def a + b` must not mint a function called `a`, nor `def c - d when ...` + * one called `c` -- the guard's left operand is itself the `c - d` + * operator, which names no function either. Neither operator form is + * extracted at all, which is what this grammar did before guards were + * handled; only the `when` token admits the unwrap. */ + ASSERT_EQ(count_defs_named(r, "Function", "a"), 0); + ASSERT_EQ(count_defs_named(r, "Function", "c"), 0); + cbm_free_result(r); + PASS(); +} + +/* A guard hides the head from the scope walk and from the calls walk. + * compute_elixir_func_qn accepts only a `call` or an `identifier` as a def's + * first argument, so a guarded clause resolved no QN and opened NO function + * scope: every call in its body was attributed to the FILE node, the function + * reported no outgoing edges, and its callees gained an in-edge from the file + * instead of from their caller. A paren-less guarded clause (`def f when g`) is + * worse still: it has no inner call node at all, so the guard operator itself + * falls through to extract_callee_name's first-identifier last resort and mints + * a phantom CALLS edge naming the function being defined. */ +TEST(extract_elixir_guarded_def_head_scope) { + CBMFileResult *r = extract("defmodule Guards do\n" + " def target(x), do: x\n" + " def other(x), do: x\n" + " def unguarded_caller(a), do: target(a)\n" + " def guarded_caller(a) when is_binary(a) do\n" + " target(a)\n" + " other(a)\n" + " end\n" + " def bare_guarded when true, do: target(:ok)\n" + "end\n", + CBM_LANG_ELIXIR, "t", "guard_scope.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* A paren-less guarded head is a definition, never a call to itself. */ + ASSERT_EQ(count_calls_named(r, "bare_guarded"), 0); + + /* Both calls in the guarded body source to the guarded function, and the + * paren-less clause's body call to that clause. */ + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.guarded_caller"), 1); + ASSERT_EQ(count_calls_from(r, "other", "t.guard_scope.guarded_caller"), 1); + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.bare_guarded"), 1); + + /* Control on the path the fix does not touch: an unguarded clause still + * sources its body call to itself. This is NOT a leak detector -- the walk + * expires scopes by tree depth (pop_expired_scopes), so a sibling clause + * always unwinds the previous one before pushing its own and "pushed but + * never popped" is not a state this walk can reach. */ + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope.unguarded_caller"), 1); + + /* Nothing falls back to the file node. */ + ASSERT_EQ(count_calls_from(r, "target", "t.guard_scope"), 0); + ASSERT_EQ(count_calls_from(r, "other", "t.guard_scope"), 0); + + cbm_free_result(r); + PASS(); +} + +/* The half the calls and scope assertions above cannot see: the usages walk + * asks whether an identifier sits inside the def's SIGNATURE, and treats + * everything that does as part of the binding rather than as a reference. A + * guard makes that signature the whole `when` operator, which spans the guard + * expression too -- so `a` read by `is_binary(a)` was classified as its own + * binding and emitted no USAGE row at all. A guard is an expression, not a + * pattern: the parameters are bound by `guarded(a, b)` and every mention of one + * to its right is a read, exactly as the same mention in the body already is. + * Unwrapping the signature to the head restores those rows without admitting + * the binding sites themselves, which is why each name is asserted both within + * its scope and in total. */ +TEST(extract_elixir_guarded_def_head_guard_usages) { + CBMFileResult *r = extract("defmodule GuardUsage do\n" + " def plain(x), do: x\n" + " def guarded(a, b) when is_binary(a) and byte_size(a) > 3 do\n" + " b\n" + " end\n" + "end\n", + CBM_LANG_ELIXIR, "t", "guard_usage.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* Both guard reads of the parameter are usages of the guarded clause. */ + ASSERT_EQ(count_usages_from(r, "a", "t.guard_usage.guarded"), 2); + /* ...and nothing else claims `a`: the `a` in `guarded(a, b)` binds it. */ + ASSERT_EQ(count_usages_named(r, "a"), 2); + + /* The body read is unchanged, and the head's `b` is still a binding -- + * an unwrap that took the guard instead of the head would add both head + * parameters here. */ + ASSERT_EQ(count_usages_from(r, "b", "t.guard_usage.guarded"), 1); + ASSERT_EQ(count_usages_named(r, "b"), 1); + + /* Control: an unguarded clause was never affected either way. */ + ASSERT_EQ(count_usages_from(r, "x", "t.guard_usage.plain"), 1); + ASSERT_EQ(count_usages_named(r, "x"), 1); + + cbm_free_result(r); + PASS(); +} + +/* Every clause of an Elixir function is its own `def` call, and an Elixir QN + * carries neither module nor arity, so all clauses compute one qualified name + * and collide on a single graph node. cbm_gbuf_upsert_node ranks a same-QN + * collision by LARGEST start_line, so the survivor was the last clause in the + * file and get_code_snippet returned that clause as the whole function: a + * two-clause `admin?` read back as `def admin?(%__MODULE__{}), do: false` -- + * the graph asserting a predicate that always returns false. The node has to + * span its own clauses, and ONLY its own: the fold is bounded to the + * immediately preceding definition and to the enclosing module body, so a def + * of another name ends the group and two same-named functions in two modules + * stay two nodes. */ +TEST(extract_elixir_clauses_fold_only_when_adjacent_in_one_module) { + CBMFileResult *r = extract("defmodule Roles do\n" /* 1 */ + " def admin?(%{role: :admin}), do: true\n" /* 2 */ + " def admin?(%{}), do: false\n" /* 3 */ + "\n" /* 4 */ + " def promote(user), do: user\n" /* 5 */ + "\n" /* 6 */ + " def admin?(_other), do: false\n" /* 7 */ + "end\n" /* 8 */ + "\n" /* 9 */ + "defmodule Guests do\n" /* 10 */ + " def admin?(:root), do: true\n" /* 11 */ + " def admin?(_any), do: false\n" /* 12 */ + "end\n", /* 13 */ + CBM_LANG_ELIXIR, "t", "roles.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* The two adjacent Roles clauses fold; the clause past `promote` is its own + * group; the Guests pair is a DIFFERENT function and stays its own node. */ + ASSERT_EQ(3, count_defs_named(r, "Function", "admin?")); + ASSERT_EQ(1, count_defs_named(r, "Function", "promote")); + + const CBMDefinition *admin[3] = {NULL, NULL, NULL}; + const CBMDefinition *promote = NULL; + int seen = 0; + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].label, "Function") != 0) { + continue; + } + if (strcmp(r->defs.items[i].name, "admin?") == 0 && seen < 3) { + admin[seen++] = &r->defs.items[i]; + } else if (strcmp(r->defs.items[i].name, "promote") == 0) { + promote = &r->defs.items[i]; + } + } + ASSERT_NOT_NULL(admin[0]); + ASSERT_NOT_NULL(admin[1]); + ASSERT_NOT_NULL(admin[2]); + ASSERT_NOT_NULL(promote); + + ASSERT_EQ(2, (int)admin[0]->start_line); + ASSERT_EQ(3, (int)admin[0]->end_line); + /* separated from lines 2-3 by `promote`, so it keeps its own span */ + ASSERT_EQ(7, (int)admin[1]->start_line); + ASSERT_EQ(7, (int)admin[1]->end_line); + /* Guests.admin? never reaches back over the module boundary */ + ASSERT_EQ(11, (int)admin[2]->start_line); + ASSERT_EQ(12, (int)admin[2]->end_line); + ASSERT_EQ(5, (int)promote->start_line); + ASSERT_EQ(5, (int)promote->end_line); + cbm_free_result(r); + PASS(); +} + +/* A nested module is the one place where the "def of another name ends the + * group" rule is not enough: an Elixir QN carries no module, so `Outer.run` and + * `Outer.Inner.run` compute the SAME qualified name, and when Inner sits + * directly above Outer's own clause the two are adjacent in the extracted defs. + * The fold is bounded to the module body as well, so they stay two nodes. */ +TEST(extract_elixir_nested_module_clauses_do_not_fold) { + CBMFileResult *r = extract("defmodule Outer do\n" /* 1 */ + " defmodule Inner do\n" /* 2 */ + " def run(a), do: a\n" /* 3 */ + " end\n" /* 4 */ + "\n" /* 5 */ + " def run(b), do: b + 1\n" /* 6 */ + "end\n", /* 7 */ + CBM_LANG_ELIXIR, "t", "nested.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(2, count_defs_named(r, "Function", "run")); + + const CBMDefinition *runs[2] = {NULL, NULL}; + int seen = 0; + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].label, "Function") == 0 && + strcmp(r->defs.items[i].name, "run") == 0 && seen < 2) { + runs[seen++] = &r->defs.items[i]; + } + } + ASSERT_NOT_NULL(runs[0]); + ASSERT_NOT_NULL(runs[1]); + ASSERT_EQ(3, (int)runs[0]->start_line); + ASSERT_EQ(3, (int)runs[0]->end_line); + ASSERT_EQ(6, (int)runs[1]->start_line); + ASSERT_EQ(6, (int)runs[1]->end_line); + cbm_free_result(r); + PASS(); +} + +/* Folding clauses has to decide is_exported for the merged node. A `def` clause + * and a `defp` clause of the same name AND arity are clauses of one function -- + * a private guard clause or a nil short-circuit written above the public head + * is ordinary Elixir -- so is_exported is the OR over the folded clauses: the + * name is callable from outside if any clause exports it, and a wholly private + * function stays unexported. */ +TEST(extract_elixir_folded_clause_is_exported_is_the_or) { + CBMFileResult *r = extract("defmodule Acc do\n" /* 1 */ + " def build(l), do: build(l)\n" /* 2 */ + " defp build([]), do: []\n" /* 3 */ + "\n" /* 4 */ + " defp trim(nil), do: nil\n" /* 5 */ + " defp trim(s), do: s\n" /* 6 */ + "end\n", /* 7 */ + CBM_LANG_ELIXIR, "t", "acc.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(1, count_defs_named(r, "Function", "build")); + ASSERT_EQ(1, count_defs_named(r, "Function", "trim")); + + const CBMDefinition *build = NULL; + const CBMDefinition *trim = NULL; + for (int i = 0; i < r->defs.count; i++) { + if (strcmp(r->defs.items[i].label, "Function") != 0) { + continue; + } + if (strcmp(r->defs.items[i].name, "build") == 0) { + build = &r->defs.items[i]; + } else if (strcmp(r->defs.items[i].name, "trim") == 0) { + trim = &r->defs.items[i]; + } + } + ASSERT_NOT_NULL(build); + ASSERT_NOT_NULL(trim); + ASSERT_EQ(2, (int)build->start_line); + ASSERT_EQ(3, (int)build->end_line); + ASSERT_TRUE(build->is_exported); + ASSERT_EQ(5, (int)trim->start_line); + ASSERT_EQ(6, (int)trim->end_line); + ASSERT_FALSE(trim->is_exported); + cbm_free_result(r); + + /* The other direction, and the only one the `prev->is_exported ||` disjunct + * decides. Above, the exporting clause is the FIRST one, so a plain + * `prev->is_exported = is_exported` assignment would also have to lose it + * on the later defp -- but a last-clause-wins assignment happens to be + * right whenever the def comes last. Here the defp comes first and the def + * second, so the surviving flag can only be true if the fold ORs rather + * than assigns. */ + CBMFileResult *o = extract("defmodule Ord do\n" /* 1 */ + " defp z(1), do: 1\n" /* 2 */ + " def z(2), do: 2\n" /* 3 */ + "end\n", /* 4 */ + CBM_LANG_ELIXIR, "t", "ord.ex"); + ASSERT_NOT_NULL(o); + ASSERT_FALSE(o->has_error); + ASSERT_EQ(1, count_defs_named(o, "Function", "z")); + const CBMDefinition *z = NULL; + for (int i = 0; i < o->defs.count; i++) { + if (strcmp(o->defs.items[i].label, "Function") == 0 && + strcmp(o->defs.items[i].name, "z") == 0) { + z = &o->defs.items[i]; + } + } + ASSERT_NOT_NULL(z); + ASSERT_EQ(2, (int)z->start_line); + ASSERT_EQ(3, (int)z->end_line); + ASSERT_TRUE(z->is_exported); + cbm_free_result(o); + PASS(); +} + +/* First definition with this name, for the Elixir clause-fold tests below. + * find_def() is declared much further down this file. */ +static const CBMDefinition *elixir_first_def(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 guarded clause head is a DECLARATION of the name, never a call to it, and + * the call extractor has to agree with the def extractor about which node the + * head is. It did not: it suppressed a head only when the head node IS the + * def's first named argument, but `def f(x) when g` parses its whole head as a + * `when` binary_operator, so the inner `f(x)` was not that argument and was + * recorded as a call to `f`. Under the folded span that phantom lands inside + * the function's own node, and self-recursion is decided by line containment, + * so an ordinary two-clause guarded function reported itself recursive. + * Recursion is a load-bearing signal: `recursive` is a queryable node property + * and seeds the cycle detection in pass_complexity. */ +TEST(extract_elixir_guarded_clause_head_is_not_a_self_call) { + CBMFileResult *r = extract("defmodule Guarded do\n" /* 1 */ + " def f(x) when is_integer(x), do: x\n" /* 2 */ + " def f(_x), do: 0\n" /* 3 */ + "\n" /* 4 */ + " def solo(x) when is_binary(x), do: x\n" /* 5 */ + "end\n", /* 6 */ + CBM_LANG_ELIXIR, "t", "guard_call.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* No call to either name exists anywhere in this file. */ + ASSERT_EQ(0, count_calls_named(r, "f")); + ASSERT_EQ(0, count_calls_named(r, "solo")); + /* The guard predicate IS a real call and stays one. */ + ASSERT_EQ(1, count_calls_named(r, "is_integer")); + ASSERT_EQ(1, count_calls_named(r, "is_binary")); + + ASSERT_EQ(1, count_defs_named(r, "Function", "f")); + const CBMDefinition *f = elixir_first_def(r, "f"); + ASSERT_NOT_NULL(f); + ASSERT_EQ(2, (int)f->start_line); + ASSERT_EQ(3, (int)f->end_line); + ASSERT_FALSE(f->is_recursive); + + /* A single guarded clause was wrong even before the span widened: the + * phantom landed on its own one-line node. */ + const CBMDefinition *solo = elixir_first_def(r, "solo"); + ASSERT_NOT_NULL(solo); + ASSERT_FALSE(solo->is_recursive); + cbm_free_result(r); + PASS(); +} + +/* Every reference of any kind this file records for `name`, across all four + * extractors the unified walk runs over one subtree: a call site, a value + * usage, a read/write occurrence, a type reference. Suppressing a node in ONE + * of them does not remove a phantom reference, it moves it -- handle_calls + * declining a node leaves state->callee_expr unset, and handle_usages then + * reaches the bare identifier and mints a USAGE instead. USAGE and CALLS are + * separate rows in the edge table, so the relabelled phantom also escapes the + * UNIQUE(source,target,type) dedup that had been collapsing it onto a real + * call, and pass_importance counts an inbound reference that did not exist + * before. An assertion naming only r->calls cannot see any of that. */ +static int elixir_any_ref_count(CBMFileResult *r, const char *name) { + int count = count_calls_named(r, name); + for (int i = 0; i < r->usages.count; i++) { + if (r->usages.items[i].ref_name && strcmp(r->usages.items[i].ref_name, name) == 0) { + count++; + } + } + for (int i = 0; i < r->rw.count; i++) { + if (r->rw.items[i].var_name && strcmp(r->rw.items[i].var_name, name) == 0) { + count++; + } + } + for (int i = 0; i < r->type_refs.count; i++) { + if (r->type_refs.items[i].type_name && strcmp(r->type_refs.items[i].type_name, name) == 0) { + count++; + } + } + return count; +} + +/* The head suppression has to REMOVE the phantom, not relabel it. What makes + * that hold for a def head is not the calls walk alone: is_elixir_def_binding in + * extract_usages.c already treats everything inside a def's first argument as + * a binding occurrence rather than a reference, so the identifier handle_calls + * declines is declined by handle_usages too. That agreement is the invariant + * under test, and it is why the counts below are over every extractor rather + * than over r->calls. + * + * `spec_subject` pins the boundary between the two mechanisms. A typespec + * subject is the same shape as a def head, and suppressing it in the calls walk + * would only relabel its phantom as a USAGE, because nothing treats it as a + * binding. It is removed instead by skipping the whole `@spec` subtree before + * any extractor sees it (is_elixir_typespec_attribute, extract_unified.c), so + * the assertion is both that no reference of any kind survives AND that what + * does survive is never a relabelling: the equality holds at 0 == 0 here, and + * would hold at 1 == 1 on a build with the subtree skip removed but the calls + * suppression left in place. */ +TEST(extract_elixir_declaration_head_mints_no_reference_of_any_kind) { + CBMFileResult *r = extract("defmodule Heads do\n" /* 1 */ + " def guarded(x) when is_integer(x), do: x\n" /* 2 */ + " def guarded(_x), do: 0\n" /* 3 */ + "\n" /* 4 */ + " defp bare(x), do: x\n" /* 5 */ + "\n" /* 6 */ + " @spec spec_subject(integer) :: integer\n" /* 7 */ + " def spec_subject(n), do: n\n" /* 8 */ + "end\n", /* 9 */ + CBM_LANG_ELIXIR, "t", "heads.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + /* Nothing in this file references `guarded` or `bare`. Not as a call, not + * as a value, not as a read/write, not as a type. */ + ASSERT_EQ(0, elixir_any_ref_count(r, "guarded")); + ASSERT_EQ(0, elixir_any_ref_count(r, "bare")); + /* The guard predicate is a real call and survives as one. */ + ASSERT_EQ(1, count_calls_named(r, "is_integer")); + ASSERT_EQ(1, elixir_any_ref_count(r, "is_integer")); + /* Neither the @spec line nor the def head it precedes leaves a reference, + * and nothing has been relabelled into a usage, a read/write or a type + * reference. */ + ASSERT_EQ(0, count_calls_named(r, "spec_subject")); + ASSERT_EQ(count_calls_named(r, "spec_subject"), + elixir_any_ref_count(r, "spec_subject")); + + ASSERT_EQ(1, count_defs_named(r, "Function", "guarded")); + const CBMDefinition *guarded = elixir_first_def(r, "guarded"); + ASSERT_NOT_NULL(guarded); + ASSERT_EQ(2, (int)guarded->start_line); + ASSERT_EQ(3, (int)guarded->end_line); + ASSERT_FALSE(guarded->is_recursive); + cbm_free_result(r); + PASS(); +} + +/* The other direction: suppressing declaration heads must not blind the + * detector to real recursion, and folding must not lose a self-call written in + * a clause that is no longer the surviving one. `walk` recurses from its + * guarded second clause; exactly one call to `walk` is recorded (the body one, + * not the two heads) and the folded node is recursive. */ +TEST(extract_elixir_real_recursion_survives_head_suppression) { + CBMFileResult *r = extract("defmodule Rec do\n" /* 1 */ + " def walk([]), do: []\n" /* 2 */ + " def walk([h | t]) when is_integer(h), do: [h | walk(t)]\n" /* 3 */ + "end\n", /* 4 */ + CBM_LANG_ELIXIR, "t", "rec.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(1, count_calls_named(r, "walk")); + ASSERT_EQ(1, count_defs_named(r, "Function", "walk")); + const CBMDefinition *walk = elixir_first_def(r, "walk"); + ASSERT_NOT_NULL(walk); + ASSERT_EQ(2, (int)walk->start_line); + ASSERT_EQ(3, (int)walk->end_line); + ASSERT_TRUE(walk->is_recursive); + cbm_free_result(r); + PASS(); +} + +/* An Elixir module attribute parses as `unary_operator(@, call(, args))`, + * so a typespec line reaches the unified walk as ordinary code and every symbol + * it names collects a phantom inbound edge from the enclosing Module -- a CALLS + * onto the specified function, and for `@type` a USAGE plus a WRITES asserting + * a mutation that does not exist. On a codebase whose convention is @spec on + * every public function, fan_in then never reaches 0 and "which exported + * functions nothing calls" is unanswerable. + * + * All six heads in the skip list are exercised, so deleting any one entry from + * typespec_heads[] breaks this test. + * + * Two of the groups below pin a decision rather than a repair, and both are + * argued in is_elixir_typespec_attribute's comment: + * + * type_refs[] -- a typespec's type references are not indexed at all. A type + * declaration mints no node, so such a reference resolves onto nothing or + * onto a same-named FUNCTION, which is the pollution rather than a record of + * it. Restoring any of them must break this test. + * + * behaviour_contract_names[] -- accepted cost. A @callback / @macrocallback + * name is a function name, so before this change it resolved cross-file onto + * the functions implementing the behaviour. Nothing replaces that edge. + * + * The attribute NAME phantom outside the typespec family is deliberately still + * there: `@timeout` keeps its one reference, and its VALUE is ordinary + * compile-time code that still runs. `guarded` is pinned at 0 rather than 1 + * because the guarded def head no longer mints a phantom either -- the calls + * walk now recognises a `when`-wrapped head as a declaration. */ +TEST(extract_elixir_typespec_attribute_is_not_code) { + /* Names a typespec declares, plus the six attribute heads themselves. + * Every one carries a reference on the pristine build. */ + static const char *const declared[] = {"with_spec", "entry", "secret", "handle", + "spec", "type", "typep", "opaque", + "callback", "macrocallback", NULL}; + /* Accepted cost: these used to resolve onto the behaviour's implementors. */ + static const char *const behaviour_contract_names[] = {"handle_it", "expand_it", NULL}; + /* Pinned decision: type references are not indexed -- remote, bare-local + * and builtin alike. */ + static const char *const type_refs[] = {"MyApp.User.t", "String.t", "MyApp.Vault.key", + "Macro.t", "t", "key", + "term", "integer", "reference", + "atom", NULL}; + CBMFileResult *r = extract("defmodule Specced do\n" + " @timeout Application.compile_env(:app, :timeout)\n" + " @type entry :: String.t()\n" + " @typep secret :: MyApp.Vault.key()\n" + " @opaque handle :: reference()\n" + " @callback handle_it(term) :: :ok\n" + " @macrocallback expand_it(term) :: Macro.t()\n" + " @spec with_spec(MyApp.User.t()) :: integer\n" + " def with_spec(n), do: n\n" + " def without_spec(n), do: n\n" + " @spec guarded(atom) :: :ok\n" + " def guarded(x) when is_atom(x), do: :ok\n" + "end\n", + CBM_LANG_ELIXIR, "t", "specced.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + /* The definitions themselves are a separate pass and are unaffected. */ + ASSERT(has_def(r, "Function", "with_spec")); + ASSERT(has_def(r, "Function", "without_spec")); + ASSERT(has_def(r, "Function", "guarded")); + /* Control on the instrument, not a pin: `without_spec` carries no typespec + * and is never called, so it reads 0 on the pristine build too. It is here + * to show elixir_any_ref_count does not score a definition as a reference, + * which is what would make every 0 below vacuous. It cannot fail first and + * cannot catch a regression. */ + ASSERT_EQ(0, elixir_any_ref_count(r, "without_spec")); + for (const char *const *name = declared; *name; name++) { + ASSERT_EQ(0, elixir_any_ref_count(r, *name)); + } + for (const char *const *name = behaviour_contract_names; *name; name++) { + ASSERT_EQ(0, elixir_any_ref_count(r, *name)); + } + for (const char *const *name = type_refs; *name; name++) { + ASSERT_EQ(0, elixir_any_ref_count(r, *name)); + } + /* A non-typespec attribute is untouched: its value really is compile-time + * code, and its name still mints the one phantom this fix does not close. */ + ASSERT_EQ(1, count_calls_named(r, "Application.compile_env")); + ASSERT_EQ(1, elixir_any_ref_count(r, "timeout")); + /* Both phantoms onto `guarded` are gone: the @spec one to the subtree skip, + * the def-head one to the calls walk recognising a guarded head. */ + ASSERT_EQ(0, elixir_any_ref_count(r, "guarded")); + cbm_free_result(r); + PASS(); +} + /* tree-sitter-elixir gives a call's arguments node no field name, so the * generic `arguments` field lookup returns null and first_string_arg was never * populated for any Elixir call — Phoenix route paths, service URLs and config @@ -1948,6 +2736,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 -> " @@ -6408,6 +7355,40 @@ TEST(extract_go_binary_concat_url_no_literal_suffix_issue1249) { PASS(); } +/* Issue #2537: a Go 1.22 ServeMux pattern may open with a method and/or a host + * ("POST /v1/payments", "GET api.example.com/v1/x"). The written pattern stays + * the first string argument, and its handler is captured exactly as for a + * plain "/v1/payments"; it was dropped because the pattern does not open with + * a slash. */ +TEST(extract_go_servemux_method_pattern_handler_issue2537) { + CBMFileResult *r = extract("package main\n" + "import \"net/http\"\n" + "func setup(mux *http.ServeMux) {\n" + " mux.HandleFunc(\"POST /v1/payments\", createPayment)\n" + " mux.Handle(\"GET api.example.com/v1/x\", hostHandler)\n" + "}\n", + CBM_LANG_GO, "t", "routes.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + const CBMCall *method = find_call_by_callee(r, "mux.HandleFunc"); + ASSERT_NOT_NULL(method); + ASSERT_NOT_NULL(method->first_string_arg); + ASSERT_STR_EQ(method->first_string_arg, "POST /v1/payments"); + ASSERT_NOT_NULL(method->second_arg_name); + ASSERT_STR_EQ(method->second_arg_name, "createPayment"); + + const CBMCall *host = find_call_by_callee(r, "mux.Handle"); + ASSERT_NOT_NULL(host); + ASSERT_NOT_NULL(host->first_string_arg); + ASSERT_STR_EQ(host->first_string_arg, "GET api.example.com/v1/x"); + ASSERT_NOT_NULL(host->second_arg_name); + ASSERT_STR_EQ(host->second_arg_name, "hostHandler"); + + cbm_free_result(r); + PASS(); +} + /* Issue #2291: the JS/TS twin of #1249. A BFF that builds its backend URL as * `${process.env.BACKEND_URL}/wallet/topup` from a configurable base must * index the literal path after the base, exactly as `BACKEND_URL + "/x"` @@ -8120,6 +9101,68 @@ TEST(extract_templ_method_qn_matches_call_scope) { PASS(); } +TEST(extract_go_multiple_init_disambiguated) { + /* Go allows several init() per package (even per file); each gets a + * file+ordinal-suffixed QN (#495 cfg-twin pattern) so none is lost to the + * same-QN upsert. The ordinal — unlike a line number — must not move when + * code is inserted above the function: the QN is node identity, and a + * line-based suffix would churn nodes on every unrelated edit. */ + CBMFileResult *r = extract("package m\n" + "func init() { a() }\n" + "func init() { b() }\n" + "func a() {}\n" + "func b() {}\n", + CBM_LANG_GO, "t", "x.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const char *first = NULL; + const char *second = NULL; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (!d->name || strcmp(d->name, "init") != 0) { + continue; + } + if (!first) { + first = d->qualified_name; + } else { + second = d->qualified_name; + } + } + ASSERT_NOT_NULL(first); + ASSERT_NOT_NULL(second); + ASSERT_STR_EQ(first, "t.init#x.go:1"); + ASSERT_STR_EQ(second, "t.init#x.go:2"); + + /* Stability: an insertion ABOVE both inits must not change either QN. */ + CBMFileResult *r2 = extract("package m\n" + "import \"fmt\"\n" + "var shifted = fmt.Sprint(\"pad\")\n" + "func init() { a() }\n" + "func init() { b() }\n" + "func a() {}\n" + "func b() {}\n", + CBM_LANG_GO, "t", "x.go"); + ASSERT_NOT_NULL(r2); + ASSERT_FALSE(r2->has_error); + int seen = 0; + for (int i = 0; i < r2->defs.count; i++) { + const CBMDefinition *d = &r2->defs.items[i]; + if (!d->name || strcmp(d->name, "init") != 0) { + continue; + } + seen++; + if (seen == 1) { + ASSERT_STR_EQ(d->qualified_name, "t.init#x.go:1"); + } else { + ASSERT_STR_EQ(d->qualified_name, "t.init#x.go:2"); + } + } + ASSERT_EQ(seen, 2); + cbm_free_result(r2); + cbm_free_result(r); + PASS(); +} + /* TS/JS/TSX receiver-aware flag (#592/#606; same intent as the Perl flag above). * A member call x.foo() with a non-this/super receiver is flagged is_method so * the resolver can suppress a weak short-name match (`re.test()` must not bind a @@ -13513,6 +14556,7 @@ SUITE(extraction) { RUN_TEST(extract_go_selector_call_flags_is_method); RUN_TEST(extract_go_method_receiver_qualified_qn); RUN_TEST(extract_templ_method_qn_matches_call_scope); + RUN_TEST(extract_go_multiple_init_disambiguated); RUN_TEST(extract_ts_member_call_flags_is_method); RUN_TEST(extract_ts_this_super_receiver_not_flagged); RUN_TEST(extract_js_member_call_flags_is_method); @@ -13559,6 +14603,7 @@ SUITE(extraction) { RUN_TEST(traversal_stack_not_in_result_arena_issue2010); RUN_TEST(extract_c_macro_hidden_call_survives_preprocessed_pass_issue2010); RUN_TEST(extract_c_macros_issue375); + RUN_TEST(extract_cpp_inc_linkage_and_callback); RUN_TEST(extract_cpp_macros_issue375); RUN_TEST(extract_cpp_functionlike_macro_type_arg_no_false_parse_partial_issue1071); RUN_TEST(extract_cpp_real_in_body_error_still_flagged_issue1071); @@ -13641,8 +14686,22 @@ SUITE(extraction) { /* Functional */ RUN_TEST(elixir_function); + RUN_TEST(elixir_def_head_is_covers_the_head_and_every_guard_above_it); + RUN_TEST(extract_elixir_guarded_def_head); + RUN_TEST(extract_elixir_clauses_fold_only_when_adjacent_in_one_module); + RUN_TEST(extract_elixir_nested_module_clauses_do_not_fold); + RUN_TEST(extract_elixir_folded_clause_is_exported_is_the_or); + RUN_TEST(extract_elixir_guarded_clause_head_is_not_a_self_call); + RUN_TEST(extract_elixir_declaration_head_mints_no_reference_of_any_kind); + RUN_TEST(extract_elixir_real_recursion_survives_head_suppression); + RUN_TEST(extract_elixir_typespec_attribute_is_not_code); RUN_TEST(elixir_call_string_argument); + 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); @@ -13883,6 +14942,7 @@ SUITE(extraction) { RUN_TEST(extract_ts_config_object_url_issue2235); RUN_TEST(extract_go_binary_concat_url_issue1249); RUN_TEST(extract_go_binary_concat_url_no_literal_suffix_issue1249); + RUN_TEST(extract_go_servemux_method_pattern_handler_issue2537); RUN_TEST(extract_ts_template_base_url_issue2291); RUN_TEST(extract_ts_template_base_url_no_literal_path_issue2291); RUN_TEST(extract_py_concat_const_url_issue706); diff --git a/tests/test_go_lsp.c b/tests/test_go_lsp.c index b79101c07..1f0d58087 100644 --- a/tests/test_go_lsp.c +++ b/tests/test_go_lsp.c @@ -1063,7 +1063,7 @@ TEST(golsp_crossfile_method_dispatch) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 4, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 4, imp_names, imp_qns, 1, NULL, &out); ASSERT_GTE(find_resolved_arr(&out, "doQuery", "Connect"), 0); @@ -1109,7 +1109,7 @@ TEST(golsp_crossfile_return_type_chain) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 4, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 4, imp_names, imp_qns, 1, NULL, &out); ASSERT_GTE(find_resolved_arr(&out, "showUser", "Name"), 0); @@ -1149,7 +1149,7 @@ TEST(golsp_crossfile_interface_dispatch) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 4, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 4, imp_names, imp_qns, 1, NULL, &out); int idx = find_resolved_arr_confident(&out, "handler", "Bind"); @@ -1205,7 +1205,7 @@ TEST(golsp_crossfile_interface_field_chain) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 6, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 6, imp_names, imp_qns, 1, NULL, &out); int idx = find_resolved_arr_confident(&out, "process", "Bind"); @@ -1245,7 +1245,7 @@ TEST(golsp_crossfile_map_index) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 3, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 3, imp_names, imp_qns, 1, NULL, &out); int idx = find_resolved_arr_confident(&out, "dispatch", "ServeHTTP"); @@ -1275,7 +1275,7 @@ TEST(golsp_crossfile_stdlib_interface) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 1, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 1, imp_names, imp_qns, 1, NULL, &out); ASSERT_GTE(find_resolved_arr_confident(&out, "process", "Done"), 0); @@ -1349,7 +1349,7 @@ TEST(golsp_crossfile_aliased_field_requal) { CBMTypeRegistry *reg = cbm_go_build_cross_registry(&arena, defs, 6); ASSERT_NOT_NULL(reg); - cbm_run_go_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.main", reg, + cbm_run_go_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.main", NULL, reg, imp_names, imp_qns, 2, NULL, &out); int svc_idx = find_resolved_arr_confident(&out, "callSvc", "Svc.Ping"); @@ -1469,7 +1469,7 @@ TEST(golsp_crossfile_type_alias_method) { CBMResolvedCallArray out = {0}; cbm_run_go_lsp_cross(&arena, go_crossfile_alias_source, (int)strlen(go_crossfile_alias_source), - "test.main", go_crossfile_alias_defs, n, NULL, NULL, 0, NULL, &out); + "test.main", NULL, go_crossfile_alias_defs, n, NULL, NULL, 0, NULL, &out); if (go_crossfile_alias_check(&out) != 0) { cbm_arena_destroy(&arena); return 1; @@ -1488,8 +1488,8 @@ TEST(golsp_crossfile_type_alias_method_prebuilt) { CBMTypeRegistry *reg = cbm_go_build_cross_registry(&arena, go_crossfile_alias_defs, n); ASSERT_NOT_NULL(reg); cbm_run_go_lsp_cross_with_registry(&arena, go_crossfile_alias_source, - (int)strlen(go_crossfile_alias_source), "test.main", reg, - NULL, NULL, 0, NULL, &out); + (int)strlen(go_crossfile_alias_source), "test.main", NULL, + reg, NULL, NULL, 0, NULL, &out); if (go_crossfile_alias_check(&out) != 0) { cbm_arena_destroy(&arena); return 1; @@ -1538,7 +1538,7 @@ TEST(golsp_crossfile_local_interface_single_impl) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 5, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 5, imp_names, imp_qns, 1, NULL, &out); int idxGet = find_resolved_arr_confident(&out, "process", "Get"); @@ -1554,6 +1554,133 @@ TEST(golsp_crossfile_local_interface_single_impl) { PASS(); } +/* ── Go init(): per-file caller QN (#1910) ──────────────────────── */ + +/* Every init() of a package is its own node, pkg.init#: + * (cbm_go_init_qn). The LSP's enclosing-function QN must be exactly that QN: + * the pipeline joins LSP rows to textual calls by caller QN, so a flat + * pkg.init loses every LSP resolution inside an init body. Two inits in one + * file pin the ordinal; the method name exists on both types, so only the + * typed receiver tells the targets apart. */ +TEST(golsp_init_caller_qn_matches_def) { + const char *source = "package main\n\n" + "type Codec struct{}\n\n" + "func (c *Codec) register() {}\n\n" + "type Store struct{}\n\n" + "func (s *Store) register() {}\n\n" + "func init() {\n\tc := &Codec{}\n\tc.register()\n}\n\n" + "func init() {\n\ts := &Store{}\n\ts.register()\n}\n"; + CBMFileResult *r = extract_go(source); + ASSERT_NOT_NULL(r); + const char *init1 = NULL; + const char *init2 = NULL; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (d->name && strcmp(d->name, "init") == 0) { + if (!init1) { + init1 = d->qualified_name; + } else { + init2 = d->qualified_name; + } + } + } + ASSERT_NOT_NULL(init1); + ASSERT_NOT_NULL(init2); + ASSERT_NOT_NULL(strstr(init1, ".init#main.go:1")); + ASSERT_NOT_NULL(strstr(init2, ".init#main.go:2")); + + int a = require_resolved(r, "init", "Codec.register"); + ASSERT_GTE(a, 0); + ASSERT_STR_EQ(r->resolved_calls.items[a].caller_qn, init1); + int b = require_resolved(r, "init", "Store.register"); + ASSERT_GTE(b, 0); + ASSERT_STR_EQ(r->resolved_calls.items[b].caller_qn, init2); + + /* The join itself: each textual register() call (raw callee text + * "c.register" / "s.register") finds its own init's row. */ + int joined = 0; + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *c = &r->calls.items[i]; + if (!c->callee_name || !strstr(c->callee_name, ".register")) { + continue; + } + const CBMResolvedCall *rc = cbm_pipeline_find_lsp_resolution(&r->resolved_calls, c, false); + ASSERT_NOT_NULL(rc); + ASSERT_STR_EQ(rc->caller_qn, c->enclosing_func_qn); + ASSERT_NOT_NULL( + strstr(rc->callee_qn, strcmp(c->enclosing_func_qn, init1) == 0 ? "Codec" : "Store")); + joined++; + } + ASSERT_EQ(joined, 2); + cbm_free_result(r); + PASS(); +} + +/* Cross-file twin: both cross entry points (per-file defs, and the shared + * Tier-2 registry the pipeline uses) name the init caller from rel_path. */ +TEST(golsp_crossfile_init_caller_qn_per_file) { + const char *source = "package reg\n\n" + "func init() {\n\tc := NewCodec()\n\tc.Register()\n}\n\n" + "func init() {\n\ts := NewStore()\n\ts.Register()\n}\n"; + CBMLSPDef defs[] = { + {.qualified_name = "test.reg.NewCodec", + .short_name = "NewCodec", + .label = "Function", + .def_module_qn = "test.reg", + .return_types = "*Codec"}, + {.qualified_name = "test.reg.Codec", + .short_name = "Codec", + .label = "Class", + .def_module_qn = "test.reg"}, + {.qualified_name = "test.reg.Codec.Register", + .short_name = "Register", + .label = "Method", + .def_module_qn = "test.reg", + .receiver_type = "test.reg.Codec"}, + {.qualified_name = "test.reg.NewStore", + .short_name = "NewStore", + .label = "Function", + .def_module_qn = "test.reg", + .return_types = "*Store"}, + {.qualified_name = "test.reg.Store", + .short_name = "Store", + .label = "Class", + .def_module_qn = "test.reg"}, + {.qualified_name = "test.reg.Store.Register", + .short_name = "Register", + .label = "Method", + .def_module_qn = "test.reg", + .receiver_type = "test.reg.Store"}, + }; + + CBMArena arena; + cbm_arena_init(&arena); + CBMResolvedCallArray out = {0}; + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.reg", "reg/b.go", defs, 6, NULL, + NULL, 0, NULL, &out); + int c1 = find_resolved_arr_confident(&out, "init", "Codec.Register"); + ASSERT_GTE(c1, 0); + ASSERT_STR_EQ(out.items[c1].caller_qn, "test.reg.init#b.go:1"); + int s1 = find_resolved_arr_confident(&out, "init", "Store.Register"); + ASSERT_GTE(s1, 0); + ASSERT_STR_EQ(out.items[s1].caller_qn, "test.reg.init#b.go:2"); + + CBMResolvedCallArray shared_out = {0}; + CBMTypeRegistry *reg = cbm_go_build_cross_registry(&arena, defs, 6); + ASSERT_NOT_NULL(reg); + cbm_run_go_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.reg", "reg/b.go", + reg, NULL, NULL, 0, NULL, &shared_out); + int c2 = find_resolved_arr_confident(&shared_out, "init", "Codec.Register"); + ASSERT_GTE(c2, 0); + ASSERT_STR_EQ(shared_out.items[c2].caller_qn, "test.reg.init#b.go:1"); + int s2 = find_resolved_arr_confident(&shared_out, "init", "Store.Register"); + ASSERT_GTE(s2, 0); + ASSERT_STR_EQ(shared_out.items[s2].caller_qn, "test.reg.init#b.go:2"); + + cbm_arena_destroy(&arena); + PASS(); +} + /* ── Suite ─────────────────────────────────────────────────────── */ SUITE(go_lsp) { @@ -1668,4 +1795,8 @@ SUITE(go_lsp) { RUN_TEST(golsp_crossfile_map_index); RUN_TEST(golsp_crossfile_stdlib_interface); RUN_TEST(golsp_crossfile_local_interface_single_impl); + + /* Go init(): per-file caller QN (#1910) */ + RUN_TEST(golsp_init_caller_qn_matches_def); + RUN_TEST(golsp_crossfile_init_caller_qn_per_file); } diff --git a/tests/test_infrascan.c b/tests/test_infrascan.c index 3b31f62e1..faffd78e4 100644 --- a/tests/test_infrascan.c +++ b/tests/test_infrascan.c @@ -173,6 +173,13 @@ TEST(infrascan_service_pattern_match_rejects_ids_inside_words) { {"kafkaesque.story.tell", CBM_SVC_NONE}, /* "Axios" glued to a lowercase continuation */ {"geo.Axiosphere.map", CBM_SVC_NONE}, + /* Angular receiver rows end in a dot (#2768): a longer receiver that + * only starts with the same letters is not the HttpClient (NestJS + * route registration, AngularJS test double) */ + {"this.httpAdapter.get", CBM_SVC_NONE}, + {"this.httpServer.post", CBM_SVC_NONE}, + {"$httpBackend.expectGET", CBM_SVC_NONE}, + {"this.httpAdapter\n .get", CBM_SVC_NONE}, {NULL, CBM_SVC_NONE}, }; ASSERT_EQ(svc_case_mismatches(cases), 0); @@ -202,6 +209,18 @@ TEST(infrascan_service_pattern_match_keeps_real_library_qns) { {"Axios.post", CBM_SVC_HTTP}, {"AxiosInstance.get", CBM_SVC_HTTP}, {"curl_exec", CBM_SVC_HTTP}, + /* Angular HttpClient receivers (#2768); `_httpClient` is the common + * C# field spelling of the same client */ + {"this.http.get", CBM_SVC_HTTP}, + {"this.httpClient.post", CBM_SVC_HTTP}, + {"httpClient.put", CBM_SVC_HTTP}, + {"_httpClient.GetAsync", CBM_SVC_HTTP}, + {"$http.get", CBM_SVC_HTTP}, + /* a chain wrapped before the verb keeps the line break (LF / CRLF) */ + {"this.http\n .post", CBM_SVC_HTTP}, + {"this.http\r\n .delete", CBM_SVC_HTTP}, + {"this.httpClient\n .put", CBM_SVC_HTTP}, + {"$http\n .get", CBM_SVC_HTTP}, {"surf.get", CBM_SVC_HTTP}, {"hyper.Client.request", CBM_SVC_HTTP}, {"phin.promisified", CBM_SVC_HTTP}, @@ -809,12 +828,61 @@ TEST(infrascan_data_flows_test_handlers_pair_only_within_their_file) { PASS(); } +/* Issue #2537: the Go 1.22 ServeMux grammar "[METHOD ][HOST]/[PATH]". The path + * is found inside the written pattern; a plain path, a lowercase word, a + * pattern without a path and a method too long for the buffer are no split. + * Only a Handle / HandleFunc registration is read with it. */ +TEST(infrascan_servemux_pattern_split_issue2537) { + char m[16]; + const char *pat = "POST /v1/payments"; + ASSERT_TRUE(cbm_service_pattern_mux_split(pat, m, sizeof(m)) == pat + 5); + ASSERT_STR_EQ(m, "POST"); + pat = "GET \t /v1/payments/{id}"; + ASSERT_TRUE(cbm_service_pattern_mux_split(pat, m, sizeof(m)) == pat + 6); + ASSERT_STR_EQ(m, "GET"); + pat = "GET api.example.com/v1/x"; + ASSERT_TRUE(cbm_service_pattern_mux_split(pat, m, sizeof(m)) == pat + 19); + ASSERT_STR_EQ(m, "GET"); + pat = "api.example.com:8443/v1/x"; + ASSERT_TRUE(cbm_service_pattern_mux_split(pat, m, sizeof(m)) == pat + 20); + ASSERT_STR_EQ(m, ""); + pat = "DELETE /"; + ASSERT_TRUE(cbm_service_pattern_mux_split(pat, NULL, 0) == pat + 7); + + ASSERT_NULL(cbm_service_pattern_mux_split("/v1/x", m, sizeof(m))); + ASSERT_NULL(cbm_service_pattern_mux_split("get /v1/x", m, sizeof(m))); + ASSERT_NULL(cbm_service_pattern_mux_split("POST", m, sizeof(m))); + ASSERT_NULL(cbm_service_pattern_mux_split("POST ", m, sizeof(m))); + ASSERT_NULL(cbm_service_pattern_mux_split("GET host", m, sizeof(m))); + ASSERT_NULL(cbm_service_pattern_mux_split("GET a b/x", m, sizeof(m))); + ASSERT_NULL(cbm_service_pattern_mux_split("", m, sizeof(m))); + ASSERT_NULL(cbm_service_pattern_mux_split(NULL, m, sizeof(m))); + ASSERT_NULL(cbm_service_pattern_mux_split("ABCDEFGHIJKLMNOP /x", m, sizeof(m))); + ASSERT_STR_EQ(m, ""); + + /* only Handle / HandleFunc register a pattern, and never with a URL */ + pat = "POST /v1/payments"; + ASSERT_TRUE(cbm_service_pattern_mux_route_path("mux.HandleFunc", pat) == pat + 5); + pat = "api.example.com/v1/x"; + ASSERT_TRUE(cbm_service_pattern_mux_route_path("s.mux.Handle", pat) == pat + 15); + ASSERT_NULL(cbm_service_pattern_mux_route_path("st.Get", "users/123")); + ASSERT_NULL(cbm_service_pattern_mux_route_path("c.Get", "https://api.example.com/v1/x")); + ASSERT_NULL(cbm_service_pattern_mux_route_path("mux.Handle", "https://api.example.com/v1/x")); + ASSERT_NULL(cbm_service_pattern_mux_route_path("PatchCollector.Delete", "storage.k8s.io/v1")); + ASSERT_NULL(cbm_service_pattern_mux_route_path("r.Route", "GET /v1/x")); + ASSERT_NULL(cbm_service_pattern_mux_route_path("mux.HandleFunc", "/v1/x")); + ASSERT_NULL(cbm_service_pattern_mux_route_path(NULL, "GET /v1/x")); + ASSERT_NULL(cbm_service_pattern_mux_route_path("mux.HandleFunc", NULL)); + PASS(); +} + SUITE(infrascan) { RUN_TEST(infrascan_http_route_literal_guard_rejects_filesystem_paths); RUN_TEST(infrascan_http_route_literal_guard_rejects_document_extensions); RUN_TEST(infrascan_client_navigation_is_js_family_only); RUN_TEST(infrascan_service_pattern_match_rejects_ids_inside_words); RUN_TEST(infrascan_service_pattern_match_keeps_real_library_qns); + RUN_TEST(infrascan_servemux_pattern_split_issue2537); RUN_TEST(infrascan_route_nodes_skip_bad_http_url_paths); RUN_TEST(infrascan_http_calls_join_matching_handler_route); RUN_TEST(infrascan_decorator_route_metadata_keeps_its_escapes); diff --git a/tests/test_lane_gate_contract.sh b/tests/test_lane_gate_contract.sh index 57e2cc12e..f016f2e99 100755 --- a/tests/test_lane_gate_contract.sh +++ b/tests/test_lane_gate_contract.sh @@ -49,7 +49,7 @@ t1_jobs() { # the jobs of a green T1 run; $1 = conclusion of the x86 leg job "lint / lint-mem" success job "test / setup-matrix" success job "test / test-unix (ubuntu-latest, gcc, g++, unix-x86, 1/1)" "$1" - job "test / test-unix (macos-14, cc, c++, unix-macos14, 1/1)" success + job "test / test-unix (macos-15, cc, c++, unix-macos14, 1/1)" success job "test / test-windows (windows-latest, CLANG64, x86_64, windows, 1/1)" success job "test / test-windows-guards" skipped job "test / test-package-wrappers" skipped @@ -116,7 +116,7 @@ gate "a selection without the job list is red" 1 "$NEEDS_OK" "$T1" "$WORK/does-n job "lint / lint" success job "lint / lint-mem" success job "test / test-windows-guards" success - job "pr-smoke (macos-14, smoke-mac)" success + job "pr-smoke (macos-15, smoke-mac)" success } >"$WORK/guards-only" gate "windows-guards is not the windows leg" 1 "$NEEDS_OK" '["lint","lint-mem","security-static","windows"]' "$WORK/guards-only" gate "pr-smoke matrix name maps to its lane" 0 "$NEEDS_OK" '["lint","lint-mem","security-static","smoke-mac","windows-guards"]' "$WORK/guards-only" diff --git a/tests/test_language.c b/tests/test_language.c index e43f40f43..56a0f829c 100644 --- a/tests/test_language.c +++ b/tests/test_language.c @@ -1457,6 +1457,55 @@ static CBMLanguage classify_content(const char *name, const char *content, size_ typedef CBMLanguage (*path_probe_fn)(const char *path); +/* Exercise the shared classifier and both disk entry points. All sources are + * synthetic; the recipe bodies deliberately contain misleading C-like code. */ +TEST(lang_inc_c_family_and_existing_dialects) { + static const struct { + const char *content; + CBMLanguage expected; + } cases[] = { + {"#include \"sample.h\"\nextern \"C\" int sample_export() { return 0; }\n", CBM_LANG_CPP}, + {"extern \"C\" __declspec(dllexport) int sample_export() { return 0; }\n", CBM_LANG_CPP}, + {"#define SAMPLE_CALLBACK(T) T\nstatic SAMPLE_CALLBACK(int) sample() { return 0; }\n", + CBM_LANG_CPP}, + {"/* in-function fragment */\n#ifdef SAMPLE_ENABLED\nif (check()) return 1;\n#endif\n", + CBM_LANG_CPP}, + {"int sample(void) { return 0; }\n", CBM_LANG_CPP}, + {" \t# include \r\n", CBM_LANG_CPP}, + {"/* example:\nextern \"C\" int example();\n*/\nSUMMARY = \"sample\"\n", CBM_LANG_BITBAKE}, + {"// extern \"C\" example only\n", CBM_LANG_BITBAKE}, + {"#include_extra \"sample.h\"\n", CBM_LANG_BITBAKE}, + {"#define Value 1\n", CBM_LANG_OBJECTSCRIPT_ROUTINE}, + {"#ifndef SAMPLE\n#define SAMPLE 1\n#endif\n", CBM_LANG_OBJECTSCRIPT_ROUTINE}, + {"ROUTINE Sample [Type=INC]\n#define Value 1\n", CBM_LANG_OBJECTSCRIPT_ROUTINE}, + {"#def1arg Value(%x) %x\n", CBM_LANG_OBJECTSCRIPT_ROUTINE}, + {"#; ObjectScript comment\n", CBM_LANG_OBJECTSCRIPT_ROUTINE}, + {"#include \n#def1arg Value(%x) %x\n", CBM_LANG_OBJECTSCRIPT_ROUTINE}, + {"# recipe\nSUMMARY = \"sample\"\nrequire base.inc\n", CBM_LANG_BITBAKE}, + {"SRC_URI += \"file://sample.c\"\ndo_compile() {\nif (check) { return; }\n}\n", + CBM_LANG_BITBAKE}, + {"python do_generate() {\nif (ready):\n pass\n}\n", CBM_LANG_BITBAKE}, + {"do_compile() {\ncat <<'EOF'\n#include \nstatic int sample;\nEOF\n}\n", + CBM_LANG_BITBAKE}, + {"require base.inc\nextern \"C\" example in recipe text\n", CBM_LANG_BITBAKE}, + {"", CBM_LANG_BITBAKE}, + }; + char path[512]; + snprintf(path, sizeof(path), "%s/cbm_lang_inc_routing.inc", cbm_tmpdir()); + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + size_t len = strlen(cases[i].content); + ASSERT_EQ(classify_content("sample.inc", cases[i].content, len), cases[i].expected); + ASSERT_TRUE(write_exact(path, cases[i].content, len)); + CBMLanguage by_probe = cbm_disambiguate_inc(path); + CBMLanguage by_file = cbm_language_for_file("sample.inc", path); + remove(path); + ASSERT_EQ(by_probe, cases[i].expected); + ASSERT_EQ(by_file, cases[i].expected); + } + ASSERT_EQ(cbm_disambiguate_inc(NULL), CBM_LANG_BITBAKE); + PASS(); +} + TEST(lang_probe_bytes_follow_the_name) { ASSERT_EQ(cbm_language_probe_bytes("main.go"), 0); ASSERT_EQ(cbm_language_probe_bytes("Makefile"), 0); @@ -1879,6 +1928,7 @@ SUITE(language) { RUN_TEST(lang_ext_sosl); RUN_TEST(lang_all_have_names); + RUN_TEST(lang_inc_c_family_and_existing_dialects); RUN_TEST(lang_probe_bytes_follow_the_name); RUN_TEST(lang_classify_matches_every_content_rule); RUN_TEST(lang_classify_unreadable_content_keeps_the_name_default); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 671cef371..cdc3c5541 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -4787,6 +4787,156 @@ TEST(tool_search_graph_bm25_zero_hint_names_the_label) { PASS(); } +/* file_pattern, kind and namespace filter after the BM25 candidate window + * (they cannot be put into the MATCH like the label). 2001 same-label nodes + * with a short path fill the window ahead of 3 that rank strictly lower (long + * path) and are the only ones passing each filter, so a windowed search + * answered 0 rows. A saturated window under such a filter is ranked over + * every candidate: all 3 rows, an exact total, consistent offset pages. */ +static void bm25_post_window_fixture(cbm_store_t *store, const char *project) { + cbm_store_upsert_project(store, project, "/tmp/bm25-post-window"); + for (int i = 0; i < 2004; i++) { + bool rare = i >= 2001; + char qn[64]; + snprintf(qn, sizeof(qn), "bm25-post-window.%s_%04d", rare ? "rare" : "fn", i); + cbm_node_t node = { + .project = project, + .label = "Function", + .name = "postneedle", + .qualified_name = qn, + .file_path = rare ? "deeply/nested/package/layout/registry.c" : "many.c", + .start_line = i + 1, + .end_line = i + 1, + .properties_json = + rare ? "{\"kind\":\"Deployment\",\"obj_key\":\"apps/Deployment/prod/web\"}" : NULL}; + cbm_store_upsert_node(store, &node); + } + cbm_store_exec(store, "INSERT INTO nodes_fts(nodes_fts) VALUES('delete-all');"); + cbm_store_exec(store, "INSERT INTO nodes_fts(rowid, name, qualified_name, label, " + "file_path) SELECT id, cbm_camel_split(name), qualified_name, " + "label, file_path FROM nodes;"); +} + +static char *bm25_post_window_search(cbm_mcp_server_t *srv, const char *filter_json, int limit, + int offset) { + char req[512]; + snprintf(req, sizeof(req), + "{\"jsonrpc\":\"2.0\",\"id\":2424,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"project\":\"bm25-post-window\",\"query\":\"postneedle\"%s," + "\"limit\":%d,\"offset\":%d,\"format\":\"json\"}}}", + filter_json, limit, offset); + char *resp = cbm_mcp_server_handle(srv, req); + char *inner = resp ? extract_text_content(resp) : NULL; + free(resp); + return inner; +} + +TEST(tool_search_graph_bm25_post_window_filters_rank_every_candidate) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + cbm_mcp_server_set_project(srv, "bm25-post-window"); + bm25_post_window_fixture(store, "bm25-post-window"); + + static const char *const filters[] = { + ",\"file_pattern\":\"deeply/nested\"", + ",\"kind\":\"Deployment\"", + ",\"namespace\":\"prod\"", + }; + for (size_t i = 0; i < sizeof(filters) / sizeof(filters[0]); i++) { + char *inner = bm25_post_window_search(srv, filters[i], 10, 0); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"search_mode\":\"bm25\"")); + ASSERT_NOT_NULL(strstr(inner, "\"bm25-post-window.rare_2001\"")); + ASSERT_NOT_NULL(strstr(inner, "\"bm25-post-window.rare_2003\"")); + ASSERT_NULL(strstr(inner, "\"bm25-post-window.fn_")); + ASSERT_NOT_NULL(strstr(inner, "\"total\":3,")); + ASSERT_NOT_NULL(strstr(inner, "\"total_relation\":\"eq\"")); + ASSERT_NULL(strstr(inner, "candidate_window_saturated")); + free(inner); + } + + /* Offset pages over the exhaustive ranking are disjoint and complete. */ + char *page1 = bm25_post_window_search(srv, filters[0], 2, 0); + char *page2 = bm25_post_window_search(srv, filters[0], 2, 2); + ASSERT_NOT_NULL(page1); + ASSERT_NOT_NULL(page2); + ASSERT_NOT_NULL(strstr(page1, "\"returned\":2,")); + ASSERT_NOT_NULL(strstr(page2, "\"returned\":1,")); + ASSERT_NOT_NULL(strstr(page2, "\"total\":3,")); + for (int i = 2001; i < 2004; i++) { + char qn[64]; + snprintf(qn, sizeof(qn), "\"bm25-post-window.rare_%04d\"", i); + ASSERT_TRUE((strstr(page1, qn) != NULL) != (strstr(page2, qn) != NULL)); + } + free(page1); + free(page2); + + /* Without a post-window filter the window stays and says so. */ + char *inner = bm25_post_window_search(srv, "", 10, 0); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"total\":2000,")); + ASSERT_NOT_NULL(strstr(inner, "\"candidate_window_saturated\":true")); + free(inner); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* An unlabelled query whose whole candidate window is File/Folder/Variable/ + * Project rows (excluded from unlabelled ranking) found no row at all, while + * a matching definition sat just beyond the window: 2001 short Folder rows + * outrank one Function with a long path. A window with no row passing the + * filters is ranked over every candidate instead. */ +TEST(tool_search_graph_bm25_empty_window_ranks_every_candidate) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + const char *project = "bm25-empty-window"; + cbm_mcp_server_set_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/bm25-empty-window"), CBM_STORE_OK); + for (int i = 0; i < 2002; i++) { + bool def = i == 2001; + char qn[64]; + snprintf(qn, sizeof(qn), "bm25-empty-window.%s_%04d", def ? "def" : "dir", i); + cbm_node_t node = {.project = project, + .label = def ? "Function" : "Folder", + .name = "foldneedle", + .qualified_name = qn, + .file_path = def ? "deeply/nested/package/layout/registry.c" : "d", + .start_line = i + 1, + .end_line = i + 1}; + ASSERT_GT(cbm_store_upsert_node(store, &node), 0); + } + ASSERT_EQ(cbm_store_exec(store, "INSERT INTO nodes_fts(nodes_fts) VALUES('delete-all');"), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_exec(store, "INSERT INTO nodes_fts(rowid, name, qualified_name, label, " + "file_path) SELECT id, cbm_camel_split(name), qualified_name, " + "label, file_path FROM nodes;"), + CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":2425,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"project\":\"bm25-empty-window\",\"query\":\"foldneedle\",\"limit\":10," + "\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + free(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"bm25-empty-window.def_2001\",\"Function\"")); + ASSERT_NULL(strstr(inner, "\"Folder\"")); + ASSERT_NOT_NULL(strstr(inner, "\"total\":1,")); + ASSERT_NOT_NULL(strstr(inner, "\"total_relation\":\"eq\"")); + free(inner); + + cbm_mcp_server_free(srv); + PASS(); +} + /* The definition whose NAME is the query ranks first: the `Table` class above * its own methods and above test methods that repeat "table" three times; the * `get_object_or_404` function above the test methods that contain it. */ @@ -7693,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); @@ -8390,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", @@ -8430,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); @@ -8450,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); @@ -8469,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); @@ -10077,6 +10431,57 @@ TEST(tool_delete_project_mutation_guard_blocks_then_releases) { PASS(); } +/* delete_project removes the ADR sidecar with the project, and a re-index of a + * same-named project afterwards starts with no ADR (round-2 review item 7c). */ +TEST(tool_delete_project_removes_adr_sidecar) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-mcp-delete-adr-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); + } + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + const char *project = "adr-delete-project"; + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *setup = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(setup); + ASSERT_EQ(cbm_store_upsert_project(setup, project, "/tmp/adr-delete-project"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_adr_store(setup, project, "# Decision\nkeep it"), CBM_STORE_OK); + cbm_store_close(setup); + + char adr_path[CBM_SZ_1K]; + snprintf(adr_path, sizeof(adr_path), "%s.adr.db", db_path); + ASSERT_TRUE(cbm_file_exists(adr_path)); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_handle_tool(srv, "delete_project", "{\"project\":\"adr-delete-project\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "deleted")); + free(resp); + + ASSERT_FALSE(cbm_file_exists(db_path)); + ASSERT_FALSE(cbm_file_exists(adr_path)); /* sidecar removed with the project */ + + /* A fresh store of the same name starts with no ADR. */ + cbm_store_t *fresh = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(fresh); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(fresh, project, &adr), CBM_STORE_NOT_FOUND); + cbm_store_adr_free(&adr); + cbm_store_close(fresh); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + cbm_rmdir(cache); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + PASS(); +} + TEST(tool_index_repository_mutation_guard_blocks_before_local_worker) { char root[CBM_SZ_1K]; (void)snprintf(root, sizeof(root), "%s/cbm-index-guard-XXXXXX", cbm_tmpdir()); @@ -11670,6 +12075,88 @@ TEST(search_code_scoped_file_pattern_is_busybox_portable) { #endif } +/* issue #2836: a file_pattern with '/' matched only the basename, so every + * directory-scoped glob silently returned 0 results. */ +TEST(search_code_file_pattern_matches_repo_relative_path_issue2836) { + char tmp[CBM_SZ_512]; + snprintf(tmp, sizeof(tmp), "%s/cbm-search-fp-path-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char src_dir[CBM_SZ_1K]; + char cli_dir[CBM_SZ_1K]; + char main_path[CBM_SZ_1K]; + char watcher_path[CBM_SZ_1K]; + snprintf(src_dir, sizeof(src_dir), "%s/src", tmp); + snprintf(cli_dir, sizeof(cli_dir), "%s/src/cli", tmp); + snprintf(main_path, sizeof(main_path), "%s/src/main.c", tmp); + snprintf(watcher_path, sizeof(watcher_path), "%s/src/cli/watcher.c", tmp); + ASSERT_EQ(cbm_mkdir(src_dir), 0); + ASSERT_EQ(cbm_mkdir(cli_dir), 0); + ASSERT_EQ(th_write_file(main_path, "int FP_PATH_NEEDLE = 1;\n"), 0); + ASSERT_EQ(th_write_file(watcher_path, "int FP_PATH_NEEDLE = 2;\n"), 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + const char *project = "search-fp-path"; + cbm_mcp_server_set_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(store, project, tmp), CBM_STORE_OK); + cbm_node_t main_file = {.project = project, + .label = "File", + .name = "main.c", + .qualified_name = "search-fp-path.src.main.c", + .file_path = "src/main.c"}; + cbm_node_t watcher_file = {.project = project, + .label = "File", + .name = "watcher.c", + .qualified_name = "search-fp-path.src.cli.watcher.c", + .file_path = "src/cli/watcher.c"}; + ASSERT_GT(cbm_store_upsert_node(store, &main_file), 0); + ASSERT_GT(cbm_store_upsert_node(store, &watcher_file), 0); + + static const struct { + const char *file_pattern; + const char *expect; + bool hint; + } cases[] = { + {"src/cli/*.c", "\"total_grep_matches\":1", false}, + {"src/**/*.c", "\"total_grep_matches\":2", false}, /* ** spans zero dirs */ + {"**/cli/*.c", "\"total_grep_matches\":1", false}, + {"src/cli/", "\"total_grep_matches\":1", false}, /* no wildcard: substring */ + {"watcher.c", "\"total_grep_matches\":1", false}, /* basename unchanged */ + {"*.c", "\"total_grep_matches\":2", false}, + {"lib/*.c", "\"total_grep_matches\":0", true}, + }; + bool ok = true; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + char args[CBM_SZ_512]; + snprintf(args, sizeof(args), + "{\"pattern\":\"FP_PATH_NEEDLE\",\"project\":\"search-fp-path\"," + "\"file_pattern\":\"%s\",\"format\":\"json\"}", + cases[i].file_pattern); + char *response = cbm_mcp_handle_tool(srv, "search_code", args); + char *inner = extract_text_content(response); + bool case_ok = inner && strstr(inner, cases[i].expect) && + (strstr(inner, "use path_filter") != NULL) == cases[i].hint; + if (!case_ok) { + fprintf(stderr, "file_pattern %s: %s\n", cases[i].file_pattern, + inner ? inner : "(null)"); + ok = false; + } + free(inner); + free(response); + } + + cbm_mcp_server_free(srv); + cbm_unlink(watcher_path); + cbm_unlink(main_path); + cbm_rmdir(cli_dir); + cbm_rmdir(src_dir); + cbm_rmdir(tmp); + ASSERT_TRUE(ok); + PASS(); +} + TEST(search_code_recursive_fallback_propagates_discovery_failures) { #ifdef _WIN32 SKIP_PLATFORM("POSIX find/sort/xargs fallback contract"); @@ -24153,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) @@ -26219,6 +26861,8 @@ SUITE(mcp) { RUN_TEST(tool_search_graph_bm25_label_scope_keeps_rank_order); RUN_TEST(tool_search_graph_bm25_serves_unranked_labels_when_named); RUN_TEST(tool_search_graph_bm25_zero_hint_names_the_label); + RUN_TEST(tool_search_graph_bm25_post_window_filters_rank_every_candidate); + RUN_TEST(tool_search_graph_bm25_empty_window_ranks_every_candidate); RUN_TEST(tool_search_graph_bm25_ranks_exact_name_first); RUN_TEST(tool_search_graph_rejects_bm25_and_semantic_query_together); RUN_TEST(tool_search_graph_semantic_ceiling_never_emits_unusable_continuation); @@ -26257,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); @@ -26322,6 +26969,7 @@ SUITE(mcp) { RUN_TEST(search_code_fails_closed_when_complete_scan_is_impossible); RUN_TEST(search_code_scoped_scan_uses_canonical_file_nodes); RUN_TEST(search_code_scoped_file_pattern_is_busybox_portable); + RUN_TEST(search_code_file_pattern_matches_repo_relative_path_issue2836); RUN_TEST(search_code_recursive_fallback_propagates_discovery_failures); RUN_TEST(search_code_default_budget_limits_raw_rows_before_graph_results); RUN_TEST(search_code_raw_and_directory_remainders_are_independently_pageable); @@ -26501,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); @@ -26522,6 +27174,7 @@ SUITE(mcp_mutation_guard) { RUN_TEST(tool_ingest_traces_mutation_guard_missing_store_releases); RUN_TEST(tool_ingest_traces_mutation_guard_query_failure_releases); RUN_TEST(tool_delete_project_mutation_guard_blocks_then_releases); + RUN_TEST(tool_delete_project_removes_adr_sidecar); RUN_TEST(tool_index_repository_mutation_guard_blocks_before_local_worker); RUN_TEST(tool_manage_adr_mutation_guard_balances_success); RUN_TEST(tool_manage_adr_read_paths_skip_blocking_mutation_guard); diff --git a/tests/test_parallel.c b/tests/test_parallel.c index 9183f33b5..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; @@ -5597,7 +5902,7 @@ TEST(parallel_php_external_row_replaces_only_its_exact_occurrence) { const char *keys[] = {"LocalRequest"}; const char *vals[] = {project_type}; cbm_pxc_run_one_with_php_bindings(CBM_LANG_PHP, r, source, (int)strlen(source), "p.main", - defs, 2, keys, vals, 1, &bindings); + NULL, defs, 2, keys, vals, 1, &bindings); int external_count = 0, project_count = 0; for (int i = 0; i < r->resolved_calls.count; i++) { const CBMResolvedCall *row = &r->resolved_calls.items[i]; @@ -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 992f5ae78..9157c4bcd 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); @@ -1088,6 +1352,217 @@ TEST(pipeline_records_unresolved_injected_call_sites) { PASS(); } +/* An ADR written from a test hook BETWEEN the start of a reindex and its + * publication survives — the write lands in the ".adr.db" sidecar, which the + * graph-DB rebuild never touches (round-2 review item 7a, full route). */ +typedef struct { + char db_path[512]; + char project[256]; + const char *adr; +} adr_hook_ctx_t; + +static void write_adr_before_final_manifest(void *userdata) { + adr_hook_ctx_t *c = (adr_hook_ctx_t *)userdata; + cbm_store_t *s = cbm_store_open_path_existing(c->db_path); + if (!s) { + s = cbm_store_open_path(c->db_path); + } + if (s) { + (void)cbm_store_adr_store(s, c->project, c->adr); + cbm_store_close(s); + } +} + +TEST(pipeline_adr_written_during_reindex_survives) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_hook_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char path[512]; + snprintf(path, sizeof(path), "%s/main.py", tmp); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + adr_hook_ctx_t ctx = {.adr = "# Decision\nWritten mid-reindex."}; + snprintf(ctx.db_path, sizeof(ctx.db_path), "%s", db_path); + snprintf(ctx.project, sizeof(ctx.project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + /* Force a full reindex, writing the ADR from the publication hook. */ + for (int i = 0; i < 4; i++) { + snprintf(path, sizeof(path), "%s/extra%d.py", tmp, i); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def g%d():\n return %d\n", i, i); + fclose(f); + } + cbm_pipeline_incremental_test_reset_faults(); + cbm_pipeline_incremental_test_before_final_manifest_once(write_adr_before_final_manifest, &ctx); + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + /* Pin the route so this stays a full-rebuild test if routing changes. */ + ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_FORCED_FULL); + cbm_pipeline_free(p2); + cbm_pipeline_incremental_test_reset_faults(); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, ctx.project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, ctx.adr); + cbm_store_adr_free(&adr); + cbm_store_close(s); + + rm_rf(tmp); + PASS(); +} + +/* Migrate-before-rebuild fails closed: if the sidecar cannot be opened (here a + * DIRECTORY sits at ".adr.db"), the reindex must NOT delete the old + * generation — it returns CBM_PIPELINE_ABORT_PRESERVE_DB and the prior graph is + * left intact. Deterministic, no hook. */ +TEST(pipeline_adr_migrate_failure_preserves_generation) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_migfail_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char path[512]; + snprintf(path, sizeof(path), "%s/main.py", tmp); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + cbm_store_t *before = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(before); + int nodes_before = cbm_store_count_nodes(before, project); + cbm_store_close(before); + ASSERT_TRUE(nodes_before > 0); + + /* A directory at ".adr.db" makes the sidecar open (and thus the + * migrate-before-delete step) fail deterministically. */ + char adr_dir[600]; + snprintf(adr_dir, sizeof(adr_dir), "%s.adr.db", db_path); + ASSERT_EQ(cbm_mkdir(adr_dir), 0); + + /* Force a full reindex; migrate-before-delete must fail closed. */ + for (int i = 0; i < 4; i++) { + snprintf(path, sizeof(path), "%s/extra%d.py", tmp, i); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def g%d():\n return %d\n", i, i); + fclose(f); + } + cbm_pipeline_incremental_test_reset_faults(); + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + int rc = cbm_pipeline_run(p2); + cbm_pipeline_free(p2); + ASSERT_EQ(rc, CBM_PIPELINE_ABORT_PRESERVE_DB); + + /* Old generation intact: the graph DB was not deleted or rebuilt. */ + ASSERT_TRUE(cbm_file_exists(db_path)); + cbm_store_t *after = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(after); + ASSERT_EQ(cbm_store_count_nodes(after, project), nodes_before); + cbm_store_close(after); + + rm_rf(tmp); + PASS(); +} + +/* Upgrade order: a legacy ADR row (pre-sidecar, in the graph DB) is migrated + * BEFORE a forced-full reindex deletes the old generation, with NO ADR read + * first — the pipeline's migrate-before-rebuild step must move it (item 7b). */ +TEST(pipeline_adr_legacy_migrates_before_forced_reindex) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_upgrade_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char path[512]; + snprintf(path, sizeof(path), "%s/main.py", tmp); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + /* Seed the ADR directly into the graph DB's project_summaries (the legacy + * layout), bypassing the sidecar entirely. */ + static const char adr_text[] = "# Decision\nLegacy row, migrated before rebuild."; + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(db_path, &raw), SQLITE_OK); + sqlite3_exec(raw, + "CREATE TABLE IF NOT EXISTS project_summaries (project TEXT PRIMARY KEY," + " summary TEXT NOT NULL, source_hash TEXT NOT NULL, created_at TEXT NOT NULL," + " updated_at TEXT NOT NULL);", + NULL, NULL, NULL); + char *sql = sqlite3_mprintf( + "INSERT OR REPLACE INTO project_summaries VALUES (%Q, %Q, '', '2020-01-01T00:00:00Z'," + " '2020-01-01T00:00:00Z');", + project, adr_text); + ASSERT_NOT_NULL(sql); + ASSERT_EQ(sqlite3_exec(raw, sql, NULL, NULL, NULL), SQLITE_OK); + sqlite3_free(sql); + sqlite3_close(raw); + + /* No ADR read here. Force a full reindex (which deletes the old graph DB). */ + for (int i = 0; i < 4; i++) { + snprintf(path, sizeof(path), "%s/extra%d.py", tmp, i); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def g%d():\n return %d\n", i, i); + fclose(f); + } + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + cbm_pipeline_free(p2); + + /* The legacy ADR was migrated to the sidecar before the delete, so it is + * still readable. */ + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, adr_text); + cbm_store_adr_free(&adr); + cbm_store_close(s); + + rm_rf(tmp); + PASS(); +} + TEST(pipeline_structure_edges) { if (setup_test_repo() != 0) { FAIL("failed to create temp dir"); @@ -6183,6 +6658,60 @@ TEST(pipeline_closure_repair_body_edit_converges_with_fresh_full) { PASS(); } +/* Companion to pipeline_adr_written_during_reindex_survives (the full route): + * an ADR written from the publication hook mid-reindex must also survive the + * closure-DELTA route (item 7a — "both the full and the delta route"). The + * delta route clones/patches/renames the graph DB but never touches the + * ".adr.db" sidecar, so the hook-written ADR is preserved. A body-only edit + * forces the closure-repair (delta) route, which we assert explicitly so this + * can't silently pass on a full rebuild. */ +TEST(pipeline_adr_written_during_delta_reindex_survives) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_delta_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + closure_probe_repo(tmp); + char db[512]; + snprintf(db, sizeof(db), "%s/closure.db", tmp); + + cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(baseline); + ASSERT_EQ(cbm_pipeline_run(baseline), 0); + adr_hook_ctx_t ctx = {.adr = "# Decision\nWritten mid-delta-reindex."}; + snprintf(ctx.db_path, sizeof(ctx.db_path), "%s", db); + snprintf(ctx.project, sizeof(ctx.project), "%s", cbm_pipeline_project_name(baseline)); + cbm_pipeline_free(baseline); + + /* Body-only edit (same exported surface) → closure-repair delta route. */ + write_temp_file(tmp, "lib.ts", + "export function closureProbeHelper(x: number): string {\n" + " const doubled = x + x;\n" + " return String(doubled);\n" + "}\n"); + + cbm_pipeline_incremental_test_reset_faults(); + cbm_pipeline_incremental_test_before_final_manifest_once(write_adr_before_final_manifest, &ctx); + cbm_pipeline_t *incr = cbm_pipeline_new(tmp, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(incr); + ASSERT_EQ(cbm_pipeline_run(incr), 0); + cbm_incremental_route_t route = cbm_pipeline_incremental_test_last_route(); + cbm_pipeline_free(incr); + cbm_pipeline_incremental_test_reset_faults(); + /* Must be the DELTA route, not a full rebuild — otherwise this proves nothing. */ + ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR); + + cbm_store_t *s = cbm_store_open_path(db); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, ctx.project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, ctx.adr); + cbm_store_adr_free(&adr); + cbm_store_close(s); + + th_rmtree(tmp); + PASS(); +} + /* Removing a definition (no additions) keeps the closure route AND must drop * the dependent's stale CALL_REFERENCE. This is the assertion legacy partial * could never pass: its QN-keyed re-link resurrected yesterday's edge. */ @@ -7214,7 +7743,6 @@ TEST(pipeline_publication_never_uses_a_predictable_staging_path) { .cancelled = NULL, .manifest = NULL, .manifest_count = 0, - .adr_content = NULL, .coverage = NULL, .coverage_count = 0, }; @@ -8855,79 +9383,215 @@ TEST(pipeline_incremental_successful_publication_preserves_adr) { PASS(); } -/* A forced-full rebuild must never erase an ADR merely because the old - * generation could not be read completely. The capture is part of the - * publication transaction: failure preserves both graph and ADR. */ -TEST(pipeline_full_adr_capture_failure_preserves_previous_generation) { +/* An ADR written in the pre-sidecar layout (a row in the graph DB's + * project_summaries) is migrated into the ".adr.db" sidecar the first time + * it is read, and then survives a full reindex — so upgrading needs no reindex + * and loses no decision record. */ +TEST(pipeline_adr_migrates_from_legacy_graph_row) { char tmp[256]; - snprintf(tmp, sizeof(tmp), "/tmp/cbm_publish_adr_capture_XXXXXX"); - ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); - write_temp_file(tmp, "generation.py", "def BeforeAdrCapture():\n return 1\n"); + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_migrate_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } char db_path[512]; - snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp); + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char main_py[512]; + snprintf(main_py, sizeof(main_py), "%s/main.py", tmp); + FILE *f = fopen(main_py, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); - cbm_pipeline_incremental_test_reset_faults(); - cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); - ASSERT_NOT_NULL(baseline); - ASSERT_EQ(cbm_pipeline_run(baseline), 0); + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); char project[256]; - snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline)); - cbm_pipeline_free(baseline); + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); - static const char adr_text[] = "# Decision\nADR capture is fail-closed."; - cbm_store_t *adr_store = cbm_store_open_path(db_path); - ASSERT_NOT_NULL(adr_store); - ASSERT_EQ(cbm_store_adr_store(adr_store, project, adr_text), CBM_STORE_OK); - cbm_store_close(adr_store); + /* Seed the ADR directly into the graph DB's project_summaries — the layout + * before the sidecar existed — bypassing cbm_store_adr_store (which now + * writes the sidecar). */ + static const char adr_text[] = "# Decision\nLegacy row migrates to the sidecar."; + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(db_path, &raw), SQLITE_OK); + char *sql = sqlite3_mprintf( + "INSERT INTO project_summaries (project, summary, source_hash, created_at, updated_at) " + "VALUES (%Q, %Q, '', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z');", + project, adr_text); + ASSERT_NOT_NULL(sql); + ASSERT_EQ(sqlite3_exec(raw, sql, NULL, NULL, NULL), SQLITE_OK); + sqlite3_free(sql); + sqlite3_close(raw); - write_temp_file(tmp, "generation.py", "def AfterAdrCapture():\n return 2\n"); - cbm_pipeline_incremental_test_fail_adr_capture_once(); - cbm_pipeline_t *faulted = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); - ASSERT_NOT_NULL(faulted); - int faulted_rc = cbm_pipeline_run(faulted); - cbm_pipeline_free(faulted); + char adr_db[600]; + snprintf(adr_db, sizeof(adr_db), "%s.adr.db", db_path); + ASSERT_TRUE(!cbm_file_exists(adr_db)); /* no sidecar yet */ - int faulted_before = -1; - int faulted_after = -1; - observe_named_generation(db_path, project, "BeforeAdrCapture", "AfterAdrCapture", - &faulted_before, &faulted_after); - cbm_store_t *preserved = cbm_store_open_path(db_path); - ASSERT_NOT_NULL(preserved); - cbm_adr_t preserved_adr = {0}; - int preserved_adr_rc = cbm_store_adr_get(preserved, project, &preserved_adr); - bool preserved_adr_matches = preserved_adr_rc == CBM_STORE_OK && preserved_adr.content && - strcmp(preserved_adr.content, adr_text) == 0; - cbm_store_adr_free(&preserved_adr); - cbm_store_close(preserved); + /* A READ returns the legacy row from the graph DB and must NOT create the + * sidecar (a query-only cache must stay unwritten). */ + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, adr_text); + cbm_store_adr_free(&adr); + cbm_store_close(s); + ASSERT_TRUE(!cbm_file_exists(adr_db)); /* read did not create the sidecar */ - cbm_pipeline_incremental_test_reset_faults(); - cbm_pipeline_t *retry = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); - ASSERT_NOT_NULL(retry); - int retry_rc = cbm_pipeline_run(retry); - cbm_pipeline_free(retry); - int retry_before = -1; - int retry_after = -1; - observe_named_generation(db_path, project, "BeforeAdrCapture", "AfterAdrCapture", &retry_before, - &retry_after); - cbm_store_t *published = cbm_store_open_path(db_path); - ASSERT_NOT_NULL(published); - cbm_adr_t published_adr = {0}; - int published_adr_rc = cbm_store_adr_get(published, project, &published_adr); - bool published_adr_matches = published_adr_rc == CBM_STORE_OK && published_adr.content && - strcmp(published_adr.content, adr_text) == 0; - cbm_store_adr_free(&published_adr); - cbm_store_close(published); - cbm_pipeline_incremental_test_reset_faults(); - th_rmtree(tmp); + /* Force a full reindex; routing migrates the legacy row into the sidecar + * before the old generation is deleted, so the ADR survives. */ + for (int i = 0; i < 4; i++) { + char extra[512]; + snprintf(extra, sizeof(extra), "%s/extra%d.py", tmp, i); + f = fopen(extra, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def g%d():\n return %d\n", i, i); + fclose(f); + } + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + cbm_pipeline_free(p2); - ASSERT_EQ(faulted_rc, CBM_PIPELINE_ABORT_PRESERVE_DB); - ASSERT_EQ(faulted_before, 1); - ASSERT_EQ(faulted_after, 0); - ASSERT_TRUE(preserved_adr_matches); - ASSERT_EQ(retry_rc, 0); - ASSERT_EQ(retry_before, 0); - ASSERT_EQ(retry_after, 1); - ASSERT_TRUE(published_adr_matches); + cbm_store_t *s2 = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s2); + cbm_adr_t adr2 = {0}; + ASSERT_EQ(cbm_store_adr_get(s2, project, &adr2), CBM_STORE_OK); + ASSERT_NOT_NULL(adr2.content); + ASSERT_STR_EQ(adr2.content, adr_text); + cbm_store_adr_free(&adr2); + cbm_store_close(s2); + + rm_rf(tmp); + PASS(); +} + +/* A sidecar that exists but was never marked `migrated` is half-created (a crash + * or a killed process between CREATE and the migration COMMIT). A read must fall + * back to the legacy graph row so a partial sidecar never hides an ADR. */ +TEST(pipeline_adr_half_created_sidecar_falls_back_to_legacy) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_halfcreated_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char main_py[512]; + snprintf(main_py, sizeof(main_py), "%s/main.py", tmp); + FILE *f = fopen(main_py, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + /* Legacy ADR still only in the graph DB (pre-sidecar layout). */ + static const char adr_text[] = "# Decision\nHalf-created sidecar must not hide me."; + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(db_path, &raw), SQLITE_OK); + char *sql = sqlite3_mprintf( + "INSERT INTO project_summaries (project, summary, source_hash, created_at, updated_at) " + "VALUES (%Q, %Q, '', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z');", + project, adr_text); + ASSERT_NOT_NULL(sql); + ASSERT_EQ(sqlite3_exec(raw, sql, NULL, NULL, NULL), SQLITE_OK); + sqlite3_free(sql); + sqlite3_close(raw); + + /* Fabricate a HALF-CREATED sidecar: schema present, NO `migrated` marker. */ + char adr_db[600]; + snprintf(adr_db, sizeof(adr_db), "%s.adr.db", db_path); + sqlite3 *side = NULL; + ASSERT_EQ(sqlite3_open(adr_db, &side), SQLITE_OK); + ASSERT_EQ(sqlite3_exec(side, + "CREATE TABLE project_summaries (project TEXT PRIMARY KEY, summary TEXT " + "NOT NULL, source_hash TEXT NOT NULL, created_at TEXT NOT NULL, " + "updated_at TEXT NOT NULL);" + "CREATE TABLE adr_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);", + NULL, NULL, NULL), + SQLITE_OK); + sqlite3_close(side); + ASSERT_TRUE(cbm_file_exists(adr_db)); /* sidecar exists but is unmarked */ + + /* The read must fall back to the legacy graph row, not report NOT_FOUND. */ + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(s, project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_STR_EQ(adr.content, adr_text); + cbm_store_adr_free(&adr); + cbm_store_close(s); + + rm_rf(tmp); + PASS(); +} + +/* ADRs live in the ".adr.db" sidecar, separate from the graph DB, and + * removing that sidecar (what delete_project does) removes the ADR store. */ +TEST(pipeline_adr_sidecar_is_separate_and_removable) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_sidecar_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("failed to create temp dir"); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + char main_py[512]; + snprintf(main_py, sizeof(main_py), "%s/main.py", tmp); + FILE *f = fopen(main_py, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def foo():\n pass\n"); + fclose(f); + + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); + cbm_pipeline_free(p1); + + static const char adr_text[] = "# Decision\nADRs live in the sidecar."; + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_adr_store(s, project, adr_text), CBM_STORE_OK); + cbm_store_close(s); + + /* The ADR is in the sidecar, NOT in the graph DB's project_summaries. */ + char adr_db[600]; + snprintf(adr_db, sizeof(adr_db), "%s.adr.db", db_path); + ASSERT_TRUE(cbm_file_exists(adr_db)); + + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(db_path, &raw), SQLITE_OK); + sqlite3_stmt *st = NULL; + ASSERT_EQ(sqlite3_prepare_v2(raw, "SELECT COUNT(*) FROM project_summaries;", -1, &st, NULL), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(st), SQLITE_ROW); + int graph_rows = sqlite3_column_int(st, 0); + sqlite3_finalize(st); + sqlite3_close(raw); + ASSERT_EQ(graph_rows, 0); + + /* Removing the sidecar (as delete_project does) removes the ADR store. */ + ASSERT_EQ(cbm_unlink(adr_db), 0); + cbm_store_t *s2 = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s2); + cbm_adr_t adr = {0}; + int rc = cbm_store_adr_get(s2, project, &adr); + cbm_store_adr_free(&adr); + cbm_store_close(s2); + ASSERT_EQ(rc, CBM_STORE_NOT_FOUND); + + rm_rf(tmp); PASS(); } @@ -8944,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); @@ -8973,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; @@ -10834,6 +11498,653 @@ TEST(pipeline_go_receiver_parallel_suppresses_weak_method_edge) { PASS(); } +TEST(pipeline_go_multi_init_nodes_survive) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_init_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + /* Two files, one package, one init() each — Go runs both at start-up. + * Pre-fix both collapsed onto one QN and the upsert kept one node + * (reproduce-first: RED asserts node count == 2). */ + write_temp_file(tmp, "go.mod", "module example.com/reg\n\ngo 1.22\n"); + write_temp_file(tmp, "reg/a.go", + "package reg\n" + "\n" + "var handlers = map[string]func(){}\n" + "\n" + "func init() { handlers[\"a\"] = handleA }\n" + "\n" + "func handleA() {}\n"); + write_temp_file(tmp, "reg/b.go", + "package reg\n" + "\n" + "func init() { handlers[\"b\"] = handleB }\n" + "\n" + "func handleB() {}\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_init.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + /* Both init functions survive as distinct nodes. */ + cbm_node_t *inits = NULL; + int ic = 0; + cbm_store_find_nodes_by_name(s, project, "init", &inits, &ic); + ASSERT_EQ(ic, 2); + cbm_store_free_nodes(inits, ic); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +/* Edges of `edge_type` whose source and target QNs end with the given + * suffixes (go_calls_between for any edge type, without the properties). */ +static int go_edges_between(cbm_store_t *s, const char *project, const char *edge_type, + const char *caller_suffix, const char *callee_suffix) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + cbm_store_find_edges_by_type(s, project, edge_type, &edges, &edge_count); + size_t cs = strlen(caller_suffix); + size_t ts = strlen(callee_suffix); + int hits = 0; + for (int i = 0; i < edge_count; i++) { + cbm_node_t src = {0}; + cbm_node_t tgt = {0}; + if (cbm_store_find_node_by_id(s, edges[i].source_id, &src) == CBM_STORE_OK && + cbm_store_find_node_by_id(s, edges[i].target_id, &tgt) == CBM_STORE_OK && + src.qualified_name && tgt.qualified_name) { + size_t sl = strlen(src.qualified_name); + size_t tl = strlen(tgt.qualified_name); + if (sl >= cs && tl >= ts && strcmp(src.qualified_name + sl - cs, caller_suffix) == 0 && + strcmp(tgt.qualified_name + tl - ts, callee_suffix) == 0) { + hits++; + } + } + cbm_node_free_fields(&src); + cbm_node_free_fields(&tgt); + } + if (edges) { + cbm_store_free_edges(edges, edge_count); + } + return hits; +} + +/* Fixture for the #1910 Go init() LSP-join probes (sequential and parallel + * twins). Three init() in two files of one package -- two in b.go, so the + * per-file ordinal is pinned too -- each calling a method that only the Go + * LSP can bind: the receiver's type comes from a constructor in another + * file, and the method name also exists on a second package type and on + * other.Decoy, so no short-name fallback lands on the right target. a.go's + * init also passes a handler by value (add("a", handleA): a CALL_REFERENCE, + * which exists only as an LSP row). pad_files > 0 adds filler Go files past + * MIN_FILES_FOR_PARALLEL. */ +static void write_go_init_lsp_fixture(const char *tmp, int pad_files) { + write_temp_file(tmp, "go.mod", "module example.com/fxinit\n\ngo 1.22\n"); + write_temp_file(tmp, "reg/codec.go", + "package reg\n" + "\n" + "type Codec struct{ n int }\n" + "\n" + "func NewCodec() *Codec { return &Codec{} }\n" + "\n" + "func (c *Codec) register() { c.n++ }\n"); + write_temp_file(tmp, "reg/store.go", + "package reg\n" + "\n" + "type Store struct{ open bool }\n" + "\n" + "func NewStore() *Store { return &Store{} }\n" + "\n" + "func (s *Store) register() { s.open = true }\n" + "\n" + "func (s *Store) warm() {}\n"); + write_temp_file(tmp, "reg/a.go", + "package reg\n" + "\n" + "var handlers = map[string]func(){}\n" + "\n" + "func handleA() {}\n" + "\n" + "func add(name string, fn func()) { handlers[name] = fn }\n" + "\n" + "func init() {\n" + "\tc := NewCodec()\n" + "\tc.register()\n" + "\tadd(\"a\", handleA)\n" + "}\n"); + write_temp_file(tmp, "reg/b.go", + "package reg\n" + "\n" + "func init() {\n" + "\ts := NewStore()\n" + "\ts.register()\n" + "}\n" + "\n" + "func init() {\n" + "\ts := NewStore()\n" + "\ts.warm()\n" + "}\n"); + write_temp_file(tmp, "other/other.go", + "package other\n" + "\n" + "type Decoy struct{}\n" + "\n" + "func (d *Decoy) register() {}\n" + "\n" + "func (d *Decoy) warm() {}\n"); + for (int i = 0; i < pad_files; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "pad/filler%d.go", i); + snprintf(body, sizeof(body), "package pad\n\nfunc filler%d() int { return %d }\n", i, i); + write_temp_file(tmp, name, body); + } +} + +/* Each init's LSP-resolved edges start at that init's own node. */ +static int assert_go_init_lsp_edges(cbm_store_t *s, const char *project) { + const char *lsp = "\"strategy\":\"lsp_"; + ASSERT_GTE(go_calls_between(s, project, ".reg.init#a.go:1", ".reg.Codec.register", lsp), 1); + ASSERT_GTE(go_calls_between(s, project, ".reg.init#b.go:1", ".reg.Store.register", lsp), 1); + ASSERT_GTE(go_calls_between(s, project, ".reg.init#b.go:2", ".reg.Store.warm", lsp), 1); + ASSERT_GTE(go_edges_between(s, project, "CALL_REFERENCE", ".reg.init#a.go:1", ".reg.handleA"), + 1); + /* No cross-wiring between the inits, and nothing onto the decoy. */ + ASSERT_EQ(go_calls_between(s, project, ".reg.init#a.go:1", ".reg.Store.register", NULL), 0); + ASSERT_EQ(go_calls_between(s, project, ".reg.init#b.go:1", ".reg.Codec.register", NULL), 0); + ASSERT_EQ(go_calls_between(s, project, ".reg.init#b.go:2", ".reg.Store.register", NULL), 0); + ASSERT_EQ(go_calls_between(s, project, ".reg.init#a.go:1", ".other.Decoy.register", NULL), 0); + ASSERT_EQ(go_calls_between(s, project, ".reg.init#b.go:1", ".other.Decoy.register", NULL), 0); + ASSERT_EQ(go_calls_between(s, project, ".reg.init#b.go:2", ".other.Decoy.warm", NULL), 0); + return 0; +} + +/* #1910: the def extractor and compute_func_qn name each init + * pkg.init#:; the Go LSP must name its caller the same way + * (cbm_go_init_qn), or every LSP resolution inside an init body loses the + * exact caller-QN join and falls back to the registry resolver. */ +TEST(pipeline_go_init_lsp_edges_per_file) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_init_lsp_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_init_lsp_fixture(tmp, 0); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_init_lsp.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_EQ(assert_go_init_lsp_edges(s, project), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +/* Parallel twin: >= MIN_FILES_FOR_PARALLEL (50) files plus CBM_WORKERS=4 + * take the parallel resolver (resolve_worker's Tier-2 dispatch). */ +TEST(pipeline_go_init_lsp_edges_per_file_parallel) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_init_lsp_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_init_lsp_fixture(tmp, 52); + + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_init_lsp_par.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_EQ(assert_go_init_lsp_edges(s, project), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + +/* Fixture for the bare-`init` probes (sequential and parallel twins), the + * shapes the kubernetes census found after #1915: a local closure `init := + * func(ctx)` that is called, written and passed by value (the e2e storage + * suites), a parameter named init (`func sum(init func() ref.Val)`), and a + * `package init` directory. The decoys are a package init in the caller's + * own package (what same_module bound before #1915) and an unrelated + * Watcher.init method (what the name fallback binds after it). Watcher.Start + * and Boot call that method through a selector, the control that must keep + * resolving. pad_files > 0 adds filler Go files past MIN_FILES_FOR_PARALLEL. */ +static void write_go_bare_init_fixture(const char *tmp, int pad_files) { + write_temp_file(tmp, "go.mod", "module example.com/fxbareinit\n\ngo 1.22\n"); + write_temp_file(tmp, "suites/suite.go", + "package suites\n" + "\n" + "import \"context\"\n" + "\n" + "var registered []string\n" + "\n" + "func init() { registered = append(registered, \"volumes\") }\n" + "\n" + "func wrap(fn func(context.Context)) { _ = fn }\n" + "\n" + "func DefineTests(ctx context.Context) {\n" + "\tinit := func(ctx context.Context) {\n" + "\t\t_ = ctx\n" + "\t}\n" + "\tinit(ctx)\n" + "\twrap(init)\n" + "}\n" + "\n" + "func Sum(init func() int) int {\n" + "\treturn init()\n" + "}\n"); + write_temp_file(tmp, "watcher/watcher.go", + "package watcher\n" + "\n" + "type Watcher struct{ ready bool }\n" + "\n" + "func NewWatcher() *Watcher { return &Watcher{} }\n" + "\n" + "func (w *Watcher) init() { w.ready = true }\n" + "\n" + "func (w *Watcher) Start() {\n" + "\tw.init()\n" + "}\n" + "\n" + "func Boot() {\n" + "\tw := NewWatcher()\n" + "\tw.init()\n" + "}\n"); + write_temp_file(tmp, "boot/init/init.go", + "// Package init installs defaults for its importers.\n" + "package init\n" + "\n" + "func init() { setup() }\n" + "\n" + "func setup() {}\n"); + for (int i = 0; i < pad_files; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "pad/filler%d.go", i); + snprintf(body, sizeof(body), "package pad\n\nfunc filler%d() int { return %d }\n", i, i); + write_temp_file(tmp, name, body); + } +} + +static bool bare_init_qn_ends_with(const char *qn, const char *suffix) { + size_t ql = strlen(qn); + size_t sl = strlen(suffix); + return ql >= sl && strcmp(qn + ql - sl, suffix) == 0; +} + +/* Resolver edges into any node named `init` whose source is not one of the + * two selector controls. Every such edge in the fixture binds a bare `init`, + * which in Go is always a local name. */ +static int go_bare_init_false_edges(cbm_store_t *s, const char *project) { + static const char *const types[] = {"CALLS", "USAGE", "WRITES", "READS", "CALL_REFERENCE"}; + int hits = 0; + for (size_t t = 0; t < sizeof(types) / sizeof(types[0]); t++) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + cbm_store_find_edges_by_type(s, project, types[t], &edges, &edge_count); + for (int i = 0; i < edge_count; i++) { + cbm_node_t src = {0}; + cbm_node_t tgt = {0}; + if (cbm_store_find_node_by_id(s, edges[i].source_id, &src) == CBM_STORE_OK && + cbm_store_find_node_by_id(s, edges[i].target_id, &tgt) == CBM_STORE_OK && + src.qualified_name && tgt.name && strcmp(tgt.name, "init") == 0 && + !bare_init_qn_ends_with(src.qualified_name, ".watcher.Watcher.Start") && + !bare_init_qn_ends_with(src.qualified_name, ".watcher.Boot")) { + printf(" bare init bound: %s %s -> %s\n", types[t], src.qualified_name, + tgt.qualified_name ? tgt.qualified_name : "?"); + hits++; + } + cbm_node_free_fields(&src); + cbm_node_free_fields(&tgt); + } + if (edges) { + cbm_store_free_edges(edges, edge_count); + } + } + return hits; +} + +static int assert_go_bare_init_edges(cbm_store_t *s, const char *project) { + /* The decoys exist, so a zero below is a refused binding and not an + * absent target: two package inits and the Watcher method. */ + cbm_node_t *inits = NULL; + int ic = 0; + cbm_store_find_nodes_by_name(s, project, "init", &inits, &ic); + int functions = 0; + int methods = 0; + for (int i = 0; i < ic; i++) { + functions += inits[i].label && strcmp(inits[i].label, "Function") == 0; + methods += inits[i].label && strcmp(inits[i].label, "Method") == 0; + } + cbm_store_free_nodes(inits, ic); + ASSERT_EQ(functions, 2); + ASSERT_EQ(methods, 1); + /* NEGATIVE: no bare `init` (closure call, closure write, closure passed + * by value, parameter call, `package init`) binds a package init or the + * unrelated method. */ + ASSERT_EQ(go_bare_init_false_edges(s, project), 0); + /* CONTROL: a selector call `w.init()` is not bare and still resolves to + * the method, from a method body and from a free function. */ + ASSERT_GTE( + go_calls_between(s, project, ".watcher.Watcher.Start", ".watcher.Watcher.init", NULL), 1); + ASSERT_GTE(go_calls_between(s, project, ".watcher.Boot", ".watcher.Watcher.init", NULL), 1); + return 0; +} + +/* R45 (follow-up to #1915): a bare Go `init` never binds a package init or, + * by name alone, an init method (cbm_go_suppress_bare_init_bind). */ +TEST(pipeline_go_bare_init_binds_nothing) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_bare_init_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_bare_init_fixture(tmp, 0); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_bare_init.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_EQ(assert_go_bare_init_edges(s, project), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +/* Parallel twin: >= MIN_FILES_FOR_PARALLEL (50) files plus CBM_WORKERS=4 + * take the parallel resolver, which carries its own copy of each guard. */ +TEST(pipeline_go_bare_init_binds_nothing_parallel) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_bare_init_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_bare_init_fixture(tmp, 52); + + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_bare_init_par.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_EQ(assert_go_bare_init_edges(s, project), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + +TEST(pipeline_go_parallel_field_hint_requires_owner_segment) { + /* #1927: try_field_type_hint (parallel resolver only) matched the hinted + * type name as a raw SUBSTRING of the candidate QN. A single-letter + * receiver — `f.Close()` on an *os.File — hints "F", and "F" is a + * substring of "…FileStore.Close", so a stdlib call was rebound to an + * arbitrary project method at PP_FIELD_HINT_CONF and presented as + * field_type_hint (the one weak strategy the Go selector guard trusts). + * The hint must fire only when the candidate's OWNING segment — the + * dot-segment immediately before the method — EQUALS the hinted type; + * otherwise the resolution stays a weak short-name match and the Go + * guard (#1906) drops it. >= 50 files forces pass_parallel.c, the only + * path that runs try_field_type_hint(). */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_fth_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "go.mod", "module example.com/fxhint\n\ngo 1.22\n"); + /* Two project methods named Close → the registry resolves f.Close() as an + * ambiguous short-name match (candidate_count 2), the shape the hint + * upgrades. FileStore's QN carries the capital F the buggy substring + * latches onto; Conn is the second candidate. */ + write_temp_file(tmp, "filestore/filestore.go", + "package filestore\n" + "\n" + "type FileStore struct{ open bool }\n" + "\n" + "func (fs *FileStore) Close() {\n" + "\tfs.open = false\n" + "}\n"); + write_temp_file(tmp, "conn/conn.go", + "package conn\n" + "\n" + "type Conn struct{ live bool }\n" + "\n" + "func (cn *Conn) Close() {\n" + "\tcn.live = false\n" + "}\n"); + /* Two project methods named Find → same ambiguity, but here the receiver + * variable IS named after its type (`finder`), the case the hint exists + * for: the owning segment of Finder.Find equals the hinted "Finder". */ + write_temp_file(tmp, "finder/finder.go", + "package finder\n" + "\n" + "type Finder struct{ n int }\n" + "\n" + "func NewFinder() *Finder { return &Finder{n: 1} }\n" + "\n" + "func (fi *Finder) Find(id int) int {\n" + "\treturn fi.n + id\n" + "}\n"); + write_temp_file(tmp, "locator/locator.go", + "package locator\n" + "\n" + "type Locator struct{ n int }\n" + "\n" + "func (lo *Locator) Find(id int) int {\n" + "\treturn lo.n - id\n" + "}\n"); + write_temp_file(tmp, "app/app.go", + "package app\n" + "\n" + "import (\n" + "\t\"os\"\n" + "\n" + "\txf \"example.com/fxhint/finder\"\n" + ")\n" + "\n" + "func Lookup(path string) int64 {\n" + "\tf, err := os.Open(path)\n" + "\tif err != nil {\n" + "\t\treturn 0\n" + "\t}\n" + "\tdefer f.Close()\n" + "\tst, err := f.Stat()\n" + "\tif err != nil {\n" + "\t\treturn 0\n" + "\t}\n" + "\treturn st.Size()\n" + "}\n" + "\n" + "func UseFinder(id int) int {\n" + "\tfinder := xf.NewFinder()\n" + "\treturn finder.Find(id)\n" + "}\n"); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "pad/filler%d.go", i); + snprintf(body, sizeof(body), "package pad\n\nfunc filler%d() int { return %d }\n", i, i); + write_temp_file(tmp, name, body); + } + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_fth.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + /* (1) Reproduce-first: RED before the fix. The substring hint rebinds the + * stdlib f.Close() to FileStore.Close ("F" ⊂ QN) at high confidence and + * the Go guard keeps it. With segment equality the upgrade is refused, + * the match stays suffix_match, and the Go guard drops it. */ + ASSERT_FALSE(cross_file_call_exists(s, project, "Lookup", "Close")); + /* (2) The intended hint case survives: `finder` names its type, so the + * owning segment of Finder.Find equals the hint. */ + ASSERT_TRUE(cross_file_call_exists(s, project, "UseFinder", "Find")); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +static int count_calls_with(cbm_store_t *s, const char *project, const char *caller_suffix, + const char *callee_suffix, const char *fragment); + +TEST(pipeline_csharp_parallel_field_hint_requires_owner_segment) { + /* #1927, non-Go twin. Go no longer runs try_field_type_hint (e554a2ab), + * but every other language on the parallel path still does, and C# is the + * hint's intended consumer (it strips the _ / m_ field prefixes). C# + * method names are PascalCase, so a one-letter receiver hints a letter + * that every candidate QN already contains in the METHOD name: `w.Write()` + * hints "W", and a raw substring test accepts "...FileStore.Write" and + * "...Conn.Write" alike, whichever the registry lists first. The hint must + * bind only a candidate whose OWNING segment equals the hinted type. + * >= 50 files forces pass_parallel.c, the only path with the hint. */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_cs_fth_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + /* Two project methods named Write: an untyped receiver resolves as an + * ambiguous short-name match (candidate_count 2), the shape the hint + * upgrades. */ + write_temp_file(tmp, "Storage/FileStore.cs", + "namespace Storage {\n" + " public class FileStore {\n" + " private int written;\n" + " public void Write(int v) { written += v; }\n" + " public void Put(int v) { written = v; }\n" + " }\n" + "}\n"); + write_temp_file(tmp, "Net/Conn.cs", + "namespace Net {\n" + " public class Conn {\n" + " private int sent;\n" + " public void Write(int v) { sent += v; }\n" + " }\n" + "}\n"); + /* Two project methods named Put, one owned by Store: the receiver `store` + * names its type, the case the hint exists for. FileStore's QN also + * contains "Store", so only segment equality picks Store.Put whatever + * the candidate order. */ + write_temp_file(tmp, "Repo/Store.cs", + "namespace Repo {\n" + " public class Store {\n" + " private int held;\n" + " public void Put(int v) { held = v; }\n" + " }\n" + "}\n"); + write_temp_file(tmp, "App/Worker.cs", + "namespace App {\n" + " public class Worker {\n" + " public void Log(System.IO.StreamWriter w) {\n" + " w.Write(1);\n" + " }\n" + " public void Keep() {\n" + " var store = Services.Resolve();\n" + " store.Put(2);\n" + " }\n" + " }\n" + "}\n"); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[192]; + snprintf(name, sizeof(name), "Pad/Filler%d.cs", i); + snprintf(body, sizeof(body), + "namespace Pad {\n public class Filler%d {\n" + " public int Get%d() { return %d; }\n }\n}\n", + i, i, i); + write_temp_file(tmp, name, body); + } + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/cs_fth.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + /* (1) Reproduce-first: RED before the fix. "W" is a substring of every + * Write candidate, so the old hint upgraded the StreamWriter call to an + * arbitrary project Write at PP_FIELD_HINT_CONF. No owner segment is "W", + * so the fixed hint must not fire at all. */ + ASSERT_FALSE(cross_file_call_has_strategy(s, project, "Log", "Write", "field_type_hint")); + /* (2) The intended hint case binds the owner it names, never FileStore. */ + ASSERT_EQ(count_calls_with(s, project, ".Keep", ".Store.Put", "field_type_hint"), 1); + ASSERT_EQ(count_calls_with(s, project, ".Keep", ".FileStore.Put", NULL), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + /* Fixture for the #1928 cross-language reference-guard probes (sequential and * parallel twins). pad_files > 0 adds filler files to push the index over the * parallel-pipeline threshold, since USAGE/WRITES/READS have one resolver per @@ -14696,6 +16007,208 @@ TEST(pipeline_route_prefix_bridge_is_order_free_and_root_bridges_nothing) { PASS(); } +/* Route nodes whose qualified name contains `part`. */ +static int route_nodes_containing(cbm_store_t *s, const char *project, const char *part) { + cbm_node_t *routes = NULL; + int route_count = 0; + if (cbm_store_find_nodes_by_label(s, project, "Route", &routes, &route_count) != CBM_STORE_OK) { + return -1; + } + int matches = 0; + for (int i = 0; i < route_count; i++) { + if (routes[i].qualified_name && strstr(routes[i].qualified_name, part)) { + matches++; + } + } + if (routes) { + cbm_store_free_nodes(routes, route_count); + } + return matches; +} + +/* CALLS edges out of the function `source_name` into a Route node: the edge a + * route registration emits beside its Route (an HTTP client call's edge to a + * Route is HTTP_CALLS instead). */ +static int calls_into_route_count(cbm_store_t *s, const char *project, const char *source_name) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + if (cbm_store_find_edges_by_type(s, project, "CALLS", &edges, &edge_count) != CBM_STORE_OK) { + return -1; + } + int matches = 0; + for (int i = 0; i < edge_count; i++) { + cbm_node_t source = {0}; + cbm_node_t target = {0}; + int source_ok = cbm_store_find_node_by_id(s, edges[i].source_id, &source) == CBM_STORE_OK; + int target_ok = cbm_store_find_node_by_id(s, edges[i].target_id, &target) == CBM_STORE_OK; + if (source_ok && target_ok && source.name && target.label && + strcmp(source.name, source_name) == 0 && strcmp(target.label, "Route") == 0) { + matches++; + } + cbm_node_free_fields(&source); + cbm_node_free_fields(&target); + } + if (edges) { + cbm_store_free_edges(edges, edge_count); + } + return matches; +} + +/* Issue #2537: since Go 1.22 a ServeMux pattern may name a method and a host + * before the path ("POST /v1/payments", "GET api.example.com/v1/x"). Such a + * registration minted no Route and no HANDLES edge, because every registration + * gate wanted the first argument to open with a slash, while the same file + * written with "/v1/payments" had both. The method now comes from the pattern + * and a host is dropped from the path, in the sequential and the parallel pass + * alike. Only Go reads that grammar: a Python registration of "GET /py" is still + * no route. Only a ServeMux registration (Handle / HandleFunc) is read with it: + * another route-verb call names no pattern and mints no Route, neither + * `st.Get("users/123")` on a project method (which keeps its CALLS edge) nor + * `c.Get("https://api.example.com/v1/x")` on a client. */ +TEST(pipeline_go_servemux_method_patterns_issue2537) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_servemux_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "go.mod", "module example.com/srv\n\ngo 1.22\n"); + write_temp_file(tmp, "main.go", + "package main\n" + "\n" + "import \"net/http\"\n" + "\n" + "func createPayment(w http.ResponseWriter, r *http.Request) {}\n" + "func getPayment(w http.ResponseWriter, r *http.Request) {}\n" + "func hostOnly(w http.ResponseWriter, r *http.Request) {}\n" + "func legacyList(w http.ResponseWriter, r *http.Request) {}\n" + "\n" + "type Store struct{ m map[string]string }\n" + "\n" + "func (s *Store) Get(k string) string { return s.m[k] }\n" + "\n" + "func lookup(st *Store) string { return st.Get(\"users/123\") }\n" + "\n" + "func fetchRemote(c *http.Client) {\n" + "\t_, _ = c.Get(\"https://api.example.com/v1/x\")\n" + "}\n" + "\n" + "func main() {\n" + "\tmux := http.NewServeMux()\n" + "\tmux.HandleFunc(\"POST /v1/payments\", createPayment)\n" + "\tmux.HandleFunc(\"GET /v1/payments/{id}\", getPayment)\n" + "\tmux.HandleFunc(\"api.example.com/v1/hosted\", hostOnly)\n" + "\tmux.HandleFunc(\"/v1/legacy\", legacyList)\n" + "\t_ = http.ListenAndServe(\":8080\", mux)\n" + "}\n"); + write_temp_file(tmp, "web/site.py", + "from flask import Flask\n" + "app = Flask(__name__)\n" + "\n" + "def py_view():\n" + " return \"ok\"\n" + "\n" + "app.add_url_rule(\"GET /py\", \"py_view\", py_view)\n"); + /* past MIN_FILES_FOR_PARALLEL, so the threaded run takes the parallel pass */ + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "pad/padding%d.go", i); + snprintf(body, sizeof(body), "package pad\n\nfunc Padding%d() int { return %d }\n", i, i); + write_temp_file(tmp, name, body); + } + const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + char *saved_single = old_single ? strdup(old_single) : NULL; + const char *old_workers = getenv("CBM_WORKERS"); + char *saved_workers = old_workers ? strdup(old_workers) : NULL; + int run_rc[2] = {-1, -1}; + bool opened[2] = {false, false}; + int post[2] = {-1, -1}; + int get_id[2] = {-1, -1}; + int any_post[2] = {-1, -1}; + int host[2] = {-1, -1}; + int legacy[2] = {-1, -1}; + int py_any[2] = {-1, -1}; + int py_get[2] = {-1, -1}; + int store_calls[2] = {-1, -1}; + int store_route[2] = {-1, -1}; + int lookup_reg[2] = {-1, -1}; + int remote_route[2] = {-1, -1}; + int remote_reg[2] = {-1, -1}; + for (int mode = 0; mode < 2; mode++) { + if (mode == 0) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/servemux_%d.db", tmp, mode); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + run_rc[mode] = p ? cbm_pipeline_run(p) : -1; + const char *project = p ? cbm_pipeline_project_name(p) : NULL; + cbm_store_t *s = cbm_store_open_path(db_path); + opened[mode] = s != NULL; + if (s && project) { + post[mode] = + handles_to_route(s, project, "createPayment", "__route__POST__/v1/payments"); + get_id[mode] = + handles_to_route(s, project, "getPayment", "__route__GET__/v1/payments/{}"); + any_post[mode] = + handles_to_route(s, project, "createPayment", "__route__ANY__/v1/payments"); + host[mode] = handles_to_route(s, project, "hostOnly", "__route__ANY__/v1/hosted"); + legacy[mode] = handles_to_route(s, project, "legacyList", "__route__ANY__/v1/legacy"); + py_any[mode] = handles_to_route(s, project, "py_view", "__route__ANY__/py"); + py_get[mode] = handles_to_route(s, project, "py_view", "__route__GET__/py"); + store_calls[mode] = named_edge_count(s, project, "CALLS", "lookup", "Get"); + store_route[mode] = route_nodes_containing(s, project, "__route__GET__/123"); + lookup_reg[mode] = calls_into_route_count(s, project, "lookup"); + remote_route[mode] = route_nodes_containing(s, project, "//api.example.com"); + remote_reg[mode] = calls_into_route_count(s, project, "fetchRemote"); + } + if (s) { + cbm_store_close(s); + } + cbm_pipeline_free(p); + } + if (saved_single) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1); + free(saved_single); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + } + if (saved_workers) { + cbm_setenv("CBM_WORKERS", saved_workers, 1); + free(saved_workers); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + for (int mode = 0; mode < 2; mode++) { + ASSERT_EQ(run_rc[mode], 0); + ASSERT_TRUE(opened[mode]); + /* the method is the pattern's, not ANY */ + ASSERT_EQ(post[mode], 1); + ASSERT_EQ(get_id[mode], 1); + ASSERT_EQ(any_post[mode], 0); + /* a host names no method and is not part of the path */ + ASSERT_EQ(host[mode], 1); + /* control: a plain path is unchanged */ + ASSERT_EQ(legacy[mode], 1); + /* another language's "GET /py" is not read as a ServeMux pattern */ + ASSERT_EQ(py_any[mode], 0); + ASSERT_EQ(py_get[mode], 0); + /* a project method named Get keeps its CALLS edge; "users/123" is no + * host + path */ + ASSERT_EQ(store_calls[mode], 1); + ASSERT_EQ(store_route[mode], 0); + ASSERT_EQ(lookup_reg[mode], 0); + /* a client Get on a URL registers no route: "https:" is no host */ + ASSERT_EQ(remote_route[mode], 0); + ASSERT_EQ(remote_reg[mode], 0); + } + PASS(); +} + TEST(pipeline_swift_http_call_makes_route_issue1892) { char tmp[256]; snprintf(tmp, sizeof(tmp), "/tmp/cbm_swifthttp_XXXXXX"); @@ -14939,6 +16452,203 @@ TEST(pipeline_typed_http_call_has_no_arg_url_twin_sequential) { return typed_http_no_url_twin_case(0); } +/* Issue #2768: Angular HttpClient calls (this.http.get, this.httpClient.post, + * this.http.delete) emit HTTP_CALLS edges typed with the method verb, and are + * never misclassified as route registrations. */ +static int angular_http_client_typed_case(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_angular_http_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "src/item.service.ts", + "import { Injectable } from '@angular/core';\n" + "import { HttpClient } from '@angular/common/http';\n" + "\n" + "@Injectable({ providedIn: 'root' })\n" + "export class ItemService {\n" + " constructor(private http: HttpClient) {}\n" + "\n" + " getItems() {\n" + " return this.http.get('/items/list');\n" + " }\n" + "\n" + " getItem(id: string) {\n" + " return this.http.get(`/items/${id}`);\n" + " }\n" + "\n" + " createItem(item: any) {\n" + " return this.http.post('/items', item);\n" + " }\n" + "\n" + " deleteItem(id: string) {\n" + " return this.http.delete(`/items/${id}`);\n" + " }\n" + "\n" + " updateItem(id: string, item: any) {\n" + " return this.http\n" + " .put(`/items/${id}`, item);\n" + " }\n" + "}\n"); + + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/filler%d.ts", i); + snprintf(body, sizeof(body), "export function filler%d(): number {\n return %d;\n}\n", i, + i); + write_temp_file(tmp, name, body); + } + + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/angular_http.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + /* Assert exact HTTP_CALLS with proper methods */ + ASSERT_EQ(http_calls_to_route(s, project, "getItems", "__route__GET__/items/list"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "getItem", "__route__GET__/items/{}"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "createItem", "__route__POST__/items"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "deleteItem", "__route__DELETE__/items/{}"), 1); + /* A chain wrapped before the verb (prettier style) is the same client call */ + ASSERT_EQ(http_calls_to_route(s, project, "updateItem", "__route__PUT__/items/{}"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "updateItem", "__route__ANY__"), 0); + + /* Assert no ANY twins */ + ASSERT_EQ(http_calls_to_route(s, project, "getItems", "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, "getItem", "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, "createItem", "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, "deleteItem", "__route__ANY__"), 0); + + /* Angular service must NOT emit HANDLES edges (not a server route registration) */ + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "HANDLES"), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + +TEST(pipeline_angular_http_client_typed_parallel) { + return angular_http_client_typed_case(52); +} + +TEST(pipeline_angular_http_client_typed_sequential) { + return angular_http_client_typed_case(0); +} + +/* #2768: the arg-URL heuristic (detect_url_in_args, via=arg_url) covers calls + * no service pattern knows, and it now types the edge with the verb the callee + * names instead of minting a method-less __route__ANY__ route. Two wrapper + * shapes reach it on the parallel resolver: + * - a project wrapper method whose verb is not a route-registration suffix + * (`this.api.head('/items/health')`, `.options(...)`), resolved to the + * project method, so the URL may be the first argument; + * - a route-verb wrapper (`.get`) whose URL is not route shaped for the + * registration reader: here a keyword it does not read as a path + * (`self.session.get(resource="/items/list")`). + * Parallel only: the sequential resolver has no arg-URL heuristic (#2848). */ +TEST(pipeline_arg_url_wrapper_call_typed_with_verb_issue2768) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_arg_url_verb_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "src/items.api.ts", + "export class ItemsApi {\n" + " head(path: string): Promise {\n" + " return fetch(path, { method: 'HEAD' });\n" + " }\n" + " options(path: string): Promise {\n" + " return fetch(path, { method: 'OPTIONS' });\n" + " }\n" + "}\n"); + write_temp_file(tmp, "src/item.service.ts", + "import { ItemsApi } from './items.api';\n" + "export class ItemService {\n" + " constructor(private readonly api: ItemsApi) {}\n" + " pingItems() {\n" + " return this.api.head('/items/health');\n" + " }\n" + " probeItems() {\n" + " return this.api.options('/items/list');\n" + " }\n" + "}\n"); + write_temp_file(tmp, "src/reader.py", + "class ItemsSession:\n" + " def get(self, *args, **kwargs):\n" + " return args\n" + "\n" + "class Reader:\n" + " def __init__(self):\n" + " self.session = ItemsSession()\n" + "\n" + " def read_items(self):\n" + " return self.session.get(resource=\"/items/list\")\n"); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/filler%d.ts", i); + snprintf(body, sizeof(body), "export function filler%d(): number {\n return %d;\n}\n", i, + i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/arg_url_verb.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + static const struct { + const char *caller; + const char *typed_route; + } wrapped[] = {{"pingItems", "__route__HEAD__/items/health"}, + {"probeItems", "__route__OPTIONS__/items/list"}, + {"read_items", "__route__GET__/items/list"}}; + for (size_t i = 0; i < sizeof(wrapped) / sizeof(wrapped[0]); i++) { + /* The edge carries the callee's verb ... */ + ASSERT_EQ(http_calls_to_route(s, project, wrapped[i].caller, wrapped[i].typed_route), 1); + /* ... and is the call site's only HTTP_CALLS edge: no ANY twin. */ + ASSERT_EQ(http_calls_to_route(s, project, wrapped[i].caller, "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, wrapped[i].caller, "__route__"), 1); + } + /* Not a route registration */ + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "HANDLES"), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + /* #706 / #1147 end to end: a URL composed from module constants reaches the * HTTP classifier, so the client call forms an HTTP_CALLS edge. Python * `API_URL = API_BASE_URL + "/orders/create"` (env-derived base, literal path @@ -16976,6 +18686,53 @@ TEST(pipeline_python_init_import_probe_failure_falls_back) { PASS(); } +/* #1910: with no shared Go registry (the incremental route can pass NULL + * cross_registries) the dispatch takes the per-file fallback, which must name + * each init() caller from the file path exactly as the def extractor does. */ +TEST(pipeline_go_init_dispatch_fallback_names_init_caller) { + const char *source = "package reg\n\n" + "func init() {\n\tc := NewCodec()\n\tc.Register()\n}\n\n" + "func init() {\n\tc := NewCodec()\n\tc.Register()\n}\n"; + CBMLSPDef defs[] = { + {.qualified_name = "test.reg.NewCodec", + .short_name = "NewCodec", + .label = "Function", + .def_module_qn = "test.reg", + .return_types = "*Codec", + .lang = CBM_LANG_GO}, + {.qualified_name = "test.reg.Codec", + .short_name = "Codec", + .label = "Class", + .def_module_qn = "test.reg", + .lang = CBM_LANG_GO}, + {.qualified_name = "test.reg.Codec.Register", + .short_name = "Register", + .label = "Method", + .def_module_qn = "test.reg", + .receiver_type = "test.reg.Codec", + .lang = CBM_LANG_GO}, + }; + CBMFileResult result = {0}; + cbm_arena_init(&result.arena); + cbm_pxc_dispatch_file(CBM_LANG_GO, &result, source, (int)strlen(source), "reg/b.go", "test.reg", + NULL, NULL, defs, 3, NULL, NULL, 0, NULL, NULL); + int first = 0; + int second = 0; + for (int i = 0; i < result.resolved_calls.count; i++) { + const CBMResolvedCall *rc = &result.resolved_calls.items[i]; + if (!rc->caller_qn || !rc->callee_qn || rc->confidence <= 0 || + strcmp(rc->callee_qn, "test.reg.Codec.Register") != 0) { + continue; + } + first += strcmp(rc->caller_qn, "test.reg.init#b.go:1") == 0; + second += strcmp(rc->caller_qn, "test.reg.init#b.go:2") == 0; + } + cbm_arena_destroy(&result.arena); + ASSERT_EQ(first, 1); + ASSERT_EQ(second, 1); + PASS(); +} + /* #1277: typed instance fields of a Python class imported from another file. * Counts CALLS edges caller -> callee (QN suffixes) whose properties name an * LSP strategy. */ @@ -18688,6 +20445,285 @@ TEST(pipeline_python_nested_def_local_after_incremental_issue1548) { PASS(); } +/* A Java record's implicit accessors are GENERATED members (#2295): Method + * nodes marked `generated: record`, registered by qualified name only, as a + * Python function's locals are. The type-aware resolver binds `p.y()` on a + * Point to Point.y; no short-name guess may land on a member the source never + * spells. `far` calls y() on a type outside the project, so the project-wide + * name registry is its only route to Point.y. `data` calls x(), whose other + * spelling is Spring's private field: neither the accessor (a name guess) nor + * the field (a Java call never names data) is its target. The record's + * hand-written z() and norm() are source members: no `generated` mark. + * `org.external.y.Vector` is not in the project, and its package segment `y` + * spells the accessor: the import's symbol-name fallback must not bind it + * there (no IMPORTS edge), or `new Vector()` in `make` would follow the import + * onto the accessor (elasticsearch: `import javax.crypto.spec.PBEKeySpec` + * bound a record's `spec()`). `stat` calls, and `refs` passes, the JDK's + * Objects.nonNull: no project callable is its target, and Seen's accessor -- + * the only project callable called nonNull -- must not be taken for it on its + * leaf name alone (elasticsearch: `Objects.nonNull(x)` and + * `filter(Objects::nonNull)` bound a test record's nonNull()). */ +static const char jgen_point_java[] = + "package com.example;\n" + "public record Point(double x, double y, double z) {\n" + " public double z() { return z; }\n" + " public double norm() { return Math.sqrt(x * x + y * y + z * z); }\n" + "}\n"; +static const char jgen_seen_java[] = "package com.example;\n" + "record Seen(boolean nonNull) {}\n"; +static const char jgen_spring_java[] = "package com.example;\n" + "public final class Spring {\n" + " private double x;\n" + " public void put(double v) { this.x = v; }\n" + "}\n"; +static const char jgen_caller_java[] = + "package com.example;\n" + "import java.util.List;\n" + "import java.util.Objects;\n" + "import org.external.y.Vector;\n" + "public final class Caller {\n" + " public double near(Point p) { return p.y(); }\n" + " public double far(Vector v) { return v.y(); }\n" + " public double data(Vector v) { return v.x(); }\n" + " public Object make() { return new Vector(); }\n" + " public boolean stat(Object o) { return Objects.nonNull(o); }\n" + " public long refs(List xs) {\n" + " return xs.stream().filter(Objects::nonNull).count();\n" + " }\n" + "}\n"; + +/* The id of the one `label` node called `name` in `file_path`; 0 when there is + * none or more than one. `generated` (optional) receives whether its + * properties carry the `generated: record` mark. */ +static int64_t jgen_node(cbm_store_t *s, const char *project, const char *name, const char *label, + const char *file_path, bool *generated) { + cbm_node_t *nodes = NULL; + int count = 0; + int64_t id = 0; + int hits = 0; + bool marked = false; + if (cbm_store_find_nodes_by_name(s, project, name, &nodes, &count) != CBM_STORE_OK) { + return 0; + } + for (int i = 0; i < count; i++) { + if (nodes[i].label && strcmp(nodes[i].label, label) == 0 && nodes[i].file_path && + strcmp(nodes[i].file_path, file_path) == 0) { + hits++; + id = nodes[i].id; + marked = nodes[i].properties_json && + strstr(nodes[i].properties_json, "\"generated\":\"record\"") != NULL; + } + } + cbm_store_free_nodes(nodes, count); + if (generated) { + *generated = marked; + } + return hits == 1 ? id : 0; +} + +/* CALLS edges out of `source_id` into `target_id`, or (target_id 0) into any + * node called `target_name`. */ +static int jgen_calls(cbm_store_t *s, int64_t source_id, int64_t target_id, + const char *target_name) { + cbm_edge_t *edges = NULL; + int ec = 0; + int n = 0; + if (cbm_store_find_edges_by_source_type(s, source_id, "CALLS", &edges, &ec) != CBM_STORE_OK) { + return -1; + } + for (int i = 0; i < ec; i++) { + if (target_id) { + n += edges[i].target_id == target_id; + continue; + } + cbm_node_t t = {0}; + if (cbm_store_find_node_by_id(s, edges[i].target_id, &t) == CBM_STORE_OK && t.name && + strcmp(t.name, target_name) == 0) { + n++; + } + cbm_node_free_fields(&t); + } + if (edges) { + cbm_store_free_edges(edges, ec); + } + return n; +} + +/* Edges of `type` into `target_id`. */ +static int jgen_edges_into(cbm_store_t *s, int64_t target_id, const char *type) { + cbm_edge_t *edges = NULL; + int ec = 0; + if (cbm_store_find_edges_by_target_type(s, target_id, type, &edges, &ec) != CBM_STORE_OK) { + return -1; + } + if (edges) { + cbm_store_free_edges(edges, ec); + } + return ec; +} + +/* One fact of jgen_check: reported, counted, and the check goes on, so a run + * shows every fact that does not hold. */ +#define JGEN_EXPECT(cond) \ + do { \ + if (!(cond)) { \ + printf(" FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + failed++; \ + } \ + } while (0) + +/* Every fact the fixture pins, on one indexed store; the number that fail. */ +static int jgen_check(cbm_store_t *s, const char *project) { + int failed = 0; + bool gen_x = false; + bool gen_y = false; + bool gen_z = true; + bool gen_norm = true; + bool gen_nn = false; + int64_t acc_nn = jgen_node(s, project, "nonNull", "Method", "Seen.java", &gen_nn); + int64_t acc_x = jgen_node(s, project, "x", "Method", "Point.java", &gen_x); + int64_t acc_y = jgen_node(s, project, "y", "Method", "Point.java", &gen_y); + int64_t own_z = jgen_node(s, project, "z", "Method", "Point.java", &gen_z); + int64_t norm = jgen_node(s, project, "norm", "Method", "Point.java", &gen_norm); + int64_t near_m = jgen_node(s, project, "near", "Method", "Caller.java", NULL); + int64_t far_m = jgen_node(s, project, "far", "Method", "Caller.java", NULL); + int64_t data = jgen_node(s, project, "data", "Method", "Caller.java", NULL); + int64_t make = jgen_node(s, project, "make", "Method", "Caller.java", NULL); + int64_t stat = jgen_node(s, project, "stat", "Method", "Caller.java", NULL); + int64_t refs = jgen_node(s, project, "refs", "Method", "Caller.java", NULL); + JGEN_EXPECT(acc_x > 0 && acc_y > 0 && own_z > 0 && norm > 0 && acc_nn > 0); + JGEN_EXPECT(near_m > 0 && far_m > 0 && data > 0 && make > 0 && stat > 0 && refs > 0); + /* The compiler's members are marked; the hand-written ones are not. */ + JGEN_EXPECT(gen_x); + JGEN_EXPECT(gen_y); + JGEN_EXPECT(gen_nn); + JGEN_EXPECT(!gen_z); + JGEN_EXPECT(!gen_norm); + /* Bound by qualified name through the receiver's type ... */ + JGEN_EXPECT(jgen_calls(s, near_m, acc_y, NULL) == 1); + /* ... and never by a short-name guess. */ + JGEN_EXPECT(jgen_calls(s, far_m, acc_y, NULL) == 0); + JGEN_EXPECT(jgen_calls(s, far_m, 0, "y") == 0); + /* Neither the accessor nor Spring's field `x`. */ + JGEN_EXPECT(jgen_calls(s, data, 0, "x") == 0); + /* No import names a generated member, so nothing follows one onto it. */ + JGEN_EXPECT(jgen_edges_into(s, acc_y, "IMPORTS") == 0); + JGEN_EXPECT(jgen_calls(s, make, 0, "y") == 0); + /* The JDK's Objects.nonNull is not Seen's accessor, called or referenced. */ + JGEN_EXPECT(jgen_calls(s, stat, acc_nn, NULL) == 0); + JGEN_EXPECT(jgen_edges_into(s, acc_nn, "CALL_REFERENCE") == 0); + JGEN_EXPECT(jgen_edges_into(s, acc_nn, "USAGE") == 0); + return failed; +} +#undef JGEN_EXPECT + +static int jgen_write_fixture(const char *tmp) { + write_temp_file(tmp, "Point.java", jgen_point_java); + write_temp_file(tmp, "Seen.java", jgen_seen_java); + write_temp_file(tmp, "Spring.java", jgen_spring_java); + write_temp_file(tmp, "Caller.java", jgen_caller_java); + return 0; +} + +TEST(pipeline_java_record_accessors_generated_qn_only) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_java_gen_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + jgen_write_fixture(tmp); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/java_gen.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + int rc = jgen_check(s, cbm_pipeline_project_name(p)); + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + PASS(); +} + +/* The same fixture through pass_parallel.c: >= 50 files and CBM_WORKERS=4. */ +TEST(pipeline_java_record_accessors_generated_qn_only_parallel) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_java_gen_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + jgen_write_fixture(tmp); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "Filler%d.java", i); + snprintf(body, sizeof(body), + "package com.example;\nclass Filler%d { int f() { return %d; } }\n", i, i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/java_gen_par.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int run = p ? cbm_pipeline_run(p) : -1; + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + ASSERT_NOT_NULL(p); + ASSERT_EQ(run, 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + int rc = jgen_check(s, cbm_pipeline_project_name(p)); + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + PASS(); +} + +/* An incremental re-index seeds its registry from the stored graph: the + * untouched Point.java's accessors come back from their nodes, and must stay + * out of the short-name index exactly as in the full index while the touched + * Caller.java is re-resolved. */ +TEST(pipeline_java_record_accessors_generated_qn_only_after_incremental) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_java_gen_incr_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + jgen_write_fixture(tmp); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/java_gen_incr.db", tmp); + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + cbm_pipeline_free(p1); + + char caller_path[512]; + snprintf(caller_path, sizeof(caller_path), "%s/Caller.java", tmp); + ASSERT_EQ(th_append_file(caller_path, "\n// touched: re-resolve Caller.java incrementally\n"), + 0); + + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + int rc = jgen_check(s, cbm_pipeline_project_name(p2)); + cbm_store_close(s); + cbm_pipeline_free(p2); + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + PASS(); +} + /* As pyn_edge, and the edge's properties also contain `fragment`. */ static bool pyn_edge_with(cbm_store_t *s, const char *project, const char *src_rel, const char *tgt_rel, const char *type, const char *fragment) { @@ -28880,7 +30916,40 @@ TEST(pipeline_calls_args_overlong_argument_stops_cleanly) { PASS(); } +/* Discovery must choose the C++ grammar before the pipeline extracts .inc + * symbols; a direct extractor-only test would miss that routing failure. */ +TEST(pipeline_inc_c_family_fragments) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "%s/cbm_inc_cpp_XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_temp_file(tmp, "runtime.inc", + "#define SAMPLE_CALLBACK(T) T\n" + "extern \"C\" int sample_export() { return 0; }\n" + "static SAMPLE_CALLBACK(int) sample_callback() { return sample_export(); }\n"); + write_temp_file(tmp, "main.cpp", "#include \"runtime.inc\"\n"); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/graph.db", tmp); + const cbm_index_mode_t modes[] = {CBM_MODE_FAST, CBM_MODE_FULL}; + for (size_t i = 0; i < sizeof(modes) / sizeof(modes[0]); i++) { + cbm_pipeline_t *pipeline = cbm_pipeline_new(tmp, db_path, modes[i]); + ASSERT_NOT_NULL(pipeline); + ASSERT_EQ(cbm_pipeline_run(pipeline), 0); + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + const char *project = cbm_pipeline_project_name(pipeline); + ASSERT_EQ(fixture_node_count(store, project, "runtime.inc", "sample_export", "Function"), + 1); + ASSERT_EQ(fixture_node_count(store, project, "runtime.inc", "sample_callback", "Function"), + 1); + ASSERT_EQ(named_node_count(store, project, "extern"), 0); + cbm_store_close(store); + cbm_pipeline_free(pipeline); + } + th_rmtree(tmp); + PASS(); +} SUITE(pipeline) { + RUN_TEST(pipeline_inc_c_family_fragments); RUN_TEST(pipeline_python_module_callers_sequential); RUN_TEST(pipeline_python_module_callers_parallel); RUN_TEST(pipeline_python_class_body_calls_sequential); @@ -28895,6 +30964,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_init_register_scope_failure_parallel); RUN_TEST(pipeline_python_init_import_filter_precedence); RUN_TEST(pipeline_python_init_import_probe_failure_falls_back); + RUN_TEST(pipeline_go_init_dispatch_fallback_names_init_caller); RUN_TEST(pipeline_issue1527_python_raw_memo_failure_sequential); RUN_TEST(pipeline_issue1527_python_raw_memo_failure_parallel); RUN_TEST(pipeline_issue1527_python_cross_memo_failure_sequential); @@ -28932,6 +31002,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); @@ -29021,6 +31097,13 @@ SUITE(pipeline) { RUN_TEST(pipeline_go_receiver_parallel_suppresses_weak_method_edge); RUN_TEST(pipeline_go_multi_return_receiver_is_typed); RUN_TEST(pipeline_go_multi_return_receiver_is_typed_parallel); + RUN_TEST(pipeline_go_parallel_field_hint_requires_owner_segment); + RUN_TEST(pipeline_csharp_parallel_field_hint_requires_owner_segment); + RUN_TEST(pipeline_go_multi_init_nodes_survive); + RUN_TEST(pipeline_go_init_lsp_edges_per_file); + RUN_TEST(pipeline_go_init_lsp_edges_per_file_parallel); + RUN_TEST(pipeline_go_bare_init_binds_nothing); + RUN_TEST(pipeline_go_bare_init_binds_nothing_parallel); RUN_TEST(pipeline_go_crossfile_type_alias_method); RUN_TEST(pipeline_go_crossfile_type_alias_method_parallel); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c); @@ -29067,10 +31150,14 @@ SUITE(pipeline) { RUN_TEST(pipeline_swift_nested_url_makes_route_issue1892); RUN_TEST(pipeline_swift_http_call_makes_route_issue1892); RUN_TEST(pipeline_route_prefix_bridge_is_order_free_and_root_bridges_nothing); + RUN_TEST(pipeline_go_servemux_method_patterns_issue2537); RUN_TEST(pipeline_native_fetch_parallel_classified_as_http_calls); RUN_TEST(pipeline_local_fetch_shadow_not_classified_as_http); RUN_TEST(pipeline_typed_http_call_has_no_arg_url_twin); RUN_TEST(pipeline_typed_http_call_has_no_arg_url_twin_sequential); + RUN_TEST(pipeline_angular_http_client_typed_parallel); + RUN_TEST(pipeline_angular_http_client_typed_sequential); + RUN_TEST(pipeline_arg_url_wrapper_call_typed_with_verb_issue2768); RUN_TEST(pipeline_url_constant_folding_http_calls_issue706_1147); RUN_TEST(pipeline_folded_topics_preserve_identity_sequential_parallel); RUN_TEST(pipeline_relative_folded_wrapper_url_candidates); @@ -29120,6 +31207,9 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_import_never_binds_a_local_def_issue1548); RUN_TEST(pipeline_python_nested_defs_parallel_issue1548); RUN_TEST(pipeline_python_nested_def_local_after_incremental_issue1548); + RUN_TEST(pipeline_java_record_accessors_generated_qn_only); + RUN_TEST(pipeline_java_record_accessors_generated_qn_only_parallel); + RUN_TEST(pipeline_java_record_accessors_generated_qn_only_after_incremental); RUN_TEST(pipeline_python_local_def_shadows_module_def_issue1548); RUN_TEST(pipeline_python_local_def_shadows_module_def_parallel_issue1548); RUN_TEST(pipeline_python_super_follows_c3_mro); @@ -29471,7 +31561,13 @@ SUITE(pipeline_semantic_manifest_repro) { RUN_TEST(pipeline_full_persist_failure_after_stage_dump_preserves_previous_generation); RUN_TEST(pipeline_incremental_persist_failure_preserves_previous_generation_and_retries); RUN_TEST(pipeline_incremental_successful_publication_preserves_adr); - RUN_TEST(pipeline_full_adr_capture_failure_preserves_previous_generation); + RUN_TEST(pipeline_adr_migrates_from_legacy_graph_row); + RUN_TEST(pipeline_adr_sidecar_is_separate_and_removable); + RUN_TEST(pipeline_adr_half_created_sidecar_falls_back_to_legacy); + RUN_TEST(pipeline_adr_written_during_reindex_survives); + RUN_TEST(pipeline_adr_written_during_delta_reindex_survives); + RUN_TEST(pipeline_adr_migrate_failure_preserves_generation); + RUN_TEST(pipeline_adr_legacy_migrates_before_forced_reindex); RUN_TEST(pipeline_semantic_manifest_rejects_non_directory_root); #ifndef _WIN32 RUN_TEST(pipeline_semantic_manifest_skips_unreadable_subdirectory); diff --git a/tests/test_py_lsp.c b/tests/test_py_lsp.c index c8033992c..429fb6628 100644 --- a/tests/test_py_lsp.c +++ b/tests/test_py_lsp.c @@ -751,6 +751,91 @@ TEST(pylsp_fused_self_attr_chain_via_overlay) { PASS(); } +/* Characterization of the shared Tier-2 build order. Defs register one at a + * time in defs[] order, so a Method seen before its Class auto-registers a + * placeholder receiver type that stays ahead of the Class entry, and a Method + * whose receiver is already known adds no type. Any change to how the build + * probes for known receivers must keep this exact sequence. */ +TEST(pylsp_cross_registry_interleaved_order) { + CBMArena arena; + cbm_arena_init(&arena); + CBMTypeRegistry *base = cbm_py_build_cross_registry(&arena, NULL, 0); + ASSERT_NOT_NULL(base); + int tb = base->type_count, fb = base->func_count; + + CBMLSPDef defs[] = { + {.qualified_name = "t.m.A.m", + .short_name = "m", + .label = "Method", + .receiver_type = "t.m.A", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + {.qualified_name = "t.m.A", + .short_name = "A", + .label = "Class", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + {.qualified_name = "t.m.B", + .short_name = "B", + .label = "Class", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + {.qualified_name = "t.m.B.f", + .short_name = "f", + .label = "Method", + .receiver_type = "t.m.B", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + {.qualified_name = "t.m.C.g", + .short_name = "g", + .label = "Method", + .receiver_type = "t.m.C", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + /* Receiver auto-registered by C.g just above: no second placeholder. */ + {.qualified_name = "t.m.C.h", + .short_name = "h", + .label = "Method", + .receiver_type = "t.m.C", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + /* Non-Python defs are skipped by the builder. */ + {.qualified_name = "t.m.D", + .short_name = "D", + .label = "Class", + .def_module_qn = "t.m", + .lang = CBM_LANG_GO}, + }; + CBMTypeRegistry *reg = + cbm_py_build_cross_registry(&arena, defs, (int)(sizeof(defs) / sizeof(defs[0]))); + ASSERT_NOT_NULL(reg); + + /* Types: placeholder A (from A.m), Class A, Class B, placeholder C. */ + ASSERT_EQ(reg->type_count, tb + 4); + ASSERT_TRUE(reg->types[tb].qualified_name == defs[0].receiver_type); + ASSERT_STR_EQ(reg->types[tb].short_name, "A"); + ASSERT_TRUE(reg->types[tb + 1].qualified_name == defs[1].qualified_name); + ASSERT_TRUE(reg->types[tb + 2].qualified_name == defs[2].qualified_name); + ASSERT_TRUE(reg->types[tb + 3].qualified_name == defs[4].receiver_type); + ASSERT_STR_EQ(reg->types[tb + 3].short_name, "C"); + + /* Funcs keep defs[] order. */ + ASSERT_EQ(reg->func_count, fb + 4); + ASSERT_STR_EQ(reg->funcs[fb].qualified_name, "t.m.A.m"); + ASSERT_STR_EQ(reg->funcs[fb + 1].qualified_name, "t.m.B.f"); + ASSERT_STR_EQ(reg->funcs[fb + 2].qualified_name, "t.m.C.g"); + ASSERT_STR_EQ(reg->funcs[fb + 2].receiver_type, "t.m.C"); + ASSERT_STR_EQ(reg->funcs[fb + 3].qualified_name, "t.m.C.h"); + + const CBMRegisteredType *a = cbm_registry_lookup_type(reg, "t.m.A"); + ASSERT_NOT_NULL(a); + ASSERT_STR_EQ(a->qualified_name, "t.m.A"); + ASSERT_NULL(cbm_registry_lookup_type(reg, "t.m.D")); + + cbm_arena_destroy(&arena); + PASS(); +} + /* Issue #228: a class/static method invoked directly on a CROSS-FILE imported * class name — ActionRecordX.build_from_text(...) — produced no CALLS edge, so * the method showed in/out degree 0 and was flagged as dead code. Distinct from @@ -2448,6 +2533,59 @@ TEST(pylsp_scale_work_is_linear) { PASS(); } +/* #2413 (O9: no clock): the shared Tier-2 build registers one def at a time + * into a registry it finalizes only at the end, so a receiver probe that scans + * the types registered so far makes it O(methods x types). The registry + * primitives count every candidate they examine (CBM_LSP_WORK): 4x the classes + * must cost at most ~4x the work; the scanning probe lands at ~16x. */ +static uint64_t cross_registry_work_for(int n_classes, int *out_types) { + CBMArena arena; + cbm_arena_init(&arena); + int n_defs = n_classes * 3; + CBMLSPDef *defs = (CBMLSPDef *)cbm_arena_calloc(&arena, (size_t)n_defs * sizeof(*defs)); + if (!defs) { + cbm_arena_destroy(&arena); + *out_types = -1; + return 0; + } + /* Extraction order: each class followed by its two methods. */ + for (int i = 0; i < n_classes; i++) { + CBMLSPDef *cls = &defs[i * 3]; + cls->qualified_name = cbm_arena_sprintf(&arena, "scale.mod.Cls%d", i); + cls->short_name = cls->qualified_name + strlen("scale.mod."); + cls->label = "Class"; + cls->def_module_qn = "scale.mod"; + cls->lang = CBM_LANG_PYTHON; + for (int j = 1; j <= 2; j++) { + CBMLSPDef *m = &defs[i * 3 + j]; + m->qualified_name = cbm_arena_sprintf(&arena, "%s.m%d", cls->qualified_name, j); + m->short_name = j == 1 ? "m1" : "m2"; + m->label = "Method"; + m->receiver_type = cls->qualified_name; + m->def_module_qn = "scale.mod"; + m->lang = CBM_LANG_PYTHON; + } + } + (void)cbm_lsp_work_take(); + CBMTypeRegistry *reg = cbm_py_build_cross_registry(&arena, defs, n_defs); + uint64_t work = cbm_lsp_work_take(); + *out_types = reg ? reg->type_count : -1; + cbm_arena_destroy(&arena); + return work; +} + +TEST(pylsp_cross_registry_build_work_is_linear) { + int t1 = 0, t4 = 0; + uint64_t w1 = cross_registry_work_for(1000, &t1); + uint64_t w4 = cross_registry_work_for(4000, &t4); + printf(" cross registry work: 1000=%llu (types=%d) 4000=%llu (types=%d)\n", + (unsigned long long)w1, t1, (unsigned long long)w4, t4); + /* Every class registered exactly once (no duplicate placeholder). */ + ASSERT_EQ(t4 - t1, 3000); + ASSERT(w4 <= 5 * w1 + 1000); + PASS(); +} + extern const TSLanguage *tree_sitter_python(void); TEST(pylsp_issue1527_memo_allocation_failure_is_reported) { @@ -4850,6 +4988,7 @@ SUITE(py_lsp) { RUN_TEST(pylsp_issue1527_long_binary_chain_is_not_nesting); RUN_TEST(pylsp_issue1527_long_receiver_chain_is_not_nesting); RUN_TEST(pylsp_scale_work_is_linear); + RUN_TEST(pylsp_cross_registry_build_work_is_linear); /* Phase 2 — smoke */ RUN_TEST(pylsp_smoke_empty); RUN_TEST(pylsp_smoke_one_function); @@ -4886,6 +5025,7 @@ SUITE(py_lsp) { RUN_TEST(pylsp_crossfile_receiver_through_typed_field); RUN_TEST(pylsp_crossfile_typed_field_shared_registry); RUN_TEST(pylsp_fused_self_attr_chain_via_overlay); + RUN_TEST(pylsp_cross_registry_interleaved_order); RUN_TEST(pylsp_crossfile_classmethod_on_class_issue228); RUN_TEST(pylsp_crossfile_inheritance); RUN_TEST(pylsp_batch_two_files); diff --git a/tests/test_registry.c b/tests/test_registry.c index 80a7b3966..b711e32ed 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -1260,6 +1260,45 @@ TEST(go_bare_ref_never_binds_field) { PASS(); } +TEST(go_bare_init_never_binds_init_namesake) { + /* Go cannot refer to a package init function, and a method or field + * named init needs a selector, so a bare `init` (a closure, a parameter, + * a `package init` clause) is always a local name. */ + /* Every Go Function named init is a package init: refused for every + * registry strategy, same_module included (the pre-#1915 binding). */ + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "same_module", "Function")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Function")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "import_map", "Function")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", NULL, "Function")); + /* A Method or Field namesake found by the name alone is refused. */ + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Method")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "unique_name", "Method")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Field")); + /* ... but not one that an import- or module-aware strategy produced. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "same_module", "Method")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "import_map", "Method")); + /* lsp_* answers are kept: the sequential resolver emits them before its + * registry guards run, so refusing one here would split the resolvers. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "lsp_direct", "Function")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "lsp_direct", "Method")); + /* The member half of a selector (`w.init()`, `e.init`) is not bare. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, true, "init", "suffix_match", "Method")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, true, "init", "suffix_match", "Field")); + /* Other names and other target kinds are untouched. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "initAll", "suffix_match", "Method")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "Init", "suffix_match", "Function")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Variable")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Module")); + /* Go only: elsewhere a bare `init()` is an ordinary call (a Java or C++ + * method body calls its own init, a C or Python init is a function). */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(false, false, "init", "suffix_match", "Method")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(false, false, "init", "same_module", "Function")); + /* Degenerate input → nothing to judge. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, NULL, "suffix_match", "Function")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", NULL)); + PASS(); +} + TEST(c_bare_ref_never_binds_field) { /* #2666: a C struct member is named only as the member half of `a.b`, * `a->b` or a designator `.b` (field_identifier → is_member_access). A @@ -1434,6 +1473,72 @@ TEST(c_member_rule_covers_c_files_and_every_dot_h) { PASS(); } +TEST(java_call_never_binds_data_member) { + /* A Java call expression never names data, so a CALLS bind that landed on + * a Variable or a Field is a spelling collision. The shape that produces + * it: a record's accessors are implicit, `p.x()` finds no declared `x()`, + * and the short-name registry hands back the first `x` in the tree — an + * unrelated class's private field. */ + ASSERT_TRUE(cbm_java_suppress_call_to_data_member(true, "Field")); + ASSERT_TRUE(cbm_java_suppress_call_to_data_member(true, "Variable")); + /* Everything callable is kept, including the constructor's Class target. */ + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, "Method")); + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, "Function")); + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, "Class")); + /* Other languages have callable data: a Kotlin property or a C function + * pointer is a name that is not a method and is called all the same. */ + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(false, "Field")); + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(false, "Variable")); + /* Degenerate input → nothing to judge. */ + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, NULL)); + PASS(); +} + +TEST(generated_member_registers_by_qn_only) { + /* A generated member (a Java record's implicit accessor) is found by its + * qualified name, never by a short-name guess. The same member registered + * the ordinary way is exactly what the guess binds. */ + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add_qn_only(r, "proj.geo.Point.Point.y", "Method"); + ASSERT_TRUE(cbm_registry_exists(r, "proj.geo.Point.Point.y")); + ASSERT_STR_EQ(cbm_registry_label_of(r, "proj.geo.Point.Point.y"), "Method"); + const char **qns = NULL; + int n = -1; + ASSERT_EQ(cbm_registry_find_by_name(r, "y", &qns, &n), 0); + ASSERT_EQ(n, 0); + cbm_resolution_t res = cbm_registry_resolve(r, "v.y", "proj.app.Caller", NULL, NULL, 0); + ASSERT_NULL(res.qualified_name); + cbm_registry_add(r, "x", "proj.geo.Point.Point.x", "Method"); + res = cbm_registry_resolve(r, "v.x", "proj.app.Caller", NULL, NULL, 0); + ASSERT_STR_EQ(res.qualified_name, "proj.geo.Point.Point.x"); + /* Degenerate input registers nothing. */ + cbm_registry_add_qn_only(r, NULL, "Method"); + cbm_registry_add_qn_only(r, "proj.geo.Point.Point.z", NULL); + cbm_registry_add_qn_only(NULL, "proj.geo.Point.Point.z", "Method"); + ASSERT_EQ(cbm_registry_size(r), 2); + cbm_registry_free(r); + PASS(); +} + +TEST(generated_member_mark_on_defs_and_nodes) { + static const char *const record[] = {"generated", "record", NULL}; + static const char *const later[] = {"source", "openapi", "generated", "lombok", NULL}; + static const char *const doc[] = {"id", "ADR-7", "status", "accepted", NULL}; + static const char *const keyless[] = {"generated", NULL}; + ASSERT_TRUE(cbm_def_props_generated(record)); + ASSERT_TRUE(cbm_def_props_generated(later)); + ASSERT_FALSE(cbm_def_props_generated(doc)); + ASSERT_FALSE(cbm_def_props_generated(keyless)); /* a key without a value is no fact */ + ASSERT_FALSE(cbm_def_props_generated(NULL)); + ASSERT_TRUE( + cbm_node_props_generated("{\"parent_class\":\"p.Point\",\"generated\":\"record\"}")); + ASSERT_FALSE(cbm_node_props_generated("{\"parent_class\":\"p.Point\"}")); + /* The word inside a value is not the key. */ + ASSERT_FALSE(cbm_node_props_generated("{\"docstring\":\"code generated by protoc\"}")); + ASSERT_FALSE(cbm_node_props_generated(NULL)); + PASS(); +} + TEST(dynamic_suppress_drops_weak_method_matches) { /* #592/#606/#1276: a member call whose receiver the LSP could not type, that * landed via a WEAK short-name strategy, is generic-resolver noise → drop. @@ -2590,6 +2695,7 @@ SUITE(registry) { RUN_TEST(registry_tie_prefers_csharp_implementation_over_ref_stub); RUN_TEST(cross_language_ref_drops_go_vs_c); RUN_TEST(go_bare_ref_never_binds_field); + RUN_TEST(go_bare_init_never_binds_init_namesake); RUN_TEST(c_bare_ref_never_binds_field); RUN_TEST(call_onto_field_follows_language_and_shape); RUN_TEST(c_bare_call_does_not_weigh_field_namesakes); @@ -2597,6 +2703,9 @@ SUITE(registry) { RUN_TEST(unique_field_qn_needs_exactly_one_field_of_that_name); RUN_TEST(c_member_access_binds_by_owner_only); RUN_TEST(c_member_rule_covers_c_files_and_every_dot_h); + RUN_TEST(java_call_never_binds_data_member); + RUN_TEST(generated_member_registers_by_qn_only); + RUN_TEST(generated_member_mark_on_defs_and_nodes); RUN_TEST(dynamic_suppress_drops_weak_method_matches); RUN_TEST(dynamic_suppress_keeps_high_confidence_and_non_methods); RUN_TEST(python_builtin_member_table_matches_builtin_type_methods); diff --git a/tests/test_store_arch.c b/tests/test_store_arch.c index 8ed3f4636..64a118288 100644 --- a/tests/test_store_arch.c +++ b/tests/test_store_arch.c @@ -620,6 +620,22 @@ TEST(adr_store_and_retrieve) { PASS(); } +TEST(adr_restore_from_graph_if_absent_memory_is_noop) { + /* A :memory: store keeps ADRs in the graph DB, so restore-if-absent is a + * well-formed no-op (returns OK) and leaves an existing ADR untouched. */ + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_adr_store(s, "test", "v1"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_adr_restore_from_graph_if_absent(s), CBM_STORE_OK); + cbm_adr_t adr; + ASSERT_EQ(cbm_store_adr_get(s, "test", &adr), CBM_STORE_OK); + ASSERT_STR_EQ(adr.content, "v1"); + cbm_store_adr_free(&adr); + cbm_store_close(s); + PASS(); +} + TEST(adr_upsert) { cbm_store_t *s = cbm_store_open_memory(); ASSERT_NOT_NULL(s); @@ -1369,6 +1385,126 @@ TEST(adr_validate_keys_empty) { PASS(); } +/* ── cbm_store_fetch_call_edges / cbm_store_free_call_edges tests ─ */ + +TEST(fetch_call_edges_basic) { + cbm_store_t *s = setup_arch_test_store(); + ASSERT_NOT_NULL(s); + + int64_t *src = NULL; + int64_t *tgt = NULL; + int count = 0; + bool truncated = true; + ASSERT_EQ(cbm_store_fetch_call_edges(s, "test", 100, &src, &tgt, &count, &truncated), + CBM_STORE_OK); + /* setup_arch_test_store() wires 5 CALLS edges among Function nodes. */ + ASSERT_EQ(count, 5); + ASSERT_FALSE(truncated); + ASSERT_NOT_NULL(src); + ASSERT_NOT_NULL(tgt); + + cbm_store_free_call_edges(src, tgt); + cbm_store_close(s); + PASS(); +} + +TEST(fetch_call_edges_truncated) { + cbm_store_t *s = setup_arch_test_store(); + ASSERT_NOT_NULL(s); + + int64_t *src = NULL; + int64_t *tgt = NULL; + int count = 0; + bool truncated = false; + /* Only 5 CALLS edges exist; capping at 2 must set truncated. */ + ASSERT_EQ(cbm_store_fetch_call_edges(s, "test", 2, &src, &tgt, &count, &truncated), + CBM_STORE_OK); + ASSERT_EQ(count, 2); + ASSERT_TRUE(truncated); + + cbm_store_free_call_edges(src, tgt); + cbm_store_close(s); + PASS(); +} + +TEST(fetch_call_edges_empty_project) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "empty", "/tmp/empty"); + + int64_t *src = NULL; + int64_t *tgt = NULL; + int count = 0; + bool truncated = true; + ASSERT_EQ(cbm_store_fetch_call_edges(s, "empty", 100, &src, &tgt, &count, &truncated), + CBM_STORE_OK); + ASSERT_EQ(count, 0); + ASSERT_FALSE(truncated); + + /* cbm_store_free_call_edges(NULL, NULL) must not crash. */ + cbm_store_free_call_edges(src, tgt); + cbm_store_close(s); + PASS(); +} + +/* The two arrays cbm_store_fetch_call_edges hands out are memory-core blocks of + * the STORE class, and cbm_store_free_call_edges is what takes them back. Both + * halves are measured: the fetch adds exactly the two arrays and the paired free + * removes exactly those, so a raw allocation on either side fails. Thirty-six + * edges outgrow the initial capacity of 16, so the growth path is covered too. */ +TEST(fetch_call_edges_memory_core_ownership) { + enum { FN = 37 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "grow", "/tmp/grow"); + int64_t ids[FN]; + for (int i = 0; i < FN; i++) { + char name[32]; + char qn[64]; + snprintf(name, sizeof(name), "f%d", i); + snprintf(qn, sizeof(qn), "grow.pkg.f%d", i); + cbm_node_t n = {.project = "grow", + .label = "Function", + .name = name, + .qualified_name = qn, + .file_path = "pkg/f.go"}; + ids[i] = cbm_store_upsert_node(s, &n); + ASSERT_TRUE(ids[i] > 0); + } + for (int i = 0; i + 1 < FN; i++) { + cbm_edge_t e = { + .project = "grow", .source_id = ids[i], .target_id = ids[i + 1], .type = "CALLS"}; + ASSERT_TRUE(cbm_store_insert_edge(s, &e) > 0); + } + + size_t bytes = cbm_mem_class_live_bytes(CBM_MEM_CLASS_STORE); + size_t blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_STORE); + int64_t *src = NULL; + int64_t *tgt = NULL; + int count = 0; + bool truncated = true; + ASSERT_EQ(cbm_store_fetch_call_edges(s, "grow", 100, &src, &tgt, &count, &truncated), + CBM_STORE_OK); + ASSERT_EQ(count, FN - 1); + ASSERT_FALSE(truncated); + ASSERT_NOT_NULL(src); + ASSERT_NOT_NULL(tgt); + /* Ordered by (source_id, target_id): growth kept every pair in place. */ + ASSERT_EQ(src[0], ids[0]); + ASSERT_EQ(tgt[0], ids[1]); + ASSERT_EQ(src[FN - 2], ids[FN - 2]); + ASSERT_EQ(tgt[FN - 2], ids[FN - 1]); + ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_STORE), blocks + 2); + ASSERT_TRUE(cbm_mem_class_live_bytes(CBM_MEM_CLASS_STORE) >= + bytes + 2 * (size_t)count * sizeof(int64_t)); + + cbm_store_free_call_edges(src, tgt); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_STORE), bytes); + ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_STORE), blocks); + cbm_store_close(s); + PASS(); +} + /* ── Louvain tests ──────────────────────────────────────────────── */ TEST(louvain_basic) { @@ -1394,7 +1530,7 @@ TEST(louvain_basic) { /* Triangle and pair different */ ASSERT_TRUE(comm[1] != comm[4]); - cbm_free(CBM_MEM_CLASS_OTHER, result); + cbm_leiden_free(result); PASS(); } @@ -1403,7 +1539,7 @@ TEST(louvain_empty) { int count = 0; ASSERT_EQ(cbm_louvain(NULL, 0, NULL, 0, &result, &count), CBM_STORE_OK); ASSERT_EQ(count, 0); - cbm_free(CBM_MEM_CLASS_OTHER, result); + cbm_leiden_free(result); PASS(); } @@ -1414,7 +1550,7 @@ TEST(louvain_single_node) { ASSERT_EQ(cbm_louvain(nodes, 1, NULL, 0, &result, &count), CBM_STORE_OK); ASSERT_EQ(count, 1); ASSERT_EQ(result[0].node_id, 42); - cbm_free(CBM_MEM_CLASS_OTHER, result); + cbm_leiden_free(result); PASS(); } @@ -1477,7 +1613,7 @@ TEST(louvain_converges) { } ASSERT_TRUE(same_count >= 8); - cbm_free(CBM_MEM_CLASS_OTHER, result); + cbm_leiden_free(result); PASS(); } @@ -1502,7 +1638,8 @@ TEST(leiden_result_memory_core_ownership) { ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks + 1); ASSERT_TRUE(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER) >= bytes + 3 * sizeof(*result)); - cbm_free(CBM_MEM_CLASS_OTHER, result); + /* Released through the exported pair, the way an FFI caller must. */ + cbm_leiden_free(result); ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER), bytes); ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks); } @@ -1649,7 +1786,7 @@ TEST(leiden_multilevel_collapses_noise) { } ASSERT_TRUE(same >= SZ - 1); } - cbm_free(CBM_MEM_CLASS_OTHER, result); + cbm_leiden_free(result); PASS(); } @@ -1681,8 +1818,8 @@ TEST(leiden_resolution_controls_granularity) { ASSERT_TRUE(n_hi > n_lo); ASSERT_TRUE(leiden_all_communities_connected(lo, N, edges, ne)); ASSERT_TRUE(leiden_all_communities_connected(hi, N, edges, ne)); - cbm_free(CBM_MEM_CLASS_OTHER, lo); - cbm_free(CBM_MEM_CLASS_OTHER, hi); + cbm_leiden_free(lo); + cbm_leiden_free(hi); PASS(); } @@ -1912,6 +2049,7 @@ SUITE(store_arch) { /* ADR */ RUN_TEST(adr_store_and_retrieve); RUN_TEST(adr_upsert); + RUN_TEST(adr_restore_from_graph_if_absent_memory_is_noop); RUN_TEST(adr_delete); RUN_TEST(adr_delete_not_found); RUN_TEST(doc_candidates_replace_and_get); @@ -1948,6 +2086,12 @@ SUITE(store_arch) { RUN_TEST(adr_splice_refuses_unterminated_fence); RUN_TEST(adr_validate_keys_empty); + /* Call-edge fetch / free */ + RUN_TEST(fetch_call_edges_basic); + RUN_TEST(fetch_call_edges_truncated); + RUN_TEST(fetch_call_edges_empty_project); + RUN_TEST(fetch_call_edges_memory_core_ownership); + /* Louvain */ RUN_TEST(louvain_basic); RUN_TEST(louvain_empty); diff --git a/tests/test_ui.c b/tests/test_ui.c index 8c866ff52..6cd0d8146 100644 --- a/tests/test_ui.c +++ b/tests/test_ui.c @@ -556,7 +556,41 @@ TEST(layout_to_json) { ASSERT(strstr(json, "\"hello\"") != NULL); ASSERT(strstr(json, "\"Function\"") != NULL); - free(json); + cbm_layout_free_json(json); + cbm_layout_free(r); + cbm_store_close(store); + PASS(); +} + +/* cbm_layout_to_json hands out one memory-core block of the OTHER class, and + * cbm_layout_free_json takes exactly that block back. A raw allocation on either + * side of the pair fails the count. */ +TEST(layout_json_memory_core_ownership) { + cbm_store_t *store = cbm_store_open_memory(); + ASSERT_NOT_NULL(store); + cbm_store_upsert_project(store, "test", "/tmp/test"); + cbm_node_t n = {.project = "test", + .label = "Function", + .name = "hello", + .qualified_name = "test::hello", + .file_path = "a.c", + .start_line = 1, + .end_line = 5}; + cbm_store_upsert_node(store, &n); + cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); + ASSERT_NOT_NULL(r); + + size_t bytes = cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER); + size_t blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER); + char *json = cbm_layout_to_json(r); + ASSERT_NOT_NULL(json); + ASSERT(strstr(json, "\"hello\"") != NULL); + ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks + 1); + ASSERT_TRUE(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER) >= bytes + strlen(json) + 1); + + cbm_layout_free_json(json); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER), bytes); + ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks); cbm_layout_free(r); cbm_store_close(store); PASS(); @@ -777,7 +811,7 @@ TEST(layout_dead_code_classification) { ASSERT_NOT_NULL(json); ASSERT(strstr(json, "\"status\":\"dead\"") != NULL); ASSERT(strstr(json, "\"in_calls\":2") != NULL); - free(json); + cbm_layout_free_json(json); cbm_layout_free(r); cbm_store_close(store); @@ -2748,6 +2782,7 @@ SUITE(ui) { RUN_TEST(layout_honors_budget_above_default); RUN_TEST(layout_deterministic); RUN_TEST(layout_to_json); + RUN_TEST(layout_json_memory_core_ownership); RUN_TEST(layout_null_inputs); RUN_TEST(layout_dead_code_classification); RUN_TEST(layout_coincident_nodes_bounded); diff --git a/tests/test_version_metadata_contract.sh b/tests/test_version_metadata_contract.sh index b74203bf6..54d787944 100644 --- a/tests/test_version_metadata_contract.sh +++ b/tests/test_version_metadata_contract.sh @@ -109,6 +109,8 @@ SURFACES=( "pkg/chocolatey/tools/chocolateyInstall.ps1|release" "pkg/scoop/codebase-memory-mcp.json|release" "pkg/homebrew/Formula/codebase-memory-mcp.rb|release" + "plugin/.claude-plugin/plugin.json|release" + "plugin/.mcp.json|release" "pkg/aur/PKGBUILD|pin:0.8.1" "pkg/aur/.SRCINFO|pin:0.8.1" ) @@ -163,6 +165,12 @@ versions_in() { # cannot see it, because the version keyword is in the function NAME. grep -oE 'return "[0-9]+\.[0-9]+\.[0-9]+"' "$path" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' + # npm package-spec form: the Claude Code plugin launches the server as + # `npx -y codebase-memory-mcp@X.Y.Z`, and that pin is the version every + # plugin user actually runs. No declaration keyword sits next to it, so + # without this form plugin/.mcp.json reads as a surface with no claim. + grep -oE 'codebase-memory-mcp@[0-9]+\.[0-9]+\.[0-9]+' "$path" | + grep -oE '[0-9]+\.[0-9]+\.[0-9]+$' } | sort -u } @@ -280,7 +288,9 @@ fi # this contract exists to prevent, and every check above would still pass. The # sweep pattern is deliberately BROADER than the extractors in section 3: if the # two shared a regex, a claim form the extractors cannot see would be invisible -# to the sweep as well. +# to the sweep as well. The Claude Code plugin tree (plugin/, .claude-plugin/) is +# swept too: a later plugin file that pins `codebase-memory-mcp@X.Y.Z` (a hook +# command, say) must be registered here, or it would go stale unchecked. while IFS= read -r path; do [[ -z "$path" ]] && continue for entry in "${SURFACES[@]}"; do @@ -293,8 +303,8 @@ while IFS= read -r path; do "SURFACES so it stays in step with the release, or to EXEMPT with a" \ "note saying what its number actually is." done < <( - git grep -IlE '(version|pkgver|releases/(download|tag)/v)[^0-9]{0,16}[0-9]+\.[0-9]+\.[0-9]+|return "[0-9]+\.[0-9]+\.[0-9]+"' \ - -- server.json pkg tests/test_version_metadata_contract.sh 2>/dev/null || true + git grep -IlE '(version|pkgver|releases/(download|tag)/v)[^0-9]{0,16}[0-9]+\.[0-9]+\.[0-9]+|return "[0-9]+\.[0-9]+\.[0-9]+"|codebase-memory-mcp@[0-9]+\.[0-9]+\.[0-9]+' \ + -- server.json pkg plugin .claude-plugin tests/test_version_metadata_contract.sh 2>/dev/null || true ) if ((failures > 0)); then