mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Merge origin/main into feat/ruby-lsp-per-file
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
commit
360ab15cdb
108 files changed
+12855
-804
No files matched your search
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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*"
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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_*
|
||||
|
||||
+38
-2
@@ -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
|
||||
|
||||
@@ -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@<version>`, 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
|
||||
|
||||
<details>
|
||||
@@ -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 (`<db>.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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Generated
+4
-4
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+19
-5
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+964
-19
File diff suppressed because it is too large.
Load diff
@@ -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(<attr>, 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 + "#<file basename>:<N>",
|
||||
* 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
|
||||
|
||||
+95
-22
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+18
-10
@@ -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#<file>:<N> (#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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"codebase-memory-mcp": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "codebase-memory-mcp@0.12.1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+120
-3
@@ -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"
|
||||
|
||||
+759
-35
@@ -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 ("<db>.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 "<db>.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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
+168
-60
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+73
-13
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+10
-4
@@ -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 <tool> --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) {
|
||||
|
||||
+95
-32
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <Uppercase>" 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);
|
||||
|
||||
|
||||
+137
-25
@@ -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 <Name>" 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 <Uppercase>". */
|
||||
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 */
|
||||
|
||||
@@ -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 ("<db>.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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+342
-120
@@ -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
|
||||
* "<db>.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 "<db>.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 ("<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();
|
||||
|
||||
+91
-8
@@ -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 "<db>.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)));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+116
-14
@@ -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, "{}");
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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, "{}");
|
||||
|
||||
+100
-61
@@ -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 "<db>.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
|
||||
* "<final>.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 "<db>.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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "<db>.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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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":"<value>". */
|
||||
return properties_json && strstr(properties_json, "\"" CBM_PROP_GENERATED "\":\"") != NULL;
|
||||
}
|
||||
|
||||
/* ── Lookup ──────────────────────────────────────────────────────── */
|
||||
|
||||
bool cbm_registry_exists(const cbm_registry_t *r, const char *qn) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */
|
||||
+399
-48
@@ -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 ("<db_path>.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 ("<graph_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 ("<db_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;
|
||||
|
||||
+20
-2
@@ -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). */
|
||||
|
||||
@@ -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]);
|
||||
|
||||
+9
-2
@@ -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);
|
||||
}
|
||||
+4
-1
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 "<db>.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 "<db>.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
|
||||
* "<db>.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);
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+1155
-11
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+142
-11
@@ -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#<file>:<N>
|
||||
* (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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 <stdint.h>\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 <sample.h>\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 <stdio.h>\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);
|
||||
|
||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user