Merge branch 'main' into test/ts-lsp-allocation-failures

This commit is contained in:
Martin Vogel
2026-10-18 00:17:58 +02:00
committed by GitHub
1743 changed files with 2084992 additions and 882291 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"
]
}
}
}
+7
View File
@@ -15,3 +15,10 @@ 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
+20 -5
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
+19 -4
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,6 +53,7 @@ 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
+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
+181 -21
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) }}
@@ -160,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
@@ -187,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
@@ -205,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:
@@ -231,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
@@ -245,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
@@ -280,6 +396,17 @@ jobs:
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
@@ -297,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:
@@ -332,6 +463,7 @@ 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:
@@ -362,6 +494,17 @@ jobs:
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
@@ -370,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:
@@ -429,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) }}
@@ -506,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
@@ -529,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)
@@ -562,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
@@ -574,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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
with:
category: "/language:c-cpp"
+223 -33
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,6 +24,7 @@ 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,
@@ -30,33 +34,102 @@ jobs:
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 }}
@@ -64,7 +137,9 @@ jobs:
run: |
# The full .diff endpoint rejects large-but-valid PRs at 20k lines.
# The paginated files endpoint remains filename-only for this gate.
if FILES=$(gh api --paginate "repos/$REPO/pulls/$PR/files?per_page=100" --jq '.[].filename'); then
# 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
# The files endpoint renders the diff server-side too, and answers
@@ -81,36 +156,57 @@ jobs:
"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"
# A here-string, not a pipe: under this shell's pipefail, grep -q
# exits on its first match, printf dies of SIGPIPE once the list
# outgrows the pipe buffer, and the MATCH is then reported as a
# failure -- product=false, smoke skipped, on the largest PRs.
if 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)' <<<"$FILES"; then
echo "product=true" >> "$GITHUB_OUTPUT"
else
echo "product=false" >> "$GITHUB_OUTPUT"
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
@@ -153,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'
@@ -163,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'
@@ -173,31 +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
# product-code PRs pay for it. ──
# PRs whose selection holds the memwaste lane (memory core, memwaste
# tooling, full runs) pay for it. ──
memwaste:
needs: [changes]
if: ${{ !cancelled() && needs.changes.outputs.product == 'true' }}
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
+25 -1
View File
@@ -236,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'
@@ -271,9 +283,15 @@ 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@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
@@ -498,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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4
with:
sarif_file: results.sarif
@@ -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
+3
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
+267 -33
View File
@@ -71,13 +71,33 @@ 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) $(MEMWASTE_CFLAGS) $(CFLAGS_EXTRA)
CXXFLAGS_PROD = $(CXXFLAGS_COMMON) -O2 $(MEMWASTE_VENDOR_CFLAGS)
$(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
@@ -102,8 +122,10 @@ 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) $(COVERAGE_MARKER_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
@@ -309,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 \
@@ -327,6 +350,7 @@ FOUNDATION_SRCS = \
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 \
@@ -337,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 \
@@ -348,14 +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
@@ -398,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 = \
@@ -423,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)
@@ -435,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 \
@@ -442,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 \
@@ -451,12 +517,33 @@ PIPELINE_SRCS = \
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 \
@@ -485,7 +572,7 @@ 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/config_edit_path.c src/cli/activation_transaction.c
@@ -494,7 +581,16 @@ CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c src/cli/
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
@@ -614,6 +710,8 @@ 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 \
@@ -632,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 \
@@ -643,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 \
@@ -655,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 = \
@@ -672,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_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_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_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
@@ -716,12 +849,12 @@ 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_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
@@ -831,8 +964,14 @@ BUILD_DIR = build/c
# ── Object file compilation (grammars need relaxed warnings) ─────
# Grammar + tree-sitter runtime: compiled without -Werror (upstream code has warnings)
# $(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) \
$(MEMWASTE_VENDOR_CFLAGS)
$(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) \
@@ -850,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 fuzz
.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)
@@ -861,7 +1000,7 @@ $(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) ─
@@ -889,7 +1028,7 @@ $(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 $@ $<
@@ -940,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>)
@@ -970,7 +1111,7 @@ $(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)
$(BUILD_DIR)/prod_tre.o: $(TRE_SRC) $(BUILD_DIR)/.build-config | $(BUILD_DIR)
$(CC) $(TRE_CFLAGS) -O2 $(MEMWASTE_VENDOR_CFLAGS) -c -o $@ $<
endif
@@ -1018,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.
@@ -1032,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)
@@ -1124,28 +1265,35 @@ 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 $(GRAMMAR_UNITY_DEPS) | $(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)
$(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_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)
$(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)
@@ -1161,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)|MEMWASTE=$(MEMWASTE)|MEMWASTE_ACCESS=$(MEMWASTE_ACCESS)|MEMWASTE_PROFILE=$(MEMWASTE_PROFILE)
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)
@@ -1196,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
@@ -1364,6 +1516,84 @@ 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
@@ -1379,8 +1609,12 @@ 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 -o $@ \
$(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) \
+326 -128
View File
@@ -1,11 +1,12 @@
# 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-8050_passing-brightgreen)](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-10_languages-blue)](#hybrid-lsp)
[![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)
@@ -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 162 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. 17 MCP tools. No language runtime, hosted service, or API key. Plug and play across 45 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.
- **162 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.
- **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.
- **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.
- **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.
- **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,9 +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.
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).
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
@@ -171,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.
@@ -183,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
@@ -211,59 +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 `.codebase-memory/.gitattributes` line with `merge=ours` is auto-created on first export, so concurrent edits don't produce conflicts on the binary artifact
- **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:
- **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, so a repo that already has the blobs in history needs `git-filter-repo` to rewrite them first. 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**: never committed unless you want it. Add `.codebase-memory/` to `.gitignore` if you prefer everyone to reindex from scratch.
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?"
@@ -275,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 |
|-----------|------|-------|
@@ -293,28 +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
@@ -328,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`.
@@ -370,8 +502,8 @@ 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 the graph UI embedded (`--ui=true` works) |
| `graph-ui` | Just the built frontend assets (`dist/`) |
| `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:
@@ -379,7 +511,7 @@ Run directly without installing:
# Standard server
nix run github:DeusData/codebase-memory-mcp
# Server with the embedded graph UI
# 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
```
@@ -393,7 +525,7 @@ 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.
> **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
@@ -404,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 (8,060 tests across 141 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
@@ -433,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>
@@ -461,7 +620,7 @@ Add to `~/.claude.json` (user scope) or project `.mcp.json`:
}
```
Restart your agent. Verify with `/mcp` — you should see `codebase-memory-mcp` with 17 tools.
Restart your agent. Verify with `/mcp`; you should see `codebase-memory-mcp` with 17 tools.
</details>
@@ -476,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
@@ -496,7 +655,7 @@ overwrite user-modified agents.
| Zed | Detected | platform `settings.json` (JSONC) | `AGENTS.md` + shared skill |
| OpenCode | Detected | `$OPENCODE_CONFIG` or resolved global config | `AGENTS.md`, skill, three deny-by-default read-only agents; plugin adds grep/glob graph lookup, post-`read` coverage, first-tool-result session context, and post-compaction reinjection |
| 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 |
@@ -531,7 +690,7 @@ 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 |
@@ -630,7 +789,7 @@ 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.
@@ -661,7 +820,7 @@ codebase-memory-mcp cli search_graph --project my-project --label Function --for
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
@@ -669,18 +828,45 @@ 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` | Structural, BM25, and semantic search. Page structural rows with `offset`/`limit` and ranked semantic rows independently with `semantic_offset`/`semantic_limit`. |
| `trace_path` | BFS traversal — who calls a function and what it calls (alias: `trace_call_path`). Depth 1-5. |
| `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. |
@@ -690,9 +876,9 @@ JSON arguments can also be piped on stdin, for tools that take arguments. A tool
| `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 (`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` | Ingest runtime traces to validate HTTP_CALLS edges. |
| `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(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.
@@ -700,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`, `REFERENCES_FILE`, `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
@@ -716,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`.
@@ -735,7 +923,9 @@ 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
@@ -749,16 +939,16 @@ is preserved. See [Index resource limits](docs/INDEX_RESOURCE_LIMITS.md).
| 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` | 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`–`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–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
@@ -799,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.
@@ -829,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 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, 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
162 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 |
|------|-------|-----------|
@@ -844,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
@@ -860,8 +1052,9 @@ src/
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)
graph-ui/ CodeAtlasWeb: React, Monaco, and Three.js browser frontend
internal/cbm/ Vendored tree-sitter grammars (162 languages) + AST extraction engine
```
@@ -869,24 +1062,29 @@ internal/cbm/ Vendored tree-sitter grammars (162 languages) + AST extrac
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
+119 -5
View File
@@ -60,15 +60,26 @@ License summary:
smart-coin language, written for this project because no usable public
grammar exists; its source and corpus tests live in
`tools/tree-sitter-chialisp/`.
- Seven further grammars (`arkts`, `assembly`, `cfml`,
`cfscript`, `dotenv`, `pine`, `qml`) 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
- 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/`.
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
@@ -131,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
+51 -2
View File
@@ -88,10 +88,18 @@ 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). |
| `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. |
| `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*
@@ -200,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
@@ -221,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
+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

+3 -3
View File
@@ -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>,
@@ -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>
@@ -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>
+1 -1
View File
@@ -15,7 +15,7 @@
- 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
+1 -1
View File
@@ -63,7 +63,7 @@
packages = forAllSystems (pkgs: rec {
default = pkgs.stdenv.mkDerivation {
pname = "codebase-memory-mcp";
version = "0.11.0";
version = "0.12.1";
src = ./.;
+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
+2143 -2888
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.11"
"@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();
}
})();
-59
View File
@@ -1,59 +0,0 @@
/* @vitest-environment jsdom */
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";
import { messages } from "./lib/i18n";
vi.mock("./components/GraphTab", () => ({ GraphTab: () => null }));
vi.mock("./components/StatsTab", () => ({ StatsTab: () => null }));
vi.mock("./components/ControlTab", () => ({ ControlTab: () => null }));
vi.mock("./lib/i18n", async (importOriginal) => {
const actual = await importOriginal<typeof import("./lib/i18n")>();
return { ...actual, useUiMessages: () => messages.en };
});
describe("App", () => {
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
window.history.replaceState(null, "", "/");
});
it("shows the serving binary version", async () => {
vi.stubGlobal("fetch", vi.fn(async () =>
new Response(JSON.stringify({ lang: "en", version: "0.10.8" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
));
render(<App />);
expect(await screen.findByText("v0.10.8")).toBeVisible();
});
it("hides the version when the config has no string version", async () => {
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify({ lang: "en", version: 108 }), { status: 200 }),
);
vi.stubGlobal("fetch", fetchMock);
render(<App />);
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/ui-config"));
expect(screen.queryByTitle("Server version")).not.toBeInTheDocument();
});
it("hides the version when the config request fails", async () => {
const fetchMock = vi.fn(async () => {
throw new Error("offline");
});
vi.stubGlobal("fetch", fetchMock);
render(<App />);
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("/api/ui-config"));
expect(screen.queryByTitle("Server version")).not.toBeInTheDocument();
});
});
+5020 -146
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>;
}
+61
View File
@@ -0,0 +1,61 @@
import type { AdrRecord } from '../projects/projects-model';
export const ADR_CONTENT_LIMIT = 8000;
export const ADR_BODY_LIMIT = 16_384;
export interface AdrDraft {
version: 1;
base: AdrRecord;
content: string;
}
const memoryDrafts = new Map<string, AdrDraft>();
const draftKey = (project: string) => `cbm.adr.draft:${encodeURIComponent(project)}`;
export function sameAdr(left: AdrRecord, right: AdrRecord): boolean {
return left.hasAdr === right.hasAdr && left.content === right.content;
}
export function adrSize(project: string, content: string) {
const encoder = new TextEncoder();
const bytes = encoder.encode(content).length;
const bodyBytes = encoder.encode(JSON.stringify({ project, content })).length;
return { bytes, bodyBytes, valid: bytes <= ADR_CONTENT_LIMIT && bodyBytes <= ADR_BODY_LIMIT };
}
export function readAdrDraft(project: string): AdrDraft | undefined {
const key = draftKey(project);
const inMemory = memoryDrafts.get(key);
if (inMemory) return inMemory;
try {
const value: unknown = JSON.parse(sessionStorage.getItem(key) ?? 'null');
if (!value || typeof value !== 'object') return;
const draft = value as Partial<AdrDraft>;
if (draft.version !== 1 || typeof draft.content !== 'string' || !draft.base
|| typeof draft.base.hasAdr !== 'boolean' || typeof draft.base.content !== 'string'
|| typeof draft.base.updatedAt !== 'string') return;
if (draft.content === draft.base.content) return;
const restored: AdrDraft = { version: 1, content: draft.content, base: { ...draft.base } };
memoryDrafts.set(key, restored);
return restored;
} catch { return; }
}
/** Memory also retains drafts across project switches when browser storage is unavailable. */
export function storeAdrDraft(project: string, draft: AdrDraft): boolean {
const key = draftKey(project);
memoryDrafts.set(key, draft);
try { sessionStorage.setItem(key, JSON.stringify(draft)); return true; } catch { return false; }
}
export function clearAdrDraft(project: string): void {
const key = draftKey(project);
memoryDrafts.delete(key);
try { sessionStorage.removeItem(key); } catch { /* Storage can be unavailable. */ }
}
/** Generic authoring scaffold; no repository facts are invented. */
export function appendDecision(content: string): string {
const heading = content.trim() ? '\n\n---\n\n## Decision title' : '# Architecture decisions\n\n## Decision title';
return `${content.trimEnd()}${heading}\n\n**Status:** Proposed\n\n### Context\n\nWhat problem or constraint led to this decision?\n\n### Decision\n\nWhat will change, and why?\n\n### Alternatives\n\nWhich options were considered?\n\n### Consequences\n\nWhat benefits, costs, and follow-up work should readers know?\n`;
}
+44
View File
@@ -0,0 +1,44 @@
export const adrStrings = {
label: 'Architecture decisions',
noProject: 'Select a project to read its architecture decisions.',
loading: 'Loading decisions…',
/** Review of K42: the Refresh button says that it runs, then when it ran (shared words in ui/refresh/strings.ts). */
refreshFeedback: { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Decisions refreshed at ${time}` },
retry: 'Try again',
edit: 'Edit',
add: 'Add decision',
create: 'Create decision record',
emptyTitle: 'Record the decisions behind this project.',
emptyBody: 'Keep context, alternatives, and consequences together in a Markdown document shared with your agents.',
save: 'Save',
saving: 'Saving…',
saved: 'Saved',
cancel: 'Cancel',
preview: 'Preview',
hidePreview: 'Hide preview',
editor: 'Decision record Markdown',
document: 'Decision document',
outline: 'On this page',
draft: 'Unsaved draft',
restored: 'Draft restored. Changes are saved only when you choose Save.',
edited: 'Updated',
noDate: 'Markdown document',
readFailed: 'Could not load the decision record. Try again.',
saveFailed: 'Could not save. Your draft is intact; try again.',
checkFailed: 'Could not check the latest version. Your draft is intact; try again.',
verifyFailed: 'The save could not be verified. Your draft is intact; try again.',
busy: 'The project is busy. Wait for indexing to finish, then try again.',
conflict: 'The server document changed. Review the latest version before saving your draft.',
mismatch: 'The server returned different content after saving. Your draft is intact; review the latest version.',
review: 'Review latest version',
hideReview: 'Hide latest version',
latest: 'Latest server version',
latestEmpty: 'The server returned no document content.',
reload: 'Discard draft and load latest',
rebase: 'Use latest as edit base',
rebased: 'Your draft remains in the editor. Incorporate the reviewed changes before saving.',
storageUnavailable: 'The draft is kept for this page session, but browser storage is unavailable. Keep this page open until you save.',
size: (bytes: number) => `${bytes.toLocaleString('en-US')} / 8,000 bytes`,
sizeExceeded: 'Keep the document within 8,000 UTF-8 bytes.',
bodyExceeded: 'The encoded save request is too large. Shorten the document before saving.',
};
+77
View File
@@ -0,0 +1,77 @@
.adr-workspace {
--adr-border: #a9cbb420;
--adr-muted: #93aa9b;
--chat-border: var(--adr-border);
--chat-muted: var(--adr-muted);
--chat-accent: #abd4ba;
display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden;
color: #dce5df; background: #0d1410ed; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.adr-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 16px 22px; border-bottom: 1px solid var(--adr-border); }
.adr-project { display: flex; align-items: baseline; gap: 10px; min-width: 0; flex-wrap: wrap; }
.adr-project strong { font-size: 14px; font-weight: 550; overflow-wrap: anywhere; }
.adr-project :is(span, time) { font-size: 11px; color: var(--adr-muted); }
.adr-actions, .adr-conflict-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.adr-workspace button { border: 1px solid #a4c3ae30; border-radius: 6px; padding: 7px 12px; color: #c7d9cd; background: #a1c3ae08; font: inherit; font-size: 12px; cursor: pointer; }
.adr-workspace button:hover:not(:disabled) { background: #a1c3ae18; border-color: #a4c3ae65; }
.adr-workspace button:disabled { cursor: default; opacity: .45; }
.adr-workspace .adr-primary { background: #a1c3ae1c; border-color: #a4c3ae48; color: #dcece2; }
.adr-workspace :is(button, textarea):focus-visible { outline: 2px solid #a8cdb7; outline-offset: 2px; }
.adr-body { display: grid; grid-template-columns: minmax(0, 1fr); flex: 1; min-height: 0; overflow: hidden; }
.adr-body[data-outline="true"] { grid-template-columns: minmax(0, 1fr) 190px; }
.adr-body[data-editing="true"][data-preview="false"] { grid-template-columns: minmax(0, 1fr); }
.adr-body[data-editing="true"][data-preview="true"] { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.adr-body[data-editing="true"][data-preview="true"][data-outline="true"] { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 170px; }
.adr-document-area { min-width: 0; overflow: auto; overscroll-behavior: contain; padding: clamp(20px, 4vw, 48px); scroll-padding-top: 24px; }
.adr-document { max-width: 80ch; margin: 0 auto; font-size: 14px; line-height: 1.75; }
.adr-workspace .cbm-chat-markdown { min-width: 0; overflow-wrap: anywhere; }
.adr-workspace .cbm-chat-markdown :is(h1, h2, h3, h4, h5, h6) { font-family: inherit; font-weight: 550; line-height: 1.3; letter-spacing: -.02em; margin: 1.7em 0 .65em; }
.adr-workspace .cbm-chat-markdown > :first-child { margin-top: 0; }
.adr-workspace .cbm-chat-markdown h1 { font-size: 27px; }
.adr-workspace .cbm-chat-markdown h2 { font-size: 21px; }
.adr-workspace .cbm-chat-markdown h3 { font-size: 16px; }
.adr-workspace .cbm-chat-markdown :is(h4, h5, h6) { font-size: 14px; }
.adr-workspace .cbm-chat-markdown p { margin: .75em 0; }
.adr-workspace .cbm-chat-markdown a { color: #b3d7c0; text-underline-offset: 3px; }
.adr-workspace .cbm-chat-markdown :is(ul, ol) { padding-left: 24px; }
.adr-workspace .cbm-chat-markdown li { margin: 5px 0; }
.adr-workspace .cbm-chat-markdown hr { margin: 2em 0; border: 0; border-top: 1px solid var(--adr-border); }
.adr-workspace .cbm-chat-markdown blockquote { margin-left: 0; border-left: 2px solid #789886; padding: 3px 16px; color: #a8bcae; }
.adr-workspace .cbm-chat-code-block { border: 1px solid var(--adr-border); border-radius: 7px; background: #07100b; overflow: hidden; }
.adr-workspace .cbm-chat-code-header { display: flex; align-items: center; justify-content: space-between; padding: 7px 11px; color: var(--adr-muted); background: #adccba09; }
.adr-workspace .cbm-chat-code-header button { padding: 3px 8px; }
.adr-workspace .cbm-chat-code-block pre { max-width: 100%; overflow: auto; margin: 0; padding: 14px; white-space: pre; }
.adr-workspace .cbm-chat-code-block pre > code { font: 12px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; color: #c8d8cd; }
.adr-workspace .cbm-chat-markdown :not(pre) > code { font-size: .9em; padding: 2px 4px; border-radius: 4px; color: #c4d9cb; background: #a6c8b410; }
.adr-workspace .cbm-chat-table-scroll { overflow-x: auto; }
.adr-workspace .cbm-chat-markdown table { width: 100%; border-collapse: collapse; font-size: 13px; }
.adr-workspace .cbm-chat-markdown :is(th, td) { border: 1px solid var(--adr-border); padding: 9px 11px; text-align: left; }
.adr-workspace .cbm-chat-markdown th { background: #a1c3ae0b; font-weight: 550; }
.adr-workspace .cbm-chat-markdown .task-list-item { list-style-type: none; }
.adr-outline { min-height: 0; overflow: auto; border-left: 1px solid var(--adr-border); padding: 24px 14px; }
.adr-outline p, .adr-section-label { color: var(--adr-muted); font-size: 11px; margin: 0 0 12px; }
.adr-outline button { display: block; width: 100%; border: 0; padding: 6px 8px; border-radius: 4px; background: transparent; text-align: left; line-height: 1.4; overflow-wrap: anywhere; color: var(--adr-muted); }
.adr-outline button[data-level="2"] { color: #c0d3c7; }
.adr-outline button:is([data-level="3"], [data-level="4"], [data-level="5"], [data-level="6"]) { padding-left: 18px; font-size: 11px; }
.adr-editor { width: 100%; height: 100%; min-height: 0; min-width: 0; resize: none; border: 0; border-radius: 0; border-right: 1px solid var(--adr-border); padding: 24px; background: #080f0bd9; color: #c9dacf; font: 13px/1.75 ui-monospace, SFMono-Regular, Menlo, monospace; tab-size: 4; }
.adr-edit-info { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 9px 22px; color: var(--adr-muted); font-size: 11px; border-bottom: 1px solid var(--adr-border); }
.adr-limit-exceeded { color: #d8baa4; }
.adr-notice { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 22px; margin: 0; border-bottom: 1px solid var(--adr-border); color: #c2cdbf; background: #d5c5a909; font-size: 12px; }
.adr-notice button { flex-shrink: 0; }
.adr-conflict { max-height: 48%; overflow: auto; flex-shrink: 0; }
.adr-latest { padding: 20px 24px; border-bottom: 1px solid var(--adr-border); font-size: 13px; line-height: 1.65; background: #070d09; }
.adr-conflict-actions { padding: 12px 22px; border-bottom: 1px solid var(--adr-border); }
.adr-empty { margin: auto; max-width: 60ch; padding: 40px 24px; color: var(--adr-muted); font-size: 14px; line-height: 1.65; }
.adr-empty h2 { font-size: 22px; font-weight: 500; color: #d0ddd5; letter-spacing: -.025em; }
@media (max-width: 1050px) {
.adr-body[data-editing="true"][data-preview="true"][data-outline] { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.adr-body[data-editing="true"] .adr-outline { display: none; }
}
@media (max-width: 680px) {
.adr-toolbar { padding: 12px 14px; gap: 12px; }
.adr-body, .adr-body[data-outline] { grid-template-columns: minmax(0, 1fr); }
.adr-outline { display: none; }
.adr-body[data-editing="true"][data-preview="true"][data-outline] { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(180px, 1fr) minmax(180px, 1fr); overflow: auto; }
.adr-document-area, .adr-editor { padding: 20px; }
.adr-notice { align-items: flex-start; flex-wrap: wrap; padding: 12px 14px; }
}
@@ -0,0 +1,34 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { expect, it, vi } from 'vitest';
import ActivityPanel from './ActivityPanel';
import { emptyAgentsState, withEvent } from './agent-store';
import type { AgentEvent } from './agent-event';
it('keeps unmapped evidence visible and only opens recorded graph locations', async () => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
const host = document.createElement('div'); document.body.append(host);
const root = createRoot(host);
const event: AgentEvent = { ts: 1000, agent: 'Ada', run: 'one', seq: 1, phase: 'end',
tool: 'Read', path: 'missing.ts', detail: 'inspect', source: 'bridge', replay: false };
const onOpenNode = vi.fn();
const props = { state: withEvent(emptyAgentsState(), event), on: false, port: 4142,
status: { state: 'off' as const, origin: '', requests: 0, drops: 0, hello: undefined, error: '' },
onToggle: vi.fn(), onOpenNode };
try {
await act(async () => root.render(<ActivityPanel {...props} graph={{ nodes: [], edges: [], total_nodes: 100 }} />));
expect(host.querySelector('.cbm-activity-filters input')).toBeNull();
expect(host.querySelectorAll('.cbm-activity-filters select')).toHaveLength(3);
await act(async () => host.querySelector<HTMLButtonElement>('.cbm-activity-row')!.click());
expect(host.querySelector('.cbm-activity-inspector')?.textContent).toContain('Unmapped');
expect(host.querySelector('.cbm-activity-inspector')?.textContent).toContain('loaded graph');
expect(host.querySelector('.cbm-activity-inspector')?.textContent).not.toContain('the index has no node');
expect(host.querySelector('.cbm-activity-inspector button')).toBeNull();
expect(host.textContent).toContain('Its result is not reported');
const node = { id: 1, x: 0, y: 0, z: 0, size: 1, color: 'green', label: 'Module', name: 'main', file_path: 'missing.ts' };
await act(async () => root.render(<ActivityPanel {...props} graph={{ nodes: [node], edges: [], total_nodes: 1 }} />));
await act(async () => host.querySelector<HTMLButtonElement>('.cbm-activity-inspector button')!.click());
expect(onOpenNode).toHaveBeenCalledWith(node);
} finally { await act(async () => root.unmount()); host.remove(); }
});
+96
View File
@@ -0,0 +1,96 @@
import { useMemo, useState } from 'react';
import type { JSX } from 'react';
import type { AgentEvent } from './agent-event';
import { eventKey, workKindOf } from './agent-event';
import type { AgentsState } from './agent-store';
import type { AgentSourceStatus } from './agent-source';
import type { GraphData, GraphNode } from '../galaxy/types';
import { buildPlacementIndex, placeEvent } from './agent-placement';
import { activityRows } from './activity-model';
import type { ActivityFilter } from './activity-model';
import { activityStrings as s } from './activity-strings';
import './activity.css';
import AgentSetup from './AgentSetup';
import type { AtlasApi } from '../app/atlas-api';
interface Props {
state: AgentsState; status: AgentSourceStatus; on: boolean; port: number;
project?: string; api?: Pick<AtlasApi, 'repoInfo'>;
graph?: GraphData; onToggle: () => void; onOpenNode: (node: GraphNode) => void;
}
const emptyFilter: ActivityFilter = { agent: '', run: '', kind: '' };
const rowKey = (row: AgentEvent): string => `${row.agent}:${eventKey(row)}`;
export default function ActivityPanel(props: Props): JSX.Element {
const [filter, setFilter] = useState<ActivityFilter>(emptyFilter);
const [selected, setSelected] = useState<string>();
const rows = useMemo(() => activityRows(props.state, filter), [props.state, filter]);
const all = useMemo(() => activityRows(props.state, emptyFilter), [props.state]);
const index = useMemo(() => buildPlacementIndex(props.graph?.nodes ?? []), [props.graph]);
const entry = all.find((row) => rowKey(row) === selected);
const placement = entry === undefined ? undefined : placeEvent(entry, workKindOf(entry.tool, entry.detail), index);
const target = placement?.nodeId === undefined ? undefined : props.graph?.nodes.find((node) => node.id === placement.nodeId);
const agents = [...new Set(all.map((row) => row.agent))].sort();
const runs = [...new Set(all.filter((row) => !filter.agent || row.agent === filter.agent).map((row) => row.run))].sort();
const set = (patch: Partial<ActivityFilter>): void => setFilter((value) => ({ ...value, ...patch }));
return <section className="cbm-activity" aria-label={s.title}>
<header className="cbm-page-head">
<div><p className="cbm-eyebrow">{s.live}</p><h2>{s.title}</h2><p>{s.subtitle}</p></div>
<div className="cbm-activity-connection"><span data-state={props.status.state}>{props.status.state}</span>
<button type="button" onClick={props.onToggle}>{props.on ? s.disconnect : s.connect}</button></div>
</header>
<AgentSetup api={props.api} project={props.project} port={props.port} />
<p className="cbm-activity-source-status">{props.on ? props.status.state === 'connected' ? 'History polling connected' : 'Waiting for daemon history' : 'History polling off'} · {all.length ? <>Latest recorded tool call: <time dateTime={new Date(Math.max(...all.map(row => row.ts))).toISOString()}>{new Date(Math.max(...all.map(row => row.ts))).toLocaleString()}</time>. This does not mean an agent is currently working.</> : 'No recorded tool call has been received for this project.'}</p>
<div className="cbm-activity-metrics">
{[[s.events, props.state.actors.filter((actor) => !actor.you).reduce((sum, actor) => sum + actor.count, 0)], [s.actors, props.state.actors.filter((actor) => !actor.you).length],
[s.gaps, props.state.missed], [s.dropped, props.status.drops]].map(([label, value]) =>
<div key={label}><strong>{value}</strong><span>{label}</span></div>)}
</div>
{!props.on && <p className="cbm-activity-notice">{all.length > 0 ? s.disconnected : s.noSource} <code>{`127.0.0.1:${props.port}`}</code></p>}
{props.status.error && <p role="status" className="cbm-activity-notice">{props.status.error}</p>}
{props.state.unreadable > 0 && <p role="status" className="cbm-activity-notice">{s.unreadable}: {props.state.unreadable}</p>}
<div className="cbm-activity-filters">
<select aria-label={s.agent} value={filter.agent} onChange={(event) => set({ agent: event.target.value, run: '' })}>
<option value="">{s.allAgents}</option>{agents.map((value) => <option key={value}>{value}</option>)}
</select>
<select aria-label={s.run} value={filter.run} onChange={(event) => set({ run: event.target.value })}>
<option value="">{s.allRuns}</option>{runs.map((value) => <option key={value}>{value}</option>)}
</select>
<select aria-label={s.kind} value={filter.kind} onChange={(event) => set({ kind: event.target.value as ActivityFilter['kind'] })}>
<option value="">{s.allKinds}</option>{s.kinds.map((value) => <option key={value}>{value}</option>)}
</select>
<button type="button" onClick={() => setFilter(emptyFilter)}>{s.clear}</button>
</div>
<div className="cbm-activity-body">
<div className="cbm-activity-list" role="list" aria-label={s.title}>
{rows.length === 0 && <div className="cbm-activity-empty">{all.length === 0 ? s.noEvents : s.noMatch}</div>}
{rows.map((row) => {
const kind = workKindOf(row.tool, row.detail);
const mapped = placeEvent(row, kind, index);
return <button type="button" key={rowKey(row)} className="cbm-activity-row"
aria-pressed={rowKey(row) === selected} onClick={() => setSelected(rowKey(row))}>
<span className="cbm-activity-dot" data-kind={kind} />
<span><strong>{row.agent}</strong><span className="cbm-activity-action">{row.tool}</span>
<code>{row.path || row.detail || s.unknown}</code>
<small>{row.run} · {row.phase} · {row.replay ? s.replay : s.live}{mapped.kind === 'none' ? ` · ${s.unmapped}` : ''}</small></span>
<time dateTime={Number.isFinite(new Date(row.ts).getTime()) ? new Date(row.ts).toISOString() : undefined}>{new Date(row.ts).toLocaleTimeString()}</time>
</button>;
})}
</div>
<aside className="cbm-activity-inspector"><h3>{s.evidence}</h3>
{entry === undefined ? <p>{s.select}</p> : <>
<h4>{entry.tool}</h4><p>{entry.agent} · {entry.run}</p>
<dl>{[[s.timestamp, new Date(entry.ts).toLocaleString()], [s.phase, entry.phase],
[s.source, entry.source || s.unknown], [s.mapping, placement?.kind === 'none' ? s.unmapped : placement?.kind ?? s.unknown]]
.map(([label, value]) => <div key={label}><dt>{label}</dt><dd>{value}</dd></div>)}</dl>
{entry.path && <code>{entry.path}{entry.lines ? `:${entry.lines.join('-')}` : ''}</code>}
{placement?.why && <p>{placement.kind === 'none' && entry.path ? s.noGraphMatch : placement.why}</p>}
{target !== undefined && <button type="button" onClick={() => props.onOpenNode(target)}>{s.open}</button>}
<h4>{s.details}</h4><pre>{entry.detail || s.unknown}</pre>
<p className="cbm-activity-note">{s.outcome}</p>
</>}
</aside>
</div>
<footer><p>{s.retained}</p><p className="atlas-guidance-note">{s.limited}</p></footer>
</section>;
}
+34
View File
@@ -0,0 +1,34 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import AgentSetup, { agentInstallCommand } from './AgentSetup';
let host: HTMLDivElement; let root: Root;
beforeEach(() => { (globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true; host = document.createElement('div'); document.body.append(host); root = createRoot(host); });
afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.restoreAllMocks(); });
const open = async () => { await act(async () => { const details = host.querySelector('details')!; details.open = true; details.dispatchEvent(new Event('toggle')); }); };
it('exposes actionable setup without querying or changing anything until opened', async () => {
const api = { repoInfo: vi.fn().mockResolvedValue({ rootPath: '/repo with spaces', branch: 'main', remoteUrl: '' }) };
await act(async () => root.render(<AgentSetup api={api} project="project-a" port={9749} />));
expect(host.querySelector('summary')?.textContent).toBe('Connect your agent');
expect(api.repoInfo).not.toHaveBeenCalled();
await open();
expect(api.repoInfo).toHaveBeenCalledWith('project-a');
expect(host.querySelector('pre')?.textContent).toContain("--root '/repo with spaces' --project 'project-a'");
expect(host.textContent).toContain('no file contents');
expect(host.textContent).toContain('does not configure your coding client');
expect(host.textContent).toContain('Other coding clients need their own event adapter');
});
it('never presents the previous repository command when a new root fails to load', async () => {
const api = { repoInfo: vi.fn().mockResolvedValueOnce({ rootPath: '/repo-a' }).mockRejectedValueOnce(new Error('repository unavailable')) };
await act(async () => root.render(<AgentSetup api={api} project="a" port={9749} />)); await open();
expect(host.querySelector('pre')?.textContent).toContain('/repo-a');
await act(async () => root.render(<AgentSetup api={api} project="b" port={9749} />));
expect(host.querySelector('pre')).toBeNull();
expect(host.querySelector('[role="alert"]')?.textContent).toContain('repository unavailable');
});
it('quotes project and root values as literal shell words', () => {
const command = agentInstallCommand("/repo'; touch /tmp/unwanted", '$(env)', 9749);
expect(command).toContain("--root '/repo'\\''; touch /tmp/unwanted'");
expect(command).toContain("--project '$(env)'");
});
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import type { AtlasApi } from '../app/atlas-api';
import hookSource from '../../agents/hooks/atlas-trace.py?raw';
/** POSIX shell word quoting; paths and indexed names are data, never commands. */
export const shellWord = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
export const agentInstallCommand = (root: string, project: string, port: number): string =>
`python3 ~/Downloads/cbm-atlas-trace.py --install-claude --root ${shellWord(root)} --project ${shellWord(project)} --daemon-url ${shellWord(`http://127.0.0.1:${port}`)}`;
export default function AgentSetup({ api, project, port }: { api?: Pick<AtlasApi, 'repoInfo'>; project?: string; port: number }) {
const [open, setOpen] = useState(false);
const [reading, setReading] = useState<{ project: string; root: string; error: string }>();
const [copied, setCopied] = useState('');
useEffect(() => {
if (!open || !api || !project) return;
let disposed = false;
setReading(undefined); setCopied('');
void api.repoInfo(project).then(info => {
if (!disposed) setReading({ project, root: info.rootPath, error: info.rootPath ? '' : 'The daemon did not report this repository root.' });
}).catch(error => {
if (!disposed) setReading({ project, root: '', error: error instanceof Error ? error.message : String(error) });
});
return () => { disposed = true; };
}, [open, api, project]);
const current = reading?.project === project ? reading : undefined;
const command = current?.root && project ? agentInstallCommand(current.root, project, port) : '';
const download = () => {
const url = URL.createObjectURL(new Blob([hookSource], { type: 'text/x-python;charset=utf-8' }));
const anchor = document.createElement('a'); anchor.href = url; anchor.download = 'cbm-atlas-trace.py';
anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
};
return <details className="cbm-agent-setup" onToggle={event => setOpen(event.currentTarget.open)}>
<summary>Connect your agent</summary>
<p>Claude Code on macOS/Linux · Python 3 required. This installs a project-local PostToolUse hook. Connecting the browser only reads history; it does not configure your coding client.</p>
{!project || !api ? <p>Select an indexed project to generate its setup command.</p> : <>
<ol>
<li><button type="button" onClick={download}>Download hook</button> Save <code>cbm-atlas-trace.py</code> in Downloads, or adjust its path in the command below.</li>
<li>Review and run this command in your terminal. It copies the hook into <code>.claude/hooks/</code> and adds it to <code>.claude/settings.local.json</code>, preserving unrelated settings. Conflicting existing hooks are refused.
{current?.error ? <p role="alert">Setup unavailable: {current.error}</p> : command ? <>
<pre>{command}</pre><button type="button" onClick={() => { void navigator.clipboard.writeText(command).then(() => setCopied('Command copied')).catch(() => setCopied('Clipboard unavailable; select and copy the command.')); }}>Copy setup command</button> <span role="status">{copied}</span>
</> : <p>Reading the indexed repository root…</p>}
</li>
<li>Start a new Claude Code session in that repository and perform a tool call. Then choose <strong>Load recorded activity</strong> here. A new recorded event is the evidence that the hook reached this daemon.</li>
</ol>
<p>Destination: <code>http://127.0.0.1:{port}/api/agent-events</code> · Project: <code>{project}</code>. No bridge or extra listener. Pending events retry on the next tool call.</p>
</>}
<p>The hook records tool names, paths, timestamps, optional line spans and up to 180 characters of a command or search pattern. It records no file contents or tool results. Metadata stays in the local daemon; commands and paths can still contain sensitive information.</p>
<p>To remove it, delete only the entry referencing <code>cbm-atlas-trace.py</code> from this project's <code>PostToolUse</code> hooks. Other coding clients need their own event adapter; their activity is not inferred.</p>
</details>;
}
+363
View File
@@ -0,0 +1,363 @@
// @vitest-environment jsdom
/*
* Das Instrument, ohne einen gerenderten Pixel.
*
* Was hier geprueft wird, ist die Ehrlichkeitsregel dieser Flaeche und nicht
* ihr Aussehen: eine Absichtszeile NUR dort, wo ein Ereignis sie mitbringt,
* und dann gekennzeichnet; keine Zahl, die nicht aus der Sicht kommt; kein
* Fortschritt; und die drei Lagen mit genau dem, was in jeder von ihnen
* stehen soll. Wie gross der Kasten wirklich wird, misst der Beweislauf im
* Browser (tools/smoke-w11a.mjs); jsdom hat kein Layout.
*/
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import AgentsHud from './AgentsHud';
import type { AgentsHudProps } from './AgentsHud';
import { readAgentEvent } from './agent-event';
import { pulseOf } from './agent-motion';
import type { AgentEvent } from './agent-event';
import type { ActorView, AgentsView } from './agent-view';
import type { AgentSourceStatus } from './agent-source';
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const event = (over: Partial<AgentEvent>): AgentEvent => readAgentEvent({
ts: 1788040000000, agent: 'implementer', run: 'r1', seq: 3, phase: 'end',
tool: 'Edit', path: 'src/services/userService.ts', lines: [24, 30], detail: 'changed it',
...over,
}) as AgentEvent;
const actor = (over: Partial<ActorView> & { id: string }): ActorView => ({
name: over.id,
you: false,
color: 'hsl(200 92% 72%)',
letter: over.id.slice(0, 1).toUpperCase(),
kind: 'write',
kindLetter: 'W',
placement: {
kind: 'range', nodeId: 51, name: 'createUser', qualifiedName: 'p.createUser',
uncertain: false, why: '', ghostIds: [],
},
node: undefined,
testedNode: undefined,
ghostNodes: [],
last: event({}),
lastTs: 1788040000000,
hereMs: 12000,
count: 9,
missed: 0,
strip: new Array<number>(30).fill(0).map((_, index) => (index === 29 ? 2 : 0)),
stripTotal: 2,
intent: '',
paths: ['src/services/userService.ts'],
sinceMs: 4000,
idle: false,
recentEvents: 9,
pulse: pulseOf(9),
trail: [],
waves: [],
drawn: true,
...over,
});
const view = (over: Partial<AgentsView> = {}): AgentsView => {
const actors = over.actors ?? [
actor({ id: 'implementer', intent: 'tightening the input check in createUser' }),
actor({ id: 'checker', kind: 'test', kindLetter: 'T' }),
];
return {
actors,
all: over.all ?? actors,
unmapped: over.unmapped ?? [{
ts: 1788039000000, agent: 'checker', tool: 'Read', path: 'package.json',
detail: 'read it', why: 'the index has no node for this path',
}],
events: 45,
missed: 2,
perMinute: 7,
unreadable: 0,
cap: 8,
capped: 0,
ticker: [],
...over,
};
};
const status = (over: Partial<AgentSourceStatus> = {}): AgentSourceStatus => ({
state: 'connected',
origin: 'http://127.0.0.1:4142',
requests: 1,
drops: 0,
hello: { mode: 'live', file: '/home/x/.atlas-trace/events.jsonl', events: 45, unreadable: 0 },
error: '',
...over,
});
const props = (over: Partial<AgentsHudProps> = {}): AgentsHudProps => ({
view: view(),
status: status(),
port: 4142,
size: 'compact',
onSize: vi.fn(),
filter: 'both',
onFilter: vi.fn(),
switches: { follow: false, trails: false, fullscreen: false },
onSwitch: vi.fn(),
trailWindowMs: 60000,
onTrailWindow: vi.fn(),
layerOn: true,
...over,
});
async function render(over: Partial<AgentsHudProps> = {}): Promise<AgentsHudProps> {
const use = props(over);
await act(async () => {
root.render(<AgentsHud {...use} />);
});
return use;
}
const all = (testId: string): HTMLElement[] =>
[...container.querySelectorAll(`[data-testid="${testId}"]`)] as HTMLElement[];
const one = (testId: string): HTMLElement | null =>
container.querySelector(`[data-testid="${testId}"]`);
const textOf = (testId: string): string =>
(one(testId)?.textContent ?? '').replace(/\s+/g, ' ').trim();
describe('die Absichtszeile', () => {
it('steht NUR dort, wo ein Ereignis sie mitbringt', async () => {
await render();
const rows = all('atlas-agents-row');
expect(rows).toHaveLength(2);
const withIntent = rows.filter(
(row) => row.querySelector('[data-testid="atlas-agents-intent"]') !== null,
);
expect(withIntent).toHaveLength(1);
expect(withIntent[0]?.getAttribute('data-actor')).toBe('implementer');
});
it('ist als Selbstauskunft gekennzeichnet und nicht als Messung', async () => {
await render();
const intent = one('atlas-agents-intent');
expect(intent?.getAttribute('data-self-reported')).toBe('true');
expect(intent?.textContent).toContain('agent says:');
expect(intent?.textContent).toContain('tightening the input check in createUser');
});
it('erscheint nicht, wenn das Ereignis das Feld verliert', async () => {
await render({
view: view({
actors: [actor({ id: 'implementer' })],
all: [actor({ id: 'implementer' })],
}),
});
expect(all('atlas-agents-intent')).toHaveLength(0);
});
});
describe('was nicht gedeutet wird', () => {
it('zeichnet kein einziges Fortschrittselement', async () => {
await render({ size: 'expanded' });
expect(container.querySelectorAll('progress, [role="progressbar"], [aria-valuenow]'))
.toHaveLength(0);
});
it('nennt keine Prozentzahl und keine Bewertung', async () => {
await render({ size: 'expanded' });
const text = (container.textContent ?? '').toLowerCase();
for (const word of ['%', 'progress', 'thinking', 'almost done', 'score']) {
expect(text, word).not.toContain(word);
}
});
});
describe('die Zahlen im Kopf', () => {
it('kommen aus der Sicht und werden nicht gerechnet', async () => {
await render();
expect(one('atlas-agents-count')?.getAttribute('data-count')).toBe('2');
expect(one('atlas-agents-rate')?.getAttribute('data-per-minute')).toBe('7');
expect(one('atlas-agents-order')?.getAttribute('data-missed')).toBe('2');
expect(textOf('atlas-agents-order')).toBe('2 events missed');
});
it('sagen "in Ordnung", wenn nichts fehlte', async () => {
await render({ view: view({ missed: 0 }) });
expect(textOf('atlas-agents-order')).toBe('order intact');
});
it('malen den Streifen aus den gezaehlten Ereignissen', async () => {
await render();
const strip = one('atlas-agents-strip');
expect(strip?.getAttribute('data-bars')?.split(',')).toHaveLength(30);
expect(strip?.getAttribute('data-total')).toBe('2');
expect(strip?.querySelectorAll('.atlas-agents-bar')).toHaveLength(30);
});
});
describe('die drei Lagen', () => {
it('zeigt eingeklappt eine Zeile mit der Zahl und keine Reihe', async () => {
await render({ size: 'collapsed' });
expect(textOf('atlas-agents-line')).toBe('2 actors on the graph');
expect(all('atlas-agents-row')).toHaveLength(0);
expect(all('atlas-agents-switch')).toHaveLength(0);
});
it('zeigt kompakt je Akteur eine Zeile und die Zahl der unverortbaren', async () => {
await render({ size: 'compact' });
expect(all('atlas-agents-row')).toHaveLength(2);
expect(all('atlas-agents-card')).toHaveLength(0);
expect(one('atlas-agents-unmapped')?.getAttribute('data-count')).toBe('1');
expect(all('atlas-agents-unmapped-row')).toHaveLength(0);
});
it('zeigt gross die Karte je Akteur und die Rohereignisse', async () => {
await render({ size: 'expanded' });
expect(all('atlas-agents-card')).toHaveLength(2);
const raw = all('atlas-agents-unmapped-row');
expect(raw).toHaveLength(1);
expect(raw[0]?.getAttribute('data-path')).toBe('package.json');
expect(raw[0]?.textContent).toContain('the index has no node for this path');
});
it('meldet jeden Wechsel der Lage nach oben, statt ihn selbst zu behalten', async () => {
const use = await render({ size: 'compact' });
act(() => {
(one('atlas-agents-expand') as HTMLButtonElement).click();
});
expect(use.onSize).toHaveBeenCalledWith('expanded');
act(() => {
(one('atlas-agents-fold') as HTMLButtonElement).click();
});
expect(use.onSize).toHaveBeenCalledWith('collapsed');
});
});
describe('die Quelle', () => {
it('sagt bei ausgeschaltetem Modus, dass nichts gefragt wird', async () => {
await render({ status: status({ state: 'off', hello: undefined, requests: 0 }) });
expect(textOf('atlas-agents-reading')).toContain('live mode is off');
expect(one('atlas-agents-source')?.getAttribute('data-state')).toBe('off');
});
it('nennt bei fehlender Verbindung den vorhandenen Daemon', async () => {
await render({
status: status({ state: 'no-source', hello: undefined, error: 'refused' }),
port: 4711,
});
expect(textOf('atlas-agents-reading')).toContain('no daemon is answering');
expect(one('atlas-agents-command')?.getAttribute('data-command'))
.toBe('codebase-memory-mcp --ui');
});
it('gibt eine Wiedergabe als Wiedergabe aus und nicht als Gegenwart', async () => {
const replay = status({
hello: {
mode: 'replay', file: 'fixtures/agent-events/w11a-replay.jsonl',
events: 45, unreadable: 0,
},
});
// Kompakt sagt die eine Kopfzeile es, gross steht der Satz ueber die
// verschobenen Zeitstempel daneben. Beides zusammen ist die Auskunft;
// drei Zeilen dafuer in einem Instrument von 320 Pixeln waeren keine.
await render({ status: replay });
expect(textOf('atlas-agents-reading')).toContain('replaying');
expect(all('atlas-agents-replay')).toHaveLength(0);
await render({ status: replay, size: 'expanded' });
expect(textOf('atlas-agents-replay')).toContain('recorded earlier');
});
it('sagt, wenn die Ebene in den Einstellungen aus ist', async () => {
await render({ layerOn: false });
expect(textOf('atlas-agents-layer-off')).toContain('off in the settings');
});
});
describe('die Umschalter', () => {
it('melden ihre Wahl nach oben', async () => {
const use = await render();
act(() => {
(container.querySelector('[data-testid="atlas-agents-filter-option"][data-option="you"]') as HTMLButtonElement).click();
});
expect(use.onFilter).toHaveBeenCalledWith('you');
act(() => {
(container.querySelector('[data-testid="atlas-agents-switch"][data-switch="follow"]') as HTMLButtonElement).click();
});
expect(use.onSwitch).toHaveBeenCalledWith('follow');
});
it('zeigen den Zeitraum der Wegzeile erst, wenn es eine gibt', async () => {
await render();
expect(all('atlas-agents-window-option')).toHaveLength(0);
expect(all('atlas-agents-path')).toHaveLength(0);
await render({ switches: { follow: false, trails: true, fullscreen: false } });
expect(all('atlas-agents-window-option').length).toBeGreaterThanOrEqual(3);
expect(all('atlas-agents-path')).toHaveLength(2);
});
it('traegt keinen Knopf ohne Griff', async () => {
await render({ size: 'expanded' });
for (const button of container.querySelectorAll('button')) {
expect(button.getAttribute('type'), button.textContent ?? '').toBe('button');
}
expect(all('atlas-agents-switch').map((node) => node.getAttribute('data-switch')))
.toEqual(['follow', 'trails', 'fullscreen']);
});
});
describe('der Ort in Worten', () => {
it('nennt die Zeilen, wo es welche gibt', async () => {
await render();
expect(textOf('atlas-agents-place')).toBe('createUser, lines 24 to 30');
});
it('sagt bei einem Knoten ohne Endzeile, dass er keinen Bereich traegt', async () => {
await render({
view: view({
actors: [actor({
id: 'explorer',
kind: 'search',
kindLetter: 'S',
placement: {
kind: 'file', nodeId: 5, name: 'src', qualifiedName: 'p.src',
uncertain: true, why: '', ghostIds: [],
},
})],
all: [],
}),
});
expect(textOf('atlas-agents-place')).toBe('src (no line range in the index)');
expect(one('atlas-agents-row')?.getAttribute('data-uncertain')).toBe('true');
});
it('sagt bei einem Ereignis ohne Ort, dass es sich nicht verorten laesst', async () => {
await render({
view: view({
actors: [actor({
id: 'checker',
placement: {
kind: 'none', name: '', qualifiedName: '', uncertain: false,
why: 'the index has no node for this path', ghostIds: [],
},
})],
all: [],
}),
});
expect(textOf('atlas-agents-place')).toBe('not placeable on the graph');
});
});
+759
View File
@@ -0,0 +1,759 @@
/**
* Das Instrument unten rechts: es erklaert, was auf dem Graphen zu sehen ist.
*
* Es ist ausdruecklich KEIN Dashboard. Was es zeigt, sind die Koerper, die
* daneben kreisen, in Worten: wer, welche Art von Arbeit, an welchem Ort, seit
* wann, und wie dicht die Ereignisse in den letzten dreissig Sekunden lagen.
* Jede Zahl darin ist gezaehlt.
*
* ## Drei Groessen, und warum es drei sein muessen
*
* Die schriftliche Gegenrede zum Entwurf verlangt ein sehr kompaktes Instrument
* (Richtwert 320 mal 150 Pixel), das Designbild des Nutzers zeigt eine deutlich
* groessere Karte je Agent mit Wegzeile, und es traegt selbst einen
* EXPAND-Knopf. Beides ist richtig, aber nicht gleichzeitig:
*
* - **kompakt** ist der Normalzustand, weil das Instrument den Graphen erklaeren
* und ihn nicht zudecken soll. Eine Zeile je Akteur, und alles, was in dieser
* Zeile nicht Platz hat, kuerzt sich mit einem Auslassungszeichen und steht
* vollstaendig im Tooltip und in der grossen Lage.
* - **expand** zeigt die ausfuehrliche Karte je Akteur, so wie das Bild sie
* zeigt: den ganzen Ort, die Wegzeile, die Zahlen der Laeufe, das letzte
* Ereignis im Wortlaut, und die Liste dessen, was sich nicht verorten liess.
* - **eingeklappt** bleibt eine Zeile, die sagt, wie viele Akteure laufen. Ein
* Instrument, das ganz verschwindet, laesst den Leser vor kreisenden Punkten
* ohne Erklaerung sitzen.
*
* Die gewaehlte Groesse liegt im Speicher des Browsers und ueberlebt den Reload.
*
* ## Die Ehrlichkeitsregel dieser Flaeche
*
* Drei Dinge stehen hier NICHT, und alle drei fehlen mit Absicht:
*
* 1. **Kein Fortschritt.** Weder ein Balken noch eine Prozentzahl noch ein
* "fast fertig". Diese Oberflaeche sieht Werkzeugaufrufe, nicht Vorhaben;
* wie weit ein Agent ist, weiss sie nicht und kann sie nicht wissen.
* 2. **Keine Bewertung.** Kein "gut", kein "haengt", kein "denkt nach". Eine
* Pause zwischen zwei Ereignissen ist eine Pause zwischen zwei Ereignissen.
* 3. **Keine erfundene Absicht.** Eine Absichtszeile erscheint NUR, wenn das
* Ereignis ein `intent`-Feld mitbringt, das der Agent selbst geschrieben
* hat, und dann in eigener Schriftfarbe, mit dem Praefix "agent says:" und
* einem Tooltip, der sagt, dass es gemeldet und nicht gemessen ist. Das
* Designbild zeigt an dieser Stelle Zeilen wie "Refactoring
* OrderService.createOrder"; genau diese Zeile ist verboten, solange kein
* Ereignis sie traegt.
*/
import type { JSX } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import Hint from '../ui/tooltip/Hint';
import { STRIP_SECONDS } from './agent-store';
import { TRAIL_WINDOWS } from './agent-view';
import type { ActorFilter, ActorView, AgentsView, HudSize } from './agent-view';
import { WORK_KIND_WORD, agentStrings as text } from './agent-strings';
import type { AgentSourceStatus } from './agent-source';
import { bridgeCommand } from './agent-source';
/** Die drei Umschalter des laufenden Blicks. */
export interface HudSwitches {
follow: boolean;
trails: boolean;
/**
* Das Vollbild.
*
* Hiess bis W11b `cinema`. Der Nutzer hat es am 2026-08-30 benutzt und
* woertlich gesagt: "bin jetzt im cinema mode, sollte fullscreen heissen."
*/
fullscreen: boolean;
}
export interface AgentsHudProps {
view: AgentsView;
status: AgentSourceStatus;
/** Der Port der Bruecke, fuer den Befehl, der sie startet. */
port: number;
size: HudSize;
onSize: (size: HudSize) => void;
filter: ActorFilter;
onFilter: (filter: ActorFilter) => void;
switches: HudSwitches;
onSwitch: (name: keyof HudSwitches) => void;
trailWindowMs: number;
onTrailWindow: (ms: number) => void;
/** Ob die Zeichenebene ueberhaupt an ist (Einstellungen, W10-Gruppe). */
layerOn: boolean;
/**
* Ob das Instrument als Spalte am Rand steht (Vollbild, W11b AC5).
*
* In der Spalte bekommt jeder Akteur ZWEI Zeilen statt einer, und das ist
* die Antwort auf einen Befund am Beweisbild von W11a: in einer Spalte von
* dreihundert Pixeln schrumpfen die Namen auf vier Zeichen ("expl...",
* "chec..."), und sauber gekuerzt ist immer noch gekuerzt. "chec..." sagt
* niemandem, wer da arbeitet. Zwei Zeilen kosten Hoehe, und Hoehe ist genau
* das, was eine Spalte im Vollbild hat.
*/
column?: boolean;
}
const FILTERS: readonly ActorFilter[] = ['you', 'agent', 'both'];
function filterLabel(option: ActorFilter): string {
return option === 'you' ? text.filterYou : option === 'agent' ? text.filterAgent : text.filterBoth;
}
/** Die Zeitspanne in Worten. Sekunden bis eine Minute, danach Minuten. */
function spell(ms: number): string {
return text.ago(ms / 1000);
}
/** Der Ort in Worten, samt der Frage, wie sicher er ist. */
function placeText(actor: ActorView): string {
const place = actor.placement;
if (place.kind === 'none') {
return text.placeNone;
}
if (place.uncertain) {
return text.placeUncertain(place.name);
}
if (place.kind === 'range' && actor.last.lines !== undefined) {
return text.placeRange(place.name, actor.last.lines[0], actor.last.lines[1]);
}
return text.placeFile(place.name);
}
/** Der Aktivitaetsstreifen: ein Balken je Sekunde, aus echten Ereignissen. */
function Strip(props: { actor: ActorView }): JSX.Element {
const peak = Math.max(1, ...props.actor.strip);
return (
<Hint
name={`agents-strip-${props.actor.id}`}
text={text.stripTitle(STRIP_SECONDS, props.actor.stripTotal)}
>
<span
className="atlas-agents-strip"
data-testid="atlas-agents-strip"
data-actor={props.actor.id}
data-bars={props.actor.strip.join(',')}
data-total={props.actor.stripTotal}
data-seconds={STRIP_SECONDS}
aria-hidden="true"
>
{props.actor.strip.map((count, index) => (
<span
// eslint-disable-next-line react/no-array-index-key
key={index}
className="atlas-agents-bar"
data-count={count}
style={{
height: `${count === 0 ? 1 : Math.round((count / peak) * 100)}%`,
background: count === 0 ? undefined : props.actor.color,
}}
/>
))}
</span>
</Hint>
);
}
/** Die Absichtszeile. Sie gibt es nur, wenn das Ereignis sie mitbringt. */
function Intent(props: { actor: ActorView }): JSX.Element | null {
if (props.actor.intent.length === 0) {
return null;
}
return (
<Hint name={`agents-intent-${props.actor.id}`} text={text.intentTitle}>
<span
className="atlas-agents-intent"
data-testid="atlas-agents-intent"
data-actor={props.actor.id}
data-self-reported="true"
>
<span className="atlas-agents-intent-prefix">{text.intentPrefix}</span>
{` ${props.actor.intent}`}
</span>
</Hint>
);
}
/** Der Ort, mit seinem Tooltip. In beiden Lagen derselbe Text. */
function Place(props: { actor: ActorView; className: string }): JSX.Element {
const { actor } = props;
return (
<Hint
name={`agents-place-${actor.id}`}
text={
actor.placement.kind === 'none'
? text.placeNoneTitle(actor.placement.why)
: actor.placement.uncertain
? text.placeUncertainTitle
: actor.placement.qualifiedName
}
>
<span className={props.className} data-testid="atlas-agents-place">
{placeText(actor)}
</span>
</Hint>
);
}
/**
* Eine Zeile je Akteur.
*
* Kompakt ist sie EINE Zeile: Farbpunkt, Name, Buchstabe der Art, Ort, Streifen,
* Dauer. Was nicht hineinpasst, kuerzt sich mit einem Auslassungszeichen; der
* ganze Wortlaut steht im Tooltip und in der grossen Lage. Eine Absichtszeile
* kommt darunter, aber nur, wenn das Ereignis eine mitbringt.
*/
function Row(props: {
actor: ActorView;
trails: boolean;
expanded: boolean;
column: boolean;
}): JSX.Element {
const { actor, expanded, column } = props;
const place = <Place actor={actor} className="atlas-agents-place" />;
return (
<li
className="atlas-agents-row"
data-testid="atlas-agents-row"
data-actor={actor.id}
data-kind={actor.kind}
data-letter={actor.letter}
data-you={actor.you}
data-idle={actor.idle}
data-drawn={actor.drawn}
data-lines={column ? 2 : 1}
data-placement={actor.placement.kind}
data-uncertain={actor.placement.uncertain}
data-node={actor.placement.nodeId ?? ''}
>
<span className="atlas-agents-row-head">
<span
className="atlas-agents-dot"
data-testid="atlas-agents-dot"
data-color={actor.color}
style={{ background: actor.color }}
aria-hidden="true"
/>
<span className="atlas-agents-name" data-testid="atlas-agents-name">
{actor.name}
</span>
<Hint
name={`agents-kind-${actor.id}`}
text={text.kindTitle(actor.kind, actor.kindLetter)}
>
<span
className="atlas-agents-kind"
data-testid="atlas-agents-kind"
data-kind={actor.kind}
data-letter={actor.kindLetter}
>
{actor.kindLetter}
</span>
</Hint>
{!expanded && !column && place}
{!column && <Strip actor={actor} />}
<Hint
name={`agents-since-${actor.id}`}
text={actor.idle ? text.idleTitle(actor.sinceMs / 1000) : text.orderTitle}
>
<span className="atlas-agents-since" data-testid="atlas-agents-since">
{spell(actor.hereMs)}
</span>
</Hint>
</span>
{/*
* Die zweite Zeile der Spalte: Ort und Streifen.
*
* Sie hat ihren eigenen Kasten und nicht nur einen Umbruch, damit
* der Ort die ganze Breite bekommt. Das ist der Unterschied
* zwischen "chec..." und "checker" und zwischen "hots..." und
* "hotspotScan".
*/}
{column && (
<span className="atlas-agents-row-second">
{place}
<Strip actor={actor} />
</span>
)}
{expanded && !column && (
<Place actor={actor} className="atlas-agents-place atlas-agents-place-full" />
)}
<Intent actor={actor} />
{expanded && actor.kind === 'test' && (
<span
className="atlas-agents-tested"
data-testid="atlas-agents-tested"
data-node={actor.testedNode?.id ?? ''}
>
{actor.testedNode === undefined
? text.testedUnknown
: text.testedTitle(actor.testedNode.name)}
</span>
)}
{/*
* Die Verlaufszeile.
*
* In der Spalte steht sie immer, denn genau sie ist die
* "Verlaufszeile je Agent", die AC5 dort verlangt. Im Panel
* haengt sie weiter am Schalter TRAILS, weil eine Zeile mehr je
* Akteur dort der Unterschied zwischen einem Instrument und einem
* Panel ist.
*/}
{(props.trails || column) && (
<span
className="atlas-agents-path"
data-testid="atlas-agents-path"
data-count={actor.paths.length}
>
{actor.paths.length === 0 ? text.pathEmpty : actor.paths.join(' < ')}
</span>
)}
{expanded && (
<span className="atlas-agents-card" data-testid="atlas-agents-card">
<span className="atlas-agents-card-line">
{`${actor.count} events, ${
actor.missed === 0 ? text.orderIntact : text.orderMissed(actor.missed)
}`}
</span>
<span className="atlas-agents-card-line">
{`${actor.last.tool} ${actor.last.detail}`}
</span>
</span>
)}
</li>
);
}
export default function AgentsHud(props: AgentsHudProps): JSX.Element {
const { view, status, size } = props;
const collapsed = size === 'collapsed';
const expanded = size === 'expanded';
const column = props.column === true;
/*
* Die Kante des Kastens, gemessen wie bei der Legende der Galaxie.
*
* Dieselbe Begruendung wie dort: der Bildlauf dieser Plattform ist eine
* ueberlagernde Leiste, die im Ruhezustand unsichtbar ist. Ohne einen
* eigenen Hinweis endet der letzte sichtbare Satz an einer harten Kante, und
* das liest sich als Fehler und nicht als Fortsetzung.
*/
const body = useRef<HTMLDivElement | null>(null);
const [edge, setEdge] = useState({ above: false, below: false });
const measure = useCallback(() => {
const node = body.current;
if (node === null) {
return;
}
const above = node.scrollTop > 1;
const below = node.scrollTop + node.clientHeight < node.scrollHeight - 1;
setEdge((current) =>
(current.above === above && current.below === below ? current : { above, below }));
}, []);
useEffect(() => {
measure();
}, [measure, size, view.actors, props.switches.trails]);
useEffect(() => {
const node = body.current;
if (node === null || typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver(measure);
observer.observe(node);
return () => observer.disconnect();
}, [measure, size]);
const sourceHead = (): string => {
if (status.state === 'off') {
return text.sourceOff;
}
if (status.state === 'connecting') {
return text.sourceConnecting;
}
if (status.state === 'no-source') {
return text.sourceNone;
}
const hello = status.hello;
return hello?.mode === 'replay'
? text.sourceReplay(hello.file)
: text.sourceConnected(hello?.file ?? '');
};
/*
* Der ausfuehrliche Teil der Quelle steht nur dort, wo er gebraucht wird:
* wenn es keine Quelle gibt (dann traegt er den Befehl, der sie startet)
* und in der grossen Lage. Im Normalfall haette er in einem Instrument von
* 320 Pixeln drei Zeilen belegt, um zu sagen, was die eine Zeile darueber
* schon sagt.
*/
const showSourceDetail = expanded || status.state === 'no-source' || status.state === 'off';
return (
<aside
className="atlas-agents"
data-testid="atlas-agents"
data-size={size}
data-source={status.state}
data-layer={props.layerOn}
data-filter={props.filter}
data-column={column}
aria-label={text.panelLabel}
>
<header className="atlas-agents-head" data-testid="atlas-agents-head">
<span className="atlas-agents-head-row">
<span className="atlas-agents-title">{text.title}</span>
{collapsed ? (
<span
className="atlas-agents-line"
data-testid="atlas-agents-line"
data-count={view.all.length}
>
{text.collapsedLine(view.all.length)}
</span>
) : (
<span className="atlas-agents-source-head" data-testid="atlas-agents-reading">
{sourceHead()}
</span>
)}
<span className="atlas-agents-head-buttons">
{!collapsed && (
<Hint name="agents-expand" text={text.expandTitle(expanded)}>
<button
type="button"
className="atlas-agents-button"
data-testid="atlas-agents-expand"
aria-pressed={expanded}
onClick={() => props.onSize(expanded ? 'compact' : 'expanded')}
>
{text.expand(expanded)}
</button>
</Hint>
)}
<Hint name="agents-fold" text={text.foldTitle(!collapsed)}>
<button
type="button"
className="atlas-agents-button"
data-testid="atlas-agents-fold"
aria-expanded={!collapsed}
data-fold={collapsed ? 'open' : 'collapse'}
onClick={() => props.onSize(collapsed ? 'compact' : 'collapsed')}
>
{text.fold(!collapsed)}
</button>
</Hint>
</span>
</span>
{!collapsed && (
<span className="atlas-agents-head-row">
<span
className="atlas-agents-count"
data-testid="atlas-agents-count"
data-count={view.all.length}
>
{text.agentCount(view.all.length)}
</span>
<span
className="atlas-agents-rate"
data-testid="atlas-agents-rate"
data-per-minute={view.perMinute}
data-events={view.events}
>
{text.perMinute(view.perMinute)}
</span>
<Hint name="agents-order" text={text.orderTitle}>
<span
className="atlas-agents-order"
data-testid="atlas-agents-order"
data-missed={view.missed}
>
{view.missed === 0 ? text.orderIntact : text.orderMissed(view.missed)}
</span>
</Hint>
</span>
)}
</header>
{!collapsed && (
<div
className="atlas-agents-body"
data-testid="atlas-agents-body"
data-more-above={edge.above}
data-more-below={edge.below}
ref={body}
onScroll={measure}
>
{showSourceDetail && (
<div
className="atlas-agents-source"
data-testid="atlas-agents-source"
data-state={status.state}
data-mode={status.hello?.mode ?? ''}
>
{status.state === 'off' && (
<span className="atlas-agents-source-detail">{text.sourceOffDetail}</span>
)}
{status.state === 'no-source' && (
<>
<span className="atlas-agents-source-detail">
{text.sourceNoneDetail}
</span>
<code
className="atlas-agents-command"
data-testid="atlas-agents-command"
data-command={bridgeCommand(props.port)}
>
{bridgeCommand(props.port)}
</code>
</>
)}
{status.hello?.mode === 'replay' && (
<span
className="atlas-agents-source-detail"
data-testid="atlas-agents-replay"
>
{text.replayNote}
</span>
)}
{view.unreadable > 0 && (
<span
className="atlas-agents-source-detail"
data-testid="atlas-agents-unreadable"
>
{text.unreadableLines(view.unreadable)}
</span>
)}
</div>
)}
{!props.layerOn && (
<p className="atlas-agents-note" data-testid="atlas-agents-layer-off">
{text.layerOff}
</p>
)}
<div
className="atlas-agents-filter"
data-testid="atlas-agents-filter"
role="group"
aria-label={text.filterLabel}
>
<span className="atlas-agents-filter-label">{text.filterLabel}</span>
{FILTERS.map((option) => (
<Hint
key={option}
name={`agents-filter-${option}`}
text={text.filterTitle(option)}
>
<button
type="button"
className="atlas-agents-option"
data-testid="atlas-agents-filter-option"
data-option={option}
data-active={props.filter === option}
aria-pressed={props.filter === option}
onClick={() => props.onFilter(option)}
>
{filterLabel(option)}
</button>
</Hint>
))}
</div>
<ul
className="atlas-agents-rows"
data-testid="atlas-agents-rows"
data-count={view.actors.length}
data-column={column}
>
{view.actors.map((actor) => (
<Row
key={actor.id}
actor={actor}
trails={props.switches.trails}
expanded={expanded}
column={column}
/>
))}
</ul>
{/*
* Der Deckel sagt sich selbst.
*
* Er greift an den Koerpern und nicht an den Zeilen: jeder
* Akteur steht oben, mit allen seinen Zahlen. Was fehlt,
* ist ein Punkt auf dem Graphen, und ein Bild, dem man
* nicht ansieht, dass es unvollstaendig ist, waere die
* stille Behauptung, es sei vollstaendig.
*/}
{view.capped > 0 && (
<Hint name="agents-cap" text={text.capTitle}>
<p
className="atlas-agents-cap"
data-testid="atlas-agents-cap"
data-cap={view.cap}
data-capped={view.capped}
data-drawn={view.actors.filter((actor) => actor.drawn).length}
>
{text.capLine(
view.actors.filter((actor) => actor.drawn).length,
view.actors.length,
)}
</p>
</Hint>
)}
{/*
* Der Ereignis-Ticker im Klartext (AC5). Nur in der Spalte:
* im Panel waeren sechs weitere Zeilen der Unterschied
* zwischen einem Instrument und einem Protokoll.
*/}
{column && view.ticker.length > 0 && (
<div
className="atlas-agents-ticker"
data-testid="atlas-agents-ticker"
data-count={view.ticker.length}
>
<Hint name="agents-ticker" text={text.tickerTitle}>
<span className="atlas-agents-ticker-head">{text.tickerLabel}</span>
</Hint>
{view.ticker.map((entry) => (
<span
key={`${entry.ts}-${entry.actor}-${entry.tool}-${entry.path}`}
className="atlas-agents-ticker-row"
data-testid="atlas-agents-ticker-row"
data-actor={entry.actor}
data-kind={entry.kind}
data-place={entry.place}
data-lines={entry.lines.join('-')}
style={{ ['--atlas-agent-color' as string]: entry.color }}
>
{text.tickerLine(
entry.name,
WORK_KIND_WORD[entry.kind],
entry.place,
entry.lines,
)}
</span>
))}
</div>
)}
{view.unmapped.length > 0 && (
<div
className="atlas-agents-unmapped"
data-testid="atlas-agents-unmapped"
data-count={view.unmapped.length}
>
<Hint name="agents-unmapped" text={text.unmappedIntro}>
<span className="atlas-agents-unmapped-head">
{text.unmappedTitle(view.unmapped.length)}
</span>
</Hint>
{expanded && (
<span className="atlas-agents-unmapped-intro">
{text.unmappedIntro}
</span>
)}
{expanded && view.unmapped.map((event) => (
<span
className="atlas-agents-unmapped-row"
data-testid="atlas-agents-unmapped-row"
data-tool={event.tool}
data-path={event.path}
data-why={event.why}
key={`${event.ts}-${event.tool}-${event.path}`}
>
{text.unmappedRow(event.tool, event.path, event.why)}
</span>
))}
</div>
)}
<div
className="atlas-agents-switches"
data-testid="atlas-agents-switches"
role="group"
aria-label={text.title}
>
<Hint name="agents-follow" text={text.followTitle(props.switches.follow)}>
<button
type="button"
className="atlas-agents-option"
data-testid="atlas-agents-switch"
data-switch="follow"
data-active={props.switches.follow}
aria-pressed={props.switches.follow}
onClick={() => props.onSwitch('follow')}
>
{text.follow}
</button>
</Hint>
<Hint name="agents-trails" text={text.trailsTitle(props.switches.trails)}>
<button
type="button"
className="atlas-agents-option"
data-testid="atlas-agents-switch"
data-switch="trails"
data-active={props.switches.trails}
aria-pressed={props.switches.trails}
onClick={() => props.onSwitch('trails')}
>
{text.trails}
</button>
</Hint>
<Hint
name="agents-fullscreen"
text={text.fullscreenTitle(props.switches.fullscreen)}
>
<button
type="button"
className="atlas-agents-option"
data-testid="atlas-agents-switch"
data-switch="fullscreen"
data-active={props.switches.fullscreen}
aria-pressed={props.switches.fullscreen}
onClick={() => props.onSwitch('fullscreen')}
>
{text.fullscreen}
</button>
</Hint>
{props.switches.trails && TRAIL_WINDOWS.map((ms) => (
<Hint key={ms} name={`agents-window-${ms}`} text={text.windowTitle}>
<button
type="button"
className="atlas-agents-option atlas-agents-window-option"
data-testid="atlas-agents-window-option"
data-option={ms}
data-active={props.trailWindowMs === ms}
aria-pressed={props.trailWindowMs === ms}
onClick={() => props.onTrailWindow(ms)}
>
{text.windowOption(ms)}
</button>
</Hint>
))}
</div>
{expanded && props.switches.fullscreen && (
<p className="atlas-agents-note" data-testid="atlas-agents-fullscreen-scope">
{text.fullscreenScope}
</p>
)}
</div>
)}
{(edge.above || edge.below) && !collapsed && (
<span
className="atlas-agents-more"
data-testid="atlas-agents-more-mark"
data-scroll-hint={[edge.above ? 'top' : '', edge.below ? 'bottom' : '']
.filter((part) => part.length > 0)
.join(' ')}
>
{edge.below ? '▾ more' : '▴ more'}
</span>
)}
</aside>
);
}
+216
View File
@@ -0,0 +1,216 @@
/**
* Der Zeitstrahl unter dem Graphen: eine Spur je Akteur, ein Strich je
* Ereignis.
*
* ## Warum er ein Strich je Ereignis ist und keine Kurve
*
* Weil eine Kurve eine Aussage waere. Sie muesste glaetten, und Glaetten heisst
* hier, zwischen zwei Werkzeugaufrufen eine Dichte zu behaupten, die niemand
* gemessen hat. Ein Strich je Ereignis ist die Ablesung: wo einer steht, war
* ein Aufruf fertig; wo keiner steht, war keiner. Die Luecken sind dabei die
* interessantere Haelfte des Bildes.
*
* ## Die drei Lagen und die eine Gefahr
*
* `live` laeuft mit, `paused` haelt das Fenster an (die Ereignisse laufen
* weiter ein), `replay` zeigt den Zustand von damals. Die dritte ist die
* gefaehrliche: ein alter Zustand sieht aus wie der jetzige. Sie kennzeichnet
* sich darum nicht nur hier, sondern faerbt die ganze Ansicht (siehe
* `data-replay` am Panel) und sagt in Worten, wie weit zurueck sie steht.
*/
import type { JSX, MouseEvent as ReactMouseEvent } from 'react';
import { useCallback, useRef } from 'react';
import Hint from '../ui/tooltip/Hint';
import { agentStrings as text } from './agent-strings';
import { TIMELINE_WINDOWS, timeAtFraction } from './agent-timeline';
import type { Timeline } from './agent-timeline';
export interface AgentsTimelineProps {
timeline: Timeline;
/** Die Gegenwart, fuer den Satz "so weit zurueck". */
now: number;
windowMs: number;
onWindow: (ms: number) => void;
/** Anhalten und weiterlaufen lassen. */
onPause: () => void;
/** Auf einen Zeitpunkt springen. */
onScrub: (ts: number) => void;
/** Zurueck in die Gegenwart. */
onLive: () => void;
}
function modeLabel(timeline: Timeline): string {
return timeline.mode === 'replay'
? text.timelineReplay
: timeline.mode === 'paused' ? text.timelinePaused : text.timelineLive;
}
export default function AgentsTimeline(props: AgentsTimelineProps): JSX.Element {
const { timeline } = props;
const lanes = useRef<HTMLDivElement | null>(null);
const scrub = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
const box = lanes.current?.getBoundingClientRect();
if (box === undefined || box.width === 0) {
return;
}
props.onScrub(timeAtFraction(timeline, (event.clientX - box.x) / box.width));
}, [props, timeline]);
return (
<div
className="atlas-agents-timeline"
data-testid="atlas-agents-timeline"
data-mode={timeline.mode}
data-tracks={timeline.tracks.length}
data-ticks={timeline.ticks}
data-window={timeline.windowMs}
data-from={timeline.from}
data-to={timeline.to}
>
<div className="atlas-agents-timeline-head">
<span className="atlas-agents-timeline-label">{text.timelineLabel}</span>
<Hint
name="agents-timeline-mode"
text={
timeline.mode === 'replay'
? text.timelineReplayTitle
: timeline.mode === 'paused'
? text.timelinePauseTitle
: text.timelineLiveTitle
}
>
<span
className="atlas-agents-timeline-mode"
data-testid="atlas-agents-timeline-mode"
data-mode={timeline.mode}
>
{modeLabel(timeline)}
</span>
</Hint>
<Hint name="agents-timeline-pause" text={text.timelinePauseTitle}>
<button
type="button"
className="atlas-agents-option"
data-testid="atlas-agents-timeline-pause"
data-active={timeline.mode === 'paused'}
aria-pressed={timeline.mode === 'paused'}
onClick={props.onPause}
>
{timeline.mode === 'paused' ? text.timelineResume : text.timelinePause}
</button>
</Hint>
{timeline.mode === 'replay' && (
<>
<span
className="atlas-agents-timeline-replay"
data-testid="atlas-agents-timeline-replay"
>
{text.timelineReplayNote(text.ago((props.now - timeline.to) / 1000))}
</span>
<button
type="button"
className="atlas-agents-option"
data-testid="atlas-agents-timeline-live"
onClick={props.onLive}
>
{text.timelineBackToLive}
</button>
</>
)}
<span className="atlas-agents-timeline-windows">
{TIMELINE_WINDOWS.map((ms) => (
<Hint key={ms} name={`agents-timeline-window-${ms}`} text={text.windowTitle}>
<button
type="button"
className="atlas-agents-option"
data-testid="atlas-agents-timeline-window"
data-option={ms}
data-active={props.windowMs === ms}
aria-pressed={props.windowMs === ms}
onClick={() => props.onWindow(ms)}
>
{text.windowOption(ms)}
</button>
</Hint>
))}
</span>
</div>
<div className="atlas-agents-timeline-body">
<div className="atlas-agents-timeline-names">
{timeline.tracks.map((track) => (
<span
key={track.id}
className="atlas-agents-timeline-name"
data-testid="atlas-agents-timeline-name"
data-actor={track.id}
data-idle={track.idle}
style={{ color: track.color }}
>
{track.name}
</span>
))}
</div>
{/*
* Der Klickbereich ist die Flaeche der Spuren und nicht ein
* eigener Streifen darunter: der Leser zeigt auf den Strich,
* den er meint. Ein `div` mit einem Klick und ohne Rolle waere
* fuer die Tastatur nicht da; die Rolle `slider` sagt, was es
* ist, und die drei `aria`-Werte sagen, wo es steht.
*/}
<div
className="atlas-agents-timeline-lanes"
data-testid="atlas-agents-timeline-lanes"
ref={lanes}
role="slider"
tabIndex={0}
aria-label={text.timelineLabel}
aria-valuemin={timeline.from}
aria-valuemax={timeline.to}
aria-valuenow={timeline.to}
onClick={scrub}
onKeyDown={(event) => {
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
event.preventDefault();
const step = timeline.windowMs / 20;
props.onScrub(timeline.to + (event.key === 'ArrowLeft' ? -step : step));
}
}}
>
{timeline.tracks.map((track) => (
<Hint
key={track.id}
name={`agents-timeline-track-${track.id}`}
text={text.timelineTrackTitle(track.name, track.count)}
>
<div
className="atlas-agents-timeline-track"
data-testid="atlas-agents-timeline-track"
data-actor={track.id}
data-count={track.count}
data-idle={track.idle}
>
{track.ticks.map((tick) => (
<span
key={`${tick.ts}-${tick.at}`}
className="atlas-agents-timeline-tick"
data-testid="atlas-agents-timeline-tick"
data-kind={tick.kind}
style={{
left: `${(tick.at * 100).toFixed(3)}%`,
background: track.color,
}}
aria-hidden="true"
/>
))}
</div>
</Hint>
))}
</div>
</div>
</div>
);
}

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