Merge branch 'main' into fix/issue-1718-approve-sensitive-separators

main landed the same separator-equivalent comparison in 2e6dc087
(ws_prefix_equal), so the conflict in ws_is_ancestor_or_equal resolves to
main's helper; the #1718 comment above the function is kept. The two
test-file conflicts were both sides adding tests at the same place, and
keep both.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-10-20 05:42:35 +02:00
2048 changed files with 3898320 additions and 1359290 deletions
+95
View File
@@ -0,0 +1,95 @@
{
"test_impact": {
"version": 1,
"tests": {
"presets": {
"c-cbm": true
}
},
"rules": [
{
"id": "harness",
"paths": ["tests/test_framework.h", "tests/test_helpers.*", "tests/test_main.c"],
"action": "run_all"
},
{
"id": "engine",
"paths": [
".codebase-memory.json",
"src/mcp/test_impact*",
"src/store/store_impact.*",
"scripts/test-impact/**",
"scripts/ci/test-impact-*"
],
"action": "run_all"
},
{
"id": "build",
"paths": [
"scripts/build.sh",
"scripts/env.sh",
"scripts/test.sh",
"scripts/run-tests-parallel.sh",
"scripts/run-test-wave.py",
"scripts/path-safety.sh",
"scripts/clean.sh",
"scripts/suite-failure-report.sh",
"scripts/ci/verify-shard-union.sh",
"scripts/embed-frontend.sh",
"/.gitattributes"
],
"action": "run_all"
},
{
"id": "grammars",
"paths": ["internal/cbm/vendored/**"],
"action": "run_all"
},
{
"id": "fixtures",
"paths": ["tests/fixtures/*/**"],
"action": "referencing_tests",
"key": "path_segment:2"
},
{
"id": "contracts",
"paths": ["tests/**/*.{sh,py,json,txt,ps1}"],
"action": "ignore"
},
{
"id": "notices",
"paths": ["NOTICE*", "CHANGELOG*", "THIRD_PARTY*"],
"action": "ignore"
},
{
"id": "repo-data",
"paths": [
"scripts/**",
"pkg/**",
"test-infrastructure/**",
"docs/**",
"**/*.md",
"graph-ui/**",
"tools/**",
"tests/fuzz/corpus/**",
"/{.clang-format,.clang-tidy,.cppcheck,.gitignore,.gitleaksignore,DCO,Formula}",
"/{flake.lock,flake.nix,glama.json,install.ps1,install.sh,server.json}"
],
"action": "referencing_tests",
"key": "path",
"unreferenced": "ignore"
}
],
"unmapped_files": "run_all",
"coverage": {
"compatibility_paths": [
"tests/test_framework.h",
"tests/test_main.c",
"tests/test_helpers.h",
"Makefile.cbm",
"scripts/test-impact/coverage-map.py",
"scripts/test-impact/coverage-merge.py"
]
}
}
}
+11
View File
@@ -11,3 +11,14 @@ internal/cbm/vendored/grammars/**/scanner.c linguist-generated=true diff=false
# the git hooks are extensionless and need explicit entries. (#1272)
scripts/git-hooks/commit-msg text eol=lf
scripts/hooks/pre-commit text eol=lf
# Chialisp grammar corpus: one case asserts CRLF comment termination, so this
# file must keep its exact bytes on every platform (no eol conversion). (#919)
tools/tree-sitter-chialisp/test/corpus/comments.txt -text
# llvm-profdata text captures: tests/test_profile_text_parser.py checks their
# SHA-256, so they keep their exact bytes on every platform (no eol conversion).
tests/fixtures/profile_text/*.proftext -text
# Golden profile-map outputs: tests/test_profile_map_v2.py compares the
# producer's LF bytes against them, so they keep their exact bytes too.
tests/fixtures/profile_map_v2/*.tsv -text
+5
View File
@@ -0,0 +1,5 @@
# Constraints for the ScanCode install of the license gate (PIP_CONSTRAINT).
# pluggy 1.7.0 (2026-10-15) no longer finds plugincode 32.0.0's class-level
# hookspecs: scancode then crashes at import with "did not find any
# 'pre_scan' hooks" and the gate fails on every run.
pluggy==1.6.0
+5 -5
View File
@@ -97,7 +97,7 @@ jobs:
- name: Attest release candidate provenance
if: ${{ inputs.attest }}
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: |
release-candidates/${{ matrix.goos }}-${{ matrix.goarch }}/candidate-provenance.tsv
@@ -155,7 +155,7 @@ jobs:
- name: Attest release candidate provenance
if: ${{ inputs.attest }}
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: |
release-candidates/windows-amd64/candidate-provenance.tsv
@@ -217,7 +217,7 @@ jobs:
- name: Attest release candidate provenance
if: ${{ inputs.attest }}
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: |
release-candidates/windows-arm64/candidate-provenance.tsv
@@ -280,7 +280,7 @@ jobs:
- name: Attest release candidate provenance
if: ${{ inputs.attest }}
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: |
release-candidates/linux-${{ matrix.arch }}-portable/candidate-provenance.tsv
@@ -432,7 +432,7 @@ jobs:
- name: Attest selected containers and decision evidence
if: ${{ inputs.attest }}
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: |
release-containers/**/*.tar.gz
+24 -9
View File
@@ -3,18 +3,31 @@
name: Lint & Security
on:
workflow_call: {}
workflow_call:
inputs:
lanes:
description: 'PR lane selection (scripts/ci/select-lanes.sh JSON list); "all" = every job (dry run, release)'
type: string
default: all
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'lint') }}
# Pinned, not ubuntu-latest: the LLVM install below adds an
# apt.llvm.org llvm-toolchain-NOBLE repo, which is 24.04 by name. When
# GitHub moves ubuntu-latest to the next LTS this job breaks on a day
# nobody chose. Pin the runner to the release the repo line already names.
runs-on: ubuntu-24.04
# 15 min proved too tight on a slow runner day and a timed-out lint reads
# as "cancelled", which the release graph must treat as a hard stop; keep
# a bound, but one only a genuine hang can hit (normal runtime ~5 min).
timeout-minutes: 30
# a bound, but one only a genuine hang can hit. 30 min stopped being that
# in 2026-10: single-process cppcheck over a src tree that grew ~60 % in
# files took the lint step from ~8 to 22-29 min, and three runs on
# 2026-10-13 were cancelled at 30 min with cppcheck still alive.
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -60,7 +73,9 @@ jobs:
# provability (never suppressed — the NOLINT ban applies here too).
# Vendored-tree diagnostics are path-filtered, mirroring .cppcheck.
lint-mem:
runs-on: ubuntu-latest
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'lint-mem') }}
# Pinned for the same reason as `lint`: llvm-toolchain-noble-21 below.
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -68,12 +83,12 @@ jobs:
- name: Install build deps
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev
- name: Install LLVM 22 (pinned analyzer toolchain)
- name: Install LLVM 21 (pinned analyzer toolchain)
run: |
wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc
echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-22 main" | sudo tee /etc/apt/sources.list.d/llvm-22.list
echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list
sudo apt-get update
sudo apt-get install -y clang-tidy-22
sudo apt-get install -y clang-tidy-21
- name: Memory-analyzer gate
run: scripts/ci/lint-mem.sh clang-tidy-22
run: scripts/ci/lint-mem.sh clang-tidy-21
+183
View File
@@ -0,0 +1,183 @@
# Reusable: the waste sanitizer (memory + CPU waste), REPORT-ONLY.
#
# What runs (scripts/memwaste.sh, memwaste-scaling.py, memwaste-ratchet.py):
# mode=pr Linux: the event lane (memory waste + CPU work) indexing this
# repository at the checked-out commit, and the CPU scaling lane
# (src/ replicated 2x against 4x, per-function counter ratios).
# mode=full the same, plus the event lane on macOS and Windows and the access
# lane (read/write verdicts per allocation) on Linux.
#
# The corpus is the repository itself: pinned by the commit, no network, and a
# real mix of C, C++, Python, JavaScript and vendored code.
#
# REPORT-ONLY: every job uploads its report and prints the ratchet comparison
# against scripts/memwaste-baseline.json, but nothing here can turn a PR red --
# the ratchet runs without --enforce and the PR's ci-ok gate does not depend on
# these jobs. A job fails only when the MEASUREMENT is unsound (a table
# overflowed, frees the layer never saw): that is a defect of the tool, and a
# red job is how it gets noticed. Switching the ratchet to --enforce is a
# separate, deliberate change once the numbers have been stable across reruns.
name: Memwaste
on:
workflow_call:
inputs:
mode:
description: "pr (Linux event + scaling) or full (all platforms, access lane)"
type: string
default: pr
permissions:
contents: read
jobs:
event-linux:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install deps
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 clang llvm
- name: Event lane on this repository
run: |
# `cmd | tee` reports TEE's status: without this a failing lane
# (a missing exec bit, a build error) passes the step and only
# surfaces as the ratchet not finding its JSON.
set -o pipefail
mkdir -p "$RUNNER_TEMP/memwaste-event"
scripts/memwaste.sh "$GITHUB_WORKSPACE" --top 40 --out "$RUNNER_TEMP/memwaste-event" \
| tee "$RUNNER_TEMP/memwaste-event/report.txt"
- name: Ratchet (report-only)
run: python3 scripts/memwaste-ratchet.py --out "$RUNNER_TEMP/memwaste-event" --venue ${{ inputs.mode }}-linux
- name: Upload report
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: memwaste-event-linux-${{ inputs.mode }}
path: |
${{ runner.temp }}/memwaste-event/report.txt
${{ runner.temp }}/memwaste-event/memwaste.json
${{ runner.temp }}/memwaste-event/index.json
if-no-files-found: warn
scaling-linux:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install deps
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 clang llvm
- name: CPU scaling lane (src/ at 2x against 4x)
# Report-only: the script exits 1 when it flags a super-linear function.
continue-on-error: true
run: |
# `cmd | tee` reports TEE's status: without this a failing lane
# (a missing exec bit, a build error) passes the step and only
# surfaces as the ratchet not finding its JSON.
set -o pipefail
mkdir -p "$RUNNER_TEMP/memwaste-scaling"
python3 scripts/memwaste-scaling.py --corpus src --replicas 2 \
--out "$RUNNER_TEMP/memwaste-scaling" | tee "$RUNNER_TEMP/memwaste-scaling/report.txt"
- name: Upload report
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: memwaste-scaling-linux-${{ inputs.mode }}
path: ${{ runner.temp }}/memwaste-scaling/report.txt
if-no-files-found: warn
access-linux:
if: ${{ inputs.mode == 'full' }}
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install deps
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 clang llvm
- name: Access lane on this repository
run: |
# `cmd | tee` reports TEE's status: without this a failing lane
# (a missing exec bit, a build error) passes the step and only
# surfaces as the ratchet not finding its JSON.
set -o pipefail
mkdir -p "$RUNNER_TEMP/memwaste-access"
scripts/memwaste.sh "$GITHUB_WORKSPACE" --lane access --top 40 --sort untouched \
--out "$RUNNER_TEMP/memwaste-access" | tee "$RUNNER_TEMP/memwaste-access/report.txt"
- name: Upload report
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: memwaste-access-linux-${{ inputs.mode }}
path: |
${{ runner.temp }}/memwaste-access/report.txt
${{ runner.temp }}/memwaste-access/memwaste.json
if-no-files-found: warn
event-macos:
if: ${{ inputs.mode == 'full' }}
runs-on: macos-14
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Event lane on this repository
run: |
# `cmd | tee` reports TEE's status: without this a failing lane
# (a missing exec bit, a build error) passes the step and only
# surfaces as the ratchet not finding its JSON.
set -o pipefail
mkdir -p "$RUNNER_TEMP/memwaste-event"
scripts/memwaste.sh "$GITHUB_WORKSPACE" --top 40 --out "$RUNNER_TEMP/memwaste-event" \
| tee "$RUNNER_TEMP/memwaste-event/report.txt"
- name: Ratchet (report-only)
run: python3 scripts/memwaste-ratchet.py --out "$RUNNER_TEMP/memwaste-event" --venue full-macos
- name: Upload report
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: memwaste-event-macos-${{ inputs.mode }}
path: |
${{ runner.temp }}/memwaste-event/report.txt
${{ runner.temp }}/memwaste-event/memwaste.json
if-no-files-found: warn
event-windows:
if: ${{ inputs.mode == 'full' }}
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2
with:
msystem: CLANG64
path-type: inherit
install: >-
mingw-w64-clang-x86_64-clang
mingw-w64-clang-x86_64-llvm
mingw-w64-clang-x86_64-zlib
mingw-w64-clang-x86_64-python3
make
git
coreutils
- name: Event lane on this repository
shell: msys2 {0}
run: |
# `cmd | tee` reports TEE's status: without this a failing lane
# (a missing exec bit, a build error) passes the step and only
# surfaces as the ratchet not finding its JSON.
set -o pipefail
out="$(cygpath -u "$RUNNER_TEMP")/memwaste-event"
mkdir -p "$out"
CC=clang CXX=clang++ scripts/memwaste.sh "$(cygpath -u "$GITHUB_WORKSPACE")" --top 40 --out "$out" \
| tee "$out/report.txt"
- name: Ratchet (report-only)
shell: msys2 {0}
run: python3 scripts/memwaste-ratchet.py --out "$(cygpath -u "$RUNNER_TEMP")/memwaste-event" --venue full-windows
- name: Upload report
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: memwaste-event-windows-${{ inputs.mode }}
path: |
${{ runner.temp }}/memwaste-event/report.txt
${{ runner.temp }}/memwaste-event/memwaste.json
if-no-files-found: warn
+71 -10
View File
@@ -4,7 +4,12 @@
name: Security Gate
on:
workflow_call: {}
workflow_call:
inputs:
lanes:
description: 'PR lane selection (scripts/ci/select-lanes.sh JSON list); "all" = every job (dry run, release)'
type: string
default: all
permissions:
contents: read
@@ -16,19 +21,28 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: "Layer 1: Static allow-list audit"
run: scripts/security-audit.sh
run: |
bash tests/test_security_audit_subprocess_calls.sh
scripts/security-audit.sh
- name: "Layer 6: UI security audit"
run: scripts/security-ui.sh
run: |
bash tests/test_security_ui_urls.sh
scripts/security-ui.sh
- name: "Layer 8: Vendored dependency integrity"
run: scripts/security-vendored.sh
license-gate:
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'license-gate') }}
runs-on: ubuntu-latest
timeout-minutes: 240
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install ScanCode Toolkit
run: pipx install scancode-toolkit
# Pinned: an unpinned install picked up pluggy 1.7.0, which breaks
# scancode at import (see .github/scancode-constraints.txt).
env:
PIP_CONSTRAINT: ${{ github.workspace }}/.github/scancode-constraints.txt
run: pipx install scancode-toolkit==32.5.0
- name: "Gate self-test (a planted violation must be detected)"
run: scripts/license-gate.sh --selftest
- name: "License compliance gate (one finding fails)"
@@ -39,10 +53,19 @@ jobs:
run: scripts/audit-license-provenance.py
codeql-gate:
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'codeql-gate') }}
runs-on: ubuntu-latest
timeout-minutes: 240
# Without security-events:read this job inherits the workflow's
# contents:read, the code-scanning alert API answers 403, and the gate
# below reported "0 alerts" on every run it has ever made. actions:read is
# what the CodeQL-run poll above needs for the same reason.
permissions:
contents: read
security-events: read
actions: read
steps:
- name: Wait for CodeQL on current commit (max 45 min)
- name: Wait for CodeQL on current commit (max 150 min)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
@@ -50,11 +73,17 @@ jobs:
# CodeQL runs are recorded against the PR head SHA.
CURRENT_SHA="${{ github.event.pull_request.head.sha || github.sha }}"
echo "Waiting for CodeQL to complete on $CURRENT_SHA..."
for attempt in $(seq 1 90); do
# 300 attempts x 30s = 150 min. The previous budget was 90 x 30s = 45 min,
# which is shorter than CodeQL actually takes on this repository: a measured
# run on PR #1426 (head 7b72652a) completed with conclusion=success after
# 124 min, 7 minutes AFTER this gate had already given up. That marked at
# least five contributor PRs red for a scan that passed. The job's own
# timeout-minutes is 240, so 150 still leaves headroom.
for attempt in $(seq 1 300); do
LATEST=$(gh api "repos/${{ github.repository }}/actions/workflows/codeql.yml/runs?head_sha=$CURRENT_SHA&per_page=1" \
--jq '.workflow_runs[] | "\(.conclusion) \(.status)"' 2>/dev/null | head -1 || echo "")
if [ -z "$LATEST" ]; then
echo " $attempt/90: no run yet..."; sleep 30; continue
echo " $attempt/300: no run yet..."; sleep 30; continue
fi
CONCLUSION=$(echo "$LATEST" | cut -d' ' -f1)
STATUS=$(echo "$LATEST" | cut -d' ' -f2)
@@ -63,7 +92,7 @@ jobs:
elif [ "$STATUS" = "completed" ]; then
echo "BLOCKED: CodeQL $CONCLUSION"; exit 1
fi
echo " $attempt/90: $STATUS..."; sleep 30
echo " $attempt/300: $STATUS..."; sleep 30
done
echo "BLOCKED: CodeQL timeout"; exit 1
@@ -71,11 +100,43 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# FAIL CLOSED. The previous form was `gh api ... 2>/dev/null || echo "0"`,
# which looks defensive and is the opposite. gh writes the API error
# BODY to stdout, so a 403 made ALERTS the string
# {"message":"Resource not accessible by integration",...,"status":"403"}0
# every `[` comparison then failed as non-integer, the `if` took its
# false branch, and the step printed "CodeQL gate passed (0 alerts)"
# and exited 0. That is exactly how a security gate should NEVER fail:
# silently, in the direction of "everything is fine". An unreadable
# alert list is now an error, not a zero.
# NOTE: this runs in a command substitution, so it must `return`, not
# `exit` -- an `exit` here would only end the subshell and let the
# caller continue with an empty count, recreating the very bug this
# replaces. The callers below check the status explicitly.
count_open_alerts() {
local body status
body=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \
--jq 'length' 2>/dev/null)
status=$?
if [ $status -ne 0 ]; then
echo "BLOCKED: cannot read code scanning alerts (gh exit $status)." >&2
echo " Refusing to treat an unreadable alert list as zero." >&2
echo " Most likely cause: the job lacks security-events:read." >&2
return 1
fi
case "$body" in
''|*[!0-9]*)
echo "BLOCKED: alert count was not a number: ${body:-<empty>}" >&2
return 1
;;
esac
printf '%s' "$body"
}
echo "Waiting 60s for alert API to settle..."
sleep 60
ALERTS=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0")
ALERTS=$(count_open_alerts) || exit 1
sleep 15
ALERTS2=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0")
ALERTS2=$(count_open_alerts) || exit 1
[ "$ALERTS" -lt "$ALERTS2" ] && ALERTS=$ALERTS2
if [ "$ALERTS" -gt 0 ]; then
echo "BLOCKED: $ALERTS open alert(s)"
+23
View File
@@ -95,6 +95,13 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# SMOKE_REQUIRE_WRAPPERS=1 needs a Go toolchain for the Go wrapper; the
# macOS runner images have none on PATH.
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: pkg/go/go.mod
cache: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}
@@ -118,6 +125,7 @@ jobs:
env:
CBM_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/cbm-artifact
SMOKE_REQUIRE_UI: "1"
SMOKE_REQUIRE_WRAPPERS: "1"
- name: Security audits
run: |
@@ -175,6 +183,13 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# SMOKE_REQUIRE_WRAPPERS=1 needs a Go toolchain for the Go wrapper; the
# MSYS2 shell below inherits it (path-type: inherit).
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: pkg/go/go.mod
cache: false
- uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2
with:
msystem: ${{ matrix.arch == 'arm64' && 'CLANGARM64' || 'CLANG64' }}
@@ -224,6 +239,7 @@ jobs:
env:
SMOKE_ARCH: ${{ matrix.arch }}
SMOKE_REQUIRE_UI: "1"
SMOKE_REQUIRE_WRAPPERS: "1"
run: |
export CBM_SMOKE_ARTIFACT_DIR="$(cygpath -u "$RUNNER_TEMP")/cbm-artifact"
bash test-infrastructure/vm/vm-smoke.sh
@@ -279,6 +295,12 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# SMOKE_REQUIRE_WRAPPERS=1 needs a Go toolchain for the Go wrapper.
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: pkg/go/go.mod
cache: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: binaries-linux-${{ matrix.arch }}-portable
@@ -301,6 +323,7 @@ jobs:
env:
CBM_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/cbm-artifact
SMOKE_REQUIRE_UI: "1"
SMOKE_REQUIRE_WRAPPERS: "1"
# The -portable binary is what all linux install/update paths now deliver;
# it MUST start on old glibc (Debian 11 / RHEL 8 / Ubuntu 20.04). Runs it
+220 -28
View File
@@ -16,6 +16,18 @@ on:
description: 'Split each C-suite leg across parallel shard jobs (ubuntu x3, windows x2); off = one job per leg, byte-identical to the pre-shard topology'
type: boolean
default: false
shard_profile:
description: 'Shard counts when shard_suites is on: standard = ubuntu x3, windows x2 (dry run, release); pr = ubuntu x86 x1, ubuntu arm64 x2, windows x1 (fewer jobs re-paying setup)'
type: string
default: standard
lanes:
description: 'PR lane selection (scripts/ci/select-lanes.sh JSON list): only those legs and jobs run; "all" = every one (dry run, release)'
type: string
default: all
test_selection:
description: 'PR test selection (smart CI): the artifact the select-tests job uploaded; the Linux x86_64 test-unix leg runs only its tests, the arm64, macOS and Windows legs run every test. Empty = every test (dry run, release, and any PR the selection does not narrow)'
type: string
default: ''
permissions:
contents: read
@@ -30,18 +42,26 @@ jobs:
outputs:
unix: ${{ steps.set.outputs.unix }}
windows: ${{ steps.set.outputs.windows }}
tsan: ${{ steps.set.outputs.tsan }}
has_unix: ${{ steps.set.outputs.has_unix }}
has_windows: ${{ steps.set.outputs.has_windows }}
has_tsan: ${{ steps.set.outputs.has_tsan }}
steps:
- name: Compute matrices
id: set
env:
BROAD: ${{ inputs.broad_platforms }}
SHARDS: ${{ inputs.shard_suites }}
SHARD_PROFILE: ${{ inputs.shard_profile }}
LANES: ${{ inputs.lanes }}
run: |
# Every CORE leg is tagged with the lane (scripts/ci/select-lanes.sh)
# it serves; the tag is what lane selection filters on below.
CORE_UNIX='[
{"os":"ubuntu-latest","cc":"gcc","cxx":"g++"},
{"os":"ubuntu-24.04-arm","cc":"gcc","cxx":"g++"},
{"os":"macos-14","cc":"cc","cxx":"c++"},
{"os":"macos-15-intel","cc":"cc","cxx":"c++"}
{"os":"ubuntu-latest","cc":"gcc","cxx":"g++","lane":"unix-x86"},
{"os":"ubuntu-24.04-arm","cc":"gcc","cxx":"g++","lane":"unix-arm64"},
{"os":"macos-14","cc":"cc","cxx":"c++","lane":"unix-macos14"},
{"os":"macos-15-intel","cc":"cc","cxx":"c++","lane":"unix-macos-intel"}
]'
# Broad matrix legs are REQUIRED gates (no optional/continue-on-error
# escape hatches): the release dry run must be all-green, every platform.
@@ -59,7 +79,7 @@ jobs:
# interception crashed (interception_win: unhandled instruction). With
# the native ARM64 toolchain ASan instruments native ARM64 code, so it
# is a real (non-optional) gate, not a tolerated emulated-flake.
CORE_WIN='[{"os":"windows-latest","msystem":"CLANG64","pkg":"x86_64"}]'
CORE_WIN='[{"os":"windows-latest","msystem":"CLANG64","pkg":"x86_64","lane":"windows"}]'
BROAD_WIN='[{"os":"windows-2025","msystem":"CLANG64","pkg":"x86_64"},{"os":"windows-11-arm","msystem":"CLANGARM64","pkg":"aarch64"}]'
if [ "$BROAD" = "true" ]; then
UNIX=$(jq -cn --argjson a "$CORE_UNIX" --argjson b "$BROAD_UNIX" '$a + $b')
@@ -68,6 +88,30 @@ jobs:
UNIX=$(jq -cn --argjson a "$CORE_UNIX" '$a')
WIN=$(jq -cn --argjson a "$CORE_WIN" '$a')
fi
# The ThreadSanitizer legs (test-tsan), tagged the same way.
TSAN='[
{"os":"ubuntu-latest","cc":"clang","cxx":"clang++","lane":"tsan-x86"},
{"os":"ubuntu-24.04-arm","cc":"clang","cxx":"clang++","lane":"tsan-arm"},
{"os":"macos-14","cc":"cc","cxx":"c++","lane":"tsan-mac"}
]'
# Lane selection. "all" (dry run, release) keeps every leg and drops
# the tags, so those matrices -- and their job names -- stay exactly
# as before lane selection. A PR keeps only its selected legs, and
# the tag stays in the job name, where ci-ok reads it back. An
# untagged leg could never be selected: that is a configuration
# error here, never a leg silently dropped.
if [ "$LANES" = "all" ]; then
UNIX=$(jq -c 'map(del(.lane))' <<< "$UNIX")
WIN=$(jq -c 'map(del(.lane))' <<< "$WIN")
TSAN=$(jq -c 'map(del(.lane))' <<< "$TSAN")
else
for M in "$UNIX" "$WIN" "$TSAN"; do
jq -e 'all(.[]; has("lane"))' <<< "$M" > /dev/null || { echo "::error::untagged test leg in $M"; exit 1; }
done
UNIX=$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$UNIX")
WIN=$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$WIN")
TSAN=$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$TSAN")
fi
# Suite sharding (opt-in via the shard_suites input): each platform's
# C-suite run is split across N runner jobs. CBM_TEST_SHARD="i/N"
# slices deterministically inside run-tests-parallel.sh; each
@@ -78,20 +122,37 @@ jobs:
# shards); Windows gets 2 (every extra shard re-pays ~5 min of
# MSYS2 setup); macOS stays at 1 — mac legs are not the critical
# path and mac runner concurrency ceilings are the tightest.
# The pr profile shards less: a PR runs few legs and every shard
# re-pays its setup, so x86 and Windows run whole and only arm64,
# the slowest leg (~72 min unsharded), splits in two.
case "$SHARD_PROFILE" in
standard) N_X86=3; N_ARM=3; N_WIN=2 ;;
pr) N_X86=1; N_ARM=2; N_WIN=1 ;;
*) echo "::error::unknown shard_profile '$SHARD_PROFILE'"; exit 1 ;;
esac
if [ "$SHARDS" = "true" ]; then
UNIX=$(jq -c '[.[] | . as $e
| (if ($e.os | startswith("ubuntu")) then 3 else 1 end) as $n
UNIX=$(jq -c --argjson x86 "$N_X86" --argjson arm "$N_ARM" '[.[] | . as $e
| (if ($e.os | startswith("ubuntu")) then
(if ($e.os | endswith("-arm")) then $arm else $x86 end)
else 1 end) as $n
| range(1; $n + 1) as $i
| $e + {shard: "\($i)/\($n)"}]' <<< "$UNIX")
WIN=$(jq -c '[.[] | . as $e
| range(1; 3) as $i
| $e + {shard: "\($i)/2"}]' <<< "$WIN")
WIN=$(jq -c --argjson n "$N_WIN" '[.[] | . as $e
| range(1; $n + 1) as $i
| $e + {shard: "\($i)/\($n)"}]' <<< "$WIN")
fi
echo "unix={\"include\":$UNIX}" >> "$GITHUB_OUTPUT"
echo "windows={\"include\":$WIN}" >> "$GITHUB_OUTPUT"
echo "tsan={\"include\":$TSAN}" >> "$GITHUB_OUTPUT"
# GitHub rejects an empty matrix, so a job whose legs were all
# deselected is skipped through these flags instead.
echo "has_unix=$(jq 'length > 0' <<< "$UNIX")" >> "$GITHUB_OUTPUT"
echo "has_windows=$(jq 'length > 0' <<< "$WIN")" >> "$GITHUB_OUTPUT"
echo "has_tsan=$(jq 'length > 0' <<< "$TSAN")" >> "$GITHUB_OUTPUT"
test-unix:
needs: setup-matrix
if: ${{ needs.setup-matrix.outputs.has_unix == 'true' }}
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.setup-matrix.outputs.unix) }}
@@ -107,6 +168,32 @@ jobs:
if: startsWith(matrix.os, 'ubuntu')
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev ccache
# Ubuntu 23.10+ ships kernel.apparmor_restrict_unprivileged_userns=1, which
# blocks unshare(CLONE_NEWUSER) for unprivileged users. Without this, the
# #1830 single-uid-userns smoke test SKIPS on every 24.04 leg, and its only
# real-namespace coverage lived on the two ubuntu-22.04 legs -- which are
# broad-matrix only (never PR CI) and which GitHub retires on 2027-04-17.
# A deliberate relaxation of a security check was therefore guarded by a
# test that ran in no PR and was on a clock. Hosted runners have
# passwordless sudo, so one sysctl puts it on the CORE matrix instead.
# Best-effort: a runner that refuses simply returns to skipping.
- name: Permit unprivileged user namespaces (Ubuntu)
if: startsWith(matrix.os, 'ubuntu')
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || \
echo "could not lift the userns restriction; namespace tests will skip"
# Then SAY whether it worked. The runner prints per-shard aggregates
# ("N passed, M skipped") and never individual test names, so a
# namespace test that silently returns to skipping is invisible in a
# green log -- the same false assurance that let #1830 ship with a
# test that never executed. This line is the evidence. Probed exactly
# as the test does it: unprivileged, no sudo.
if unshare -U -r true 2>/dev/null; then
echo "CBM_USERNS_AVAILABLE=yes"
else
echo "CBM_USERNS_AVAILABLE=no (namespace-dependent tests will skip)"
fi
- name: Install ccache (macOS)
if: startsWith(matrix.os, 'macos')
run: command -v ccache >/dev/null 2>&1 || brew install ccache
@@ -134,12 +221,24 @@ jobs:
ccache-test-${{ matrix.os }}-${{ matrix.cc }}-${{ github.ref }}-
ccache-test-${{ matrix.os }}-${{ matrix.cc }}-
- name: Fetch the test selection
# Only the Linux x86_64 leg narrows (user decision 2026-10-16): the
# coverage that justifies an omission comes from the Linux x86_64
# producer, so arm64, macOS and Windows code under #ifdef has static
# evidence only and those legs run every test.
if: ${{ inputs.test_selection != '' && runner.os == 'Linux' && runner.arch == 'X64' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ inputs.test_selection }}
path: ${{ runner.temp }}/test-selection
- name: Test
run: scripts/test.sh CC=${{ matrix.cc }} CXX=${{ matrix.cxx }}
env:
CBM_TEST_SHARD: ${{ matrix.shard }}
CBM_TEST_LEG: ${{ matrix.os }}-${{ matrix.cc }}
CBM_SKIP_PERF: ${{ inputs.skip_perf && '1' || '' }}
CBM_TEST_SELECTION_DIR: ${{ inputs.test_selection != '' && runner.os == 'Linux' && runner.arch == 'X64' && format('{0}/test-selection', runner.temp) || '' }}
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_MAXSIZE: 1500M
@@ -161,6 +260,23 @@ jobs:
# shard-completeness job still gates the cross-shard union.
if-no-files-found: warn
# The job log carries a red suite's SUMMARY; these are the complete logs
# behind it, so a failure can be read instead of re-run. The harness
# copies the log of every suite that did not end green, plus results.txt,
# into test-logs/failed/ (scripts/suite-failure-report.sh) — typically a
# few kilobytes for a red suite, not the ~25 MB of a whole run's logs.
# Runs only on a failed job. warn, not error: a job that failed OUTSIDE
# the suite wave (contract step, build, prod-binary guards) has no such
# directory, and a second error would only bury the first.
- name: Failing suite logs
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: suite-logs-${{ matrix.os }}-${{ matrix.cc }}-${{ strategy.job-index }}
path: build/c/test-logs/failed/
retention-days: 7
if-no-files-found: warn
- name: Compiler cache (save)
if: always() && (matrix.shard == null || startsWith(matrix.shard, '1/'))
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
@@ -179,6 +295,7 @@ jobs:
# inode/flock behavior and Windows handle/link behavior are each gating,
# without adding Node/Go provisioning to every sanitizer/container leg.
test-package-wrappers:
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'pkg-wrappers') }}
strategy:
fail-fast: false
matrix:
@@ -205,6 +322,28 @@ jobs:
shell: bash
run: scripts/ci/test-package-wrappers.sh
# CodeAtlasWeb (graph-ui) leg: the frontend's own vitest suite (2000+
# tests; the render tests run under jsdom), its hardcoded-string and
# promise scans, its portable frozen acceptance checks, and a production
# build with the same npm ci the product build runs. One canonical script
# (scripts/ci/test-ui.sh) is the whole leg, here and locally. No browser
# is downloaded: the browser proofs (graph-ui/tools/smoke-*.mjs) need a
# running server and Playwright browsers, and they stay a local, manually
# dispatched venue documented in graph-ui/README.md rather than a hosted
# gate.
test-ui:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
- name: CodeAtlasWeb leg (install, unit, style, promises, acceptance, build)
run: scripts/ci/test-ui.sh
# ThreadSanitizer data-race gate. The threaded production surfaces (watcher,
# subprocess, httpd, pipeline, mcp, worker pool, diagnostics, the runnable
# daemon-coordination paths) plus allocator concurrency all run under TSan
@@ -219,7 +358,10 @@ jobs:
# parallel suite — catches what an older compiler-rt can miss. Same
# canonical scripts/test.sh wave as every other leg.
test-diag:
runs-on: ubuntu-latest
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'diag') }}
# Pinned for the same reason as the lint jobs: the pinned diagnostic
# toolchain below installs from llvm-toolchain-noble-21.
runs-on: ubuntu-24.04
timeout-minutes: 120
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -227,12 +369,12 @@ jobs:
- name: Install deps
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev ccache
- name: Install LLVM 22 (pinned diagnostic toolchain)
- name: Install LLVM 21 (pinned diagnostic toolchain)
run: |
wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc
echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-22 main" | sudo tee /etc/apt/sources.list.d/llvm-22.list
echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list
sudo apt-get update
sudo apt-get install -y clang-22
sudo apt-get install -y clang-21
- name: Compiler cache (content-verified, restore)
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
@@ -247,13 +389,24 @@ jobs:
# lane: stack-use-after-return, stack-use-after-scope, and strict string
# checks. Mirrors `make -f Makefile.cbm diag`, including its reasoning
# for leaving detect_invalid_pointer_pairs out.
- name: Test (clang-22, ASan+UBSan + off-by-default ASan checks)
- name: Test (clang-21, ASan+UBSan + off-by-default ASan checks)
env:
ASAN_OPTIONS: "detect_stack_use_after_return=1:strict_string_checks=1:detect_stack_use_after_scope=1"
run: |
scripts/test.sh CC=clang-22 CXX=clang++-22 \
scripts/test.sh CC=clang-21 CXX=clang++-21 \
SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer -fno-optimize-sibling-calls"
# Same step as in test-unix: the complete logs of the suites that did
# not end green, only when the job failed.
- name: Failing suite logs
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: suite-logs-diag
path: build/c/test-logs/failed/
retention-days: 7
if-no-files-found: warn
- name: Compiler cache (save)
if: always()
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
@@ -271,17 +424,21 @@ jobs:
# artifact — the local ladder cannot answer that, having no faithful x86-64
# emulation. It has now answered it, and the answer SPLIT the list:
#
# - the five deep-recursion suites overflow their thread stacks on x86-64
# too, so that limit is NOT architectural;
# - five suites aborted with "stack-overflow ... nested bug" on both
# architectures. That was not deep recursion: it was MSan crashing while
# it reported real uninitialized reads (reporting with the slow unwinder
# exhausted the main thread's stack). scripts/msan.sh now unwinds with
# frame pointers, the
# reports print, the reads are fixed, and the five suites run again;
# - `cli` does not overflow here at all — it fails in the install path for
# an unrelated, still-undiagnosed reason;
# - `incremental` was a shadow-memory RSS artifact, now fixed in the test
# itself rather than skipped, so it stays in the lane.
#
# Per-cause detail lives in scripts/msan.sh, which is the single authoritative
# list both venues now share. Zero use-of-uninitialized-value findings were
# reported across the lane on either architecture.
# list both venues now share.
test-msan:
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'msan') }}
runs-on: ubuntu-latest
timeout-minutes: 180
steps:
@@ -306,13 +463,20 @@ jobs:
# so this leg is the ordinary ASan suite built with that toolchain and run
# with detect_leaks=1. Mirrors the local `make -f Makefile.cbm test-lsan`.
test-lsan-macos:
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'lsan-macos') }}
runs-on: macos-latest
timeout-minutes: 120
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Pinned: the unversioned `llvm` formula tracks Homebrew stable, so the
# leg's compiler changed under us when stable moved 22 -> 23 (a new
# -Wunused-but-set-global under -Werror broke the build on whichever
# runner drew the 23 bottle, and passed on the ones that drew 22). A
# test verdict must be a function of the code, not of the image snapshot;
# moving to a newer major is a deliberate one-line bump here.
- name: Install LSan-capable toolchain
run: brew install llvm ccache
run: brew install llvm@22 ccache
- name: Compiler cache (content-verified, restore)
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
@@ -327,9 +491,20 @@ jobs:
env:
ASAN_OPTIONS: "detect_leaks=1:halt_on_error=1"
run: |
LLVM_PREFIX="$(brew --prefix llvm)"
LLVM_PREFIX="$(brew --prefix llvm@22)"
scripts/test.sh CC="$LLVM_PREFIX/bin/clang" CXX="$LLVM_PREFIX/bin/clang++"
# Same step as in test-unix: the complete logs of the suites that did
# not end green, only when the job failed.
- name: Failing suite logs
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: suite-logs-lsan-macos
path: build/c/test-logs/failed/
retention-days: 7
if-no-files-found: warn
- name: Compiler cache (save)
if: always()
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
@@ -338,13 +513,11 @@ jobs:
key: ccache-lsan-${{ github.ref }}-${{ github.sha }}
test-tsan:
needs: setup-matrix
if: ${{ needs.setup-matrix.outputs.has_tsan == 'true' }}
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-latest, cc: clang, cxx: clang++ }
- { os: ubuntu-24.04-arm, cc: clang, cxx: clang++ }
- { os: macos-14, cc: cc, cxx: c++ }
matrix: ${{ fromJSON(needs.setup-matrix.outputs.tsan) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 120
steps:
@@ -397,6 +570,7 @@ jobs:
test-windows:
needs: setup-matrix
if: ${{ needs.setup-matrix.outputs.has_windows == 'true' }}
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.setup-matrix.outputs.windows) }}
@@ -474,6 +648,8 @@ jobs:
CBM_TEST_SHARD: ${{ matrix.shard }}
CBM_TEST_LEG: ${{ matrix.os }}-${{ matrix.msystem }}
CBM_SKIP_PERF: ${{ inputs.skip_perf && '1' || '' }}
# Windows legs run every test: see the Linux x86_64-only fetch in test-unix.
CBM_TEST_SELECTION_DIR: ''
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_MAXSIZE: 1500M
@@ -497,6 +673,17 @@ jobs:
# shard-completeness job still gates the cross-shard union.
if-no-files-found: warn
# Same step as in test-unix: the complete logs of the suites that did
# not end green, only when the job failed.
- name: Failing suite logs
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: suite-logs-${{ matrix.os }}-${{ matrix.msystem }}-${{ strategy.job-index }}
path: build/c/test-logs/failed/
retention-days: 7
if-no-files-found: warn
# Shard 1 (or the unsharded job) saves the compiler cache; every shard
# builds the identical runner, so one cache carries the full object set.
- name: Compiler cache (save)
@@ -530,8 +717,12 @@ jobs:
with:
pattern: shard-manifest-*
path: manifests
# A PR with no test leg selected uploads no manifest; the script
# accepts that only when the selection really holds no leg.
- name: Verify cross-shard union per leg
run: repo/scripts/ci/verify-shard-union.sh manifests
env:
LANES: ${{ inputs.lanes }}
run: repo/scripts/ci/verify-shard-union.sh manifests --lanes "$LANES"
# Windows product-surface regression guards. Distinct from test-windows above
# (the sanitizer C suite): these drive a real product binary + external UI pack
@@ -542,6 +733,7 @@ jobs:
# CLI arguments being mangled by the narrow-argv main() (#423/#20, fixed by the
# wide-argv entrypoint that reads GetCommandLineW).
test-windows-guards:
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'windows-guards') }}
runs-on: windows-latest
timeout-minutes: 60
steps:
+7 -3
View File
@@ -9,8 +9,12 @@ on:
permissions:
contents: read
# One analysis per pull request and one per pushed branch. A newer push to main
# cancels the analysis of an older main commit; only the newest commit's
# results matter for main, and a merge-heavy day otherwise queues one 40-100
# minute analysis per merge ahead of every pull request run.
concurrency:
group: codeql-${{ github.ref }}-${{ github.event.pull_request.number || github.sha }}
group: codeql-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
@@ -29,7 +33,7 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev
- name: Initialize CodeQL
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
with:
languages: c-cpp
build-mode: manual
@@ -38,6 +42,6 @@ jobs:
run: scripts/build.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
with:
category: "/language:c-cpp"
+43
View File
@@ -45,6 +45,10 @@ on:
description: 'Skip candidate scan; mark unscanned and default to stripped'
type: boolean
default: false
skip_memwaste:
description: 'Skip the waste sanitizer (report-only) and the fuzz smoke'
type: boolean
default: false
soak_level:
description: 'Soak: full (quick+asan), quick (10min), none'
type: choice
@@ -58,6 +62,13 @@ jobs:
# ── Security (independent island — does not block main pipeline) ──
security:
uses: ./.github/workflows/_security.yml
# The called job requests security-events:read to count code-scanning
# alerts; a reusable workflow cannot request more than its caller grants,
# so withholding this here fails the whole run at startup.
permissions:
contents: read
security-events: read
actions: read
secrets: inherit
# ── Lint (cppcheck + clang-format) ────────────────────────────
@@ -100,6 +111,38 @@ jobs:
with:
broad_platforms: true
# ── Waste sanitizer, REPORT-ONLY (independent island) ─────────
# Event lane on Linux, macOS and Windows, access lane and the per-function
# scaling lane on Linux. Uploads reports; fails only when the measurement is
# unsound (a defect of the tool itself).
memwaste:
if: ${{ inputs.skip_memwaste != true }}
uses: ./.github/workflows/_memwaste.yml
with:
mode: full
# ── Fuzz smoke (independent island) ───────────────────────────
# A fixed number of executions from a fixed seed per target, so the verdict is
# a function of the commit (O9). A crash here is a real defect in a parser
# that reads untrusted input; its reproducer is uploaded.
fuzz:
if: ${{ inputs.skip_memwaste != true }}
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install deps
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev clang llvm libclang-rt-dev
- name: Fuzz smoke (extract, cypher, config)
run: scripts/fuzz.sh all --runs 20000 --seed 1
- name: Upload reproducers
if: ${{ failure() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fuzz-artifacts
path: build/fuzz/artifacts-*/
if-no-files-found: ignore
# ── Soak tests (optional, parallel with smoke) ────────────────
soak:
if: ${{ inputs.soak_level != 'none' && !cancelled() && needs.build.result != 'failure' && needs.build.result != 'skipped' }}
+21
View File
@@ -35,6 +35,27 @@ permissions:
contents: read
jobs:
# Fuzz exploration: new inputs are the point here, so it runs for a time
# budget instead of a fixed count. NON-GATING like the soak; a crash uploads
# its reproducer, which becomes a fixed-seed regression input once fixed.
fuzz:
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install deps
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev clang llvm libclang-rt-dev
- name: Fuzz exploration (30 min per target)
run: scripts/fuzz.sh all --max-total-time 1800 --seed 0
- name: Upload reproducers
if: ${{ failure() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fuzz-artifacts-nightly
path: build/fuzz/artifacts-*/
if-no-files-found: ignore
soak:
uses: ./.github/workflows/_soak.yml
with:
+1 -1
View File
@@ -41,4 +41,4 @@ jobs:
with:
path: ./docs
- id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
+253 -25
View File
@@ -1,7 +1,10 @@
# PR validation: security gates + lint + full test suite. Builds, smoke and
# soak stay maintainer-driven via the dry-run workflow_dispatch. Branch
# PR validation: security gates + lint + the test legs this PR's changes need.
# `changes` runs scripts/ci/select-lanes.sh on the PR's file list; every lane
# job below runs only when selected (unknown paths, workflows, the harness,
# Makefile.cbm and vendored code select everything). Builds, smoke and soak at
# full breadth stay maintainer-driven via the dry-run workflow_dispatch. Branch
# protection requires `dco` + `ci-ok` — a single stable summary context that
# fails unless every PR stage succeeded.
# fails unless every PR stage succeeded and every selected lane ran green.
name: PR
on:
@@ -21,35 +24,112 @@ concurrency:
jobs:
security:
needs: [changes]
uses: ./.github/workflows/_security.yml
# The called job requests security-events:read to count code-scanning
# alerts; a reusable workflow cannot request more than its caller grants,
# so withholding this here fails the whole run at startup.
permissions:
contents: read
security-events: read
actions: read
secrets: inherit
with:
lanes: ${{ needs.changes.outputs.lanes }}
lint:
needs: [changes]
if: ${{ contains(fromJSON(needs.changes.outputs.lanes), 'lint') || contains(fromJSON(needs.changes.outputs.lanes), 'lint-mem') }}
uses: ./.github/workflows/_lint.yml
with:
lanes: ${{ needs.changes.outputs.lanes }}
# ── Smart CI, PR CI only: run only the tests this change can reach.
# Active only while the repository variable TEST_IMPACT_MODE is `gate`;
# unset (shadow) every test runs and test-impact-shadow only reports.
# test-impact-select.sh --gate narrows only on a well-formed answer: a
# failure of this job, a run-all answer or a lane that selects nothing
# leaves `test` without a selection, and every test runs. ci-ok does not
# need this job: when it breaks, PRs cost more minutes, never coverage.
# Dry runs and releases never pass a selection.
select-tests:
needs: [changes]
if: ${{ vars.TEST_IMPACT_MODE == 'gate' }}
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
actions: read # the merge base's team-artifact bundle (test-impact-artifact.yml)
outputs:
selection: ${{ steps.select.outputs.selection }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Full history: the engine certifies the history it diffs and refuses
# a shallow clone, so a depth-2 checkout always answered `all`.
fetch-depth: 0
persist-credentials: false
- name: Build this checkout's engine
run: |
sudo apt-get update && sudo apt-get install -y zlib1g-dev
scripts/build.sh CC=gcc CXX=g++
- name: Select the tests
id: select
env:
GH_TOKEN: ${{ github.token }}
run: scripts/ci/test-impact-select.sh --binary build/c/codebase-memory-mcp --out "$RUNNER_TEMP/test-impact" --gate "$RUNNER_TEMP/test-selection"
- name: Upload the selection
if: ${{ steps.select.outputs.selection == 'narrowed' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-selection
path: ${{ runner.temp }}/test-selection
if-no-files-found: error
overwrite: true
# The selection a veto discarded (written only when one did): evidence
# for the vetoes, never read by a test leg.
- name: Upload the vetoed selection
if: ${{ !cancelled() && steps.select.outcome != 'skipped' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-selection-vetoed
path: ${{ runner.temp }}/test-impact/selection-vetoed.json
if-no-files-found: ignore
overwrite: true
test:
needs: [lint]
needs: [changes, lint, select-tests]
if: ${{ !cancelled() && needs.lint.result == 'success' }}
uses: ./.github/workflows/_test.yml
with:
lanes: ${{ needs.changes.outputs.lanes }}
# Smart CI: only a successful select-tests run that narrowed passes a
# selection; skipped (shadow), failed, run-all and nothing-selected
# answers all run every test.
test_selection: ${{ needs.select-tests.result == 'success' && needs.select-tests.outputs.selection == 'narrowed' && 'test-selection' || '' }}
# Perf assertions are timing-sensitive on shared runners; they stay in
# dry runs and releases where a flaky red does not block a merge.
skip_perf: true
# Iteration speed: split each C-suite leg across parallel shard jobs
# (ubuntu x3, windows x2; coverage re-proven every run by the
# shard-completeness job). Dry runs and releases shard identically.
# Iteration speed: split C-suite legs across parallel shard jobs
# (coverage re-proven every run by the shard-completeness job). The PR
# profile shards only arm64 (x2); x86 and Windows run whole, since each
# shard re-pays its setup. Dry runs and releases keep x3 / x2.
shard_suites: true
shard_profile: pr
# ── Which files changed? Gate the (heavier) build+smoke on product code so
# docs / CI / test-only PRs stay fast. ──
# ── Which lanes does this PR need? The changed files go through
# scripts/ci/select-lanes.sh (tier, lanes, full, reasons); docs / CI /
# test-only PRs stay fast, anything it cannot place runs everything. ──
changes:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
product: ${{ steps.f.outputs.product }}
tier: ${{ steps.select.outputs.tier }}
full: ${{ steps.select.outputs.full }}
lanes: ${{ steps.select.outputs.lanes }}
smoke: ${{ steps.select.outputs.smoke }}
steps:
- name: Detect product-code changes
id: f
- name: List the changed files
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
@@ -57,33 +137,76 @@ jobs:
run: |
# The full .diff endpoint rejects large-but-valid PRs at 20k lines.
# The paginated files endpoint remains filename-only for this gate.
FILES=$(gh api --paginate "repos/$REPO/pulls/$PR/files?per_page=100" --jq '.[].filename')
printf '%s\n' "$FILES"
if printf '%s\n' "$FILES" | grep -qE '^(src/|internal/|install\.(sh|ps1)|scripts/build\.sh|scripts/smoke-test\.sh|scripts/smoke-local\.sh|scripts/smoke-fixture-server\.py|scripts/gen-third-party-notices\.sh|scripts/env\.sh|test-infrastructure/vm/(vm-smoke\.sh|windows-user-path-guard\.ps1)|Makefile\.cbm)'; then
echo "product=true" >> "$GITHUB_OUTPUT"
# A rename lists its old path too, as the fallback's --no-renames
# diff does: moving a file away is a change to where it was.
if FILES=$(gh api --paginate "repos/$REPO/pulls/$PR/files?per_page=100" --jq '.[] | .filename, (.previous_filename // empty)'); then
echo "file list: pulls/files API"
else
echo "product=false" >> "$GITHUB_OUTPUT"
# The files endpoint renders the diff server-side too, and answers
# 422 "this diff is taking too long to generate" for a PR that
# regenerates a vendored parser (#2246: a 42 MB sql/parser.c) --
# so does the compare endpoint. The PR merge ref's first parent is
# the base, so a name-only diff across it is the same file list.
# depth 2 + blob:none fetches commits and trees only (well under
# 1 MB, under a second); nothing from the PR is checked out or run.
echo "::notice::pulls/files API failed; file list taken from refs/pull/$PR/merge"
CHANGES_GIT="$RUNNER_TEMP/changes.git"
git init -q --bare "$CHANGES_GIT"
git --git-dir="$CHANGES_GIT" fetch -q --depth=2 --filter=blob:none \
"https://github.com/$REPO" "refs/pull/$PR/merge"
FILES=$(git --git-dir="$CHANGES_GIT" diff --name-only --no-renames FETCH_HEAD^1 FETCH_HEAD)
fi
printf '%s\n' "$FILES" > "$RUNNER_TEMP/changed-files.txt"
cat "$RUNNER_TEMP/changed-files.txt"
# The selector is PR code: it runs in its own step, after the
# token-bearing one, from a checkout that keeps no credentials.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# The pulls/files API lists at most 3000 files: the PR's own count lets
# the selector notice a cut list and select everything instead.
- name: Select lanes
id: select
env:
CHANGED_FILES: ${{ github.event.pull_request.changed_files }}
run: |
scripts/ci/select-lanes.sh --format github-output --expect-files "$CHANGED_FILES" "$RUNNER_TEMP/changed-files.txt" > "$RUNNER_TEMP/selection.txt"
cat "$RUNNER_TEMP/selection.txt"
cat "$RUNNER_TEMP/selection.txt" >> "$GITHUB_OUTPUT"
# pr-smoke's legs, each tagged with its lane (kept in the job name,
# where ci-ok reads it back); the job gets the selected ones.
SMOKE='[{"os":"ubuntu-latest","lane":"smoke-ubuntu"},{"os":"macos-14","lane":"smoke-mac"},{"os":"windows-latest","lane":"smoke-win"}]'
LANES=$(sed -n 's/^lanes=//p' "$RUNNER_TEMP/selection.txt")
echo "smoke={\"include\":$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$SMOKE")}" >> "$GITHUB_OUTPUT"
# ── Light smoke: build the PRODUCTION binary and run the core smoke on the
# three RELIABLE NATIVE platforms. Catches built-binary regressions at PR
# time (e.g. the Windows CreateProcess argv-quoting class that previously
# only surfaced in the release dry run). The full broad/emulated smoke stays
# in the dry run. Only product-code PRs pay this; every leg gates.
# in the dry run. Only the selected legs run (ubuntu from the extraction
# tier up, all three for platform/packaging changes); every leg gates.
# The maintained local wrappers add a race-free release fixture so the
# download/checksum/install/update phases run here as well. ──
pr-smoke:
needs: [changes]
if: ${{ !cancelled() && needs.changes.outputs.product == 'true' }}
if: ${{ !cancelled() && needs.changes.result == 'success' && (contains(fromJSON(needs.changes.outputs.lanes), 'smoke-ubuntu') || contains(fromJSON(needs.changes.outputs.lanes), 'smoke-mac') || contains(fromJSON(needs.changes.outputs.lanes), 'smoke-win')) }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-14, windows-latest]
matrix: ${{ fromJSON(needs.changes.outputs.smoke) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# SMOKE_REQUIRE_WRAPPERS=1 needs a Go toolchain for the Go wrapper; the
# macOS runner images have none on PATH.
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: pkg/go/go.mod
cache: false
- name: Install deps (Ubuntu)
if: matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev ccache
@@ -126,6 +249,7 @@ jobs:
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_MAXSIZE: 1000M
SMOKE_REQUIRE_WRAPPERS: "1"
- name: Build prod + smoke (macOS)
if: matrix.os == 'macos-14'
@@ -136,6 +260,7 @@ jobs:
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_MAXSIZE: 1000M
SMOKE_REQUIRE_WRAPPERS: "1"
- name: Build prod + smoke (Windows)
if: matrix.os == 'windows-latest'
@@ -146,20 +271,123 @@ jobs:
env:
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_MAXSIZE: 1000M
SMOKE_REQUIRE_WRAPPERS: "1"
# ── Waste sanitizer, REPORT-ONLY: memory + CPU waste on this repository and
# the per-function scaling lane (Linux). Deliberately NOT in ci-ok's needs:
# it uploads evidence and prints the ratchet, it never blocks a merge. Only
# PRs whose selection holds the memwaste lane (memory core, memwaste
# tooling, full runs) pay for it. ──
memwaste:
needs: [changes]
if: ${{ !cancelled() && needs.changes.result == 'success' && contains(fromJSON(needs.changes.outputs.lanes), 'memwaste') }}
uses: ./.github/workflows/_memwaste.yml
with:
mode: pr
# ── Test-impact SHADOW, REPORT-ONLY: predicts from the code graph which C
# suites this change can affect (the latest released binary indexes the
# PR checkout; detect_changes at depth 15 against the base, mapped to
# suites, with the selector's run-all triggers) and uploads the
# prediction, so graph-based selection can be judged against what really
# failed before anything gates on it. continue-on-error and NOT in
# ci-ok's needs: it can never block or pass a merge. ──
test-impact-shadow:
runs-on: ubuntu-latest
timeout-minutes: 45
continue-on-error: true
permissions:
contents: read
actions: read # the merge base's team-artifact bundle (test-impact-artifact.yml)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Full history: the engine (test-impact select) certifies the history
# it diffs and refuses a shallow clone ("Graft or shallow state
# prevents history certification"), which made every shadow answer
# run_all (GIT_UNAVAILABLE). About 400 MiB, this job only.
fetch-depth: 0
persist-credentials: false
- name: Fetch the latest released binary
id: release
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: scripts/ci/test-impact-shadow.sh --fetch-release "$RUNNER_TEMP/cbm-bin"
- name: Build the binary from this checkout (download failed)
if: ${{ steps.release.outcome == 'failure' }}
run: |
sudo apt-get update && sudo apt-get install -y zlib1g-dev
scripts/build.sh CC=gcc CXX=g++
mkdir -p "$RUNNER_TEMP/cbm-bin"
cp build/c/codebase-memory-mcp "$RUNNER_TEMP/cbm-bin/"
- name: Predict the impacted suites
run: scripts/ci/test-impact-shadow.sh --binary "$RUNNER_TEMP/cbm-bin/codebase-memory-mcp" --out "$RUNNER_TEMP/test-impact"
# The engine (test-impact select) is this checkout's own code: the
# released binary predates it, so the checkout is built either way.
- name: Build this checkout's engine
if: ${{ steps.release.outcome != 'failure' }}
run: |
sudo apt-get update && sudo apt-get install -y zlib1g-dev
scripts/build.sh CC=gcc CXX=g++
- name: Select with the team artifact (engine shadow)
env:
GH_TOKEN: ${{ github.token }}
run: scripts/ci/test-impact-select.sh --binary build/c/codebase-memory-mcp --out "$RUNNER_TEMP/test-impact"
- name: Upload the prediction
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-impact-prediction
path: |
${{ runner.temp }}/test-impact/prediction.json
${{ runner.temp }}/test-impact/selection.json
${{ runner.temp }}/test-impact/changed-files.txt
${{ runner.temp }}/test-impact/selection-engine.json
${{ runner.temp }}/test-impact/summary-engine.md
if-no-files-found: warn
# ── Docs-only and non-product PRs (tiers T0a, T0b) run the static contract
# steps of the canonical test leg on their own: they police docs
# surfaces (language counts, version metadata), test-infrastructure/ and
# lint config too. Every test leg runs the same steps. ──
contracts:
needs: [changes]
if: ${{ !cancelled() && needs.changes.result == 'success' && contains(fromJSON(needs.changes.outputs.lanes), 'contracts') }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Contract steps only
run: scripts/test.sh --contracts-only
ci-ok:
# The one required context (besides dco) — fails unless every PR stage
# succeeded, so matrix renames can never silently deadlock merges. `skipped`
# is OK: pr-smoke skips on docs/CI/test-only PRs (changes.product == false).
needs: [security, lint, test, changes, pr-smoke]
# succeeded, so matrix renames can never silently deadlock merges, and
# every lane the selector chose ran green: `skipped` is OK only for a lane
# nobody selected (a selected lane that skipped is a silent gate loss).
# memwaste and the test-impact shadow are report-only and not needed here.
needs: [changes, security, lint, test, pr-smoke, contracts]
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
# actions:read lists this run's jobs, the lane-level truth behind `needs`.
permissions:
contents: read
actions: read
steps:
# Needs a checkout (unlike before): the gate logic lives in the canonical
# scripts/ci entry, not inline YAML (venue-parity contract).
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: All PR stages must have succeeded
- name: List this run's jobs
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?filter=latest&per_page=100" \
--jq '.jobs[] | {name, status, conclusion}' > "$RUNNER_TEMP/jobs.jsonl"
- name: All PR stages and every selected lane must have succeeded
env:
RESULTS: ${{ toJSON(needs) }}
LANES: ${{ needs.changes.outputs.lanes }}
JOBS: ${{ runner.temp }}/jobs.jsonl
run: scripts/ci/require-all-green.sh
+43 -3
View File
@@ -44,6 +44,13 @@ jobs:
# ── Security (starts immediately, blocks only verify) ───────────
security:
uses: ./.github/workflows/_security.yml
# The called job requests security-events:read to count code-scanning
# alerts; a reusable workflow cannot request more than its caller grants,
# so withholding this here fails the whole run at startup.
permissions:
contents: read
security-events: read
actions: read
secrets: inherit
# ── 0. Preflight: refuse malformed dispatch inputs ──────────────
@@ -138,6 +145,15 @@ jobs:
version: ${{ inputs.version }}
use_release_artifacts: true
# ── Waste sanitizer, REPORT-ONLY ────────────────────────────────
# The same full run as the dry run, on the release commit. Not in
# release-draft's needs: the report is evidence for the release notes, never
# a publication gate.
memwaste:
uses: ./.github/workflows/_memwaste.yml
with:
mode: full
# ── 6. Create DRAFT release ────────────────────────────────────
# Requires smoke to have actually SUCCEEDED, not merely "not failed". The bare
# !cancelled() && !failure() form is fail-OPEN: a skipped smoke is neither
@@ -202,7 +218,7 @@ jobs:
run: scripts/ci/append-legacy-alias-checksums.sh checksums.txt
- name: Attest checksum provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: checksums.txt
@@ -220,8 +236,20 @@ jobs:
- name: Generate SBOM
run: python3 scripts/ci/generate-sbom.py "${{ inputs.version }}"
# actions/attest-sbom is deprecated. What it does now is emit
# "::warning::actions/attest-sbom has been deprecated" and forward to
# actions/attest with NODE_OPTIONS set, passing sbom-path straight
# through. Calling attest directly produces the same attestation without
# the wrapper, and sbom-path is a first-class input there (verified
# against the action definition at this exact SHA, which is tag v4.1.0).
#
# This step deliberately carries no continue-on-error: a release must
# fail if its SBOM cannot be attested. An unattested SBOM is worse than
# an absent one, because the artifact still ships looking complete.
- name: Attest SBOM
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
env:
NODE_OPTIONS: '--max-http-header-size=32768'
with:
subject-path: '*.tar.gz'
sbom-path: 'sbom.json'
@@ -255,11 +283,17 @@ jobs:
# move the release tag. (A floating HEAD also broke the 0.8.0 release:
# HEAD had become a commit touching .github/workflows/, and the App
# token may not create refs pointing at workflow-modifying commits.)
# The Go wrapper is a nested module: `go install
# .../pkg/go/cmd/codebase-memory-mcp@vX.Y.Z` and @latest resolve its
# versions from pkg/go/v* tags only (without one, Go fetches the whole
# repository, which is over its 500 MB module limit).
run: |
git tag -f "$VERSION" "$GITHUB_SHA"
git push origin "$VERSION" --force
git tag -f "pkg/go/$VERSION" "$GITHUB_SHA"
git push origin "pkg/go/$VERSION" --force
- uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v2
- uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: ${{ inputs.version }}
draft: true
@@ -482,6 +516,12 @@ jobs:
else
npm publish --access public --provenance
fi
# `next` is "the newest you can opt into": once a stable release
# exists, an older RC must not stay behind it (0.9.1-rc.1 did until
# v0.12.1). Re-runnable: dist-tag add is idempotent.
if [[ "$RELEASE_VERSION" != *-* ]]; then
npm dist-tag add "codebase-memory-mcp@$V" next
fi
# ── PyPI ─────────────────────────────────────────────────
- name: Setup Python
+7 -1
View File
@@ -8,6 +8,12 @@ on:
permissions: read-all
# Only the newest main commit is scored: a newer push (or the weekly run)
# cancels a pending or running score of an older commit.
concurrency:
group: scorecard-${{ github.ref }}
cancel-in-progress: true
jobs:
scorecard:
runs-on: ubuntu-latest
@@ -27,6 +33,6 @@ jobs:
publish_results: true
- name: Upload SARIF results
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4
with:
sarif_file: results.sarif
+1 -1
View File
@@ -1,7 +1,7 @@
# Smoke invariants across the WIDEST set of GitHub-hosted runners. Builds the
# production source path with explicit fault-injection test seams and runs
# scripts/smoke-invariants.sh
# (version/help, MCP initialize handshake [#513], all 14 tools invocable, malformed-
# (version/help, MCP initialize handshake [#513], all registered tools invocable, malformed-
# input resilience, clean EOF exit, shared-lib resolution, install dry-run).
#
# Maximizing platforms is the point: ubuntu-22.04 (older glibc → AlmaLinux/#182
@@ -0,0 +1,87 @@
# Team artifact for test selection (smart CI, report-only until the oracles
# and the shadow comparison are green). Every push to main publishes the
# commit's bundle — its frozen graph, built incrementally from the previous
# bundle, and its per-test coverage map, re-run only for the suites the push
# selects — as the Actions artifact test-impact-<sha>. A weekly run rebuilds
# both from scratch and fails when the incremental graph differs from the full
# one. PR selection fetches the bundle of its merge base
# (scripts/ci/test-impact-bundle.sh) and never narrows on anything else.
name: test-impact-artifact
on:
push:
branches: [main]
schedule:
- cron: "23 3 * * 0" # weekly full refresh, Sunday 03:23 UTC
workflow_dispatch:
inputs:
mode:
description: incremental or full
type: choice
options: [incremental, full]
default: full
permissions:
contents: read
actions: read # fetch the previous bundle
concurrency:
# Bundles chain: each push builds on the previous one, so runs never overlap
# and are never cancelled half-way.
group: test-impact-artifact
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-24.04
timeout-minutes: 120
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Full history: `test-impact select/publish` certify the history they
# read and refuse a shallow clone.
fetch-depth: 0
persist-credentials: false
- name: Install build deps and LLVM 21 (coverage toolchain)
run: |
sudo apt-get update && sudo apt-get install -y zlib1g-dev
wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc
echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list
sudo apt-get update
sudo apt-get install -y clang-21 llvm-21 libclang-rt-21-dev
- name: Build the production binary and the coverage runner
run: |
scripts/build.sh CC=gcc CXX=g++
make -f Makefile.cbm test-runner-cov COV_LLVM_BIN=/usr/lib/llvm-21/bin -j"$(nproc)"
- name: Publish the bundle
env:
GH_TOKEN: ${{ github.token }}
EVENT: ${{ github.event_name }}
BEFORE: ${{ github.event.before }}
REQUESTED: ${{ inputs.mode }}
# The selection lane excludes @perf and PR CI skips perf, so their
# (slowest, longest under coverage) suites are neither mapped nor
# listed: the runner then neither lists nor runs them.
CBM_SKIP_PERF: "1"
run: |
mode=incremental
if [ "$EVENT" = schedule ] || [ "$REQUESTED" = full ]; then mode=full; fi
before="$(git rev-parse HEAD^1)"
if [ "$EVENT" = push ] && [ -n "$BEFORE" ] && [ "$BEFORE" != 0000000000000000000000000000000000000000 ]; then
before="$BEFORE"
fi
scripts/ci/test-impact-publish.sh --binary build/c/codebase-memory-mcp \
--runner build/cov/test-runner --llvm-bin /usr/lib/llvm-21/bin \
--out "$RUNNER_TEMP/bundle" --mode "$mode" --before "$before"
- name: Upload the bundle
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-impact-${{ github.sha }}
path: ${{ runner.temp }}/bundle
include-hidden-files: true # the graph lives in .codebase-memory/
retention-days: 30
if-no-files-found: error
+7
View File
@@ -51,6 +51,9 @@ graph-ui/dist/
# Generated by scripts/embed-frontend.sh into the source tree (the linker needs
# it beside the other UI sources). Removed by scripts/clean.sh; never committed.
src/ui/embedded_assets.c
# Raw coverage profiles. A coverage-instrumented binary (make test-runner-cov,
# cbm-cov) run without LLVM_PROFILE_FILE drops default.profraw in its cwd.
*.profraw
# Generated reports
BENCHMARK_REPORT.md
@@ -86,3 +89,7 @@ soak-results-query-leak/
# Scorecard as a binary artifact in source). Source only, never the build.
/pkg/go/codebase-memory-mcp
/pkg/go/codebase-memory-mcp.exe
# Nix build symlinks (nix build → ./result, ./result-1, …)
/result
/result-*
+20 -1
View File
@@ -55,7 +55,7 @@ src/
foundation/ Arena allocator, hash table, string utils, platform compat
store/ SQLite graph storage (WAL mode, FTS5)
cypher/ Cypher query → SQL translation
mcp/ MCP server (JSON-RPC 2.0 over stdio, 14 tools)
mcp/ MCP server (JSON-RPC 2.0 over stdio, 17 tools)
pipeline/ Multi-pass indexing pipeline
pass_*.c Individual pipeline passes (definitions, calls, usages, etc.)
httplink.c HTTP route extraction (Go/Express/Laravel/Ktor/Python)
@@ -149,6 +149,25 @@ If in doubt, open an issue and ask.
- Run `scripts/test.sh` and `scripts/lint.sh` before submitting
- Keep PRs focused — avoid unrelated reformatting or refactoring
## AI-assisted and AI-authored contributions
Contributions written with the help of an AI tool, or written by an AI agent acting for a person, are welcome here on the same terms as any other. What we review is the change, not who or what typed it. Three things make that work:
- **Say so.** If an agent wrote the PR or is answering in the thread, disclose it — a line in the PR description or at the top of a comment is enough ("written with X", "this account is operated by an AI agent on behalf of Y"). Disclosure is never held against a contribution. Finding out later is.
- **A person is accountable.** The [DCO sign-off](#license-and-sign-off-dco--required-on-every-commit) is a legal certification, and only a person can make it. The human behind the account certifies every commit, answers for it, and is who we talk to if something goes wrong. An agent may carry the conversation; it cannot carry the responsibility.
- **The bar does not move.** Every claim in a PR must be something a reviewer can check: the failing test, the command that was run, the numbers and the machine they came from. "The model says it works" is not evidence, and a confident description does not replace a test that fails without the fix. We read AI-written PRs exactly as carefully as human-written ones — no more suspiciously, and no less.
Two practical notes:
- `Co-authored-by:` trailers naming a tool are fine. Links to private chat or agent sessions are not — they are dead to everyone else and do not belong in permanent history; we will ask you to remove them.
- High-volume, low-effort submissions — many near-identical PRs, changes nobody ran, replies that do not engage with the review — will be closed, whoever or whatever produced them. One careful PR is worth more than twenty generated ones.
### Who speaks for the project
Only the maintainer account ([@DeusData](https://github.com/DeusData)) speaks for this project. A review, an approval, a "this will be merged" or a "this is a duplicate" from any other account is that person's opinion, however official it sounds — helpful reviews from the community are very welcome, and that is what they are.
**Impersonating a maintainer is not acceptable**, by a person or by an agent: presenting yourself as part of the maintainer team, posting approvals or change requests styled as maintainer decisions, or telling other contributors what the project has decided. We dismiss such reviews, hide the comments, say publicly in the affected threads that they were not ours, and report and block the account. If you are unsure whether a response you received is from the project, check the account name — and ask.
## Security
We take security seriously. All PRs go through:
+449 -47
View File
@@ -71,19 +71,40 @@ ifeq ($(TEST_SEAMS),1)
TEST_SEAM_DEFINE := -DCBM_ENABLE_TEST_SEAMS=1
endif
# Per-test coverage for the test-impact map. TEST_COVERAGE=1 compiles clang
# source coverage into OUR code only -- the one-shot sources, the LSP unity
# object and the preprocessor -- in the test runner and in the product binary,
# and turns on the runner's per-test profile hooks (tests/test_framework.h).
# Vendored objects stay uninstrumented: a vendored change selects tests by rule,
# and every counter compiled in is written out again for every test. Like every
# other flavour it needs its own BUILD_DIR; the test-runner-cov and cbm-cov
# targets pin one.
# -fprofile-continuous: a child process started with `%c` in LLVM_PROFILE_FILE
# keeps its counters mapped onto its profile file, so a child that is killed
# still leaves a complete profile. Linux needs the flag for `%c` to work at
# all; with it macOS behaves the same way (tests/test_main.c, "Children").
TEST_COVERAGE ?= 0
TEST_COVERAGE_FLAGS :=
TEST_COVERAGE_DEFINE :=
ifeq ($(TEST_COVERAGE),1)
TEST_COVERAGE_FLAGS := -fprofile-instr-generate -fcoverage-mapping -fprofile-continuous
TEST_COVERAGE_DEFINE := -DCBM_TEST_COVERAGE=1
endif
# Production flags (CFLAGS_EXTRA allows CI to inject -DCBM_VERSION)
# CBM_BIND_TS_ALLOCATOR=1: bind the tree-sitter runtime to mimalloc (#424). Only
# the prod build uses mimalloc (MI_OVERRIDE=1); the test build is CRT+ASan, where
# binding would create an alloc/free mismatch, so the guard is prod-only.
CFLAGS_PROD = $(CFLAGS_COMMON) -O2 -DCBM_BIND_TS_ALLOCATOR=1 $(TEST_SEAM_DEFINE) \
$(GLOBAL_OVERRIDE_DEFINE) $(CFLAGS_EXTRA)
CXXFLAGS_PROD = $(CXXFLAGS_COMMON) -O2
$(GLOBAL_OVERRIDE_DEFINE) $(MEMWASTE_CFLAGS) $(TEST_COVERAGE_FLAGS) $(CFLAGS_EXTRA)
CXXFLAGS_PROD = $(CXXFLAGS_COMMON) -O2 $(MEMWASTE_VENDOR_CFLAGS) $(TEST_COVERAGE_FLAGS)
# Test flags: debug + sanitizers (override SANITIZE= to disable on Windows)
SANITIZE = -fsanitize=address,undefined -fno-omit-frame-pointer
EDITOR_TEST_DEFINES = -DCBM_JSON_LIKE_ENABLE_TEST_API=1 \
-DCBM_TOML_EDIT_ENABLE_TEST_API=1 -DCBM_YAML_ENABLE_TEST_API=1 \
-DCBM_TEXT_EDIT_ENABLE_TEST_API=1 -DCBM_CLI_ENABLE_TEST_API=1 \
-DCBM_CONFIG_EDIT_PATH_ENABLE_TEST_API=1 \
-DCBM_DIAGNOSTICS_ENABLE_TEST_API=1 -DCBM_ENABLE_TEST_SEAMS=1
# The build system is the single source of truth for "is this binary
# instrumented": compiler-specific probes (__SANITIZE_ADDRESS__) miss
@@ -98,10 +119,13 @@ endif
KOTLIN_DEDUP_TEST_DEFINE = -DCBM_KOTLIN_DEDUP_TEST_API=1
CALL_REFERENCE_LOOKUP_TEST_DEFINE = -DCBM_CALL_REFERENCE_LOOKUP_TEST_API=1
INCREMENTAL_TEST_DEFINE = -DCBM_INCREMENTAL_TEST_API=1
COVERAGE_MARKER_TEST_DEFINE = -DCBM_COVERAGE_MARKER_TEST_API=1
CFLAGS_TEST = $(CFLAGS_COMMON) $(EDITOR_TEST_DEFINES) $(SANITIZED_DEFINE) \
$(KOTLIN_DEDUP_TEST_DEFINE) $(CALL_REFERENCE_LOOKUP_TEST_DEFINE) \
$(INCREMENTAL_TEST_DEFINE) -g -O1 $(SANITIZE)
CXXFLAGS_TEST = $(CXXFLAGS_COMMON) $(SANITIZED_DEFINE) -g -O1 $(SANITIZE) $(CXX_STDLIB_FLAGS)
$(INCREMENTAL_TEST_DEFINE) $(COVERAGE_MARKER_TEST_DEFINE) $(TEST_COVERAGE_DEFINE) \
-g -O1 $(SANITIZE) $(TEST_COVERAGE_FLAGS)
CXXFLAGS_TEST = $(CXXFLAGS_COMMON) $(SANITIZED_DEFINE) -g -O1 $(SANITIZE) $(TEST_COVERAGE_FLAGS) \
$(CXX_STDLIB_FLAGS)
# TSan (can't combine with ASan)
TSAN_SANITIZE = -fsanitize=thread -fno-omit-frame-pointer
@@ -118,6 +142,7 @@ TSAN_SANITIZE = -fsanitize=thread -fno-omit-frame-pointer
# macro of ours.
CFLAGS_TSAN = $(CFLAGS_COMMON) $(EDITOR_TEST_DEFINES) $(KOTLIN_DEDUP_TEST_DEFINE) \
$(CALL_REFERENCE_LOOKUP_TEST_DEFINE) $(INCREMENTAL_TEST_DEFINE) \
$(COVERAGE_MARKER_TEST_DEFINE) \
-DCBM_SANITIZED_BUILD=1 -g -O1 $(TSAN_SANITIZE)
CXXFLAGS_TSAN = $(CXXFLAGS_COMMON) -DCBM_SANITIZED_BUILD=1 -g -O1 \
$(TSAN_SANITIZE)
@@ -181,13 +206,107 @@ endif
# ~259 MB of tree-sitter parse tables was mapped executable at runtime while the
# amd64 build of the same source mapped them R only. That is a large ROP gadget
# surface for data that is never executed, and it is invisible to section flags.
#
# -z relro + -z now finish the same job on the OTHER writable-but-shouldn't-be
# region: the GOT. Both are free at runtime here (a static binary resolves
# everything at link time, so there is no lazy binding left to pay for), and
# they are added because the SHIPPED artifact measurably lacked the property --
# not because the linker accepts the flags. Measured on this binary, built the
# way .github/workflows/_build.yml builds the Linux release (STATIC=1, gcc 13.3
# / GNU ld 2.42, Ubuntu 24.04 aarch64); PT_GNU_RELRO ends at 0x11DA0000 in both:
#
# without -z now: .got 0x11d9ec20+0x13c8, .got.plt 0x11d9ffe8+0x40
# -> .got.plt ends at 0x11DA0028, i.e. 40 bytes PAST the
# window: those GOT slots stayed writable for the whole
# process lifetime, in every Linux binary we have shipped
# with -z now: .got.plt folded into .got (0x11d9ec20+0x13c8, ends
# 0x11D9FFE8) -> the whole GOT is inside the window and is
# re-mapped read-only after startup
#
# -z relro is already this toolchain's default and changes nothing here; it is
# named anyway so the property stops depending on one distro's spec file (the
# musl/portable and glibc-floor images are different toolchains, and a default
# is not a guarantee). A1c/A1d in scripts/ci/check-binary-composition.sh assert
# the OUTCOME on the produced binary, because a flag the compiler accepts is
# not evidence that the artifact gained anything -- the same reason A1 exists
# next to the .note.GNU-stack annotation.
ELF_HARDENING_FLAGS :=
ifeq ($(IS_LINUX),yes)
ifneq ($(IS_MINGW),yes)
ELF_HARDENING_FLAGS := -Wl,-z,noexecstack -Wl,-z,separate-code
ELF_HARDENING_FLAGS := -Wl,-z,noexecstack -Wl,-z,separate-code \
-Wl,-z,relro -Wl,-z,now
endif
endif
# MEMWASTE=1: the waste-sanitizer flavour (src/foundation/mem_events.h). Built
# like production -- -O2, mimalloc, the real allocator routing -- plus the event
# layer and its observers, because waste measured under ASan's allocator would
# describe ASan. Frame pointers stay so the macOS observer can walk to the
# caller. On Linux this is also what finally LINKS the --wrap observation shim:
# its flags and source were defined here but never reached a link line, so the
# Linux profiler had been reporting nothing.
#
# Every object is built this way, VENDORED ones included (grammars, the
# tree-sitter runtime, SQLite, tre, lz4, zstd, the C++ preprocessor): waste in
# a vendored parser is waste in this program. mimalloc alone is left as it is --
# its reads and writes are the allocator's own bookkeeping, not program data.
#
# MEMWASTE_ACCESS=1 adds the access lane (reads and writes of every tracked
# block through the compiler's load/store callbacks; clang only). It builds at
# -O1 without vectorisation: at -O2 the callbacks describe the OPTIMISED program,
# and reads the optimiser removed would be reported as never read.
# MEMWASTE_PROFILE=1 adds clang's execution counters (-fprofile-instr-generate)
# for the CPU scaling lane (scripts/memwaste-scaling.py).
# Each flavour needs its own BUILD_DIR: vendored objects are not rebuilt when
# only the flavour changes.
#
# The libc wrappers (mem_override_libc.c) need --wrap on Linux and Windows;
# macOS defines the symbols instead. STATIC=1 (musl) is not a memwaste target.
MEMWASTE ?= 0
MEMWASTE_ACCESS ?= 0
MEMWASTE_PROFILE ?= 0
ifeq ($(MEMWASTE_ACCESS),1)
override MEMWASTE := 1
endif
ifeq ($(MEMWASTE_PROFILE),1)
override MEMWASTE := 1
endif
MEMWASTE_CFLAGS :=
MEMWASTE_VENDOR_CFLAGS :=
MEMWASTE_LDFLAGS :=
MEMWASTE_SRCS :=
MEMWASTE_ALLOC_SYMS_LINUX := malloc calloc realloc free strdup strndup posix_memalign \
aligned_alloc memalign
MEMWASTE_LIBC_SYMS_LINUX := memcpy memmove memset memcmp strlen strcmp strncmp \
__memcpy_chk __memmove_chk __memset_chk read __read_chk write \
pread pwrite pread64 pwrite64 open open64 __open_2 __open64_2 \
stat lstat fopen fopen64 fread __fread_chk fwrite opendir readdir \
pthread_mutex_lock
MEMWASTE_LIBC_SYMS_WIN := memcpy memmove memset memcmp strlen strcmp strncmp _wfopen fread fwrite
ifeq ($(MEMWASTE),1)
MEMWASTE_CFLAGS := -DCBM_MEMWASTE=1 -fno-omit-frame-pointer
MEMWASTE_VENDOR_CFLAGS := -DCBM_MEMWASTE=1 -fno-omit-frame-pointer
ifeq ($(IS_LINUX),yes)
MEMWASTE_LDFLAGS := $(foreach sym,$(MEMWASTE_ALLOC_SYMS_LINUX) $(MEMWASTE_LIBC_SYMS_LINUX),-Wl,--wrap=$(sym))
MEMWASTE_SRCS := src/foundation/mem_override_posix.c
endif
ifeq ($(IS_MINGW),yes)
MEMWASTE_LDFLAGS := $(foreach sym,$(MEMWASTE_LIBC_SYMS_WIN),-Wl,--wrap=$(sym))
endif
endif
ifeq ($(MEMWASTE_ACCESS),1)
MEMWASTE_ACCESS_FLAGS := -DCBM_MEMWASTE_ACCESS=1 -O1 -fno-vectorize -fno-slp-vectorize \
-fsanitize-coverage=inline-bool-flag,trace-loads,trace-stores
MEMWASTE_CFLAGS += $(MEMWASTE_ACCESS_FLAGS)
MEMWASTE_VENDOR_CFLAGS += $(MEMWASTE_ACCESS_FLAGS)
endif
ifeq ($(MEMWASTE_PROFILE),1)
MEMWASTE_PROFILE_FLAGS := -DCBM_MEMWASTE_PROFILE=1 -fprofile-instr-generate -fprofile-update=atomic
MEMWASTE_CFLAGS += $(MEMWASTE_PROFILE_FLAGS)
MEMWASTE_VENDOR_CFLAGS += $(MEMWASTE_PROFILE_FLAGS)
MEMWASTE_LDFLAGS += $(MEMWASTE_PROFILE_FLAGS)
endif
# The POSIX wrap shim exists only so the profiler can observe allocations. It
# must never reach a sanitized build: the Linux/macOS test builds are CRT+ASan,
# and redirecting malloc into mimalloc underneath ASan's own interception mixes
@@ -197,7 +316,7 @@ endif
# instead of the system libstdc++ (scripts/msan.sh overrides both vars).
CXX_STDLIB ?= -lstdc++
CXX_STDLIB_FLAGS ?=
LDFLAGS = -lm $(CXX_STDLIB) -lpthread -lz $(WIN32_LIBS) $(STATIC_FLAGS) $(MIMALLOC_WRAP_FLAGS) $(ELF_HARDENING_FLAGS)
LDFLAGS = -lm $(CXX_STDLIB) -lpthread -lz $(WIN32_LIBS) $(STATIC_FLAGS) $(MIMALLOC_WRAP_FLAGS) $(MEMWASTE_LDFLAGS) $(ELF_HARDENING_FLAGS)
LDFLAGS_TEST = -lm $(CXX_STDLIB) -lpthread -lz $(SANITIZE) $(WIN32_LIBS) $(MIMALLOC_WRAP_FLAGS) $(ELF_HARDENING_FLAGS)
LDFLAGS_TSAN = -lm $(CXX_STDLIB) -lpthread -lz $(TSAN_SANITIZE) $(WIN32_LIBS) $(MIMALLOC_WRAP_FLAGS) $(ELF_HARDENING_FLAGS)
@@ -212,6 +331,7 @@ FOUNDATION_SRCS = \
src/foundation/str_util.c \
src/foundation/workspace.c \
src/foundation/platform.c \
src/foundation/path_syntax_internal.c \
src/foundation/system_info.c \
src/foundation/slab_alloc.c \
src/foundation/yaml.c \
@@ -220,11 +340,17 @@ FOUNDATION_SRCS = \
src/foundation/compat_fs.c \
src/foundation/compat_regex.c \
src/foundation/mem.c \
src/foundation/mem_core.c \
src/foundation/mem_events.c \
src/foundation/mem_override_darwin.c \
src/foundation/mem_override_libc.c \
src/foundation/diagnostics.c \
src/foundation/profile.c \
src/foundation/dump_verify.c \
src/foundation/limits.c \
src/foundation/index_policy.c \
src/foundation/subprocess.c \
src/foundation/git_env.c \
src/foundation/sha256.c \
src/foundation/secure_random.c \
src/foundation/macos_acl.c \
@@ -235,10 +361,12 @@ FOUNDATION_SRCS = \
EXTRACTION_SRCS = \
$(CBM_DIR)/cbm.c \
$(CBM_DIR)/extract_defs.c \
$(CBM_DIR)/callable_sig.c \
$(CBM_DIR)/extract_calls.c \
$(CBM_DIR)/extract_imports.c \
$(CBM_DIR)/extract_usages.c \
$(CBM_DIR)/extract_unified.c \
$(CBM_DIR)/extract_url_fold.c \
$(CBM_DIR)/extract_semantic.c \
$(CBM_DIR)/extract_type_refs.c \
$(CBM_DIR)/extract_type_assigns.c \
@@ -246,11 +374,39 @@ EXTRACTION_SRCS = \
$(CBM_DIR)/extract_channels.c \
$(CBM_DIR)/extract_k8s.c \
$(CBM_DIR)/extract_dbt.c \
$(CBM_DIR)/doclink.c \
$(CBM_DIR)/doclink_cs.c \
$(CBM_DIR)/doclink_md.c \
$(CBM_DIR)/doclink_rst.c \
$(CBM_DIR)/doclink_py.c \
$(CBM_DIR)/doc_adr.c \
$(CBM_DIR)/doc_pdf.c \
$(CBM_DIR)/doc_adoc.c \
$(CBM_DIR)/pdf/pdf_obj.c \
$(CBM_DIR)/pdf/pdf_font.c \
$(CBM_DIR)/pdf/pdf_text.c \
$(CBM_DIR)/pdf/pdf_tables.c \
$(CBM_DIR)/pdf/pdf_unicode.c \
$(CBM_DIR)/doclink_go.c \
$(CBM_DIR)/doclink_jvm.c \
$(CBM_DIR)/doclink_rs.c \
$(CBM_DIR)/doclink_perl.c \
$(CBM_DIR)/doclink_ts.c \
$(CBM_DIR)/doclink_php.c \
$(CBM_DIR)/doclink_c.c \
$(CBM_DIR)/diagram.c \
$(CBM_DIR)/route_path.c \
$(CBM_DIR)/openapi.c \
$(CBM_DIR)/sql_values.c \
$(CBM_DIR)/helpers.c \
$(CBM_DIR)/cpp_branch_views.c \
$(CBM_DIR)/result_compact.c \
$(CBM_DIR)/result_spill.c \
$(CBM_DIR)/lang_specs.c \
$(CBM_DIR)/macro_table.c \
$(CBM_DIR)/iris_export_xml.c \
$(CBM_DIR)/service_patterns.c
$(CBM_DIR)/service_patterns.c \
$(CBM_DIR)/spawn_patterns.c
# LSP resolvers (compiled as one unit via lsp_all.c)
LSP_SRCS = $(CBM_DIR)/lsp_all.c
@@ -293,13 +449,15 @@ PREPROCESSOR_SRC = $(CBM_DIR)/preprocessor.cpp
SQLITE_WRITER_SRC = $(CBM_DIR)/sqlite_writer.c
# Store module (new)
STORE_SRCS = src/store/store.c
STORE_SRCS = src/store/store.c src/store/graph_mem.c src/store/architecture_projection.c src/store/store_impact.c src/store/store_graph_digest.c src/store/store_content_digest.c
# Cypher module (new)
CYPHER_SRCS = src/cypher/cypher.c
# MCP server module (new)
MCP_SRCS = src/mcp/mcp.c src/mcp/index_supervisor.c src/mcp/compact_out.c
MCP_SRCS = src/mcp/mcp.c src/mcp/index_supervisor.c src/mcp/compact_out.c src/mcp/architecture_jobs.c \
src/mcp/test_impact.c src/mcp/test_impact_diff.c src/mcp/test_impact_classify.c src/mcp/test_impact_source.c src/mcp/test_impact_seed.c src/mcp/test_impact_engine.c src/mcp/test_impact_engine_snapshot.c src/mcp/test_impact_engine_reach.c src/mcp/test_impact_engine_artifact.c src/mcp/test_impact_cli.c src/mcp/test_impact_artifact.c src/mcp/test_impact_changes.c src/mcp/test_impact_origins.c src/mcp/test_impact_coverage.c src/mcp/test_impact_config.c src/mcp/test_impact_select.c src/mcp/test_impact_policy.c src/mcp/test_impact_git.c src/mcp/test_impact_inventory.c src/mcp/test_impact_inventory_input.c src/mcp/test_impact_tree.c src/mcp/test_impact_tree_audit.c src/mcp/test_impact_tree_read.c src/mcp/test_impact_tree_batch.c src/mcp/test_impact_tree_digest.c src/mcp/test_impact_tree_names.c src/mcp/test_impact_tree_plan.c src/mcp/test_impact_tree_posix.c src/mcp/test_impact_tree_posix_acl.c src/mcp/test_impact_tree_posix_io.c src/mcp/test_impact_tree_util.c src/mcp/test_impact_tree_windows.c src/mcp/test_impact_tree_windows_acl.c src/mcp/test_impact_tree_windows_io.c \
src/mcp/test_impact_result.c src/mcp/test_impact_result_json.c src/mcp/test_impact_result_project.c src/mcp/test_impact_result_receipt.c src/mcp/test_impact_result_rows.c src/mcp/test_impact_result_util.c src/mcp/test_impact_result_validate.c src/mcp/test_impact_result_wire.c
# Shared MCP daemon coordination and local transport
DAEMON_SRCS = \
@@ -318,7 +476,18 @@ DAEMON_SRCS = \
DISCOVER_SRCS = \
src/discover/language.c \
src/discover/userconfig.c \
src/discover/test_conventions.c \
src/discover/gitignore.c \
src/discover/gitignore_checked.c \
src/discover/inventory_filter.c \
src/discover/inventory.c \
src/discover/inventory_guard.c \
src/discover/inventory_metadata.c \
src/discover/inventory_directories.c \
src/discover/inventory_controls.c \
src/discover/gitignore_checked_parse.c \
src/discover/gitignore_core.c \
src/discover/gitignore_guard.c \
src/discover/discover.c
# Graph buffer module (new)
@@ -330,6 +499,7 @@ PIPELINE_SRCS = \
src/pipeline/lsp_surface.c \
src/pipeline/pipeline_delta.c \
src/pipeline/path_alias.c \
src/pipeline/laravel_routing.c \
src/pipeline/registry.c \
src/pipeline/pipeline.c \
src/pipeline/pipeline_incremental.c \
@@ -337,6 +507,7 @@ PIPELINE_SRCS = \
src/pipeline/pass_parallel.c \
src/pipeline/pass_definitions.c \
src/pipeline/pass_calls.c \
src/pipeline/pass_spawns.c \
src/pipeline/pass_lsp_cross.c \
src/pipeline/pass_usages.c \
src/pipeline/pass_semantic.c \
@@ -345,17 +516,41 @@ PIPELINE_SRCS = \
src/pipeline/pass_gitdiff.c \
src/pipeline/pass_configures.c \
src/pipeline/pass_configlink.c \
src/pipeline/pass_doclinks.c \
src/pipeline/doc_links.c \
src/pipeline/doc_links_cs.c \
src/pipeline/doc_links_md.c \
src/pipeline/doc_links_pdf.c \
src/pipeline/doc_links_rst.c \
src/pipeline/doc_links_adoc.c \
src/pipeline/doc_links_go.c \
src/pipeline/doc_links_jvm.c \
src/pipeline/doc_links_rs.c \
src/pipeline/doc_links_perl.c \
src/pipeline/doc_links_ts.c \
src/pipeline/doc_links_php.c \
src/pipeline/doc_links_c.c \
src/pipeline/pass_diagrams.c \
src/pipeline/doc_links_msbuild.c \
src/pipeline/pass_route_nodes.c \
src/pipeline/pass_enrichment.c \
src/pipeline/pass_envscan.c \
src/pipeline/pass_compile_commands.c \
src/pipeline/pass_infrascan.c \
src/pipeline/pass_k8s.c \
src/pipeline/pass_operator.c \
src/pipeline/pass_shell.c \
src/pipeline/pass_terraform.c \
src/pipeline/pass_ansible.c \
src/pipeline/pass_docker.c \
src/pipeline/pass_envjoin.c \
src/pipeline/pass_ci.c \
src/pipeline/pass_similarity.c \
src/pipeline/pass_semantic_edges.c \
src/pipeline/pass_complexity.c \
src/pipeline/pass_importance.c \
src/pipeline/pass_cross_repo.c \
src/pipeline/artifact.c \
src/pipeline/pass_ensemble_routing.c \
src/pipeline/pass_pkgmap.c
# SimHash / MinHash module
@@ -377,16 +572,25 @@ WATCHER_SRCS = src/watcher/watcher.c
GIT_SRCS = src/git/git_context.c
# CLI module (new)
CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c src/cli/client_adapter.c \
CLI_SRCS = src/cli/cli.c src/cli/runtime_settings.c src/cli/progress_sink.c src/cli/hook_augment.c src/cli/client_adapter.c \
src/cli/agent_clients.c src/cli/agent_profiles.c \
src/cli/config_json_like.c src/cli/config_toml_edit.c src/cli/config_yaml_edit.c \
src/cli/config_text_edit.c src/cli/activation_transaction.c
src/cli/config_text_edit.c src/cli/config_edit_path.c src/cli/activation_transaction.c
# UI module (graph visualization)
UI_SRCS = \
src/ui/config.c \
src/ui/http_server.c \
src/ui/activity.c \
src/ui/atlas_repository.c \
src/ui/layout3d.c \
src/ui/layout_regions.c \
src/ui/atlas_api.c \
src/ui/atlas_flows.c \
src/ui/atlas_handout.c \
src/ui/atlas_why.c \
src/ui/atlas_metrics.c \
src/ui/atlas_impact.c \
src/ui/httpd.c \
src/ui/embedded_stub.c
@@ -439,10 +643,19 @@ ifneq ($(filter yes,$(IS_MINGW) $(IS_LINUX)),)
MIMALLOC_OVERRIDE_DEFINE := -DMI_MALLOC_OVERRIDE=1
GLOBAL_OVERRIDE_DEFINE := -DCBM_MEM_GLOBAL_OVERRIDE=1
endif
# MI_DEBUG=0: the production allocator is mimalloc's RELEASE build. Its header
# defaults to MI_DEBUG=2 unless NDEBUG is defined, and nothing here defined it,
# so every shipped binary up to v0.11.0 ran mimalloc's debug build: internal
# assertions, fine-grained statistics, padding on every block and a memset of
# every freed block. The waste sanitizer measured it on the Go corpus as 92 GB
# (macOS) and 444 GB (Linux, where mimalloc also serves libc's malloc) of byte
# work inside the allocator (2026-09-17). Committed-bytes accounting, which the
# memory budget reads, is kept in every build (subproc stats, not MI_STAT).
# The TEST build below keeps mimalloc's debug checks.
MIMALLOC_CFLAGS = -std=c11 -O2 -w \
-Ivendored/mimalloc/include \
-Ivendored/mimalloc/src \
-DMI_OVERRIDE=1 \
-DMI_OVERRIDE=1 -DMI_DEBUG=0 \
$(MIMALLOC_OVERRIDE_DEFINE)
MIMALLOC_CFLAGS_TEST = -std=c11 -g -O1 -w \
-Ivendored/mimalloc/include \
@@ -463,7 +676,7 @@ MIMALLOC_CFLAGS_TEST = -std=c11 -g -O1 -w \
SQLITE3_OMIT_FLAGS = -DSQLITE_OMIT_LOAD_EXTENSION
SQLITE3_SRC = vendored/sqlite3/sqlite3.c
SQLITE3_CFLAGS = -std=c11 -O2 -w -DSQLITE_DQS=0 -DSQLITE_THREADSAFE=1 -DSQLITE_ENABLE_FTS5 \
$(SQLITE3_OMIT_FLAGS)
$(SQLITE3_OMIT_FLAGS) $(MEMWASTE_VENDOR_CFLAGS)
SQLITE3_CFLAGS_TEST = -std=c11 -g -O1 -w -DSQLITE_DQS=0 -DSQLITE_THREADSAFE=1 -DSQLITE_ENABLE_FTS5 \
$(SQLITE3_OMIT_FLAGS)
@@ -475,7 +688,7 @@ TRE_CFLAGS = -std=c11 -g -O1 -w -Ivendored/tre
YYJSON_SRC = vendored/yyjson/yyjson.c
# All production sources
PROD_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DAEMON_SRCS) $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) $(TRACES_SRCS) $(WATCHER_SRCS) $(GIT_SRCS) $(CLI_SRCS) $(UI_SRCS) $(YYJSON_SRC)
PROD_SRCS = $(MEMWASTE_SRCS) $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DAEMON_SRCS) $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) $(TRACES_SRCS) $(WATCHER_SRCS) $(GIT_SRCS) $(CLI_SRCS) $(UI_SRCS) $(YYJSON_SRC)
EXISTING_C_SRCS = $(EXTRACTION_SRCS) $(LSP_SRCS) $(TS_RUNTIME_SRC) \
$(GRAMMAR_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC)
@@ -497,12 +710,15 @@ PROJECT_HDRS = $(wildcard src/*.h src/*/*.h $(CBM_DIR)/*.h $(CBM_DIR)/lsp/*.h te
TEST_FOUNDATION_SRCS = \
tests/test_main.c \
tests/test_coverage_children.c \
tests/test_coverage_parent_failure.c \
tests/test_arena.c \
tests/test_hash_table.c \
tests/test_dyn_array.c \
tests/test_str_intern.c \
tests/test_log.c \
tests/test_str_util.c \
tests/test_index_policy.c \
tests/test_workspace.c \
tests/test_platform.c \
tests/test_diagnostics.c \
@@ -514,6 +730,8 @@ TEST_FOUNDATION_SRCS = \
TEST_EXTRACTION_SRCS = \
tests/test_extraction.c \
tests/test_callable_sig.c \
tests/test_test_conventions.c \
tests/test_extraction_inheritance.c \
tests/test_extraction_imports.c \
tests/test_parse_coverage.c \
@@ -525,8 +743,13 @@ TEST_EXTRACTION_SRCS = \
TEST_STORE_SRCS = \
tests/test_store_nodes.c \
tests/test_store_edges.c \
tests/test_store_impact.c \
tests/test_store_scope.c \
tests/test_store_graph_digest.c \
tests/test_graph_mem.c \
tests/test_store_search.c \
tests/test_store_arch.c \
tests/test_architecture_projection.c \
tests/test_store_bulk.c \
tests/test_store_pragmas.c \
tests/test_store_checkpoint.c \
@@ -537,6 +760,23 @@ TEST_CYPHER_SRCS = \
TEST_MCP_SRCS = \
tests/test_mcp.c \
tests/test_test_impact.c \
tests/test_test_impact_profiles.c \
tests/test_test_impact_changes.c \
tests/test_test_impact_origins.c \
tests/test_test_impact_git.c \
tests/test_test_impact_tree.c \
tests/test_test_impact_inventory.c \
tests/test_test_impact_classify.c \
tests/test_test_impact_source.c \
tests/test_test_impact_seed.c \
tests/test_test_impact_engine.c \
tests/test_test_impact_artifact.c \
tests/test_test_impact_inventory_errors.c \
tests/test_test_impact_inventory_support.c \
tests/test_test_impact_runner_filter.c \
tests/test_test_impact_runner_filter_fixture.c \
tests/test_test_impact_runner_filter_process.c \
tests/test_index_supervisor.c
TEST_DAEMON_SRCS = \
@@ -554,12 +794,23 @@ TEST_DISCOVER_SRCS = \
tests/test_language.c \
tests/test_userconfig.c \
tests/test_gitignore.c \
tests/test_gitignore_checked.c \
tests/if_native_git_path.c \
tests/test_inventory_filter.c \
tests/test_inventory_filter_cases.c \
tests/test_inventory_filter_errors.c \
tests/test_inventory_filter_limits.c \
tests/test_inventory_filter_native.c \
tests/test_inventory_filter_native_fixture.c \
tests/test_inventory_filter_native_platform.c \
tests/test_inventory_filter_structure_extra.c \
tests/test_inventory_filter_support.c \
tests/test_git_context.c \
tests/test_discover.c
TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c
TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_call_reference_contract.c tests/repro/repro_call_scope_usages.c tests/repro/repro_call_argument_usages.c tests/repro/repro_reference_precision.c tests/repro/repro_lexical_binding_precision.c tests/repro/repro_call_argument_matrix_a.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_behaviors.c tests/repro/repro_language_registry.c tests/repro/repro_call_node_manifest.c tests/repro/repro_lsp_ordered_signatures.c tests/repro/repro_lsp_ordered_local.c tests/repro/repro_ts_overload_return_chains.c tests/repro/repro_harness_cleanup.c tests/repro/repro_runner_filter.c
TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_pipeline_conventions.c tests/test_pipeline_frozen.c tests/test_importance.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_doclinks.c tests/test_doc_mentions.c tests/test_doc_links_md.c tests/test_doc_links_pdf.c tests/test_doc_links_rst.c tests/test_doc_links_adoc.c tests/test_doc_mentions_go.c tests/test_doc_mentions_jvm.c tests/test_doc_mentions_rs.c tests/test_doc_mentions_perl.c tests/test_doc_mentions_ts.c tests/test_doc_mentions_php.c tests/test_doc_mentions_c.c tests/test_diagrams.c tests/test_openapi.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_index_format.c tests/test_call_reference_contract.c tests/repro/repro_call_scope_usages.c tests/repro/repro_call_argument_usages.c tests/repro/repro_reference_precision.c tests/repro/repro_lexical_binding_precision.c tests/repro/repro_call_argument_matrix_a.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_behaviors.c tests/repro/repro_language_registry.c tests/repro/repro_call_node_manifest.c tests/repro/repro_lsp_ordered_signatures.c tests/repro/repro_lsp_ordered_local.c tests/repro/repro_ts_overload_return_chains.c tests/repro/repro_harness_cleanup.c tests/repro/repro_runner_filter.c
TEST_WATCHER_SRCS = tests/test_watcher.c
@@ -595,20 +846,20 @@ TEST_PY_LSP_SCALE_SRCS = tests/test_py_lsp_scale.c
TEST_TS_LSP_SRCS = tests/test_ts_lsp.c
TEST_JAVA_LSP_SRCS = tests/test_java_lsp.c tests/test_java_lsp_coverage.c
TEST_KOTLIN_LSP_SRCS = tests/test_kotlin_lsp.c
TEST_KOTLIN_LSP_SRCS = tests/test_kotlin_lsp.c tests/test_kotlin_typed_calls.c
TEST_RUST_LSP_SRCS = tests/test_rust_lsp.c
TEST_INTEGRATION_SRCS = tests/test_integration.c tests/test_incremental.c tests/test_lang_contract.c tests/test_edge_imports.c tests/test_edge_structural.c tests/test_lsp_resolution_probe.c tests/test_node_creation_probe.c tests/test_edge_types_probe.c tests/test_convergence_probe.c tests/test_matrix_known_classes.c tests/test_matrix_new_constructs.c tests/test_grammar_probe_a.c tests/test_grammar_probe_b.c tests/test_grammar_probe_c.c tests/test_grammar_probe_d.c tests/test_grammar_probe_e.c tests/test_grammar_probe_f.c tests/test_grammar_probe_g.c
TEST_INTEGRATION_SRCS = tests/test_integration.c tests/test_incremental.c tests/test_lang_contract.c tests/test_edge_imports.c tests/test_edge_structural.c tests/test_lsp_resolution_probe.c tests/test_node_creation_probe.c tests/test_edge_types_probe.c tests/test_convergence_probe.c tests/test_matrix_known_classes.c tests/test_matrix_new_constructs.c tests/test_grammar_probe_a.c tests/test_grammar_probe_b.c tests/test_grammar_probe_c.c tests/test_grammar_probe_d.c tests/test_grammar_probe_e.c tests/test_grammar_probe_f.c tests/test_grammar_probe_g.c tests/test_conditional_variants.c tests/test_spawns.c
TEST_TRACES_SRCS = tests/test_traces.c
TEST_CLI_SRCS = tests/test_cli.c tests/test_agent_clients.c tests/test_agent_profiles.c \
TEST_CLI_SRCS = tests/test_cli.c tests/test_runtime_settings.c tests/test_agent_clients.c tests/test_agent_profiles.c \
tests/test_config_json_like.c \
tests/test_config_toml_edit.c tests/test_config_yaml_edit.c tests/test_config_text_edit.c \
tests/test_activation_transaction.c
TEST_MEM_SRCS = tests/test_mem.c
TEST_MEM_SRCS = tests/test_mem.c tests/test_mem_events.c
TEST_UI_SRCS = tests/test_ui.c
TEST_HTTPD_SRCS = tests/test_httpd.c
@@ -678,6 +929,7 @@ TEST_REPRO_SRCS = \
tests/repro/repro_issue787.c \
tests/repro/repro_issue842.c \
tests/repro/repro_issue964.c \
tests/repro/repro_issue1692.c \
tests/repro/repro_invariant_calls.c \
tests/repro/repro_invariant_graph.c \
tests/repro/repro_invariant_breadth.c \
@@ -712,7 +964,14 @@ BUILD_DIR = build/c
# ── Object file compilation (grammars need relaxed warnings) ─────
# Grammar + tree-sitter runtime: compiled without -Werror (upstream code has warnings)
GRAMMAR_CFLAGS = -std=c11 -D_DEFAULT_SOURCE -O2 -w -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC)
# $(TEST_SEAM_DEFINE): these prod objects (grammars, ts_runtime, lsp_all) are
# linked into the same binary as the CFLAGS_PROD sources, so both halves must
# agree on the seam switch. lsp_all holds seam code of its own (ts_lsp.c's
# budget probes, any seam counter a resolver defines): without the define a
# TEST_SEAMS=1 binary silently lacks those seams, and a seam symbol that cbm.c
# references fails to link. GRAMMAR_CFLAGS_TEST always carries the define.
GRAMMAR_CFLAGS = -std=c11 -D_DEFAULT_SOURCE -O2 -w -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC) \
$(TEST_SEAM_DEFINE) $(MEMWASTE_VENDOR_CFLAGS)
GRAMMAR_CFLAGS_TEST = -std=c11 -D_DEFAULT_SOURCE -g -O1 -w -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC) \
-DCBM_ENABLE_TEST_SEAMS=1 $(SANITIZED_DEFINE) $(SANITIZE)
GRAMMAR_CFLAGS_TSAN = -std=c11 -D_DEFAULT_SOURCE -g -O1 -w -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC) \
@@ -730,7 +989,7 @@ PP_OBJ_TSAN = $(BUILD_DIR)/tsan_preprocessor.o
# ── Targets ──────────────────────────────────────────────────────
.PHONY: test test-par test-repro test-foundation test-tsan test-daemon-smoke cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-tidy-diff lint-cppcheck lint-format security
.PHONY: test-coverage-flush test test-par test-repro test-foundation test-tsan test-daemon-smoke test-runner-cov cbm-cov cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-tidy-diff lint-cppcheck lint-format security fuzz
$(BUILD_DIR):
mkdir -p $(BUILD_DIR)
@@ -741,23 +1000,40 @@ $(BUILD_DIR)/test-foundation: $(TEST_FOUNDATION_SRCS) $(FOUNDATION_SRCS) $(PROJE
$(CC) $(CFLAGS_TEST) -o $@ $(TEST_FOUNDATION_SRCS) $(FOUNDATION_SRCS) $(LDFLAGS_TEST)
test-foundation: $(BUILD_DIR)/test-foundation
cd $(CURDIR) && $(BUILD_DIR)/test-foundation
cd "$(CURDIR)" && $(BUILD_DIR)/test-foundation
# ── Grammar/TS/LSP object files (compiled with relaxed warnings) ─
$(BUILD_DIR)/%.o: $(CBM_DIR)/%.c | $(BUILD_DIR)
# Each grammar_<lang>.c is a unity object that #includes its own vendored
# parser.c and, where the grammar has one, scanner.c. Those includes were
# prerequisites of nothing, so editing a vendored scanner left the object
# untouched and the edit silently absent from the binary: on 2026-09-19 a fix
# to properties/scanner.c appeared not to work at all, and `nm` showed the old
# symbol still sitting in prod_grammar_properties.o. MANIFEST.md's "Local
# source patches" table means patching a vendored scanner is expected, so this
# trap is reachable by design. Same reasoning as LSP_UNITY_DEPS and
# TS_RUNTIME_DEPS above; secondary expansion is what lets one pattern rule name
# each grammar's own directory ($$* is the stem, grammar_properties ->
# properties). Non-grammar objects match no directory and gain no prerequisite.
GRAMMAR_VENDOR_DIR = $(CBM_DIR)/vendored/grammars/$$(subst grammar_,,$$*)
GRAMMAR_UNITY_DEPS = $$(wildcard $(GRAMMAR_VENDOR_DIR)/*.c) \
$$(wildcard $(GRAMMAR_VENDOR_DIR)/*.h) \
$$(wildcard $(GRAMMAR_VENDOR_DIR)/tree_sitter/*.h)
.SECONDEXPANSION:
$(BUILD_DIR)/%.o: $(CBM_DIR)/%.c $(GRAMMAR_UNITY_DEPS) | $(BUILD_DIR)
$(CC) $(GRAMMAR_CFLAGS_TEST) -c -o $@ $<
$(BUILD_DIR)/ts_runtime.o: $(TS_RUNTIME_DEPS) | $(BUILD_DIR)
$(CC) $(GRAMMAR_CFLAGS_TEST) -c -o $@ $<
$(BUILD_DIR)/lsp_all.o: $(LSP_UNITY_DEPS) | $(BUILD_DIR)
$(CC) $(GRAMMAR_CFLAGS_TEST) $(SANITIZE) -c -o $@ $<
$(CC) $(GRAMMAR_CFLAGS_TEST) $(SANITIZE) $(TEST_COVERAGE_FLAGS) -c -o $@ $<
$(BUILD_DIR)/preprocessor.o: $(CBM_DIR)/preprocessor.cpp | $(BUILD_DIR)
$(CXX) $(CXXFLAGS_TEST) -w -I$(CBM_DIR)/vendored -c -o $@ $<
$(BUILD_DIR)/tsan_%.o: $(CBM_DIR)/%.c | $(BUILD_DIR)
$(BUILD_DIR)/tsan_%.o: $(CBM_DIR)/%.c $(GRAMMAR_UNITY_DEPS) | $(BUILD_DIR)
$(CC) $(GRAMMAR_CFLAGS_TSAN) -c -o $@ $<
$(BUILD_DIR)/tsan_ts_runtime.o: $(TS_RUNTIME_DEPS) | $(BUILD_DIR)
@@ -803,7 +1079,9 @@ $(BUILD_DIR)/sqlite3.o: $(SQLITE3_SRC) | $(BUILD_DIR)
$(BUILD_DIR)/tsan_sqlite3.o: $(SQLITE3_SRC) | $(BUILD_DIR)
$(CC) $(SQLITE3_CFLAGS_TEST) $(TSAN_SANITIZE) -c -o $@ $<
$(BUILD_DIR)/prod_sqlite3.o: $(SQLITE3_SRC) Makefile.cbm | $(BUILD_DIR)
# .build-config: SQLITE3_CFLAGS carries MEMWASTE_VENDOR_CFLAGS (see the stamp
# under "Production binary").
$(BUILD_DIR)/prod_sqlite3.o: $(SQLITE3_SRC) Makefile.cbm $(BUILD_DIR)/.build-config | $(BUILD_DIR)
$(CC) $(SQLITE3_CFLAGS) -c -o $@ $<
# TRE regex (only compiled on Windows — POSIX uses system <regex.h>)
@@ -833,8 +1111,8 @@ $(BUILD_DIR)/tre.o: $(TRE_SRC) | $(BUILD_DIR)
$(BUILD_DIR)/tsan_tre.o: $(TRE_SRC) | $(BUILD_DIR)
$(CC) $(TRE_CFLAGS) $(TSAN_SANITIZE) -c -o $@ $<
$(BUILD_DIR)/prod_tre.o: $(TRE_SRC) | $(BUILD_DIR)
$(CC) $(TRE_CFLAGS) -O2 -c -o $@ $<
$(BUILD_DIR)/prod_tre.o: $(TRE_SRC) $(BUILD_DIR)/.build-config | $(BUILD_DIR)
$(CC) $(TRE_CFLAGS) -O2 $(MEMWASTE_VENDOR_CFLAGS) -c -o $@ $<
endif
# Vendored LZ4 (test build)
@@ -881,13 +1159,13 @@ $(BUILD_DIR)/test-runner: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_
$(LDFLAGS_TEST)
test: $(BUILD_DIR)/test-runner
cd $(CURDIR) && $(BUILD_DIR)/test-runner
cd "$(CURDIR)" && $(BUILD_DIR)/test-runner
# Parallel variant: every suite as its own process; identical gate quality
# (see the ZERO-LOSS CONTRACT in scripts/run-tests-parallel.sh — union guard
# against --list-suites + aggregated pass/fail/skip totals).
test-par: $(BUILD_DIR)/test-runner
cd $(CURDIR) && bash scripts/run-tests-parallel.sh $(BUILD_DIR)/test-runner
cd "$(CURDIR)" && bash scripts/run-tests-parallel.sh $(BUILD_DIR)/test-runner
# Focused native development is intentionally a separate, explicit target so
# an inherited TEST_SUITES value cannot silently narrow the gating test target.
@@ -895,7 +1173,7 @@ TEST_SUITES ?=
test-focused: $(BUILD_DIR)/test-runner
@test -n "$(strip $(TEST_SUITES))" || \
(echo "TEST_SUITES is required for test-focused"; exit 2)
cd $(CURDIR) && $(BUILD_DIR)/test-runner $(TEST_SUITES)
cd "$(CURDIR)" && $(BUILD_DIR)/test-runner $(TEST_SUITES)
# ── Cumulative bug-reproduction runner (RED board by design, non-gating) ──
# Mirrors test-runner's link line but uses repro_main.c (own main + counters)
@@ -987,29 +1265,36 @@ TS_RUNTIME_OBJ_PROD = $(BUILD_DIR)/prod_ts_runtime.o
LSP_OBJ_PROD = $(BUILD_DIR)/prod_lsp_all.o
PP_OBJ_PROD = $(BUILD_DIR)/prod_preprocessor.o
$(BUILD_DIR)/prod_%.o: $(CBM_DIR)/%.c | $(BUILD_DIR)
# Every prod object whose flags carry a BUILD_CONFIG_SIG variable
# (TEST_SEAM_DEFINE via GRAMMAR_CFLAGS, MEMWASTE_VENDOR_CFLAGS) depends on the
# $(BUILD_DIR)/.build-config stamp. These objects are compiled once and linked
# into every later binary, so without the stamp a TEST_SEAMS or MEMWASTE flip
# relinks the PREVIOUS flavour: a release binary keeps seam code compiled into
# lsp_all, or a seams binary misses it. prod_mimalloc.o and the unixcoder blob
# take no config variable and stay off the stamp.
$(BUILD_DIR)/prod_%.o: $(CBM_DIR)/%.c $(GRAMMAR_UNITY_DEPS) $(BUILD_DIR)/.build-config | $(BUILD_DIR)
$(CC) $(GRAMMAR_CFLAGS) -c -o $@ $<
$(BUILD_DIR)/prod_ts_runtime.o: $(TS_RUNTIME_DEPS) | $(BUILD_DIR)
$(BUILD_DIR)/prod_ts_runtime.o: $(TS_RUNTIME_DEPS) $(BUILD_DIR)/.build-config | $(BUILD_DIR)
$(CC) $(GRAMMAR_CFLAGS) -c -o $@ $<
$(BUILD_DIR)/prod_lsp_all.o: $(LSP_UNITY_DEPS) | $(BUILD_DIR)
$(CC) $(GRAMMAR_CFLAGS) -c -o $@ $<
$(BUILD_DIR)/prod_lsp_all.o: $(LSP_UNITY_DEPS) $(BUILD_DIR)/.build-config | $(BUILD_DIR)
$(CC) $(GRAMMAR_CFLAGS) $(TEST_COVERAGE_FLAGS) -c -o $@ $<
$(BUILD_DIR)/prod_preprocessor.o: $(CBM_DIR)/preprocessor.cpp | $(BUILD_DIR)
$(BUILD_DIR)/prod_preprocessor.o: $(CBM_DIR)/preprocessor.cpp $(BUILD_DIR)/.build-config | $(BUILD_DIR)
$(CXX) $(CXXFLAGS_PROD) -w -I$(CBM_DIR)/vendored -c -o $@ $<
# Vendored LZ4 (compiled separately, not unity-built via lz4_store.c)
LZ4_OBJ_PROD = $(BUILD_DIR)/prod_lz4.o $(BUILD_DIR)/prod_lz4hc.o
$(BUILD_DIR)/prod_lz4.o: $(CBM_DIR)/vendored/lz4/lz4.c | $(BUILD_DIR)
$(CC) -std=c11 -D_DEFAULT_SOURCE -O2 -w -I$(CBM_DIR) -c -o $@ $<
$(BUILD_DIR)/prod_lz4hc.o: $(CBM_DIR)/vendored/lz4/lz4hc.c | $(BUILD_DIR)
$(CC) -std=c11 -D_DEFAULT_SOURCE -O2 -w -I$(CBM_DIR)/vendored/lz4 -c -o $@ $<
$(BUILD_DIR)/prod_lz4.o: $(CBM_DIR)/vendored/lz4/lz4.c $(BUILD_DIR)/.build-config | $(BUILD_DIR)
$(CC) -std=c11 -D_DEFAULT_SOURCE -O2 -w $(MEMWASTE_VENDOR_CFLAGS) -I$(CBM_DIR) -c -o $@ $<
$(BUILD_DIR)/prod_lz4hc.o: $(CBM_DIR)/vendored/lz4/lz4hc.c $(BUILD_DIR)/.build-config | $(BUILD_DIR)
$(CC) -std=c11 -D_DEFAULT_SOURCE -O2 -w $(MEMWASTE_VENDOR_CFLAGS) -I$(CBM_DIR)/vendored/lz4 -c -o $@ $<
# Vendored zstd (compiled separately, not unity-built via zstd_store.c)
ZSTD_OBJ_PROD = $(BUILD_DIR)/prod_zstd.o
$(BUILD_DIR)/prod_zstd.o: $(CBM_DIR)/vendored/zstd/zstd.c | $(BUILD_DIR)
$(CC) -std=c11 -D_DEFAULT_SOURCE -O2 -w -I$(CBM_DIR)/vendored/zstd -c -o $@ $<
$(BUILD_DIR)/prod_zstd.o: $(CBM_DIR)/vendored/zstd/zstd.c $(BUILD_DIR)/.build-config | $(BUILD_DIR)
$(CC) -std=c11 -D_DEFAULT_SOURCE -O2 -w $(MEMWASTE_VENDOR_CFLAGS) -I$(CBM_DIR)/vendored/zstd -c -o $@ $<
OBJS_VENDORED_PROD = $(MIMALLOC_OBJ_PROD) $(SQLITE3_OBJ_PROD) $(TRE_OBJ_PROD) $(GRAMMAR_OBJS_PROD) $(TS_RUNTIME_OBJ_PROD) $(LSP_OBJ_PROD) $(PP_OBJ_PROD) $(LZ4_OBJ_PROD) $(ZSTD_OBJ_PROD) $(UNIXCODER_OBJ)
@@ -1024,14 +1309,15 @@ MAIN_SRC = src/main.c
# configuration and it is rewritten only when that content changes, so ordinary
# incremental builds are unaffected. (Single-quoted: the signature may contain
# the escaped quotes of -DCBM_VERSION, which are literal inside '', but must not
# contain a literal single quote.)
# contain a literal single quote.) The config-flavoured prod objects above
# depend on the stamp for the same reason as the binary.
# SANITIZE belongs here: it changes the instrumentation compiled into every
# object, so a build that only changes it MUST rebuild. Its absence made
# `scripts/test.sh SANITIZE= ...` -- the documented way to get a plain build
# for trap debugging -- silently re-run the previously instrumented binary,
# which during this bisect produced a "crashes without sanitizers too" result
# that was pure artifact and sent the investigation down the wrong path.
BUILD_CONFIG_SIG := TEST_SEAMS=$(TEST_SEAMS)|CFLAGS_EXTRA=$(CFLAGS_EXTRA)|SANITIZE=$(SANITIZE)
BUILD_CONFIG_SIG := TEST_SEAMS=$(TEST_SEAMS)|CFLAGS_EXTRA=$(CFLAGS_EXTRA)|SANITIZE=$(SANITIZE)|MEMWASTE=$(MEMWASTE)|MEMWASTE_ACCESS=$(MEMWASTE_ACCESS)|MEMWASTE_PROFILE=$(MEMWASTE_PROFILE)|TEST_COVERAGE=$(TEST_COVERAGE)
.PHONY: build-config-check
build-config-check:
$(BUILD_DIR)/.build-config: build-config-check | $(BUILD_DIR)
@@ -1059,8 +1345,11 @@ PROD_SRCS_WITH_ASSETS = $(subst src/ui/embedded_stub.c,src/ui/embedded_assets.c,
# Embedded asset object files (generated by embed script)
EMBED_OBJS = $(wildcard $(BUILD_DIR)/embedded/embed_*.o)
# PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: the UI's dev dependencies include
# playwright for its local browser proofs; a plain `npm ci` would otherwise
# download browsers, which no build or gate needs.
frontend:
cd graph-ui && npm ci && npm run build
cd graph-ui && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci && npm run build
embed: frontend
scripts/embed-frontend.sh graph-ui/dist $(BUILD_DIR)/embedded
@@ -1157,8 +1446,17 @@ lint-no-suppress:
lint: lint-tidy lint-cppcheck lint-format lint-no-suppress
@echo "=== All linters passed ==="
# Memory-core linter: memory is allocated through src/foundation/mem_core.h,
# not through raw malloc/calloc/realloc/free/strdup. A checked-in baseline
# (scripts/memory-core-baseline.txt) records each file's raw-site count and the
# gate goes red the moment any file grows. Files only ever go down; lower the
# baseline line in the same change that migrates the file.
lint-memory-core:
@echo "=== memory-core linter ==="
@python3 scripts/lint-memory-core.py
# CI linters (no clang-tidy — platform-dependent, enforced locally via pre-commit)
lint-ci: lint-cppcheck lint-format lint-no-suppress
lint-ci: lint-cppcheck lint-format lint-no-suppress lint-memory-core
@echo "=== CI linters passed ==="
# ── Local memory-diagnostic lanes (not PR-CI gates by decision: the diag
@@ -1218,6 +1516,110 @@ test-lsan:
cd $(CURDIR) && ASAN_OPTIONS="detect_leaks=1:halt_on_error=1" \
./build/lsan/test-runner $(LSAN_SUITES)
# ── Per-test coverage build (test-impact map) ───────────────────
# The instrumented runner and product binary (TEST_COVERAGE=1 above), in their
# own build directory. Homebrew LLVM rather than Apple clang: the raw profile
# format is versioned with the compiler, so llvm-profdata and llvm-cov have to
# come from the toolchain that built the binary.
# LLVM_PROFILE_FILE=/dev/null CBM_TEST_COVERAGE_DIR=<dir> \
# build/cov/test-runner [suite...]
# leaves one profile set per test (tests/test_main.c). The runner names every
# profile itself, but the profile runtime creates its default file before main
# runs: started without LLVM_PROFILE_FILE it leaves an empty default.profraw in
# the working directory.
# No C suite runs the product binary -- they re-exec the runner itself. The
# shell process regressions do, and they find the instrumented one the way they
# find any other: CBM_TEST_BINARY=build/cov/codebase-memory-mcp, with
# LLVM_PROFILE_FILE set by whoever collects the profiles.
COV_LLVM_BIN ?= /opt/homebrew/opt/llvm/bin
COV_BUILD_DIR ?= build/cov
COV_MAKE = $(MAKE) -f Makefile.cbm CC=$(COV_LLVM_BIN)/clang CXX=$(COV_LLVM_BIN)/clang++ \
BUILD_DIR=$(COV_BUILD_DIR) SANITIZE= TEST_COVERAGE=1
test-runner-cov:
$(COV_MAKE) $(COV_BUILD_DIR)/test-runner
cbm-cov:
$(COV_MAKE) $(COV_BUILD_DIR)/codebase-memory-mcp TEST_SEAMS=1
# Genuine LLVM write failures must fail the parent coverage run.
test-coverage-flush: test-runner-cov
@evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-flush.XXXXXX") && \
printf 'Coverage flush evidence: %s/proof\n' "$$evidence_dir" && \
python3 tests/test_coverage_flush.py \
--runner "$(COV_BUILD_DIR)/test-runner" \
--llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \
--output-dir "$$evidence_dir/proof"
.PHONY: test-coverage-children
# Genuine child profile/marker and setup-reducer controls; evidence is retained.
test-coverage-children: test-runner-cov
@evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-children.XXXXXX") && \
printf 'Coverage child evidence: %s/proof\n' "$$evidence_dir" && \
python3 tests/test_coverage_children.py \
--runner "$(COV_BUILD_DIR)/test-runner" \
--llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \
--reducer scripts/test-impact/coverage-map.py \
--output-dir "$$evidence_dir/proof"
.PHONY: test-coverage-parent-failure
# Positive default plus genuine owned EMFILE; all evidence is retained.
test-coverage-parent-failure: test-runner-cov
@evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-parent-failure.XXXXXX") && \
printf 'Coverage parent failure evidence: %s/proof\n' "$$evidence_dir" && \
python3 tests/test_coverage_parent_failure.py \
--runner "$(COV_BUILD_DIR)/test-runner" \
--llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \
--reducer scripts/test-impact/coverage-map.py \
--output-dir "$$evidence_dir/proof"
.PHONY: test-coverage-terminal
# A readable stale terminal profile cannot hide a failed parent write.
test-coverage-terminal: test-runner-cov
@evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-terminal.XXXXXX") && \
printf 'Coverage terminal evidence: %s/proof\n' "$$evidence_dir" && \
python3 tests/test_coverage_terminal.py \
--runner "$(COV_BUILD_DIR)/test-runner" \
--llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \
--helper tests/test_coverage_children.py \
--reducer scripts/test-impact/coverage-map.py \
--output-dir "$$evidence_dir/proof"
.PHONY: test-coverage-live-v2
# Native full-identity transport remains explicitly unadmitted; evidence is retained.
test-coverage-live-v2: test-runner-cov
@evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-live-v2.XXXXXX") && \
printf 'Coverage live-v2 evidence: %s/proof\n' "$$evidence_dir" && \
PYTHONDONTWRITEBYTECODE=1 python3 tests/test_coverage_live_v2.py \
--collector scripts/test-impact/coverage-map.py \
--runner "$(COV_BUILD_DIR)/test-runner" \
--llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \
--output "$$evidence_dir/proof" $(if $(COV_LIVE_V2_CASE),--case "$(COV_LIVE_V2_CASE)")
# ── Coverage-guided fuzzing (libFuzzer) ─────────────────────────
# One binary, three targets (tests/fuzz/cbm_fuzz.c): the per-file extractor,
# the Cypher parser, the TOML/YAML/JSON config editors. ASan+UBSan as in the
# test lane, plus fuzzer-no-link on EVERY object -- the vendored grammars and
# the tree-sitter runtime feed coverage back, which is where the parser state
# space lives. Needs a clang that ships libFuzzer (Homebrew LLVM on macOS; Apple
# clang does not). Run through scripts/fuzz.sh.
FUZZ_CC ?= clang
FUZZ_CXX ?= clang++
FUZZ_SANITIZE = -fsanitize=address,undefined,fuzzer-no-link -fno-omit-frame-pointer
FUZZ_SRC = tests/fuzz/cbm_fuzz.c
fuzz:
$(MAKE) -f Makefile.cbm build/fuzz/cbm-fuzz BUILD_DIR=build/fuzz \
CC=$(FUZZ_CC) CXX=$(FUZZ_CXX) SANITIZE="$(FUZZ_SANITIZE)"
# -fsanitize-link-c++-runtime: the link runs through the C driver, which on
# Linux leaves out UBSan's C++ runtime, but the C++ objects (preprocessor,
# simplecpp) carry -fsanitize=vptr checks that need it
# (__ubsan_vptr_type_cache, __ubsan_handle_dynamic_type_cache_miss).
$(BUILD_DIR)/cbm-fuzz: $(FUZZ_SRC) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TEST) $(PROJECT_HDRS) | $(BUILD_DIR)
$(CC) $(CFLAGS_TEST) -fsanitize=fuzzer -fsanitize-link-c++-runtime -o $@ \
$(FUZZ_SRC) $(PROD_SRCS) \
$(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \
$(OBJS_VENDORED_TEST) \
$(LDFLAGS_TEST)
# Path-sensitive memory analysis: leak paths, null derefs, uninitialized reads.
# Non-gating: triage findings against the code (this lane's first run produced
# 9 real fixes and 6 recorded false positives — see the analyzer batch commit).
+408 -135
View File
@@ -1,12 +1,13 @@
# codebase-memory-mcp
# codebase-memory-mcp: local MCP server for code search, lower token usage and visualization
[![GitHub Release](https://img.shields.io/github/v/release/DeusData/codebase-memory-mcp?style=flat&color=blue)](https://github.com/DeusData/codebase-memory-mcp/releases/latest)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![CI](https://img.shields.io/github/actions/workflow/status/DeusData/codebase-memory-mcp/dry-run.yml?label=CI)](https://github.com/DeusData/codebase-memory-mcp/actions/workflows/dry-run.yml)
[![Tests](https://img.shields.io/badge/tests-6768_passing-brightgreen)](https://github.com/DeusData/codebase-memory-mcp)
[![Languages](https://img.shields.io/badge/languages-158-orange)](https://github.com/DeusData/codebase-memory-mcp)
[![Hybrid LSP](https://img.shields.io/badge/Hybrid_LSP-10_languages-blue)](#hybrid-lsp)
[![Agents](https://img.shields.io/badge/agent_surfaces-43-purple)](https://github.com/DeusData/codebase-memory-mcp)
[![CI](https://img.shields.io/github/actions/workflow/status/DeusData/codebase-memory-mcp/release.yml?branch=main&label=main%20CI)](https://github.com/DeusData/codebase-memory-mcp/actions/workflows/release.yml)
[![C tests](https://img.shields.io/badge/C_tests-9388_cases-blue)](#tests)
[![MCP tools](https://img.shields.io/badge/MCP_tools-17-blue)](#mcp-tools)
[![Languages](https://img.shields.io/badge/languages-162-orange)](https://github.com/DeusData/codebase-memory-mcp)
[![Hybrid LSP](https://img.shields.io/badge/Hybrid_LSP-type_resolution-blue)](#hybrid-lsp)
[![Agents](https://img.shields.io/badge/agent_surfaces-45-purple)](https://github.com/DeusData/codebase-memory-mcp)
[![Pure C](https://img.shields.io/badge/pure_C-no_language_runtime-blue)](https://github.com/DeusData/codebase-memory-mcp)
[![Platform](https://img.shields.io/badge/macOS_%7C_Linux_%7C_Windows-supported-lightgrey)](https://github.com/DeusData/codebase-memory-mcp/releases/latest)
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/DeusData/codebase-memory-mcp/badge)](https://scorecard.dev/viewer/?uri=github.com/DeusData/codebase-memory-mcp)
@@ -14,42 +15,70 @@
[![VirusTotal](https://img.shields.io/badge/VirusTotal-scanned_every_release-brightgreen?logo=virustotal)](https://github.com/DeusData/codebase-memory-mcp/releases/latest)
[![arXiv](https://img.shields.io/badge/arXiv-2603.27277-b31b1b?logo=arxiv)](https://arxiv.org/abs/2603.27277)
**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 native executable with a small verified runtime-asset set for macOS, Linux, and Windows — download, run `install`, done.
**Search your code, follow call graphs, and explore software architecture in your browser.** codebase-memory-mcp is a local [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that gives Claude Code, Codex, and other coding agents a persistent code knowledge graph. It indexes functions, classes, imports, HTTP routes, and their relationships across [162 languages](#language-support). [Hybrid LSP](#hybrid-lsp) adds semantic type resolution for **Python, TypeScript, JavaScript, JSX/TSX, PHP, C#, Go, C, C++, Java, Kotlin, Rust, and Perl**.
High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across all 158 languages, enhanced with [**Hybrid LSP** semantic type resolution](#hybrid-lsp) for Python, TypeScript / JavaScript / JSX / TSX, PHP, C#, Go, C, C++, Java, Kotlin, Rust, and Perl — producing a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. 15 MCP tools. No language runtime, hosted service, or API key. Plug and play across 43 supported automatic/conditional client surfaces.
**About 120× fewer tokens** in the project's reported five-query comparison: ~3,400 vs ~412,000 with file-by-file exploration. Published backend benchmarks also report **Linux kernel indexing in ~3 minutes** (28M lines of code) and **Cypher relationship queries under 1 ms** on an Apple M3 Pro. [Benchmark details](#performance).
> **Research** — The design and benchmarks behind this project are described in the preprint [*Codebase-Memory: Tree-Sitter-Based Knowledge Graphs for LLM Code Exploration via MCP*](https://arxiv.org/abs/2603.27277) (arXiv:2603.27277). Evaluated across 31 real-world repositories: 83% answer quality, 10× fewer tokens, 2.1× fewer tool calls vs. file-by-file exploration.
**CBM Lens Beta helps you understand unfamiliar code and stay in control as it grows.** Use it to get up to speed in a new codebase, tackle rescue engineering on an inherited project, or make sense of 50,000 lines of AI-generated code from a single day. Its IDE-style source reader, local code chat, 3D architecture map, and Galaxy graph let you see how the code fits together and follow dependencies and call paths back to the source. Use that context to review changes, investigate fragile areas, and keep code quality high. Architecture decisions stay alongside the project's index.
> **Security & Trust** — This tool reads your codebase and writes to your agent configuration files. That is what it is designed to do. If you prefer to audit before running, the [full source is here](https://github.com/DeusData/codebase-memory-mcp). For each release product, three behaviourally identical executable candidates (unstripped, debug-stripped, stripped) are submitted to VirusTotal before testing; the selected candidate is then packaged with its SHA-256 unchanged. Release notes link every measured candidate result. Publication permits only the narrowly documented single-Microsoft `!ml` tolerance in [SECURITY.md](SECURITY.md#our-release-policy). All processing happens 100% locally; your code never leaves your machine. Found a security issue? We want to know — see [SECURITY.md](SECURITY.md). Security is Priority #1 for us.
The native C backend exposes 17 MCP tools and supports 45 automatic or conditional client integrations. Indexing and graph queries need no hosted service or API key.
> **CBM Lens Beta is new on `main`:** it arrived with [PR #2068](https://github.com/DeusData/codebase-memory-mcp/pull/2068) and is not part of the current v0.11.0 release, whose installer still ships the previous graph UI. To run the interface shown below now, [build from source](#build-from-source).
[Quick start](#quick-start) · [Explore CBM Lens](#cbm-lens-beta-code-visualization-and-source-navigation) · [Chat with your codebase locally](#chat-with-your-codebase-locally) · [MCP tools](#mcp-tools) · [Build from source](#build-from-source)
> **Research:** The [preprint](https://arxiv.org/abs/2603.27277) compares agents across 31 repositories, reporting **10× lower token use** and **2.1× fewer tool calls**, with answer-quality scores of 83% for CBM versus 92% for file-by-file exploration. These backend results are separate from the five-query comparison above and from CBM Lens.
> **Local processing:** Source analysis and graph storage run on your machine. CBM Lens can optionally download a model for browser AI after you enable it; inference stays on the device. The installer writes agent configuration for the clients you select. See [Security](#security) for release verification and [SECURITY.md](SECURITY.md) for reporting issues.
**Architecture overview.** Folders become nested platforms, files and symbols become selectable blocks, and arrows show indexed relationships. The recording zooms into the map of this repository, selects `src/pipeline`, opens the area, selects `artifact.c` and opens its source. Height can show source size; hotspot gravity reflects static fan-in. [Still image](docs/images/lens-architecture.png).
<p align="center">
<img src="docs/graph-ui-screenshot.png" alt="Graph visualization UI showing the codebase-memory-mcp knowledge graph" width="800">
<br>
<em>Built-in 3D graph visualization — explore your knowledge graph at localhost:9749</em>
<img src="docs/images/lens-overview.gif" alt="CBM Lens Beta architecture walkthrough: zoom into the source folders of this repository, open src/pipeline, select artifact.c and open its source" width="1000">
</p>
**Galaxy.** Explore the code knowledge graph in 3D. The recording searches `cbm_pipeline_run`, follows its outgoing calls one layer further, switches to the hierarchy and shows the path to `cbm_store_check_integrity`. Edge pulses show relationship direction; they do not represent live execution. [Current still: two dependency layers around `cbm_max_file_bytes`](docs/images/lens-galaxy.jpg).
<p align="center">
<img src="docs/images/lens-galaxy.gif" alt="Code knowledge graph in CBM Lens Beta Galaxy: search a function, expand its outgoing calls, switch to the hierarchy and follow a path" width="1000">
</p>
**Explore.** Browse files in an IDE-style workspace, read syntax-highlighted source in Monaco, and keep the code graph alongside it. File impact shows what depends on the open file, and Code details shows the callers and calls of the symbol under the caret. Select code to [ask the local chat about it](#chat-with-your-codebase-locally).
<p align="center"><img src="docs/images/lens-explore.jpg" alt="CBM Lens Beta Explore: limits.c in the source reader beside its file impact summary and direct graph dependencies" width="1000"></p>
<details>
<summary>More views: system structure and behavior</summary>
<p>System structure groups symbols into components and lets you inspect their dependencies.</p>
<p align="center"><img src="docs/images/lens-system-structure.png" alt="CBM Lens Beta system structure: source groups of this repository with their dependencies, src selected" width="1000"></p>
<p>Behavior shows possible calls from a selected operation and the source evidence behind them.</p>
<p align="center"><img src="docs/images/lens-behavior.png" alt="CBM Lens Beta Behavior view: the possible direct calls of compileCards and its declaration" width="1000"></p>
</details>
These captures show CBM Lens Beta on 12–13 October 2026, using public source from this repository. See [capture details](docs/images/README.md) for each capture's build, indexed scope and recording setup.
## Why codebase-memory-mcp
- **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** — native executable plus authenticated release-owned assets for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (amd64). The native install needs no Docker, language runtime, or API keys. Download → `install` → restart agent → done.
- **158 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.
- **43 supported automatic/conditional client surfaces** — `install` configures detected clients and safely activates conditional clients only when their documented platform, marker, or explicit existing config path is present. See [Multi-Agent Support](#multi-agent-support) for the complete matrix and manual/UI-only boundaries.
- **Built-in graph visualization** — 3D interactive UI at `localhost:9749`, served from the binary itself.
- **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.
- **15 MCP tools** — search, trace, architecture, impact analysis, targeted index-coverage checks, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more.
- **Indexing performance**: 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**: native executable plus authenticated release-owned assets for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (arm64/amd64). The native install needs no Docker, language runtime, or API keys. Download → `install` → restart agent → done.
- **162 languages**: vendored tree-sitter grammars compiled into the binary. No separate parser installation.
- **About 120× fewer tokens in the reported five-query comparison**: ~3,400 tokens vs ~412,000 via file-by-file search. See [Performance](#performance) for the scope and sources.
- **45 supported automatic/conditional client surfaces**: `install` configures detected clients and safely activates conditional clients only when their documented platform, marker, or explicit existing config path is present. See [Multi-Agent Support](#multi-agent-support) for the complete matrix and manual/UI-only boundaries.
- **CBM Lens Beta**: read source, chat about selected code with a local model, explore architecture and call graphs, inspect coverage gaps, and edit architecture decision records in your browser.
- **Infrastructure-as-code indexing**: Dockerfiles, container images, docker compose, Kubernetes manifests, and Kustomize overlays indexed as graph nodes with cross-references. `Resource` nodes for K8s kinds and compose services, `Image` nodes joining the images that are built and run, `Module` nodes for Kustomize overlays with `IMPORTS` edges to referenced resources; a Service walks to the program its container runs.
- **17 MCP tools**: search, trace, architecture, impact analysis, targeted index-coverage checks, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more.
## Quick Start
**One-line install** (macOS / Linux):
**Install the latest release** (macOS / Linux). For CBM Lens Beta shown above, [build from source](#build-from-source) until the next release.
```bash
curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash
```
With graph visualization UI:
```bash
curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash
```
The released graph UI is included in the standard installer.
**Windows** (PowerShell):
```powershell
@@ -72,14 +101,14 @@ Unblock-File .\install.ps1
Options: `--skip-config` (binary only, no agent setup), `--dir=<path>` (custom location).
> **Antivirus note:** Microsoft Defender may flag a release binary as
> `Trojan:Script/Wacatac.B!ml`. This is a known false positive — typically 61 of
> `Trojan:Script/Wacatac.B!ml`. This is a known false positive; typically 61 of
> ~62 engines return clean, and the same detection family hits `gh`, llama.cpp,
> Godot and Microsoft's own Go toolchain. See
> [Antivirus False Positives](SECURITY.md#antivirus-false-positives) for the
> evidence, how to verify the artifacts yourself, and how to report it if you
> think we are wrong.
Restart your coding agent. Say **"Index this project"** — done.
Restart your coding agent. Say **"Index this project"**: done.
<details>
<summary>Manual install</summary>
@@ -104,14 +133,14 @@ Restart your coding agent. Say **"Index this project"** — done.
3. **Restart** your coding agent.
The `install` command automatically strips macOS quarantine attributes and ad-hoc signs the binary — no manual `xattr`/`codesign` needed.
The `install` command automatically strips macOS quarantine attributes and ad-hoc signs the binary; no manual `xattr`/`codesign` needed.
</details>
The `install` command auto-detects installed coding agents and configures their documented MCP entries plus durable instructions, skills, and lifecycle hooks where supported.
### Session Coordination Daemon
CBM automatically shares one per-account coordination daemon across Claude Code, Codex, OpenCode, and every other configured client. There is no opt-in setting for MCP servers or hook clients: the first daemon-backed CBM session starts it, each session registers its own work, and the final session shuts it down. The daemon owns long-lived background services such as watchers, shared indexing jobs, and the optional UI. Closing one session cancels work owned only by that session, while work still needed by another session continues.
CBM automatically shares one per-account coordination daemon across Claude Code, Codex, OpenCode, and every other configured client. There is no opt-in setting for MCP servers or hook clients: the first daemon-backed CBM session starts it, each session registers its own work, and a session-managed daemon stops after the final session. A daemon started with `daemon start` remains running until stopped. The daemon owns long-lived background services such as watchers, shared indexing jobs, and the optional UI. Closing one session cancels work owned only by that session, while work still needed by another session continues.
The detached daemon does not depend on an MCP frontend's stderr. It keeps owner-only durable records under the canonical `${CBM_CACHE_DIR}/logs` directory (default `~/.cache/codebase-memory-mcp/logs`):
@@ -127,19 +156,87 @@ All active CBM processes must run the exact same version, executable build, coor
The native `install`, `update`, and `uninstall` commands are the deliberate exception to that conflict rule. Download, verification, and private same-filesystem staging happen first so a bad candidate never disrupts active work. Activation then publishes account-wide maintenance intent, asks the daemon and every temporary local operation to cancel, and waits to a finite deadline for all coordinated CBM processes to exit. It holds the admission and lifetime barriers exclusively while changing the active binary, configuration, PATH, or indexes. New CBM work cannot enter during this window. Activation progress and results are recorded in `${CBM_CACHE_DIR}/logs/activation-events.ndjson`, and a successful command tells you to restart open coding-agent sessions so they launch the activated build.
Package-manager setup (npm, PyPI, or Go) verifies and publishes a coherent private cached runtime set. Sidecars are replaced before the executable with per-file atomic renames; an interrupted multi-file publication is detected and repaired on the next launch rather than being described as one crash-atomic filesystem transaction. It does not replace the active native installation and therefore does not stop running CBM sessions. When that cached binary is executed, it still enters the same exact-build admission barrier. The shell and PowerShell installers invoke the verified candidate's native `install` command, so they do receive the full account-wide activation guarantee.
Package-manager setup (npm, PyPI, or Go) verifies and caches the native executable for that package. It does not replace the active native installation and therefore does not stop running CBM sessions. When that cached binary is executed, it still enters the same exact-build admission barrier. The shell and PowerShell installers invoke the verified candidate's native `install` command, so they do receive the full account-wide activation guarantee.
The ordinary `cli` mode is intentionally separate: it runs one command locally and never starts or connects to the coordination daemon, registers a daemon session, or starts watchers/UI. Its only shared state is the OS admission barrier plus per-project locks for graph mutations. While the command is running, a temporary monitor lets activation cancel that operation and its supervised worker safely; the monitor exits with the command and never becomes a standing daemon. See [CLI Mode](#cli-mode) for details.
The ordinary `cli` mode runs one command locally, but it is not disconnected from the daemon above: it connects to the shared coordination daemon (starting one if none is already running) for that same admission barrier plus per-project locks on graph mutations. Its connection is a `cli_session`: it never registers with the background watcher and never starts a UI, and it holds the admission lease only for the command's lifetime. If the command's own connection is what started the daemon, that daemon exits again once the command closes and no other session is attached; if a daemon was already running, the command simply joins and leaves it exactly as found. While the command is running, a temporary monitor separately lets activation cancel that operation and its supervised worker safely; the monitor exits with the command and never becomes a standing daemon. See [CLI Mode](#cli-mode) for details.
### Graph Visualization UI
### CBM Lens Beta: code visualization and source navigation
The graph UI is built into the binary — every install on every channel has it. Then run it:
CBM Lens Beta is the browser interface built into the binary. It builds on [CodeAtlasWeb](graph-ui/README.md) by [Bernhard Jackiewicz](https://github.com/BernhardJackiewicz) (design and tracking in #1964). It runs on loopback; the only request that leaves your machine is the optional model download from Hugging Face, and only after you start it. Each panel names where its facts come from, and what the index did not record is shown as a gap. Start the local server:
```bash
codebase-memory-mcp --ui=true --port=9749
./build/c/codebase-memory-mcp daemon start --open --port=9749
```
Open `http://localhost:9749` in your browser. The UI is owned by the shared coordination daemon, so concurrent agent sessions do not start duplicate HTTP servers.
The browser opens at `http://127.0.0.1:9749`. A permanent daemon keeps the UI available after the command exits. Use `daemon status` to inspect it and `daemon stop` when you are finished. For an MCP session, `--ui=true --port=9749` enables the same shared UI. Active clients must use the same executable build and cache directory.
**Open your first repository**
1. Open the project menu and choose **Add project index**. You can also use `Alt+P`.
2. Choose a local repository folder and name the index. Files stay in place.
3. Start indexing and follow its reported status. You can close the dialog while it runs and reopen its activity from the project menu.
4. Open the indexed project. Start with Architecture for an overview or Explore to read a file.
To bookmark a view, use `?project=<name>&workspace=architecture`. Other workspace values are `explore`, `galaxy`, `adr`, `coverage`, and `system`.
| Workspace | What you can do |
|-----------|-----------------|
| **Explore** | Navigate the indexed file tree and read source in Monaco. File impact shows what depends on the open file; Code details shows the callers, calls, references and related tests of the symbol under the caret. |
| **Galaxy** | Search and focus graph nodes, load their neighbors layer by layer, switch to hierarchy, follow a path, and open source. Back and Forward return to earlier views. The coverage shadow marks known gaps around the loaded graph. |
| **Architecture** | Explore the folder map, entry points, routes, hotspots, system structure, and behavior paths. Select a node or connection to inspect its evidence. |
| **ADR** | Read and edit the project's architecture decision record. Save explicitly; the document is stored with the index and shared with `manage_adr`. |
| **Coverage** | Check indexed, partially parsed, skipped, and excluded paths, together with available freshness information. |
| **System** | Inspect daemon activity, indexes, and logs. Filter logs by project, severity, or text. |
**Architecture views**
- **Overview:** move through source folders and files on a 3D map. Choose source-size or uniform height, language or kind colors, and relationship filters. Switch to Entry points to follow a static call graph from a starting symbol.
- **Routes:** switch between the service map and endpoint connections. Deployment metadata and indexed route evidence are shown separately; unresolved registrations remain visible.
- **Hotspots:** inspect symbols using measured signals such as fan-in and complexity. Gravity wells help locate frequently referenced areas. They are not a code-quality score.
- **System structure:** inspect component dependencies, expand groups, isolate cycles, and follow connections back to source evidence.
- **Behavior:** choose a source and a reachable destination to inspect static call paths and their source statements. These paths show possible relationships, not a recorded execution order. Runtime observations require separately ingested traces.
Drag a 3D view to orbit, scroll to zoom, and select a node or edge to inspect it. Edge colors distinguish relationships, and pulses indicate their direction. Reduced-motion preferences pause the pulses. Large views show their limits and omitted counts. A missing edge can reflect an indexing or display limit, so check Coverage and the source before drawing conclusions.
#### Chat with your codebase locally
Open **Chat** with the panel button at the top right to ask about the code you are looking at. In Explore it uses the open file or the exact text you have selected; in Galaxy it uses the selected node and its loaded neighbors. You can also attach graph evidence through **Add context**.
1. Click the robot icon at the top right to open the agent configuration.
2. Choose a local model. The default Qwen2.5 Coder 0.5B downloads about 567 MB; Qwen3 0.6B, LFM2.5 1.2B and Qwen3.5 2B are larger options. All need WebGPU with `shader-f16` support.
3. Click **Download & load**. Model files come from Hugging Face; inference runs in your browser, with no hosted chat API or API key. A model that is already in the browser cache loads with **Load model (cached, no download)**.
4. Ask a question. The composer shows the file or selection that will accompany it. Select a smaller range when you want to focus the answer.
Questions about callers and callees, such as "Who calls cbm_pipeline_run?", are answered from the loaded graph without the model, and the answer says so. When you select a symbol, the chat lists its indexed facts and the lines of its code, and the model adds one sentence; that sentence is left out when it claims something the source does not show. Text written by the model is labelled as such.
The recording shows a question about callers answered from the indexed graph, next to the automatic explanation of the selected symbol. [Still image](docs/images/lens-chat.png).
<p align="center"><img src="docs/images/lens-chat.gif" alt="Chat with your codebase locally in CBM Lens Beta: a selected symbol, its listed facts and a caller question answered from the indexed graph" width="1000"></p>
The chat history is kept for 24 hours in this browser, per project. Each new message uses the source currently open in the reader; earlier source snapshots are not silently accumulated, and messages about another file or selection are not sent along. If a prompt is too long for the model, older messages are left out first; if it still does not fit, CBM Lens asks you to select less code instead of cutting the code. This chat explains the context you give it; it cannot edit files or run tools. Check its answers against the source. The small default model words things loosely, especially in languages other than English.
#### Search
`Cmd+K` or `Ctrl+K` opens the Galaxy search. It finds symbols, files and folders by name or path in the whole index, also nodes that are not loaded yet. For callers and callees, ask in the chat; the answer is listed from the graph.
**Architecture decisions**
The ADR workspace keeps an unsaved draft per project in this browser tab. It checks whether the stored document changed before saving, but the API does not provide an atomic document lock. The editor accepts up to 8,000 UTF-8 bytes. Saving changes the indexed decision document, not a file in your repository.
<details>
<summary>Experimental agent activity</summary>
The Agents workspace and activity overlays are hidden in normal builds. To include them in a source build:
```bash
VITE_EXPERIMENTAL_AGENTS=true scripts/build.sh --with-ui
```
Activity comes from explicitly configured tool hooks. The bundled adapter supports Claude Code PostToolUse; other clients need their own adapter. Recorded events go to the local daemon, and the reader must enable activity before the browser polls them. This does not automatically observe every agent. See the [frontend setup guide](graph-ui/README.md#die-agentenebene-was-live-arbeitende-agenten-auf-dem-graphen-zeigen).
</details>
For troubleshooting, open System logs. Frontend errors and failed requests are recorded locally. `GET /api/ui-log` reads frontend entries from the daemon's SQLite journal; `${CBM_CACHE_DIR}/logs/ui.log` is also retained as a rotating JSONL export. See the [frontend README](graph-ui/README.md) for log scope, model setup, and development details.
### Auto-Index
@@ -151,7 +248,13 @@ codebase-memory-mcp config set auto_index true
When enabled, new projects are indexed automatically on first connection. Previously-indexed projects are registered with the background watcher for ongoing git-based change detection. Configurable file limit: `config set auto_index_limit 50000`.
Watcher registration is controlled separately by `auto_watch` (default `true`). Set `config set auto_watch false` to keep a session from registering its project with the background watcher — useful when working across many projects and you want each session contained to explicit indexing.
The watcher follows MCP sessions: it watches the project each open MCP session is rooted in (the client's working directory) and stops when the last session for that project closes. A repository indexed with `cli index_repository`, or indexed from a session rooted somewhere else, is not watched; re-run `index_repository` after changing it. `index_status` reports this in its `watch` object: `watched`, and either the poll cadence and last scan time or the `reason` it is not watched.
Watcher registration is controlled separately by `auto_watch` (default `true`). Set `config set auto_watch false` to keep a session from registering its project with the background watcher; useful when working across many projects and you want each session contained to explicit indexing.
To turn the watcher off entirely, set `config set watcher_enabled false` (default `true`): the background poll thread never starts and no project is registered, while `auto_index` and manual `index_repository` keep working. Unlike `auto_watch`, which is consulted per session, `watcher_enabled` is read once when the background daemon starts, so run `codebase-memory-mcp daemon stop` after changing it; reconnecting your MCP client alone will not restart the daemon. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#2-cli-managed-runtime-settings).
The watcher follows **git** projects only by default: a project indexed from a directory that is not a git repository is not watched and stays at its last index until you run `index_repository` again. To have such roots polled too, set `config set watch_non_git true` (default `false`, read when the daemon starts): they are then checked on the same cadence with a file-tree scan that uses the indexer's own ignore rules.
### Keeping Up to Date
@@ -169,7 +272,7 @@ powershell -ExecutionPolicy Bypass -File "<install-dir>\install.ps1"
The install script is placed next to the binary at install time, so the printed path resolves beside the executable. It is idempotent, so re-running it *is* the update: it stops the daemon, retires the running binary, installs the new one, and cleans up.
Why it works this way. On Windows it is a hard requirement — a running executable cannot replace its own image, so the swap has to happen from a process that is not the binary being replaced. On macOS and Linux it is a deliberate choice: an in-process updater is structurally a downloader (fetch an archive, verify it, unpack it, mark a file executable, run it), and shipping that composite in every binary to serve a command most people run a handful of times is a poor trade. The release archives now carry no download URLs at all, and **cbm makes no network request of its own accord** — it does not check for new versions in the background, and nothing phones home. You find out about releases from the install script, your package manager, or GitHub.
Why it works this way. On Windows it is a hard requirement; a running executable cannot replace its own image, so the swap has to happen from a process that is not the binary being replaced. On macOS and Linux it is a deliberate choice: an in-process updater is structurally a downloader (fetch an archive, verify it, unpack it, mark a file executable, run it), and shipping that composite in every binary to serve a command most people run a handful of times is a poor trade. The release archives now carry no download URLs at all, and **cbm makes no network request of its own accord**: it does not check for new versions in the background, and nothing phones home. You find out about releases from the install script, your package manager, or GitHub.
If PowerShell refuses to run the script because the file came from the internet, `Unblock-File` it first.
@@ -181,23 +284,30 @@ Installed through **npm or pip**? Update with your package manager on every plat
codebase-memory-mcp uninstall
```
Removes owned agent config entries, skills, hooks, instructions, and the installed binary. Existing graph indexes are listed and deleted only after confirmation.
Removes owned agent config entries, skills, hooks, instructions, and the installed binary. Existing graph indexes are listed and **kept by default**. `-y`/`--yes`, `-n`/`--no`, and noninteractive input keep them unless `--delete-indexes` is explicitly given. An interactive terminal without that flag is asked separately; the default answer is to keep them.
The install script placed beside the binary is **reported, not deleted** — uninstall prints its path and the `rm` command for it. It is left alone on purpose: it may be your own copy, a symlink into a checkout, or managed by a package manager, and an uninstaller should not delete a file it cannot prove it owns.
```bash
codebase-memory-mcp uninstall -y --delete-indexes # also delete every project index
codebase-memory-mcp uninstall --dry-run --delete-indexes # preview; change nothing
```
`--delete-indexes` is explicit consent and takes precedence over `--no`; `--dry-run` always preserves the files. When indexes are kept, uninstall prints their cache directory (`${CBM_CACHE_DIR:-~/.cache/codebase-memory-mcp}`) and how to remove them.
The install script placed beside the binary is **reported, not deleted**: uninstall prints its path and the `rm` command for it. It is left alone on purpose: it may be your own copy, a symlink into a checkout, or managed by a package manager, and an uninstaller should not delete a file it cannot prove it owns.
## Features
### Graph & analysis
- **Architecture overview**: `get_architecture` returns languages, packages, entry points, routes, hotspots, boundaries, layers, and clusters in a single call
- **Architecture overview**: `get_architecture` returns a compact summary by default. Request specific `aspects` for routes, hotspots, dependencies, clusters, or cycles. `system_structure` provides the projection used by CBM Lens and must be requested on its own.
- **Architecture Decision Records**: `manage_adr` persists architectural decisions across sessions
- **Louvain community detection**: Discovers functional modules by clustering call edges
- **Git diff impact mapping**: `detect_changes` maps uncommitted changes to affected symbols with risk classification
- **Call graph**: Resolves function calls across files and packages (import-aware, type-inferred)
- **Dead code detection**: Finds functions with zero callers, excluding entry points
- **Dead code candidates**: find functions with no indexed callers, excluding entry points. Check dynamic dispatch, coverage gaps, and framework conventions before removing code.
- **Cypher-like queries**: `MATCH (f:Function)-[:CALLS]->(g) WHERE f.name = 'main' RETURN g.name`
### Search
- **Semantic search** (`semantic_query`): vector search across the entire graph, powered by bundled Nomic `nomic-embed-code` embeddings (40K tokens, 768d int8) compiled into the binary — no API key, no Ollama, no Docker. 11-signal combined scoring (TF-IDF, RRI, API/Type/Decorator signatures, AST profiles, data flow, Halstead-lite, MinHash, module proximity, graph diffusion).
- **Semantic search** (the `semantic_query` parameter of `search_graph`, not a separate tool): vector search across the entire graph, powered by bundled Nomic `nomic-embed-code` embeddings (40K tokens, 768d int8) compiled into the binary; no API key, no Ollama, no Docker. 11-signal combined scoring (TF-IDF, RRI, API/Type/Decorator signatures, AST profiles, data flow, Halstead-lite, MinHash, module proximity, graph diffusion).
- **BM25 full-text search** via SQLite FTS5 with `cbm_camel_split` tokenizer (camelCase / snake_case aware)
- **Structural search** (`search_graph`): regex name patterns, label filters, min/max degree, file scoping
- **Code search** (`search_code`): graph-augmented grep over indexed files only
@@ -209,53 +319,83 @@ The install script placed beside the binary is **reported, not deleted** — uni
### Cross-repo intelligence
- **`CROSS_*` edges** link nodes across multiple repos indexed under the same store
- **Multi-galaxy 3D UI layout** for cross-repo architecture visualization
- **Cross-repo architecture summary** combining services, routes, and dependencies across the indexed fleet
- **Run status**: `index_status` (and the UI layout next to `linked_projects`) carries a `cross_repo` object, `{"status":"never_run"}` until `index_repository(mode="cross-repo-intelligence")` has run with that project as the source, then `"ran"` with `last_run_at`, `outcome`, `targets`, `projects_scanned`, `total_cross_edges` and `skipped_projects`. That is how you tell "never linked" apart from "linked, nothing matched". The status is stored in the project's own index next to its `CROSS_*` edges, so a reindex that rebuilds the index drops both at once and the status goes back to `never_run`.
- **Skipped stores**: `target_projects: ["*"]` skips an index built before the #768 schema change rather than failing the whole run. The response lists each one as `skipped_projects: [{"name", "reason": "pre_768_schema", "hint": "reindex this project"}]`.
### Edge types (selected)
- `CALLS` — a callable is invoked at the source site
- `CALL_REFERENCE` — a callable is used at a supported reference site (for example, a direct value argument) and resolves to one exact target
- `USAGE` — an identifier is used, but a unique callable target is not proven (including ambiguous or complex expressions)
- `CALLS`: a callable is invoked at the source site
- `CALL_REFERENCE`: a callable is used at a supported reference site (for example, a direct value argument) and resolves to one exact target
- `USAGE`: an identifier is used, but a unique callable target is not proven (including ambiguous or complex expressions)
- `IMPORTS`, `DEFINES`, `IMPLEMENTS`, `INHERITS`
- `HTTP_CALLS`, `ASYNC_CALLS` (cross-service)
- `HTTP_CALLS`, `ASYNC_CALLS` (cross-service); a shell script, Make recipe or CI step running `curl` / `wget` on a URL it states (constant words resolved, unknown parts `{}`) to its `Route` with the method its flags give: a Kubernetes Service of the repository by the path, as code clients do, any other host (a download, localhost) with the host kept; the edge keeps `via`, `method`, `url_path`, `host`, never the user info or the query
- `SPAWNS`: the caller starts another program (`subprocess.run`, `exec.Command`, `posix_spawn`, `Process.Start`, a shell command or Make recipe running a program by path, ...); the target is a `Process` node named after the program, or `<dynamic>` when the program is not a literal. When a shell script, Make recipe, GitHub Actions step or Prow job runs a script of the repository (`./hack/x.sh`, `bash hack/x.sh`), the target is that script's `Module` (else its `File`); `make TARGET` (also `$(MAKE)`, `-C`, `-f`) targets the rule of that target, followed through included makefiles; edges from CI carry `ci` and `job`. A Dockerfile's last stage `ENTRYPOINT` (else `CMD`) spawns what the container runs (`api: dockerfile`): the repository script or source copied into the image, the Go `main` of a binary a stage builds with `go build -o`, else a `Process`
- `INFRA_MAPS`: a Kubernetes Service to each workload whose pod labels carry every `key: value` of the Service's selector, within one namespace
- `CONNECTS_TO`: a Kubernetes workload or compose service to the Service its containers' environment addresses (`PRODUCT_CATALOG_SERVICE_ADDR: productcatalogservice:3550`, a URL's host, `<svc>.<ns>.svc...`, a bare name on an `*_ADDR` / `*_HOST` / `*_URL` / ... key; compose: the project's `.env` interpolated): a Kubernetes Service of the same bundle and namespace, or a compose service of the same file. The edge keeps `via: env`, `key` and `container`, never the value
- `USAGE` with `ref: image`: a Kubernetes workload (any pod template: containers, initContainers, CronJobs, custom resources) or a docker compose service to the `Image` it runs
- `DEPLOYS`: what puts manifests, kustomizations or charts on a cluster, to what it puts there: a Flux Kustomization or Argo Application to the kustomizations / manifests / chart of its path, a Flux HelmRelease to its chart (`spec.chart` or `chartRef` → OCIRepository), a shell command or Make recipe (`kubectl apply -f/-k`, `kustomize build | kubectl apply`, `helm install/upgrade/template`), a Go installer loading a chart with the Helm SDK, a Terraform `helm_release`, a Terraform `kubernetes_*` resource the object it declares (a `Resource` like a manifest's, `ecosystem: terraform`, when its name is a literal), a kustomization inflating a chart (`helmCharts`; its values file an `IMPORTS`). A chart outside the repository is an external `Chart`, keyed by its publisher when its repository URL is stated (`__chart__<repository>/<name>`, the same node in every project, the URL in `repository`), else one per project and name; the edge says `via` (`flux`, `argo`, `helmrelease`, `kubectl`, `kustomize`, `helm`, `helm-sdk`, `terraform`, `helmcharts`, ...)
- `DEPENDS_ON`: a Helm chart to the charts it declares (the local chart for a resolvable `file://` repository, otherwise an external `Chart`; the edge keeps `repository`), and a `go.mod` / `requirements.txt` file to an external `Package`; a Dockerfile to the `Image`s it builds on (`via: from`, `copy-from`); an `Image` to the Dockerfile it is built from (`via: build`; `tool`: `skaffold`, `compose`, `docker-build` for `docker` / `podman` / `nerdctl` / `buildah` build commands in scripts, Make and CI); a compose service to the services its `depends_on` / `links` name
- `CONFIGURES`: code reading an environment variable to the shared `EnvVar` of that name (`strategy: env_access`); a Kubernetes container, compose service or Dockerfile (`ENV` of the image) setting one to that container's own `EnvVar` (`strategy: env_set`, with `source`: `value`, `configMapKeyRef`, `secretKeyRef`, `fieldRef`, ..., `compose`, `dockerfile`; values are never stored); and that container's `EnvVar` to the code reading the name inside the files its image is built from (`strategy: env_read`, with `dockerfile`): the image's files are the sources its Dockerfile's `COPY` / `ADD` put into it, less the context's `.dockerignore`
- `CONFIGURES` with `strategy: key_symbol`: a code symbol to the config key it names (`config_key`). A YAML, JSON, HCL (Terraform, OpenTofu, tfvars), TOML or INI key links by exact name (`maxRetryCount` = `max_retry_count`) and only to code inside the config file's component: its directory, or the one around a `config/`, `configs/`, `conf/`, `settings/` or `etc/` directory. A `.env` or `.properties` key also links by substring (confidence 0.75)
- `OVERRIDE` with `via: patch`: a Kustomize patch to each object of its kustomization's build it changes (child → base): a patch file's documents to the object of the same kind and name; an inline patch (from the kustomization) or a JSON6902 operation file to what its `target` selects (group, version, kind, name, namespace, label selector); a component's in the build of each kustomization including it. The edge keeps `kustomization`, `patch` and `line`; a patch file is an `IMPORTS` (`local_name: patches`) of its kustomization, never a member of the build
- `OVERRIDE` with `via: namePrefix` / `nameSuffix`: an object a kustomization renames, from its variant: a `Resource` named with the deployed name (`capi-` + `controller-manager`; the prefixes of nested kustomizations outermost first, suffixes innermost first) in the namespace that build gives it, `ecosystem: kustomize`, which the kustomization's file `DEFINES`. CRDs, Namespaces and API services keep their names, as kustomize does. The edge keeps `kustomization` and `line`
- `USAGE` with `via: terraform`: a Terraform / OpenTofu reference (`var.X`, `local.X`, `module.X`, `data.T.N`, `T.N`) from the top-level block holding it to the block of its module (the `.tf` / `.tofu` files of its directory) that declares it (a `locals` entry: its `locals` block); an entry of the block's `depends_on` list is a `DEPENDS_ON` instead. The edge keeps `ref` (`var`, `local`, `module`, `data`, `resource`) and `line`; a name only mentioned in string text is no reference. A module call with a local source (`module "net" { source = "./modules/net" }`) `IMPORTS` (`local_name`: the module's name) the `Folder` of that directory (the root directory: the `Project`), and `module.net.id` (also `module.net[0].id`, `module.net[*].id`) `USAGE`s (`ref: output`) the `output "id"` there, beside `module.net`
- `OVERRIDE` with `via: tfvars`: a `.tfvars` assignment to a variable the module of its own directory declares, from the file's `Variable` to that `variable` block (the edge keeps `line`); a `.tfvars` file whose own directory declares none of its names sets the nearest ancestor module's that does, the edge marked `inferred` (such a file is applied by a `-var-file` the repository does not state)
- Terragrunt (`via: terragrunt`): a unit (`terragrunt.hcl`), evaluated in its own context: an `include` `IMPORTS` (`local_name`) the file its `path` names; a `read_terragrunt_config` `IMPORTS` (`read`: the file holding the call) the file it names, from the block holding the call or, for a call in an included file, from the unit's `include` block that brings it in; a `dependency` `DEPENDS_ON` the other unit; a local `terraform` `source` `IMPORTS` its `Folder`. Paths may use `find_in_parent_folders`, `dirname` and `get_terragrunt_dir`; anything else is unknown and links nothing
- Ansible (`via: ansible`): a playbook or task file (a YAML file whose top level is a sequence) `IMPORTS` (`local_name`: the directive) what its directives name: `import_playbook` a playbook, `include_tasks` / `import_tasks` / `include` a task file (from its directory, else its role's `tasks/`), `roles:` items and `include_role` / `import_role` a role's `Folder` (`roles/<name>` at or above its directory). A templated target links nothing. Each task and handler is a `Function` (named by its `name:`, else its module; `source: ansible`, `kind`, `module`) that its file `DEFINES`: in a playbook's plays, a role's `tasks/` and `handlers/` files, and the files those include; a block's tasks, not the block. A `notify` `CALLS` (`via: notify`) each handler of that name or `listen` topic in the task's own role (else its file). A `kube` (kubespray's module) or `kubernetes.core.k8s` task `DEPLOYS` (`via: ansible`, `module`) the repository file its play rendered the applied manifest from: the `template` (in the role's `templates/`) or `copy` (its `files/`) task whose `dest` is the task's `filename`, item by item through `register` / `with_items` / `loop` lists the play states (a literal list, a `set_fact`, the role's `defaults` / `vars`), or a `k8s` `template:` argument
- `EMITS`, `LISTENS_ON` (channels)
- `DATA_FLOWS` with arg-to-param mapping + field access chains
- `SIMILAR_TO` (MinHash + LSH near-clone detection, Jaccard scored)
- `SEMANTICALLY_RELATED` (vocabulary-mismatch, same-language, score ≥ 0.80)
### Indexing pipeline
- **158 vendored tree-sitter grammars** compiled into the binary
- **Generic package / module resolution** — bare specifiers like `@myorg/pkg`, `github.com/foo/bar`, `use my_crate::foo` resolved via manifest scanning (`package.json`, `go.mod`, `Cargo.toml`, `pyproject.toml`, `composer.json`, `pubspec.yaml`, `pom.xml`, `build.gradle`, `mix.exs`, `*.gemspec`)
- **Infrastructure-as-code indexing** — Dockerfiles, Kubernetes manifests, Kustomize overlays as graph nodes
- **[Hybrid LSP semantic type resolution](#hybrid-lsp)** for Python, TypeScript / JavaScript / JSX / TSX, PHP, C#, Go, C, C++, Java, Kotlin, Rust, and Perl — a lightweight C implementation of language type-resolution algorithms, structurally inspired by and compatible with major language servers including tsserver / typescript-go, pyright, gopls, Roslyn, Eclipse JDT, and rust-analyzer (parameter binding, return-type inference, generic substitution, JSX component dispatch, JSDoc inference for plain JS files, namespace + trait + late-static-binding resolution for PHP, file-scoped namespaces + records + LINQ method syntax for C#, class-hierarchy + overload + lambda resolution for Java, extension-function + scope-function resolution for Kotlin, trait-method + UFCS resolution for Rust)
- **162 vendored tree-sitter grammars** compiled into the binary
- **Generic package / module resolution**: bare specifiers like `@myorg/pkg`, `github.com/foo/bar`, `use my_crate::foo` resolved via manifest scanning (`package.json`, `go.mod`, `Cargo.toml`, `pyproject.toml`, `composer.json`, `pubspec.yaml`, `pom.xml`, `build.gradle`, `mix.exs`, `*.gemspec`)
- **Infrastructure-as-code indexing**: Dockerfiles (`Dockerfile.*`, `*.Dockerfile`, `Containerfile`: bases, what the container runs, `ENV`), container images (`Image`: the normalised repository, tag and digest on the edges; a repository built from several Dockerfiles, one per component tag, gets an `Image` per component), docker compose services (`Resource`), Kubernetes manifests (`Resource`, with `group`, `version`, `kind`, `namespace`, `labels` as `k=v,k2=v2`), Kustomize overlays (`IMPORTS` to their entries and patch files, `OVERRIDE` from their patches), Helm charts (`Chart`, `DEPENDS_ON`), Terraform / OpenTofu (`.tf`, `.tofu`, `.tfvars`) as graph nodes. A Service walks to the code it runs: Service → workload → `Image` → Dockerfile → `main`
- **[Hybrid LSP semantic type resolution](#hybrid-lsp)** for Python, TypeScript / JavaScript / JSX / TSX, PHP, C#, Go, C, C++, Java, Kotlin, Rust, and Perl; a lightweight C implementation of language type-resolution algorithms, structurally inspired by and compatible with major language servers including tsserver / typescript-go, pyright, gopls, Roslyn, Eclipse JDT, and rust-analyzer (parameter binding, return-type inference, generic substitution, JSX component dispatch, JSDoc inference for plain JS files, namespace + trait + late-static-binding resolution for PHP, file-scoped namespaces + records + LINQ method syntax for C#, class-hierarchy + overload + lambda resolution for Java, extension-function + scope-function resolution for Kotlin, trait-method + UFCS resolution for Rust)
- **RAM-first pipeline**: LZ4 compression, in-memory SQLite, single dump at end. Memory released after.
### Distribution & operation
- **Native runtime set, zero infrastructure services**: SQLite-backed, persists to `~/.cache/codebase-memory-mcp/`
- **Auto-sync**: Background watcher detects file changes and re-indexes automatically
- **Auto-sync**: Background watcher detects file changes in the project an MCP session is working in and re-indexes it automatically (other indexed repos: re-run `index_repository`)
- **Route nodes**: REST endpoints are first-class graph entities
- **CLI mode**: `codebase-memory-mcp cli search_graph '{"project": "my-project", "name_pattern": ".*Handler.*"}'`
- **Available on**: npm, PyPI, Homebrew, Scoop, Winget, Chocolatey, AUR, `go install`
- **CLI mode**: `codebase-memory-mcp cli search_graph --project my-project --name-pattern ".*Handler.*"`
- **Available on**: npm, PyPI, Homebrew, Scoop, AUR, `go install`
### Indexing modes
| Mode | Use it for |
|------|------------|
| `full` | Full indexing with semantic analysis. This is the default. |
| `moderate` | A filtered index with semantic analysis. |
| `fast` | A filtered index without semantic analysis. |
| `cross-repo-intelligence` | Link services across indexed projects, optionally limited by `target_projects`. |
## Team-Shared Graph Artifact
Commit a single compressed file to your repo and your teammates skip the reindex.
Share a compressed graph snapshot so teammates can reuse an indexed baseline.
`.codebase-memory/graph.db.zst` is a zstd-compressed snapshot of the knowledge graph that lives next to your source. When you index, the artifact is written or refreshed; when a teammate clones the repo and runs `codebase-memory-mcp` for the first time, the artifact is decompressed and incremental indexing fills in their local diff.
`.codebase-memory/graph.db.zst` is a zstd-compressed snapshot of the knowledge graph that lives next to your source. The artifact is opt-in: `index_repository` writes it only with `persistence: true` (the default is `false`), and once it exists later indexes refresh it. Local indexes persist regardless. When a teammate indexes a clone with an existing artifact, CBM imports that baseline and applies their local changes incrementally.
- **Format**: SQLite database, indexes stripped, `VACUUM INTO` compacted, then zstd 1.5.7 compressed (8–13:1 ratio typical)
- **Format**: SQLite database, indexes stripped, `VACUUM INTO` compacted, then zstd 1.5.7 compressed (8 to 13:1 ratio typical)
- **Two tiers**:
- **Best** (`zstd -9` + index strip + `VACUUM INTO`) — written on explicit `index_repository`
- **Fast** (`zstd -3`) — written by the watcher for low-latency incremental updates
- **Bootstrap**: when no local DB exists but the artifact is present, `index_repository` imports the artifact first, then runs incremental indexing — avoiding the full reindex cost
- **No merge pain**: a `.gitattributes` line with `merge=ours` is auto-created on first export, so concurrent edits don't produce conflicts on the binary artifact
- **Optional**: never committed unless you want it. Add `.codebase-memory/` to `.gitignore` if you prefer everyone to reindex from scratch.
- **Best** (`zstd -9` + index strip + `VACUUM INTO`); written by `index_repository` with `persistence: true`
- **Fast** (`zstd -3`); refreshes an existing artifact on every other index, including the watcher's low-latency incremental updates
- **Bootstrap**: when no local DB exists but the artifact is present, `index_repository` imports the artifact first, then runs incremental indexing, reusing the shared baseline where possible
- **Merge policy**: the first export creates `.codebase-memory/.gitattributes` with `merge=ours`. Check your repository's Git merge-driver setup before relying on it for concurrent artifact updates.
- **Commit it deliberately**: the artifact is rewritten on every index, including the watcher's Fast tier, and git stores each rewrite as a full new blob. Committing every refresh is what turns a 20 MB file into gigabytes of history; one team reached ~6 GB across ~350 commits of this single path. Pick a cadence (a release, a milestone, a nightly job) rather than committing every save.
- **Git LFS, if it must move on every commit**: track it from the **repo-root** `.gitattributes` and leave the auto-created `.codebase-memory/.gitattributes` in place; the nearer file goes on supplying `merge=ours`, and only `filter` comes from the root:
```gitattributes
.codebase-memory/graph.db.zst filter=lfs diff=lfs merge=lfs -text
```
Track only the `.zst`; `artifact.json` is small and carries the schema version. The attribute applies to future commits only. Removing existing blobs requires a separate history migration. Two costs to weigh before adopting it: GitHub meters LFS storage and bandwidth, and its objects cannot be pruned without contacting support; and every teammate needs `git lfs install`; without it their checkout leaves a pointer file where the artifact should be, the integrity-checked import refuses it, and they fall back to a full reindex.
- **Optional**: shared export is opt-in, and Git commits remain your choice. Add `.codebase-memory/` to `.gitignore` if you prefer everyone to reindex from scratch.
The result is similar in spirit to graphify's `graphify-out/` directory, but as a single compressed file with explicit two-tier export, integrity-checked import, and zero merge friction.
The artifact provides a shared baseline while each developer keeps a local query database.
## How It Works
codebase-memory-mcp is a **structural analysis backend** — it builds and queries the knowledge graph. It does **not** include an LLM. Instead, it relies on your MCP client (Claude Code, or any MCP-compatible agent) to be the intelligence layer.
codebase-memory-mcp is a **structural analysis backend**: it builds and queries the knowledge graph. The backend does not require a generative model. It relies on your MCP client (Claude Code, or any MCP-compatible agent) to be the intelligence layer.
```
You: "what calls ProcessOrder?"
@@ -267,11 +407,11 @@ codebase-memory-mcp: executes graph query, returns structured results
Agent: presents the call chain in plain English
```
**Why no built-in LLM?** Other code graph tools embed an LLM for natural language → graph query translation. This means extra API keys, extra cost, and another model to configure. With MCP, the agent you're already talking to *is* the query translator.
With MCP, your coding agent translates a question into tool calls. CBM Lens can also use an optional browser model for explanations; this does not change how the backend indexes or queries code.
## Performance
Benchmarked on Apple M3 Pro:
Published backend benchmarks on Apple M3 Pro. These are separate from the CBM Lens checks:
| Operation | Time | Notes |
|-----------|------|-------|
@@ -285,26 +425,28 @@ Benchmarked on Apple M3 Pro:
**RAM-first pipeline**: All indexing runs in memory (LZ4 HC compressed read, in-memory SQLite, single dump at end). Memory is released back to the OS after indexing completes.
**Token efficiency**: Five structural queries consumed ~3,400 tokens via codebase-memory-mcp versus ~412,000 tokens via file-by-file grep exploration — a **99.2% reduction**.
**Token efficiency**: The project's [original README report](https://github.com/DeusData/codebase-memory-mcp/blob/4ff6feeab639c4668da0f491930326ed5ec63093/README.md#performance) lists ~3,400 tokens for five structural queries via codebase-memory-mcp versus ~412,000 via file-by-file exploration. That is approximately **120× fewer tokens**, or a **99.2% reduction**, for that comparison. The separate [31-repository study](https://arxiv.org/html/2603.27277v1#S4.SS1) reports a 10× token reduction. Its [system-performance measurements](https://arxiv.org/html/2603.27277v1#S4.SS3) include Linux kernel indexing in about 3 minutes and Cypher relationship queries under 1 ms on an Apple M3 Pro.
To measure comparable quality, latency, and agent-efficiency metrics on your own workload, see [Measuring quality, latency, and agent savings](docs/MEASURING_SAVINGS.md). Exact reproduction of the figures above requires the original inputs and raw artifacts.
## Troubleshooting & Diagnostics
codebase-memory-mcp runs **100% locally and collects no telemetry** — your code, queries, environment, and usage never leave your machine. That privacy guarantee also means that when you hit something we can't reproduce on our side (a slow memory climb over hours, a performance regression, a leak that only appears after days of real use), **we have no data at all unless you choose to send it.** Here is how to capture it yourself.
codebase-memory-mcp runs **100% locally and collects no telemetry**: your code, queries, environment, and usage never leave your machine. That privacy guarantee also means that when you hit something we can't reproduce on our side (a slow memory climb over hours, a performance regression, a leak that only appears after days of real use), **we have no data at all unless you choose to send it.** Here is how to capture it yourself.
### Capture a diagnostics log
Set `CBM_DIAGNOSTICS=1` before the first daemon-backed MCP session starts, then reproduce the problem (let it run as long as it takes — a slow leak needs time to show in the trend). The shared daemon captures this setting from the session that starts it. If it is already running, close all daemon-backed sessions so it exits before changing the setting. The daemon creates a fresh owner-private `cbm-diagnostics-<pid>-<random>` directory below the system temp directory (`$TMPDIR` or `/tmp` on macOS/Linux, `%TEMP%` on Windows). The exact paths are recorded by the `diagnostics.start` event in `${CBM_CACHE_DIR}/logs/cbm-daemon.log`:
Set `CBM_DIAGNOSTICS=1` before the first daemon-backed MCP session starts, then reproduce the problem (let it run as long as it takes; a slow leak needs time to show in the trend). The shared daemon captures this setting from the session that starts it. If it is already running, close all daemon-backed sessions so it exits before changing the setting. The daemon creates a fresh owner-private `cbm-diagnostics-<pid>-<random>` directory below the system temp directory (`$TMPDIR` or `/tmp` on macOS/Linux, `%TEMP%` on Windows). The exact paths are recorded by the `diagnostics.start` event in `${CBM_CACHE_DIR}/logs/cbm-daemon.log`:
| File | What it is |
|------|------------|
| `trajectory.ndjson` | **The memory trajectory** — one JSON line every 5 s with `rss`, `committed` (Windows commit charge), `peak_*`, `page_faults`, `fd`, and `queries`. **This is the file we need for memory/leak reports** — the *trend over time* is what pinpoints a leak. It is **kept on disk after the server exits** (so you can grab it post-mortem) and rotates to `trajectory.ndjson.1` past ~8 MB. |
| `snapshot.json` | The latest snapshot only — handy for a quick live check. Removed on clean exit. |
| `trajectory.ndjson` | **The memory trajectory**: one JSON line every 5 s with `rss`, `committed` (Windows commit charge), `peak_*`, `page_faults`, `fd`, and `queries`. **This is the file we need for memory/leak reports**: the *trend over time* is what pinpoints a leak. It is **kept on disk after the server exits** (so you can grab it post-mortem) and rotates to `trajectory.ndjson.1` past ~8 MB. |
| `snapshot.json` | The latest snapshot only; handy for a quick live check. Removed on clean exit. |
The private randomized directory prevents another local account from pre-placing a link or special file at a predictable diagnostics path. Its `<pid>` component is the shared daemon's process ID, also recorded by the `daemon.start` event. Set the variable consistently in the `env` block of each agent's MCP server config, or export it before launching the first session.
### What to share
When you open a memory/performance issue, **attach the `.ndjson` trajectory** — it contains no source code or query text, only resource counters. If you'd rather not attach a file, paste it (or an agent's summary of it) into the issue: your assistant can read the NDJSON directly and report whether `rss`/`committed` grow monotonically, how fast, and relative to query count — which is exactly what we need to find the cause.
When you open a memory/performance issue, **attach the `.ndjson` trajectory**: it contains no source code or query text, only resource counters. If you'd rather not attach a file, paste it (or an agent's summary of it) into the issue: your assistant can read the NDJSON directly and report whether `rss`/`committed` grow monotonically, how fast, and relative to query count; which is exactly what we need to find the cause.
## Installation
@@ -318,7 +460,7 @@ When you open a memory/performance issue, **attach the `.ndjson` trajectory**
| Linux (ARM64) | `codebase-memory-mcp-linux-arm64.tar.gz` |
| Windows (x86_64) | `codebase-memory-mcp-windows-amd64.zip` |
Every release includes `checksums.txt` with SHA-256 hashes. The executable is self-contained — no adjacent data file is required. Linux `-portable` archives contain the fully static builds; ordinary platform archives use their native system ABI.
Every release includes `checksums.txt` with SHA-256 hashes. The executable is self-contained; no adjacent data file is required. Linux `-portable` archives contain the fully static builds; ordinary platform archives use their native system ABI.
> **Windows note**: SmartScreen may show a warning for unsigned software. Click **"More info"** → **"Run anyway"**. Verify integrity with `checksums.txt`.
@@ -353,6 +495,38 @@ paru -S codebase-memory-mcp-bin
The `codebase-memory-mcp-bin` package is available at: https://aur.archlinux.org/packages/codebase-memory-mcp-bin
### Nix (flake)
The flake exposes two server packages plus the standalone frontend:
| Package | Contents |
|---------|----------|
| `default` (`codebase-memory-mcp`) | Standard server, no UI |
| `codebase-memory-mcp-ui` | Server with CBM Lens embedded (`--ui=true` works) |
| `graph-ui` | Just the built CBM Lens assets (`dist/`) |
Run directly without installing:
```bash
# Standard server
nix run github:DeusData/codebase-memory-mcp
# Server with CBM Lens embedded
nix run github:DeusData/codebase-memory-mcp#codebase-memory-mcp-ui -- --ui=true --port=9749
# then open http://127.0.0.1:9749
```
Or build a binary into `./result/bin/codebase-memory-mcp`:
```bash
nix build github:DeusData/codebase-memory-mcp # standard
nix build github:DeusData/codebase-memory-mcp#codebase-memory-mcp-ui # with UI
```
Working in a clone? Use `.` in place of the flake URL, e.g. `nix run .#codebase-memory-mcp-ui -- --ui=true`, or drop into a shell that puts the binary on `PATH` with `nix shell .#codebase-memory-mcp-ui`.
> **Note:** launched by hand (not from an MCP client) the server exits as soon as `stdin` closes; that's normal MCP behaviour. Keep `stdin` open while testing the UI, e.g. `sleep infinity | codebase-memory-mcp --ui=true --port=9749`. The `codebase-memory-mcp-ui` package embeds the UI at build time; `nix run`'ing the standard `default` package with `--ui=true` will refuse to start the HTTP server.
### Install via Claude Code
```
@@ -362,28 +536,41 @@ You: "Install this MCP server: https://github.com/DeusData/codebase-memory-mcp"
### Build from Source
<details>
<summary>Prerequisites: C compiler + zlib</summary>
<summary>Prerequisites: C/C++ compiler, zlib, and Node.js for CBM Lens</summary>
| Requirement | Check | Install |
|-------------|-------|---------|
| **C compiler** (gcc or clang) | `gcc --version` or `clang --version` | macOS: `xcode-select --install`, Linux: `apt install build-essential` |
| **C++ compiler** | `g++ --version` or `clang++ --version` | Same as above |
| **zlib** | — | macOS: included, Linux: `apt install zlib1g-dev` |
| **zlib** | n/a | macOS: included, Linux: `apt install zlib1g-dev` |
| **Git** | `git --version` | Pre-installed on most systems |
| **Node.js 22+ and npm** | `node --version` and `npm --version` | Required for `--with-ui` |
</details>
```bash
git clone https://github.com/DeusData/codebase-memory-mcp.git
cd codebase-memory-mcp
scripts/build.sh --with-ui # the shipped composition (graph UI embedded)
scripts/build.sh --with-ui # the shipped composition (CBM Lens embedded, needs Node.js)
scripts/build.sh # without the UI (development only)
# Binary at: build/c/codebase-memory-mcp (codebase-memory-mcp.exe on Windows)
```
Every platform ships **one self-contained executable**: the graph UI and the agent integration templates are linked into the binary, so an extracted archive is immediately complete.
The `--with-ui` build embeds CBM Lens and the agent integration templates into the native executable. You do not need Node.js to run that executable. Optional browser model weights are downloaded separately after opt-in.
Run the test suite (6,768 tests across 120 suites):
#### Tests
Counts on `main` at `4d9592b5`, where CBM Lens arrived. Native and Go counts use the macOS configuration; platform-specific registrations differ.
| Suite | Count | Checked for this README |
|---|---:|---|
| Native C | 9,388 tests across 189 suites | 9,376 passed, 11 skipped, 1 failed on macOS (`regex_compile_refuses_overlong_pattern`) |
| UI unit tests (Vitest) | 3,970 tests in 310 files | All passed |
| Go package wrapper | 30 top-level tests | Counted in the source |
| npm package wrapper | 24 tests | Counted in the source |
| Python package wrapper | 43 tests | Counted in the source |
Run the test suite (9,388 tests across 189 suites):
```bash
scripts/test.sh # full: clean sanitizer build + all suites + guards
@@ -391,7 +578,21 @@ scripts/test.sh --suites <name> # one suite, incremental, seconds
build/c/test-runner --list-suites # what is available
```
`scripts/test.sh` is the same entry the CI gates run, so a local pass means the same thing a CI pass does. The canonical local artifact-flow check builds both stripped/unstripped candidates, defaults to the stripped candidate for this explicitly unscanned local run, packages those exact bytes, extracts the archive, and smokes it:
Frontend development and validation:
```bash
cd graph-ui
npm ci
npm run test:unit
npm run build
npm run dev
```
The dev server listens on port 5173 and proxies `/api` and `/rpc` to port 9749. Start the matching CBM backend first.
Package wrappers have their own test commands: `go test ./...` in `pkg/go`, `npm test` in `pkg/npm`, and `python3 -m unittest discover -s tests -p 'test_*.py'` in `pkg/pypi`. The Go wrapper requires Go 1.26.1 or newer.
`scripts/test.sh` is the C test entry point used by CI. The canonical local artifact-flow check builds both stripped/unstripped candidates, defaults to the stripped candidate for this explicitly unscanned local run, packages those exact bytes, extracts the archive, and smokes it:
```bash
scripts/ci/smoke-artifact.sh <linux|darwin|windows> <amd64|arm64>
@@ -419,13 +620,13 @@ Add to `~/.claude.json` (user scope) or project `.mcp.json`:
}
```
Restart your agent. Verify with `/mcp` — you should see `codebase-memory-mcp` with 15 tools.
Restart your agent. Verify with `/mcp`; you should see `codebase-memory-mcp` with 17 tools.
</details>
## Multi-Agent Support
`install` configures 43 client surfaces: 37 detected automatically and 6
`install` configures 45 client surfaces: 39 detected automatically and 6
conditional or explicit. “Conditional” means the installer writes only when the
documented platform or an explicit, already-existing config path proves the
target is active. It never flips experimental feature flags, enables plugins,
@@ -434,9 +635,9 @@ YOLO modes, global permission bypasses, or third-party instruction trust.
Where a client has a documented custom-agent format, the installer creates three
exact-owned definitions from one canonical contract:
- **Scout (Tier 1)** — about 3–4 narrow calls for fast positive, provisional discovery; no absence, exhaustive-impact, or dead-code claims.
- **Verify (Tier 2, default)** — task-directed graph evidence, exact source checks, path coverage for every cited file, and scope coverage before negative claims.
- **Auditor (Tier 3)** — bounded scope, current index generation, complete relevant pagination, broader relationship checks, and explicit unresolved limitations.
- **Scout (Tier 1)**: about 3 to 4 narrow calls for fast positive, provisional discovery; no absence, exhaustive-impact, or dead-code claims.
- **Verify (Tier 2, default)**: task-directed graph evidence, exact source checks, path coverage for every cited file, and scope coverage before negative claims.
- **Auditor (Tier 3)**: bounded scope, current index generation, complete relevant pagination, broader relationship checks, and explicit unresolved limitations.
Every direct tier batches `check_index_coverage` for its evidence paths and reads
flagged ranges or skipped/excluded files directly. A clean coverage result means
@@ -448,13 +649,13 @@ overwrite user-modified agents.
| Agent | Activation | MCP config | Durable context / augmentation |
|-------|------------|------------|--------------------------------|
| Claude Code | Detected | `~/.claude.json` | Skill + three exact-tool graph agents; `SessionStart`, `SubagentStart`, non-blocking `PreToolUse` for `Grep`/`Glob`, and post-`Read` coverage |
| Codex CLI | Detected | `$CODEX_HOME/config.toml` | `AGENTS.md`, skill, three read-only agents; `SessionStart` + `SubagentStart` |
| Claude Code | Detected | `~/.claude.json` | Skill + three exact-tool graph agents; `SessionStart`, `SubagentStart`, non-blocking `PreToolUse` for `Grep`/`Glob`/`Bash`, and post-`Read` coverage |
| Codex CLI | Detected | `$CODEX_HOME/config.toml` | Managed `AGENTS.md` activation pointer, skill, three read-only agents; `SessionStart` + `SubagentStart` |
| Gemini CLI | Detected | `.gemini/settings.json` | `GEMINI.md`, three explicit read/graph-tool subagents; `BeforeTool`, `AfterTool` `read_file` coverage, and `SessionStart` |
| Zed | Detected | platform `settings.json` (JSONC) | `AGENTS.md` + shared skill |
| OpenCode | Detected | `$OPENCODE_CONFIG` or resolved global config | `AGENTS.md`, skill, three deny-by-default read-only agents |
| OpenCode | Detected | `$OPENCODE_CONFIG` or resolved global config | `AGENTS.md`, skill, three deny-by-default read-only agents; plugin adds grep/glob graph lookup, post-`read` coverage, first-tool-result session context, and post-compaction reinjection |
| Antigravity | Detected | `.gemini/config/mcp_config.json` | `.gemini/GEMINI.md` |
| Aider | Detected | — | `CONVENTIONS.md` via `.aider.conf.yml` |
| Aider | Detected | none | `CONVENTIONS.md` via `.aider.conf.yml` |
| KiloCode | Detected | `.config/kilo/kilo.jsonc` | Rule + three graph-tool subagents with deny-by-default permissions |
| VS Code | Detected | platform `Code/User/mcp.json` | `~/.copilot/skills`, three read-only agents, `sessionStart` + `subagentStart` |
| Cursor | Detected | `.cursor/mcp.json` | Skill + three read-only parent-handoff agents; context hooks withheld because session injection races and `readonly` blocks MCP |
@@ -473,6 +674,7 @@ overwrite user-modified agents.
| Crush | Detected | `.config/crush/crush.json` | Managed context path with explicit parent-to-child handoff |
| Goose | Detected | `.config/goose/config.yaml` | `.goosehints` |
| Mistral Vibe | Detected | `$VIBE_HOME/config.toml` | `AGENTS.md`, skill, and three matched agent/prompt pairs with explicit read-only graph-tool allowlists |
| Grok Build | Detected | `$GROK_HOME/config.toml` | Owned `rules/codebase-memory.md`, skill, three graph agents with named-server `mcpInheritance` and exact `server__tool` dispatcher ids; context hooks withheld because its passive hook events discard stdout |
| Qoder CLI | Detected | `~/.qoder/settings.json` | Skill, three directly MCP-attached agents with named-server scoping and exact per-tier graph-tool lists; `SessionStart`, `SubagentStart`, and post-`Read` coverage, including documented PowerShell execution on Windows |
| Kimi Code CLI | Detected | `$KIMI_CODE_HOME/mcp.json` (default `~/.kimi-code`) | Same-root `AGENTS.md` + skill; fail-open `UserPromptSubmit` hook in `config.toml` |
| GitLab Duo CLI | Detected | `$GLAB_CONFIG_DIR/duo/mcp.json` or platform fallback | Fail-open user `SessionStart` on macOS/Linux; hook withheld on Windows; no experimental global skill enablement |
@@ -488,14 +690,20 @@ overwrite user-modified agents.
| CodeBuddy Code CLI | Detected | `~/.codebuddy/.mcp.json` (preserves an active deprecated/legacy file) | `CODEBUDDY.md`, skill, three read-only graph agents; beta hooks are not auto-installed |
| IBM Bob Shell | Detected by `bob` | `~/.bob/mcp_settings.json` | Shared rule; no invented hook or agent |
| Pochi | Detected | `~/.pochi/config.jsonc` (`mcp`) | `README.pochi.md`, skill, and three `readFile`-only parent-handoff agents |
| Pi | Detected | — | `~/.pi/agent/AGENTS.md` + skill; MCP/subagents require an explicit reviewed extension |
| Pi | Detected | none | `~/.pi/agent/AGENTS.md` + skill; MCP/subagents require an explicit reviewed extension |
| IBM Bob IDE | Conditional | Existing `~/.bob/mcp.json` | Shared rule + IDE skill; no invented hook or agent |
| Oh My Pi (omp) | Detected | Effective agent directory (`OMP_PROFILE` / `PI_CODING_AGENT_DIR`; default `~/.omp/agent/mcp.json`) | Skill and three direct-MCP graph-tool subagents (Scout/Verify/Auditor); preserves user `AGENTS.md` |
| Sourcegraph Cody | Explicit opt-in | Existing `$CBM_CODY_CONFIG_PATH` | MCP only |
For Codex, install keeps only a tiny managed activation pointer in global
`$CODEX_HOME/AGENTS.md`; all detailed behavior lives in the installed `codebase-memory` skill.
Fresh installs create the pointer, upgrades replace the legacy full managed block while preserving
all user-owned bytes, and uninstall removes only the managed pointer.
### Sessions, compaction, and subagents
Hooks installed by this project are fail-open and context-only. Claude Code's
`PreToolUse` observes `Grep`/`Glob` and injects matching graph symbols as
`PreToolUse` observes `Grep`/`Glob`/`Bash` and injects matching graph symbols as
`additionalContext`; `PostToolUse` on `Read` adds targeted coverage context when
the graph could not fully parse or index that file. It never denies or replaces
the requested tool call.
@@ -521,7 +729,7 @@ carry the contract across fresh sessions and compaction: verify the graph projec
and index freshness, query structural facts in the parent, then pass the project,
qualified symbols, paths, and call-chain evidence in every delegated task.
Claude, Codex, Gemini, Kiro, Qwen, Copilot, CodeBuddy, OpenCode, Kilo, Vibe,
Qoder, Junie, and Factory receive Scout, Verify, and Auditor graph profiles.
Qoder, Junie, Factory, and Grok Build receive Scout, Verify, and Auditor graph profiles.
Kiro embeds this MCP server with `--tool-profile scout` for Scout and
`--tool-profile analysis` for Verify/Auditor. Junie registers equivalent named
server aliases because its subagent schema filters by server rather than by
@@ -547,7 +755,12 @@ project or plugin agents before user agents with the same name; reload the
client after installation or profile changes.
Cursor context
hooks are withheld: session context injection has a known race, `subagentStart`
is control-only, and read-only subagents cannot safely receive MCP access. Rovo
is control-only, and read-only subagents cannot safely receive MCP access. Grok Build's
passive hook events (`SessionStart`, `SubagentStart`, `PostToolUse`) discard
stdout and `PreToolUse` honors only deny/rewrite decisions, so its context hooks
are withheld; Grok also reads Claude and Cursor MCP, skill, and hook files
through its compat layer, and the native `config.toml` entry shadows that copy
by name. Rovo
has no documented session context-output hook, and Bob
documents neither a suitable hook nor a custom-agent surface. Those surfaces are
not approximated with invented augmentation. Kimi plugins, Amp plugins, and
@@ -576,11 +789,17 @@ no longer a suitable automatic global target.
## CLI Mode
Every MCP tool can be invoked as a local, one-shot command. CLI tools neither start nor connect to the coordination daemon and leave no standing process behind. They hold a crash-safe exact-build admission lease only for the command lifetime. `index_repository` is the only exception internally: it starts a temporary, exact-build supervised worker for the index, then stops that worker before the CLI command exits; the worker holds its own lease until exit.
Every MCP tool can be invoked as a local, one-shot command. CLI tools connect to the shared coordination daemon (starting one if none is already running) to get a crash-safe exact-build admission lease for the command's lifetime; they never register with the background watcher, and a daemon a command had to start itself exits again once the command's connection closes and no other session is using it. `index_repository` is the only exception internally: it starts a temporary, exact-build supervised worker for the index, then stops that worker before the CLI command exits; the worker holds its own lease until exit.
Commands that mutate graph data use shared OS-backed, per-project locks. This serializes conflicting work from CLI and MCP sessions on the same project while allowing unrelated projects to proceed independently.
When stderr is an interactive terminal, the CLI automatically shows lifecycle and indexing progress. Pass `--progress` to force the same feedback when stderr is redirected or the command is run non-interactively. Progress is written only to stderr; stdout remains reserved for the command result, so pipes and scripts stay machine-safe. Pass `--json` when the full MCP result envelope is needed.
When stderr is an interactive terminal, the CLI automatically shows lifecycle and indexing progress. Pass `--progress` to force the same feedback when stderr is redirected or the command is run non-interactively. Pass `--quiet` to disable automatic terminal progress and ordinary diagnostics while retaining errors; it cannot be combined with `--progress` or outer `cli --verbose`. Routine informational logs are quiet by default; pass outer `cli --verbose` to include them. Progress and logs use stderr while stdout remains reserved for the command result. Read tools return a compact tree by default; pass a tool's `--format json` for machine-readable payload JSON, or outer `--json` for the full MCP envelope.
Large compact-tree tables may start with a response-local `<section>_refs` directory and an explicit `<section>_ref_rule`. A cell such as `@0+handler.go` reconstructs to ref `0`'s prefix plus `handler.go`. References are local to that sibling `<section>` table and expansion is non-recursive: entries inside `<section>_refs` are always literal prefixes. This is limited to declared path and qualified-name columns and activates only when the exact rendered table is at least 15% and 64 bytes smaller and a conservative model-neutral token-shape proxy also improves by at least 1%. Search and trace likewise render direct and prefix-grouped tree shapes and keep the smaller complete representation, so singleton or scattered answers do not pay directory overhead. Keys are declared once per table but never cryptically abbreviated, and `--format json` keeps stable literal strings for machine consumers. Both gates are deterministic; exact token counts still depend on the caller's tokenizer.
Lean responses truncate semantically, never by cutting arbitrary bytes from code or identifiers. Ranked graph rows are retained ahead of raw grep rows and diagnostic summaries; omitted rows/sections report totals, `has_more`, and a strictly advancing continuation offset or cursor. If even the first whole row cannot fit, CBM asks for a higher budget and emits no self-looping cursor. `max_output_tokens` is model-neutral sizing guidance: CBM enforces a deterministic ceiling of four UTF-8 bytes per requested token, so it is not a tokenizer-exact count. Detail flags such as `diagnostics`, `source_mode`, and `detail` opt into heavier fields. `search_code` pages ranked rows with `result_limit`/`result_offset` (`limit` remains a compatibility alias), raw rows with `raw_limit`/`raw_offset`, and directory summaries with `directory_limit`/`directory_offset`. Raw lines default to a UTF-8-safe match-centered preview; each row reports `content_start_byte`, returned/total byte counts, match byte bounds when known, and a content continuation offset. Pass `raw_content_offset` to page the original line without moving the raw-row cursor. `match_limit` and `source_max_lines` bound per-result details, with exact omission metadata. `detect_changes` pages changed files, impacted symbols, and module summaries independently; prefer its snapshot-bound `*_cursor` continuations, which reject changed commits, worktree bytes, graph generation, or semantic arguments instead of silently skipping or duplicating rows.
Every response is standard UTF-8. Identifiers, paths, and raw search previews preserve POSIX byte-string identities: a preserved value containing malformed UTF-8 is emitted reversibly as `@bytes:<lowercase hex of every original byte>`. A valid preserved value that literally begins with the reserved `@bytes:` or `@utf8:` prefix is emitted as `@utf8:<original value>`, so decoding is unambiguous: strip one `@utf8:` prefix for literal UTF-8, or hex-decode one `@bytes:` prefix for original bytes. Ordinary valid UTF-8 is unchanged and pays no output-token overhead. To keep code readable, source bodies replace malformed UTF-8 with U+FFFD; use the pageable raw search preview when byte-exact source inspection is required.
Use `cli <tool> --help` to see the flags generated from that tool's input schema:
@@ -595,10 +814,13 @@ codebase-memory-mcp cli query_graph --project my-project --query 'MATCH (f:Funct
# Force human-readable progress without contaminating stdout.
codebase-memory-mcp cli --progress index_repository --repo-path /path/to/repo
codebase-memory-mcp cli search_graph --project my-project --label Function | jq '.results[].name'
# Suppress automatic terminal progress and non-error diagnostics.
codebase-memory-mcp cli --quiet list_projects --format json
codebase-memory-mcp cli search_graph --project my-project --label Function --format json
codebase-memory-mcp cli list_projects --format json --detail stats | jq '.projects[].name'
```
JSON arguments can also be piped on stdin, for tools that take arguments. A tool whose input schema declares none — `list_projects` — never reads stdin, so it stays responsive when it inherits a pipe the caller never closes (the default for `child_process.spawn` and similar wrappers). Inline JSON remains accepted for backward compatibility but is deprecated in favor of flags, `--args-file`, or stdin.
JSON arguments can also be piped on stdin, for tools that take arguments. A tool whose input schema declares none; `list_projects`; never reads stdin, so it stays responsive when it inherits a pipe the caller never closes (the default for `child_process.spawn` and similar wrappers). Inline JSON remains accepted for backward compatibility but is deprecated in favor of flags, `--args-file`, or stdin.
## MCP Tools
@@ -606,25 +828,57 @@ JSON arguments can also be piped on stdin, for tools that take arguments. A tool
| Tool | Description |
|------|-------------|
| `index_repository` | Index a repository into the graph. Auto-sync keeps it fresh after that. |
| `index_repository` | Index a repository into the graph. Auto-sync keeps it fresh only while it is the project of an open MCP session; repos indexed from the CLI or from another project's session need a re-run (`index_status` shows `watch`). Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). |
| `list_projects` | List all indexed projects with node/edge counts. |
| `delete_project` | Remove a project and all its graph data. |
| `index_status` | Check indexing status of a project. |
| `check_index_coverage` | Check whether exact paths or a scope are indexed and fresh. A clean result means no recorded gap, not proof of completeness. |
**Long indexes and client call deadlines.** A synchronous `index_repository` on a large
repository can take longer than an MCP client allows one tool call (some IDE clients give up
after a fixed deadline). When a client cancels or disconnects, the daemon cancels an index that
nobody else is waiting for, so retrying the same blocking call never finishes. Use the async
mode instead:
1. `index_repository(repo_path="/abs/path", async: true)` starts the index in the daemon (or
joins the one already running for that project) and returns immediately with
`state` (`queued`/`running`). The job keeps running even if the client cancels, times out or
disconnects; only stopping the daemon ends it.
2. `index_repository(repo_path="/abs/path", status: true)` reports `state`
(`queued`, `running`, `cancelling`, `succeeded`, `failed`, `cancelled`), `started_at`,
`finished_at` and an `error` summary. Poll it until the state is `succeeded`, `failed` or
`cancelled`. Pass the same `repo_path` (and `name`, if the index call used one).
`async` and `status` are exclusive; `async` does not apply to `cross-repo-intelligence`. Both
need the daemon-backed server (the default `codebase-memory-mcp` entry point). A temporary
daemon (started on demand rather than by `codebase-memory-mcp daemon start`) stops, and
cancels its jobs, when its last client disconnects. A connected MCP session keeps it alive, so
async from your editor works. A one-shot `codebase-memory-mcp cli index_repository --async`
that is the daemon's only client is refused with a clear error, because the job would die the
moment the command exits: run `codebase-memory-mcp daemon start` first, keep an MCP session
open, or call without `--async`. `status` works everywhere. When a synchronous call was cut
short, the next
`index_repository` or `status` call for that project carries a `notice` suggesting the async
mode. `index_status` keeps describing the published graph and its freshness.
### Querying
| Tool | Description |
|------|-------------|
| `search_graph` | Structured search by label, name pattern, file pattern, degree filters. Pagination via limit/offset. |
| `trace_path` | BFS traversal — who calls a function and what it calls (alias: `trace_call_path`). Depth 1-5. |
| `search_graph` | Structural, BM25, and semantic search. Page structural rows with `offset`/`limit` and ranked semantic rows independently with `semantic_offset`/`semantic_limit`. |
| `trace_path` | Trace callers, callees, data flow, or cross-service paths (alias: `trace_call_path`). Depth 1 to 15; tests are excluded by default. `mode`: `calls` (default), `data_flow`, `cross_service`, `topology` (Kubernetes objects, routes, controllers and what they deploy; start at a Resource or Route), `deploy` (what a CI job, script or Make rule runs and deploys; outbound from the job, inbound from a chart), `run` (what a Service or workload runs: workload → `Image` → Dockerfile → the program its container starts, with the environment each container is given; outbound from a Service, workload or compose service). |
| `detect_changes` | Map git diff to affected symbols + blast radius with risk classification. |
| `query_graph` | Execute Cypher-like graph queries (read-only). |
| `get_graph_schema` | Node/edge counts, relationship patterns, property definitions per label. Run this first. |
| `compare_graphs` | Compare two indexed snapshots: node/edge additions and removals between a base and a target. |
| `get_code_snippet` | Read source code for a function by qualified name. |
| `get_file_outline` | Declaration outline of one repository-relative file in source order, with optional label filter and paging. |
| `get_architecture` | Codebase overview: languages, packages, routes, hotspots, clusters, ADR. |
| `search_code` | Grep-like text search within indexed project files. |
| `manage_adr` | CRUD for Architecture Decision Records. Query modes do not wait behind a same-project reindex; writes remain serialized. |
| `ingest_traces` | Ingest runtime traces to validate HTTP_CALLS edges. |
| `manage_adr` | CRUD for Architecture Decision Records (`get` reads, `update` replaces the whole document, `set_sections` rewrites only the named sections and leaves every other byte untouched, `sections` lists headings). Query modes do not wait behind a same-project reindex; writes remain serialized. |
| `ingest_traces` | Store observed call paths or caller/callee pairs under a run label. Both endpoints must resolve to indexed symbols; unresolved names are reported. |
`manage_adr(mode='set_sections')` writes one or more sections by name and splices them into the stored document, so text outside the named sections (including a preamble, code fences and section ordering) is preserved byte-for-byte. Any `## Heading` works, not just the conventional PURPOSE / STACK / ARCHITECTURE / PATTERNS / TRADEOFFS / PHILOSOPHY set; names match exactly, including case. Writing the same section twice is a no-op, so a retry after a lost response cannot duplicate content.
`manage_adr` query modes (`get` and `sections`) use the server's cached query store so they can proceed while a same-project reindex is running. If another process publishes a replacement store during reindexing, they can return the pre-publication ADR until idle eviction refreshes that cache. Updates remain serialized through the project mutation guard.
@@ -632,11 +886,13 @@ JSON arguments can also be piped on stdin, for tools that take arguments. A tool
### Node Labels
`Project`, `Package`, `Folder`, `File`, `Module`, `Class`, `Function`, `Method`, `Interface`, `Enum`, `Type`, `Route`, `Resource`
`Project`, `Package`, `Folder`, `File`, `Module`, `Section`, `Class`, `Struct`, `Interface`, `Trait`, `Enum`, `Type`, `Function`, `Method`, `Field`, `Variable`, `Constant`, `Macro`, `Decorator`, `Route`, `Channel`, `Table`, `Process`, `EnvVar`, `Resource`, `Chart`, `Image`, `Branch`
### Edge Types
`CONTAINS_PACKAGE`, `CONTAINS_FOLDER`, `CONTAINS_FILE`, `DEFINES`, `DEFINES_METHOD`, `IMPORTS`, `CALLS`, `CALL_REFERENCE`, `HTTP_CALLS`, `ASYNC_CALLS`, `IMPLEMENTS`, `HANDLES`, `USAGE`, `CONFIGURES`, `WRITES`, `MEMBER_OF`, `TESTS`, `USES_TYPE`, `FILE_CHANGES_WITH`
`CONTAINS_FOLDER`, `CONTAINS_FILE`, `DEFINES`, `DEFINES_METHOD`, `IMPORTS`, `CALLS`, `CALL_REFERENCE`, `USAGE`, `INHERITS`, `IMPLEMENTS`, `OVERRIDE`, `DECORATES`, `RAISES`, `THROWS`, `WRITES`, `DATA_FLOWS`, `HTTP_CALLS`, `GRPC_CALLS`, `ASYNC_CALLS`, `EMITS`, `LISTENS_ON`, `HANDLES`, `SPAWNS`, `CONFIGURES`, `INFRA_MAPS`, `CONNECTS_TO`, `DEPLOYS`, `DEPENDS_ON`, `REFERENCES_FILE`, `MENTIONS`, `TESTS`, `TESTS_FILE`, `SIMILAR_TO`, `SEMANTICALLY_RELATED`, `FILE_CHANGES_WITH`, `HAS_BRANCH`, and the cross-repo `CROSS_HTTP_CALLS`, `CROSS_GRPC_CALLS`, `CROSS_GRAPHQL_CALLS`, `CROSS_TRPC_CALLS`, `CROSS_ASYNC_CALLS`, `CROSS_CHANNEL`
`MENTIONS` links a documented definition to the code its doc comment references (C# `<see cref>`, `<seealso cref>`, `<exception cref>`, `<inheritdoc cref>`), one edge per pair with `via`, `syntax`, `tier` (`exact` or `unique`), first `line` and `count`. A reference is bound only when it names exactly one definition under the language's own lookup rules; the rest are counted by reason in `index_status` under `doc_links` (samples with `diagnostics='full'`).
### Qualified Names
@@ -648,7 +904,7 @@ JSON arguments can also be piped on stdin, for tools that take arguments. A tool
- **Clauses**: `MATCH`, `OPTIONAL MATCH`, multiple `MATCH`, `WHERE`, `WITH` (+ `WITH … WHERE`), `RETURN`, `ORDER BY`, `SKIP`, `LIMIT`, `DISTINCT`, `UNWIND`, `UNION` / `UNION ALL`, `CASE`.
- **Patterns**: labelled nodes, label alternation `(n:A|B)`, relationship types/direction, variable-length paths `[*1..3]`, inline property maps.
- **WHERE**: `= <> < <= > >=`, `AND/OR/XOR/NOT`, `IN`, `CONTAINS`, `STARTS WITH`, `ENDS WITH`, `IS [NOT] NULL`, regex `=~`, label test `n:Label`, and `EXISTS { (n)-[:TYPE]->() }` (single-hop existence — great for dead-code, e.g. `WHERE NOT EXISTS { (f)<-[:CALLS]-() }`).
- **WHERE**: `= <> < <= > >=`, `AND/OR/XOR/NOT`, `IN`, `CONTAINS`, `STARTS WITH`, `ENDS WITH`, `IS [NOT] NULL`, regex `=~`, label test `n:Label`, and `EXISTS { (n)-[:TYPE]->() }` (single-hop existence; great for dead-code, e.g. `WHERE NOT EXISTS { (f)<-[:CALLS]-() }`).
- **Aggregates**: `count` (+`DISTINCT`), `sum`, `avg`, `min`, `max`, `collect`.
- **Functions**: `labels`, `type`, `id`, `keys`, `properties`; `toLower/toUpper/toString/toInteger/toFloat/toBoolean`; `size`, `length`, `trim/ltrim/rtrim`, `reverse`; `coalesce`, `substring`, `replace`, `left`, `right`.
@@ -667,23 +923,32 @@ codebase-memory-mcp config list # show all settings
codebase-memory-mcp config set auto_index true # auto-index on session start
codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-index
codebase-memory-mcp config set auto_watch false # don't register background git watcher (default: true)
codebase-memory-mcp config set doc_candidates false # skip doc<->code candidate scoring on huge repos (default: auto = on for full, off for moderate/fast)
codebase-memory-mcp config set watcher_enabled false # stop the watcher thread entirely (default: true)
codebase-memory-mcp config set watch_non_git true # also poll non-git project roots (default: false)
codebase-memory-mcp config set index_max_files 250000 # optional per-index source-file limit
codebase-memory-mcp config set index_max_source_mb 16384 # optional per-index source-size limit
codebase-memory-mcp config reset auto_index # reset to default
```
The two `index_max_*` settings default to `off`. Exceeding one fails the complete
index attempt rather than publishing a partial graph; an existing serving index
is preserved. See [Index resource limits](docs/INDEX_RESOURCE_LIMITS.md).
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `CBM_ALLOWED_ROOT` | *(unset)* | Confine `index_repository` to paths within this directory. When set, a `repo_path` that resolves (after symlink / `..` resolution) outside this root is refused, and the same check now applies to the graph UI's `POST /api/index` route rather than only to the MCP tool. Unset imposes no *containment* restriction — but see the always-on limits below, which apply whether or not this is set. Useful when the server may be driven by an untrusted caller, e.g. agentic or multi-tenant deployments. |
| `CBM_ALLOWED_ROOT` | *(unset)* | Confine `index_repository` to paths within this directory. When set, a `repo_path` that resolves (after symlink / `..` resolution) outside this root is refused, and the same check now applies to the graph UI's `POST /api/index` route rather than only to the MCP tool. Unset imposes no *containment* restriction; but see the always-on limits below, which apply whether or not this is set. Useful when the server may be driven by an untrusted caller, e.g. agentic or multi-tenant deployments. |
| `CBM_CACHE_DIR` | `~/.cache/codebase-memory-mcp` | Override the database storage directory. All project indexes and config are stored here. One account can use only one canonical cache root at a time; close active CBM sessions/commands before switching it. |
| `CBM_DIAGNOSTICS` | `false` | Set to `1` or `true` to enable the shared daemon's periodic `snapshot.json` and retained `trajectory.ndjson` below a fresh owner-private directory in the system temp directory. Exact paths are logged by `diagnostics.start`. |
| `CBM_DOWNLOAD_URL` | *(GitHub releases)* | Override the download URL for updates. Used for testing or self-hosted deployments. |
| `CBM_LOG_LEVEL` | `info` | Set the minimum log level. Accepted values (case-insensitive): `debug`, `info`, `warn`, `error`, `none` — or their numeric equivalents `0`–`4` matching the internal enum. Thin-frontend messages go to that session's stderr; detached daemon events go to `${CBM_CACHE_DIR}/logs/cbm-daemon.log`. Stdout is reserved for MCP JSON-RPC. |
| `CBM_WORKERS` | *(detected)* | Override the parallel-indexing worker count returned by `cbm_default_worker_count`. Useful inside containers where `sysconf(_SC_NPROCESSORS_ONLN)` reports host CPUs rather than the cgroup's effective quota. Range 1–256; invalid values are ignored with a warning. |
| `CBM_LOG_LEVEL` | role-aware | Set the minimum log level. Thin MCP/CLI/hook frontends default to `warn`; the detached daemon and its supervised index workers default to `info` so lifecycle and liveness records remain available. Accepted values (case-insensitive): `debug`, `info`, `warn`, `error`, `none`; or their numeric equivalents `0` to `4`. A physical worker retains INFO liveness records even under a stricter override because its private log drives the supervisor's no-progress timeout. Frontend messages go to that session's stderr; detached daemon events go to `${CBM_CACHE_DIR}/logs/cbm-daemon.log`. Stdout is reserved for MCP JSON-RPC. |
| `CBM_WORKERS` | *(detected)* | Override the parallel-indexing worker count returned by `cbm_default_worker_count`. Useful inside containers where `sysconf(_SC_NPROCESSORS_ONLN)` reports host CPUs rather than the cgroup's effective quota. Range 1 to 256; invalid values are ignored with a warning. |
| `CBM_MEM_BUDGET_MB` | *(detected)* | Override the in-memory graph budget with an explicit cap in MiB, taking precedence over the `ram_fraction × total_RAM` default. Useful on bare-metal hosts without a cgroup limit, or to pin a budget *below* the cgroup limit so headroom is left for sibling processes. Must be a positive integer; it is clamped to detected total RAM (logged as `mem.budget.clamped`), and non-numeric or non-positive values are ignored with a warning (`mem.budget.env.invalid`). |
| `CBM_DUMP_VERIFY_MIN_RATIO` | `0.5` | After indexing, compare persisted SQLite node count to the in-memory dump count. When persisted nodes fall below this fraction of committed nodes (and committed > 50), `index_repository` returns `status:"degraded"` instead of silent `indexed`. Range 0–1; set `0` to disable. Invalid values are ignored with a warning. |
| `CBM_DUMP_VERIFY_MIN_RATIO` | `0.5` | After indexing, compare persisted SQLite node count to the in-memory dump count. When persisted nodes fall below this fraction of committed nodes (and committed > 50), `index_repository` returns `status:"degraded"` instead of silent `indexed`. Range 0 to 1; set `0` to disable. Invalid values are ignored with a warning. |
Environment used by daemon-owned components—such as diagnostics, daemon logging, and process-wide indexing resource limits—is captured from the first daemon-backed session that starts the daemon. Later sessions join that process and cannot replace those values. To change them, close all daemon-backed sessions, update the relevant agent configurations consistently, and restart a session. `CBM_ALLOWED_ROOT` remains session-specific, a conflicting `CBM_CACHE_DIR` is rejected, and one-shot CLI commands read their own environment without starting the daemon.
Environment used by daemon-owned components (such as diagnostics, daemon logging, and process-wide indexing resource limits) is captured from the first daemon-backed session that starts the daemon. Later sessions join that process and cannot replace those values. To change them, close all daemon-backed sessions, update the relevant agent configurations consistently, and restart a session. `CBM_ALLOWED_ROOT` remains session-specific, a conflicting `CBM_CACHE_DIR` is rejected, and a one-shot CLI command is not exempt from the rule above: it connects to the coordination daemon like any other session, starting one if none is running. Its own environment becomes the captured daemon-owned environment only when its invocation is the one that starts the daemon; joining an already-running daemon, it inherits that daemon's already-captured values instead.
```bash
# Store indexes in a custom directory
@@ -724,16 +989,18 @@ SQLite databases stored at `~/.cache/codebase-memory-mcp/`. Persists across rest
|---------|-----|
| `/mcp` doesn't show the server | Check `.mcp.json` path is absolute. Restart agent. Test: `echo '{}' \| /path/to/binary` should output JSON. |
| `index_repository` fails | Pass absolute path: `index_repository(repo_path="/absolute/path")` |
| `index_repository` times out in the client | Start it with `async: true`, then poll with `status: true` (see [Indexing](#indexing)). |
| `trace_path` returns 0 results | Use `search_graph(name_pattern=".*PartialName.*")` first to find the exact name. |
| Queries return wrong project results | Add `project="name"` parameter. Use `list_projects` to see names. |
| Binary not found after install | Add to PATH: `export PATH="$HOME/.local/bin:$PATH"` |
| UI not loading | Ensure you ran `--ui=true`. Check `http://localhost:9749`. |
| CBM Lens shows "server unreachable" or "Architecture is not available yet." | Check that `?project=` names an indexed project exactly; pick it from the project menu, or see `list_projects`. Then look at **System**, tab **Logs**, or `${CBM_CACHE_DIR}/logs/ui.log`. |
## Hybrid LSP
**Semantic type resolution beyond tree-sitter.**
Tree-sitter alone gives a syntactic AST. That handles naming, structure, and call sites well, but it can't tell you that `user.profile.display_name()` resolves to `Profile.display_name` declared three modules away — tree-sitter doesn't track imports, generics, inheritance, or stdlib types.
Tree-sitter alone gives a syntactic AST. That handles naming, structure, and call sites well, but it can't tell you that `user.profile.display_name()` resolves to `Profile.display_name` declared three modules away; tree-sitter doesn't track imports, generics, inheritance, or stdlib types.
codebase-memory-mcp ships a **lightweight C implementation of language type-resolution algorithms, structurally inspired by and compatible with major language servers** (tsserver / typescript-go, pyright, gopls, Roslyn, Eclipse JDT, rust-analyzer), embedded directly into the native executable. No language server process, no per-project setup, no API key. We call this layer **Hybrid LSP**: it runs alongside tree-sitter on every parse and refines invocation resolution (`CALLS` / `RESOLVED_CALLS`) and callable-value resolution (`CALL_REFERENCE`, with ambiguous values retained as `USAGE`) using type information, so the resulting graph mirrors what an IDE "Go to Definition" would resolve.
@@ -754,14 +1021,14 @@ codebase-memory-mcp ships a **lightweight C implementation of language type-reso
**Two-layer architecture:**
1. **Tree-sitter pass** — fast, syntactic, runs for every one of the 158 languages. Extracts definitions, calls, imports.
2. **Hybrid LSP pass** — type-aware, runs above the tree-sitter pass per-language. Refines call edges using the import graph plus a per-file or pre-built cross-file definition registry. Languages without a Hybrid LSP pass yet fall back to textual resolution, so you always get *some* answer.
1. **Tree-sitter pass**: fast, syntactic, runs for every one of the 162 languages. Extracts definitions, calls, imports.
2. **Hybrid LSP pass**: type-aware, runs above the tree-sitter pass per-language. Refines call edges using the import graph plus a per-file or pre-built cross-file definition registry. Languages without a Hybrid LSP pass yet fall back to textual resolution, with coverage varying by language and construct.
The result is a knowledge graph accurate enough to drive `trace_path` across packages, inheritance hierarchies, and stdlib calls — without paying for a language server process per project.
The result is a knowledge graph accurate enough to drive `trace_path` across packages, inheritance hierarchies, and stdlib calls; without paying for a language server process per project.
## Language Support
158 languages, all parsed via vendored tree-sitter grammars compiled into the binary. Benchmarked against 64 real open-source repositories (78 to 49K nodes):
162 languages, counted by the vendored grammar directories in [the parser manifest](internal/cbm/vendored/grammars/MANIFEST.md). Benchmarked against 64 real open-source repositories (78 to 49K nodes):
| Tier | Score | Languages |
|------|-------|-----------|
@@ -769,7 +1036,7 @@ The result is a knowledge graph accurate enough to drive `trace_path` across pac
| **Good** (75-89%) | | Python, TypeScript, TSX, Go, Rust, Java, R, Dart, JavaScript, Erlang, Elixir, Scala, Ruby, PHP, C#, SQL |
| **Functional** (< 75%) | | OCaml, Haskell |
Also supported (not yet benchmarked): Ada, Agda, Apex, Assembly (NASM), Astro, AWK, Beancount, BibTeX, Bicep, Bitbake, Blade, Cairo, Cap'n Proto, Clojure, CMake, COBOL, Common Lisp, Crystal, CSV, CUDA, D, Devicetree, Diff, .env, Elm, Emacs Lisp, F#, Fennel, Fish, FORM, Fortran, FunC, GDScript, .gitattributes, .gitignore, Gleam, GLSL, GN, Go module, Go template, GraphQL, Hare, HLSL, Hyprlang, INI, ISPC, Janet, Jinja2, JSDoc, JSON, JSON5, Jsonnet, Julia, Just, Kconfig, KDL, Lean 4, Linker Script, Liquid, LLVM IR, Luau, Magma, Makefile, Markdown, MATLAB, Mermaid, Meson, Move, Nickel, Nim, Nix, Odin, Pascal, Pkl, PO (gettext), Pony, PowerShell, Prisma, .properties, Protobuf, Puppet, PureScript, Racket, Regex, requirements.txt, ReScript, RON, reStructuredText, Scheme, Slang, Smali, Smithy, Solidity, SOQL, SOSL, Squirrel, SSH config, Starlark, Svelte, Sway, SystemVerilog, TableGen, Tcl, Teal, Templ, Thrift, TLA+, Typst, Verilog, VHDL, Vim script, Vue, WGSL, WIT, Wolfram, XML, Zsh.
Also supported (not yet benchmarked): Ada, Agda, Apex, ArkTS, Assembly (NASM), Astro, AWK, Beancount, BibTeX, Bicep, Bitbake, Blade, Cairo, Cap'n Proto, CFML, CFScript, Chialisp, Clojure, CMake, COBOL, Common Lisp, Crystal, CSV, CUDA, D, Devicetree, Diff, .env, Elm, Emacs Lisp, F#, Fennel, Fish, FORM, Fortran, FunC, GDScript, .gitattributes, .gitignore, Gleam, GLSL, GN, Go module, Go template, GraphQL, Hare, HLSL, Hyprlang, INI, ISPC, Janet, Jinja2, JSDoc, JSON, JSON5, Jsonnet, Julia, Just, Kconfig, KDL, Lean 4, Linker Script, Liquid, LLVM IR, Luau, Magma, Makefile, Markdown, MATLAB, Mermaid, Meson, Mojo, Move, Nickel, Nix, ObjectScript (UDL and routines), Odin, Pascal, Pine Script, Pkl, PL/SQL, PO (gettext), Pony, PowerShell, Prisma, .properties, Protobuf, Puppet, PureScript, QML, Racket, Regex, requirements.txt, ReScript, RON, reStructuredText, Scheme, Slang, Smali, Smithy, Solidity, SOQL, SOSL, Squirrel, SSH config, Starlark, Svelte, Sway, SystemVerilog, TableGen, Tcl, Teal, Templ, Thrift, TLA+, Typst, Verilog, VHDL, Vim script, Vue, WGSL, WIT, Wolfram, XML, Zsh.
## Architecture
@@ -777,41 +1044,47 @@ Also supported (not yet benchmarked): Ada, Agda, Apex, Assembly (NASM), Astro, A
src/
main.c Entry point (MCP stdio server + CLI + install/update/config)
daemon/ Per-account session coordination, IPC, lifecycle, shared jobs/watchers
mcp/ MCP server (15 tools, JSON-RPC 2.0, session detection, auto-index)
cli/ Install/uninstall/update/config (43 client surfaces, hooks, instructions)
mcp/ MCP server (17 tools, JSON-RPC 2.0, session detection, auto-index)
cli/ Install/uninstall/update/config (45 client surfaces, hooks, instructions)
store/ SQLite graph storage (nodes, edges, traversal, search, Louvain)
pipeline/ Multi-pass indexing (structure → definitions → calls → HTTP links → config → tests)
cypher/ Cypher query lexer, parser, planner, executor
discover/ File discovery (.gitignore, .cbmignore, symlink handling)
watcher/ Background auto-sync (git polling, adaptive intervals)
traces/ Runtime trace ingestion
ui/ Local HTTP server + verified external 3D-UI asset pack
ui/ Local HTTP server + embedded CBM Lens frontend
foundation/ Platform abstractions (threads, filesystem, logging, memory)
internal/cbm/ Vendored tree-sitter grammars (158 languages) + AST extraction engine
graph-ui/ CodeAtlasWeb: React, Monaco, and Three.js browser frontend
internal/cbm/ Vendored tree-sitter grammars (162 languages) + AST extraction engine
```
## Security
Every release is verified through a multi-layer pipeline before publication:
- **VirusTotal** — all 24 executable candidates (unstripped, debug-stripped and stripped) across the eight release products are scanned before smoke/soak (clean is preferred and only the single-Microsoft `!ml` tolerance documented in [SECURITY.md](SECURITY.md#our-release-policy) may pass; the number of engines that returned a decisive result is recorded as evidence but is VirusTotal fleet availability, not a pass condition). The selected executable is packaged without changing its SHA-256, release notes link the verdict for the exact bytes shipped, and the full per-candidate evidence is published alongside the release as TSVs for anyone auditing the selection. Every distinct object extracted from the shipped containers — `install.sh`, `install.ps1`, `LICENSE`, `THIRD_PARTY_NOTICES.md`, the MCPB `manifest.json` and the unpacked UI assets — is then scanned as well, so the full published surface is covered, not just the executables.
- **SLSA Level 3** — cryptographic build provenance generated by the trusted GitHub Actions build workflow; verify with `gh attestation verify <file> --repo DeusData/codebase-memory-mcp --signer-workflow DeusData/codebase-memory-mcp/.github/workflows/_build.yml`
- **Sigstore cosign** — keyless signatures on all artifacts; bundles included in every release
- **SHA-256 checksums** — `checksums.txt` published with every release; verified by both install scripts before extraction
- **CodeQL SAST** — blocks release pipeline if any open alerts remain
- **No language-runtime dependency chain** — libraries are vendored at compile time; the small release-owned runtime assets are checksum-verified and content-addressed
- **VirusTotal**: all 24 executable candidates (unstripped, debug-stripped and stripped) across the eight release products are scanned before smoke/soak (clean is preferred and only the single-Microsoft `!ml` tolerance documented in [SECURITY.md](SECURITY.md#our-release-policy) may pass; the number of engines that returned a decisive result is recorded as evidence but is VirusTotal fleet availability, not a pass condition). The selected executable is packaged without changing its SHA-256, release notes link the verdict for the exact bytes shipped, and the full per-candidate evidence is published alongside the release as TSVs for anyone auditing the selection. Every distinct object extracted from the shipped containers; `install.sh`, `install.ps1`, `LICENSE`, `THIRD_PARTY_NOTICES.md`, the MCPB `manifest.json` and the unpacked UI assets; is then scanned as well, so the full published surface is covered, not just the executables.
- **SLSA Level 3**: cryptographic build provenance generated by the trusted GitHub Actions build workflow; verify with `gh attestation verify <file> --repo DeusData/codebase-memory-mcp --signer-workflow DeusData/codebase-memory-mcp/.github/workflows/_build.yml`
- **Sigstore cosign**: keyless signatures on all artifacts; bundles included in every release
- **SHA-256 checksums**: `checksums.txt` published with every release; verified by both install scripts before extraction
- **CodeQL SAST**: blocks release pipeline if any open alerts remain
- **No language-runtime dependency chain**: the native backend uses libraries vendored at compile time, and the frontend is embedded by the UI build
### v0.7.0 VirusTotal scans
### VirusTotal release scans
| Binary | SHA-256 | VirusTotal |
|--------|---------|-----------|
| `linux-amd64` | `8e12bb2d6ead7f20a6d3...` | [0/72 ✅](https://www.virustotal.com/gui/file/8e12bb2d6ead7f20a6d3bf2be1e51f978c38acce810f0734f510d134b039d152/detection) |
| `linux-arm64` | `10f7136bfbf3950c6b2a...` | [0/72 ✅](https://www.virustotal.com/gui/file/10f7136bfbf3950c6b2a1a950bbf85e88b97ee55ab00b4dfbc2a5e9c2ede8672/detection) |
| `darwin-arm64` | `7062a7408906344bf4f8...` | [0/72 ✅](https://www.virustotal.com/gui/file/7062a7408906344bf4f835e9580048af85d12dd2b7cec0edf869df93ad9a0592/detection) |
| `darwin-amd64` | `28c6d640e1a0ac7bfcab...` | [0/72 ✅](https://www.virustotal.com/gui/file/28c6d640e1a0ac7bfcab5094c2186eced5264a20dcdffcb4455a1b28c5df2171/detection) |
| `windows-amd64` | `9c3ddcf78368fd4fa891...` | [0/72 ✅](https://www.virustotal.com/gui/file/9c3ddcf78368fd4fa89156a553641bf1e03640b4fb6dd29a12c84aa5bc98cd86/detection) |
The [v0.11.0 release notes](https://github.com/DeusData/codebase-memory-mcp/releases/tag/v0.11.0#security-verification) report clean scans for all eight shipped binaries. Each result is tied to the exact executable hash. The links below show the reports for those binaries; the displayed hashes are shortened for readability.
Scan links for every release are also included in the GitHub Release notes automatically.
| Binary | SHA-256 prefix | Reported verdict |
|--------|----------------|------------------|
| `linux-amd64` | `a831cdcafaed7b1f7c23...` | [clean](https://www.virustotal.com/gui/file/a831cdcafaed7b1f7c23ce996a72a035f2188d7e20e9a18f39ec4b2d4e3302a6/detection) |
| `linux-arm64` | `7812062c19030de15ae2...` | [clean](https://www.virustotal.com/gui/file/7812062c19030de15ae21ec2d30746447bb2c27bc72f3bf21924ba0e2dacd7e0/detection) |
| `linux-amd64-portable` | `ce11c141431aeadd7885...` | [clean](https://www.virustotal.com/gui/file/ce11c141431aeadd788506c3a7e6942db8fd438dec369d0707a39ec9fd8c6510/detection) |
| `linux-arm64-portable` | `403d0fab6204e7129167...` | [clean](https://www.virustotal.com/gui/file/403d0fab6204e712916701936a3229dd472bad05080c757ea5177318a80fdbfe/detection) |
| `darwin-amd64` | `69ca71b4b62fe2336778...` | [clean](https://www.virustotal.com/gui/file/69ca71b4b62fe233677851bdf54953e2a6660cf5d687b4d64226b2591b14ed39/detection) |
| `darwin-arm64` | `a67b7ccead5d2ca85205...` | [clean](https://www.virustotal.com/gui/file/a67b7ccead5d2ca852051f8619458ab96af41393257b56fb36e523a110265d48/detection) |
| `windows-amd64` | `7edcd3807ebcfd85ec19...` | [clean](https://www.virustotal.com/gui/file/7edcd3807ebcfd85ec1968985964080f2589748da2fc3c7ce9261eebab31ff04/detection) |
| `windows-arm64` | `5615aa31e3cdbe6155e7...` | [clean](https://www.virustotal.com/gui/file/5615aa31e3cdbe6155e7096c43d07e62e335fe34d28e489b4cc3ce113e81b4e6/detection) |
The release also publishes [candidate scan results](https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.11.0/virustotal-candidate-results.tsv), [binary provenance](https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.11.0/release-candidates.tsv), and [selection records](https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.11.0/release-selection.tsv). For subsequent versions, use the scan evidence linked from the [latest release](https://github.com/DeusData/codebase-memory-mcp/releases/latest).
## License
+147 -7
View File
@@ -37,7 +37,7 @@ The core runtime headers in `internal/cbm/vendored/common/tree_sitter/`
## Tree-sitter Grammars
159 pre-generated parsers are vendored in `internal/cbm/vendored/grammars/<lang>/`
160 pre-generated parsers are vendored in `internal/cbm/vendored/grammars/<lang>/`
(generated `parser.c` plus `scanner.c` where applicable, compiled statically).
Each grammar is the work of its upstream authors and each grammar directory
contains the upstream `LICENSE` file.
@@ -52,12 +52,49 @@ License summary:
- `clojure` ([sogaiu/tree-sitter-clojure](https://github.com/sogaiu/tree-sitter-clojure)) is **CC0-1.0**;
`fennel` is **CC0-1.0**; `jinja2` and `just` are **Apache-2.0**;
`pine` is **ISC** (declared by its upstream).
- The grammars authored in-house for this project (`cobol`, `form`, `janet`,
`magma`, `protobuf`, `wolfram`) are **MIT** under the project's own license,
(c) DeusData. Six further grammars (`assembly`, `cfml`, `cfscript`,
`dotenv`, `pine`, `qml`) are self-maintained forks that retain their
original upstream authors' licenses — see the manifest for per-grammar
provenance.
- The grammars authored in-house for this project (`chialisp`, `cobol`, `form`,
`janet`, `magma`, `protobuf`, `wolfram`) are **MIT** under the project's own
license, (c) DeusData. Each ships the repository's own LICENSE, byte-identical
to the root copy; they carry no third-party copyright because there is no
third party. `chialisp` is a generic s-expression grammar for the Chia
smart-coin language, written for this project because no usable public
grammar exists; its source and corpus tests live in
`tools/tree-sitter-chialisp/`.
- Ten further grammars (`arkts`, `assembly`, `cfml`,
`cfscript`, `dotenv`, `javascript`, `pascal`, `pine`, `qml`, `tsx`) are
self-maintained forks that retain their original upstream authors' licenses —
see the manifest for per-grammar provenance. `arkts` is a first-party derivative of
[tree-sitter/tree-sitter-typescript](https://github.com/tree-sitter/tree-sitter-typescript)
(MIT, (c) 2017 Max Brunsfeld; on the
[tree-sitter-javascript](https://github.com/tree-sitter/tree-sitter-javascript)
base, MIT, (c) 2014 Max Brunsfeld) with (c) 2026 DeusData ArkTS additions;
its grammar source lives in `tools/tree-sitter-arkts/`. `javascript`
([tree-sitter/tree-sitter-javascript](https://github.com/tree-sitter/tree-sitter-javascript),
MIT, (c) 2014 Max Brunsfeld) and `tsx`
([tree-sitter/tree-sitter-typescript](https://github.com/tree-sitter/tree-sitter-typescript)'s
`tsx` dialect, MIT, (c) 2017 Max Brunsfeld, on the same javascript base) are
the upstream grammars with one (c) 2026 DeusData patch that lets a lone `&`
appear in JSX strings and text; their grammar sources live in
`tools/tree-sitter-javascript/` and `tools/tree-sitter-tsx/`. `pascal`
([Isopod/tree-sitter-pascal](https://github.com/Isopod/tree-sitter-pascal),
MIT, (c) 2018 Benjamin Gray) is the upstream grammar with one (c) 2026
DeusData patch that accepts non-ASCII letters in identifiers; its grammar
source lives in `tools/tree-sitter-pascal/`.
### tree-sitter-plsql
- **Project:** [AndreasMaierDe/tree-sitter-plsql](https://github.com/AndreasMaierDe/tree-sitter-plsql)
- **License:** MIT
- **Copyright:** (c) 2022 AndreasMaierDe
- **Vendored at:** `internal/cbm/vendored/grammars/plsql/`
- **Pinned commit:** `28aebef209be`
- **Notes:** Community-maintained grammar for Oracle PL/SQL; not in the
nvim-treesitter or Helix registries (`community-niche` in the manifest). No
external scanner. One local patch: `parser.c`'s
`#include <tree_sitter/parser.h>` is changed to the quoted form used by every
other vendored grammar, as documented in
`internal/cbm/vendored/grammars/MANIFEST.md`. PL/SQL support was originally
contributed in PR #1033 by Oğuz (@ouzsrcm).
### tree-sitter-objectscript (UDL + routine)
@@ -105,6 +142,109 @@ Semantic vector search uses static token embeddings derived from the
See `vendored/nomic/NOTICE` for the exact derivation procedure
(per-token inference + int8 quantization via `scripts/extract_nomic_vectors.py`).
## Embedded Character Data (PDF text layer)
The PDF text-layer extractor (`internal/cbm/pdf/`) carries static tables
derived from the following sources. No upstream source code is included.
- **Adobe Glyph List** — the glyph-name subset in `internal/cbm/pdf/pdf_tables.c`
(the names of the standard Latin encodings and common symbols, mapped to
Unicode) follows [adobe-type-tools/agl-aglfn](https://github.com/adobe-type-tools/agl-aglfn)
`glyphlist.txt`.
- **License:** BSD-3-Clause
- **Copyright:** Copyright 2002-2019 Adobe (http://www.adobe.com/).
- **Adobe core-14 font metrics** — the advance widths of the printable ASCII
range of Helvetica and Times-Roman in `pdf_tables.c` (used only for a
standard-14 font that omits its `/Widths`) are the values of Adobe's
core-14 AFM files. The AFM files themselves are not distributed.
- **Copyright:** Copyright (c) 1985, 1987, 1989, 1990, 1997 Adobe Systems
Incorporated. All Rights Reserved.
- **Unicode Character Database** — `internal/cbm/pdf/pdf_unicode.c` (NFKC of
each code point, canonical compositions, character classes) is generated
by `scripts/gen-pdf-unicode.py` from CPython's `unicodedata` (Unicode
16.0.0).
- **License:** Unicode License v3 (text below)
- **Copyright:** Copyright © 1991-2026 Unicode, Inc.
- The base encodings (Standard, WinAnsi, MacRoman, Symbol) are those of
ISO 32000-1, Annex D.
Adobe Glyph List license (BSD-3-Clause):
```
Copyright 2002-2019 Adobe (http://www.adobe.com/).
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
Neither the name of Adobe nor the names of its contributors may be
used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
Unicode License v3:
```
UNICODE LICENSE V3
COPYRIGHT AND PERMISSION NOTICE
Copyright © 1991-2026 Unicode, Inc.
NOTICE TO USER: Carefully read the following legal agreement. BY
DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
Permission is hereby granted, free of charge, to any person obtaining a
copy of data files and any associated documentation (the "Data Files") or
software and any associated documentation (the "Software") to deal in the
Data Files or Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, and/or sell
copies of the Data Files or Software, and to permit persons to whom the
Data Files or Software are furnished to do so, provided that either (a)
this copyright and permission notice appear with all copies of the Data
Files or Software, or (b) this copyright and permission notice appear in
associated Documentation.
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
THIRD PARTY RIGHTS.
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
FILES OR SOFTWARE.
Except as contained in this notice, the name of a copyright holder shall
not be used in advertising or otherwise to promote the sale, use or other
dealings in these Data Files or Software without prior written
authorization of the copyright holder.
```
## Hybrid LSP — Reference Language Servers
The Hybrid LSP layer (`internal/cbm/lsp/`) is an original C implementation
+90 -2
View File
@@ -77,6 +77,7 @@ codebase-memory-mcp config list
codebase-memory-mcp config get auto_index
codebase-memory-mcp config set auto_index true
codebase-memory-mcp config set auto_index_limit 50000
codebase-memory-mcp config set watcher_enabled false
codebase-memory-mcp config reset auto_index
```
@@ -86,6 +87,52 @@ Current keys:
|---|---|---|
| `auto_index` | `false` | Automatically index new projects when an MCP session starts. |
| `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. |
| `auto_watch` | `true` | Register the session's project with the background git watcher on connect. Set `false` to keep a session from registering its project (the watcher still runs for other projects). |
| `doc_candidates` | `auto` | Score documentation sections against code to store doc↔code candidates (the possible targets of a section, and the sections that may describe a function or file, each with a judged probability). Unset (`auto`), the index mode decides: on for `full` indexes (the default mode), off for `moderate` and `fast`. Set `true` or `false` to choose for every mode; `config reset doc_candidates` returns to `auto`. The scoring grows with sections × functions: under a second up to ~100k functions, ~15 s at ~400k, ~3 minutes on the Linux kernel. Off stores no candidates and leaves the rest of the graph unchanged; on in `fast` mode stores candidates without adding `fast`'s skipped `SIMILAR_TO` / `SEMANTICALLY_RELATED` edges. `index_repository` also takes `doc_candidates` for a single index, which wins over this setting. Applies from the next index. |
| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Only an open MCP session's own project is ever watched; `index_status` reports the current state in its `watch` object. |
| `watch_non_git` | `false` | Also poll project roots that are **not git repositories**. By default the watcher only follows git projects, so a project indexed from a plain directory is never refreshed after its first index — reindex it manually with `index_repository`. Set `true` to poll such roots on the same adaptive cadence with a file-tree scan: the indexer's own discovery walk (same skip lists, `.gitignore` and `.cbmignore` rules) hashed over each file's path, size and mtime. Any change reindexes once; paths the indexer skips (including cbm's own `.codebase-memory/` output) never trigger. The first poll after the daemon starts reindexes each such project once, since nothing records which tree state the index holds. The scan walks the whole tree every poll, so it costs more than git polling on very large trees. Read once when the daemon starts, like `watcher_enabled`. |
| `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. |
| `index_max_source_mb` | `off` | Optional maximum accepted source size in MiB in one discovery run. |
For a watched project, `index_status` names the polling method in `watch.strategy`:
`git` (HEAD and dirty-state polling), `tree` (a non-git root polled by the
`watch_non_git` file-tree scan), `none` (a non-git root that is registered but never
polled, the default for a plain directory) or `pending` (registered; the first poll
has not run yet).
> **`watcher_enabled` vs `auto_watch`.** `watcher_enabled` controls whether the
> watcher *subsystem* starts at all (the background poll thread). `auto_watch` is
> narrower: it only controls whether a connecting session registers *its own*
> project with an already-running watcher. When `watcher_enabled=false`,
> `auto_watch` has no effect — there is no watcher to register with.
>
> They also differ in **when they are read**, which matters because the watcher
> lives in the background daemon, not in your MCP client:
>
> - `auto_watch` is consulted each time a session would register its project, so
> a change applies to sessions that connect afterwards.
> - `watcher_enabled` is read **once, when the daemon starts**, because it decides
> whether the watcher is built at all. The daemon is long-lived and outlives
> individual MCP sessions, so **reconnecting your client is not enough** — retire
> the daemon so the next one picks the new value up:
>
> ```bash
> codebase-memory-mcp config set watcher_enabled false
> codebase-memory-mcp daemon stop # next session starts a daemon without the watcher
> codebase-memory-mcp daemon status # confirm
> ```
>
> Disabling the watcher does not disable anything else: the daemon still starts,
> `auto_index` still runs, and `index_repository` stays available for manual
> reindexing.
The two `index_max_*` settings are independent and disabled by default. They
apply to explicit indexing, automatic indexing, and watcher re-indexing, but not
to `cross-repo-intelligence`, which does not scan repository source files.
Equality is allowed; exceeding either setting fails the complete index request
and preserves any previously serving database. See
[Index resource limits](INDEX_RESOURCE_LIMITS.md) for counting, validation, and
error-response details.
## 3. UI Settings
@@ -120,7 +167,7 @@ These environment variables affect runtime behavior:
| `CBM_CACHE_DIR` | `~/.cache/codebase-memory-mcp` | Override the cache directory used for indexes, `_config.db`, and UI `config.json`. |
| `CBM_DIAGNOSTICS` | `false` | Enable periodic `snapshot.json` and retained `trajectory.ndjson` below a fresh owner-private directory in the system temp directory. The daemon records the randomized paths in the `diagnostics.start` discovery record (a single JSON line) in `${CBM_CACHE_DIR}/logs/cbm-daemon.log`; that one record is emitted even when `CBM_LOG_LEVEL` suppresses ordinary logging, so the paths always remain discoverable. |
| `CBM_DOWNLOAD_URL` | GitHub releases | Override the update download URL. |
| `CBM_LOG_LEVEL` | `info` | Set the log level to `debug`, `info`, `warn`, `error`, or `none` (or `0`-`4`). Thin-frontend messages use that session's stderr; detached daemon events use `${CBM_CACHE_DIR}/logs/cbm-daemon.log`. |
| `CBM_LOG_LEVEL` | role-aware | Set the log level to `debug`, `info`, `warn`, `error`, or `none` (or `0`-`4`). Thin MCP/CLI/hook frontends default to `warn`; the detached daemon and supervised index workers default to `info`. Physical workers retain INFO liveness records because their private logs drive the supervisor's no-progress timeout. Frontend messages use that session's stderr; detached daemon events use `${CBM_CACHE_DIR}/logs/cbm-daemon.log`. |
| `CBM_RUNTIME_DIR` | `%LOCALAPPDATA%` (Windows), `/private/tmp` (macOS), `/tmp` (other) | Parent directory for the daemon/CLI rendezvous directory, which CBM creates inside it as `cbm-daemon-<uid>` (`cbm-daemon-<key>` on Windows). Set it when the default ancestry cannot pass the private-directory check — see below. `CBM_CACHE_DIR` does **not** move the rendezvous. |
| `CBM_WORKERS` | auto-detected | Override the indexing worker count. |
@@ -161,7 +208,21 @@ process that should share one daemon must see the same value — set it in the
environment of your MCP client and your shell alike, or a CLI invocation without
it will coordinate through the default location instead.
Environment used by daemon-owned components—such as diagnostics, daemon logging, and process-wide indexing resource limits—is captured from the first daemon-backed session that starts the daemon. Later sessions join the existing process and cannot replace those values. To change them, close every daemon-backed session, update the relevant agent configurations consistently, and restart a session. `CBM_ALLOWED_ROOT` remains session-specific, a conflicting `CBM_CACHE_DIR` is rejected, and one-shot CLI commands use their own current environment without starting the daemon.
**WSL2 and Windows drives (`/mnt/c`, `/mnt/e`, ...).** `CBM_CACHE_DIR` goes through
the same private-directory check. With WSL's default automount options, DrvFs
reports every directory as `0777` and ignores `chmod`, so a cache under
`/mnt/<drive>` is refused, and the refusal names this remedy. Either turn on
permission metadata in `/etc/wsl.conf`, then run `wsl --shutdown` and reopen WSL:
```ini
[automount]
options = "metadata,umask=22,fmask=11"
```
or keep the cache on the Linux filesystem (the default `~/.cache/codebase-memory-mcp`),
which is also much faster than a 9p-mounted Windows drive.
Environment used by daemon-owned components—such as diagnostics, daemon logging, and process-wide indexing resource limits—is captured from the first daemon-backed session that starts the daemon. Later sessions join the existing process and cannot replace those values. To change them, close every daemon-backed session, update the relevant agent configurations consistently, and restart a session. `CBM_ALLOWED_ROOT` remains session-specific, a conflicting `CBM_CACHE_DIR` is rejected, and a one-shot CLI command is not exempt from the rule above: it connects to the coordination daemon like any other session, starting one if none is running, so its own environment becomes the captured daemon-owned environment only when its invocation is the one that starts the daemon — joining an already-running daemon, it inherits that daemon's already-captured values instead.
### Roots that are always refused
@@ -182,6 +243,33 @@ be indexed and later returned. And the credential list is a denylist, so it
raises the cost of a mistake rather than closing the class — a directory it does
not name is permitted.
### Shared / HPC filesystems
On clusters and other shared machines, home directories often live on a network
filesystem (NFS, Lustre, GPFS, SMB/CIFS), and `~/.cache` is sometimes a symlink
into a shared tree. Three rules keep CBM working there:
- **Put the cache and the rendezvous on local storage.** Set `CBM_CACHE_DIR` (indexes,
`_config.db`) and, if needed, `CBM_RUNTIME_DIR` (the daemon rendezvous) to a
local disk you own, for example `/local/scratch/$USER/cbm` or `/tmp/$USER-cbm`.
Set the same values in your MCP client's environment and your shell.
- **Do not route the cache into another account's tree.** A symlinked `~/.cache` is
resolved and allowed, but every directory on the *resolved* path must be owned
by you or by root. If a parent such as `/shared/fs` belongs to another account,
that account can rename or replace anything below it, so CBM refuses the path
and names the directory and its owner:
```text
... (cache-private) - '/shared/fs' is owned by uid 1234 (alice), not by you (uid 1000) or root; ...
```
This check has no override. Choose a location with a safe ancestry instead.
- **Avoid network filesystems for the cache.** The indexes are SQLite databases in
WAL mode, which needs shared-memory locking that network filesystems do not
provide reliably. On Linux, CBM logs one `daemon.cache_root_network_fs` warning
at startup when the cache root is on NFS, Lustre, GPFS, CIFS or SMB2. The cache
still works there, but it can be slow, and a database can be corrupted.
## 5. Agent and Editor Integration Files
The `install` command can also write MCP entries and instruction blocks into agent/editor config files such as Claude Code, Codex, Gemini, VS Code, Cursor, Zed, and others.
+5 -4
View File
@@ -311,15 +311,16 @@ SHA, and — **not just totals** — a **per-type breakdown**:
- `node-types.json` — a histogram of **node count by label** (`Function`, `Method`, `Class`,
`Interface`, `Type`/`Enum`/`Struct`, `Field`, `Variable`, `Route`, `Module`, `Section`, `Macro`,
`File`, `Folder`) + total.
- `edge-types.json` — a histogram of **edge count by type, with one entry for every one of the 32
- `edge-types.json` — a histogram of **edge count by type, with one entry for every one of the 33
edge types, including those that came back `0`**. The canonical set is the indexer's own
`ALL_EDGE_TYPES[]` (26 intra-repo types — `tests/test_lang_contract.c`): `CALLS`, `ASYNC_CALLS`,
`ALL_EDGE_TYPES[]` (27 intra-repo types — `tests/test_lang_contract.c`): `CALLS`, `ASYNC_CALLS`,
`HTTP_CALLS`, `GRPC_CALLS`, `GRAPHQL_CALLS`, `TRPC_CALLS`, `DEFINES`, `DEFINES_METHOD`, `IMPLEMENTS`,
`INHERITS`, `OVERRIDE`, `DECORATES`, `IMPORTS`, `HANDLES`, `CONFIGURES`, `DEPENDS_ON`, `USAGE`,
`DATA_FLOWS`, `SEMANTICALLY_RELATED`, `SIMILAR_TO`, `TESTS`, `TESTS_FILE`, `INFRA_MAPS`,
`FILE_CHANGES_WITH`, `CONTAINS_FILE`, `CONTAINS_FOLDER` — **plus the 6 cross-repo types** from the
`FILE_CHANGES_WITH`, `CONTAINS_FILE`, `CONTAINS_FOLDER`, `MENTIONS` (doc comment -> referenced
code) — **plus the 6 cross-repo types** from the
cross-repo pass (`CROSS_HTTP_CALLS`, `CROSS_ASYNC_CALLS`, `CROSS_GRPC_CALLS`, `CROSS_GRAPHQL_CALLS`,
`CROSS_TRPC_CALLS`, `CROSS_CHANNEL`) and a total. The writer **emits the full 32-type list and
`CROSS_TRPC_CALLS`, `CROSS_CHANNEL`) and a total. The writer **emits the full 33-type list and
back-fills missing types with `0`** rather than recording only the types that appeared.
These come straight from `query_graph` (`MATCH (n) RETURN labels(n), count(*)` and
+68
View File
@@ -0,0 +1,68 @@
# Index resource limits
Index resource limits are optional operator controls for repositories whose
discovery breadth is not known in advance. They are disabled by default so
existing large-repository workloads retain their current behavior.
## Discovery settings
| Key | Default | Accepted value | Protects |
|---|---:|---:|---|
| `index_max_files` | `off` | `off` or `1..10000000` | Accepted source-file count |
| `index_max_source_mb` | `off` | `off` or `1..1048576` | Accepted source-file bytes |
Set or reset them with the normal configuration command:
```bash
codebase-memory-mcp config set index_max_files 250000
codebase-memory-mcp config set index_max_source_mb 16384
codebase-memory-mcp config reset index_max_files
```
Values use base-10 integers. MiB means 1,048,576 bytes. Empty values, zero,
negative values, suffixes, trailing characters, and values outside the stated
ranges are rejected without changing the stored value.
## Counting and failure semantics
`index_max_files` counts a file only after it passes directory pruning, ignore
rules, filename and suffix filters, language detection, and the existing
per-file size rule. `index_max_source_mb` sums the filesystem sizes of that same
accepted set.
Equality is allowed. The first file or byte that makes an observed value greater
than its limit stops discovery. CBM discards the partial file list and does not
publish a partial graph as a complete index.
For an explicit MCP request the error payload contains:
```json
{
"status": "error",
"code": "resource_limit_exceeded",
"stage": "discovery",
"resource": "files",
"observed": 250001,
"limit": 250000,
"unit": "files",
"retryable": true,
"serving_index_preserved": true,
"message": "Index discovery exceeded index_max_files"
}
```
The previous database remains available because publication occurs only after a
complete discovery and successful staged build. If no previous database exists,
`serving_index_preserved` is false.
## Trust and compatibility
Limits are read from the CLI-managed `_config.db`; they are not MCP request
arguments. A supervised parent replaces any caller-supplied internal policy
before spawning its worker, and the worker rejects a missing or incomplete
parent policy.
These settings do not replace or increase `auto_index_limit`, change the 512 MiB
single-file cap, alter workspace-root authorization, or affect
`cross-repo-intelligence`. With both settings `off`, discovery follows the
existing unbounded path.
+296
View File
@@ -0,0 +1,296 @@
# Measuring quality, latency, and agent savings
This guide describes how to measure codebase-memory-mcp (CBM) on your own
repository without mixing three different questions:
1. Does the agent give a better answer?
2. Is CBM fast and stable under the workload?
3. Does graph-assisted exploration use fewer model tokens or tool calls than
file-by-file exploration?
Measure and report those classes separately. A fast graph query does not prove
that the final answer is correct, and CBM's query counters do not reveal the
agent's model-token or total tool-call consumption. The README's published
[performance figures](../README.md#performance), the
[language benchmark](BENCHMARK.md) and the more detailed
[evaluation plan](EVALUATION_PLAN.md) provide the grading concepts and the
larger comparative methodology behind this smaller recipe.
## Freeze the experiment
The shell examples in this guide require a POSIX-compatible shell on macOS or
Linux, or Git Bash on Windows. They are not native PowerShell syntax.
Run the command blocks in one dedicated shell: the setup and preflight blocks
enable fail-fast handling, and they capture Git command output before testing it
so a failed inner command cannot be mistaken for a clean worktree.
Before either condition runs, record:
- the repository URL or local identity and exact Git commit SHA;
- the question set and expected scope of each answer;
- the CBM version, index mode, operating system, and machine details;
- the agent model/version, system prompt, tool instructions, context limits,
and per-question budget;
- which condition runs first, plus any warm-up policy;
- the client or evaluation harness used to record tokens and tool calls.
Use the same repository SHA, questions, model, prompt, budget, and clean-session
policy for both conditions. Do not let the second condition see the first
condition's answers or tool results. If runs are repeated, choose the repeat
count and aggregation method before looking at results and retain every run,
including failures and zero-result queries.
Do not run either condition in a merely clean-looking working copy. A frozen
experiment includes untracked files, so create separate clean detached
worktrees for the two conditions:
```bash
set -eu
SOURCE_REPO=/absolute/path/to/source-repository
COMMITISH=main
SHA=$(git -C "$SOURCE_REPO" rev-parse "$COMMITISH^{commit}") || exit 1
MODE=full
RUN_ROOT=$(mktemp -d) || exit 1
GRAPH_REPO="$RUN_ROOT/graph"
BASELINE_REPO="$RUN_ROOT/baseline"
ARTIFACT_ROOT="$RUN_ROOT/artifacts"
git -C "$SOURCE_REPO" worktree add --detach "$GRAPH_REPO" "$SHA" || exit 1
git -C "$SOURCE_REPO" worktree add --detach "$BASELINE_REPO" "$SHA" || exit 1
mkdir "$ARTIFACT_ROOT" || exit 1
GRAPH_SHA=$(git -C "$GRAPH_REPO" rev-parse HEAD) || exit 1
GRAPH_STATUS=$(git -C "$GRAPH_REPO" status --porcelain=v1 --untracked-files=all) || exit 1
BASELINE_SHA=$(git -C "$BASELINE_REPO" rev-parse HEAD) || exit 1
BASELINE_STATUS=$(git -C "$BASELINE_REPO" status --porcelain=v1 --untracked-files=all) || exit 1
test "$GRAPH_SHA" = "$SHA" || exit 1
test -z "$GRAPH_STATUS" || exit 1
test "$BASELINE_SHA" = "$SHA" || exit 1
test -z "$BASELINE_STATUS" || exit 1
```
Repeat the matching SHA and cleanliness assertions immediately before indexing
and immediately before each condition. If either assertion fails, stop and
create a new detached worktree; do not erase unknown files to make a reused
checkout appear clean. Write run artifacts under `ARTIFACT_ROOT`, outside both
condition worktrees.
## Preflight the graph
Immediately before every Graph-condition measurement, repeat its clean-worktree
assertions and require a successful fresh index of that exact worktree and mode:
```bash
set -eu
GRAPH_SHA=$(git -C "$GRAPH_REPO" rev-parse HEAD) || exit 1
GRAPH_STATUS=$(git -C "$GRAPH_REPO" status --porcelain=v1 --untracked-files=all) || exit 1
test "$GRAPH_SHA" = "$SHA" || exit 1
test -z "$GRAPH_STATUS" || exit 1
codebase-memory-mcp cli index_repository \
--repo-path "$GRAPH_REPO" \
--mode "$MODE" || exit 1
GRAPH_SHA_AFTER=$(git -C "$GRAPH_REPO" rev-parse HEAD) || exit 1
GRAPH_STATUS_AFTER=$(git -C "$GRAPH_REPO" status --porcelain=v1 --untracked-files=all) || exit 1
test "$GRAPH_SHA_AFTER" = "$SHA" || exit 1
test -z "$GRAPH_STATUS_AFTER" || exit 1
```
Take `PROJECT_NAME` from the successful indexing response. A verbose status call
is useful only as a root/current-HEAD cross-check:
```bash
codebase-memory-mcp cli list_projects
codebase-memory-mcp cli index_status --project PROJECT_NAME --verbose
```
Confirm that `root_path` is `GRAPH_REPO`, `git.head_sha` is `SHA`, and the Git
context is detached. `index_status` describes the project root and its current
filesystem Git context; it does **not** by itself prove that indexed records
were produced from that revision. The immediately preceding successful fresh
`index_repository` call is the freshness requirement.
Finally, run one or more representative queries whose expected symbols you
have verified directly at the recorded SHA:
```bash
codebase-memory-mcp cli search_graph \
--project PROJECT_NAME \
--name-pattern 'KNOWN_SYMBOL_PATTERN' \
--limit 10
```
Do not start measurement until the fresh index succeeds, the status cross-check
names the intended detached checkout and current SHA, and the representative
queries return the expected symbols. Save the indexing response, status, and
query outputs with the run artifacts. Immediately before the file-by-file
condition, repeat the corresponding SHA and cleanliness assertions for
`BASELINE_REPO`.
## 1. Measure answer quality
Create a fixed set of real developer questions. Include a mix of definition
discovery, relationships or call paths, targeted source retrieval,
architecture, and cross-cutting questions where those dimensions apply. Record
the expected scope or an independently derived ground truth for each question.
Run two isolated conditions:
| Condition | Allowed exploration tools |
|-----------|---------------------------|
| Graph | CBM graph tools such as `search_graph`, `trace_path`, `query_graph`, `get_code_snippet`, `get_architecture`, and `search_code` |
| File-by-file baseline | File listing, text search, and targeted file reads only |
Grade the final answers against the source at the frozen SHA, not against how
plausible they sound. The compact rubric in [BENCHMARK.md](BENCHMARK.md) uses
PASS (1.0), PARTIAL (0.5), and FAIL (0.0), excluding truly inapplicable
questions from the denominator. For a finer comparison, follow
[EVALUATION_PLAN.md](EVALUATION_PLAN.md): score correctness, completeness, and
specificity separately, blind the grader to which condition produced each
answer, randomize A/B order, and require source evidence for the grade.
Keep quality scores beside, but separate from, efficiency metrics. A token
reduction is only useful when the answer still meets the chosen quality bar.
## 2. Measure latency and stability
Record indexing time separately from query latency, and classify the index run
as full-source, artifact-assisted, or incremental. When no local project
database exists, `index_repository` can import a compatible
`.codebase-memory/graph.db.zst` and then take the incremental-manifest route.
Before timing, record whether that artifact exists in `GRAPH_REPO`, retain the
index log, and require the later successful `artifact.import` record containing
`db` and `size_mb` before classifying the run as artifact-assisted. A bootstrap
attempt alone, or an `artifact.import` record containing `skip` or `err`, does
not prove that the artifact was used. For queries, use the same fixed workload
in the same order, identify warm-up calls in advance, and retain per-call
durations and exit status rather than only a single average. Report the machine,
OS, CBM version, repository SHA, index mode, question set, index-run class, and
whether query results are cold or warm.
### Built-in diagnostics
For a daemon-backed run, enable diagnostics before the first session starts.
The daemon captures its environment at startup; if it is already running,
close all daemon-backed sessions before changing the setting. See
[Configuration](CONFIGURATION.md#4-environment-variables) for the complete
environment contract.
```bash
export CBM_DIAGNOSTICS=1
```
CBM creates a fresh randomized, owner-private diagnostics directory below the
system temporary directory. Do not assume or construct an old predictable
`/tmp` filename. Discover the exact `snapshot` and `trajectory` paths from the
`diagnostics.start` JSON record in
`${CBM_CACHE_DIR}/logs/cbm-daemon.log` (the default cache directory is
`~/.cache/codebase-memory-mcp`). The record is emitted even when the configured
log level suppresses ordinary logging. The README's
[diagnostics section](../README.md#troubleshooting--diagnostics) explains the
files and retention behavior.
The live `snapshot.json` includes CBM-side `query_count`, `query_errors`,
`query_total_us`, `query_avg_us`, and `query_max_us`, along with process resource
counters. The retained `trajectory.ndjson` provides the resource and query-count
trend over time. These are useful for CBM latency and stability analysis, but
they are not a record of an agent's model usage or of non-CBM tools.
Because a daemon can be shared by multiple sessions, use an otherwise idle
daemon and record before/after values (or start a dedicated run) when attributing
its counters to one workload.
### Canonical soak workload
From a source checkout, the canonical endurance entry point is:
```bash
scripts/soak-legs.sh build/c/codebase-memory-mcp 10
```
It runs the quick mixed workload and the read-only query-leak workload, checks
for a valid completion summary, and writes per-call latency plus resource
results. The quick leg writes `soak-results/`; the query-leak leg writes
`soak-results-query-leak/`. Each directory contains `latency.csv`, `metrics.csv`,
and `summary.txt`. The two CSV files begin with these exact headers:
```text
timestamp,tool,duration_ms,exit_code
timestamp,uptime_s,rss_bytes,heap_committed,fd_count,query_count,query_max_us
```
Do not invoke `scripts/soak-test.sh` directly; `soak-legs.sh` owns the
release-gating sequence. Treat these artifacts as stability and CBM latency
evidence, not as answer-quality or model-token evidence.
## 3. Measure token and tool-call savings
Run the Graph and file-by-file conditions on the same frozen inputs described
above. The MCP client or evaluation harness must capture agent usage because
CBM cannot know the final model input/output token count or the agent's total
tool-call consumption.
Define each `run_id` as one paired experimental replicate. Each
`(run_id, condition)` is exactly one isolated client session that answers one
question. Capture one row for every usage window the client directly measures:
```text
run_id,condition,repo_sha,question_id,window,input_tokens,output_tokens,total_tokens,tool_calls,wall_time_ms,answer_artifact,quality_score
```
In each row, `tool_calls` is the count of every client tool invocation inside
that same window, including orchestration calls and the graph or file tools
permitted for that condition, plus retries, errors, and zero-result calls. Do
not count only CBM calls. Candidate windows are:
- **Answering tokens:** input plus output tokens between fixed markers around
the question-answering phase.
- **Full-session tokens:** the entire isolated session, including orientation,
initial probes, dead ends, and answer formatting.
The full-session value best represents an adopter's total cost; the answering
window helps explain where a difference arose. Do not substitute CBM's
`query_count` for either `tool_calls` value: it cannot see file searches, file
reads, or other client tools.
For each Graph/baseline pair, compare only a window that both clients report
directly, with at most one row per `(run_id, condition, window)`. If either
client cannot expose a window, omit that window or record its usage fields as
N/A and exclude it from the comparison. Never infer a missing window, partition
a session total across questions, or duplicate one session total into multiple
question rows.
Select one window `W` and calculate from the Graph and baseline rows for that
same window only:
```text
token reduction (%) = 100 * (baseline tokens - Graph tokens) / baseline tokens
tool-call reduction (%) = 100 * (baseline calls - Graph calls) / baseline calls
token ratio = baseline tokens / Graph tokens
tool-call ratio = baseline calls / Graph calls
```
Calculate and label every common measured window separately; never mix their
numerators or denominators. If baseline tokens or calls are zero, the
corresponding reduction percentage is N/A. If Graph tokens or calls are zero,
the corresponding ratio is N/A. Do not add pseudocounts. Publish the raw paired
counts alongside ratios and percentages. Also publish the quality result for
each pair, the number of runs, aggregation method, failures, and experimental
controls. Never turn a result from one repository, question set, model, or
machine into a universal savings claim.
## Reproducibility checklist
- Repository identity and exact SHA are recorded; each condition uses a clean,
detached worktree with no tracked or untracked changes.
- CBM version, index mode, project name, and preflight outputs are retained.
- Every Graph run follows a successful fresh index of the frozen worktree and
mode; verbose status is only a root/current-HEAD cross-check.
- Questions, ground truth, prompts, budgets, and condition order are frozen.
- Graph and file-by-file runs use isolated sessions and the same controls.
- Quality, CBM latency/stability, and agent savings are reported separately.
- Usage counts come directly from the client or evaluation harness; each
condition run is one isolated question/session, common windows use separate
rows, and unsupported windows are omitted or N/A rather than inferred.
- Diagnostics paths come from `diagnostics.start`, not a guessed temp path.
- Raw outputs, errors, zero results, and calculation inputs are retained.
- Claims identify their repository, SHA, question set, model, machine, and run
count, with no invented or extrapolated benchmark numbers.
+541
View File
@@ -0,0 +1,541 @@
# Runtime Trace Observation Model
## Status and recommendation
This document is an architecture contract, not an implementation. It recommends a
durable, versioned runtime-observation sidecar whose publications can be read alongside
a pinned static index generation. Runtime observations MUST NOT patch static `CALLS`
edges, fabricate static nodes, or appear as persistent edges in the default graph.
The model intentionally separates facts observed at runtime from facts derived from
source. A caller may explicitly request a `RUNTIME_CALL` overlay; existing static
search, architecture, and default trace behavior remain unchanged.
This document-only change does not alter the runtime wire version, runtime schema
version, runtime semantic version, runtime artifact version, static semantic index
version, or artifact schema version. In particular, the static semantic index version
remains 3.
## Scope
The model defines:
- stable identities for spans, producer contributions, observations, endpoints, and
composite publications;
- deterministic ingestion, aggregation, publication, and replay semantics;
- generation-scoped resolution from runtime endpoints to static symbols;
- an opt-in runtime call overlay without changing the stored static graph;
- concurrency, retry, conflict, recovery, privacy, and cardinality boundaries; and
- six bounded implementation packages that can be reviewed and shipped separately.
## Non-goals
This design does not:
- infer new static `CALLS` edges or change confidence on existing ones;
- create placeholder nodes in the static graph for runtime-only identities;
- persist runtime edges in the generic graph edge table;
- make runtime data visible to existing graph tools unless an overlay is requested;
- define distributed tracing transport, sampling policy, or collector deployment;
- promise complete traces, causal proof from timing alone, or source-line attribution;
- store arbitrary span payloads, headers, request bodies, query strings, or secrets;
- average percentile summaries or treat a producer's p99 as a mergeable percentile;
- provide a compatibility promise for a future mutation API before its versioned
contract exists; or
- change any runtime or static version merely by adding this document.
## Current surface
The current `ingest_traces` surface requires `project` and `traces[]`. Each trace item
permits `caller`, `callee`, and `count`, but those item fields are not currently
individually required. Its handler counts the supplied records and returns
`status="accepted"`, `traces_received`, and an unimplemented note; this is not an
accepted durable mutation, and the handler does not open or mutate a store.
The existing trace helper surface can extract `service.name`, HTTP method, HTTP path,
HTTP status, span kind, duration, URL path, and p99. Verified span-kind values are:
- `1`: internal;
- `2`: server; and
- `3`: client.
Generic graph edges are unique by source, target, type, and the local-name
discriminator. Generic edge upsert merges properties with `json_patch`. Those rules are
appropriate for the static graph but are not the contribution or aggregation rules in
this document. The store already exposes `BEGIN IMMEDIATE`, `COMMIT`, and `ROLLBACK`.
Static publication already uses opaque generations and request-scoped reads.
## Normative terms
`MUST`, `MUST NOT`, `SHOULD`, and `MAY` are normative. "Canonical JSON" means UTF-8
JSON with a fixed schema, lexicographically sorted object keys, arrays in their
specified order, no insignificant whitespace, and integers rather than locale- or
platform-dependent numeric strings. Hashes are computed over those canonical bytes.
## Identity layers
The following identities are different and MUST NOT be substituted for one another.
### Trace and span identity
A span is identified within one producer namespace by:
```text
(project, producer_id, producer_epoch, trace_id, span_id)
```
`trace_id` and `span_id` are opaque byte strings rendered in one canonical lowercase
hex form at the boundary. `producer_epoch` is part of span identity so a producer can
reuse trace or span identifiers in a later epoch without collision. The tuple identifies
an input span for diagnostics and deduplication; it does not identify a static node or
an aggregate.
### Producer contribution identity
Every accepted batch has a producer-stable identifier:
```text
contribution_id = H(canonical JSON {
producer_id,
producer_epoch,
source_batch_id
})
```
`producer_id` names the authenticated logical producer, not a transient process.
`producer_epoch` changes only when that producer intentionally starts a new identity
domain. `source_batch_id` is stable across delivery retries. The resulting
`contribution_id` is scoped by project.
An accepted contribution is immutable. Retrying the same `(project, producer_id,
contribution_id)` with the same canonical payload hash is an idempotent success and
returns the original disposition. Retrying it with different canonical bytes is a
conflict and MUST be rejected without mutation. A producer MUST NOT reuse another
producer's namespace, and the service MUST NOT silently mint a replacement ID to turn
a conflict into a second contribution.
The payload hash covers the canonical normalized allowlisted contribution map together
with its pinned wire and runtime semantic version fields. It does not cover original
envelope ordering, unsupported fields, discarded fields, or raw payload bytes.
### Endpoint identity
Every endpoint has an explicit tag and a canonical key.
1. `symbol`: a source-derived symbol reference `(project, qualified_name)`. This stable
endpoint identity deliberately excludes a static generation. A separate resolution
record maps it within one named static generation and resolver version. A later
generation MUST resolve again and MUST NOT inherit a node identifier from an older
generation.
2. `runtime`: a runtime-only endpoint derived from an allowlisted protocol identity,
such as canonical service name plus normalized HTTP route and method. It is stored
only in the sidecar and never creates a static node.
3. `unknown`: an opaque, scoped endpoint used when the allowlisted evidence cannot
identify either a symbol or a runtime service endpoint. Unknown keys MUST be stable
only inside their documented scope (for example, producer plus trace) and MUST NOT
collapse unrelated missing values into one global endpoint.
Endpoint canonicalization is versioned by `runtime_semantic_version`. All strings are
Unicode-normalized, methods and protocol tokens use their specified canonical case,
and absent values are distinct from empty values. Raw URL queries, fragments, and
credentials are excluded.
### Observation identity
An observation describes one directed runtime perspective, not one delivery. Its
canonical key is:
```text
observation_key = H(canonical JSON {
runtime_semantic_version,
project,
protocol,
perspective,
source_endpoint_key,
target_endpoint_key,
operation_key
})
```
The key excludes counters, timestamps, duration summaries, status summaries,
contribution IDs, and static database row IDs. `operation_key` contains only the
versioned allowlisted operation identity, such as canonical HTTP method and normalized
route template. Consequently, identical observations from multiple producers converge
while semantically different directions or operations remain distinct.
### Composite publication identity
A reader-visible snapshot is identified by the atomic pair:
```text
{ static_generation, runtime_generation }
```
The pair is the head identity, but its halves are independent immutable publications.
A runtime generation identifies runtime contributions and aggregates without embedding
a static generation. Generation-scoped symbol resolutions and overlay projections are
keyed by the pair plus resolver version. Neither half may be silently advanced while a
request is using the pair.
## Protocol and perspective mapping
Ingestion parses and normalizes only versioned, allowlisted span fields into the
contribution, then derives a perspective from that evidence. The raw span payload is
not durably preserved. The HTTP mapper uses the existing service-name, HTTP method,
HTTP path or URL path, status, span-kind, and duration helpers. The route field is
normalized as a route template when the source provides one; a raw high-cardinality URL
path MUST NOT be promoted to a template by guesswork.
For the verified span kinds:
- `client` (`3`) represents an outbound attempt. The source is the resolved local
symbol when available, otherwise a tagged local runtime or scoped unknown endpoint.
The target is the allowlisted remote service/HTTP endpoint or a scoped unknown
endpoint. The perspective token is `outbound`.
- `server` (`2`) represents an inbound handling attempt. The source is an allowlisted
peer/runtime endpoint or a scoped unknown endpoint. The target is the resolved local
handler when available, otherwise the local service/HTTP runtime endpoint. The
perspective token is `inbound`.
- `internal` (`1`) represents local work. A directed call projection is produced only
when explicit parent/child evidence supplies two endpoints. Otherwise the
observation remains queryable as internal activity but does not become a
`RUNTIME_CALL`. The perspective token is `internal`.
Unsupported or absent span kinds are retained only as normalized, allowlisted non-call
observation metadata if policy allows; unsupported fields and raw payload bytes are
discarded. They MUST NOT be guessed into one of the three perspectives. Status and
duration contribute to summaries but never to observation identity.
Symbol resolution is best-effort and generation-scoped. Ambiguous matches remain
tagged runtime or unknown endpoints. Resolution MUST fail closed: it must not choose a
symbol by filename substring, short-name coincidence, timing, or insertion order.
## Sidecar model
The durable sidecar is conceptually separate from the static graph store even if an
implementation initially shares the same database file. It contains these logical
relations:
### `runtime_endpoints`
Stores the endpoint tag, canonical endpoint key, and canonical allowlisted identity.
For a `symbol` endpoint, the durable identity contains project plus qualified name but
no static generation. Generation and static database row IDs belong only to resolution
records or caches, not durable endpoint identity.
### `runtime_contributions`
Stores project, producer ID, contribution ID, canonical payload hash, ingestion state,
and immutable canonical contribution metadata. States include at least `staged` and
`finalized`; an aborted transaction leaves neither state visible.
### `runtime_observation_contributions`
Maps `(contribution_id, observation_key)` to that contribution's canonical summary.
The map entry is immutable after finalization. Replacing it requires a new contribution
identity.
### `runtime_aggregates`
Stores the deterministic fold of finalized contribution-map entries for each
observation key and runtime generation. It is derived state and can be rebuilt from
finalized contributions.
### `runtime_resolutions`
Maps a stable runtime or unknown endpoint key to an optional stable SymbolEndpoint
`(project, qualified_name)` plus its current static node/cache result, unresolved
result, or ambiguity result. Each record is keyed by project, runtime generation,
static generation, stable endpoint key, and resolver version. Resolution records are
derived caches, not endpoint or observation identity, and a record from one static
generation MUST NOT be reused for another.
### `runtime_publications`
Stores immutable runtime generations independently of static generations. A separate
atomic per-project composite head pairs one current runtime generation with one current
static generation. Publication metadata includes the relevant runtime versions; the
composite head records the static index/artifact versions used for compatibility
checks.
No table above is a static graph node or edge table. The implementation MUST NOT reuse
generic `json_patch` edge upsert for contribution merging.
## Deterministic aggregation
Before immutable summaries are stored, one contribution is normalized deterministically:
1. canonicalize only allowlisted fields and compute the exact span identity
`(project, producer_id, producer_epoch, trace_id, span_id)`;
2. deduplicate records with the same span identity and identical normalized bytes;
3. reject the entire contribution if one span identity has different normalized bytes;
4. group the remaining unique normalized records by `observation_key`;
5. after keyed exact-span deduplication/set union supplies idempotence, fold integer
counts and duration sums, fixed status buckets, and fixed-schema histogram bins over
the unique normalized span map using associative and commutative exact addition; and
6. sort keys and canonical-serialize the contribution map before hashing or storage.
The normalized contribution map and its immutable summaries MUST be byte-identical for
every input ordering and worker partitioning of the same unique normalized span set.
Discarded or unsupported fields cannot change the payload hash.
For each observation, aggregation is a map union keyed by contribution identity:
```text
aggregate(observation) = fold(sorted union {
contribution_id -> canonical contribution summary
})
```
Union of identical key/value entries is idempotent. A differing value under the same
key is a conflict, not a winner selection. Map union is associative and commutative;
sorting by contribution ID before serialization makes the resulting bytes independent
of producer, retry, transaction, or worker order.
Canonical contribution summaries MAY contain integer count, integer duration sum in a
fixed unit, fixed-schema status buckets, and a mergeable duration distribution whose
encoding is pinned by `runtime_semantic_version`. Aggregate totals are exact integer
sums over the contribution map. Derived display values are computed after the fold.
Percentiles are not additive. A batch p99 MUST NOT be averaged, weighted-averaged, or
selected as the aggregate p99. The existing p99 helper may summarize a single raw batch
for diagnostics, but a published cross-contribution p99 requires a versioned mergeable
distribution (for example fixed histogram boundaries). Without such a distribution,
the aggregate p99 is unavailable.
Canonical aggregate JSON MUST be byte-identical for every permutation and
parenthesization of the same finalized contribution set.
## Ingestion and publication transaction
Full wire parsing, canonicalization, payload hashing, and every validation that does
not depend on mutable project state happen before acquiring the publication lock.
Publication is then serialized by one shared per-project publication lock used by both
static and runtime publishers. An implementation MUST NOT introduce an independent
runtime lock that permits lost updates to either half of the composite head.
Within that lock, a runtime publisher:
1. begins a write transaction with `BEGIN IMMEDIATE`;
2. reads and pins the current composite head and validates compatibility versions;
3. performs state-dependent authorization, quota, producer identity, deduplication,
contribution conflict, and expected-head checks;
4. stages all endpoints, immutable contribution-map entries, and rebuilt aggregates;
5. allocates an opaque runtime generation independent of the pinned static generation;
6. durably finalizes every contribution in the publication;
7. writes the immutable runtime publication and atomically changes only the runtime
half of the composite head, preserving its pinned static generation;
8. commits all changes with `COMMIT`; and
9. releases the per-project publication lock.
Every validation or write failure before commit uses `ROLLBACK`. There is no visible
partial contribution, aggregate, generation, or head. Finalization and head movement
are in the same transaction, so a success response is sent only after durable commit.
Static publication follows the same lock discipline and atomically changes only the
static half of the composite head, preserving its pinned runtime generation. It does
not create a new runtime generation merely because source was reindexed. Resolution
records for the new `{static_generation, runtime_generation}` pair are recomputed or
materialized under their pair-and-resolver-version key; records from the old static
generation are never treated as resolutions for the new one.
## Reader contract
A request that opts into runtime data pins one composite head at request start. Every
page, endpoint resolution, aggregate, and overlay edge in that request is read from
that pair. Default requests pin only their existing static request scope and do not pay
for or expose the overlay.
Pagination cursors encode the project, composite head, query shape, overlay mode, and
runtime semantic version. If the current head differs, a subsequent request with that
cursor returns a stale-cursor error; it never resumes against mixed generations.
Readers MAY explicitly request a still-retained historical composite head. Missing or
garbage-collected heads fail explicitly.
## `RUNTIME_CALL` overlay
Runtime call projection is an explicit read option. It presents directed overlay edges
derived from one pinned runtime generation. These edges:
- use the distinct type `RUNTIME_CALL`;
- carry endpoint tags so callers can distinguish symbol, runtime, and unknown ends;
- include deterministic aggregate summaries and the composite head identity;
- never mutate, replace, increase confidence on, or suppress a static `CALLS` edge;
- are omitted from existing search, architecture, and default trace responses unless
the caller requests the overlay; and
- are regenerated when either half of the composite publication changes.
If a projected runtime call has symbol endpoints, it references their qualified-name
identity within the pinned static generation. Runtime-only and unknown endpoints remain
sidecar objects and are not fabricated as graph nodes.
## Version boundaries
Compatibility is divided deliberately:
- `wire_version`: request and response envelope, field encodings, and retry result;
- `runtime_schema_version`: sidecar tables, indexes, and migration compatibility;
- `runtime_semantic_version`: canonical endpoint/observation keys, mappings, and
aggregation meaning;
- `runtime_artifact_version`: exported/imported sidecar artifact layout;
- `static_semantic_index_version`: source-derived graph semantics; and
- `static_artifact_schema_version`: static artifact representation.
A change bumps only the boundary it changes. A runtime semantic change can invalidate
runtime generations without changing static semantic index version. A storage-only
migration can change runtime schema version without changing observation identity.
Import rejects incompatible artifact versions before mutation. This document changes
none of these versions.
## Security, privacy, and cardinality
Ingestion is project-authorized and producer-authenticated. Producer IDs, contribution
IDs, and project scope are server-validated; caller-provided project or producer fields
do not grant access. Reads apply the same project authorization to runtime and static
data.
Only allowlisted attributes enter canonical identities or summaries. Credentials,
authorization data, cookies, request/response bodies, query strings, fragments,
arbitrary baggage, and raw exception text are rejected or discarded before durable
storage. Logs and errors use opaque IDs and counts, not rejected payloads.
The service enforces bounded request bytes, records, endpoints, observations, attribute
lengths, route-template segments, status buckets, and contribution-map growth. Raw URL
paths are not accepted as unbounded route labels. Unknown endpoints are scoped to avoid
both accidental global collapse and attacker-controlled global cardinality. Quotas and
retention apply per project and producer. Limit failure is all-or-nothing and does not
publish a truncated contribution unless a future wire version explicitly defines that
behavior.
## Failure and recovery matrix
| Condition | Required disposition | Visible state |
| --- | --- | --- |
| Same contribution ID, same payload hash | Idempotent success; return prior result | Unchanged |
| Same contribution ID, different payload hash | Conflict; reject entire request | Unchanged |
| Unauthorized project or producer | Reject before staging | Unchanged |
| Invalid/unsupported wire or semantic version | Reject before staging | Unchanged |
| Invalid endpoint, attribute, or cardinality limit | Reject entire contribution | Unchanged |
| Ambiguous symbol resolution | Keep runtime/unknown tag; do not guess | Valid sidecar observation only |
| Client-supplied expected composite head differs from the head read under lock | Reject as stale; retry from the newly read head | Unchanged |
| Failure while staging or aggregating | `ROLLBACK` | No partial state |
| Crash before commit | Database recovery rolls back the transaction | Prior head remains |
| Commit succeeds but response is lost | Producer retry resolves idempotently | One finalized contribution |
| Derived aggregate missing or corrupt | Rebuild from finalized contribution map before publish | Prior valid head remains |
| Cursor references a different or removed head | Return stale/missing cursor error | No mixed-generation read |
| Unsupported span kind | Retain only normalized, allowlisted non-call observation metadata; discard unsupported fields and raw payload bytes | No guessed call overlay |
| No mergeable duration distribution | Publish counts/sums; report p99 unavailable | No averaged p99 |
## Alternatives considered
### Patch static `CALLS` edges
Benefit: existing graph queries would see runtime evidence with no overlay option.
Trade-offs: it conflates observed and source-derived semantics, lets sampled or hostile
telemetry alter the static graph, cannot represent runtime-only endpoints honestly,
couples runtime retention to static indexing, and makes rollback and provenance
ambiguous. It is rejected.
### Fabricate static nodes for runtime services and unknown peers
Benefit: all endpoints would fit the existing graph node/edge shape.
Trade-offs: fabricated nodes look source-derived, create unstable identities and high
cardinality, collide with future symbol resolution, and pollute architecture/search
results. It is rejected.
### Store persistent runtime edges in the default graph
Benefit: generic edge uniqueness and `json_patch` upsert are already available.
Trade-offs: generic uniqueness has no immutable producer-contribution dimension;
`json_patch` is neither the required commutative map union nor a conflict detector;
default readers would unexpectedly mix generations and data classes. It is rejected.
### Keep runtime observations entirely ephemeral
Benefit: minimal schema, retention, and migration burden; no durable sensitive data.
Trade-offs: retries cannot be made idempotent across restarts, results are not
reproducible or pageable, multi-producer aggregation is unstable, and a reader cannot
pin a coherent composite publication. It is rejected as the primary model, though
ephemeral pre-validation remains useful.
### Durable versioned sidecar with an opt-in overlay (selected)
Benefits: preserves static semantics, gives runtime-only endpoints an honest identity,
supports deterministic replay and aggregation, isolates retention and authorization,
and provides atomic cross-generation reads.
Trade-offs: adds schema and version boundaries, a shared publication lock, endpoint
resolution, aggregate rebuilds, overlay-aware APIs, and explicit operational limits.
These costs are accepted because they make provenance, concurrency, failure recovery,
and compatibility testable rather than implicit.
## Bounded implementation packages
Implementation remains deferred. The following packages are intentionally ordered and
bounded; each requires its own reproduce-first tests and review.
1. **Versioned wire validation and canonicalization.** Define authenticated producer
identity and epoch, contribution ID, exact span-identity deduplication, conflicting
duplicate rejection, canonical contribution maps and payload hashes, allowlisted
HTTP/span mapping, limits, and dry-run validation. No store mutation.
2. **Sidecar schema and immutable contributions.** Add runtime schema migrations,
endpoints, contribution records, observation-contribution maps, idempotent retry,
conflict rejection, and transactional rollback. No publication head or overlay.
3. **Deterministic aggregation.** Add canonical intra-contribution grouping, keyed
exact-span idempotent set union/deduplication, associative and commutative exact
integer/status/histogram addition, cross-contribution map-union folds, fixed
mergeable duration distributions, input/worker/merge-order permutation tests,
rebuild verification, and explicit unavailable p99 behavior.
4. **Generation-scoped resolution and publication.** Add the shared per-project lock,
independent runtime generation allocation, durable finalization, pair-and-version
resolution records, atomic composite head updates that preserve the other half, and
crash/retry recovery tests.
5. **Pinned read and `RUNTIME_CALL` overlay.** Add opt-in queries, tagged endpoints,
request-scoped composite pins, stale cursors, historical-head handling, and proofs
that default static tools remain byte-for-byte unchanged.
6. **Artifact, retention, and operational hardening.** Add versioned runtime artifact
import/export, quota/retention policy, authorization audits, corruption rebuild,
migration/rollback tests, metrics, and multi-producer concurrency tests.
No package may silently absorb a later package's public surface or persistence model.
## Acceptance invariants
An implementation of this contract is acceptable only if all of these hold:
1. Static nodes and `CALLS` edges are byte-identical with runtime ingestion enabled or
disabled for the same source input.
2. Existing search, architecture, and default trace responses exclude runtime data.
3. Runtime data appears only through an explicit, versioned overlay request.
4. Same producer contribution plus same payload is idempotent across retries and
restarts; a different payload under that identity is a no-mutation conflict.
5. Normalized contribution and aggregate bytes are identical for every input, delivery,
worker, and merge order of the same unique normalized span set, including identical
duplicate replay; a conflicting duplicate span identity rejects the whole
contribution without mutation.
6. Aggregate p99 is derived only from a pinned mergeable distribution and is otherwise
unavailable; producer p99 values are never averaged.
7. Symbol resolution is qualified-name based, generation-scoped, and fail-closed on
ambiguity; runtime and unknown endpoints never fabricate static nodes.
8. Runtime generation state contains all of its finalized contributions, endpoints,
and aggregates, and reader visibility requires the separate atomic composite-head
update; the transaction exposes both the complete generation and head update, or
neither.
9. Every overlay read and page uses one immutable
`{static_generation, runtime_generation}` pair; stale cursors fail explicitly.
10. Static and runtime publication serialize through the same per-project lock; each
publisher changes only its own half of the composite head and preserves the other,
and symbol-resolution records are never reused across static generations or
resolver versions.
11. Unsupported span kinds, missing identities, and limit violations never become
guessed calls or partial publications.
12. Authorization, privacy filtering, quotas, and retention are enforced before data
becomes durable or reader-visible.
13. Wire, runtime schema, runtime semantic, runtime artifact, static semantic, and
static artifact versions can change independently according to their boundaries.
14. Documentation-only adoption leaves all six version values and all runtime behavior
unchanged.
+512
View File
@@ -0,0 +1,512 @@
# Smart CI: pull-request test selection on GitHub Actions
Smart CI runs, for each pull request, only the CI jobs and tests that the
change can reach. This page explains how this repository does it and how
another repository on GitHub Actions could do the same with the released
`codebase-memory-mcp` binary.
Status: experimental. Every answer carries `"experimental": true`. The
selection narrows real test runs only while the repository variable
`TEST_IMPACT_MODE` is `gate`; otherwise it runs in report-only (shadow) mode.
> **Read the scope section before you start.** The lane layer is a pattern
> any repository can copy. The test layer narrows only C/C++ code whose tests
> follow this repository's test-framework conventions (or declared
> equivalents), and only with a per-test coverage map that you must produce
> yourself. In any other repository the engine answers "run everything". That
> is safe, but it saves nothing.
## The guarantee
Smart CI never runs fewer tests unless it can show that the omitted tests
cannot be affected. When it is unsure, it runs everything:
- a path that no rule knows, an empty or truncated file list, or a change to
the selection's own configuration, tooling or workflows;
- a shallow clone, a diff that cannot be read in full, or an invalid config;
- no coverage map, or a coverage map that is not admitted (wrong commit, more
than 7 days old, made for another platform or harness);
- a change that reaches the test runner's `main`, or a test-runner role
(below) that no test names;
- a role table entry the engine cannot read, or a macro naming a role's
literal that it cannot follow;
- any failure of the selection job itself.
Each of these makes the run cost more CI minutes. None of them loses coverage.
## Two layers
```
changed files ──► Layer 1: lane selector ──► which CI jobs run
(scripts/ci/select-lanes.sh, your own rules)
│
▼ (the Linux x86_64 test leg only, in this repo)
merge base + PR ─► Layer 2: test-impact engine ─► which suites / tests run
(codebase-memory-mcp test-impact select
+ team-artifact bundle from main)
```
| | Layer 1: lanes | Layer 2: tests |
|---|---|---|
| Question | Which jobs (lint, test legs, smoke, sanitizers) does this PR need? | Inside a test job, which suites and tests? |
| Input | The PR's changed-file list | Git history, the code graph, a per-test coverage map |
| Implementation | `scripts/ci/select-lanes.sh` (repository-specific rules) | The binary's `test-impact select` subcommand |
| Unknown input | Every lane runs | Every test runs (`run_all` with reasons) |
| Portable? | As a pattern: you write your own rules | C/C++ with supported test conventions only (see below) |
## Can your repository use it?
| Piece | General? | What you provide |
|---|---|---|
| Lane selection (Layer 1) | Pattern only | Your own path → category → lanes rules. `select-lanes.sh` encodes this repository's paths. |
| Engine (`test-impact select`) | Ships in the binary | Nothing to build. |
| Source languages it can narrow | **C family only**: `.c .h .cc .cpp .hpp .m .mm .cxx .hh` | Every other changed file must match a config rule (`ignore`, `referencing_tests`, ...). Otherwise the answer is `run_all` (`UNMAPPED_FILE`). |
| Test model | **C family only** | The `c-cbm` preset (this repository's `TEST`/`SUITE`/`RUN_TEST`/`RUN_SELECTED_SUITE` macros, see `tests/test_framework.h`), or custom `tests.conventions` records for equivalent C macros. |
| Coverage map | **Required to narrow at all** | Format-1 files (`functions.tsv`, `tests.tsv`, `meta.json`). This repository produces them with an LLVM-instrumented runner and `scripts/test-impact/coverage-map.py`, which are both specific to its harness. |
| Runner filter | Pattern | A test runner that can run a `suite` / `suite:test` list and fails when a listed test does not exist. |
The other presets (`pytest`, `go`, `junit`, `gtest`, `rust`, `jest`) are
accepted switches, but the engine does not model them. An enabled `gtest`
blocks narrowing for C++ files. Custom `tests.conventions` are covered by the
model's unit tests. The engine's end-to-end tests use `c-cbm` only. Treat
custom conventions as unproven until you have shadow evidence.
Without admitted coverage the engine marks every suite `whole`, and the lane
reports `run_all`. Static reachability alone never omits a suite.
## Prerequisites
1. **The binary, pinned.** Use the same release in the producer and the PR
jobs. Download that release's Linux archive and verify it against the
release's `checksums.txt`, as this repository's shadow job does
(`scripts/ci/test-impact-shadow.sh --fetch-release`):
```bash
CBM_VERSION=vX.Y.Z # a release that contains the features you use (see below)
asset=codebase-memory-mcp-linux-amd64.tar.gz
gh release download "$CBM_VERSION" --repo DeusData/codebase-memory-mcp \
--pattern "$asset" --pattern checksums.txt --dir "$RUNNER_TEMP/cbm"
(cd "$RUNNER_TEMP/cbm" && grep " $asset\$" checksums.txt | sha256sum -c - &&
tar -xzf "$asset" codebase-memory-mcp)
"$RUNNER_TEMP/cbm/codebase-memory-mcp" --version
```
For a quick start, `install.sh` installs the latest release with the same
checks (HTTPS only, SHA-256 against `checksums.txt`, the archive's member
list, `--version`); `--skip-config` skips agent configuration, which a CI
runner does not need. It has no version flag, so it is not a pin.
**Version note.** `v0.12.0` and `v0.12.1` have `test-impact select`/`publish`.
They do not have `key: "path"`, `unreferenced`, `--vetoed-out` or
`run_all_rules`; those ship in a later release. With either version, a config
that uses those keys is invalid, and every answer is `run_all`. Until that
release, build the binary from source (`make -f Makefile.cbm cbm`), as this
repository's producer and `select-tests` jobs do.
2. **Full git history.** Check out with `fetch-depth: 0` in every job that
runs the engine. The engine verifies the history it diffs and refuses a
shallow clone (`GIT_UNAVAILABLE`, everything runs).
3. **A runner that runs a named subset.** See "The test job" below.
4. **A coverage producer on the default branch** that writes the format-1 map
for every test, child processes included. A test whose coverage is
uncertain must be marked `incomplete`. An incomplete test is always
selected.
5. **`gh`, `python3`, and `GH_TOKEN` with `actions: read`** in the PR job, to
fetch the bundle. GitHub-hosted runners include `gh` and `python3`.
## Recipe
Copy these files from this repository and adapt the marked lines:
| File | Adapt |
|---|---|
| `scripts/ci/test-impact-bundle.sh` | `WORKFLOW=` (producer file name) and the hard-coded `main` branch filter. |
| `scripts/ci/test-impact-select.sh` | `--lane unit` (your lane name) and the `PLATFORM` default (must equal the producer's). |
| `scripts/test-impact/selection.py` | Nothing, if your runner accepts `suite` / `suite:test` lines. |
### (a) Producer: publish a bundle for every commit on main
The bundle holds the commit's frozen graph, its coverage map and a
`receipt.json` that binds both by SHA-256. It is uploaded as the Actions
artifact `test-impact-<sha>`. `test-impact-bundle.sh` accepts a bundle only
from a successful `push`, `schedule` or `workflow_dispatch` run of the
producer workflow, in the same repository, on `main`, whose head is exactly
the requested commit.
```yaml
# .github/workflows/test-impact-artifact.yml (the name test-impact-bundle.sh looks for)
name: test-impact-artifact
on:
push:
branches: [main]
schedule:
- cron: "23 3 * * 0" # weekly full refresh
workflow_dispatch:
permissions:
contents: read
actions: read # fetch the previous bundle (incremental mode)
concurrency:
group: test-impact-artifact # bundles chain: never overlap, never cancel
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@<pinned-sha>
with:
fetch-depth: 0
persist-credentials: false
- name: Install codebase-memory-mcp
run: <the pinned install from Prerequisites>
- name: Per-test coverage map # YOURS: format 1, see coverage-map.py
run: |
echo "OBSERVED=$(date +%s)" >> "$GITHUB_ENV" # when the coverage run starts
./ci/coverage-map.sh --out "$RUNNER_TEMP/coverage"
- name: Publish the bundle
run: |
export CBM_CACHE_DIR="$RUNNER_TEMP/ti-cache"
export CBM_RUNTIME_DIR="$(mktemp -d /tmp/cbm-rt.XXXXXX)" # short: sun_path
mkdir -p "$RUNNER_TEMP/ti-work" && chmod 700 "$RUNNER_TEMP/ti-work"
"$RUNNER_TEMP/cbm/codebase-memory-mcp" test-impact publish \
--repo "$GITHUB_WORKSPACE" --out "$RUNNER_TEMP/bundle" \
--work "$RUNNER_TEMP/ti-work" --coverage "$RUNNER_TEMP/coverage" \
--observed-at "$OBSERVED" --platform linux-x86_64-clang21
- uses: actions/upload-artifact@<pinned-sha>
with:
name: test-impact-${{ github.sha }}
path: ${{ runner.temp }}/bundle
include-hidden-files: true # the graph lives in .codebase-memory/
retention-days: 30
if-no-files-found: error
```
This repository's producer (`.github/workflows/test-impact-artifact.yml` with
`scripts/ci/test-impact-publish.sh`) adds an incremental mode. A push re-runs
coverage only for the suites that its own selection picks and merges them into
the previous map (`coverage-merge.py`). The graph is built incrementally from
the previous bundle (`--previous`). The weekly run rebuilds everything and
fails if the incremental graph topology differs from the full one. The
incremental mode is an optimisation. A full coverage run on every push is
simpler and just as correct.
The receipt keeps the time of the oldest coverage observation. An incremental
publish carries it forward unchanged. A map whose oldest observation is more
than 7 days old is refused (`TOO_OLD`), so a full refresh has to run more often
than that.
### (b) PR job, shadow mode (report only)
```yaml
test-impact-shadow:
runs-on: ubuntu-latest
continue-on-error: true # can never block or pass a merge
permissions:
contents: read
actions: read # the merge base's bundle
steps:
- uses: actions/checkout@<pinned-sha>
with:
fetch-depth: 0
persist-credentials: false
- name: Install codebase-memory-mcp
run: <the pinned install from Prerequisites>
- name: Select with the team artifact (shadow)
env:
GH_TOKEN: ${{ github.token }}
run: scripts/ci/test-impact-select.sh --binary "$RUNNER_TEMP/cbm/codebase-memory-mcp" --out "$RUNNER_TEMP/test-impact"
- uses: actions/upload-artifact@<pinned-sha>
if: ${{ always() }}
with:
name: test-impact-prediction
path: |
${{ runner.temp }}/test-impact/selection-engine.json
${{ runner.temp }}/test-impact/summary-engine.md
```
Leave this job out of your aggregate required check. What
`test-impact-select.sh` does:
1. Takes the base side of the PR merge commit (`HEAD^1`) and fetches that
commit's bundle.
2. If the producer skipped that commit (merge bursts: GitHub keeps one pending
run per concurrency group), it uses the nearest first-parent ancestor that
has a bundle, up to 20 commits back, and diffs against that ancestor. If the
PR reverts a change that main made after the ancestor, the ancestor's map
cannot speak for that file, so the script selects without coverage (the
revert guard).
3. Passes `--artifact-verified` only for a bundle that it fetched and checked
itself. Nothing inside a bundle can assert where the bundle came from.
4. Runs `test-impact select` and writes `selection-engine.json`. It also
writes a Markdown summary to the job's step summary.
The engine reads `.codebase-memory.json` from the **selection base** (the merge
base, or the ancestor whose bundle was admitted), not from the PR. A PR cannot
loosen its own selection. A PR that changes the config runs
everything (`builtin:test-impact-config`).
### (c) Gate job and the test job
```yaml
select-tests:
if: ${{ vars.TEST_IMPACT_MODE == 'gate' }}
runs-on: ubuntu-24.04
permissions:
contents: read
actions: read
outputs:
selection: ${{ steps.select.outputs.selection }}
steps:
- uses: actions/checkout@<pinned-sha>
with:
fetch-depth: 0
persist-credentials: false
- name: Install codebase-memory-mcp
run: <the pinned install from Prerequisites>
- id: select
env:
GH_TOKEN: ${{ github.token }}
run: scripts/ci/test-impact-select.sh --binary "$RUNNER_TEMP/cbm/codebase-memory-mcp" --out "$RUNNER_TEMP/test-impact" --gate "$RUNNER_TEMP/test-selection"
- if: ${{ steps.select.outputs.selection == 'narrowed' }}
uses: actions/upload-artifact@<pinned-sha>
with:
name: test-selection
path: ${{ runner.temp }}/test-selection
if-no-files-found: error
test:
needs: [select-tests]
if: ${{ !cancelled() }} # a skipped or failed select-tests must NOT skip the tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<pinned-sha>
- if: ${{ needs.select-tests.result == 'success' && needs.select-tests.outputs.selection == 'narrowed' }}
uses: actions/download-artifact@<pinned-sha>
with:
name: test-selection
path: ${{ runner.temp }}/test-selection
- run: ./ci/run-tests.sh # YOURS: narrows only if test-only.txt is present
```
With `--gate DIR`, the script runs `selection.py filter … --lane unit` and
writes `selection=narrowed` or `selection=all` to `$GITHUB_OUTPUT`. A narrowed
answer writes:
- `DIR/test-only.txt`: one token per line, either `suite` (run the whole
suite) or `suite:test`;
- `DIR/optional-suites.txt`: selected suites that are registered only under a
preprocessor condition. A build may lack them.
Everything else means `all`: a `run_all` answer, an unknown schema or filter
encoding, a filter that disagrees with the lane's suite list, a crash, and a
lane that selects nothing. The test job then gets no selection and runs every
test.
**The test job's runner** has to keep the same contract as this repository's
runner (`scripts/test.sh` → `scripts/run-tests-parallel.sh` →
`CBM_TEST_ONLY_FILE`, read by `tests/test_main.c`):
- launch only the suites that the tokens name, and only the named tests inside
a `suite:test` suite;
- **fail** when the selection names a suite the build does not register,
unless `optional-suites.txt` lists it;
- **fail** when a selected test cannot be found;
- **fail** when a selection was promised but the file is missing or empty.
A selection that is silently ignored, or partly ignored, would turn a narrowing
into missed coverage.
**A runner that starts itself again.** Tests often spawn the test binary as a
child (`<runner> __probe_x ...`) and its `main` hands over to a probe. A change
that reaches `main` reaches every test, so the engine runs everything
(`RUNNER_REACHED`). List the probes instead in a table in the runner's source,
one entry per line, as `tests/test_main.c` does:
```c
TF_ROLE(TF_PHASE_SEAMED, "__cbm_sockprobe", tf_maybe_run_socket_probe),
TF_ROLE(TF_PHASE_WORKER, "--index-worker", tf_maybe_run_index_worker),
TF_ROLE(TF_PHASE_SPAWNER, "CBM_INDEX_SUPERVISOR", tf_maybe_run_index_worker),
```
The engine reads the table: a change that only a probe's handler reaches
selects the tests whose source names one of that handler's literals (the argv
the child gets, or a switch that lets production code start it), also through
a `#define` that names the literal. The runner's own use of a literal names no
spawner. A reached handler that no test names, a table entry the engine cannot
read, and a change reaching `main` any other way still run everything; the
engine note names the call chain into `main`.
**Only the legs the coverage speaks for should narrow.** Here the coverage
comes from one Linux x86_64 clang-21 producer. Only the Linux x86_64 test leg
downloads the selection (`runner.os == 'Linux' && runner.arch == 'X64'` in
`_test.yml`). The arm64, macOS and Windows legs always run everything,
because code under architecture or platform `#ifdef`s has static evidence
only there. That leg builds with gcc, not clang: code under a compiler `#if`
has static evidence only too, and `--platform` names the producer's platform,
so the engine's platform check admits the map by construction.
### (d) Flip the variable
Shadow is the default: the variable is unset, `select-tests` is skipped, and
every test runs. When the evidence is in, set the repository variable:
```bash
gh variable set TEST_IMPACT_MODE --body gate # gate
gh variable delete TEST_IMPACT_MODE # back to shadow
```
Do not add `select-tests` to your required checks or to the aggregate gate.
When it breaks, PRs cost more minutes. They do not lose coverage.
### Optional: Layer 1 (lanes)
`scripts/ci/select-lanes.sh` is this repository's lane rule set. A `changes`
job lists the PR's files (the `pulls/{n}/files` API, with a git fallback),
runs the selector, and writes `tier`, `full`, `lanes` (a JSON list) and
`reasons` to `$GITHUB_OUTPUT`. Each lane job then runs
`if: contains(fromJSON(needs.changes.outputs.lanes), '<lane>')`. A single
aggregate job (`ci-ok`, `scripts/ci/require-all-green.sh`) is the only required
check. It fails when a *selected* lane was skipped, cancelled or missing.
For your own version, keep these properties:
- An unknown path, an empty list, workflow and harness changes, and a list
truncated at the API's 3000-file cap all select every lane.
- The selector runs from a checkout without credentials, after the step that
holds the token.
- Replay your PR history through the selector and check that every real
failure had its catching lane selected. This repository's equivalent is
`scripts/test-impact/replay-selector.sh`.
## Configuration: `.codebase-memory.json` → `test_impact`
| Key | Values | Default |
|---|---|---|
| `version` | `1` (required) | — |
| `tests.presets` | `{"c-cbm": true, ...}`. Unknown names are an error. | `c-cbm` off, the six others on |
| `tests.conventions` | C-family macro records: `case`, `suite`, `registration`, `suite_registration` | none |
| `rules` | ordered list, see below | none (built-ins only) |
| `lanes` | list of `{name, suites, exclude_suites?, default: "run"\|"skip", narrow?}` | one lane `unit`: all suites except `@perf`, `narrow: true` |
| `unmapped_files` | `"run_all"` (the only accepted value) | `run_all` |
| `coverage.compatibility_paths` | repo-relative files that the coverage map depends on (harness, runner, build flags) | none |
**Rules.** Each rule is `{id, paths, action, ...}`. `paths` are gitignore-style
globs with `{a,b}` alternatives. A pattern with a `/` is anchored at the
repository root. Without a `/`, it matches at any depth. `**` matches across
directories. Project rules come first, then the built-ins. For each changed
path, **the first matching rule wins**, so order matters.
| `action` | Effect |
|---|---|
| `run_all` | Any match runs everything. The summary names the rule (`run_all_rules`). |
| `ignore` | The path selects nothing. |
| `referencing_tests` | Selects the tests whose source contains the path as text. The engine tries the path itself, then its parent directories down to the `key`, each with and without its first segment. `key: "path_segment:N"` stops at segment N (zero-based). `key: "path"` tries the exact path only. If no test names the path, everything runs, unless `unreferenced: "ignore"` is set. |
| `suites` | Runs the named suites whole. |
| `lanes` | Activates the named lanes (for lanes with `default: "skip"`). |
Built-in rules, applied after yours:
| id | paths | action |
|---|---|---|
| `builtin:test-impact-config` | `.codebase-memory.json` | `run_all` |
| `builtin:build` | `Makefile*`, `CMakeLists.txt`, `*.cmake`, `meson.build`, `BUILD*`, `WORKSPACE`, `package*.json`, `go.mod`, `go.sum`, `Cargo.*`, `pom.xml`, `build.gradle*`, `pyproject.toml`, `setup.*`, `requirements*.txt` | `run_all` |
| `builtin:ci` | `.github/**`, `.gitlab-ci.yml`, `.circleci/**`, `Jenkinsfile*`, `.buildkite/**`, `azure-pipelines*.yml` | `run_all` |
| `builtin:vendor` | `vendor*/**`, `third_party/**` | `run_all` |
| `builtin:conftest` | `conftest.py` | `run_all` |
| `builtin:documentation` | `docs/**`, `**/*.md`, `LICENSE*` | `ignore` |
**Lanes.** A lane that you declare yourself does **not** narrow unless you set
`"narrow": true`. `signals` is accepted but has no effect yet.
**This repository's file, explained:**
| Rule | Why |
|---|---|
| `harness` → `run_all` | The test framework and the runner's `main` touch every suite. |
| `engine` → `run_all` | The selection's own code and config. A change to them could loosen what is selected. |
| `build` → `run_all` | Build and runner scripts: the file list cannot tell a flag change from a harmless one. `/.gitattributes` is anchored at the root. |
| `grammars` → `run_all` | Vendored parsers are not modelled per test. |
| `fixtures` → `referencing_tests`, `path_segment:2` | `tests/fixtures/<name>/...` selects the tests that mention the fixture, down to `tests/fixtures/<name>`. |
| `contracts` → `ignore` | Shell, Python and data contract tests run in every leg's contract step, which is never narrowed. |
| `notices` → `ignore` | Release notices. |
| `repo-data` → `referencing_tests`, `path`, `unreferenced: ignore` | Scripts, docs and packaging. A C test whose source names one of these files (its path, as text) is selected. A file that no test names selects nothing. |
`coverage.compatibility_paths` lists the harness and coverage tools. Changing
the bytes of any of them, or the `--platform` label, makes the existing coverage
map incompatible until the producer publishes a new one.
## Rollout and evidence
1. **Shadow.** Run (a) and (b). Leave `TEST_IMPACT_MODE` unset.
2. **Measure.** For each PR run, compare `selection-engine.json` with what
failed in that run's full test legs.
- A **miss** is a test that failed in the full run but was not selected.
One unexplained miss blocks the flip.
- **Savings** are the suites and tests that were not selected.
Nothing in this repository computes misses for Layer 2 yet. Layer 1's
history replay (`replay-selector.sh`) is the model to follow.
3. **Gate.** Set `TEST_IMPACT_MODE=gate`. Keep the shadow job running, so that
the evidence keeps coming.
**Reading the step summary** (`### test-impact engine`):
| Line | Meaning |
|---|---|
| `decision: \`selected\`` / `\`run_all\`` / `\`nothing\``, then `run-all reasons [...]` | The top-level answer and why everything runs. `decided by rule(s) [...]` names the matched `run_all` rule ids. |
| `team artifact: fetched (ancestor …)` / `none`; `coverage \`admitted\`` / `\`rejected\` [reasons]` / `\`unavailable\`` | Whether a bundle was used, and whether its coverage was admitted. |
| `engine note: …` | The engine's first diagnostic line, such as the path that caused `UNMAPPED_FILE`, or the call chain into the runner's `main` for `RUNNER_REACHED`. |
| `lane \`unit\` (selected): N whole [REASON n, …], M narrowed (K tests)` | Per lane: suites that run whole (with a histogram of reasons), and suites narrowed to K tests. |
| `the runner-main veto (RUNNER_REACHED) discarded this selection:` | The change reached the runner's `main`, so everything runs. The lines below show what would have been selected otherwise. |
| `PR CI runs N selection tokens` / `PR CI runs every test (selection exit N)` | Gate mode only: what the test legs received. |
Common run-all reasons:
| Reason | Cause |
|---|---|
| `RULE_RUN_ALL` | A `run_all` rule matched (see `run_all_rules`). |
| `UNMAPPED_FILE` | A changed file matched no rule and is not C-family source. |
| `RULE_TARGET_UNKNOWN` | A `referencing_tests` path that no test names (without `unreferenced: "ignore"`). |
| `RUNNER_REACHED` | The change reaches the test runner's `main` (or a child role none of whose literals a test names). |
| `GIT_UNAVAILABLE` | No git, a shallow clone, or a diff that cannot be read in full. |
| `CONFIG_INVALID` / `POLICY_UNAVAILABLE` | The merge base's config or rules do not parse. |
Reasons per suite: `ARTIFACT_REJECTED` (no admitted coverage), `SETUP_HIT` /
`SETUP_UNKNOWN` (the suite's between-test setup ran changed code, or its
coverage is incomplete), `INVENTORY_UNKNOWN` (the suite cannot be read with
certainty), `CONDITIONAL` (`#if`-registered), `STATIC` / `CHANGED` / `COVERAGE`
/ `RULE` (the evidence that selected it).
## Failure modes
Every row below costs CI minutes, not coverage.
| What happens | Result |
|---|---|
| No bundle for the merge base | Nearest bundled ancestor, up to 20 first-parent commits back. Otherwise no coverage, and everything runs. |
| Bundle fetch fails (token, API) | Warning, no coverage, and everything runs. |
| The PR reverts a change that main made after the ancestor | Revert guard: no coverage, and everything runs. |
| Coverage older than 7 days, or a platform/compatibility mismatch | Map refused: every suite runs whole. |
| Shallow checkout | `GIT_UNAVAILABLE`: everything runs. |
| Engine deadline (default 1800 s) or crash | The job fails. The test job gets no selection, so everything runs. |
| `select-tests` skipped (shadow) or failed | Everything runs (`!cancelled()` on the test job). |
| The selection names a suite or test that the build lacks | The test run **fails**: a plumbing fault, reported, never skipped. |
## Known limits
- Narrowing covers C-family source and C test conventions only. Everything
else is decided by rules or runs everything.
- The coverage map format and its producer come from this repository's LLVM
toolchain and harness. You need your own equivalent.
- One producer platform. The selection narrows only the leg that matches the
producer's platform; every other leg runs everything. Narrowing more legs
needs a producer (and a bundle) per platform.
- The weekly full refresh and the 7-day age limit are the same length. Each
week, when the refresh runs late, there can be a short window in which maps
are refused and every test runs.
- In merge bursts the producer skips commits. The ancestor fallback covers up
to 20 of them.
- The answer schema is `cbm.test_impact.v0` and is marked experimental. It may
change between releases. Pin the binary.
+6 -1
View File
@@ -70,7 +70,12 @@ For directories:
1. **Built-in skip list** — `.git`, `node_modules`, `dist`, `target`,
`vendor`, tool caches, etc. (60+ names; the fast/moderate index modes add
more, e.g. `docs`, `examples`, `testdata`). A `.cbmignore` negation
more, e.g. `docs`, `examples`, `testdata`). Generated output whose
folder name alone is too common to skip everywhere is matched by path
suffix instead: Laravel's compiled Blade view cache
`storage/framework/views/` (at any depth) is skipped even without
Laravel's stock `.gitignore` there, while other `views/` folders stay
indexed. A `.cbmignore` negation
(for example `!target/`) can un-skip these directories, except the
non-negatable safety core: `.git`, `node_modules`, `.worktrees`, and
`.claude-worktrees`.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 279 KiB

+49
View File
@@ -0,0 +1,49 @@
# CBM Lens Beta README captures
These are screenshots and browser recordings of the application, captured on 12–13 October 2026. The application data and text were not retouched. Bernhard Jackiewicz captured the recordings and PNG stills on 12 October. The Explorer and Galaxy JPEG stills were refreshed on 13 October. One addition is drawn on top of the recordings: a small mouse pointer and click ring, because a headless browser shows no pointer of its own.
## Original captures: 12 October
- Repository: [DeusData/codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp)
- Branch: `main`, after [PR #2068](https://github.com/DeusData/codebase-memory-mcp/pull/2068) brought CBM Lens Beta in
- Commit: `4d9592b5`
- Build: `scripts/build.sh --with-ui`
- Browser: headless Chromium (Playwright) on macOS, English locale, viewport 1440 × 900, device scale factor 1
- Agents: standard build, experimental agent activity disabled
The `v0.0.1` label in the application is the frontend package version. It is not the backend release version.
### Data shown
The project `codebase-memory-mcp` is an export of the same public commit (`git archive`), indexed in `full` mode into an isolated local cache. No private repository was used. Because the export has no `.git` folder, Explore reports "Git unavailable" in its file impact line.
The index reported 75,037 nodes and 361,349 edges. These are properties of this source scope, not a benchmark. The interface keeps its coverage and display-limit notices.
## Refreshed stills: 13 October
`lens-explore.jpg` and `lens-galaxy.jpg` are unedited browser captures at 1280 × 720 from the locally served CBM Lens Beta UI. The running binary reports `dev`; its exact build commit is unavailable, so these captures do not certify an exact source revision. The local project is named `cbm-triage-main-20260919`; the screenshots show public CBM source and indexed relationships.
- **Explore:** `src/foundation/limits.c`, its compact file impact summary and direct dependencies (24 nodes, 26 edges). The UI's freshness, sampling and coverage caveats remain visible.
- **Galaxy:** `cbm_max_file_bytes`, both directions, all edge types, two layers (160 nodes, 218 edges).
These counts describe the displayed index snapshot, not a benchmark or proof of complete repository coverage. The original recordings and other stills are unchanged.
## Media
| File | What it shows |
|------|---------------|
| [lens-overview.gif](lens-overview.gif) | Architecture overview of the repository: zoom and a small orbit, selecting `src/pipeline`, opening that area, selecting `artifact.c` and opening its source. |
| [lens-architecture.png](lens-architecture.png) | Still image of the whole repository on the architecture map. |
| [lens-galaxy.gif](lens-galaxy.gif) | Galaxy: searching `cbm_pipeline_run`, following its outgoing calls one layer further, the hierarchy view and the path to `cbm_store_check_integrity`. |
| [lens-galaxy.jpg](lens-galaxy.jpg) | Two dependency layers around `cbm_max_file_bytes`, with direction and edge-type controls. |
| [lens-explore.jpg](lens-explore.jpg) | `src/foundation/limits.c` in the reader with its file impact summary, file tree and direct graph dependencies. |
| [lens-chat.gif](lens-chat.gif) | The chat with the local Qwen2.5 Coder 0.5B model: the explanation of `cbm_pipeline_pass_doclinks` and "Who calls cbm_pipeline_pass_doclinks?" answered from the graph. |
| [lens-chat.png](lens-chat.png) | Still image of the same explanation and listed answer. |
| [lens-system-structure.png](lens-system-structure.png) | Component groups and their dependencies in System structure, `src` selected. |
| [lens-behavior.png](lens-behavior.png) | Possible direct calls of `compileCards`, with its declaration. |
The recordings were captured as browser video, then encoded as GIF at 960 pixels wide and 10 frames per second with a palette per recording. While a search is typed, the Galaxy briefly shows "No matching indexed nodes." until the index search answers; those frames (under one second) were cut. The original PNG stills are 1440 × 900, the chat still 1440 × 1200 so that the explanation and the answer fit together. The refreshed JPEG stills are 1280 × 720. Directional pulses represent indexed relationships, not observed execution.
## Local chat check
The chat recording uses the actual browser model, downloaded through the application's own setup. The explanation lists the indexed facts and the declaration of the selected function; the one sentence after them was written by the model and is labelled as such. The caller answer is listed from the indexed graph without the model.
Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

+22 -22
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>codebase-memory-mcp — Code Intelligence Knowledge Graph for AI Coding Agents</title>
<meta name="description" content="codebase-memory-mcp is an open-source MCP server that indexes any codebase into a persistent knowledge graph so AI coding agents answer structural questions with ~120x fewer tokens. 158 languages, Hybrid LSP type resolution, local semantic vector search, code-clone detection, sub-1ms queries, Linux kernel indexed in 3 minutes. Native executable with verified assets and no language runtime. One command configures 43 automatic/conditional client surfaces.">
<meta name="description" content="codebase-memory-mcp is an open-source MCP server that indexes any codebase into a persistent knowledge graph so AI coding agents answer structural questions with ~120x fewer tokens. 162 languages, Hybrid LSP type resolution, local semantic vector search, code-clone detection, sub-1ms queries, Linux kernel indexed in 3 minutes. Native executable with verified assets and no language runtime. One command configures 45 automatic/conditional client surfaces.">
<meta name="keywords" content="MCP server, code intelligence, knowledge graph, tree-sitter, Hybrid LSP, semantic code search, code embeddings, code clone detection, cross-repo analysis, data-flow analysis, Claude Code, Codex CLI, Gemini CLI, Cursor, Zed, code exploration, token reduction, call graph, dead code detection">
<meta name="author" content="DeusData">
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
@@ -12,7 +12,7 @@
<!-- Open Graph -->
<meta property="og:title" content="codebase-memory-mcp — Code Intelligence Knowledge Graph for AI Coding Agents">
<meta property="og:description" content="Index any codebase into a persistent knowledge graph. AI agents answer structural questions with ~120x fewer tokens. 158 languages, Hybrid LSP, sub-1ms queries, native executable with verified assets.">
<meta property="og:description" content="Index any codebase into a persistent knowledge graph. AI agents answer structural questions with ~120x fewer tokens. 162 languages, Hybrid LSP, sub-1ms queries, native executable with verified assets.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://deusdata.github.io/codebase-memory-mcp/">
<meta property="og:image" content="https://deusdata.github.io/codebase-memory-mcp/graph-ui-screenshot.png">
@@ -24,7 +24,7 @@
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="codebase-memory-mcp — Code Intelligence for AI Coding Agents">
<meta name="twitter:description" content="Index any codebase into a knowledge graph. ~120x fewer tokens for AI code exploration. 158 languages, Hybrid LSP, sub-1ms queries, native executable with verified assets.">
<meta name="twitter:description" content="Index any codebase into a knowledge graph. ~120x fewer tokens for AI code exploration. 162 languages, Hybrid LSP, sub-1ms queries, native executable with verified assets.">
<meta name="twitter:image" content="https://deusdata.github.io/codebase-memory-mcp/graph-ui-screenshot.png">
<link rel="canonical" href="https://deusdata.github.io/codebase-memory-mcp/">
@@ -41,7 +41,7 @@
"applicationSubCategory": "Model Context Protocol (MCP) server",
"operatingSystem": "macOS, Linux, Windows",
"softwareVersion": "0.8.1",
"description": "An open-source MCP server that indexes a codebase into a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links, so AI coding agents answer structural questions with roughly 120x fewer tokens than file-by-file search. Parses 158 languages via tree-sitter with Hybrid LSP semantic type resolution for 9 language families. Ships as a native executable with authenticated release-owned assets; the native install needs no language runtime.",
"description": "An open-source MCP server that indexes a codebase into a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links, so AI coding agents answer structural questions with roughly 120x fewer tokens than file-by-file search. Parses 162 languages via tree-sitter with Hybrid LSP semantic type resolution for 9 language families. Ships as a native executable with authenticated release-owned assets; the native install needs no language runtime.",
"url": "https://deusdata.github.io/codebase-memory-mcp/",
"downloadUrl": "https://github.com/DeusData/codebase-memory-mcp/releases/latest",
"codeRepository": "https://github.com/DeusData/codebase-memory-mcp",
@@ -56,7 +56,7 @@
"featureList": [
"Indexes 158 programming languages via vendored tree-sitter grammars",
"Hybrid LSP semantic type resolution for Python, TypeScript/JavaScript, PHP, C#, Go, C/C++, Java, Kotlin, and Rust",
"15 MCP tools for structural search, call-path tracing, targeted coverage checks, and Cypher graph queries",
"17 MCP tools for structural search, call-path tracing, targeted coverage checks, and Cypher graph queries",
"Semantic vector code search via bundled nomic-embed-code embeddings (no API key, fully local)",
"Semantic graph edges (SEMANTICALLY_RELATED) and near-clone detection (SIMILAR_TO, MinHash + LSH)",
"Cross-service linking for HTTP, gRPC, GraphQL, tRPC, and pub/sub channels with confidence scoring",
@@ -68,7 +68,7 @@
"Infrastructure-as-code indexing for Dockerfiles, Kubernetes, and Kustomize",
"Built-in 3D graph visualization UI",
"Auto-sync background watcher for incremental re-indexing",
"One command configures 43 automatic/conditional client surfaces"
"One command configures 45 automatic/conditional client surfaces"
],
"author": {
"@type": "Organization",
@@ -125,7 +125,7 @@
"name": "What is codebase-memory-mcp?",
"acceptedAnswer": {
"@type": "Answer",
"text": "codebase-memory-mcp is an open-source Model Context Protocol (MCP) server that indexes a codebase into a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. AI coding agents query that graph instead of reading files one by one, answering structural questions with roughly 120x fewer tokens. It parses 158 languages and ships as a native executable with authenticated release-owned assets; the native install needs no language runtime."
"text": "codebase-memory-mcp is an open-source Model Context Protocol (MCP) server that indexes a codebase into a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. AI coding agents query that graph instead of reading files one by one, answering structural questions with roughly 120x fewer tokens. It parses 162 languages and ships as a native executable with authenticated release-owned assets; the native install needs no language runtime."
}
},
{
@@ -141,7 +141,7 @@
"name": "Which programming languages does codebase-memory-mcp support?",
"acceptedAnswer": {
"@type": "Answer",
"text": "It supports 158 languages through vendored tree-sitter grammars compiled into the binary, including Python, Go, JavaScript, TypeScript, Rust, Java, C, C++, C#, PHP, Ruby, Kotlin, Swift, and many more. Nine language families — Python, TypeScript/JavaScript, PHP, C#, Go, C/C++, Java, Kotlin, and Rust — additionally get Hybrid LSP semantic type resolution."
"text": "It supports 162 languages through vendored tree-sitter grammars compiled into the binary, including Python, Go, JavaScript, TypeScript, Rust, Java, C, C++, C#, PHP, Ruby, Kotlin, Swift, and many more. Nine language families — Python, TypeScript/JavaScript, PHP, C#, Go, C/C++, Java, Kotlin, and Rust — additionally get Hybrid LSP semantic type resolution."
}
},
{
@@ -157,7 +157,7 @@
"name": "Which AI coding agents work with codebase-memory-mcp?",
"acceptedAnswer": {
"@type": "Answer",
"text": "A single install command configures 43 automatic/conditional client surfaces. The 37 detected surfaces are Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, Cursor, Windsurf, Augment / Auggie, OpenClaw, Kiro, Junie, Hermes, OpenHands, Cline, Warp, Qwen Code, GitHub Copilot CLI, Factory Droid, Crush, Goose, Mistral Vibe, Qoder CLI, Kimi Code CLI, GitLab Duo CLI, Rovo Dev CLI, Amp, Devin CLI / Local, Tabnine, Amazon Q Developer IDE, CodeBuddy Code CLI, IBM Bob Shell, Pochi, and Pi. Continue / cn, Visual Studio, TRAE, Roo Code, IBM Bob IDE, and Sourcegraph Cody are conditional or explicit integrations. Qodo, Warp MCP, JetBrains AI/ACP, GitHub Copilot coding agent, Jules, CodeRabbit, Replit, BLACKBOX AI, Plandex, and SWE-agent require manual, UI, cloud, or repository-managed setup and are not counted among the 43."
"text": "A single install command configures 45 automatic/conditional client surfaces. The 39 detected surfaces are Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, Cursor, Windsurf, Augment / Auggie, OpenClaw, Kiro, Junie, Hermes, OpenHands, Cline, Warp, Qwen Code, GitHub Copilot CLI, Factory Droid, Crush, Goose, Mistral Vibe, Grok Build, Qoder CLI, Kimi Code CLI, GitLab Duo CLI, Rovo Dev CLI, Amp, Devin CLI / Local, Tabnine, Amazon Q Developer IDE, CodeBuddy Code CLI, IBM Bob Shell, Pochi, Pi, and Oh My Pi (omp). Continue / cn, Visual Studio, TRAE, Roo Code, IBM Bob IDE, and Sourcegraph Cody are conditional or explicit integrations. Qodo, Warp MCP, JetBrains AI/ACP, GitHub Copilot coding agent, Jules, CodeRabbit, Replit, BLACKBOX AI, Plandex, and SWE-agent require manual, UI, cloud, or repository-managed setup and are not counted among the 45."
}
},
{
@@ -444,13 +444,13 @@
The fastest, most efficient code intelligence engine for AI coding agents. It indexes any
repository into a persistent knowledge graph — full-indexing an average repo in seconds and the
Linux kernel in 3 minutes — so your agent answers structural questions with ~120x fewer tokens.
Tree-sitter parsing across 158 languages, Hybrid LSP type resolution, native executable with verified assets.
Tree-sitter parsing across 162 languages, Hybrid LSP type resolution, native executable with verified assets.
</p>
<div>
<div class="stat"><div class="number">~120x</div><div class="label">fewer tokens</div></div>
<div class="stat"><div class="number">158</div><div class="label">languages</div></div>
<div class="stat"><div class="number">3 min</div><div class="label">Linux kernel index</div></div>
<div class="stat"><div class="number">43</div><div class="label">client surfaces</div></div>
<div class="stat"><div class="number">44</div><div class="label">client surfaces</div></div>
</div>
<div class="cta-buttons">
<a href="https://github.com/DeusData/codebase-memory-mcp" class="cta-primary">View on GitHub</a>
@@ -483,7 +483,7 @@
Protocol (MCP)</a> server that indexes a codebase into a persistent knowledge graph of functions,
classes, call chains, HTTP routes, and cross-service links. Instead of reading files one at a time,
an AI coding agent queries the graph — answering structural questions with roughly 120x fewer
tokens. It parses 158 languages and ships as a native executable with authenticated release-owned assets;
tokens. It parses 162 languages and ships as a native executable with authenticated release-owned assets;
the native install needs no language runtime.
</p>
<p class="muted">
@@ -527,12 +527,12 @@
<span class="cmd">"Index this project"</span>
</div>
<p class="muted">
One command configures 43 automatic/conditional client surfaces. <strong>Detected automatically (37):</strong>
One command configures 45 automatic/conditional client surfaces. <strong>Detected automatically (39):</strong>
Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, Cursor,
Windsurf, Augment / Auggie, OpenClaw, Kiro, Junie, Hermes, OpenHands, Cline, Warp, Qwen Code,
GitHub Copilot CLI, Factory Droid, Crush, Goose, Mistral Vibe, Qoder CLI, Kimi Code CLI,
GitHub Copilot CLI, Factory Droid, Crush, Goose, Mistral Vibe, Grok Build, Qoder CLI, Kimi Code CLI,
GitLab Duo CLI, Rovo Dev CLI, Amp, Devin CLI / Local, Tabnine, Amazon Q Developer IDE,
CodeBuddy Code CLI, IBM Bob Shell, Pochi, and Pi.
CodeBuddy Code CLI, IBM Bob Shell, Pochi, Pi, and Oh My Pi (omp).
</p>
<p class="muted">
<strong>Conditional or explicit (6):</strong> Continue / cn, Visual Studio, TRAE, Roo Code, IBM Bob IDE,
@@ -540,7 +540,7 @@
Warp MCP, JetBrains AI/ACP, GitHub Copilot coding agent, Jules, CodeRabbit, Replit, BLACKBOX AI,
Plandex, and SWE-agent. Warp is counted above for its detected skill installation; its MCP connection
remains manual. Windows users run <code>install.ps1</code>. Also available via <code>npm</code>,
<code>pip</code>, Homebrew, Scoop, Winget, Chocolatey, AUR, and <code>go install</code>.
<code>pip</code>, Homebrew, Scoop, AUR, and <code>go install</code>.
</p>
<p class="muted">
Lifecycle installation follows documented context contracts: Qoder uses <code>SessionStart</code>,
@@ -606,7 +606,7 @@
</tbody>
</table>
<p class="muted">
The two-layer pipeline runs a fast syntactic tree-sitter pass for every one of the 158 languages,
The two-layer pipeline runs a fast syntactic tree-sitter pass for every one of the 162 languages,
then a type-aware Hybrid LSP pass on top for the families above. Languages without a Hybrid LSP pass
yet fall back to textual resolution, so you always get an answer.
</p>
@@ -699,7 +699,7 @@
<h2>Features</h2>
<div class="features">
<div class="feature">
<h3>158 languages</h3>
<h3>162 languages</h3>
<p>Python, Go, JS, TS, TSX, Rust, Java, C++, C#, C, PHP, Ruby, Kotlin, Scala, Zig, Elixir, Haskell, OCaml, Swift, Dart, Lean 4, and many more via vendored tree-sitter grammars compiled into the binary.</p>
</div>
<div class="feature">
@@ -728,7 +728,7 @@
</div>
<div class="feature">
<h3>Auto-sync</h3>
<p>A background watcher detects changes and re-indexes incrementally. No manual reindex after editing files.</p>
<p>A background watcher detects changes in the project your MCP session is working in and re-indexes it incrementally. No manual reindex after editing files there.</p>
</div>
<div class="feature">
<h3>Team-shared graph artifact</h3>
@@ -739,8 +739,8 @@
<p>The optional UI variant adds one verified, content-addressed frontend pack and serves an interactive 3D graph at <code>localhost:9749</code> to explore nodes, edges, and clusters visually.</p>
</div>
<div class="feature">
<h3>15 MCP tools</h3>
<p><code>search_graph</code>, <code>trace_path</code>, <code>detect_changes</code>, <code>query_graph</code> (Cypher), <code>get_architecture</code>, <code>get_code_snippet</code>, <code>check_index_coverage</code>, <code>manage_adr</code>, and 7 more.</p>
<h3>17 MCP tools</h3>
<p><code>search_graph</code>, <code>trace_path</code>, <code>detect_changes</code>, <code>query_graph</code> (Cypher), <code>get_architecture</code>, <code>get_code_snippet</code>, <code>check_index_coverage</code>, <code>manage_adr</code>, and 9 more.</p>
</div>
<div class="feature">
<h3>Cypher graph queries</h3>
@@ -805,7 +805,7 @@
<h3>Do I need Docker or a runtime?</h3>
<p>The native install needs no language runtime. It is a native executable with authenticated release-owned
assets for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (amd64); npm, PyPI, and Go wrapper
assets for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (arm64/amd64); npm, PyPI, and Go wrapper
installations naturally use their package runtime.</p>
<h3>How does it stay up to date as I edit code?</h3>
+3 -3
View File
@@ -1,6 +1,6 @@
# codebase-memory-mcp
> An open-source Model Context Protocol (MCP) server that indexes a codebase into a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. AI coding agents query the graph instead of reading files one by one, answering structural questions with roughly 120x fewer tokens (~3,400 vs ~412,000 across five structural queries). Parses 158 languages via vendored tree-sitter grammars, with Hybrid LSP semantic type resolution for Python, TypeScript/JavaScript, PHP, C#, Go, C/C++, Java, Kotlin, and Rust. It also generates semantic graph edges (SEMANTICALLY_RELATED for vocabulary-mismatch matches, SIMILAR_TO for near-clone detection) and supports semantic vector search via bundled nomic-embed-code embeddings compiled into the native executable — no API key, no Ollama, no Docker. Ships a native executable with an authenticated integration asset; the UI variant adds one content-addressed frontend pack. All processing is local — the embeddings run on-device; there is no embedded LLM and no API key.
> An open-source Model Context Protocol (MCP) server that indexes a codebase into a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. AI coding agents query the graph instead of reading files one by one, answering structural questions with roughly 120x fewer tokens (~3,400 vs ~412,000 across five structural queries). Parses 162 languages via vendored tree-sitter grammars, with Hybrid LSP semantic type resolution for Python, TypeScript/JavaScript, PHP, C#, Go, C/C++, Java, Kotlin, and Rust. It also generates semantic graph edges (SEMANTICALLY_RELATED for vocabulary-mismatch matches, SIMILAR_TO for near-clone detection) and supports semantic vector search via bundled nomic-embed-code embeddings compiled into the native executable — no API key, no Ollama, no Docker. Ships a native executable with an authenticated integration asset; the UI variant adds one content-addressed frontend pack. All processing is local — the embeddings run on-device; there is no embedded LLM and no API key.
## Key facts
@@ -12,10 +12,10 @@
- Semantic & similarity edges: SEMANTICALLY_RELATED (vocabulary-mismatch matches) and SIMILAR_TO (MinHash + LSH near-clone / duplicate detection).
- Cross-repo intelligence: CROSS_* edges link nodes across multiple repos indexed in one store; multi-galaxy 3D layout and cross-repo architecture summary.
- Cross-service linking: HTTP route ↔ call-site matching, plus gRPC/GraphQL/tRPC detection and pub/sub channels (EMITS/LISTENS_ON for Socket.IO, EventEmitter, generic buses).
- Supported agents: 43 automatic/conditional client surfaces (37 automatically detected + 6 conditional/explicit): Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, Cursor, Windsurf, Augment / Auggie, OpenClaw, Kiro, Junie, Hermes, OpenHands, Cline, Warp, Qwen Code, GitHub Copilot CLI, Factory Droid, Crush, Goose, Mistral Vibe, Qoder CLI, Kimi Code CLI, GitLab Duo CLI, Rovo Dev CLI, Amp, Devin CLI / Local, Tabnine, Continue / cn, Visual Studio, TRAE, Roo Code, Amazon Q Developer IDE, CodeBuddy Code CLI, IBM Bob IDE, IBM Bob Shell, Pochi, Pi, and Sourcegraph Cody.
- Supported agents: 45 automatic/conditional client surfaces (39 automatically detected + 6 conditional/explicit): Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, Cursor, Windsurf, Augment / Auggie, OpenClaw, Kiro, Junie, Hermes, OpenHands, Cline, Warp, Qwen Code, GitHub Copilot CLI, Factory Droid, Crush, Goose, Mistral Vibe, Grok Build, Qoder CLI, Kimi Code CLI, GitLab Duo CLI, Rovo Dev CLI, Amp, Devin CLI / Local, Tabnine, Continue / cn, Visual Studio, TRAE, Roo Code, Amazon Q Developer IDE, CodeBuddy Code CLI, IBM Bob IDE, IBM Bob Shell, Pochi, Pi, Oh My Pi (omp), and Sourcegraph Cody.
- Agent profiles: documented custom-agent formats receive Scout (fast/provisional), Verify (default/task-directed), and Auditor (bounded/full verification) definitions. Every direct tier checks exact path/scope coverage with check_index_coverage and falls back to source for flagged gaps; unsafe child-MCP formats use explicit parent handoff. Kiro and Junie use positive-allowlist Scout/Analysis server profiles (7/11 tools); Qoder combines named-server selection with exact tier-specific MCP tool IDs, while Factory uses exact registered IDs without additive whole-server exposure. Foreign Junie aliases are preserved and force parent handoff.
- Performance: Linux kernel (28M LOC, 75K files) full index in 3 minutes → 4.81M nodes, 7.72M edges; Cypher queries in under 1ms.
- Distribution: verified runtime set (native executable plus authenticated release-owned assets); fully static Linux builds are available in the `-portable` archives. Also available through npm, PyPI, Homebrew, Scoop, Winget, Chocolatey, AUR, and `go install`.
- Distribution: verified runtime set (native executable plus authenticated release-owned assets); fully static Linux builds are available in the `-portable` archives. Also available through npm, PyPI, Homebrew, Scoop, AUR, and `go install`.
## Links
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1776255774,
"narHash": "sha256-psVTpH6PK3q1htMJpmdz1hLF5pQgEshu7gQWgKO6t6Y=",
"lastModified": 1783915482,
"narHash": "sha256-FmieJB8/OUvNxbkboi7+IGfIuSXY3nF/hZQm8kD0r50=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "566acc07c54dc807f91625bb286cb9b321b5f42a",
"rev": "6cdc7fc76e8bf7fde9fa43a849fcaaa70e230dee",
"type": "github"
},
"original": {
+52 -2
View File
@@ -60,10 +60,10 @@
};
in
{
packages = forAllSystems (pkgs: {
packages = forAllSystems (pkgs: rec {
default = pkgs.stdenv.mkDerivation {
pname = "codebase-memory-mcp";
version = "0.6.0";
version = "0.12.1";
src = ./.;
@@ -90,6 +90,56 @@
platforms = systems;
};
};
# The graph-ui React frontend, built to static assets (graph-ui/dist).
# buildNpmPackage fetches npm deps offline via the pinned package-lock.json;
# bump npmDepsHash whenever that lockfile changes:
# nix run nixpkgs#prefetch-npm-deps -- graph-ui/package-lock.json
graph-ui = pkgs.buildNpmPackage {
pname = "codebase-memory-graph-ui";
version = "0.1.0";
src = ./graph-ui;
npmDepsHash = "sha256-P1JVo+GFr+Gsq88dnn9OsedZqrTaj3DDGbej+nHbp4U=";
# `npm run build` runs `tsc -b && vite build`, emitting to dist/.
installPhase = ''
runHook preInstall
cp -r dist $out
runHook postInstall
'';
};
# Same server binary as `default`, but with the graph-ui assets embedded
# so `--ui=true` starts the HTTP server. The frontend is built separately
# by the graph-ui package above; here we drop its dist/ into place and run
# the embed + link path (cbm-with-ui) — no network access needed.
codebase-memory-mcp-ui = default.overrideAttrs (old: {
pname = "codebase-memory-mcp-ui";
buildPhase = ''
# cbm-with-ui depends on `embed`, which depends on `frontend`, which
# runs `npm ci && npm run build` — needs network access, unavailable
# in the sandbox. Supply the pre-built assets, run the embed script
# directly, then link with `cbm-with-ui` while telling make its
# `frontend`/`embed` prerequisites are already up to date (-o) so it
# won't re-run the npm build.
mkdir -p graph-ui/dist
cp -r ${graph-ui}/. graph-ui/dist/
chmod -R u+w graph-ui/dist
bash scripts/embed-frontend.sh graph-ui/dist build/c/embedded
make -j$NIX_BUILD_CORES -f Makefile.cbm \
-o frontend -o embed \
cbm-with-ui
'';
meta = old.meta // {
description = "codebase-memory-mcp with the embedded graph UI (--ui)";
};
});
});
devShells = forAllSystems (pkgs: {
+60
View File
@@ -0,0 +1,60 @@
.DS_Store
cbm/
node_modules/
dist/
*.log
.vite/
*.tsbuildinfo
# Die lokalen Modelle und die gebaute Inferenz-Runtime: zusammen ueber 11 GB
# und beide reproduzierbar aus Pruefsummen und Baubefehl. Was committet wird,
# ist die Herkunft und nicht das Gewicht.
#
# Die vier Zeilen je Verzeichnis sind kein Umweg, sondern das, was git
# verlangt: ein ausgeschlossenes VERZEICHNIS wird gar nicht erst betreten, also
# koennte eine Ausnahme darin nie greifen (git-scm, gitignore(5): "It is not
# possible to re-include a file if a parent directory of that file is
# excluded"). Die Verzeichnisregel steht trotzdem zuerst, weil sie die Absicht
# nennt; die naechste hebt sie fuer das Verzeichnis selbst wieder auf, die
# dritte schliesst seinen Inhalt aus, und erst die vierte holt die eine Datei
# zurueck, die committet gehoert.
models/
!models/
models/*
!models/SHA256SUMS
vendor/
!vendor/
vendor/*
!vendor/llama/
vendor/llama/*
!vendor/llama/HERKUNFT.md
# Die Laufspuren des Sidecars. Die PID-Datei gehoert dem laufenden Prozess,
# das Log ist ohnehin schon durch *.log erfasst.
llm/.pid
# Der Node-Bau des Context-Compilers fuer die Eval. Er wird bei jedem Lauf neu
# erzeugt (tools/lib/compiler-bundle.mjs); ein committetes Bundle waere eine
# zweite, alternde Fassung der Dateien daneben.
.eval-build/
# Die Ereignisdatei der Agenten (W11a). Sie enthaelt keine Dateiinhalte, aber
# sie verraet, an welchen Dateien jemand gearbeitet und welche Befehle er
# gefahren hat. Das ist die Arbeitsspur einer Person und gehoert nicht in ein
# Repository; was committet ist, ist die AUFZEICHNUNG des Beweislaufs unter
# fixtures/agent-events/, und deren Herkunft steht dort daneben.
.atlas-trace/
events.jsonl
events.jsonl.seq
# Local visual captures and development reports; JSON/JSONL fixtures stay tracked.
/verification/**/*.png
/verification/**/*.jpg
/verification/**/*.jpeg
/verification/**/*.gif
/verification/**/*.webp
/verification/**/*.svg
/verification/**/*.webm
/verification/**/*.mp4
/verification/**/*.md
-48
View File
@@ -1,48 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
-64
View File
@@ -1,64 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
-92
View File
@@ -1,92 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
-32
View File
@@ -1,32 +0,0 @@
"use client"
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
-21
View File
@@ -1,21 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
-56
View File
@@ -1,56 +0,0 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
-26
View File
@@ -1,26 +0,0 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+73
View File
@@ -0,0 +1,73 @@
# Frontend configuration
Open **System**, then **Configuration**. Settings are grouped by indexing,
resources, server, diagnostics, and browser. The **External inputs** reference
covers repository files, installation, launch flags, integration paths, and
development settings whose owner is outside the running daemon.
## Daemon overrides
Daemon edits are staged until **Save changes**. A batch is validated and committed
atomically to `_config.db` in the CBM cache directory. Invalid keys or values reject
the whole batch. Each row exposes its source, default, saved override, current
process value, and application timing. **Reset** removes the saved override; it
does not overwrite the inherited environment or replace it with a hardcoded default.
Legacy environment values outside the registry's canonical format are shown as
runtime-interpreted inputs, rather than guessed effective values. Saving an override
requires a validated value.
Supported environment overrides are loaded once when a process starts, ahead of
the inherited environment and built-in defaults. They do not mutate or export the
process environment. Restart CBM to apply changes throughout the installation;
newly launched index workers can load saved values before the existing daemon is
restarted. Explicit command flags, per-index options, and supervisor recovery caps
retain their documented precedence.
The existing `auto_index`, `auto_index_limit`, `auto_watch`, `watcher_enabled`, and
`ui-lang` settings share their canonical SQLite values with CLI configuration.
UI enablement and port overrides use the same transactional store, with legacy
UI `config.json` as a fallback. Explicit CLI/UI saves update that store too.
Concurrent edits use a revision check; a stale save fails without changing data.
Launch locations, filesystem access grants, arbitrary output destinations,
installer settings, test injection, and supervisor controls are references rather
than editable process overrides. Platform-specific or unlinked functionality is
marked unavailable. No generic environment editor or secret enumeration is exposed.
## Browser preferences
Browser changes apply immediately. Graph display and edge motion use their existing
preference stores. Galaxy budgets, coverage shadow, and architecture appearance
share a versioned, per-project browser store with the controls in those views.
Changing these values preserves the current selection. If storage is unavailable,
preferences remain usable for the current session.
The top-bar agent status opens separate local model controls. Opening configuration never
downloads weights or starts a model. Repository extension mappings and ignore files
remain repository-owned and take effect on subsequent indexing.
## Local HTTP API
`GET /api/config` returns `{ revision, settings }`. Each setting declares its type,
validation bounds or enum choices, lifecycle, provenance, and editability.
The registry lives in `src/cli/runtime_settings.c`; consumer locations are included
in its metadata. Browser response validation lives in `src/settings/config-model.ts`.
`POST /api/config` accepts JSON with the revision just read and a nonempty changes
object. Values are canonical strings; `null` removes an override:
```json
{
"revision": "12",
"changes": {
"CBM_WORKERS": "4",
"CBM_SEMANTIC_ENABLED": "false",
"CBM_SQLITE_MMAP_SIZE": null
}
}
```
Success returns the updated snapshot. Invalid batches return 400, conflicting
revisions return 409, and storage failures return 500. Existing loopback Host,
same-origin, and JSON-content checks protect the endpoint. Frontend drafts survive
errors so users can inspect the conflict before reloading or discarding them.
+132
View File
@@ -0,0 +1,132 @@
# CBM Lens Beta
CBM Lens is the local exploration frontend served by `codebase-memory-mcp --ui=true`.
It combines source, the indexed graph and optional browser inference. Source files
are read-only; project architecture decisions can be edited in the ADR workspace.
The frontend was contributed by Bernhard Jackiewicz as CodeAtlasWeb.
## Explore a project
- **Explore** follows the current file or marked source. Its graph requests direct
dependencies separately from the capped repository layout. File-level impact
summaries use graph and local Git evidence, with details available on demand.
- **Galaxy** searches nodes, files and folders. Select a scope and expand incoming
or outgoing relationships by depth and edge type. Overview budgets limit drawing;
a capped layout does not establish a complete neighborhood. Bounded caches reuse
positions and verified adjacency, and fresh index generations invalidate evidence.
- **Architecture** presents the repository map, routes, hotspots, system structure
and behavior. Connectivity clusters are not proof of architectural boundaries.
Moving arrows show edge direction, not observed execution. Reduced-motion settings
retain stationary direction markers.
- **Coverage** explains indexing gaps. **System** contains daemon status, indexes,
diagnostics and MCP configuration. See [configuration and precedence](CONFIGURATION.md).
- **ADR** stores one editable Markdown decision document per project through
`GET/POST /api/adr`, shared with `manage_adr`. It does not edit repository files.
Use **Add project index** in the project dropdown (or Alt+P) to choose a local
repository, name its index and follow daemon-reported progress. Files are indexed
in place, not uploaded. Closing the dialog does not cancel indexing.
ADR drafts stay in this tab's session storage. Before saving, the UI compares the
server document with the editing baseline and retains conflicting drafts. This is
not an atomic document lock. Saves are limited to 8,000 UTF-8 bytes and a 16 KiB
serialized request. The examples in `fixtures/adr/` are explicitly synthetic and
are never inserted into real projects automatically.
## Local agent
The top-bar agent status opens model configuration; the adjacent chat control folds
or unfolds the conversation. Model downloads require explicit activation. The chat
uses browser-local inference; the selected source is not sent to a remote model.
Model provenance and license limitations are in [THIRD_PARTY.md](THIRD_PARTY.md).
Explorer provides the exact marked text, or the loaded file when nothing is marked.
A caret alone does not narrow the context. One replaceable source block precedes
conversation history; each message captures the current selection. Navigation does
not alter an in-flight request, and Retry uses its original snapshot. Missing or
partial source is disclosed. Token counting uses the selected model's tokenizer;
oversized requests are rejected rather than silently shortened.
Automatic explanations respond to new selections independently of follow-up chat.
Browser history is separated by project, retained for 24 hours, and can be cleared
in Agent configuration.
## Build and preview
From the repository root:
```sh
make -f Makefile.cbm cbm-with-ui
```
This installs the locked frontend dependencies, builds `dist/` and embeds it in the
native binary. For frontend development, run `npm ci` and `npm run dev` in this
directory. Vite listens on 5173 and proxies `/api` and `/rpc` to the local server on
9749. `npm run build` creates a production bundle.
The standalone test preview in `tools/lib/static-proxy.mjs` snapshots `dist/` when
it starts. Restart that preview after building and compare the served `index.html`
asset name with `dist/index.html` before reviewing changes.
The canonical frontend CI entry point is `scripts/ci/test-ui.sh` from the repository
root. It runs unit tests, style and promise checks, acceptance checks and a production
build. Browser smoke commands require a running server and Playwright Chromium;
install that browser explicitly with `npx playwright install chromium` when needed.
## Experimental agent activity
Build with `VITE_EXPERIMENTAL_AGENTS=true` to expose recorded tool activity, hook
setup and graph overlays. The value must be exactly `true`; URL parameters and saved
preferences cannot enable it. Browser-local chat is independent of this flag.
Activity remains off until enabled in the UI. It requires an event adapter and is
not automatic observation of arbitrary coding agents.
The supported adapter is `agents/hooks/atlas-trace.py`. **Connect your agent**
downloads it and offers a project-specific install command. Review and run it locally:
```sh
python3 ~/Downloads/cbm-atlas-trace.py --install-claude --root /absolute/repository --project INDEXED_NAME --daemon-url http://127.0.0.1:9749
```
The installer preserves unrelated settings and hooks, rejects conflicts, symlinks
and malformed configuration, and writes only the selected repository's
`.claude/settings.local.json` and `.claude/hooks/`.
Start a new session in your configured client after installation. Other clients
need their own adapter.
For manual hook setup, set `ATLAS_PROJECT` to the indexed name, optionally
`ATLAS_AGENT_NAME` and `ATLAS_DAEMON_URL`, and pass the tool event on stdin. The hook
records tool, run, sequence, timestamp, path and explicit line ranges. It omits source
contents, tool output and free-form commands, search patterns and descriptions. Paths
can still be sensitive. Destinations must be local; proxies and redirects are refused.
A transactional outbox at `~/.atlas-trace/events.jsonl.outbox.sqlite3` buffers up to
10,000 pending events. `ATLAS_TRACE_FILE` changes the path prefix. Delivery resumes
on the next hook or `python3 agents/hooks/atlas-trace.py --flush`; one flush sends up
to 100 events from one project. The older JSONL/replay tools are fixture utilities.
## Activity and diagnostic APIs
`POST /api/agent-events` accepts `{project, events: [...]}` with at most 100 events.
Each event needs `ts`, `agent`, `run`, integer `seq`, `phase: start|end` and `tool`.
Optional fields include `path`, `lines`, `detail`, `source`, self-reported `intent`
and explicit `replay`. Unknown fields are discarded. The UI distinguishes recorded
facts from self-reports and leaves unmapped paths visible.
The daemon stores activity in `<cache>/activity.db`, independently of replaceable
project indexes. Monotonic cursors, `(project, run, seq)` identities and retired
sequence floors prevent duplicate or expired events from reappearing. Retention is
bounded to 10,000 events; expired history is incomplete. Poll with
`GET /api/agent-events?project=NAME&after=CURSOR&limit=200` on the graph's origin.
Daemon and frontend logs share the SQLite journal. `GET /api/logs` accepts `project`,
`scope=unattributed`, `min_level`, literal `q`, `after` and `lines`; filters apply
before counts and pagination. Unattributed records are not assigned to the selected
project. Responses identify severity, source, receipt time, durable ID and database
generation. A bounded memory fallback is explicitly marked `persistent:false`.
`POST /api/ui-log` batches frontend errors and request failures. Failed journal writes
return 503 for retry. `GET /api/ui-log` reads the same journal; `path` names
`activity.db`, while `export_path` names the rotating compatibility JSONL export.
Changing that export does not change HTTP history. These APIs publish nothing
externally.
+149
View File
@@ -0,0 +1,149 @@
# CBM Lens third-party notices
This document records imported source, package licenses and optional model provenance.
Preserve the copyright and license headers in individual source files. The npm
lockfile records the exact installed dependency versions.
## Galaxy source
The initial scene was adapted from [DeusData/codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp),
`feat/atlas-r1` at `bda46086`, on 2026-08-28. It is MIT licensed,
Copyright (c) 2025 DeusData; the full license is in the repository's `LICENSE`.
| Lens file | Original under `graph-ui/src` |
| --- | --- |
| `src/galaxy/GraphScene.tsx` | `components/GraphScene.tsx` |
| `src/galaxy/NodeCloud.tsx` | `components/NodeCloud.tsx` |
| `src/galaxy/EdgeLines.tsx` | `components/EdgeLines.tsx` |
| `src/galaxy/NodeLabels.tsx` | `components/NodeLabels.tsx` |
| `src/galaxy/HaloLayer.tsx` | `components/HaloLayer.tsx` |
| `src/galaxy/types.ts` | `lib/types.ts` |
| `src/galaxy/density.ts` | `lib/density.ts` |
Adaptations include component-local canvas access, memoized scene measurements,
frontend-specific props and types, and revised graph interaction/rendering.
`NodeTooltipCard.tsx` is a separate implementation using Lens styling rather than
the original Tailwind tooltip. File headers retain the initial import details;
subsequent changes are recorded in Git history.
## Packages
| Package | Recorded version | License |
| --- | --- | --- |
| `three` | 0.183.2 | MIT |
| `@react-three/fiber` | 9.5.0 | MIT |
| `@react-three/drei` | 10.7.7 | MIT |
| `@react-three/postprocessing` | 3.0.4 | MIT |
| `postprocessing` | 6.38.3 | Zlib |
| `three-stdlib` | 2.36.1 | MIT |
| `@types/three` | 0.183.1 | MIT |
| `@huggingface/transformers` | 4.2.0 | Apache-2.0 |
| `onnxruntime-web` | 1.26.0-dev.20260416-b7804b056c | MIT |
| `sharp` | 0.35.0 | Apache-2.0 |
| `react-markdown` | 10.1.0 | MIT |
| `remark-gfm` | 4.0.1 | MIT |
These packages are used in their published form. `postprocessing` requires accurate
origin attribution and identification of modified versions under its Zlib license.
React, Vite, Vitest and Monaco use MIT; TypeScript and Playwright use Apache-2.0.
`sharp` is a transitive Node dependency with a targeted override, not browser code.
Full Markdown renderer license texts appear below.
## CodeAtlasIDE provenance
Provider, semantic IR, twin and semantic-search modules were ported from Bernhard
Jackiewicz's CodeAtlasIDE project by the same author. Their source headers retain
that origin. They are original contributions from Bernhard, not imports from an
unrelated third party.
The concept of deriving an entry tour from project structure was inspired by
Understand-Anything (MIT). `src/tours/tour-generator.ts` contains original Kahn
traversal, role labels, wording and cycle handling; no source was copied from that
project. Its direct code origin is CodeAtlasIDE.
## Optional browser models
Transformers.js and ONNX Runtime assets are served locally. Weights are not bundled;
downloading them requires an explicit browser action. Source inference stays in the
browser. Model policy pins revisions and permitted files; it does not execute remote
model scripts.
The following metadata was recorded on 2026-09-08. Download sizes include tokenizer
and configuration files and do not describe GPU memory requirements.
| Model | Fixed revision | Download bytes | Recorded license |
| --- | --- | --- | --- |
| [onnx-community/Qwen2.5-Coder-0.5B-Instruct](https://huggingface.co/onnx-community/Qwen2.5-Coder-0.5B-Instruct) | `f0292f665fd307846ff3c318a91a1bc29d091492` | 566366194 | Apache-2.0 for the base model |
| [onnx-community/Qwen3-0.6B-ONNX](https://huggingface.co/onnx-community/Qwen3-0.6B-ONNX) | `da1453100cf3ff33ef56d17983fc7a8648706db6` | 578917626 | Apache-2.0 |
| [LiquidAI/LFM2.5-1.2B-Instruct-ONNX](https://huggingface.co/LiquidAI/LFM2.5-1.2B-Instruct-ONNX) | `10f72e70abf67ac0fd7ebf15bc5854726891d864` | 763763755 | Liquid AI license; review commercial terms above $10M annual revenue |
| [onnx-community/Qwen3.5-2B-ONNX-OPT](https://huggingface.co/onnx-community/Qwen3.5-2B-ONNX-OPT) | `2ea7886f48b926aca97de8b0e041ffca7e3ebaa9` | 1402850762 | Apache-2.0 |
All use `q4f16`. Qwen3.5 loads the text decoder and embeddings only, not vision
files. The Qwen2.5 ONNX weight file is 554935833 bytes; its published LFS SHA-256 is
`60c076ac0d3910881fe0cad75997e803459a5f71bb74bcbcd82407ef83db7ba0`.
The converter's model card does not declare a separate license; its
[official base model](https://huggingface.co/Qwen/Qwen2.5-Coder-0.5B-Instruct)
is Apache-2.0.
`src/browser-ai/model-policy.ts` retains complete file lists, published LFS hashes
and allowed hosts, including `huggingface.co` and the recorded redirect host
`us.aws.cdn.hf.co`. The loader does not recompute the published hashes.
The browser context limit is 8192 tokens including 512 reserved output tokens;
requests are counted with the selected template and tokenizer rather than silently
truncated. Qwen3 and Qwen3.5 default to non-thinking mode. Runtime support is not a
benchmark or a compatibility guarantee for every browser/GPU combination.
## Markdown renderer licenses
### react-markdown
```text
The MIT License (MIT)
Copyright (c) Espen Hovlandsdal
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### remark-gfm
```text
(The MIT License)
Copyright (c) Titus Wormer <tituswormer@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
@@ -0,0 +1,99 @@
#!/usr/bin/env node
/**
* Die zweite Quelle: Schreibvorgaenge auf der Platte.
*
* Sie ist der Rueckfall fuer alles, was keine Werkzeug-Hooks kennt. Ein Agent,
* der mit einem Editor oder einem Skript arbeitet, ruft keinen Hook auf; was
* dabei trotzdem sichtbar ist, ist die Datei, die sich aendert.
*
* ## Was sie sieht, und was nicht
*
* Sie sieht **nur Schreibvorgaenge**. Kein Lesen, kein Suchen, kein Testlauf,
* kein Befehl. Genau so ist jedes Ereignis gekennzeichnet (`source: "fs"`), und
* das ist keine Formalie: wer spaeter eine Aufzeichnung ansieht, muss wissen,
* dass die Ruhe zwischen zwei Schreibvorgaengen nicht Ruhe bedeutet, sondern
* Arbeit, die hier nicht ankommt. Ein Protokoll, das seine eigene Blindheit
* verschweigt, ist schlimmer als keines.
*
* Sie schreibt **keine Dateiinhalte**, so wenig wie der Hook daneben: ein
* Ereignis nennt den Pfad und die Zeit, mehr nicht. Einen Zeilenbereich kann
* sie nicht nennen, denn ein Dateisystem meldet keine Zeilen; das Feld fehlt
* darum, statt eine Zahl zu erfinden.
*
* ## Aufruf
*
* node agents/hooks/atlas-trace-watch.mjs <verzeichnis>
*
* Wohin geschrieben wird, sagt ATLAS_TRACE_FILE; ohne die Variable ist es
* ~/.atlas-trace/events.jsonl. Wie der Agent heisst, sagt ATLAS_AGENT_NAME;
* ohne sie steht dort "file-writes", weil das die Wahrheit ueber diese Quelle
* ist: sie kennt keinen Agenten, sie kennt eine Datei, die sich geaendert hat.
*
* Kein Paket, nur node:fs.
*/
import { appendFileSync, mkdirSync, watch } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { homedir } from 'node:os';
const ROOT = process.argv[2] ?? process.cwd();
const OUT = process.env['ATLAS_TRACE_FILE']
?? join(homedir(), '.atlas-trace', 'events.jsonl');
const AGENT = process.env['ATLAS_AGENT_NAME'] ?? 'file-writes';
/** Der Lauf. Acht Zeichen, wie die Kennung des Hooks daneben. */
const RUN = String(Date.now()).slice(-8);
/** Was nie interessiert: Bauwerk, Fremdcode, Beweisbilder, das Protokoll selbst. */
const IGNORE = /(^|\/)(node_modules|\.git|dist|cbm|vendor|models|verification|coverage)(\/|$)/;
/**
* Derselbe Pfad zweimal in dieser Zeitspanne ist ein Ereignis, nicht zwei.
*
* Ein Editor schreibt gern mehrfach (Datei anlegen, Inhalt schreiben, Rechte
* setzen), und ein Bild soll die Handlung zeigen und nicht die Schreibtechnik.
*/
const COALESCE_MS = 400;
const lastSeen = new Map();
let seq = 0;
function record(path) {
const now = Date.now();
if (now - (lastSeen.get(path) ?? 0) < COALESCE_MS) {
return;
}
lastSeen.set(path, now);
seq += 1;
const line = {
ts: now,
agent: AGENT,
run: RUN,
seq,
phase: 'end',
tool: 'Write',
path,
source: 'fs',
detail: 'seen as a write on disk; reads, searches and test runs are invisible here',
};
try {
appendFileSync(OUT, JSON.stringify(line) + '\n', 'utf8');
process.stdout.write(`${new Date(now).toISOString()} ${path}\n`);
} catch {
/* Ein volles Dateisystem kostet das Protokoll, nicht die Arbeit. */
}
}
try {
mkdirSync(dirname(OUT), { recursive: true });
} catch {
/* Steht es schon da, ist alles gut; steht es nicht, meldet der erste Schreibversuch es. */
}
watch(ROOT, { recursive: true }, (_event, name) => {
if (typeof name !== 'string' || IGNORE.test(name)) {
return;
}
record(relative('.', name) || name);
});
process.stdout.write(`watching ${ROOT}, appending to ${OUT}\n`);
+256
View File
@@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""Record tool metadata in a local outbox and POST it to the existing daemon.
Configure ATLAS_PROJECT to the indexed project name and ATLAS_DAEMON_URL when
using a nondefault daemon port. No bridge, listener or external transmission.
Pending events survive downtime and retry on the next hook or --flush.
The hook always returns zero so observing work cannot break the tool call.
"""
import json
import os
import sys
import time
import sqlite3
import uuid
import urllib.request
import urllib.parse
import argparse
import pathlib
import shlex
import tempfile
DEFAULT_FILE = os.path.join(os.path.expanduser('~'), '.atlas-trace', 'events.jsonl')
def trace_file():
return os.environ.get('ATLAS_TRACE_FILE') or DEFAULT_FILE
def open_outbox():
path = trace_file() + '.outbox.sqlite3'
os.makedirs(os.path.dirname(os.path.abspath(path)), mode=0o700, exist_ok=True)
db = sqlite3.connect(path, timeout=0.5)
os.chmod(path, 0o600)
db.executescript("""
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS counters(run TEXT PRIMARY KEY, seq INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS pending(id INTEGER PRIMARY KEY, project TEXT NOT NULL,
payload TEXT NOT NULL);
""")
return db
def enqueue(db, project, row):
# Counter allocation and enqueue are one SQLite transaction: parallel hooks
# cannot assign the same run/sequence pair and silently discard an event.
with db:
db.execute('BEGIN IMMEDIATE')
db.execute('INSERT INTO counters VALUES(?,1) ON CONFLICT(run) DO UPDATE SET seq=seq+1',
(row['run'],))
row['seq'] = db.execute('SELECT seq FROM counters WHERE run=?', (row['run'],)).fetchone()[0]
db.execute('INSERT INTO pending(project,payload) VALUES(?,?)',
(project, json.dumps(row, ensure_ascii=False)))
# A prolonged daemon outage cannot fill the disk without bound. Sequence
# gaps remain visible when older undelivered metadata expires.
db.execute('DELETE FROM pending WHERE id IN (SELECT id FROM pending ORDER BY id DESC LIMIT -1 OFFSET 10000)')
def flush(db):
origin = os.environ.get('ATLAS_DAEMON_URL', 'http://127.0.0.1:9749')
parsed = urllib.parse.urlsplit(origin)
if (parsed.scheme != 'http' or parsed.hostname not in ('127.0.0.1', 'localhost')
or parsed.username or parsed.password or parsed.path not in ('', '/')
or parsed.query or parsed.fragment):
return # Never forward local metadata to an external endpoint.
first = db.execute('SELECT project FROM pending ORDER BY id LIMIT 1').fetchone()
if not first:
return
rows = db.execute('SELECT id,payload FROM pending WHERE project=? ORDER BY id LIMIT 100',
first).fetchall()
data = json.dumps({'project': first[0], 'events': [json.loads(row[1]) for row in rows]}).encode()
request = urllib.request.Request(origin.rstrip('/') + '/api/agent-events', data=data,
headers={'Content-Type': 'application/json'})
# No environment proxy, redirects, or second server; post only to loopback.
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
with opener.open(request, timeout=0.75) as response:
result = json.load(response)
if response.status != 200 or result.get('accepted', 0) + result.get('duplicates', 0) != len(rows):
return
with db:
db.executemany('DELETE FROM pending WHERE id=?', [(row[0],) for row in rows])
def line_span(file_path, args):
"""Use an explicit requested source range, never infer it after an edit.
PostToolUse runs after mutation. Searching old_string could locate a different,
unchanged occurrence and falsely attribute that line to the observed edit.
"""
offset = args.get('offset')
limit = args.get('limit')
if isinstance(offset, int) and not isinstance(offset, bool) and offset > 0:
if isinstance(limit, int) and not isinstance(limit, bool) and limit > 0:
return [offset, offset + limit - 1]
return [offset, offset]
return None
def daemon_origin(value):
parsed = urllib.parse.urlsplit(value)
if (parsed.scheme != 'http' or parsed.hostname not in ('127.0.0.1', 'localhost')
or parsed.username or parsed.password or parsed.path not in ('', '/')
or parsed.query or parsed.fragment):
raise ValueError('The daemon URL must be a loopback HTTP origin.')
return value.rstrip('/')
def install_claude(root, project, origin):
"""Explicit, project-local install; preserve unrelated settings and hooks.
No package downloads or network requests. Refuse symlinks, malformed settings,
and conflicting older installations rather than replace user configuration.
"""
root = pathlib.Path(root).resolve(strict=True)
if not root.is_dir() or not project or len(project.encode()) > 255:
raise ValueError('Choose the indexed repository root and exact project name.')
origin = daemon_origin(origin)
directory = root / '.claude'
hook_dir = directory / 'hooks'
settings = directory / 'settings.local.json'
target = hook_dir / 'cbm-atlas-trace.py'
if any(path.is_symlink() for path in (directory, hook_dir, settings, target)):
raise ValueError('Refusing symlinked hook or settings paths.')
original = settings.read_bytes() if settings.exists() else None
config = json.loads(original) if original is not None else {}
if not isinstance(config, dict):
raise ValueError('Existing settings must be a JSON object.')
hooks = config.setdefault('hooks', {})
if not isinstance(hooks, dict):
raise ValueError('Existing hooks must be an object.')
entries = hooks.setdefault('PostToolUse', [])
if not isinstance(entries, list):
raise ValueError('Existing PostToolUse hooks must be an array.')
command = ('ATLAS_PROJECT=' + shlex.quote(project) + ' ATLAS_AGENT_NAME=' + shlex.quote('Claude Code')
+ ' ATLAS_DAEMON_URL=' + shlex.quote(origin)
+ ' python3 ' + shlex.quote(str(target)))
proposed = {'matcher': '', 'hooks': [{'type': 'command', 'command': command, 'timeout': 3}]}
installed = proposed in entries
for entry in entries:
for handler in entry.get('hooks', []) if isinstance(entry, dict) else []:
if not isinstance(handler, dict):
continue
try:
words = shlex.split(str(handler.get('command', '')))
except ValueError:
words = []
references_target = any(word in (str(target), '.claude/hooks/cbm-atlas-trace.py',
'./.claude/hooks/cbm-atlas-trace.py') for word in words)
if references_target and entry != proposed:
raise ValueError('An existing Atlas hook differs. Review its matcher, command and timeout before changing it.')
source = pathlib.Path(__file__).read_bytes()
if target.exists() and target.read_bytes() != source:
raise ValueError('An existing Atlas hook file differs. Review it before replacing it.')
if not installed:
entries.append(proposed)
encoded = (json.dumps(config, indent=2, ensure_ascii=False) + '\n').encode()
# A changed file is never overwritten after our read. Directory operations
# happen only after the complete, reviewable configuration was validated.
directory.mkdir(mode=0o700, exist_ok=True)
hook_dir.mkdir(mode=0o700, exist_ok=True)
if (settings.read_bytes() if settings.exists() else None) != original:
raise ValueError('Settings changed during setup; retry after reviewing them.')
created_hook = not target.exists()
if created_hook:
with target.open('xb') as handle:
handle.write(source)
target.chmod(0o600)
if not installed:
descriptor, temporary = tempfile.mkstemp(prefix='.cbm-atlas-settings-', dir=directory)
try:
with os.fdopen(descriptor, 'wb') as handle:
handle.write(encoded)
if (settings.read_bytes() if settings.exists() else None) != original:
raise ValueError('Settings changed during setup; nothing was replaced.')
os.replace(temporary, settings)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
return {'installed': True, 'changed': not installed or created_hook,
'settings': str(settings), 'hook': str(target), 'project': project,
'daemon': origin, 'next': 'Start a new Claude Code session in this repository, perform a tool call, then load activity.'}
def setup_main():
parser = argparse.ArgumentParser(description='Install the local Code Atlas Claude Code tool hook.')
parser.add_argument('--install-claude', action='store_true', required=True)
parser.add_argument('--root', required=True, help='Exact indexed repository root')
parser.add_argument('--project', required=True, help='Exact indexed project name')
parser.add_argument('--daemon-url', default='http://127.0.0.1:9749')
args = parser.parse_args()
try:
result = install_claude(args.root, args.project, args.daemon_url)
except Exception as error:
print('Atlas setup: ' + str(error), file=sys.stderr)
return 1
print(json.dumps(result, ensure_ascii=False))
return 0
def main():
if '--flush' in sys.argv:
try:
with open_outbox() as db:
flush(db)
except Exception:
pass
return 0
try:
payload = json.load(sys.stdin)
except Exception:
return 0
try:
path = trace_file()
directory = os.path.dirname(path)
if directory:
os.makedirs(directory, exist_ok=True)
tool = payload.get('tool_name') or ''
args = payload.get('tool_input') or {}
if not isinstance(args, dict):
args = {}
file_path = args.get('file_path') or args.get('notebook_path') or ''
run = str(payload.get('session_id') or uuid.uuid4())
root = str(payload.get('cwd') or os.getcwd())
project = os.environ.get('ATLAS_PROJECT', os.path.basename(root.rstrip(os.sep)))
row = {
'ts': int(time.time() * 1000),
'agent': os.environ.get('ATLAS_AGENT_NAME', 'agent'),
'run': run,
'seq': 0,
'phase': 'end',
'tool': tool,
'path': str(file_path or args.get('path') or ''),
# Commands, patterns and descriptions may contain credentials.
'detail': '',
'source': 'tool-hook',
}
span = line_span(file_path, args) if tool == 'Read' else None
if span is not None:
row['lines'] = span
if os.path.isabs(row['path']):
row['path'] = os.path.relpath(row['path'], root)
with open_outbox() as db:
enqueue(db, project, row)
flush(db)
except Exception:
pass
return 0
if __name__ == '__main__':
sys.exit(setup_main() if '--install-claude' in sys.argv else main())
+218
View File
@@ -0,0 +1,218 @@
"""Reproducible local producer tests; no external network or agent accounts."""
import concurrent.futures
import importlib.util
import io
import json
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location('atlas_trace', Path(__file__).with_name('atlas-trace.py'))
hook = importlib.util.module_from_spec(spec)
spec.loader.exec_module(hook)
class TraceTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.environment = patch.dict(os.environ, {
'ATLAS_TRACE_FILE': os.path.join(self.directory.name, 'events'),
'ATLAS_DAEMON_URL': 'http://127.0.0.1:9749',
})
self.environment.start()
def tearDown(self):
self.environment.stop()
self.directory.cleanup()
def record(self, number):
with hook.open_outbox() as db:
hook.enqueue(db, 'fixture', {'run': 'concurrent-run', 'seq': 0, 'ts': number,
'agent': 'fixture', 'tool': 'Read', 'phase': 'end'})
def assert_private_tool_input_is_omitted(self, tool, field, value, private_marker):
root = Path(self.directory.name)
source = root / 'src' / 'example.py'
payload = {'tool_name': tool, 'session_id': 'privacy-run', 'cwd': str(root),
'tool_input': {field: value, 'file_path': str(source)}}
generated = []
original_enqueue = hook.enqueue
def capture_enqueue(db, project, row):
generated.append(json.loads(json.dumps(row)))
original_enqueue(db, project, row)
with patch.dict(os.environ, {'ATLAS_PROJECT': 'fixture', 'ATLAS_AGENT_NAME': 'fixture'}), \
patch.object(hook.sys, 'argv', ['atlas-trace.py']), \
patch.object(hook.sys, 'stdin', io.StringIO(json.dumps(payload))), \
patch.object(hook.time, 'time', return_value=1234.5), \
patch.object(hook, 'enqueue', side_effect=capture_enqueue), \
patch.object(hook.urllib.request, 'build_opener') as transport:
transport.return_value.open.side_effect = OSError('offline fixture')
self.assertEqual(hook.main(), 0)
transport.return_value.open.assert_called_once()
request = transport.return_value.open.call_args.args[0]
with hook.open_outbox() as db:
pending = db.execute('SELECT project,payload FROM pending').fetchall()
self.assertEqual(len(generated), 1)
self.assertEqual(len(pending), 1)
self.assertEqual(pending[0][0], 'fixture')
outbound = json.loads(request.data)
self.assertEqual(outbound['project'], 'fixture')
self.assertEqual(len(outbound['events']), 1)
for surface, event, serialized in (
('generated', generated[0], json.dumps(generated[0])),
('persisted', json.loads(pending[0][1]), pending[0][1]),
('outgoing', outbound['events'][0], request.data.decode())):
with self.subTest(surface=surface):
self.assertEqual(event['tool'], tool)
self.assertEqual(event['path'], os.path.join('src', 'example.py'))
self.assertEqual(event['agent'], 'fixture')
self.assertEqual(event['run'], 'privacy-run')
self.assertEqual(event['phase'], 'end')
self.assertEqual(event['source'], 'tool-hook')
self.assertEqual(event['ts'], 1234500)
self.assertNotIn(private_marker, serialized)
self.assertNotIn(value, serialized)
def test_command_private_values_never_enter_event_payloads(self):
marker = 'SYNTHETIC_COMMAND_PRIVATE_VALUE_71E5'
self.assert_private_tool_input_is_omitted(
'Bash', 'command', 'AUTH_TOKEN=' + marker + ' example-tool --quiet', marker)
def test_search_patterns_never_enter_event_payloads(self):
marker = 'SYNTHETIC_PATTERN_PRIVATE_VALUE_83A6'
self.assert_private_tool_input_is_omitted('Grep', 'pattern', marker, marker)
def test_description_private_values_never_enter_event_payloads(self):
marker = 'SYNTHETIC_DESCRIPTION_PRIVATE_VALUE_92B7'
self.assert_private_tool_input_is_omitted(
'Bash', 'description', 'Inspect the value ' + marker, marker)
def test_concurrent_hooks_allocate_unique_sequences_and_keep_pending_after_failure(self):
with hook.open_outbox():
pass
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(self.record, range(20)))
with hook.open_outbox() as db:
rows = db.execute('SELECT payload FROM pending').fetchall()
self.assertEqual(sorted(json.loads(row[0])['seq'] for row in rows), list(range(1, 21)))
with patch.object(hook.urllib.request, 'build_opener', side_effect=OSError('offline')):
with self.assertRaises(OSError):
hook.flush(db)
self.assertEqual(db.execute('SELECT count(*) FROM pending').fetchone()[0], 20)
def test_external_destinations_and_credentials_are_never_contacted(self):
self.record(1)
for origin in ('https://example.com', 'http://example.com',
'http://127.0.0.1.evil.test', 'http://secret@localhost:9749',
'http://localhost:9749/redirect', 'http://localhost:9749?secret=1'):
with patch.dict(os.environ, {'ATLAS_DAEMON_URL': origin}), \
patch.object(hook.urllib.request, 'build_opener') as network, \
hook.open_outbox() as db:
hook.flush(db)
network.assert_not_called()
self.assertEqual(db.execute('SELECT count(*) FROM pending').fetchone()[0], 1)
def test_explicit_setup_preserves_settings_is_idempotent_and_escapes_names(self):
root = Path(self.directory.name) / "repo with 'quote'"
(root / '.claude').mkdir(parents=True)
settings = root / '.claude/settings.local.json'
original = {'permissions': {'allow': ['Read']}, 'hooks': {'PostToolUse': [
{'matcher': 'Read', 'hooks': [{'type': 'command', 'command': 'existing-hook'}]}]}}
settings.write_text(json.dumps(original))
with patch.object(hook.urllib.request, 'build_opener') as network:
result = hook.install_claude(root, "project'; echo bad", 'http://127.0.0.1:9749')
again = hook.install_claude(root, "project'; echo bad", 'http://127.0.0.1:9749')
network.assert_not_called()
self.assertTrue(result['changed'])
self.assertFalse(again['changed'])
installed = json.loads(settings.read_text())
self.assertEqual(installed['permissions'], original['permissions'])
self.assertEqual(installed['hooks']['PostToolUse'][0], original['hooks']['PostToolUse'][0])
self.assertEqual(len(installed['hooks']['PostToolUse']), 2)
command = installed['hooks']['PostToolUse'][1]['hooks'][0]['command']
self.assertEqual(hook.shlex.split(command)[0], "ATLAS_PROJECT=project'; echo bad")
self.assertEqual(hook.shlex.split(command)[-1], str(root.resolve() / '.claude/hooks/cbm-atlas-trace.py'))
before = settings.read_bytes()
with self.assertRaises(ValueError):
hook.install_claude(root, 'another-project', 'http://127.0.0.1:9749')
self.assertEqual(settings.read_bytes(), before)
def test_setup_refuses_symlinks_malformed_settings_and_external_daemons_before_writes(self):
root = Path(self.directory.name) / 'repo'
root.mkdir()
with self.assertRaises(ValueError):
hook.install_claude(root, 'fixture', 'https://example.com')
self.assertFalse((root / '.claude').exists())
(root / '.claude').mkdir()
settings = root / '.claude/settings.local.json'
settings.write_text('malformed user data')
with self.assertRaises(ValueError):
hook.install_claude(root, 'fixture', 'http://127.0.0.1:9749')
self.assertEqual(settings.read_text(), 'malformed user data')
settings.unlink()
target = root / 'external-settings.json'
target.write_text('{}')
settings.symlink_to(target)
with self.assertRaises(ValueError):
hook.install_claude(root, 'fixture', 'http://127.0.0.1:9749')
self.assertEqual(target.read_text(), '{}')
self.assertFalse((root / '.claude/hooks').exists())
def test_setup_refuses_a_second_execution_with_a_different_matcher_or_timeout(self):
root = Path(self.directory.name) / 'TEST fixture'
root.mkdir()
hook.install_claude(root, 'fixture', 'http://127.0.0.1:9749')
settings = root / '.claude/settings.local.json'
pristine = json.loads(settings.read_text())
for change in ('matcher', 'timeout', 'relative-command'):
config = json.loads(json.dumps(pristine))
entry = config['hooks']['PostToolUse'][0]
if change == 'matcher':
entry['matcher'] = 'Read'
elif change == 'timeout':
entry['hooks'][0]['timeout'] = 5
else:
entry['hooks'][0]['command'] = 'python3 .claude/hooks/cbm-atlas-trace.py'
settings.write_text(json.dumps(config))
before = settings.read_bytes()
with self.assertRaisesRegex(ValueError, 'existing Atlas hook differs'):
hook.install_claude(root, 'fixture', 'http://127.0.0.1:9749')
self.assertEqual(settings.read_bytes(), before)
self.assertEqual(len(json.loads(settings.read_text())['hooks']['PostToolUse']), 1)
def test_post_edit_range_is_unknown_instead_of_an_unmodified_duplicate_occurrence(self):
source = Path(self.directory.name) / 'edited.txt'
source.write_text('new value\nold value\n')
with patch('builtins.open', side_effect=AssertionError('Metadata hook must not read source')):
self.assertIsNone(hook.line_span(str(source), {'old_string': 'old value', 'new_string': 'new value'}))
self.assertEqual(hook.line_span(str(source), {'offset': 8, 'limit': 3}), [8, 10])
self.assertIsNone(hook.line_span(str(source), {'offset': True}))
def test_acknowledged_duplicate_retry_removes_only_acknowledged_rows(self):
self.record(1)
self.record(2)
class Response:
status = 200
def __enter__(self): return self
def __exit__(self, *args): return None
def read(self): return b'{"accepted":0,"duplicates":2}'
class Opener:
def open(self, request, timeout):
self_body = json.loads(request.data)
assert request.full_url == 'http://127.0.0.1:9749/api/agent-events'
assert self_body['project'] == 'fixture'
assert len(self_body['events']) == 2
return Response()
with patch.object(hook.urllib.request, 'build_opener', return_value=Opener()), \
hook.open_outbox() as db:
hook.flush(db)
self.assertEqual(db.execute('SELECT count(*) FROM pending').fetchone()[0], 0)
if __name__ == '__main__':
unittest.main()
-1
View File
@@ -1 +0,0 @@
{"$schema":"https://ui.shadcn.com/schema.json","style":"new-york","rsc":false,"tsx":true,"tailwind":{"config":"","css":"src/styles/globals.css","baseColor":"neutral","cssVariables":true},"aliases":{"components":"@/components","utils":"@/lib/utils","ui":"@/components/ui","lib":"@/lib","hooks":"@/hooks"},"iconLibrary":"lucide"}
+70
View File
@@ -0,0 +1,70 @@
# ADR 0001: Optional local inference and model evidence
## Decision
CBM Lens offers optional browser inference. Weights are downloaded only after the
user opts in; they are not embedded in the executable. Model selection remains a
user choice. Browser model IDs, fixed revisions, allowed files and licenses live in
`src/browser-ai/model-policy.ts`; [THIRD_PARTY.md](../../THIRD_PARTY.md) documents them.
The older GGUF sidecar catalogue remains supported by local scripts and its recorded
evaluation. It is separate from the browser ONNX catalogue. Its measurements do not
predict browser performance, another machine's performance or a model's general rank.
## Sidecar model sources
The six Q4_K_M candidates below were recorded in August 2026. Class A used a 3072-token
context and class B used 8192 tokens. Exact download sizes refer to those evaluated
files, not runtime memory. Checksums are retained in `models/SHA256SUMS`.
| Model | Class | Download source | Recorded size | License recorded for the base model |
| --- | --- | --- | --- | --- |
| Qwen3.5-2B | A | [unsloth/Qwen3.5-2B-GGUF](https://huggingface.co/unsloth/Qwen3.5-2B-GGUF) | 1280835840 Bytes | Apache 2.0 |
| LFM2.5-1.2B-Instruct | A | [LiquidAI/LFM2.5-1.2B-Instruct-GGUF](https://huggingface.co/LiquidAI/LFM2.5-1.2B-Instruct-GGUF) | 730895168 Bytes | LFM Open License v1.0; commercial terms apply at $10M annual revenue |
| MiniCPM5-1B | A | [openbmb/MiniCPM5-1B-GGUF](https://huggingface.co/openbmb/MiniCPM5-1B-GGUF) | 688065920 Bytes | Apache 2.0 |
| Qwen2.5-Coder-1.5B-Instruct | A | [Qwen/Qwen2.5-Coder-1.5B-Instruct-GGUF](https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct-GGUF) | 1117320768 Bytes | Apache 2.0 |
| Qwen3.5-4B | B | [unsloth/Qwen3.5-4B-MTP-GGUF](https://huggingface.co/unsloth/Qwen3.5-4B-MTP-GGUF) | 2834975040 Bytes | Apache 2.0 |
| Gemma 4 E4B-it | B | [unsloth/gemma-4-E4B-it-GGUF](https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF) | 4977171584 Bytes | Apache 2.0 |
Review current model-card terms before downloading or redistribution. The recorded
LFM license allows free commercial use below $10M annual revenue; use beyond that
threshold requires separate consideration. No evaluation result overrides licensing.
## Evaluation limits
`verification/w5/eval.json` records 44 fixture questions, including 23 in German,
with temperature 0 and seed 42. The figures below summarize that saved run. Citation
compliance measures the evaluation's citation rule, not factual correctness.
| Model | Class | Pass rate | Citation compliance | Mean tokens/s, rounded |
| --- | --- | --- | --- | --- |
| Qwen3.5-2B | A | 0.682 | 0.932 | 86 |
| LFM2.5-1.2B | A | 0.295 | 0.432 | 110 |
| MiniCPM5-1B | A | 0.250 | 0.545 | 171 |
| Qwen2.5-Coder-1.5B | A | 0.227 | 0.705 | 84 |
| Qwen3.5-4B | B | 0.818 | 0.955 | 38 |
| gemma-4-E4B | B | 0.841 | 1.000 | 32 |
The recorded class recommendations were Qwen3.5-2B and gemma-4-E4B, with pass-rate
and citation thresholds of 0.6 and 0.9. They are recommendations, not installed
defaults. A reported repeat run has no separate saved artifact and is not independent
reproduction evidence. The original record does not measure unscorable citations;
missing measurements must not be displayed as zero.
## Engine boundary
The sidecar evaluation used llama.cpp on Apple Silicon with Metal and GGUF files.
Its source/build record is in [runtime provenance](../../vendor/llama/HERKUNFT.md).
`llm/start.sh` also supports a local model cache and OpenAI-compatible transport;
the class-specific script options reproduce the recorded evaluation.
Browser inference uses a different runtime and format. Compare actual device and
workload behavior before drawing performance conclusions, especially prompt prefill,
GPU memory limits and large-file handling. Relevant engine sources retained from
the original investigation are [llama.cpp](https://github.com/ggml-org/llama.cpp),
[wllama](https://github.com/ngxson/wllama), [WebLLM](https://webllm.mlc.ai/docs/),
[Transformers.js](https://github.com/huggingface/transformers.js),
[WebGPU limits](https://developer.mozilla.org/en-US/docs/Web/API/GPUSupportedLimits),
and the [ggml WebGPU study](https://arxiv.org/html/2605.20706v1).
These references explain the formats and tradeoffs; the sidecar timings above are
not browser benchmarks.
+357
View File
@@ -0,0 +1,357 @@
[
{
"id": "who-01-de",
"class": "who-calls",
"focusQn": "src.services.userService.createUser",
"question": "Wer ruft createUser auf?",
"expected": ["registerUserRoutes", "create"],
"depth": 1
},
{
"id": "who-02-en",
"class": "who-calls",
"focusQn": "src.services.userService.listUsers",
"question": "Who calls listUsers?",
"expected": ["registerUserRoutes", "createUser"],
"depth": 1
},
{
"id": "who-03-de",
"class": "who-calls",
"focusQn": "src.util.validate.validateUser",
"question": "Wer ruft validateUser?",
"expected": ["createUser"],
"depth": 1
},
{
"id": "who-04-en",
"class": "who-calls",
"focusQn": "src.repo.db.insert",
"question": "Who calls insert?",
"expected": ["createUser"],
"depth": 1
},
{
"id": "who-05-de",
"class": "who-calls",
"focusQn": "src.services.orderService.getOrder",
"question": "Wer ruft getOrder?",
"expected": ["registerOrderRoutes"],
"depth": 1
},
{
"id": "who-06-en",
"class": "who-calls",
"focusQn": "src.repo.db.query",
"question": "Who calls query?",
"expected": ["listUsers"],
"depth": 1
},
{
"id": "who-07-de",
"class": "who-calls",
"focusQn": "src.routes.users.registerUserRoutes",
"question": "Wer ruft registerUserRoutes?",
"expected": ["createApp"],
"depth": 1
},
{
"id": "who-08-en",
"class": "who-calls",
"focusQn": "src.config.loadConfig",
"question": "Who calls loadConfig?",
"expected": ["main"],
"depth": 1
},
{
"id": "what-01-de",
"class": "what-is",
"focusQn": "src.services.userService.createUser",
"question": "Was macht createUser?",
"expected": ["validateUser", "insert"],
"depth": 1
},
{
"id": "what-02-en",
"class": "what-is",
"focusQn": "src.services.userService.listUsers",
"question": "What does listUsers do?",
"expected": ["query"],
"depth": 1
},
{
"id": "what-03-de",
"class": "what-is",
"focusQn": "src.services.orderService.getOrder",
"question": "Was macht getOrder?",
"expected": ["validateId", "query"],
"depth": 1
},
{
"id": "what-04-en",
"class": "what-is",
"focusQn": "src.repo.db.hotspotScan",
"question": "What does hotspotScan do?",
"expected": ["query"],
"depth": 1
},
{
"id": "what-05-de",
"class": "what-is",
"focusQn": "src.server.main",
"question": "Was macht main?",
"expected": ["loadConfig", "createApp"],
"depth": 1
},
{
"id": "what-06-en",
"class": "what-is",
"focusQn": "src.server.createApp",
"question": "What does createApp do?",
"expected": ["registerUserRoutes"],
"depth": 1
},
{
"id": "what-07-de",
"class": "what-is",
"focusQn": "src.config.loadConfig",
"question": "Was macht loadConfig?",
"expected": ["loadConfig"],
"depth": 0
},
{
"id": "what-08-en",
"class": "what-is",
"focusQn": "src.util.validate.ValidationError",
"question": "What is ValidationError?",
"expected": ["validate.ts"],
"depth": 0
},
{
"id": "what-09-de",
"class": "what-is",
"focusQn": "src.services.userService.create",
"question": "Was tut `userService.create`?",
"expected": ["createUser"],
"depth": 1
},
{
"id": "what-10-en",
"class": "what-is",
"focusQn": "src.routes.orders.registerOrderRoutes",
"question": "What does registerOrderRoutes do?",
"expected": ["getOrder"],
"depth": 1
},
{
"id": "err-01-de",
"class": "why-error",
"focusQn": "src.services.userService.createUser",
"question": "Welche Fehler kann createUser werfen?",
"expected": ["ValidationError"],
"depth": 1
},
{
"id": "err-02-en",
"class": "why-error",
"focusQn": "src.util.validate.validateUser",
"question": "What errors can validateUser raise?",
"expected": ["ValidationError"],
"depth": 1
},
{
"id": "err-03-de",
"class": "why-error",
"focusQn": "src.util.validate.validateId",
"question": "Welche Ausnahme wirft validateId?",
"expected": ["ValidationError"],
"depth": 1
},
{
"id": "err-04-en",
"class": "why-error",
"focusQn": "src.services.orderService.getOrder",
"question": "What errors can getOrder raise?",
"expected": ["ValidationError"],
"depth": 1
},
{
"id": "err-05-de",
"class": "why-error",
"focusQn": "src.services.userService.createUser",
"question": "Warum schlaegt createUser fehl, wenn DB_URL nicht gesetzt ist?",
"expected": ["ValidationError", "DB_URL"],
"depth": 1
},
{
"id": "err-06-en",
"class": "why-error",
"focusQn": "src.repo.db.insert",
"question": "What errors can insert raise?",
"expected": ["insert"],
"forbidden": ["ValidationError"],
"depth": 1
},
{
"id": "if-01-de",
"class": "what-if",
"focusQn": "src.services.userService.createUser",
"question": "Was passiert, wenn ich createUser aendere?",
"expected": ["registerUserRoutes", "create"],
"depth": 1
},
{
"id": "if-02-en",
"class": "what-if",
"focusQn": "src.services.userService.listUsers",
"question": "What happens if I change listUsers?",
"expected": ["registerUserRoutes"],
"depth": 1
},
{
"id": "if-03-de",
"class": "what-if",
"focusQn": "src.repo.db.query",
"question": "Welche Auswirkung haette eine Aenderung an query?",
"expected": ["listUsers"],
"depth": 1
},
{
"id": "if-04-en",
"class": "what-if",
"focusQn": "src.util.validate.validateUser",
"question": "What is the blast radius of validateUser?",
"expected": ["createUser"],
"depth": 1
},
{
"id": "if-05-de",
"class": "what-if",
"focusQn": "src.repo.db.insert",
"question": "Was passiert, wenn ich insert aendere?",
"expected": ["createUser"],
"depth": 2
},
{
"id": "if-06-en",
"class": "what-if",
"focusQn": "src.routes.users.registerUserRoutes",
"question": "What happens if registerUserRoutes changes?",
"expected": ["createApp"],
"depth": 1
},
{
"id": "entry-01-de",
"class": "where-entry",
"focusQn": "src.server.main",
"question": "Welche Routen gibt es in diesem Projekt?",
"expected": ["/users"],
"depth": 1
},
{
"id": "entry-02-en",
"class": "where-entry",
"focusQn": "src.server.main",
"question": "Which HTTP routes does this project expose?",
"expected": ["/users", "/orders"],
"depth": 1
},
{
"id": "entry-03-de",
"class": "where-entry",
"focusQn": "src.server.main",
"question": "Wo faengt dieses Projekt an?",
"expected": ["src/server.ts"],
"depth": 1
},
{
"id": "entry-04-en",
"class": "where-entry",
"focusQn": "src.server.main",
"question": "Where do I start reading this project?",
"expected": ["src/server.ts"],
"depth": 1
},
{
"id": "entry-05-de",
"class": "where-entry",
"focusQn": "src.services.userService.createUser",
"question": "Welche Route fuehrt zu createUser?",
"expected": ["/users"],
"depth": 1
},
{
"id": "cmp-01-de",
"class": "compare",
"focusQn": "src.services.userService.createUser",
"question": "Was ist der Unterschied zwischen createUser und listUsers?",
"expected": ["validateUser", "query"],
"depth": 1
},
{
"id": "cmp-02-en",
"class": "compare",
"focusQn": "src.services.orderService.getOrder",
"question": "What is the difference between getOrder and createUser?",
"expected": ["validateId", "validateUser"],
"depth": 1
},
{
"id": "cmp-03-de",
"class": "compare",
"focusQn": "src.services.userService.listUsers",
"question": "Vergleiche listUsers und createUser.",
"expected": ["query", "validateUser"],
"depth": 1
},
{
"id": "cmp-04-en",
"class": "compare",
"focusQn": "src.repo.db.hotspotScan",
"question": "What is the difference between hotspotScan and walk?",
"expected": ["query"],
"depth": 1
},
{
"id": "over-01-de",
"class": "overview",
"focusQn": "src.server.main",
"question": "Gib mir einen Ueberblick ueber dieses Projekt.",
"expected": ["TypeScript"],
"depth": 1
},
{
"id": "over-02-en",
"class": "overview",
"focusQn": "src.server.main",
"question": "What is this project made of?",
"expected": ["TypeScript"],
"depth": 1
},
{
"id": "over-03-de",
"class": "overview",
"focusQn": "src.server.main",
"question": "Wie ist die Architektur dieses Projekts aufgebaut?",
"expected": ["symbol"],
"depth": 1
},
{
"id": "honest-01-de",
"class": "other",
"focusQn": "src.services.userService.createUser",
"question": "Hallo, alles klar bei dir?",
"expected": ["no card"],
"forbidden": ["createUser"],
"depth": 1
},
{
"id": "honest-02-en",
"class": "what-is",
"focusQn": "src.services.userService.createUser",
"question": "What is @notASymbolAnywhereInThisRepo?",
"expected": ["no card"],
"forbidden": ["createUser"],
"depth": 1
}
]
+63
View File
@@ -0,0 +1,63 @@
# Architecture decisions
> Preview examples for cbm-preview. These editable notes capture frontend design choices discussed during development; they are not a historical ADR archive or graph-inferred facts. Status: proposed for review.
## 001: Explain architecture from evidence
**Status:** Proposed · **Scope:** Architecture workspace
### Context
A repository map explains where code lives. System structure and behavior also need to explain relationships, boundaries, and execution steps. A convincing picture should remain traceable to its inputs.
### Decision
Combine indexed graph relationships with source evidence. Keep the repository map, routes, hotspots, system structure, and behavior as distinct lenses. Mark inferred relationships as inferred, and make their evidence inspectable. Do not embed project-specific architectural explanations in the visualization code.
### Consequences
- The same analysis can be applied to other indexed repositories.
- Missing evidence remains a visible limitation rather than a fabricated connection.
- A source or graph change can alter the view; these written decisions change only when someone edits them.
**Implementation references:** `graph-ui/src/architecture/`, `src/store/architecture_projection.c`.
## 002: Keep browser AI optional
**Status:** Proposed · **Scope:** Local chat
### Context
Code exploration should work before any model is downloaded. Users may want to ask about a file or a literal code selection without sending source to a hosted model.
### Decision
Run the chat model locally in the browser after explicit setup. Download weights only after opt-in. Attach the selected code or graph context to the next prompt and render answers as Markdown.
### Consequences
| Benefit | Tradeoff |
| --- | --- |
| Exploration works without model setup | Chat is unavailable until a model is enabled |
| Inference stays on the device | Model quality and speed depend on local resources |
| The prompt can use exact marked code | A bounded context cannot include an entire large repository |
**Implementation references:** `graph-ui/src/browser-ai/`, `graph-ui/src/reader/MonacoReader.tsx`.
## 003: Keep agent activity experimental
**Status:** Proposed · **Scope:** Agents workspace
### Context
The current integration records events from explicitly configured agent hooks. The UI cannot infer every agent operation merely by observing the graph.
### Decision
Hide Agents unless the experimental build flag is enabled. Treat hook events as observed activity, and avoid implying complete task execution, tool outcomes, or duration data.
### Consequences
The default navigation remains focused. Further agent adapters and a richer event contract can be introduced before promoting the view to a standard workspace.
**Implementation references:** `graph-ui/src/app/feature-flags.ts`, `graph-ui/agents/hooks/atlas-trace.py`, `src/ui/activity.c`.
+44
View File
@@ -0,0 +1,44 @@
# Architecture decisions
> Preview examples for the indexed Train Ticket repository. These are proposed review notes for exercising the ADR workspace, not decisions attributed to the upstream maintainers.
## 001: Review services as deployment boundaries
**Status:** Proposed · **Scope:** Routes review
### Context
A large service graph is difficult to understand when every source file has equal visual weight. Deployment declarations provide another useful level of organization.
### Decision
Use detected service and container boundaries to organize the routes review. Keep the declaration behind each boundary inspectable. Treat a deployment dependency and a source-level request as different kinds of evidence.
### Consequences
- The initial view can show relationships between services before drilling into handlers.
- A declared dependency alone does not prove that a request is sent.
- Services without discovered connections should remain visible.
## 002: Follow a request through its evidence
**Status:** Proposed · **Scope:** Cross-service investigation
### Context
Matching a caller to a route helps explain the system, but static analysis may not resolve dynamic hosts, route parameters, or runtime configuration.
### Decision
During review, follow a connection from its caller through the route evidence to the destination handler. Keep unresolved destinations visible as unknowns. Use source inspection to verify a suspected relationship before treating it as established.
### Review checklist
- [ ] Inspect an outgoing request in the selected service.
- [ ] Compare its path and method with the destination route.
- [ ] Check relevant configuration and service naming.
- [ ] Record unresolved runtime assumptions here.
### Consequences
This creates a repeatable review path while avoiding claims that a static graph proves runtime behavior.
+25
View File
@@ -0,0 +1,25 @@
# Architecture decisions
> Preview example for voting-services. This proposed review note is test content, not an upstream architectural decision.
## 001: Distinguish declared and observed connections
**Status:** Proposed · **Scope:** Service map review
### Context
A small connected application is useful for checking whether a service visualization makes each relationship understandable.
### Decision
Review deployment dependencies, request relationships, and datastore connections separately. Inspect the source or configuration evidence for each edge. Do not interpret an edge count as traffic volume or a static dependency as an observed runtime call.
### Consequences
The service map can explain what is connected and why without implying measurements that were never collected. Missing connections become concrete questions for further inspection.
### Next review
- [ ] Select a service and inspect its neighboring connections.
- [ ] Verify one connection against source or configuration.
- [ ] Edit this note with the outcome and save it.
@@ -0,0 +1,56 @@
# Agent-event fixture provenance
These stable replay inputs exercise activity visualization without requiring a live
coding session. They contain no source contents or tool output. They are test
fixtures, not evidence of current agent activity.
## `w11a-replay.jsonl`
Derived from a real tool-hook recording made while working on this repository on
29-30 August 2026. The private original is not committed because it contains local
paths and commands. This fixture preserves its event shape, relative tool mix,
initial timestamp (`1788038561000`) and 44 consecutive time intervals in milliseconds:
```text
10065, 13599, 115703, 5123, 18182, 6789, 1140, 13387, 5243, 5570, 6676,
21168, 51146, 6330, 6339, 28257, 4029, 318, 2709, 709, 83, 6176, 1116,
3373, 3642, 3149, 2275, 4175, 2884, 4066, 2310, 2028, 2838, 33596, 5878,
17621, 826, 5788, 11062, 6988, 5724, 10001, 17767, 13461
```
The intervals came from the first 45-event window without a pause over two minutes,
beginning at line 57 of the original. The following content was deliberately changed:
- Absolute paths became relative `fixtures/atlas-sample` paths. `package.json` is
intentionally outside the indexed graph to exercise unmapped activity.
- One original agent became the synthetic roles `implementer`, `checker` and
`explorer`; their eight-character run IDs are invented.
- Explicit `lines` ranges were added from fixture symbols. The original hook did
not record line ranges.
- Run `b7e4d19c` jumps from sequence 6 to 9 to expose two missing events.
- Two synthetic `intent` fields exercise explicitly labeled self-reports.
- `detail` values describe fixture operations instead of retaining private commands.
The result contains 45 events, three runs, four work types, one two-event sequence
gap, two self-reports and one path outside the index.
## `w11b-replay.jsonl`
Entirely constructed test data, not a session recording. Its paths and line spans
come from `fixtures/atlas-sample`; timestamps and activity are deliberately chosen.
- Eight agents plus the browser reader exercise the eight-body display limit.
- `explorer` and `planner` end 75 and 85 seconds before the last event. Both are
idle but still within retention; the oldest actor exercises the display cap.
- `implementer` writes `createUser` lines 23-36 five times in 1.8 seconds to test
aggregation into one visible wave, and visits fifteen symbols to exceed trail limits.
- `reviewer`, `indexer` and `tracer` end at `hotspotScan` in `src/repo/db.ts`, lines
39-54, to test deterministic orbit separation at one symbol.
- Activity rates differ: fifteen recent events for `implementer`, two for `packer`,
none for idle `planner`. One `package.json` path remains unmapped and one event
has a synthetic self-reported `intent`.
There are 73 events across eight runs and four work types over 145 seconds. Last
activity offsets are 0, 3.8, 4.4, 5, 7, 10, 75 and 85 seconds for `implementer`,
`tracer`, `indexer`, `reviewer`, `packer`, `checker`, `explorer` and `planner`.
The fixture does not encode progress, quality assessments or source contents.
@@ -0,0 +1,45 @@
{"ts": 1788038561000, "agent": "explorer", "run": "c3a80f52", "seq": 1, "phase": "end", "tool": "Grep", "path": "src", "detail": "validate"}
{"ts": 1788038571065, "agent": "implementer", "run": "a1f2c3d4", "seq": 1, "phase": "end", "tool": "Read", "path": "src/services/userService.ts", "detail": "read the whole module before touching it", "lines": [1, 43]}
{"ts": 1788038584664, "agent": "checker", "run": "b7e4d19c", "seq": 1, "phase": "end", "tool": "Read", "path": "package.json", "detail": "read the scripts of the fixture repository"}
{"ts": 1788038700367, "agent": "explorer", "run": "c3a80f52", "seq": 2, "phase": "end", "tool": "Grep", "path": "src", "detail": "createUser"}
{"ts": 1788038705490, "agent": "implementer", "run": "a1f2c3d4", "seq": 2, "phase": "end", "tool": "Read", "path": "src/util/validate.ts", "detail": "read validateUser", "lines": [19, 31]}
{"ts": 1788038723672, "agent": "implementer", "run": "a1f2c3d4", "seq": 3, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "detail": "replaced the validation branch of createUser", "lines": [24, 30]}
{"ts": 1788038730461, "agent": "checker", "run": "b7e4d19c", "seq": 2, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "detail": "read the whole file"}
{"ts": 1788038731601, "agent": "explorer", "run": "c3a80f52", "seq": 3, "phase": "end", "tool": "Grep", "path": "src", "detail": "query"}
{"ts": 1788038744988, "agent": "implementer", "run": "a1f2c3d4", "seq": 4, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "detail": "reflowed createUser after the edit above", "lines": [23, 36]}
{"ts": 1788038750231, "agent": "checker", "run": "b7e4d19c", "seq": 3, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
{"ts": 1788038755801, "agent": "explorer", "run": "c3a80f52", "seq": 4, "phase": "end", "tool": "Read", "path": "src/routes/users.ts", "detail": "read registerUserRoutes", "lines": [7, 18]}
{"ts": 1788038762477, "agent": "implementer", "run": "a1f2c3d4", "seq": 5, "phase": "end", "tool": "Edit", "path": "src/util/validate.ts", "detail": "validateUser now names the field it rejected", "lines": [19, 31]}
{"ts": 1788038783645, "agent": "checker", "run": "b7e4d19c", "seq": 4, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
{"ts": 1788038834791, "agent": "explorer", "run": "c3a80f52", "seq": 5, "phase": "end", "tool": "Grep", "path": "src", "detail": "Order"}
{"ts": 1788038841121, "agent": "implementer", "run": "a1f2c3d4", "seq": 6, "phase": "end", "tool": "Read", "path": "src/services/orderService.ts", "detail": "read getOrder", "lines": [8, 26]}
{"ts": 1788038847460, "agent": "checker", "run": "b7e4d19c", "seq": 5, "phase": "end", "tool": "Read", "path": "test/userService.test.ts", "detail": "read the test file itself"}
{"ts": 1788038875717, "agent": "implementer", "run": "a1f2c3d4", "seq": 7, "phase": "end", "tool": "Edit", "path": "src/services/orderService.ts", "detail": "create now carries the customer id it was given", "lines": [30, 35]}
{"ts": 1788038879746, "agent": "explorer", "run": "c3a80f52", "seq": 6, "phase": "end", "tool": "Grep", "path": "src", "detail": "insert"}
{"ts": 1788038880064, "agent": "checker", "run": "b7e4d19c", "seq": 6, "phase": "end", "tool": "Bash", "path": "", "detail": "node --test tests/scaffold/"}
{"ts": 1788038882773, "agent": "implementer", "run": "a1f2c3d4", "seq": 8, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "detail": "read insert", "lines": [31, 35]}
{"ts": 1788038883482, "agent": "explorer", "run": "c3a80f52", "seq": 7, "phase": "end", "tool": "Read", "path": "src/config.ts", "detail": "read loadConfig", "lines": [11, 18]}
{"ts": 1788038883565, "agent": "implementer", "run": "a1f2c3d4", "seq": 9, "phase": "end", "tool": "Edit", "path": "src/repo/db.ts", "detail": "insert writes the payload it was handed", "lines": [31, 35], "intent": "making insert keep the payload instead of dropping it"}
{"ts": 1788038889741, "agent": "checker", "run": "b7e4d19c", "seq": 9, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
{"ts": 1788038890857, "agent": "explorer", "run": "c3a80f52", "seq": 8, "phase": "end", "tool": "Grep", "path": "src", "detail": "walk"}
{"ts": 1788038894230, "agent": "implementer", "run": "a1f2c3d4", "seq": 10, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "detail": "read hotspotScan", "lines": [39, 54]}
{"ts": 1788038897872, "agent": "checker", "run": "b7e4d19c", "seq": 10, "phase": "end", "tool": "Read", "path": "src/server.ts", "detail": "read createApp", "lines": [32, 37]}
{"ts": 1788038901021, "agent": "implementer", "run": "a1f2c3d4", "seq": 11, "phase": "end", "tool": "Edit", "path": "src/repo/db.ts", "detail": "hotspotScan stops at the first needle it finds", "lines": [39, 54]}
{"ts": 1788038903296, "agent": "explorer", "run": "c3a80f52", "seq": 9, "phase": "end", "tool": "Read", "path": "src/types.ts", "detail": "read UserEntity", "lines": [37, 53]}
{"ts": 1788038907471, "agent": "checker", "run": "b7e4d19c", "seq": 11, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
{"ts": 1788038910355, "agent": "implementer", "run": "a1f2c3d4", "seq": 12, "phase": "end", "tool": "Write", "path": "src/util/validate.ts", "detail": "wrote validateId over the old body", "lines": [33, 38]}
{"ts": 1788038914421, "agent": "explorer", "run": "c3a80f52", "seq": 10, "phase": "end", "tool": "Grep", "path": "src", "detail": "Router"}
{"ts": 1788038916731, "agent": "checker", "run": "b7e4d19c", "seq": 12, "phase": "end", "tool": "Read", "path": "src/routes/orders.ts", "detail": "read the whole route module"}
{"ts": 1788038918759, "agent": "implementer", "run": "a1f2c3d4", "seq": 13, "phase": "end", "tool": "Read", "path": "src/services/userService.ts", "detail": "read listUsers", "lines": [18, 21]}
{"ts": 1788038921597, "agent": "explorer", "run": "c3a80f52", "seq": 11, "phase": "end", "tool": "Read", "path": "src/util/validate.ts", "detail": "read ValidationError", "lines": [4, 12]}
{"ts": 1788038955193, "agent": "checker", "run": "b7e4d19c", "seq": 13, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
{"ts": 1788038961071, "agent": "implementer", "run": "a1f2c3d4", "seq": 14, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "detail": "listUsers takes the limit it is given", "lines": [18, 21]}
{"ts": 1788038978692, "agent": "explorer", "run": "c3a80f52", "seq": 12, "phase": "end", "tool": "Grep", "path": "src", "detail": "loadConfig"}
{"ts": 1788038979518, "agent": "checker", "run": "b7e4d19c", "seq": 14, "phase": "end", "tool": "Read", "path": "src/config.ts", "detail": "read isProduction", "lines": [20, 22]}
{"ts": 1788038985306, "agent": "implementer", "run": "a1f2c3d4", "seq": 15, "phase": "end", "tool": "Read", "path": "src/routes/users.ts", "detail": "read registerUserRoutes before wiring it", "lines": [7, 18]}
{"ts": 1788038996368, "agent": "explorer", "run": "c3a80f52", "seq": 13, "phase": "end", "tool": "Read", "path": "src/services/orderService.ts", "detail": "read the whole order module"}
{"ts": 1788039003356, "agent": "checker", "run": "b7e4d19c", "seq": 15, "phase": "end", "tool": "Read", "path": "src/server.ts", "detail": "read main", "lines": [39, 46]}
{"ts": 1788039009080, "agent": "implementer", "run": "a1f2c3d4", "seq": 16, "phase": "end", "tool": "Edit", "path": "src/routes/users.ts", "detail": "the user routes hand the limit through", "lines": [7, 18]}
{"ts": 1788039019081, "agent": "implementer", "run": "a1f2c3d4", "seq": 17, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "detail": "tightened the input check of createUser again", "lines": [24, 30], "intent": "tightening the input check in createUser"}
{"ts": 1788039036848, "agent": "checker", "run": "b7e4d19c", "seq": 16, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
{"ts": 1788039050309, "agent": "explorer", "run": "c3a80f52", "seq": 14, "phase": "end", "tool": "Grep", "path": "src", "detail": "validate"}
@@ -0,0 +1,73 @@
{"ts": 1788139200000, "agent": "explorer", "run": "c3a80f52", "seq": 1, "phase": "end", "tool": "Grep", "path": "src", "detail": "validate"}
{"ts": 1788139201200, "agent": "implementer", "run": "a1f2c3d4", "seq": 1, "phase": "end", "tool": "Read", "path": "src/services/userService.ts", "lines": [23, 36], "detail": "read createUser before touching it"}
{"ts": 1788139202400, "agent": "planner", "run": "1b8d5f27", "seq": 1, "phase": "end", "tool": "Read", "path": "package.json", "detail": "read the scripts of the fixture repository"}
{"ts": 1788139205000, "agent": "reviewer", "run": "d5b1e807", "seq": 1, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [17, 29], "detail": "read query"}
{"ts": 1788139207000, "agent": "tracer", "run": "e9c72f16", "seq": 1, "phase": "end", "tool": "Read", "path": "src/types.ts", "lines": [37, 53], "detail": "read UserEntity"}
{"ts": 1788139209000, "agent": "indexer", "run": "f2d0a34b", "seq": 1, "phase": "end", "tool": "Glob", "path": "src", "detail": "loadConfig"}
{"ts": 1788139211000, "agent": "packer", "run": "0a4e6c81", "seq": 1, "phase": "end", "tool": "Read", "path": "src/config.ts", "lines": [11, 18], "detail": "read loadConfig"}
{"ts": 1788139213000, "agent": "packer", "run": "0a4e6c81", "seq": 2, "phase": "end", "tool": "Read", "path": "src/server.ts", "lines": [32, 37], "detail": "read createApp"}
{"ts": 1788139216000, "agent": "explorer", "run": "c3a80f52", "seq": 2, "phase": "end", "tool": "Grep", "path": "src", "detail": "createUser"}
{"ts": 1788139218000, "agent": "implementer", "run": "a1f2c3d4", "seq": 2, "phase": "end", "tool": "Read", "path": "src/util/validate.ts", "lines": [19, 31], "detail": "read validateUser"}
{"ts": 1788139221000, "agent": "checker", "run": "b7e4d19c", "seq": 1, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
{"ts": 1788139224000, "agent": "reviewer", "run": "d5b1e807", "seq": 2, "phase": "end", "tool": "Edit", "path": "src/repo/db.ts", "lines": [31, 35], "detail": "insert writes the payload it was handed"}
{"ts": 1788139227000, "agent": "tracer", "run": "e9c72f16", "seq": 2, "phase": "end", "tool": "Read", "path": "src/services/orderService.ts", "lines": [8, 26], "detail": "read getOrder"}
{"ts": 1788139230000, "agent": "indexer", "run": "f2d0a34b", "seq": 2, "phase": "end", "tool": "Glob", "path": "src", "detail": "Router"}
{"ts": 1788139233000, "agent": "packer", "run": "0a4e6c81", "seq": 3, "phase": "end", "tool": "Read", "path": "src/routes/users.ts", "lines": [7, 18], "detail": "read registerUserRoutes"}
{"ts": 1788139236000, "agent": "reviewer", "run": "d5b1e807", "seq": 3, "phase": "end", "tool": "Read", "path": "src/config.ts", "lines": [20, 22], "detail": "read isProduction"}
{"ts": 1788139239000, "agent": "explorer", "run": "c3a80f52", "seq": 3, "phase": "end", "tool": "Read", "path": "src/routes/orders.ts", "lines": [6, 15], "detail": "read registerOrderRoutes"}
{"ts": 1788139242000, "agent": "implementer", "run": "a1f2c3d4", "seq": 3, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "lines": [18, 21], "detail": "listUsers takes the limit it is given"}
{"ts": 1788139245000, "agent": "tracer", "run": "e9c72f16", "seq": 3, "phase": "end", "tool": "Read", "path": "src/server.ts", "lines": [39, 46], "detail": "read main"}
{"ts": 1788139248000, "agent": "checker", "run": "b7e4d19c", "seq": 2, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
{"ts": 1788139251000, "agent": "reviewer", "run": "d5b1e807", "seq": 4, "phase": "end", "tool": "Read", "path": "src/util/validate.ts", "lines": [33, 38], "detail": "read validateId"}
{"ts": 1788139254000, "agent": "tracer", "run": "e9c72f16", "seq": 4, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [58, 66], "detail": "read walk"}
{"ts": 1788139257000, "agent": "indexer", "run": "f2d0a34b", "seq": 3, "phase": "end", "tool": "Glob", "path": "src", "detail": "insert"}
{"ts": 1788139260000, "agent": "packer", "run": "0a4e6c81", "seq": 4, "phase": "end", "tool": "Read", "path": "src/types.ts", "lines": [27, 30], "detail": "read User"}
{"ts": 1788139260000, "agent": "planner", "run": "1b8d5f27", "seq": 2, "phase": "end", "tool": "Read", "path": "src/server.ts", "lines": [32, 37], "detail": "read createApp"}
{"ts": 1788139266000, "agent": "implementer", "run": "a1f2c3d4", "seq": 4, "phase": "end", "tool": "Edit", "path": "src/util/validate.ts", "lines": [19, 31], "detail": "validateUser now names the field it rejected"}
{"ts": 1788139269000, "agent": "checker", "run": "b7e4d19c", "seq": 3, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [4, 8], "detail": "read Row"}
{"ts": 1788139270000, "agent": "explorer", "run": "c3a80f52", "seq": 4, "phase": "end", "tool": "Grep", "path": "src", "detail": "Order"}
{"ts": 1788139272000, "agent": "reviewer", "run": "d5b1e807", "seq": 5, "phase": "end", "tool": "Read", "path": "src/services/orderService.ts", "lines": [30, 35], "detail": "read create"}
{"ts": 1788139275000, "agent": "tracer", "run": "e9c72f16", "seq": 5, "phase": "end", "tool": "Read", "path": "src/server.ts", "lines": [16, 30], "detail": "read SimpleRouter"}
{"ts": 1788139278000, "agent": "indexer", "run": "f2d0a34b", "seq": 4, "phase": "end", "tool": "Glob", "path": "src", "detail": "query"}
{"ts": 1788139281000, "agent": "checker", "run": "b7e4d19c", "seq": 4, "phase": "end", "tool": "Read", "path": "src/config.ts", "lines": [4, 9], "detail": "read AppConfig"}
{"ts": 1788139284000, "agent": "implementer", "run": "a1f2c3d4", "seq": 5, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [17, 29], "detail": "read query"}
{"ts": 1788139286000, "agent": "implementer", "run": "a1f2c3d4", "seq": 6, "phase": "end", "tool": "Read", "path": "src/services/orderService.ts", "lines": [8, 26], "detail": "read getOrder"}
{"ts": 1788139287000, "agent": "checker", "run": "b7e4d19c", "seq": 5, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
{"ts": 1788139289000, "agent": "implementer", "run": "a1f2c3d4", "seq": 7, "phase": "end", "tool": "Edit", "path": "src/services/orderService.ts", "lines": [30, 35], "detail": "create now carries the customer id it was given"}
{"ts": 1788139290000, "agent": "checker", "run": "b7e4d19c", "seq": 6, "phase": "end", "tool": "Read", "path": "src/types.ts", "lines": [22, 25], "detail": "read Entity"}
{"ts": 1788139292000, "agent": "implementer", "run": "a1f2c3d4", "seq": 8, "phase": "end", "tool": "Read", "path": "src/types.ts", "lines": [37, 53], "detail": "read UserEntity"}
{"ts": 1788139293000, "agent": "packer", "run": "0a4e6c81", "seq": 5, "phase": "end", "tool": "Read", "path": "src/services/userService.ts", "lines": [40, 42], "detail": "read create"}
{"ts": 1788139295000, "agent": "implementer", "run": "a1f2c3d4", "seq": 9, "phase": "end", "tool": "Edit", "path": "src/util/validate.ts", "lines": [33, 38], "detail": "validateId rejects the empty string by name"}
{"ts": 1788139296000, "agent": "reviewer", "run": "d5b1e807", "seq": 6, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [39, 54], "detail": "read hotspotScan"}
{"ts": 1788139298000, "agent": "implementer", "run": "a1f2c3d4", "seq": 10, "phase": "end", "tool": "Read", "path": "src/server.ts", "lines": [39, 46], "detail": "read main"}
{"ts": 1788139299000, "agent": "tracer", "run": "e9c72f16", "seq": 6, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [17, 29], "detail": "read query"}
{"ts": 1788139301000, "agent": "implementer", "run": "a1f2c3d4", "seq": 11, "phase": "end", "tool": "Edit", "path": "src/routes/users.ts", "lines": [7, 18], "detail": "the user routes hand the limit through"}
{"ts": 1788139302000, "agent": "indexer", "run": "f2d0a34b", "seq": 5, "phase": "end", "tool": "Glob", "path": "src", "detail": "hotspot"}
{"ts": 1788139304000, "agent": "implementer", "run": "a1f2c3d4", "seq": 12, "phase": "end", "tool": "Read", "path": "src/routes/orders.ts", "lines": [6, 15], "detail": "read registerOrderRoutes"}
{"ts": 1788139305000, "agent": "tracer", "run": "e9c72f16", "seq": 7, "phase": "end", "tool": "Read", "path": "src/config.ts", "lines": [11, 18], "detail": "read loadConfig"}
{"ts": 1788139307000, "agent": "implementer", "run": "a1f2c3d4", "seq": 13, "phase": "end", "tool": "Edit", "path": "src/config.ts", "lines": [11, 18], "detail": "loadConfig reads the port it was given"}
{"ts": 1788139308000, "agent": "checker", "run": "b7e4d19c", "seq": 7, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
{"ts": 1788139310000, "agent": "implementer", "run": "a1f2c3d4", "seq": 14, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [31, 35], "detail": "read insert"}
{"ts": 1788139311000, "agent": "tracer", "run": "e9c72f16", "seq": 8, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [31, 35], "detail": "read insert"}
{"ts": 1788139313000, "agent": "implementer", "run": "a1f2c3d4", "seq": 15, "phase": "end", "tool": "Edit", "path": "src/repo/db.ts", "lines": [39, 54], "detail": "hotspotScan stops at the first needle it finds"}
{"ts": 1788139314000, "agent": "indexer", "run": "f2d0a34b", "seq": 6, "phase": "end", "tool": "Glob", "path": "src", "detail": "walk"}
{"ts": 1788139316000, "agent": "implementer", "run": "a1f2c3d4", "seq": 16, "phase": "end", "tool": "Read", "path": "src/services/userService.ts", "lines": [9, 16], "detail": "read toUser"}
{"ts": 1788139317000, "agent": "indexer", "run": "f2d0a34b", "seq": 7, "phase": "end", "tool": "Read", "path": "src/util/validate.ts", "lines": [4, 12], "detail": "read ValidationError"}
{"ts": 1788139319000, "agent": "implementer", "run": "a1f2c3d4", "seq": 17, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "lines": [18, 21], "detail": "listUsers takes the limit it is given"}
{"ts": 1788139320000, "agent": "checker", "run": "b7e4d19c", "seq": 8, "phase": "end", "tool": "Read", "path": "src/types.ts", "lines": [32, 35], "detail": "read Order"}
{"ts": 1788139322000, "agent": "implementer", "run": "a1f2c3d4", "seq": 18, "phase": "end", "tool": "Read", "path": "src/util/validate.ts", "lines": [19, 31], "detail": "read validateUser"}
{"ts": 1788139323000, "agent": "reviewer", "run": "d5b1e807", "seq": 7, "phase": "end", "tool": "Edit", "path": "src/repo/db.ts", "lines": [58, 66], "detail": "walk returns the names it visited"}
{"ts": 1788139325000, "agent": "implementer", "run": "a1f2c3d4", "seq": 19, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [58, 66], "detail": "read walk"}
{"ts": 1788139326000, "agent": "tracer", "run": "e9c72f16", "seq": 9, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [4, 8], "detail": "read Row"}
{"ts": 1788139329000, "agent": "indexer", "run": "f2d0a34b", "seq": 8, "phase": "end", "tool": "Glob", "path": "src", "detail": "Row"}
{"ts": 1788139332000, "agent": "checker", "run": "b7e4d19c", "seq": 9, "phase": "end", "tool": "Grep", "path": "src", "detail": "insert"}
{"ts": 1788139335000, "agent": "checker", "run": "b7e4d19c", "seq": 10, "phase": "end", "tool": "Bash", "path": "", "detail": "npx vitest run test/userService.test.ts"}
{"ts": 1788139338000, "agent": "packer", "run": "0a4e6c81", "seq": 6, "phase": "end", "tool": "Read", "path": "src/config.ts", "lines": [20, 22], "detail": "read isProduction"}
{"ts": 1788139340000, "agent": "reviewer", "run": "d5b1e807", "seq": 8, "phase": "end", "tool": "Edit", "path": "src/repo/db.ts", "lines": [39, 54], "detail": "hotspotScan keeps the first hit"}
{"ts": 1788139340600, "agent": "indexer", "run": "f2d0a34b", "seq": 9, "phase": "end", "tool": "Glob", "path": "src/repo/db.ts", "lines": [39, 54], "detail": "hotspotScan"}
{"ts": 1788139341200, "agent": "tracer", "run": "e9c72f16", "seq": 10, "phase": "end", "tool": "Read", "path": "src/repo/db.ts", "lines": [39, 54], "detail": "read hotspotScan"}
{"ts": 1788139343200, "agent": "implementer", "run": "a1f2c3d4", "seq": 20, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "lines": [23, 36], "detail": "tightened the input check of createUser", "intent": "tightening the input check in createUser"}
{"ts": 1788139343650, "agent": "implementer", "run": "a1f2c3d4", "seq": 21, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "lines": [23, 36], "detail": "tightened the input check of createUser"}
{"ts": 1788139344100, "agent": "implementer", "run": "a1f2c3d4", "seq": 22, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "lines": [23, 36], "detail": "tightened the input check of createUser"}
{"ts": 1788139344550, "agent": "implementer", "run": "a1f2c3d4", "seq": 23, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "lines": [23, 36], "detail": "tightened the input check of createUser"}
{"ts": 1788139345000, "agent": "implementer", "run": "a1f2c3d4", "seq": 24, "phase": "end", "tool": "Edit", "path": "src/services/userService.ts", "lines": [23, 36], "detail": "tightened the input check of createUser"}
@@ -0,0 +1,10 @@
# Large-source fixture provenance
Generated for this frontend on 2026-08-28; no external code was copied.
`src/big.ts` deterministically contains 100 small exported functions and `runAll`.
`src/support.ts` and `src/index.ts` were written for the fixture.
The 717-line source exceeds the 500-line `get_code_snippet` limit and exercises
`source_clipped`. The reader must disclose missing source rather than presenting
an incomplete file as complete. This corpus is separate from `atlas-sample` to
preserve that fixture's graph counts. It is indexing input, not a runnable package.
@@ -0,0 +1,8 @@
{
"name": "atlas-sample-large",
"version": "1.0.0",
"private": true,
"description": "Cap-Mess-Fixture fuer CodeAtlas. Never built, installed or executed by the monorepo.",
"main": "src/index.ts",
"license": "UNLICENSED"
}
@@ -0,0 +1,717 @@
// Generierte Datei fuer die Cap-Messung des Readers (2026-08-28).
// Deterministisch erzeugt: 100 kleine exportierte Funktionen, damit der
// Indexer sie sauber frisst und die Datei sicher ueber 550 Zeilen liegt.
// Der 500-Zeilen-Cap von get_code_snippet schneidet sie also mitten durch,
// und genau das soll der Reader ehrlich benennen statt zu verschweigen.
//
// Von Hand aendern hat keinen Sinn: die Datei ist Messmaterial, kein Code,
// der gelesen werden will.
import { normalize, weigh } from './support';
/** Schritt 1 des generierten Korpus. */
export function step001(input: number): number {
const scaled = input * 1 + 7;
return normalize(scaled);
}
/** Schritt 2 des generierten Korpus. */
export function step002(input: number): number {
const scaled = input * 2 + 1;
return weigh(scaled);
}
/** Schritt 3 des generierten Korpus. */
export function step003(input: number): number {
const scaled = input * 3 + 8;
return normalize(scaled);
}
/** Schritt 4 des generierten Korpus. */
export function step004(input: number): number {
const scaled = input * 4 + 2;
return weigh(scaled);
}
/** Schritt 5 des generierten Korpus. */
export function step005(input: number): number {
const scaled = input * 5 + 9;
return normalize(scaled);
}
/** Schritt 6 des generierten Korpus. */
export function step006(input: number): number {
const scaled = input * 6 + 3;
return weigh(scaled);
}
/** Schritt 7 des generierten Korpus. */
export function step007(input: number): number {
const scaled = input * 7 + 10;
return normalize(scaled);
}
/** Schritt 8 des generierten Korpus. */
export function step008(input: number): number {
const scaled = input * 8 + 4;
return weigh(scaled);
}
/** Schritt 9 des generierten Korpus. */
export function step009(input: number): number {
const scaled = input * 9 + 11;
return normalize(scaled);
}
/** Schritt 10 des generierten Korpus. */
export function step010(input: number): number {
const scaled = input * 10 + 5;
return weigh(scaled);
}
/** Schritt 11 des generierten Korpus. */
export function step011(input: number): number {
const scaled = input * 11 + 12;
return normalize(scaled);
}
/** Schritt 12 des generierten Korpus. */
export function step012(input: number): number {
const scaled = input * 12 + 6;
return weigh(scaled);
}
/** Schritt 13 des generierten Korpus. */
export function step013(input: number): number {
const scaled = input * 13 + 0;
return normalize(scaled);
}
/** Schritt 14 des generierten Korpus. */
export function step014(input: number): number {
const scaled = input * 14 + 7;
return weigh(scaled);
}
/** Schritt 15 des generierten Korpus. */
export function step015(input: number): number {
const scaled = input * 15 + 1;
return normalize(scaled);
}
/** Schritt 16 des generierten Korpus. */
export function step016(input: number): number {
const scaled = input * 16 + 8;
return weigh(scaled);
}
/** Schritt 17 des generierten Korpus. */
export function step017(input: number): number {
const scaled = input * 17 + 2;
return normalize(scaled);
}
/** Schritt 18 des generierten Korpus. */
export function step018(input: number): number {
const scaled = input * 18 + 9;
return weigh(scaled);
}
/** Schritt 19 des generierten Korpus. */
export function step019(input: number): number {
const scaled = input * 19 + 3;
return normalize(scaled);
}
/** Schritt 20 des generierten Korpus. */
export function step020(input: number): number {
const scaled = input * 20 + 10;
return weigh(scaled);
}
/** Schritt 21 des generierten Korpus. */
export function step021(input: number): number {
const scaled = input * 21 + 4;
return normalize(scaled);
}
/** Schritt 22 des generierten Korpus. */
export function step022(input: number): number {
const scaled = input * 22 + 11;
return weigh(scaled);
}
/** Schritt 23 des generierten Korpus. */
export function step023(input: number): number {
const scaled = input * 23 + 5;
return normalize(scaled);
}
/** Schritt 24 des generierten Korpus. */
export function step024(input: number): number {
const scaled = input * 24 + 12;
return weigh(scaled);
}
/** Schritt 25 des generierten Korpus. */
export function step025(input: number): number {
const scaled = input * 25 + 6;
return normalize(scaled);
}
/** Schritt 26 des generierten Korpus. */
export function step026(input: number): number {
const scaled = input * 26 + 0;
return weigh(scaled);
}
/** Schritt 27 des generierten Korpus. */
export function step027(input: number): number {
const scaled = input * 27 + 7;
return normalize(scaled);
}
/** Schritt 28 des generierten Korpus. */
export function step028(input: number): number {
const scaled = input * 28 + 1;
return weigh(scaled);
}
/** Schritt 29 des generierten Korpus. */
export function step029(input: number): number {
const scaled = input * 29 + 8;
return normalize(scaled);
}
/** Schritt 30 des generierten Korpus. */
export function step030(input: number): number {
const scaled = input * 30 + 2;
return weigh(scaled);
}
/** Schritt 31 des generierten Korpus. */
export function step031(input: number): number {
const scaled = input * 31 + 9;
return normalize(scaled);
}
/** Schritt 32 des generierten Korpus. */
export function step032(input: number): number {
const scaled = input * 32 + 3;
return weigh(scaled);
}
/** Schritt 33 des generierten Korpus. */
export function step033(input: number): number {
const scaled = input * 33 + 10;
return normalize(scaled);
}
/** Schritt 34 des generierten Korpus. */
export function step034(input: number): number {
const scaled = input * 34 + 4;
return weigh(scaled);
}
/** Schritt 35 des generierten Korpus. */
export function step035(input: number): number {
const scaled = input * 35 + 11;
return normalize(scaled);
}
/** Schritt 36 des generierten Korpus. */
export function step036(input: number): number {
const scaled = input * 36 + 5;
return weigh(scaled);
}
/** Schritt 37 des generierten Korpus. */
export function step037(input: number): number {
const scaled = input * 37 + 12;
return normalize(scaled);
}
/** Schritt 38 des generierten Korpus. */
export function step038(input: number): number {
const scaled = input * 38 + 6;
return weigh(scaled);
}
/** Schritt 39 des generierten Korpus. */
export function step039(input: number): number {
const scaled = input * 39 + 0;
return normalize(scaled);
}
/** Schritt 40 des generierten Korpus. */
export function step040(input: number): number {
const scaled = input * 40 + 7;
return weigh(scaled);
}
/** Schritt 41 des generierten Korpus. */
export function step041(input: number): number {
const scaled = input * 41 + 1;
return normalize(scaled);
}
/** Schritt 42 des generierten Korpus. */
export function step042(input: number): number {
const scaled = input * 42 + 8;
return weigh(scaled);
}
/** Schritt 43 des generierten Korpus. */
export function step043(input: number): number {
const scaled = input * 43 + 2;
return normalize(scaled);
}
/** Schritt 44 des generierten Korpus. */
export function step044(input: number): number {
const scaled = input * 44 + 9;
return weigh(scaled);
}
/** Schritt 45 des generierten Korpus. */
export function step045(input: number): number {
const scaled = input * 45 + 3;
return normalize(scaled);
}
/** Schritt 46 des generierten Korpus. */
export function step046(input: number): number {
const scaled = input * 46 + 10;
return weigh(scaled);
}
/** Schritt 47 des generierten Korpus. */
export function step047(input: number): number {
const scaled = input * 47 + 4;
return normalize(scaled);
}
/** Schritt 48 des generierten Korpus. */
export function step048(input: number): number {
const scaled = input * 48 + 11;
return weigh(scaled);
}
/** Schritt 49 des generierten Korpus. */
export function step049(input: number): number {
const scaled = input * 49 + 5;
return normalize(scaled);
}
/** Schritt 50 des generierten Korpus. */
export function step050(input: number): number {
const scaled = input * 50 + 12;
return weigh(scaled);
}
/** Schritt 51 des generierten Korpus. */
export function step051(input: number): number {
const scaled = input * 51 + 6;
return normalize(scaled);
}
/** Schritt 52 des generierten Korpus. */
export function step052(input: number): number {
const scaled = input * 52 + 0;
return weigh(scaled);
}
/** Schritt 53 des generierten Korpus. */
export function step053(input: number): number {
const scaled = input * 53 + 7;
return normalize(scaled);
}
/** Schritt 54 des generierten Korpus. */
export function step054(input: number): number {
const scaled = input * 54 + 1;
return weigh(scaled);
}
/** Schritt 55 des generierten Korpus. */
export function step055(input: number): number {
const scaled = input * 55 + 8;
return normalize(scaled);
}
/** Schritt 56 des generierten Korpus. */
export function step056(input: number): number {
const scaled = input * 56 + 2;
return weigh(scaled);
}
/** Schritt 57 des generierten Korpus. */
export function step057(input: number): number {
const scaled = input * 57 + 9;
return normalize(scaled);
}
/** Schritt 58 des generierten Korpus. */
export function step058(input: number): number {
const scaled = input * 58 + 3;
return weigh(scaled);
}
/** Schritt 59 des generierten Korpus. */
export function step059(input: number): number {
const scaled = input * 59 + 10;
return normalize(scaled);
}
/** Schritt 60 des generierten Korpus. */
export function step060(input: number): number {
const scaled = input * 60 + 4;
return weigh(scaled);
}
/** Schritt 61 des generierten Korpus. */
export function step061(input: number): number {
const scaled = input * 61 + 11;
return normalize(scaled);
}
/** Schritt 62 des generierten Korpus. */
export function step062(input: number): number {
const scaled = input * 62 + 5;
return weigh(scaled);
}
/** Schritt 63 des generierten Korpus. */
export function step063(input: number): number {
const scaled = input * 63 + 12;
return normalize(scaled);
}
/** Schritt 64 des generierten Korpus. */
export function step064(input: number): number {
const scaled = input * 64 + 6;
return weigh(scaled);
}
/** Schritt 65 des generierten Korpus. */
export function step065(input: number): number {
const scaled = input * 65 + 0;
return normalize(scaled);
}
/** Schritt 66 des generierten Korpus. */
export function step066(input: number): number {
const scaled = input * 66 + 7;
return weigh(scaled);
}
/** Schritt 67 des generierten Korpus. */
export function step067(input: number): number {
const scaled = input * 67 + 1;
return normalize(scaled);
}
/** Schritt 68 des generierten Korpus. */
export function step068(input: number): number {
const scaled = input * 68 + 8;
return weigh(scaled);
}
/** Schritt 69 des generierten Korpus. */
export function step069(input: number): number {
const scaled = input * 69 + 2;
return normalize(scaled);
}
/** Schritt 70 des generierten Korpus. */
export function step070(input: number): number {
const scaled = input * 70 + 9;
return weigh(scaled);
}
/** Schritt 71 des generierten Korpus. */
export function step071(input: number): number {
const scaled = input * 71 + 3;
return normalize(scaled);
}
/** Schritt 72 des generierten Korpus. */
export function step072(input: number): number {
const scaled = input * 72 + 10;
return weigh(scaled);
}
/** Schritt 73 des generierten Korpus. */
export function step073(input: number): number {
const scaled = input * 73 + 4;
return normalize(scaled);
}
/** Schritt 74 des generierten Korpus. */
export function step074(input: number): number {
const scaled = input * 74 + 11;
return weigh(scaled);
}
/** Schritt 75 des generierten Korpus. */
export function step075(input: number): number {
const scaled = input * 75 + 5;
return normalize(scaled);
}
/** Schritt 76 des generierten Korpus. */
export function step076(input: number): number {
const scaled = input * 76 + 12;
return weigh(scaled);
}
/** Schritt 77 des generierten Korpus. */
export function step077(input: number): number {
const scaled = input * 77 + 6;
return normalize(scaled);
}
/** Schritt 78 des generierten Korpus. */
export function step078(input: number): number {
const scaled = input * 78 + 0;
return weigh(scaled);
}
/** Schritt 79 des generierten Korpus. */
export function step079(input: number): number {
const scaled = input * 79 + 7;
return normalize(scaled);
}
/** Schritt 80 des generierten Korpus. */
export function step080(input: number): number {
const scaled = input * 80 + 1;
return weigh(scaled);
}
/** Schritt 81 des generierten Korpus. */
export function step081(input: number): number {
const scaled = input * 81 + 8;
return normalize(scaled);
}
/** Schritt 82 des generierten Korpus. */
export function step082(input: number): number {
const scaled = input * 82 + 2;
return weigh(scaled);
}
/** Schritt 83 des generierten Korpus. */
export function step083(input: number): number {
const scaled = input * 83 + 9;
return normalize(scaled);
}
/** Schritt 84 des generierten Korpus. */
export function step084(input: number): number {
const scaled = input * 84 + 3;
return weigh(scaled);
}
/** Schritt 85 des generierten Korpus. */
export function step085(input: number): number {
const scaled = input * 85 + 10;
return normalize(scaled);
}
/** Schritt 86 des generierten Korpus. */
export function step086(input: number): number {
const scaled = input * 86 + 4;
return weigh(scaled);
}
/** Schritt 87 des generierten Korpus. */
export function step087(input: number): number {
const scaled = input * 87 + 11;
return normalize(scaled);
}
/** Schritt 88 des generierten Korpus. */
export function step088(input: number): number {
const scaled = input * 88 + 5;
return weigh(scaled);
}
/** Schritt 89 des generierten Korpus. */
export function step089(input: number): number {
const scaled = input * 89 + 12;
return normalize(scaled);
}
/** Schritt 90 des generierten Korpus. */
export function step090(input: number): number {
const scaled = input * 90 + 6;
return weigh(scaled);
}
/** Schritt 91 des generierten Korpus. */
export function step091(input: number): number {
const scaled = input * 91 + 0;
return normalize(scaled);
}
/** Schritt 92 des generierten Korpus. */
export function step092(input: number): number {
const scaled = input * 92 + 7;
return weigh(scaled);
}
/** Schritt 93 des generierten Korpus. */
export function step093(input: number): number {
const scaled = input * 93 + 1;
return normalize(scaled);
}
/** Schritt 94 des generierten Korpus. */
export function step094(input: number): number {
const scaled = input * 94 + 8;
return weigh(scaled);
}
/** Schritt 95 des generierten Korpus. */
export function step095(input: number): number {
const scaled = input * 95 + 2;
return normalize(scaled);
}
/** Schritt 96 des generierten Korpus. */
export function step096(input: number): number {
const scaled = input * 96 + 9;
return weigh(scaled);
}
/** Schritt 97 des generierten Korpus. */
export function step097(input: number): number {
const scaled = input * 97 + 3;
return normalize(scaled);
}
/** Schritt 98 des generierten Korpus. */
export function step098(input: number): number {
const scaled = input * 98 + 10;
return weigh(scaled);
}
/** Schritt 99 des generierten Korpus. */
export function step099(input: number): number {
const scaled = input * 99 + 4;
return normalize(scaled);
}
/** Schritt 100 des generierten Korpus. */
export function step100(input: number): number {
const scaled = input * 100 + 11;
return weigh(scaled);
}
/** Alle Schritte der Reihe nach, damit die Datei einen Einstiegspunkt hat. */
export function runAll(seed: number): number {
const steps = [
step001,
step002,
step003,
step004,
step005,
step006,
step007,
step008,
step009,
step010,
step011,
step012,
step013,
step014,
step015,
step016,
step017,
step018,
step019,
step020,
step021,
step022,
step023,
step024,
step025,
step026,
step027,
step028,
step029,
step030,
step031,
step032,
step033,
step034,
step035,
step036,
step037,
step038,
step039,
step040,
step041,
step042,
step043,
step044,
step045,
step046,
step047,
step048,
step049,
step050,
step051,
step052,
step053,
step054,
step055,
step056,
step057,
step058,
step059,
step060,
step061,
step062,
step063,
step064,
step065,
step066,
step067,
step068,
step069,
step070,
step071,
step072,
step073,
step074,
step075,
step076,
step077,
step078,
step079,
step080,
step081,
step082,
step083,
step084,
step085,
step086,
step087,
step088,
step089,
step090,
step091,
step092,
step093,
step094,
step095,
step096,
step097,
step098,
step099,
step100,
];
return steps.reduce((acc, step) => step(acc), seed);
}
@@ -0,0 +1,11 @@
// Einstiegspunkt des Mess-Korpus. Er tut absichtlich fast nichts: gemessen
// wird der Reader an big.ts, nicht dieses Projekt.
import { runAll, step001 } from './big';
import { normalize } from './support';
/** Faehrt den generierten Korpus einmal durch und gibt das Ergebnis zurueck. */
export function main(seed = 1): number {
const first = step001(seed);
return normalize(runAll(first));
}
@@ -0,0 +1,16 @@
// Zwei Hilfsfunktionen, die big.ts aufruft. Sie sind hier, damit die grosse
// Datei nicht beziehungslos im Graphen haengt: der Indexer sieht Importe und
// Aufrufkanten und der Reader hat neben big.ts noch etwas Kleines zu zeigen.
/** Haelt einen Wert in einem festen Fenster, damit runAll nicht davonlaeuft. */
export function normalize(value: number): number {
if (!Number.isFinite(value)) {
return 0;
}
return ((value % 997) + 997) % 997;
}
/** Gewichtet einen Wert und normalisiert ihn danach. */
export function weigh(value: number): number {
return normalize(Math.trunc(value / 3) + 11);
}
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"rootDir": ".",
"noEmit": true
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,8 @@
# Sample fixture provenance
Copied from Bernhard Jackiewicz's CodeAtlasIDE project on 2026-08-28,
`fixtures/atlas-sample`, by the same author.
The twelve TypeScript and configuration files form a deliberately small, stable
demo and test corpus. Graph assertions depend on that stable source. Changes here
apply only to this fixture; they do not update its source project.
@@ -0,0 +1,8 @@
{
"name": "atlas-sample",
"version": "1.0.0",
"private": true,
"description": "Indexing fixture for CodeAtlas. Never built, installed or executed by the monorepo.",
"main": "src/server.ts",
"license": "UNLICENSED"
}
@@ -0,0 +1,22 @@
// Configuration loader. Every value comes from the environment, which gives
// the fixture a stable set of environment read sites for the graph.
export interface AppConfig {
host: string;
port: number;
databaseUrl: string;
logLevel: string;
}
export function loadConfig(): AppConfig {
return {
host: process.env.HOST ?? '127.0.0.1',
port: Number(process.env.PORT ?? '8080'),
databaseUrl: process.env.DB_URL ?? 'memory://atlas-sample',
logLevel: process.env.LOG_LEVEL ?? 'info',
};
}
export function isProduction(): boolean {
return process.env.NODE_ENV === 'production';
}
@@ -0,0 +1,66 @@
// In-memory storage stand-in. No driver and no network: the fixture only needs
// call sites and control flow that an indexer can see.
export interface Row {
id: string;
table: string;
payload: Record<string, unknown>;
}
export interface TreeNode {
name: string;
children: TreeNode[];
}
const rows: Row[] = [];
export function query(table: string, limit: number): Row[] {
const matches: Row[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row.table === table) {
matches.push(row);
}
if (matches.length >= limit) {
break;
}
}
return matches;
}
export function insert(table: string, id: string, payload: Record<string, unknown>): Row {
const row: Row = { id, table, payload };
rows.push(row);
return row;
}
// Deliberate hotspot: three nested loops with a linear lookup in the innermost
// body. The complexity view is expected to flag this function.
export function hotspotScan(tables: string[], keys: string[], needles: string[]): Row[] {
const found: Row[] = [];
for (const table of tables) {
for (const key of keys) {
for (const needle of needles) {
const candidates = query(table, 1000);
const position = candidates.map((row) => row.id).indexOf(needle);
const hit = candidates.find((row) => String(row.payload[key]) === needle);
if (position >= 0 && hit) {
found.push(hit);
}
}
}
}
return found;
}
// Deliberately unguarded recursion: walk has no base case of its own and
// relies on the child list running out.
export function walk(node: TreeNode): string[] {
const names: string[] = [node.name];
for (const child of node.children) {
for (const name of walk(child)) {
names.push(name);
}
}
return names;
}
@@ -0,0 +1,17 @@
// Order routes. A single parameterised lookup that delegates to the service.
import { getOrder } from '../services/orderService';
// Deliberately unused harness fixture: proves the file-level import finding.
import { insert } from '../repo/db';
import type { HttpRequest, HttpResponse, Router } from '../types';
export function registerOrderRoutes(router: Router): void {
router.get('/orders/:id', (req: HttpRequest, res: HttpResponse) => {
const order = getOrder(req.params.id);
if (!order) {
res.status(404).json({ error: 'order not found' });
return;
}
res.json({ order });
});
}
@@ -0,0 +1,18 @@
// User routes. Handlers stay thin so the call edge from the route to the
// service is the only thing the graph has to resolve.
import { createUser, listUsers } from '../services/userService';
import type { HttpRequest, HttpResponse, Router } from '../types';
export function registerUserRoutes(router: Router): void {
router.get('/users', (req: HttpRequest, res: HttpResponse) => {
const limit = Number(req.query.limit ?? '50');
const users = listUsers(limit);
res.json({ users, count: users.length });
});
router.post('/users', (req: HttpRequest, res: HttpResponse) => {
const user = createUser(req.body);
res.status(201).json({ user });
});
}
@@ -0,0 +1,48 @@
// Entry point of the atlas-sample fixture. Builds the router, registers the
// route modules and prints the routing table. Nothing here opens a socket:
// the fixture is indexed, never executed.
import { loadConfig } from './config';
import { registerOrderRoutes } from './routes/orders';
import { registerUserRoutes } from './routes/users';
import type { RouteHandler, Router } from './types';
interface Route {
method: string;
path: string;
handler: RouteHandler;
}
export class SimpleRouter implements Router {
private readonly routes: Route[] = [];
get(path: string, handler: RouteHandler): void {
this.routes.push({ method: 'GET', path, handler });
}
post(path: string, handler: RouteHandler): void {
this.routes.push({ method: 'POST', path, handler });
}
table(): string[] {
return this.routes.map((route) => `${route.method} ${route.path}`);
}
}
export function createApp(): SimpleRouter {
const router = new SimpleRouter();
registerUserRoutes(router);
registerOrderRoutes(router);
return router;
}
export function main(): void {
const config = loadConfig();
const app = createApp();
for (const entry of app.table()) {
console.log(`[atlas-sample] route ${entry}`);
}
console.log(`[atlas-sample] would listen on ${config.host}:${config.port}`);
}
main();
@@ -0,0 +1,35 @@
// Order use cases. getOrder wraps the repository call in try/catch so the
// fixture has a recoverable failure path to render.
import { insert, query } from '../repo/db';
import type { Order } from '../types';
import { validateId } from '../util/validate';
export function getOrder(id: string): Order | undefined {
try {
const key = validateId(id);
const rows = query('orders', 100);
const row = rows.find((candidate) => candidate.id === key);
if (!row) {
return undefined;
}
return {
id: row.id,
createdAt: String(row.payload.createdAt ?? ''),
customerId: String(row.payload.customerId ?? ''),
total: Number(row.payload.total ?? 0),
};
} catch (error) {
console.error(`[orders] lookup failed for ${id}: ${String(error)}`);
return undefined;
}
}
// Same bare name as userService.create on purpose: the pair is the ambiguity
// fixture for path tracing.
export function create(customerId: string, total: number): Order {
const createdAt = new Date(0).toISOString();
const id = `order-${customerId}-${total}`;
insert('orders', id, { customerId, total, createdAt });
return { id, createdAt, customerId, total };
}
@@ -0,0 +1,42 @@
// User use cases. Kept small on purpose so the facts the graph reports for
// createUser stay stable: it calls, throws and reads exactly one thing each.
import { insert, query, Row } from '../repo/db';
import { UserEntity } from '../types';
import type { User } from '../types';
import { ValidationError, validateUser } from '../util/validate';
function toUser(row: Row): User {
return {
id: row.id,
createdAt: String(row.payload.createdAt ?? ''),
email: String(row.payload.email ?? ''),
name: String(row.payload.name ?? ''),
};
}
export function listUsers(limit = 50): User[] {
const rows = query('users', limit);
return rows.map(toUser);
}
export function createUser(input: unknown): User {
const parsed = validateUser(input);
const dsn = process.env.DB_URL ?? '';
if (dsn.length === 0) {
throw new ValidationError('DB_URL', 'DB_URL must be set before writing users');
}
const entity = new UserEntity(`user-${listUsers().length + 1}`, parsed.email, parsed.name);
const row = insert('users', entity.id, {
email: entity.email,
name: entity.name,
createdAt: entity.createdAt,
});
return toUser(row);
}
// Thin alias kept on purpose: orderService exports a function with the same
// bare name, which makes "create" an ambiguous trace target.
export function create(input: unknown): User {
return createUser(input);
}
@@ -0,0 +1,53 @@
// Shared types for the atlas-sample fixture: a small express-like HTTP surface
// plus the domain entities the services work with. No runtime dependencies.
export interface HttpRequest {
params: Record<string, string>;
query: Record<string, string>;
body: unknown;
}
export interface HttpResponse {
status(code: number): HttpResponse;
json(payload: unknown): void;
}
export type RouteHandler = (req: HttpRequest, res: HttpResponse) => void;
export interface Router {
get(path: string, handler: RouteHandler): void;
post(path: string, handler: RouteHandler): void;
}
export interface Entity {
id: string;
createdAt: string;
}
export interface User extends Entity {
email: string;
name: string;
}
export interface Order extends Entity {
customerId: string;
total: number;
}
export class UserEntity implements Entity {
readonly id: string;
readonly createdAt: string;
readonly email: string;
readonly name: string;
constructor(id: string, email: string, name: string) {
this.id = id;
this.email = email;
this.name = name;
this.createdAt = new Date(0).toISOString();
}
label(): string {
return `${this.name} <${this.email}>`;
}
}
@@ -0,0 +1,38 @@
// Input validation. ValidationError lives here so both services throw the
// same class and the graph has a single error type to point at.
export class ValidationError extends Error {
readonly field: string;
constructor(field: string, message: string) {
super(message);
this.name = 'ValidationError';
this.field = field;
}
}
export interface UserInput {
email: string;
name: string;
}
export function validateUser(input: unknown): UserInput {
const candidate = input as Partial<UserInput> | null;
if (!candidate || typeof candidate.email !== 'string') {
throw new ValidationError('email', 'email must be a string');
}
if (!candidate.email.includes('@')) {
throw new ValidationError('email', 'email must contain an at sign');
}
if (typeof candidate.name !== 'string' || candidate.name.length === 0) {
throw new ValidationError('name', 'name must be a non-empty string');
}
return { email: candidate.email, name: candidate.name };
}
export function validateId(value: string): string {
if (value.trim().length === 0) {
throw new ValidationError('id', 'id must not be empty');
}
return value;
}
@@ -0,0 +1,16 @@
// Minimal fixture test. It exercises listUsers and nothing else, so the graph
// is expected to show a test edge for that function only.
import test from 'node:test';
import assert from 'node:assert/strict';
import { listUsers } from '../src/services/userService';
test('listUsers returns an array', () => {
const users = listUsers();
assert.ok(Array.isArray(users));
});
test('listUsers honours the limit', () => {
const users = listUsers(1);
assert.ok(users.length <= 1);
});
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"rootDir": ".",
"noEmit": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+5 -3
View File
@@ -1,11 +1,13 @@
<!doctype html>
<html lang="en" class="dark">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Codebase Memory — Graph</title>
<meta name="color-scheme" content="dark" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27%3E%3Crect width=%2716%27 height=%2716%27 fill=%27%230a0e0d%27/%3E%3Ccircle cx=%278%27 cy=%278%27 r=%274%27 fill=%27%2333ff99%27/%3E%3C/svg%3E" />
<title>CBM Lens Beta</title>
</head>
<body class="bg-[#0a0a10] text-[#e4e4ed] min-h-screen">
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+134
View File
@@ -0,0 +1,134 @@
#!/bin/sh
# Ein Modell von Hugging Face in das Cache-Verzeichnis dieses Projekts holen.
#
# Aufruf:
# llm/fetch-model.sh <user>/<repo>[:<quant>] holt das Modell
# llm/fetch-model.sh --print <user>/<repo> zeigt nur den Befehl
# llm/fetch-model.sh --list zeigt, was im Cache liegt
#
# ## Warum dieses Skript und nicht ein Knopf in der Oberflaeche
#
# Die Oberflaeche ist eine SPA ohne eigenes Backend. Sie startet keinen Prozess
# und schreibt nichts auf die Platte, also kann sie auch nichts herunterladen.
# Was sie kann, tut sie: sie zeigt die Vorschlaege mit ihren gemessenen Zahlen
# und den fertigen Aufruf zum Kopieren. Der Aufruf ist dieser hier.
#
# ## Was hier wirklich passiert
#
# llama-server kann selbst laden: `-hf user/repo[:quant]` holt die Datei von
# huggingface.co in das Verzeichnis, das llama.cpp als Cache liest
# (Umgebungsvariable LLAMA_CACHE, ohne sie auf macOS $HOME/Library/Caches/
# llama.cpp). Dieses Skript setzt LLAMA_CACHE auf das Cache-Verzeichnis dieses
# Projekts, damit ein geholtes Modell in derselben Liste auftaucht wie die schon
# vorhandenen und llm/start.sh es ohne weitere Angabe findet.
#
# Der Quant ist optional; ohne ihn nimmt llama-server Q4_K_M, und wenn es den
# nicht gibt, die erste Datei des Repositories. Er wird ohne Ansehen der
# Gross- und Kleinschreibung gesucht.
#
# ## Der eine Satz, der vor jedem Lauf dasteht
#
# Dieser Befehl GEHT INS NETZ. Er ist damit die eine Stelle, an der dieses
# ansonsten abgeschottete Produkt eine fremde Adresse anspricht, und er sagt das
# vorher, samt Ziel und Ablageort. Ein Beweislauf fuehrt ihn nicht aus.
set -eu
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$HERE/.." && pwd)
SERVER="$ROOT/vendor/llama/llama-server"
# Dasselbe Verzeichnis wie in llm/start.sh, mit derselben Reihenfolge. Zwei
# Vorgaben fuer denselben Ort waeren ein geholtes Modell, das in der Liste des
# Starters nicht auftaucht.
MODELS_DIR=${ATLAS_MODELS_DIR:-${LLAMA_CACHE:-$ROOT/models}}
usage() {
echo "Aufruf: llm/fetch-model.sh <user>/<repo>[:<quant>]" >&2
echo " llm/fetch-model.sh --print <user>/<repo>[:<quant>]" >&2
echo " llm/fetch-model.sh --list" >&2
echo "" >&2
echo "Beispiele (die sechs gemessenen Kandidaten stehen in docs/adr/0001-modellwahl.md):" >&2
echo " llm/fetch-model.sh unsloth/Qwen3.5-2B-GGUF" >&2
echo " llm/fetch-model.sh LiquidAI/LFM2.5-1.2B-Instruct-GGUF:Q4_K_M" >&2
echo "" >&2
echo "Cache-Verzeichnis: $MODELS_DIR (ueber ATLAS_MODELS_DIR aenderbar)" >&2
}
PRINT_ONLY=0
case "${1:-}" in
--list)
echo "Cache-Verzeichnis: $MODELS_DIR"
FOUND=0
for CANDIDATE in "$MODELS_DIR"/*.gguf; do
if [ -f "$CANDIDATE" ]; then
FOUND=1
echo " $(basename "$CANDIDATE")"
fi
done
if [ "$FOUND" -eq 0 ]; then
echo " (leer)"
echo "Ein Modell holen: llm/fetch-model.sh unsloth/Qwen3.5-2B-GGUF"
fi
exit 0
;;
--print)
PRINT_ONLY=1
shift
;;
-h|--help|'')
usage
exit 2
;;
esac
REPO=${1:-}
if [ -z "$REPO" ]; then
usage
exit 2
fi
# Eine reine Formpruefung, keine Netzabfrage: user/repo, wahlweise mit :quant.
# Ob es das Repository gibt, weiss nur huggingface.co, und dieses Skript
# behauptet darueber nichts.
if ! printf '%s' "$REPO" | grep -Eq '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+(:[A-Za-z0-9._-]+)?$'; then
echo "Das ist keine Repo-Kennung: $REPO" >&2
echo "Erwartet wird user/repo oder user/repo:quant, zum Beispiel" >&2
echo "unsloth/Qwen3.5-2B-GGUF oder unsloth/Qwen3.5-2B-GGUF:Q4_K_M." >&2
exit 2
fi
COMMAND="LLAMA_CACHE=\"$MODELS_DIR\" $SERVER -hf $REPO --no-warmup -c 512"
echo "Dieser Aufruf geht ins Netz: er laedt $REPO von huggingface.co"
echo "und legt die Datei in $MODELS_DIR ab."
echo "Die Anwendung selbst laedt nichts herunter; sie zeigt nur diesen Aufruf."
echo ""
echo "$COMMAND"
if [ "$PRINT_ONLY" -eq 1 ]; then
exit 0
fi
if [ ! -x "$SERVER" ]; then
echo "" >&2
echo "llama-server fehlt: $SERVER" >&2
echo "vendor/llama/ ist gitignoriert; Herkunft und Baubefehl stehen in vendor/llama/HERKUNFT.md." >&2
exit 4
fi
mkdir -p "$MODELS_DIR"
# Im Vordergrund und ohne Kunstgriffe.
#
# `-hf` laedt und startet danach den Dienst; einen Modus "nur laden" gibt es
# nicht. Dieses Skript raet deshalb nicht, wann der Download fertig ist, sondern
# sagt, woran man es sieht, und ueberlaesst das Beenden dem Leser. Ein Skript,
# das nach einer geschaetzten Frist zuschlaegt, waere ein Skript, das einen
# halben Download fuer fertig erklaeren kann.
echo ""
echo "Der Server laedt jetzt und startet danach von selbst. Sobald in seiner"
echo "Ausgabe \"server is listening\" steht, ist die Datei im Cache: dann mit"
echo "Ctrl-C beenden. Die Datei bleibt liegen, und llm/start.sh findet sie."
echo ""
exec env LLAMA_CACHE="$MODELS_DIR" "$SERVER" -hf "$REPO" --no-warmup -c 512
+280
View File
@@ -0,0 +1,280 @@
#!/bin/sh
# Den lokalen Modell-Sidecar starten. Ein Prozess, ein Port, eine PID-Datei.
#
# Warum es dieses Skript gibt und nicht einen Knopf in der Oberflaeche: die SPA
# hat kein eigenes Backend und kann keinen Prozess starten (ADR 0001,
# Abschnitt "Engine-Entscheidung"). Ein Knopf, der einen Prozess verspricht,
# waere eine Luege in Knopfform. Der Manager im Browser erkennt, zeigt und
# leitet an; gestartet wird hier, von Hand oder aus einem Beweislauf.
#
# ## Die Vorgabe ist seit W10 der Router, nicht ein Dateiname
#
# Ohne Argument faehrt dieses Skript llama-server ueber ein CACHE-VERZEICHNIS:
#
# --models-dir "$ATLAS_MODELS_DIR" --models-max N --models-autoload
#
# Damit listet /v1/models, was in dem Verzeichnis liegt, und das Feld "model"
# einer Chat-Anfrage waehlt daraus aus. Das Einstellungen-Panel kann so zwischen
# Modellen umschalten, ohne dass jemand einen Prozess neu startet, und ohne dass
# die Oberflaeche einen Prozess anfassen muesste, was sie nicht darf.
#
# Der Grund fuer die Umstellung steht im Nachtrag W10 von docs/adr/0001: das
# Programm liefert kein Modell mehr aus und nennt auch keins als gesetzt. Welche
# Datei laeuft, entscheidet der Leser.
#
# ## Woher das Cache-Verzeichnis kommt
#
# ATLAS_MODELS_DIR, sonst $LLAMA_CACHE (die Umgebungsvariable, die llama.cpp
# selbst fuer seinen Cache liest), sonst <repo>/models. Das letzte ist kein
# Notbehelf: models/ IST das lokale Cache-Verzeichnis dieses Projekts. Es ist
# gitignoriert, sein Inhalt gehoert dem Leser, und llm/fetch-model.sh laedt in
# genau dieses Verzeichnis, damit ein geholtes Modell in derselben Liste
# auftaucht wie die schon vorhandenen.
#
# ## Aufruf
#
# llm/start.sh Router ueber das Cache-Verzeichnis
# llm/start.sh <pfad>.gguf eine bestimmte Datei
# llm/start.sh <name> eine Datei aus dem Cache-Verzeichnis
# llm/start.sh hf:user/repo[:quant] direkt von Hugging Face (geht ins Netz)
# llm/start.sh class-a die Reproduktionsgriffe der ADR-Eval
# llm/start.sh --print-command [..] zeigt die Befehlszeile und startet nichts
#
# Aus der Umgebung: ATLAS_MODEL (wie das Argument), ATLAS_CTX (Kontextfenster),
# ATLAS_MODELS_DIR, ATLAS_MODELS_MAX (wie viele Instanzen gleichzeitig).
#
# ## --print-command, und warum es das gibt
#
# Ein Beweislauf soll pruefen koennen, WOMIT dieses Skript den Server aufruft,
# ohne dafuer ein Modell zu laden. Ohne diesen Schalter muesste er Gigabyte in
# den Speicher heben, um eine Zeichenkette zu sehen. Er schreibt die Zeile auf
# stdout, startet nichts und fasst weder Port noch PID-Datei an.
#
# Idempotent: laeuft der eigene Sidecar schon, passiert nichts und das Skript
# endet mit 0. Lauscht etwas ANDERES auf dem Port, wird nichts gestartet und
# nichts abgeschossen; das Skript sagt, was es vorgefunden hat, und endet mit 3.
set -eu
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$HERE/.." && pwd)
PORT=4141
HOST=127.0.0.1
PID_FILE="$HERE/.pid"
LOG_FILE="$HERE/llama-server.log"
SERVER="$ROOT/vendor/llama/llama-server"
PRINT_ONLY=0
if [ "${1:-}" = "--print-command" ]; then
PRINT_ONLY=1
shift
fi
# Das Cache-Verzeichnis. Siehe Kopf: die Reihenfolge ist Argument-Umgebung,
# llama.cpp-Umgebung, Projektverzeichnis.
MODELS_DIR=${ATLAS_MODELS_DIR:-${LLAMA_CACHE:-$ROOT/models}}
# Wie viele Instanzen der Router gleichzeitig geladen halten darf. Zwei, weil
# genau zwei gebraucht werden, um ohne Neustart umzuschalten: die laufende und
# die gewaehlte. Jede weitere kostet den Speicher eines ganzen Modells fuer eine
# Auswahl, die niemand getroffen hat. Die Vorgabe von llama-server waere 4.
MODELS_MAX=${ATLAS_MODELS_MAX:-2}
CHOICE=${1:-${ATLAS_MODEL:-}}
# ---------------------------------------------------------------------------
# Die Reproduktionsgriffe der ADR-Eval.
#
# Das ist KEINE Vorgabe mehr, und die Reihenfolge in dieser Datei sagt das: die
# Vorgabe ist der Router weiter unten. Diese sechs Namen sind die Griffe, mit
# denen die Laeufe von W5 wiederholbar bleiben (tools/smoke-w5a.mjs faehrt
# class-a und erwartet Qwen3.5-2B mit Kontext 3072, tools/smoke-w5b.mjs und
# tools/smoke-w6-full.mjs fahren die anderen, tools/eval-check.mjs erwartet die
# Dateien unter models/). Sie sind Belege und keine Empfehlung; welches Modell
# gut ist, sagt die Eval-Tabelle im ADR, und welches laeuft, sagt der Leser.
#
# Sie loesen darum ausdruecklich gegen <repo>/models auf und nicht gegen
# $ATLAS_MODELS_DIR: die Dateien der Eval liegen dort, und ein gesetztes
# LLAMA_CACHE wuerde sonst einen Reproduktionslauf ins Leere schicken.
#
# Die Wahlen hiessen bis zum 2026-08-29 "1b" und "4b". Befund 17 des
# unabhaengigen Audits: der Sieger der Klasse A ist ein 2B-Modell, und eine
# Beschriftung, die es "1b" nennt, sagt etwas Falsches ueber die Datei, die sie
# laedt. Die Klasse haengt ohnehin am Kontextfenster und nicht an der
# Parameterzahl (siehe modelClassOf in src/llm/sidecar.ts). Die alten Namen
# bleiben als Alias gueltig.
# ---------------------------------------------------------------------------
ALIAS_FILE=''
ALIAS_CTX=''
case "$CHOICE" in
class-a|1b) ALIAS_FILE=Qwen3.5-2B-Q4_K_M.gguf ; ALIAS_CTX=3072 ;;
class-a-lfm|1b-lfm) ALIAS_FILE=LFM2.5-1.2B-Instruct-Q4_K_M.gguf ; ALIAS_CTX=3072 ;;
class-a-minicpm|1b-minicpm) ALIAS_FILE=MiniCPM5-1B-Q4_K_M.gguf ; ALIAS_CTX=3072 ;;
class-a-coder|1b-coder) ALIAS_FILE=qwen2.5-coder-1.5b-instruct-q4_k_m.gguf ; ALIAS_CTX=3072 ;;
class-b|4b) ALIAS_FILE=Qwen3.5-4B-Q4_K_M.gguf ; ALIAS_CTX=8192 ;;
class-b-gemma|4b-gemma) ALIAS_FILE=gemma-4-E4B-it-Q4_K_M.gguf ; ALIAS_CTX=8192 ;;
esac
case "$CHOICE" in
1b|1b-lfm|1b-minicpm|1b-coder|4b|4b-gemma)
echo "Hinweis: \"$CHOICE\" ist der alte Name dieser Wahl. Sie heisst jetzt nach ihrer" >&2
echo "Klasse; die Klassen sind class-a (Kontext 3072) und class-b (Kontext 8192)." >&2
;;
esac
# Der Kontext: die Umgebung schlaegt den Alias, der Alias die Vorgabe. 3072 ist
# das Budget der Klasse A aus PLAN Paragraph 5 und damit die konservative Wahl
# fuer ein Modell, ueber das dieses Skript nichts weiss.
CTX=${ATLAS_CTX:-${ALIAS_CTX:-3072}}
# ---------------------------------------------------------------------------
# Die Argumente des Servers, in genau einer Variablen.
#
# Sie entstehen hier und werden weiter unten entweder gedruckt oder gefahren.
# Zwei Stellen, an denen die Zeile entsteht, waeren zwei Zeilen: der Beweislauf
# saehe die eine und der Leser bekaeme die andere.
# ---------------------------------------------------------------------------
MODE=''
DESCRIPTION=''
set -- --host "$HOST" --port "$PORT"
if [ -n "$ALIAS_FILE" ]; then
MODE=eval-alias
MODEL_PATH="$ROOT/models/$ALIAS_FILE"
if [ "$PRINT_ONLY" -eq 0 ] && [ ! -f "$MODEL_PATH" ]; then
echo "Modell fehlt: $MODEL_PATH" >&2
echo "\"$CHOICE\" ist ein Reproduktionsgriff der ADR-Eval und erwartet die Datei aus" >&2
echo "models/SHA256SUMS. Holen: llm/fetch-model.sh --list" >&2
exit 4
fi
# Der relative Pfad, byte-identisch zu dem, was dieses Skript vor W10
# uebergeben hat: /props meldet ihn woertlich zurueck, und die Beweislaeufe
# von W5 und W6 lesen ihn dort.
set -- "$@" -m "models/$ALIAS_FILE" -c "$CTX"
DESCRIPTION="$ALIAS_FILE (Reproduktionsgriff $CHOICE), Kontext $CTX"
elif [ -z "$CHOICE" ]; then
MODE=router
set -- "$@" --models-dir "$MODELS_DIR" --models-max "$MODELS_MAX" --models-autoload -c "$CTX"
DESCRIPTION="Router ueber $MODELS_DIR (hoechstens $MODELS_MAX geladen), Kontext $CTX"
else
case "$CHOICE" in
hf:*)
MODE=hf
REPO=${CHOICE#hf:}
set -- "$@" -hf "$REPO" -c "$CTX"
DESCRIPTION="$REPO von huggingface.co, Kontext $CTX"
;;
*)
MODE=file
# Ein Pfad, oder ein Name im Cache-Verzeichnis. Erst wird gefragt,
# ob die Datei so existiert; erst dann wird sie im Cache gesucht.
if [ -f "$CHOICE" ]; then
MODEL_PATH="$CHOICE"
elif [ -f "$MODELS_DIR/$CHOICE" ]; then
MODEL_PATH="$MODELS_DIR/$CHOICE"
elif [ -f "$MODELS_DIR/$CHOICE.gguf" ]; then
MODEL_PATH="$MODELS_DIR/$CHOICE.gguf"
else
echo "unbekannte Modellwahl: $CHOICE" >&2
echo "Erwartet wird eines von:" >&2
echo " ein Pfad auf eine .gguf-Datei" >&2
echo " ein Dateiname aus $MODELS_DIR" >&2
echo " hf:user/repo[:quant] (laedt von huggingface.co)" >&2
echo " ein Reproduktionsgriff der ADR-Eval, davon" >&2
# Diese Zeile ist eine Naht: src/llm/strings.test.ts liest sie aus
# diesem Skript und vergleicht sie mit LLM_MODEL_CHOICES. Die
# Liste steht damit einmal in der Wahrheit und einmal in der
# Anzeige, und ein Test haelt beide zusammen (Audit-Befund 17).
echo "erlaubt: class-a | class-a-lfm | class-a-minicpm | class-a-coder | class-b | class-b-gemma" >&2
echo " (die alten Namen 1b, 1b-lfm, 1b-minicpm, 1b-coder, 4b, 4b-gemma gelten weiter)" >&2
echo "Ohne Argument faehrt dieses Skript den Router ueber $MODELS_DIR." >&2
exit 2
fi
set -- "$@" -m "$MODEL_PATH" -c "$CTX"
DESCRIPTION="$MODEL_PATH, Kontext $CTX"
;;
esac
fi
if [ "$PRINT_ONLY" -eq 1 ]; then
printf '%s' "$SERVER"
for ARG in "$@"; do
printf ' %s' "$ARG"
done
printf '\n'
exit 0
fi
if [ ! -x "$SERVER" ]; then
echo "llama-server fehlt: $SERVER" >&2
echo "vendor/llama/ ist gitignoriert; Herkunft und Baubefehl stehen in vendor/llama/HERKUNFT.md." >&2
exit 4
fi
# Ein leeres Cache-Verzeichnis ist kein Start, sondern eine Auskunft.
#
# Ohne diese Pruefung liefe der Router an und antwortete auf jede Anfrage mit
# einer leeren Modell-Liste, und aus der Ferne saehe das aus wie ein Defekt.
# Gesagt wird darum, was fehlt und wie man es holt.
if [ "$MODE" = router ]; then
# Ein Glob und kein find: `find -quit` gibt es nicht ueberall, und die Frage
# ist ohnehin nur, ob EINE Datei da ist.
HAVE_GGUF=0
for CANDIDATE in "$MODELS_DIR"/*.gguf; do
if [ -f "$CANDIDATE" ]; then
HAVE_GGUF=1
break
fi
done
if [ "$HAVE_GGUF" -eq 0 ]; then
echo "Kein Modell im Cache-Verzeichnis: $MODELS_DIR" >&2
echo "Dieses Programm liefert keines mit. So holen Sie eines (geht ins Netz):" >&2
echo " llm/fetch-model.sh unsloth/Qwen3.5-2B-GGUF" >&2
echo "oder direkt:" >&2
echo " LLAMA_CACHE=\"$MODELS_DIR\" $SERVER -hf unsloth/Qwen3.5-2B-GGUF:Q4_K_M" >&2
echo "Ein anderes Verzeichnis: ATLAS_MODELS_DIR=/pfad/zum/cache llm/start.sh" >&2
echo "Die Vorschlaege mit ihren gemessenen Zahlen stehen im Einstellungen-Panel." >&2
exit 5
fi
fi
# Laeuft der eigene Prozess schon? Die PID-Datei allein reicht als Auskunft
# nicht: sie ueberlebt einen Absturz. Gefragt wird deshalb das Betriebssystem.
if [ -f "$PID_FILE" ]; then
OLD=$(cat "$PID_FILE" 2>/dev/null || echo '')
if [ -n "$OLD" ] && kill -0 "$OLD" 2>/dev/null; then
echo "Sidecar laeuft bereits (pid $OLD) auf $HOST:$PORT. Nichts zu tun."
exit 0
fi
echo "verwaiste PID-Datei ($OLD) wird entfernt."
rm -f "$PID_FILE"
fi
# Der Port gehoert dem Sidecar. Wer sonst dort lauscht, wird nicht abgeschossen:
# ein Startskript, das fremde Prozesse beendet, ist ein Startskript, dem man
# nicht mehr zusehen kann.
if command -v lsof >/dev/null 2>&1; then
BUSY=$(lsof -ti "tcp:$PORT" 2>/dev/null || true)
if [ -n "$BUSY" ]; then
echo "Port $PORT ist belegt (pid $BUSY), aber nicht von diesem Skript." >&2
echo "Nichts gestartet und nichts beendet." >&2
exit 3
fi
fi
if [ "$MODE" = hf ]; then
echo "Dieser Aufruf geht ins Netz (huggingface.co) und laedt nach ${LLAMA_CACHE:-$MODELS_DIR}."
fi
cd "$ROOT"
nohup "$SERVER" "$@" > "$LOG_FILE" 2>&1 &
PID=$!
echo "$PID" > "$PID_FILE"
echo "Sidecar gestartet: pid $PID, $DESCRIPTION, http://$HOST:$PORT"
if [ "$MODE" = router ]; then
echo "Die Modelle im Cache: curl -s http://$HOST:$PORT/v1/models"
echo "Umschalten ohne Neustart: im Einstellungen-Panel der Oberflaeche."
fi
echo "Bereitschaft: curl -s http://$HOST:$PORT/health -> {\"status\":\"ok\"}"
echo "Log: $LOG_FILE"
echo "Beenden: $HERE/stop.sh"
+54
View File
@@ -0,0 +1,54 @@
#!/bin/sh
# Den eigenen Sidecar beenden, und nur den eigenen.
#
# Beendet wird ausschliesslich die PID aus llm/.pid. Kein pkill auf einen
# Namen, kein "was auch immer auf 4141 lauscht": beides wuerde fremde Prozesse
# treffen, die dieses Skript nie gestartet hat. Steht in der Datei eine PID, die
# es nicht mehr gibt, wird die Datei aufgeraeumt und das gesagt.
#
# Aufruf: llm/stop.sh
set -eu
HERE=$(cd "$(dirname "$0")" && pwd)
PID_FILE="$HERE/.pid"
if [ ! -f "$PID_FILE" ]; then
echo "keine PID-Datei ($PID_FILE): dieses Skript hat gerade nichts laufen."
exit 0
fi
PID=$(cat "$PID_FILE" 2>/dev/null || echo '')
if [ -z "$PID" ]; then
echo "PID-Datei war leer, wird entfernt."
rm -f "$PID_FILE"
exit 0
fi
if ! kill -0 "$PID" 2>/dev/null; then
echo "pid $PID laeuft nicht mehr; die PID-Datei wird entfernt."
rm -f "$PID_FILE"
exit 0
fi
kill "$PID" 2>/dev/null || true
# Erst hoeflich, dann nicht mehr. Fuenf Sekunden reichen dem Sidecar, um seine
# Slots abzuraeumen; laenger zu warten hiesse, jedem Beweislauf eine Pause
# aufzuzwingen, die nur der Hoeflichkeit dient.
i=0
while [ "$i" -lt 50 ]; do
if ! kill -0 "$PID" 2>/dev/null; then
rm -f "$PID_FILE"
echo "Sidecar beendet (pid $PID)."
exit 0
fi
sleep 0.1
i=$((i + 1))
done
echo "pid $PID reagierte nicht auf SIGTERM, sende SIGKILL." >&2
kill -9 "$PID" 2>/dev/null || true
sleep 0.3
rm -f "$PID_FILE"
echo "Sidecar hart beendet (pid $PID)."
+7
View File
@@ -0,0 +1,7 @@
# Diese sechs Summen sind der BELEG der Eval-Laeufe aus W5 (docs/adr/0001-modellwahl.md, Abschnitt "Eval-Ergebnis"), nicht eine Liste von Dateien, die dieses Programm mitliefert oder erwartet: seit W10 waehlt der Leser sein Modell selbst, models/ ist sein Cache, und wer nur drei dieser Dateien liegen hat, hat nichts Kaputtes, sondern drei Modelle.
85a896a047553e842f25297ee5b031d64ff30147d9c4af17b1e4b394cd1fab87 gemma-4-E4B-it-Q4_K_M.gguf
b1b3de114215d9507409a662a501a631095a479a419584e8a2ded6304b19b4f5 LFM2.5-1.2B-Instruct-Q4_K_M.gguf
81b64d05a23b17b34c475f42b3e72fbde62d4b92cc34541f7a8031d0752deafa MiniCPM5-1B-Q4_K_M.gguf
cc324af070c2ecbfd324a30884d2f951a7ff756aba85cb811a6ec436933bb046 qwen2.5-coder-1.5b-instruct-q4_k_m.gguf
aaf42c8b7c3cab2bf3d69c355048d4a0ee9973d48f16c731c0520ee914699223 Qwen3.5-2B-Q4_K_M.gguf
3874209241c9a397e2f62cd3f70f80fd2dfbf0dfccb6838416bdb48a714e8630 Qwen3.5-4B-Q4_K_M.gguf
+2205 -2950
View File
File diff suppressed because it is too large Load Diff
+81 -36
View File
@@ -1,47 +1,92 @@
{
"name": "graph-ui",
"name": "cbm-lens",
"version": "0.0.1",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Lokales Verstehens-Frontend auf dem CBM-Stack (localhost, air-gapped per Default).",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@react-three/drei": "^10.7.0",
"@react-three/fiber": "^9.5.0",
"@react-three/postprocessing": "^3.0.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"postprocessing": "^6.38.3",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.5.0",
"three": "~0.183.0"
"build": "tsc -b tsconfig.json tsconfig.node.json && vite build",
"test": "node --test tests/scaffold/*.test.mjs",
"test:acceptance": "node --test tests/scaffold/w*.test.mjs tests/scaffold/release-blockers.test.mjs",
"test:unit": "vitest run",
"verify": "node --test tests/scaffold/*.test.mjs",
"smoke:spike": "node tools/smoke-spike.mjs",
"smoke:w1": "node tools/smoke-w1.mjs",
"smoke:w1b": "node tools/smoke-w1b.mjs",
"smoke:w2a": "node tools/net-deny-gate.mjs --out verification/w2/netdeny.json -- node tools/smoke-w2a.mjs",
"smoke:w2b": "node tools/net-deny-gate.mjs --out verification/w2/netdeny-w2b.json -- node tools/smoke-w2b.mjs",
"smoke:w3": "node tools/net-deny-gate.mjs --out verification/w3/netdeny.json -- node tools/smoke-w3.mjs",
"smoke:w4a": "node tools/net-deny-gate.mjs --out verification/w4/netdeny.json -- node tools/smoke-w4a.mjs",
"smoke:w4b": "node tools/net-deny-gate.mjs --out verification/w4/netdeny-w4b.json -- node tools/smoke-w4b.mjs",
"smoke:w4c": "node tools/net-deny-gate.mjs --out verification/w4/netdeny-w4c.json -- node tools/smoke-w4c.mjs",
"smoke:w4d": "node tools/net-deny-gate.mjs --out verification/w4/netdeny-w4d.json -- node tools/smoke-w4d.mjs",
"smoke:w4e": "node tools/net-deny-gate.mjs --out verification/w4/netdeny-w4e.json -- node tools/smoke-w4e.mjs",
"smoke:w5a": "node tools/net-deny-gate.mjs --out verification/w5/netdeny-w5a.json -- node tools/smoke-w5a.mjs",
"smoke:w5b": "node tools/net-deny-gate.mjs --out verification/w5/netdeny-w5b.json -- node tools/smoke-w5b.mjs",
"smoke:w5c": "node tools/net-deny-gate.mjs --out verification/w5/netdeny-w5c.json -- node tools/smoke-w5c.mjs",
"smoke:w6": "node tools/net-deny-gate.mjs --out verification/w6/netdeny.json -- node tools/smoke-w6-full.mjs",
"smoke:w7a": "node tools/net-deny-gate.mjs --out verification/w7/netdeny.json -- node tools/smoke-w7a.mjs",
"smoke:w7b": "node tools/net-deny-gate.mjs --out verification/w7/netdeny-w7b.json -- node tools/smoke-w7b.mjs",
"smoke:w7c": "node tools/net-deny-gate.mjs --out verification/w7/netdeny-w7c.json -- node tools/smoke-w7c.mjs",
"smoke:w8": "node tools/net-deny-gate.mjs --out verification/w8/netdeny.json -- node tools/smoke-w8.mjs",
"smoke:w8b": "node tools/net-deny-gate.mjs --out verification/w8b/netdeny.json -- node tools/smoke-w8b.mjs",
"smoke:w8c": "node tools/net-deny-gate.mjs --out verification/w8c/netdeny.json -- node tools/smoke-w8c.mjs",
"smoke:w9": "node tools/net-deny-gate.mjs --out verification/w9/netdeny-w9.json -- node tools/smoke-w9.mjs",
"smoke:w10": "node tools/net-deny-gate.mjs --out verification/w10/netdeny.json -- node tools/smoke-w10.mjs",
"smoke:w10b": "node tools/net-deny-gate.mjs --out verification/w10b/netdeny.json -- node tools/smoke-w10b.mjs",
"smoke:w11a": "node tools/net-deny-gate.mjs --out verification/w11/netdeny.json -- node tools/smoke-w11a.mjs",
"smoke:w11b": "node tools/net-deny-gate.mjs --out verification/w11/netdeny-w11b.json -- node tools/smoke-w11b.mjs",
"smoke:w12": "node tools/net-deny-gate.mjs --out verification/w12/netdeny.json -- node tools/smoke-w12.mjs",
"smoke:w12a": "node tools/net-deny-gate.mjs --out verification/w12a/netdeny.json -- node tools/smoke-w12a.mjs",
"smoke:w13": "node tools/net-deny-gate.mjs --out verification/w13/netdeny.json -- node tools/smoke-w13.mjs",
"smoke:w14": "node tools/net-deny-gate.mjs --out verification/w14/netdeny.json -- node tools/smoke-w14.mjs",
"smoke:w15": "node tools/net-deny-gate.mjs --out verification/w15/netdeny.json -- node tools/smoke-w15.mjs",
"demo:record": "node tools/record-demo.mjs",
"gate:release": "node tools/release-gate.mjs",
"check:freshclone": "node tools/freshclone-check.mjs",
"check:style": "node tools/style-gate.mjs",
"check:promises": "node tools/promise-scan.mjs",
"eval:llm": "node tools/eval-llm.mjs",
"eval:check": "node tools/eval-check.mjs",
"verify:models": "node tools/verify-models.mjs"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.1.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/three": "~0.183.0",
"@vitejs/plugin-react": "^4.3.0",
"jsdom": "^25.0.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.7.0",
"vite": "^6.4.3",
"vitest": "^4.1.0"
"@types/node": "20.19.43",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
"@types/three": "0.183.1",
"@vitejs/plugin-react": "4.7.0",
"jsdom": "25.0.1",
"monaco-editor": "0.56.0",
"playwright": "1.62.1",
"three-stdlib": "2.36.1",
"typescript": "5.9.3",
"vite": "6.4.3",
"vitest": "4.1.11"
},
"overrides": {
"form-data": ">=4.0.6",
"@babel/core": ">=7.29.6",
"nanoid": "^3.3.17"
"sharp": "$sharp",
"monaco-editor": {
"dompurify": "3.4.16"
},
"onnxruntime-node@1.24.3": {
"adm-zip": "0.6.1",
"global-agent": "4.1.3"
}
},
"dependencies": {
"@huggingface/transformers": "4.2.0",
"@react-three/drei": "10.7.7",
"@react-three/fiber": "9.5.0",
"@react-three/postprocessing": "3.0.4",
"postprocessing": "6.38.3",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-markdown": "10.1.0",
"remark-gfm": "4.0.1",
"sharp": "file:./stubs/sharp",
"three": "0.183.2",
"yaml": "2.8.3"
}
}
+111
View File
@@ -0,0 +1,111 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>CodeAtlasWeb W0 Spike: Serverquelle in read-only Monaco</title>
<!--
W0-Spike. Wegwerf-Beweiscode, NICHT die Produktarchitektur.
Zweck: zeigen, dass der gebaute C-Server (PR 1860) echten Quelltext über
/rpc get_code_snippet liefert und Monaco ihn read-only mit genau einer
Decoration an der Call-Site rendert. Kein CDN: Monaco kommt aus
/node_modules/monaco-editor/min/vs.
-->
<style>
:root {
--atlas-bg: #06090b;
--atlas-panel: #0b1013;
--atlas-line: #16211f;
--atlas-phosphor: #33ff99;
--atlas-dim: #7d9a8e;
}
* { box-sizing: border-box; }
html, body {
height: 100%;
margin: 0;
background: var(--atlas-bg);
color: var(--atlas-phosphor);
font-family: "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
}
body {
display: flex;
flex-direction: column;
}
header {
flex: 0 0 auto;
padding: 10px 16px;
border-bottom: 1px solid var(--atlas-line);
background: var(--atlas-panel);
font-size: 12px;
letter-spacing: 0.04em;
display: flex;
gap: 18px;
align-items: baseline;
flex-wrap: wrap;
}
header .brand { color: var(--atlas-phosphor); font-weight: 700; }
header .meta { color: var(--atlas-dim); }
header .meta b { color: var(--atlas-phosphor); font-weight: 500; }
#editor {
flex: 1 1 auto;
min-height: 0;
width: 100%;
}
#status {
flex: 0 0 auto;
padding: 8px 16px;
border-top: 1px solid var(--atlas-line);
background: var(--atlas-panel);
color: var(--atlas-dim);
font-size: 12px;
}
#status.failed { color: #ff6b6b; }
/* Die eine Decoration an der Call-Site: Zeilenlicht plus Badge "1". */
.atlas-callsite-line {
background: rgba(51, 255, 153, 0.10);
box-shadow: inset 2px 0 0 0 var(--atlas-phosphor);
}
.atlas-callsite-glyph {
position: relative;
}
.atlas-callsite-glyph::after {
content: "1";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 15px;
height: 15px;
border-radius: 50%;
background: var(--atlas-phosphor);
color: var(--atlas-bg);
font: 700 10px/15px "SF Mono", Menlo, Consolas, monospace;
text-align: center;
}
</style>
</head>
<body>
<header>
<span class="brand">CODEATLAS W0 SPIKE</span>
<span class="meta">quelle <b id="hdr-source">/rpc get_code_snippet</b></span>
<span class="meta">symbol <b id="hdr-qn">...</b></span>
<span class="meta">datei <b id="hdr-file">...</b></span>
<span class="meta">modus <b id="hdr-mode">read-only</b></span>
</header>
<div id="editor"></div>
<div id="status">lade ...</div>
<script src="/node_modules/monaco-editor/min/vs/loader.js"></script>
<script src="/spike/spike.js"></script>
</body>
</html>
+210
View File
@@ -0,0 +1,210 @@
// W0-Spike. Wegwerf-Beweiscode, NICHT die Produktarchitektur.
//
// Ablauf: Symbol aus den Query-Parametern (?project=...&qn=...) holen, den
// Quelltext über POST /rpc (MCP tools/call get_code_snippet) beim gebauten
// C-Server abfragen, ihn read-only in Monaco rendern und genau eine
// Decoration auf die Zeile mit dem validateUser(-Aufruf setzen.
//
// Monaco kommt aus /node_modules/monaco-editor/min/vs (AMD-Loader), kein CDN.
(function () {
'use strict';
var MONACO_VS = '/node_modules/monaco-editor/min/vs';
var CALL_NEEDLE = 'validateUser(';
var statusEl = document.getElementById('status');
function setStatus(text, failed) {
statusEl.textContent = text;
statusEl.className = failed ? 'failed' : '';
}
function fail(stage, err) {
var message = (err && err.message) ? err.message : String(err);
setStatus('FEHLER in ' + stage + ': ' + message, true);
window.__spike = {
ready: false,
stage: stage,
error: message,
monacoReadOnly: false,
decorationCount: 0,
sourceLength: 0
};
}
function params() {
var q = new URLSearchParams(window.location.search);
return {
project: q.get('project') || '',
qn: q.get('qn') || ''
};
}
// POST /rpc im MCP-Format. Der Mini-Proxy vor dieser Seite reicht den
// Aufruf serverseitig an den C-Server weiter (dessen Origin-Prüfung
// lässt eine fremde Seite nicht direkt an /rpc).
function callTool(name, args) {
return fetch('/rpc', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: name, arguments: args }
})
}).then(function (res) {
if (!res.ok) {
throw new Error('/rpc antwortete mit HTTP ' + res.status);
}
return res.json();
}).then(function (body) {
if (body.error) {
throw new Error('/rpc-Fehler: ' + JSON.stringify(body.error));
}
var content = body.result && body.result.content;
if (!content || !content.length || typeof content[0].text !== 'string') {
throw new Error('/rpc lieferte kein content[0].text');
}
return JSON.parse(content[0].text);
});
}
// Die Call-Site suchen: die Zeile, die validateUser( aufruft, nicht der
// Import und nicht die Deklaration selbst.
function findCallSiteLine(source) {
var lines = source.split('\n');
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.indexOf(CALL_NEEDLE) === -1) { continue; }
var trimmed = line.trim();
if (trimmed.indexOf('import') === 0) { continue; }
if (/\bfunction\s+validateUser\s*\(/.test(trimmed)) { continue; }
return i + 1; // Monaco zählt Zeilen ab 1
}
return 0;
}
function loadMonaco() {
return new Promise(function (resolve, reject) {
if (typeof window.require !== 'function' || !window.require.config) {
reject(new Error('AMD-Loader aus ' + MONACO_VS + '/loader.js fehlt'));
return;
}
window.require.config({ paths: { vs: MONACO_VS } });
window.require(['vs/editor/editor.main'], function () {
resolve(window.monaco);
}, function (err) {
reject(new Error('Monaco ließ sich nicht laden: ' + (err && err.message ? err.message : String(err))));
});
});
}
function render(monaco, snippet) {
monaco.editor.defineTheme('atlas-phosphor', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': '#06090b',
'editorGutter.background': '#0b1013',
'editorLineNumber.foreground': '#2f4a40',
'editorLineNumber.activeForeground': '#33ff99'
}
});
var editor = monaco.editor.create(document.getElementById('editor'), {
value: snippet.source,
language: 'typescript',
theme: 'atlas-phosphor',
readOnly: true,
domReadOnly: true,
glyphMargin: true,
automaticLayout: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
renderLineHighlight: 'none',
fontSize: 14,
lineNumbers: function (n) {
// Zeilennummern der echten Datei, nicht des Ausschnitts.
return String((snippet.start_line || 1) + n - 1);
}
});
var callLine = findCallSiteLine(snippet.source);
var ids = [];
if (callLine > 0) {
ids = editor.deltaDecorations([], [{
range: new monaco.Range(callLine, 1, callLine, 1),
options: {
isWholeLine: true,
className: 'atlas-callsite-line',
glyphMarginClassName: 'atlas-callsite-glyph',
glyphMarginHoverMessage: { value: '1 Aufruf: validateUser' }
}
}]);
}
return { editor: editor, decorationIds: ids, callLine: callLine };
}
function main() {
var p = params();
if (!p.project || !p.qn) {
fail('parameter', new Error('project und qn sind Pflicht-Query-Parameter'));
return;
}
document.getElementById('hdr-qn').textContent = p.qn;
setStatus('hole Quelltext über /rpc get_code_snippet ...');
var snippet = null;
callTool('get_code_snippet', { project: p.project, qualified_name: p.qn })
.then(function (result) {
snippet = result;
if (typeof snippet.source !== 'string' || snippet.source.length === 0) {
throw new Error('get_code_snippet lieferte kein source-Feld');
}
document.getElementById('hdr-file').textContent =
(snippet.file_path || '?') + ':' + (snippet.start_line || '?');
setStatus('lade Monaco aus ' + MONACO_VS + ' ...');
return loadMonaco();
})
.then(function (monaco) {
var rendered = render(monaco, snippet);
var readOnly = rendered.editor.getOption(monaco.editor.EditorOption.readOnly);
window.__spike = {
ready: true,
monacoReadOnly: readOnly === true,
decorationCount: rendered.decorationIds.length,
sourceLength: snippet.source.length,
qualifiedName: snippet.qualified_name || p.qn,
filePath: snippet.file_path || '',
startLine: snippet.start_line || 0,
endLine: snippet.end_line || 0,
callSiteLine: rendered.callLine,
callSiteFileLine: rendered.callLine > 0
? (snippet.start_line || 1) + rendered.callLine - 1
: 0
};
setStatus(
'ok: ' + snippet.source.length + ' Zeichen vom Server, read-only=' +
(readOnly === true) + ', Decorations=' + rendered.decorationIds.length +
' auf Datei-Zeile ' + window.__spike.callSiteFileLine
);
})
.catch(function (err) {
fail('spike', err);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', main);
} else {
main();
}
})();
+5017 -119
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
/*
* Review of K42 ("other Refresh buttons the same way"): Refresh in ADR read
* the decision record again and nothing on screen changed (measured in the
* browser: no visible state for a single frame). It now says that it runs and
* then when it ran and whether anything changed, as the Architecture
* Refresh buttons do.
*/
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import AdrWorkspace from './AdrWorkspace';
import type { AdrRecord } from '../projects/projects-model';
import { clearAdrDraft } from './adr-model';
let host: HTMLDivElement, root: Root;
const project = 'adr-refresh-test';
const record = (content: string): AdrRecord => ({ hasAdr: true, content, updatedAt: '2026-09-14T12:00:00Z' });
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
clearAdrDraft(project);
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
});
afterEach(async () => { await act(async () => root.unmount()); host.remove(); clearAdrDraft(project); });
function deferred<T>() {
let resolve!: (value: T) => void, reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
const refresh = () => [...host.querySelectorAll('.adr-actions button')][0] as HTMLButtonElement;
const status = () => host.querySelector('.adr-actions .atlas-refresh-status')?.textContent;
const TIME = '\\d\\d:\\d\\d:\\d\\d';
it('K42: Refresh in ADR says that it runs, then when it ran and whether the record changed, or that it failed', async () => {
let next = deferred<AdrRecord>();
const adr = vi.fn().mockResolvedValueOnce(record('# Decisions')).mockImplementation(() => next.promise);
await act(async () => root.render(<AdrWorkspace project={project} active source={{ adr, saveAdr: vi.fn() }} />));
expect(refresh().textContent).toBe('Refresh');
expect(status()).toBe('');
await act(async () => refresh().click());
expect(refresh().textContent).toBe('Refreshing…');
expect(refresh().getAttribute('aria-disabled')).toBe('true');
await act(async () => next.resolve(record('# Decisions')));
expect(refresh().textContent).toBe('Refresh');
expect(status()).toMatch(new RegExp(`^Up to date at ${TIME}: no changes since the last load$`));
next = deferred<AdrRecord>();
await act(async () => refresh().click());
await act(async () => next.resolve(record('# Decisions\n\n## Storage')));
expect(status()).toMatch(new RegExp(`^Decisions refreshed at ${TIME}$`));
next = deferred<AdrRecord>();
await act(async () => refresh().click());
await act(async () => next.reject(new Error('offline')));
// The alert below the toolbar says what failed and offers Try again; the status beside the button only says when.
expect(status()).toMatch(new RegExp(`^Refresh failed at ${TIME}$`));
expect(host.querySelector('.adr-notice[role="alert"]')?.textContent).toContain('Could not load the decision record.');
});
+344
View File
@@ -0,0 +1,344 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import AdrWorkspace, { type AdrWorkspaceProps } from './AdrWorkspace';
import type { AdrRecord } from '../projects/projects-model';
import { clearAdrDraft, readAdrDraft } from './adr-model';
let host: HTMLDivElement;
let root: Root;
const record = (content = '# Decisions\n\n## Storage\n\nUse a local database.'): AdrRecord => ({ hasAdr: true, content, updatedAt: '2026-09-14T12:00:00Z' });
const empty: AdrRecord = { hasAdr: false, content: '', updatedAt: '' };
const projects = ['adr-test-alpha', 'adr-test-beta', 'adr-test-restored'];
const project = projects[0];
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
for (const name of projects) clearAdrDraft(name);
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount()); host.remove();
for (const name of projects) clearAdrDraft(name);
vi.restoreAllMocks();
});
function source(initial = record()) {
let current = initial;
return {
adr: vi.fn(async (_project: string) => ({ ...current })),
saveAdr: vi.fn(async (_project: string, content: string) => { current = record(content); }),
replace(next: AdrRecord) { current = next; },
};
}
async function render(api: AdrWorkspaceProps['source'], overrides: Partial<AdrWorkspaceProps> = {}) {
await act(async () => root.render(<AdrWorkspace project={project} active source={api} {...overrides} />));
}
function button(name: string): HTMLButtonElement {
const found = [...host.querySelectorAll('button')].find(candidate => candidate.textContent === name);
if (!found) throw new Error(`Missing button: ${name}`);
return found;
}
async function click(name: string) { await act(async () => button(name).click()); }
async function edit(content: string) {
if (!host.querySelector('textarea')) await click('Edit');
await act(async () => {
const input = host.querySelector('textarea')!;
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(input, content);
input.dispatchEvent(new Event('input', { bubbles: true }));
});
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
it('loads only while active and refreshes the same project when a clean reader is reopened', async () => {
const api = source();
await render(api, { active: false });
expect(api.adr).not.toHaveBeenCalled();
await render(api);
expect(api.adr).toHaveBeenCalledWith(project);
expect(host.querySelector('article')?.textContent).toContain('Use a local database.');
await render(api, { active: false });
api.replace(record('# New decision'));
await render(api);
expect(api.adr).toHaveBeenCalledTimes(2);
expect(host.querySelector('article')?.textContent).toBe('New decision');
});
it('preflights and verifies an explicit save; editing never writes automatically', async () => {
const api = source();
await render(api);
await edit('# Decisions\n\nUse SQLite.');
expect(api.saveAdr).not.toHaveBeenCalled();
expect(readAdrDraft(project)?.content).toContain('Use SQLite.');
await click('Save');
expect(api.adr).toHaveBeenCalledTimes(3);
expect(api.saveAdr).toHaveBeenCalledExactlyOnceWith(project, '# Decisions\n\nUse SQLite.');
expect(host.querySelector('textarea')).toBeNull();
expect(host.querySelector('[role="status"]')?.textContent).toBe('Saved');
expect(readAdrDraft(project)).toBeUndefined();
});
it('retains dirty edits when hidden, switched to another project, and remounted', async () => {
const api = source();
await render(api);
await edit('# My unsaved choice');
await render(api, { active: false });
await render(api);
expect(api.adr).toHaveBeenCalledTimes(1);
expect(host.querySelector('textarea')?.value).toBe('# My unsaved choice');
await render(api, { project: projects[1] });
expect(host.querySelector('textarea')).toBeNull();
await render(api);
expect(host.querySelector('textarea')?.value).toBe('# My unsaved choice');
expect(host.querySelector('strong')?.textContent).toBe(project);
await act(async () => root.unmount());
root = createRoot(host);
await render(api);
expect(host.querySelector('textarea')?.value).toBe('# My unsaved choice');
expect(api.saveAdr).not.toHaveBeenCalled();
});
it('restores a session-stored draft and its base after reload, then catches an intervening change', async () => {
const name = projects[2];
sessionStorage.setItem(`cbm.adr.draft:${encodeURIComponent(name)}`, JSON.stringify({ version: 1, base: record(), content: '# Restored draft' }));
const api = source(record('# Updated elsewhere'));
await render(api, { project: name });
expect(host.querySelector('textarea')?.value).toBe('# Restored draft');
expect(host.textContent).toContain('The server document changed.');
expect(button('Save').disabled).toBe(true);
expect(readAdrDraft(name)?.base.content).toBe(record().content);
});
it('does not replace a new project with the previous project’s late read', async () => {
const pending = deferred<AdrRecord>();
const api = source();
api.adr.mockImplementationOnce(() => pending.promise);
await render(api);
await render(api, { project: projects[1] });
await act(async () => pending.resolve(record('# Wrong old project')));
expect(host.querySelector('strong')?.textContent).toBe(projects[1]);
expect(host.textContent).not.toContain('Wrong old project');
});
it('shows a retry on read failure and prevents editing or saving an unread record', async () => {
const api = source();
api.adr.mockRejectedValueOnce(new Error('offline'));
await render(api);
expect(host.textContent).toContain('Could not load the decision record.');
expect(host.querySelector('textarea')).toBeNull();
expect([...host.querySelectorAll('button')].map(value => value.textContent)).not.toContain('Save');
await click('Try again');
await edit('# Recovered');
expect(button('Save').disabled).toBe(false);
});
it('keeps the draft through a busy save and succeeds on an explicit retry', async () => {
const api = source();
api.saveAdr.mockRejectedValueOnce({ status: 423 });
await render(api);
await edit('# Pending decision');
await click('Save');
expect(host.textContent).toContain('The project is busy.');
expect(host.querySelector('textarea')?.value).toBe('# Pending decision');
expect(readAdrDraft(project)?.content).toBe('# Pending decision');
expect(host.querySelector('[role="status"]')?.textContent).not.toBe('Saved');
await click('Save');
expect(host.querySelector('[role="status"]')?.textContent).toBe('Saved');
});
it('does not write when its preflight read fails', async () => {
const api = source();
await render(api);
await edit('# Keep until the server is readable');
api.adr.mockRejectedValueOnce(new Error('offline'));
await click('Save');
expect(api.saveAdr).not.toHaveBeenCalled();
expect(host.textContent).toContain('Could not check the latest version.');
expect(readAdrDraft(project)?.content).toBe('# Keep until the server is readable');
});
it('does not refresh a dirty draft on reentry after an earlier read retry', async () => {
const api = source();
api.adr.mockRejectedValueOnce(new Error('offline'));
await render(api);
await click('Try again');
await edit('# Keep my draft');
await render(api, { active: false });
await render(api);
expect(api.adr).toHaveBeenCalledTimes(2);
expect(host.querySelector('textarea')?.value).toBe('# Keep my draft');
});
it('keeps a draft across project switches when session storage is unavailable', async () => {
const api = source();
await render(api);
// Node's Web Storage can survive jsdom global setup; target the storage actually used.
const write = vi.spyOn(Object.getPrototypeOf(sessionStorage), 'setItem')
.mockImplementation(() => { throw new Error('storage unavailable'); });
await edit('# In-memory draft');
expect(write).toHaveBeenCalled();
expect(host.textContent).toContain('browser storage is unavailable');
await render(api, { project: projects[1] });
await render(api);
expect(host.querySelector('textarea')?.value).toBe('# In-memory draft');
expect(host.textContent).toContain('browser storage is unavailable');
});
it('blocks conflicting writes until the latest document has been explicitly reviewed', async () => {
const api = source();
await render(api);
await edit('# My change');
api.replace(record('# Another writer\n\nPreserve this decision.'));
await click('Save');
expect(api.saveAdr).not.toHaveBeenCalled();
expect(button('Save').disabled).toBe(true);
expect(host.querySelector('textarea')?.value).toBe('# My change');
expect([...host.querySelectorAll('button')].map(value => value.textContent)).not.toContain('Use latest as edit base');
await click('Review latest version');
expect(host.querySelector('[aria-label="Latest server version"]')?.textContent).toContain('Preserve this decision.');
await click('Use latest as edit base');
await edit('# Another writer\n\nPreserve this decision.\n\n## My change');
await click('Save');
expect(api.saveAdr).toHaveBeenCalledExactlyOnceWith(project, '# Another writer\n\nPreserve this decision.\n\n## My change');
});
it('can discard its conflicting draft and load the reviewed server document', async () => {
const api = source();
await render(api);
await edit('# My change');
api.replace(record('# New server record'));
await click('Save');
await click('Review latest version');
await click('Discard draft and load latest');
expect(host.querySelector('textarea')).toBeNull();
expect(host.querySelector('article')?.textContent).toBe('New server record');
expect(readAdrDraft(project)).toBeUndefined();
expect(api.saveAdr).not.toHaveBeenCalled();
});
it('does not report Saved after a verification failure and retains the draft for retry', async () => {
const api = source();
await render(api);
await edit('# Awaiting verification');
api.adr.mockResolvedValueOnce(record()).mockRejectedValueOnce(new Error('read-back failed'));
await click('Save');
expect(host.textContent).toContain('The save could not be verified.');
expect(readAdrDraft(project)?.content).toBe('# Awaiting verification');
expect(host.querySelector('[role="status"]')?.textContent).not.toBe('Saved');
await click('Save');
expect(api.saveAdr).toHaveBeenCalledTimes(1);
expect(host.querySelector('[role="status"]')?.textContent).toBe('Saved');
});
it('keeps a draft if the verified server content differs from what was submitted', async () => {
const api = source();
await render(api);
await edit('# Submitted');
api.adr.mockResolvedValueOnce(record()).mockResolvedValueOnce(record('# Concurrent replacement'));
await click('Save');
expect(host.textContent).toContain('The server returned different content after saving.');
expect(host.querySelector('textarea')?.value).toBe('# Submitted');
expect(button('Save').disabled).toBe(true);
expect(readAdrDraft(project)?.content).toBe('# Submitted');
});
it('disables edits during a save and ignores its completion after a project switch', async () => {
const pending = deferred<void>();
const api = source();
api.saveAdr.mockImplementationOnce(() => pending.promise);
await render(api);
await edit('# Original project draft');
await click('Save');
expect(host.querySelector('textarea')?.disabled).toBe(true);
expect(button('Cancel').disabled).toBe(true);
await render(api, { project: projects[1] });
await act(async () => pending.resolve());
expect(host.querySelector('strong')?.textContent).toBe(projects[1]);
expect(host.querySelector('[role="status"]')?.textContent).not.toBe('Saved');
expect(readAdrDraft(project)?.content).toBe('# Original project draft');
});
it('enforces UTF-8 content and complete JSON request limits', async () => {
const api = source();
await render(api);
await edit('🧭'.repeat(2001));
expect(button('Save').disabled).toBe(true);
expect(host.textContent).toContain('8,004 / 8,000 bytes');
await edit('\u0001'.repeat(3000));
expect(button('Save').disabled).toBe(true);
expect(host.textContent).toContain('The encoded save request is too large.');
await edit('a'.repeat(8000));
expect(button('Save').disabled).toBe(false);
});
it('renders Markdown safely and builds a navigable outline from actual headings', async () => {
const api = source(record('# Decisions\n\n## Same title\n\n| Choice | Reason |\n| --- | --- |\n| A | B |\n\n## Same title\n\n```js\nrun();\n```\n\n[unsafe](javascript:alert(1))\n\n<strong>Raw HTML stays text</strong>\n\n![remote](https://example.com/tracker.png)'));
const scroll = vi.fn();
const original = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = scroll;
try {
await render(api);
expect(host.querySelector('table')).not.toBeNull();
expect(host.querySelector('pre code')?.textContent).toBe('run();\n');
expect(host.querySelector('article strong, img, a[href^="javascript:"]')).toBeNull();
const nav = host.querySelector('[aria-label="On this page"]')!;
expect([...nav.querySelectorAll('button')].map(value => value.textContent)).toEqual(['Same title', 'Same title']);
const headings = [...host.querySelectorAll('article h1, article h2')];
expect(new Set(headings.map(value => value.id)).size).toBe(3);
await act(async () => nav.querySelectorAll('button')[1].click());
expect(scroll).toHaveBeenCalledOnce();
expect(document.activeElement).toBe(headings[2]);
} finally { HTMLElement.prototype.scrollIntoView = original; }
});
it('does not reserve an outline column for documents without headings', async () => {
await render(source(record('A short decision without a heading.')));
expect(host.querySelector('[aria-label="On this page"]')).toBeNull();
expect(host.querySelector('.adr-body')?.getAttribute('data-outline')).toBe('false');
});
it('creates a generic decision scaffold without saving until requested and cancels explicitly', async () => {
const api = source(empty);
await render(api);
await click('Create decision record');
const text = host.querySelector('textarea')?.value ?? '';
expect(text).toContain('### Context');
expect(text).toContain('### Alternatives');
expect(text).not.toContain(project);
expect(api.saveAdr).not.toHaveBeenCalled();
await click('Preview');
expect(host.querySelector('article h1')?.textContent).toBe('Architecture decisions');
await click('Cancel');
expect(readAdrDraft(project)).toBeUndefined();
expect(host.querySelector('textarea')).toBeNull();
});
it('warns before unloading only while a draft is dirty', async () => {
const api = source();
await render(api);
let event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
await edit('# Draft');
event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
await click('Cancel');
event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
});
it('does not request or offer writes without a selected project', async () => {
const api = source();
await render(api, { project: '' });
expect(api.adr).not.toHaveBeenCalled();
expect(host.textContent).toContain('Select a project');
expect(host.querySelector('button')).toBeNull();
});
+218
View File
@@ -0,0 +1,218 @@
import { useEffect, useId, useRef, useState } from 'react';
import type { JSX } from 'react';
import ChatMarkdown from '../browser-ai/ChatMarkdown';
import type { ProjectsSource } from '../projects/ProjectsPanel';
import type { AdrRecord } from '../projects/projects-model';
import { ADR_CONTENT_LIMIT, adrSize, appendDecision, clearAdrDraft, readAdrDraft, sameAdr, storeAdrDraft } from './adr-model';
import { adrStrings as s } from './adr-strings';
import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback';
import './adr.css';
export interface AdrWorkspaceProps {
project: string;
active: boolean;
source: Pick<ProjectsSource, 'adr' | 'saveAdr'>;
}
function busyError(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'status' in error && error.status === 423;
}
interface EditorState {
base?: AdrRecord;
content: string;
editing: boolean;
preview: boolean;
phase: 'idle' | 'loading' | 'ready' | 'error';
error: string;
notice: string;
conflict?: AdrRecord;
reviewing: boolean;
/** How many readings of the record started; the Refresh status speaks about one of them (review of K42). */
reads: number;
}
function ProjectAdrWorkspace({ project, active, source }: AdrWorkspaceProps): JSX.Element {
const [state, setState] = useState<EditorState>(() => {
const draft = readAdrDraft(project);
return { base: draft?.base, content: draft?.content ?? '', editing: !!draft, preview: false,
phase: 'idle', error: '', notice: draft ? s.restored : '', reviewing: false, reads: 0 };
});
const [saving, setSaving] = useState(false);
const [storageAvailable, setStorageAvailable] = useState(true);
const [reload, setReload] = useState(0);
const stateRef = useRef(state);
stateRef.current = state;
const mounted = useRef(true);
const savingRef = useRef(false);
const loadTicket = useRef(0);
const lastReload = useRef(0);
const hasLoaded = useRef(false);
const editor = useRef<HTMLTextAreaElement>(null);
const document = useRef<HTMLDivElement>(null);
const headingPrefix = useId();
const [outline, setOutline] = useState<{ id: string; label: string; level: number }[]>([]);
const dirty = !!state.base && state.content !== state.base.content;
const size = adrSize(project, state.content);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; loadTicket.current += 1; }; }, []);
useEffect(() => {
const current = stateRef.current;
const forced = reload !== lastReload.current;
lastReload.current = reload;
if (!active || savingRef.current || (hasLoaded.current && current.phase === 'ready' && current.base && current.content !== current.base.content && !forced)) return;
const ticket = ++loadTicket.current;
setState(value => ({ ...value, phase: 'loading', error: '', reads: value.reads + 1 }));
void source.adr(project).then(record => {
if (!mounted.current || ticket !== loadTicket.current) return;
hasLoaded.current = true;
setState(value => {
if (value.base && value.content !== value.base.content) {
return { ...value, phase: 'ready', error: '', conflict: sameAdr(value.base, record) ? undefined : record };
}
return { ...value, base: record, content: record.content, phase: 'ready', error: '', conflict: undefined };
});
}, error => {
if (mounted.current && ticket === loadTicket.current) setState(value => ({ ...value, phase: 'error', error: busyError(error) ? s.busy : s.readFailed }));
});
return () => { if (ticket === loadTicket.current) loadTicket.current += 1; };
}, [active, project, source, reload]);
useEffect(() => {
if (state.base && dirty) setStorageAvailable(storeAdrDraft(project, { version: 1, base: state.base, content: state.content }));
else if (state.base) clearAdrDraft(project);
}, [project, state.base, state.content, dirty]);
useEffect(() => {
if (!dirty) return;
const warn = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ''; };
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [dirty]);
useEffect(() => {
const headings = [...(document.current?.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6') ?? [])];
const entries = headings.map((heading, index) => {
heading.id = `${headingPrefix}-heading-${index}`;
heading.tabIndex = -1;
return { id: heading.id, label: heading.textContent ?? '', level: Number(heading.tagName.slice(1)) };
});
const level = entries.some(heading => heading.level === 2) ? 2 : Math.min(...entries.map(heading => heading.level));
setOutline(entries.filter(heading => heading.level === level));
}, [state.content, state.editing, state.preview, state.phase, headingPrefix]);
useEffect(() => { if (state.editing && active) editor.current?.focus(); }, [state.editing, active]);
const acceptSaved = (record: AdrRecord): void => {
clearAdrDraft(project);
setState(value => ({ ...value, base: record, content: record.content, phase: 'ready', editing: false,
error: '', notice: s.saved, conflict: undefined, reviewing: false }));
};
const save = async (): Promise<void> => {
const current = stateRef.current;
if (savingRef.current || current.phase !== 'ready' || !current.base || current.conflict
|| current.content === current.base.content || !adrSize(project, current.content).valid) return;
savingRef.current = true;
loadTicket.current += 1;
setSaving(true);
setState(value => ({ ...value, error: '', notice: '' }));
let stage: 'check' | 'write' | 'verify' = 'check';
try {
const latest = await source.adr(project);
if (!mounted.current) return;
if (!sameAdr(current.base, latest)) {
if (latest.content === current.content && (latest.hasAdr || current.content === '')) acceptSaved(latest);
else setState(value => ({ ...value, conflict: latest, reviewing: false }));
return;
}
stage = 'write';
await source.saveAdr(project, current.content);
if (!mounted.current) return;
stage = 'verify';
const verified = await source.adr(project);
if (!mounted.current) return;
if (verified.content !== current.content || (!verified.hasAdr && current.content !== '')) {
setState(value => ({ ...value, conflict: verified, reviewing: false, error: s.mismatch }));
return;
}
acceptSaved(verified);
} catch (error) {
if (mounted.current) setState(value => ({ ...value, error: busyError(error) ? s.busy
: stage === 'check' ? s.checkFailed : stage === 'verify' ? s.verifyFailed : s.saveFailed }));
} finally {
savingRef.current = false;
if (mounted.current) setSaving(false);
}
};
const startEditing = (append = false): void => setState(value => ({ ...value, editing: true,
content: append ? appendDecision(value.content) : value.content, error: '', notice: '' }));
const cancel = (): void => {
clearAdrDraft(project);
setState(value => ({ ...value, base: value.conflict ?? value.base, content: (value.conflict ?? value.base)?.content ?? '', editing: false,
preview: false, conflict: undefined, reviewing: false, error: '', notice: '' }));
};
const useLatest = (discard: boolean): void => setState(value => value.conflict ? ({ ...value,
base: value.conflict, content: discard ? value.conflict.content : value.content,
editing: !discard, conflict: undefined, reviewing: false, phase: 'ready', error: '', notice: discard ? '' : s.rebased }) : value);
const ready = state.phase === 'ready';
const documentVisible = !state.editing || state.preview;
/*
* Review of K42: Refresh read the record again and nothing on screen
* changed. Now it says that it runs, then when and whether the record
* changed; a failure only gets its time here, the alert below says what
* failed and offers Try again.
*/
const refresh = useRefreshFeedback({ key: String(state.reads), settled: state.phase === 'ready' || state.phase === 'error',
value: state.base && { content: state.base.content, updatedAt: state.base.updatedAt, hasAdr: state.base.hasAdr }, error: state.phase === 'error' ? '' : undefined });
return <section className="adr-workspace" aria-label={s.label} aria-busy={state.phase === 'loading' || saving}>
<header className="adr-toolbar">
<div className="adr-project"><strong>{project}</strong><span role="status">{saving ? s.saving : dirty ? s.draft : state.notice || (state.base?.updatedAt ? s.edited : s.noDate)}</span>
{!dirty && !state.notice && state.base?.updatedAt && <time dateTime={state.base.updatedAt}>{state.base.updatedAt}</time>}</div>
<div className="adr-actions">{state.editing ? <>
<button type="button" aria-pressed={state.preview} disabled={saving} onClick={() => setState(value => ({ ...value, preview: !value.preview }))}>{state.preview ? s.hidePreview : s.preview}</button>
<button type="button" disabled={saving} onClick={cancel}>{s.cancel}</button>
<button type="button" className="adr-primary" disabled={!ready || !dirty || !size.valid || saving || !!state.conflict} onClick={() => { void save(); }}>{saving ? s.saving : s.save}</button>
</> : <>
<RefreshControl labels={s.refreshFeedback} feedback={refresh.feedback} onRefresh={() => { refresh.begin(); setReload(value => value + 1); }} />
{ready && state.base?.hasAdr && <button type="button" onClick={() => startEditing()}>{s.edit}</button>}
{ready && state.base?.hasAdr && <button type="button" className="adr-primary" onClick={() => startEditing(true)}>{s.add}</button>}
</>}</div>
</header>
{state.error && <div className="adr-notice" role="alert"><span>{state.error}</span>
{state.phase === 'error' && <button type="button" onClick={() => setReload(value => value + 1)}>{s.retry}</button>}</div>}
{dirty && state.notice && <p className="adr-notice" role="status">{state.notice}</p>}
{dirty && !storageAvailable && <p className="adr-notice" role="status">{s.storageUnavailable}</p>}
{state.conflict && <div className="adr-conflict">
<div className="adr-notice" role="alert"><span>{s.conflict}</span><button type="button" disabled={saving}
aria-expanded={state.reviewing} onClick={() => setState(value => ({ ...value, reviewing: !value.reviewing }))}>{state.reviewing ? s.hideReview : s.review}</button></div>
{state.reviewing && <><section className="adr-latest" aria-label={s.latest}>
<p className="adr-section-label">{s.latest}</p>{state.conflict.content ? <ChatMarkdown text={state.conflict.content} /> : <p>{s.latestEmpty}</p>}
</section><div className="adr-conflict-actions"><button type="button" onClick={() => useLatest(false)}>{s.rebase}</button>
<button type="button" onClick={() => useLatest(true)}>{s.reload}</button></div></>}
</div>}
{state.phase === 'loading' && !state.base && <p className="adr-empty" role="status">{s.loading}</p>}
{state.editing && <div className="adr-edit-info"><span className={size.valid ? '' : 'adr-limit-exceeded'}>{s.size(size.bytes)}</span>
{!size.valid && <span role="alert">{size.bytes > ADR_CONTENT_LIMIT ? s.sizeExceeded : s.bodyExceeded}</span>}</div>}
{state.base && (state.editing || state.base.hasAdr) && <div className="adr-body" data-editing={state.editing} data-preview={state.preview} data-outline={outline.length > 0 && documentVisible}>
{state.editing && <textarea ref={editor} className="adr-editor" aria-label={s.editor} spellCheck={false} disabled={saving}
value={state.content} onChange={event => setState(value => ({ ...value, content: event.target.value, error: '', notice: '' }))} />}
{documentVisible && <>
<div ref={document} className="adr-document-area" aria-label={s.document}><article className="adr-document"><ChatMarkdown text={state.content} /></article></div>
{outline.length > 0 && <nav className="adr-outline" aria-label={s.outline}><p>{s.outline}</p>{outline.map(heading => <button
key={heading.id} type="button" data-level={heading.level} onClick={() => {
const node = document.current?.querySelector<HTMLElement>(`[id="${heading.id}"]`);
node?.scrollIntoView({ block: 'start', behavior: 'smooth' }); node?.focus({ preventScroll: true });
}}>{heading.label}</button>)}</nav>}
</>}
</div>}
{ready && !state.editing && !state.base?.hasAdr && <div className="adr-empty"><h2>{s.emptyTitle}</h2><p>{s.emptyBody}</p>
<button type="button" className="adr-primary" onClick={() => startEditing(true)}>{s.create}</button></div>}
</section>;
}
export default function AdrWorkspace(props: AdrWorkspaceProps): JSX.Element {
return props.project ? <ProjectAdrWorkspace key={props.project} {...props} />
: <section className="adr-workspace" aria-label={s.label}><p className="adr-empty">{s.noProject}</p></section>;
}

Some files were not shown because too many files have changed in this diff Show More