mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-26 06:43:15 +08:00
Merge main into docs/readme-atlas-review
Signed-off-by: Bernhard Jackiewicz <bj@techport.io> # Conflicts: # README.md
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"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.*"],
|
||||
"action": "run_all"
|
||||
},
|
||||
{
|
||||
"id": "build",
|
||||
"paths": ["scripts/**", "test-infrastructure/**", "pkg/**"],
|
||||
"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}"],
|
||||
"action": "ignore"
|
||||
},
|
||||
{
|
||||
"id": "notices",
|
||||
"paths": ["NOTICE*", "CHANGELOG*", "THIRD_PARTY*"],
|
||||
"action": "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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -3,14 +3,24 @@
|
||||
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).
|
||||
@@ -60,7 +70,9 @@ jobs:
|
||||
# provability (never suppressed — the NOLINT ban applies here too).
|
||||
# Vendored-tree diagnostics are path-filtered, mirroring .cppcheck.
|
||||
lint-mem:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'lint-mem') }}
|
||||
# Pinned for the same reason as `lint`: llvm-toolchain-noble-21 below.
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -68,12 +80,12 @@ jobs:
|
||||
- name: Install build deps
|
||||
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev
|
||||
|
||||
- name: Install LLVM 22 (pinned analyzer toolchain)
|
||||
- name: Install LLVM 21 (pinned analyzer toolchain)
|
||||
run: |
|
||||
wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc
|
||||
echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-22 main" | sudo tee /etc/apt/sources.list.d/llvm-22.list
|
||||
echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang-tidy-22
|
||||
sudo apt-get install -y clang-tidy-21
|
||||
|
||||
- name: Memory-analyzer gate
|
||||
run: scripts/ci/lint-mem.sh clang-tidy-22
|
||||
run: scripts/ci/lint-mem.sh clang-tidy-21
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Reusable: the waste sanitizer (memory + CPU waste), REPORT-ONLY.
|
||||
#
|
||||
# What runs (scripts/memwaste.sh, memwaste-scaling.py, memwaste-ratchet.py):
|
||||
# mode=pr Linux: the event lane (memory waste + CPU work) indexing this
|
||||
# repository at the checked-out commit, and the CPU scaling lane
|
||||
# (src/ replicated 2x against 4x, per-function counter ratios).
|
||||
# mode=full the same, plus the event lane on macOS and Windows and the access
|
||||
# lane (read/write verdicts per allocation) on Linux.
|
||||
#
|
||||
# The corpus is the repository itself: pinned by the commit, no network, and a
|
||||
# real mix of C, C++, Python, JavaScript and vendored code.
|
||||
#
|
||||
# REPORT-ONLY: every job uploads its report and prints the ratchet comparison
|
||||
# against scripts/memwaste-baseline.json, but nothing here can turn a PR red --
|
||||
# the ratchet runs without --enforce and the PR's ci-ok gate does not depend on
|
||||
# these jobs. A job fails only when the MEASUREMENT is unsound (a table
|
||||
# overflowed, frees the layer never saw): that is a defect of the tool, and a
|
||||
# red job is how it gets noticed. Switching the ratchet to --enforce is a
|
||||
# separate, deliberate change once the numbers have been stable across reruns.
|
||||
name: Memwaste
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
mode:
|
||||
description: "pr (Linux event + scaling) or full (all platforms, access lane)"
|
||||
type: string
|
||||
default: pr
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
event-linux:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install deps
|
||||
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 clang llvm
|
||||
- name: Event lane on this repository
|
||||
run: |
|
||||
# `cmd | tee` reports TEE's status: without this a failing lane
|
||||
# (a missing exec bit, a build error) passes the step and only
|
||||
# surfaces as the ratchet not finding its JSON.
|
||||
set -o pipefail
|
||||
mkdir -p "$RUNNER_TEMP/memwaste-event"
|
||||
scripts/memwaste.sh "$GITHUB_WORKSPACE" --top 40 --out "$RUNNER_TEMP/memwaste-event" \
|
||||
| tee "$RUNNER_TEMP/memwaste-event/report.txt"
|
||||
- name: Ratchet (report-only)
|
||||
run: python3 scripts/memwaste-ratchet.py --out "$RUNNER_TEMP/memwaste-event" --venue ${{ inputs.mode }}-linux
|
||||
- name: Upload report
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: memwaste-event-linux-${{ inputs.mode }}
|
||||
path: |
|
||||
${{ runner.temp }}/memwaste-event/report.txt
|
||||
${{ runner.temp }}/memwaste-event/memwaste.json
|
||||
${{ runner.temp }}/memwaste-event/index.json
|
||||
if-no-files-found: warn
|
||||
|
||||
scaling-linux:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install deps
|
||||
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 clang llvm
|
||||
- name: CPU scaling lane (src/ at 2x against 4x)
|
||||
# Report-only: the script exits 1 when it flags a super-linear function.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# `cmd | tee` reports TEE's status: without this a failing lane
|
||||
# (a missing exec bit, a build error) passes the step and only
|
||||
# surfaces as the ratchet not finding its JSON.
|
||||
set -o pipefail
|
||||
mkdir -p "$RUNNER_TEMP/memwaste-scaling"
|
||||
python3 scripts/memwaste-scaling.py --corpus src --replicas 2 \
|
||||
--out "$RUNNER_TEMP/memwaste-scaling" | tee "$RUNNER_TEMP/memwaste-scaling/report.txt"
|
||||
- name: Upload report
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: memwaste-scaling-linux-${{ inputs.mode }}
|
||||
path: ${{ runner.temp }}/memwaste-scaling/report.txt
|
||||
if-no-files-found: warn
|
||||
|
||||
access-linux:
|
||||
if: ${{ inputs.mode == 'full' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install deps
|
||||
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 clang llvm
|
||||
- name: Access lane on this repository
|
||||
run: |
|
||||
# `cmd | tee` reports TEE's status: without this a failing lane
|
||||
# (a missing exec bit, a build error) passes the step and only
|
||||
# surfaces as the ratchet not finding its JSON.
|
||||
set -o pipefail
|
||||
mkdir -p "$RUNNER_TEMP/memwaste-access"
|
||||
scripts/memwaste.sh "$GITHUB_WORKSPACE" --lane access --top 40 --sort untouched \
|
||||
--out "$RUNNER_TEMP/memwaste-access" | tee "$RUNNER_TEMP/memwaste-access/report.txt"
|
||||
- name: Upload report
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: memwaste-access-linux-${{ inputs.mode }}
|
||||
path: |
|
||||
${{ runner.temp }}/memwaste-access/report.txt
|
||||
${{ runner.temp }}/memwaste-access/memwaste.json
|
||||
if-no-files-found: warn
|
||||
|
||||
event-macos:
|
||||
if: ${{ inputs.mode == 'full' }}
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Event lane on this repository
|
||||
run: |
|
||||
# `cmd | tee` reports TEE's status: without this a failing lane
|
||||
# (a missing exec bit, a build error) passes the step and only
|
||||
# surfaces as the ratchet not finding its JSON.
|
||||
set -o pipefail
|
||||
mkdir -p "$RUNNER_TEMP/memwaste-event"
|
||||
scripts/memwaste.sh "$GITHUB_WORKSPACE" --top 40 --out "$RUNNER_TEMP/memwaste-event" \
|
||||
| tee "$RUNNER_TEMP/memwaste-event/report.txt"
|
||||
- name: Ratchet (report-only)
|
||||
run: python3 scripts/memwaste-ratchet.py --out "$RUNNER_TEMP/memwaste-event" --venue full-macos
|
||||
- name: Upload report
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: memwaste-event-macos-${{ inputs.mode }}
|
||||
path: |
|
||||
${{ runner.temp }}/memwaste-event/report.txt
|
||||
${{ runner.temp }}/memwaste-event/memwaste.json
|
||||
if-no-files-found: warn
|
||||
|
||||
event-windows:
|
||||
if: ${{ inputs.mode == 'full' }}
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2
|
||||
with:
|
||||
msystem: CLANG64
|
||||
path-type: inherit
|
||||
install: >-
|
||||
mingw-w64-clang-x86_64-clang
|
||||
mingw-w64-clang-x86_64-llvm
|
||||
mingw-w64-clang-x86_64-zlib
|
||||
mingw-w64-clang-x86_64-python3
|
||||
make
|
||||
git
|
||||
coreutils
|
||||
- name: Event lane on this repository
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
# `cmd | tee` reports TEE's status: without this a failing lane
|
||||
# (a missing exec bit, a build error) passes the step and only
|
||||
# surfaces as the ratchet not finding its JSON.
|
||||
set -o pipefail
|
||||
out="$(cygpath -u "$RUNNER_TEMP")/memwaste-event"
|
||||
mkdir -p "$out"
|
||||
CC=clang CXX=clang++ scripts/memwaste.sh "$(cygpath -u "$GITHUB_WORKSPACE")" --top 40 --out "$out" \
|
||||
| tee "$out/report.txt"
|
||||
- name: Ratchet (report-only)
|
||||
shell: msys2 {0}
|
||||
run: python3 scripts/memwaste-ratchet.py --out "$(cygpath -u "$RUNNER_TEMP")/memwaste-event" --venue full-windows
|
||||
- name: Upload report
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: memwaste-event-windows-${{ inputs.mode }}
|
||||
path: |
|
||||
${{ runner.temp }}/memwaste-event/report.txt
|
||||
${{ runner.temp }}/memwaste-event/memwaste.json
|
||||
if-no-files-found: warn
|
||||
@@ -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,13 +21,18 @@ 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:
|
||||
@@ -39,8 +49,17 @@ jobs:
|
||||
run: scripts/audit-license-provenance.py
|
||||
|
||||
codeql-gate:
|
||||
if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'codeql-gate') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 240
|
||||
# Without security-events:read this job inherits the workflow's
|
||||
# contents:read, the code-scanning alert API answers 403, and the gate
|
||||
# below reported "0 alerts" on every run it has ever made. actions:read is
|
||||
# what the CodeQL-run poll above needs for the same reason.
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: read
|
||||
actions: read
|
||||
steps:
|
||||
- name: Wait for CodeQL on current commit (max 150 min)
|
||||
env:
|
||||
@@ -77,11 +96,43 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# FAIL CLOSED. The previous form was `gh api ... 2>/dev/null || echo "0"`,
|
||||
# which looks defensive and is the opposite. gh writes the API error
|
||||
# BODY to stdout, so a 403 made ALERTS the string
|
||||
# {"message":"Resource not accessible by integration",...,"status":"403"}0
|
||||
# every `[` comparison then failed as non-integer, the `if` took its
|
||||
# false branch, and the step printed "CodeQL gate passed (0 alerts)"
|
||||
# and exited 0. That is exactly how a security gate should NEVER fail:
|
||||
# silently, in the direction of "everything is fine". An unreadable
|
||||
# alert list is now an error, not a zero.
|
||||
# NOTE: this runs in a command substitution, so it must `return`, not
|
||||
# `exit` -- an `exit` here would only end the subshell and let the
|
||||
# caller continue with an empty count, recreating the very bug this
|
||||
# replaces. The callers below check the status explicitly.
|
||||
count_open_alerts() {
|
||||
local body status
|
||||
body=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \
|
||||
--jq 'length' 2>/dev/null)
|
||||
status=$?
|
||||
if [ $status -ne 0 ]; then
|
||||
echo "BLOCKED: cannot read code scanning alerts (gh exit $status)." >&2
|
||||
echo " Refusing to treat an unreadable alert list as zero." >&2
|
||||
echo " Most likely cause: the job lacks security-events:read." >&2
|
||||
return 1
|
||||
fi
|
||||
case "$body" in
|
||||
''|*[!0-9]*)
|
||||
echo "BLOCKED: alert count was not a number: ${body:-<empty>}" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
printf '%s' "$body"
|
||||
}
|
||||
echo "Waiting 60s for alert API to settle..."
|
||||
sleep 60
|
||||
ALERTS=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0")
|
||||
ALERTS=$(count_open_alerts) || exit 1
|
||||
sleep 15
|
||||
ALERTS2=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0")
|
||||
ALERTS2=$(count_open_alerts) || exit 1
|
||||
[ "$ALERTS" -lt "$ALERTS2" ] && ALERTS=$ALERTS2
|
||||
if [ "$ALERTS" -gt 0 ]; then
|
||||
echo "BLOCKED: $ALERTS open alert(s)"
|
||||
|
||||
+142
-26
@@ -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 test-unix and test-windows legs run only its tests. Empty = every test (dry run, release, and any PR the selection does not narrow)'
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -30,18 +42,26 @@ jobs:
|
||||
outputs:
|
||||
unix: ${{ steps.set.outputs.unix }}
|
||||
windows: ${{ steps.set.outputs.windows }}
|
||||
tsan: ${{ steps.set.outputs.tsan }}
|
||||
has_unix: ${{ steps.set.outputs.has_unix }}
|
||||
has_windows: ${{ steps.set.outputs.has_windows }}
|
||||
has_tsan: ${{ steps.set.outputs.has_tsan }}
|
||||
steps:
|
||||
- name: Compute matrices
|
||||
id: set
|
||||
env:
|
||||
BROAD: ${{ inputs.broad_platforms }}
|
||||
SHARDS: ${{ inputs.shard_suites }}
|
||||
SHARD_PROFILE: ${{ inputs.shard_profile }}
|
||||
LANES: ${{ inputs.lanes }}
|
||||
run: |
|
||||
# Every CORE leg is tagged with the lane (scripts/ci/select-lanes.sh)
|
||||
# it serves; the tag is what lane selection filters on below.
|
||||
CORE_UNIX='[
|
||||
{"os":"ubuntu-latest","cc":"gcc","cxx":"g++"},
|
||||
{"os":"ubuntu-24.04-arm","cc":"gcc","cxx":"g++"},
|
||||
{"os":"macos-14","cc":"cc","cxx":"c++"},
|
||||
{"os":"macos-15-intel","cc":"cc","cxx":"c++"}
|
||||
{"os":"ubuntu-latest","cc":"gcc","cxx":"g++","lane":"unix-x86"},
|
||||
{"os":"ubuntu-24.04-arm","cc":"gcc","cxx":"g++","lane":"unix-arm64"},
|
||||
{"os":"macos-14","cc":"cc","cxx":"c++","lane":"unix-macos14"},
|
||||
{"os":"macos-15-intel","cc":"cc","cxx":"c++","lane":"unix-macos-intel"}
|
||||
]'
|
||||
# Broad matrix legs are REQUIRED gates (no optional/continue-on-error
|
||||
# escape hatches): the release dry run must be all-green, every platform.
|
||||
@@ -59,7 +79,7 @@ jobs:
|
||||
# interception crashed (interception_win: unhandled instruction). With
|
||||
# the native ARM64 toolchain ASan instruments native ARM64 code, so it
|
||||
# is a real (non-optional) gate, not a tolerated emulated-flake.
|
||||
CORE_WIN='[{"os":"windows-latest","msystem":"CLANG64","pkg":"x86_64"}]'
|
||||
CORE_WIN='[{"os":"windows-latest","msystem":"CLANG64","pkg":"x86_64","lane":"windows"}]'
|
||||
BROAD_WIN='[{"os":"windows-2025","msystem":"CLANG64","pkg":"x86_64"},{"os":"windows-11-arm","msystem":"CLANGARM64","pkg":"aarch64"}]'
|
||||
if [ "$BROAD" = "true" ]; then
|
||||
UNIX=$(jq -cn --argjson a "$CORE_UNIX" --argjson b "$BROAD_UNIX" '$a + $b')
|
||||
@@ -68,6 +88,30 @@ jobs:
|
||||
UNIX=$(jq -cn --argjson a "$CORE_UNIX" '$a')
|
||||
WIN=$(jq -cn --argjson a "$CORE_WIN" '$a')
|
||||
fi
|
||||
# The ThreadSanitizer legs (test-tsan), tagged the same way.
|
||||
TSAN='[
|
||||
{"os":"ubuntu-latest","cc":"clang","cxx":"clang++","lane":"tsan-x86"},
|
||||
{"os":"ubuntu-24.04-arm","cc":"clang","cxx":"clang++","lane":"tsan-arm"},
|
||||
{"os":"macos-14","cc":"cc","cxx":"c++","lane":"tsan-mac"}
|
||||
]'
|
||||
# Lane selection. "all" (dry run, release) keeps every leg and drops
|
||||
# the tags, so those matrices -- and their job names -- stay exactly
|
||||
# as before lane selection. A PR keeps only its selected legs, and
|
||||
# the tag stays in the job name, where ci-ok reads it back. An
|
||||
# untagged leg could never be selected: that is a configuration
|
||||
# error here, never a leg silently dropped.
|
||||
if [ "$LANES" = "all" ]; then
|
||||
UNIX=$(jq -c 'map(del(.lane))' <<< "$UNIX")
|
||||
WIN=$(jq -c 'map(del(.lane))' <<< "$WIN")
|
||||
TSAN=$(jq -c 'map(del(.lane))' <<< "$TSAN")
|
||||
else
|
||||
for M in "$UNIX" "$WIN" "$TSAN"; do
|
||||
jq -e 'all(.[]; has("lane"))' <<< "$M" > /dev/null || { echo "::error::untagged test leg in $M"; exit 1; }
|
||||
done
|
||||
UNIX=$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$UNIX")
|
||||
WIN=$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$WIN")
|
||||
TSAN=$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$TSAN")
|
||||
fi
|
||||
# Suite sharding (opt-in via the shard_suites input): each platform's
|
||||
# C-suite run is split across N runner jobs. CBM_TEST_SHARD="i/N"
|
||||
# slices deterministically inside run-tests-parallel.sh; each
|
||||
@@ -78,20 +122,37 @@ jobs:
|
||||
# shards); Windows gets 2 (every extra shard re-pays ~5 min of
|
||||
# MSYS2 setup); macOS stays at 1 — mac legs are not the critical
|
||||
# path and mac runner concurrency ceilings are the tightest.
|
||||
# The pr profile shards less: a PR runs few legs and every shard
|
||||
# re-pays its setup, so x86 and Windows run whole and only arm64,
|
||||
# the slowest leg (~72 min unsharded), splits in two.
|
||||
case "$SHARD_PROFILE" in
|
||||
standard) N_X86=3; N_ARM=3; N_WIN=2 ;;
|
||||
pr) N_X86=1; N_ARM=2; N_WIN=1 ;;
|
||||
*) echo "::error::unknown shard_profile '$SHARD_PROFILE'"; exit 1 ;;
|
||||
esac
|
||||
if [ "$SHARDS" = "true" ]; then
|
||||
UNIX=$(jq -c '[.[] | . as $e
|
||||
| (if ($e.os | startswith("ubuntu")) then 3 else 1 end) as $n
|
||||
UNIX=$(jq -c --argjson x86 "$N_X86" --argjson arm "$N_ARM" '[.[] | . as $e
|
||||
| (if ($e.os | startswith("ubuntu")) then
|
||||
(if ($e.os | endswith("-arm")) then $arm else $x86 end)
|
||||
else 1 end) as $n
|
||||
| range(1; $n + 1) as $i
|
||||
| $e + {shard: "\($i)/\($n)"}]' <<< "$UNIX")
|
||||
WIN=$(jq -c '[.[] | . as $e
|
||||
| range(1; 3) as $i
|
||||
| $e + {shard: "\($i)/2"}]' <<< "$WIN")
|
||||
WIN=$(jq -c --argjson n "$N_WIN" '[.[] | . as $e
|
||||
| range(1; $n + 1) as $i
|
||||
| $e + {shard: "\($i)/\($n)"}]' <<< "$WIN")
|
||||
fi
|
||||
echo "unix={\"include\":$UNIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "windows={\"include\":$WIN}" >> "$GITHUB_OUTPUT"
|
||||
echo "tsan={\"include\":$TSAN}" >> "$GITHUB_OUTPUT"
|
||||
# GitHub rejects an empty matrix, so a job whose legs were all
|
||||
# deselected is skipped through these flags instead.
|
||||
echo "has_unix=$(jq 'length > 0' <<< "$UNIX")" >> "$GITHUB_OUTPUT"
|
||||
echo "has_windows=$(jq 'length > 0' <<< "$WIN")" >> "$GITHUB_OUTPUT"
|
||||
echo "has_tsan=$(jq 'length > 0' <<< "$TSAN")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
test-unix:
|
||||
needs: setup-matrix
|
||||
if: ${{ needs.setup-matrix.outputs.has_unix == 'true' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.setup-matrix.outputs.unix) }}
|
||||
@@ -107,6 +168,32 @@ jobs:
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev ccache
|
||||
|
||||
# Ubuntu 23.10+ ships kernel.apparmor_restrict_unprivileged_userns=1, which
|
||||
# blocks unshare(CLONE_NEWUSER) for unprivileged users. Without this, the
|
||||
# #1830 single-uid-userns smoke test SKIPS on every 24.04 leg, and its only
|
||||
# real-namespace coverage lived on the two ubuntu-22.04 legs -- which are
|
||||
# broad-matrix only (never PR CI) and which GitHub retires on 2027-04-17.
|
||||
# A deliberate relaxation of a security check was therefore guarded by a
|
||||
# test that ran in no PR and was on a clock. Hosted runners have
|
||||
# passwordless sudo, so one sysctl puts it on the CORE matrix instead.
|
||||
# Best-effort: a runner that refuses simply returns to skipping.
|
||||
- name: Permit unprivileged user namespaces (Ubuntu)
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || \
|
||||
echo "could not lift the userns restriction; namespace tests will skip"
|
||||
# Then SAY whether it worked. The runner prints per-shard aggregates
|
||||
# ("N passed, M skipped") and never individual test names, so a
|
||||
# namespace test that silently returns to skipping is invisible in a
|
||||
# green log -- the same false assurance that let #1830 ship with a
|
||||
# test that never executed. This line is the evidence. Probed exactly
|
||||
# as the test does it: unprivileged, no sudo.
|
||||
if unshare -U -r true 2>/dev/null; then
|
||||
echo "CBM_USERNS_AVAILABLE=yes"
|
||||
else
|
||||
echo "CBM_USERNS_AVAILABLE=no (namespace-dependent tests will skip)"
|
||||
fi
|
||||
|
||||
- name: Install ccache (macOS)
|
||||
if: startsWith(matrix.os, 'macos')
|
||||
run: command -v ccache >/dev/null 2>&1 || brew install ccache
|
||||
@@ -134,12 +221,20 @@ jobs:
|
||||
ccache-test-${{ matrix.os }}-${{ matrix.cc }}-${{ github.ref }}-
|
||||
ccache-test-${{ matrix.os }}-${{ matrix.cc }}-
|
||||
|
||||
- name: Fetch the test selection
|
||||
if: ${{ inputs.test_selection != '' }}
|
||||
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 != '' && format('{0}/test-selection', runner.temp) || '' }}
|
||||
CCACHE_DIR: ${{ github.workspace }}/.ccache
|
||||
CCACHE_MAXSIZE: 1500M
|
||||
|
||||
@@ -179,6 +274,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:
|
||||
@@ -241,7 +337,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
|
||||
@@ -249,12 +348,12 @@ jobs:
|
||||
- name: Install deps
|
||||
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev ccache
|
||||
|
||||
- name: Install LLVM 22 (pinned diagnostic toolchain)
|
||||
- name: Install LLVM 21 (pinned diagnostic toolchain)
|
||||
run: |
|
||||
wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc
|
||||
echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-22 main" | sudo tee /etc/apt/sources.list.d/llvm-22.list
|
||||
echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang-22
|
||||
sudo apt-get install -y clang-21
|
||||
|
||||
- name: Compiler cache (content-verified, restore)
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
@@ -269,11 +368,11 @@ jobs:
|
||||
# lane: stack-use-after-return, stack-use-after-scope, and strict string
|
||||
# checks. Mirrors `make -f Makefile.cbm diag`, including its reasoning
|
||||
# for leaving detect_invalid_pointer_pairs out.
|
||||
- name: Test (clang-22, ASan+UBSan + off-by-default ASan checks)
|
||||
- name: Test (clang-21, ASan+UBSan + off-by-default ASan checks)
|
||||
env:
|
||||
ASAN_OPTIONS: "detect_stack_use_after_return=1:strict_string_checks=1:detect_stack_use_after_scope=1"
|
||||
run: |
|
||||
scripts/test.sh CC=clang-22 CXX=clang++-22 \
|
||||
scripts/test.sh CC=clang-21 CXX=clang++-21 \
|
||||
SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer -fno-optimize-sibling-calls"
|
||||
|
||||
- name: Compiler cache (save)
|
||||
@@ -293,17 +392,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:
|
||||
@@ -328,6 +431,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:
|
||||
@@ -366,13 +470,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:
|
||||
@@ -425,6 +527,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) }}
|
||||
@@ -475,6 +578,13 @@ jobs:
|
||||
ccache-test-${{ matrix.os }}-${{ matrix.msystem }}-${{ github.ref }}-
|
||||
ccache-test-${{ matrix.os }}-${{ matrix.msystem }}-
|
||||
|
||||
- name: Fetch the test selection
|
||||
if: ${{ inputs.test_selection != '' }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: ${{ inputs.test_selection }}
|
||||
path: ${{ runner.temp }}/test-selection
|
||||
|
||||
- name: Test
|
||||
shell: msys2 {0}
|
||||
# AddressSanitizer is unavailable on native ARM64 Windows (LLVM ships no
|
||||
@@ -502,6 +612,7 @@ jobs:
|
||||
CBM_TEST_SHARD: ${{ matrix.shard }}
|
||||
CBM_TEST_LEG: ${{ matrix.os }}-${{ matrix.msystem }}
|
||||
CBM_SKIP_PERF: ${{ inputs.skip_perf && '1' || '' }}
|
||||
CBM_TEST_SELECTION_DIR: ${{ inputs.test_selection != '' && format('{0}/test-selection', runner.temp) || '' }}
|
||||
CCACHE_DIR: ${{ github.workspace }}/.ccache
|
||||
CCACHE_MAXSIZE: 1500M
|
||||
|
||||
@@ -558,8 +669,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
|
||||
@@ -570,6 +685,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:
|
||||
|
||||
@@ -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@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
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@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
with:
|
||||
category: "/language:c-cpp"
|
||||
|
||||
@@ -45,6 +45,10 @@ on:
|
||||
description: 'Skip candidate scan; mark unscanned and default to stripped'
|
||||
type: boolean
|
||||
default: false
|
||||
skip_memwaste:
|
||||
description: 'Skip the waste sanitizer (report-only) and the fuzz smoke'
|
||||
type: boolean
|
||||
default: false
|
||||
soak_level:
|
||||
description: 'Soak: full (quick+asan), quick (10min), none'
|
||||
type: choice
|
||||
@@ -58,6 +62,13 @@ jobs:
|
||||
# ── Security (independent island — does not block main pipeline) ──
|
||||
security:
|
||||
uses: ./.github/workflows/_security.yml
|
||||
# The called job requests security-events:read to count code-scanning
|
||||
# alerts; a reusable workflow cannot request more than its caller grants,
|
||||
# so withholding this here fails the whole run at startup.
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: read
|
||||
actions: read
|
||||
secrets: inherit
|
||||
|
||||
# ── Lint (cppcheck + clang-format) ────────────────────────────
|
||||
@@ -100,6 +111,38 @@ jobs:
|
||||
with:
|
||||
broad_platforms: true
|
||||
|
||||
# ── Waste sanitizer, REPORT-ONLY (independent island) ─────────
|
||||
# Event lane on Linux, macOS and Windows, access lane and the per-function
|
||||
# scaling lane on Linux. Uploads reports; fails only when the measurement is
|
||||
# unsound (a defect of the tool itself).
|
||||
memwaste:
|
||||
if: ${{ inputs.skip_memwaste != true }}
|
||||
uses: ./.github/workflows/_memwaste.yml
|
||||
with:
|
||||
mode: full
|
||||
|
||||
# ── Fuzz smoke (independent island) ───────────────────────────
|
||||
# A fixed number of executions from a fixed seed per target, so the verdict is
|
||||
# a function of the commit (O9). A crash here is a real defect in a parser
|
||||
# that reads untrusted input; its reproducer is uploaded.
|
||||
fuzz:
|
||||
if: ${{ inputs.skip_memwaste != true }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install deps
|
||||
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev clang llvm libclang-rt-dev
|
||||
- name: Fuzz smoke (extract, cypher, config)
|
||||
run: scripts/fuzz.sh all --runs 20000 --seed 1
|
||||
- name: Upload reproducers
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: fuzz-artifacts
|
||||
path: build/fuzz/artifacts-*/
|
||||
if-no-files-found: ignore
|
||||
|
||||
# ── Soak tests (optional, parallel with smoke) ────────────────
|
||||
soak:
|
||||
if: ${{ inputs.soak_level != 'none' && !cancelled() && needs.build.result != 'failure' && needs.build.result != 'skipped' }}
|
||||
|
||||
@@ -35,6 +35,27 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Fuzz exploration: new inputs are the point here, so it runs for a time
|
||||
# budget instead of a fixed count. NON-GATING like the soak; a crash uploads
|
||||
# its reproducer, which becomes a fixed-seed regression input once fixed.
|
||||
fuzz:
|
||||
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install deps
|
||||
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev clang llvm libclang-rt-dev
|
||||
- name: Fuzz exploration (30 min per target)
|
||||
run: scripts/fuzz.sh all --max-total-time 1800 --seed 0
|
||||
- name: Upload reproducers
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: fuzz-artifacts-nightly
|
||||
path: build/fuzz/artifacts-*/
|
||||
if-no-files-found: ignore
|
||||
|
||||
soak:
|
||||
uses: ./.github/workflows/_soak.yml
|
||||
with:
|
||||
|
||||
+233
-25
@@ -1,7 +1,10 @@
|
||||
# PR validation: security gates + lint + full test suite. Builds, smoke and
|
||||
# soak stay maintainer-driven via the dry-run workflow_dispatch. Branch
|
||||
# PR validation: security gates + lint + the test legs this PR's changes need.
|
||||
# `changes` runs scripts/ci/select-lanes.sh on the PR's file list; every lane
|
||||
# job below runs only when selected (unknown paths, workflows, the harness,
|
||||
# Makefile.cbm and vendored code select everything). Builds, smoke and soak at
|
||||
# full breadth stay maintainer-driven via the dry-run workflow_dispatch. Branch
|
||||
# protection requires `dco` + `ci-ok` — a single stable summary context that
|
||||
# fails unless every PR stage succeeded.
|
||||
# fails unless every PR stage succeeded and every selected lane ran green.
|
||||
name: PR
|
||||
|
||||
on:
|
||||
@@ -21,35 +24,102 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
security:
|
||||
needs: [changes]
|
||||
uses: ./.github/workflows/_security.yml
|
||||
# The called job requests security-events:read to count code-scanning
|
||||
# alerts; a reusable workflow cannot request more than its caller grants,
|
||||
# so withholding this here fails the whole run at startup.
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: read
|
||||
actions: read
|
||||
secrets: inherit
|
||||
with:
|
||||
lanes: ${{ needs.changes.outputs.lanes }}
|
||||
|
||||
lint:
|
||||
needs: [changes]
|
||||
if: ${{ contains(fromJSON(needs.changes.outputs.lanes), 'lint') || contains(fromJSON(needs.changes.outputs.lanes), 'lint-mem') }}
|
||||
uses: ./.github/workflows/_lint.yml
|
||||
with:
|
||||
lanes: ${{ needs.changes.outputs.lanes }}
|
||||
|
||||
# ── Smart CI, PR CI only: run only the tests this change can reach.
|
||||
# Active only while the repository variable TEST_IMPACT_MODE is `gate`;
|
||||
# unset (shadow) every test runs and test-impact-shadow only reports.
|
||||
# test-impact-select.sh --gate narrows only on a well-formed answer: a
|
||||
# failure of this job, a run-all answer or a lane that selects nothing
|
||||
# leaves `test` without a selection, and every test runs. ci-ok does not
|
||||
# need this job: when it breaks, PRs cost more minutes, never coverage.
|
||||
# Dry runs and releases never pass a selection.
|
||||
select-tests:
|
||||
needs: [changes]
|
||||
if: ${{ vars.TEST_IMPACT_MODE == 'gate' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read # the merge base's team-artifact bundle (test-impact-artifact.yml)
|
||||
outputs:
|
||||
selection: ${{ steps.select.outputs.selection }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Full history: the engine certifies the history it diffs and refuses
|
||||
# a shallow clone, so a depth-2 checkout always answered `all`.
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Build this checkout's engine
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y zlib1g-dev
|
||||
scripts/build.sh CC=gcc CXX=g++
|
||||
- name: Select the tests
|
||||
id: select
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: scripts/ci/test-impact-select.sh --binary build/c/codebase-memory-mcp --out "$RUNNER_TEMP/test-impact" --gate "$RUNNER_TEMP/test-selection"
|
||||
- name: Upload the selection
|
||||
if: ${{ steps.select.outputs.selection == 'narrowed' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-selection
|
||||
path: ${{ runner.temp }}/test-selection
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
|
||||
test:
|
||||
needs: [lint]
|
||||
needs: [changes, lint, select-tests]
|
||||
if: ${{ !cancelled() && needs.lint.result == 'success' }}
|
||||
uses: ./.github/workflows/_test.yml
|
||||
with:
|
||||
lanes: ${{ needs.changes.outputs.lanes }}
|
||||
# Smart CI: only a successful select-tests run that narrowed passes a
|
||||
# selection; skipped (shadow), failed, run-all and nothing-selected
|
||||
# answers all run every test.
|
||||
test_selection: ${{ needs.select-tests.result == 'success' && needs.select-tests.outputs.selection == 'narrowed' && 'test-selection' || '' }}
|
||||
# Perf assertions are timing-sensitive on shared runners; they stay in
|
||||
# dry runs and releases where a flaky red does not block a merge.
|
||||
skip_perf: true
|
||||
# Iteration speed: split each C-suite leg across parallel shard jobs
|
||||
# (ubuntu x3, windows x2; coverage re-proven every run by the
|
||||
# shard-completeness job). Dry runs and releases shard identically.
|
||||
# Iteration speed: split C-suite legs across parallel shard jobs
|
||||
# (coverage re-proven every run by the shard-completeness job). The PR
|
||||
# profile shards only arm64 (x2); x86 and Windows run whole, since each
|
||||
# shard re-pays its setup. Dry runs and releases keep x3 / x2.
|
||||
shard_suites: true
|
||||
shard_profile: pr
|
||||
|
||||
# ── Which files changed? Gate the (heavier) build+smoke on product code so
|
||||
# docs / CI / test-only PRs stay fast. ──
|
||||
# ── Which lanes does this PR need? The changed files go through
|
||||
# scripts/ci/select-lanes.sh (tier, lanes, full, reasons); docs / CI /
|
||||
# test-only PRs stay fast, anything it cannot place runs everything. ──
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
product: ${{ steps.f.outputs.product }}
|
||||
tier: ${{ steps.select.outputs.tier }}
|
||||
full: ${{ steps.select.outputs.full }}
|
||||
lanes: ${{ steps.select.outputs.lanes }}
|
||||
smoke: ${{ steps.select.outputs.smoke }}
|
||||
steps:
|
||||
- name: Detect product-code changes
|
||||
id: f
|
||||
- name: List the changed files
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
@@ -57,28 +127,64 @@ jobs:
|
||||
run: |
|
||||
# The full .diff endpoint rejects large-but-valid PRs at 20k lines.
|
||||
# The paginated files endpoint remains filename-only for this gate.
|
||||
FILES=$(gh api --paginate "repos/$REPO/pulls/$PR/files?per_page=100" --jq '.[].filename')
|
||||
printf '%s\n' "$FILES"
|
||||
if printf '%s\n' "$FILES" | grep -qE '^(src/|internal/|install\.(sh|ps1)|scripts/build\.sh|scripts/smoke-test\.sh|scripts/smoke-local\.sh|scripts/smoke-fixture-server\.py|scripts/gen-third-party-notices\.sh|scripts/env\.sh|test-infrastructure/vm/(vm-smoke\.sh|windows-user-path-guard\.ps1)|Makefile\.cbm)'; then
|
||||
echo "product=true" >> "$GITHUB_OUTPUT"
|
||||
# A rename lists its old path too, as the fallback's --no-renames
|
||||
# diff does: moving a file away is a change to where it was.
|
||||
if FILES=$(gh api --paginate "repos/$REPO/pulls/$PR/files?per_page=100" --jq '.[] | .filename, (.previous_filename // empty)'); then
|
||||
echo "file list: pulls/files API"
|
||||
else
|
||||
echo "product=false" >> "$GITHUB_OUTPUT"
|
||||
# The files endpoint renders the diff server-side too, and answers
|
||||
# 422 "this diff is taking too long to generate" for a PR that
|
||||
# regenerates a vendored parser (#2246: a 42 MB sql/parser.c) --
|
||||
# so does the compare endpoint. The PR merge ref's first parent is
|
||||
# the base, so a name-only diff across it is the same file list.
|
||||
# depth 2 + blob:none fetches commits and trees only (well under
|
||||
# 1 MB, under a second); nothing from the PR is checked out or run.
|
||||
echo "::notice::pulls/files API failed; file list taken from refs/pull/$PR/merge"
|
||||
CHANGES_GIT="$RUNNER_TEMP/changes.git"
|
||||
git init -q --bare "$CHANGES_GIT"
|
||||
git --git-dir="$CHANGES_GIT" fetch -q --depth=2 --filter=blob:none \
|
||||
"https://github.com/$REPO" "refs/pull/$PR/merge"
|
||||
FILES=$(git --git-dir="$CHANGES_GIT" diff --name-only --no-renames FETCH_HEAD^1 FETCH_HEAD)
|
||||
fi
|
||||
printf '%s\n' "$FILES" > "$RUNNER_TEMP/changed-files.txt"
|
||||
cat "$RUNNER_TEMP/changed-files.txt"
|
||||
|
||||
# The selector is PR code: it runs in its own step, after the
|
||||
# token-bearing one, from a checkout that keeps no credentials.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# The pulls/files API lists at most 3000 files: the PR's own count lets
|
||||
# the selector notice a cut list and select everything instead.
|
||||
- name: Select lanes
|
||||
id: select
|
||||
env:
|
||||
CHANGED_FILES: ${{ github.event.pull_request.changed_files }}
|
||||
run: |
|
||||
scripts/ci/select-lanes.sh --format github-output --expect-files "$CHANGED_FILES" "$RUNNER_TEMP/changed-files.txt" > "$RUNNER_TEMP/selection.txt"
|
||||
cat "$RUNNER_TEMP/selection.txt"
|
||||
cat "$RUNNER_TEMP/selection.txt" >> "$GITHUB_OUTPUT"
|
||||
# pr-smoke's legs, each tagged with its lane (kept in the job name,
|
||||
# where ci-ok reads it back); the job gets the selected ones.
|
||||
SMOKE='[{"os":"ubuntu-latest","lane":"smoke-ubuntu"},{"os":"macos-14","lane":"smoke-mac"},{"os":"windows-latest","lane":"smoke-win"}]'
|
||||
LANES=$(sed -n 's/^lanes=//p' "$RUNNER_TEMP/selection.txt")
|
||||
echo "smoke={\"include\":$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$SMOKE")}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── Light smoke: build the PRODUCTION binary and run the core smoke on the
|
||||
# three RELIABLE NATIVE platforms. Catches built-binary regressions at PR
|
||||
# time (e.g. the Windows CreateProcess argv-quoting class that previously
|
||||
# only surfaced in the release dry run). The full broad/emulated smoke stays
|
||||
# in the dry run. Only product-code PRs pay this; every leg gates.
|
||||
# in the dry run. Only the selected legs run (ubuntu from the extraction
|
||||
# tier up, all three for platform/packaging changes); every leg gates.
|
||||
# The maintained local wrappers add a race-free release fixture so the
|
||||
# download/checksum/install/update phases run here as well. ──
|
||||
pr-smoke:
|
||||
needs: [changes]
|
||||
if: ${{ !cancelled() && needs.changes.outputs.product == 'true' }}
|
||||
if: ${{ !cancelled() && needs.changes.result == 'success' && (contains(fromJSON(needs.changes.outputs.lanes), 'smoke-ubuntu') || contains(fromJSON(needs.changes.outputs.lanes), 'smoke-mac') || contains(fromJSON(needs.changes.outputs.lanes), 'smoke-win')) }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-14, windows-latest]
|
||||
matrix: ${{ fromJSON(needs.changes.outputs.smoke) }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -147,19 +253,121 @@ jobs:
|
||||
CCACHE_DIR: ${{ github.workspace }}/.ccache
|
||||
CCACHE_MAXSIZE: 1000M
|
||||
|
||||
# ── Waste sanitizer, REPORT-ONLY: memory + CPU waste on this repository and
|
||||
# the per-function scaling lane (Linux). Deliberately NOT in ci-ok's needs:
|
||||
# it uploads evidence and prints the ratchet, it never blocks a merge. Only
|
||||
# PRs whose selection holds the memwaste lane (memory core, memwaste
|
||||
# tooling, full runs) pay for it. ──
|
||||
memwaste:
|
||||
needs: [changes]
|
||||
if: ${{ !cancelled() && needs.changes.result == 'success' && contains(fromJSON(needs.changes.outputs.lanes), 'memwaste') }}
|
||||
uses: ./.github/workflows/_memwaste.yml
|
||||
with:
|
||||
mode: pr
|
||||
|
||||
# ── Test-impact SHADOW, REPORT-ONLY: predicts from the code graph which C
|
||||
# suites this change can affect (the latest released binary indexes the
|
||||
# PR checkout; detect_changes at depth 15 against the base, mapped to
|
||||
# suites, with the selector's run-all triggers) and uploads the
|
||||
# prediction, so graph-based selection can be judged against what really
|
||||
# failed before anything gates on it. continue-on-error and NOT in
|
||||
# ci-ok's needs: it can never block or pass a merge. ──
|
||||
test-impact-shadow:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read # the merge base's team-artifact bundle (test-impact-artifact.yml)
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Full history: the engine (test-impact select) certifies the history
|
||||
# it diffs and refuses a shallow clone ("Graft or shallow state
|
||||
# prevents history certification"), which made every shadow answer
|
||||
# run_all (GIT_UNAVAILABLE). About 400 MiB, this job only.
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Fetch the latest released binary
|
||||
id: release
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: scripts/ci/test-impact-shadow.sh --fetch-release "$RUNNER_TEMP/cbm-bin"
|
||||
- name: Build the binary from this checkout (download failed)
|
||||
if: ${{ steps.release.outcome == 'failure' }}
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y zlib1g-dev
|
||||
scripts/build.sh CC=gcc CXX=g++
|
||||
mkdir -p "$RUNNER_TEMP/cbm-bin"
|
||||
cp build/c/codebase-memory-mcp "$RUNNER_TEMP/cbm-bin/"
|
||||
- name: Predict the impacted suites
|
||||
run: scripts/ci/test-impact-shadow.sh --binary "$RUNNER_TEMP/cbm-bin/codebase-memory-mcp" --out "$RUNNER_TEMP/test-impact"
|
||||
# The engine (test-impact select) is this checkout's own code: the
|
||||
# released binary predates it, so the checkout is built either way.
|
||||
- name: Build this checkout's engine
|
||||
if: ${{ steps.release.outcome != 'failure' }}
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y zlib1g-dev
|
||||
scripts/build.sh CC=gcc CXX=g++
|
||||
- name: Select with the team artifact (engine shadow)
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: scripts/ci/test-impact-select.sh --binary build/c/codebase-memory-mcp --out "$RUNNER_TEMP/test-impact"
|
||||
- name: Upload the prediction
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-impact-prediction
|
||||
path: |
|
||||
${{ runner.temp }}/test-impact/prediction.json
|
||||
${{ runner.temp }}/test-impact/selection.json
|
||||
${{ runner.temp }}/test-impact/changed-files.txt
|
||||
${{ runner.temp }}/test-impact/selection-engine.json
|
||||
${{ runner.temp }}/test-impact/summary-engine.md
|
||||
if-no-files-found: warn
|
||||
|
||||
# ── Docs-only and non-product PRs (tiers T0a, T0b) run the static contract
|
||||
# steps of the canonical test leg on their own: they police docs
|
||||
# surfaces (language counts, version metadata), test-infrastructure/ and
|
||||
# lint config too. Every test leg runs the same steps. ──
|
||||
contracts:
|
||||
needs: [changes]
|
||||
if: ${{ !cancelled() && needs.changes.result == 'success' && contains(fromJSON(needs.changes.outputs.lanes), 'contracts') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Contract steps only
|
||||
run: scripts/test.sh --contracts-only
|
||||
|
||||
ci-ok:
|
||||
# The one required context (besides dco) — fails unless every PR stage
|
||||
# succeeded, so matrix renames can never silently deadlock merges. `skipped`
|
||||
# is OK: pr-smoke skips on docs/CI/test-only PRs (changes.product == false).
|
||||
needs: [security, lint, test, changes, pr-smoke]
|
||||
# succeeded, so matrix renames can never silently deadlock merges, and
|
||||
# every lane the selector chose ran green: `skipped` is OK only for a lane
|
||||
# nobody selected (a selected lane that skipped is a silent gate loss).
|
||||
# memwaste and the test-impact shadow are report-only and not needed here.
|
||||
needs: [changes, security, lint, test, pr-smoke, contracts]
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
# actions:read lists this run's jobs, the lane-level truth behind `needs`.
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
# Needs a checkout (unlike before): the gate logic lives in the canonical
|
||||
# scripts/ci entry, not inline YAML (venue-parity contract).
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: All PR stages must have succeeded
|
||||
- name: List this run's jobs
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?filter=latest&per_page=100" \
|
||||
--jq '.jobs[] | {name, status, conclusion}' > "$RUNNER_TEMP/jobs.jsonl"
|
||||
- name: All PR stages and every selected lane must have succeeded
|
||||
env:
|
||||
RESULTS: ${{ toJSON(needs) }}
|
||||
LANES: ${{ needs.changes.outputs.lanes }}
|
||||
JOBS: ${{ runner.temp }}/jobs.jsonl
|
||||
run: scripts/ci/require-all-green.sh
|
||||
|
||||
@@ -44,6 +44,13 @@ jobs:
|
||||
# ── Security (starts immediately, blocks only verify) ───────────
|
||||
security:
|
||||
uses: ./.github/workflows/_security.yml
|
||||
# The called job requests security-events:read to count code-scanning
|
||||
# alerts; a reusable workflow cannot request more than its caller grants,
|
||||
# so withholding this here fails the whole run at startup.
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: read
|
||||
actions: read
|
||||
secrets: inherit
|
||||
|
||||
# ── 0. Preflight: refuse malformed dispatch inputs ──────────────
|
||||
@@ -138,6 +145,15 @@ jobs:
|
||||
version: ${{ inputs.version }}
|
||||
use_release_artifacts: true
|
||||
|
||||
# ── Waste sanitizer, REPORT-ONLY ────────────────────────────────
|
||||
# The same full run as the dry run, on the release commit. Not in
|
||||
# release-draft's needs: the report is evidence for the release notes, never
|
||||
# a publication gate.
|
||||
memwaste:
|
||||
uses: ./.github/workflows/_memwaste.yml
|
||||
with:
|
||||
mode: full
|
||||
|
||||
# ── 6. Create DRAFT release ────────────────────────────────────
|
||||
# Requires smoke to have actually SUCCEEDED, not merely "not failed". The bare
|
||||
# !cancelled() && !failure() form is fail-OPEN: a skipped smoke is neither
|
||||
@@ -220,8 +236,20 @@ jobs:
|
||||
- name: Generate SBOM
|
||||
run: python3 scripts/ci/generate-sbom.py "${{ inputs.version }}"
|
||||
|
||||
# actions/attest-sbom is deprecated. What it does now is emit
|
||||
# "::warning::actions/attest-sbom has been deprecated" and forward to
|
||||
# actions/attest with NODE_OPTIONS set, passing sbom-path straight
|
||||
# through. Calling attest directly produces the same attestation without
|
||||
# the wrapper, and sbom-path is a first-class input there (verified
|
||||
# against the action definition at this exact SHA, which is tag v4.1.0).
|
||||
#
|
||||
# This step deliberately carries no continue-on-error: a release must
|
||||
# fail if its SBOM cannot be attested. An unattested SBOM is worse than
|
||||
# an absent one, because the artifact still ships looking complete.
|
||||
- name: Attest SBOM
|
||||
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
env:
|
||||
NODE_OPTIONS: '--max-http-header-size=32768'
|
||||
with:
|
||||
subject-path: '*.tar.gz'
|
||||
sbom-path: 'sbom.json'
|
||||
|
||||
@@ -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@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
|
||||
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Smoke invariants across the WIDEST set of GitHub-hosted runners. Builds the
|
||||
# production source path with explicit fault-injection test seams and runs
|
||||
# scripts/smoke-invariants.sh
|
||||
# (version/help, MCP initialize handshake [#513], all 14 tools invocable, malformed-
|
||||
# (version/help, MCP initialize handshake [#513], all registered tools invocable, malformed-
|
||||
# input resilience, clean EOF exit, shared-lib resolution, install dry-run).
|
||||
#
|
||||
# Maximizing platforms is the point: ubuntu-22.04 (older glibc → AlmaLinux/#182
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Team artifact for test selection (smart CI, report-only until the oracles
|
||||
# and the shadow comparison are green). Every push to main publishes the
|
||||
# commit's bundle — its frozen graph, built incrementally from the previous
|
||||
# bundle, and its per-test coverage map, re-run only for the suites the push
|
||||
# selects — as the Actions artifact test-impact-<sha>. A weekly run rebuilds
|
||||
# both from scratch and fails when the incremental graph differs from the full
|
||||
# one. PR selection fetches the bundle of its merge base
|
||||
# (scripts/ci/test-impact-bundle.sh) and never narrows on anything else.
|
||||
name: test-impact-artifact
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "23 3 * * 0" # weekly full refresh, Sunday 03:23 UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: incremental or full
|
||||
type: choice
|
||||
options: [incremental, full]
|
||||
default: full
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read # fetch the previous bundle
|
||||
|
||||
concurrency:
|
||||
# Bundles chain: each push builds on the previous one, so runs never overlap
|
||||
# and are never cancelled half-way.
|
||||
group: test-impact-artifact
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Full history: `test-impact select/publish` certify the history they
|
||||
# read and refuse a shallow clone.
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install build deps and LLVM 21 (coverage toolchain)
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y zlib1g-dev
|
||||
wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc
|
||||
echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-21 main" | sudo tee /etc/apt/sources.list.d/llvm-21.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang-21 llvm-21 libclang-rt-21-dev
|
||||
|
||||
- name: Build the production binary and the coverage runner
|
||||
run: |
|
||||
scripts/build.sh CC=gcc CXX=g++
|
||||
make -f Makefile.cbm test-runner-cov COV_LLVM_BIN=/usr/lib/llvm-21/bin -j"$(nproc)"
|
||||
|
||||
- name: Publish the bundle
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
BEFORE: ${{ github.event.before }}
|
||||
REQUESTED: ${{ inputs.mode }}
|
||||
# The selection lane excludes @perf and PR CI skips perf, so their
|
||||
# (slowest, longest under coverage) suites are neither mapped nor
|
||||
# listed: the runner then neither lists nor runs them.
|
||||
CBM_SKIP_PERF: "1"
|
||||
run: |
|
||||
mode=incremental
|
||||
if [ "$EVENT" = schedule ] || [ "$REQUESTED" = full ]; then mode=full; fi
|
||||
before="$(git rev-parse HEAD^1)"
|
||||
if [ "$EVENT" = push ] && [ -n "$BEFORE" ] && [ "$BEFORE" != 0000000000000000000000000000000000000000 ]; then
|
||||
before="$BEFORE"
|
||||
fi
|
||||
scripts/ci/test-impact-publish.sh --binary build/c/codebase-memory-mcp \
|
||||
--runner build/cov/test-runner --llvm-bin /usr/lib/llvm-21/bin \
|
||||
--out "$RUNNER_TEMP/bundle" --mode "$mode" --before "$before"
|
||||
|
||||
- name: Upload the bundle
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-impact-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/bundle
|
||||
include-hidden-files: true # the graph lives in .codebase-memory/
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
@@ -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
|
||||
|
||||
+20
-1
@@ -55,7 +55,7 @@ src/
|
||||
foundation/ Arena allocator, hash table, string utils, platform compat
|
||||
store/ SQLite graph storage (WAL mode, FTS5)
|
||||
cypher/ Cypher query → SQL translation
|
||||
mcp/ MCP server (JSON-RPC 2.0 over stdio, 14 tools)
|
||||
mcp/ MCP server (JSON-RPC 2.0 over stdio, 17 tools)
|
||||
pipeline/ Multi-pass indexing pipeline
|
||||
pass_*.c Individual pipeline passes (definitions, calls, usages, etc.)
|
||||
httplink.c HTTP route extraction (Go/Express/Laravel/Ktor/Python)
|
||||
@@ -149,6 +149,25 @@ If in doubt, open an issue and ask.
|
||||
- Run `scripts/test.sh` and `scripts/lint.sh` before submitting
|
||||
- Keep PRs focused — avoid unrelated reformatting or refactoring
|
||||
|
||||
## AI-assisted and AI-authored contributions
|
||||
|
||||
Contributions written with the help of an AI tool, or written by an AI agent acting for a person, are welcome here on the same terms as any other. What we review is the change, not who or what typed it. Three things make that work:
|
||||
|
||||
- **Say so.** If an agent wrote the PR or is answering in the thread, disclose it — a line in the PR description or at the top of a comment is enough ("written with X", "this account is operated by an AI agent on behalf of Y"). Disclosure is never held against a contribution. Finding out later is.
|
||||
- **A person is accountable.** The [DCO sign-off](#license-and-sign-off-dco--required-on-every-commit) is a legal certification, and only a person can make it. The human behind the account certifies every commit, answers for it, and is who we talk to if something goes wrong. An agent may carry the conversation; it cannot carry the responsibility.
|
||||
- **The bar does not move.** Every claim in a PR must be something a reviewer can check: the failing test, the command that was run, the numbers and the machine they came from. "The model says it works" is not evidence, and a confident description does not replace a test that fails without the fix. We read AI-written PRs exactly as carefully as human-written ones — no more suspiciously, and no less.
|
||||
|
||||
Two practical notes:
|
||||
|
||||
- `Co-authored-by:` trailers naming a tool are fine. Links to private chat or agent sessions are not — they are dead to everyone else and do not belong in permanent history; we will ask you to remove them.
|
||||
- High-volume, low-effort submissions — many near-identical PRs, changes nobody ran, replies that do not engage with the review — will be closed, whoever or whatever produced them. One careful PR is worth more than twenty generated ones.
|
||||
|
||||
### Who speaks for the project
|
||||
|
||||
Only the maintainer account ([@DeusData](https://github.com/DeusData)) speaks for this project. A review, an approval, a "this will be merged" or a "this is a duplicate" from any other account is that person's opinion, however official it sounds — helpful reviews from the community are very welcome, and that is what they are.
|
||||
|
||||
**Impersonating a maintainer is not acceptable**, by a person or by an agent: presenting yourself as part of the maintainer team, posting approvals or change requests styled as maintainer decisions, or telling other contributors what the project has decided. We dismiss such reviews, hide the comments, say publicly in the affected threads that they were not ours, and report and block the account. If you are unsure whether a response you received is from the project, check the account name — and ask.
|
||||
|
||||
## Security
|
||||
|
||||
We take security seriously. All PRs go through:
|
||||
|
||||
+389
-39
@@ -71,19 +71,40 @@ ifeq ($(TEST_SEAMS),1)
|
||||
TEST_SEAM_DEFINE := -DCBM_ENABLE_TEST_SEAMS=1
|
||||
endif
|
||||
|
||||
# Per-test coverage for the test-impact map. TEST_COVERAGE=1 compiles clang
|
||||
# source coverage into OUR code only -- the one-shot sources, the LSP unity
|
||||
# object and the preprocessor -- in the test runner and in the product binary,
|
||||
# and turns on the runner's per-test profile hooks (tests/test_framework.h).
|
||||
# Vendored objects stay uninstrumented: a vendored change selects tests by rule,
|
||||
# and every counter compiled in is written out again for every test. Like every
|
||||
# other flavour it needs its own BUILD_DIR; the test-runner-cov and cbm-cov
|
||||
# targets pin one.
|
||||
# -fprofile-continuous: a child process started with `%c` in LLVM_PROFILE_FILE
|
||||
# keeps its counters mapped onto its profile file, so a child that is killed
|
||||
# still leaves a complete profile. Linux needs the flag for `%c` to work at
|
||||
# all; with it macOS behaves the same way (tests/test_main.c, "Children").
|
||||
TEST_COVERAGE ?= 0
|
||||
TEST_COVERAGE_FLAGS :=
|
||||
TEST_COVERAGE_DEFINE :=
|
||||
ifeq ($(TEST_COVERAGE),1)
|
||||
TEST_COVERAGE_FLAGS := -fprofile-instr-generate -fcoverage-mapping -fprofile-continuous
|
||||
TEST_COVERAGE_DEFINE := -DCBM_TEST_COVERAGE=1
|
||||
endif
|
||||
|
||||
# Production flags (CFLAGS_EXTRA allows CI to inject -DCBM_VERSION)
|
||||
# CBM_BIND_TS_ALLOCATOR=1: bind the tree-sitter runtime to mimalloc (#424). Only
|
||||
# the prod build uses mimalloc (MI_OVERRIDE=1); the test build is CRT+ASan, where
|
||||
# binding would create an alloc/free mismatch, so the guard is prod-only.
|
||||
CFLAGS_PROD = $(CFLAGS_COMMON) -O2 -DCBM_BIND_TS_ALLOCATOR=1 $(TEST_SEAM_DEFINE) \
|
||||
$(GLOBAL_OVERRIDE_DEFINE) $(CFLAGS_EXTRA)
|
||||
CXXFLAGS_PROD = $(CXXFLAGS_COMMON) -O2
|
||||
$(GLOBAL_OVERRIDE_DEFINE) $(MEMWASTE_CFLAGS) $(TEST_COVERAGE_FLAGS) $(CFLAGS_EXTRA)
|
||||
CXXFLAGS_PROD = $(CXXFLAGS_COMMON) -O2 $(MEMWASTE_VENDOR_CFLAGS) $(TEST_COVERAGE_FLAGS)
|
||||
|
||||
# Test flags: debug + sanitizers (override SANITIZE= to disable on Windows)
|
||||
SANITIZE = -fsanitize=address,undefined -fno-omit-frame-pointer
|
||||
EDITOR_TEST_DEFINES = -DCBM_JSON_LIKE_ENABLE_TEST_API=1 \
|
||||
-DCBM_TOML_EDIT_ENABLE_TEST_API=1 -DCBM_YAML_ENABLE_TEST_API=1 \
|
||||
-DCBM_TEXT_EDIT_ENABLE_TEST_API=1 -DCBM_CLI_ENABLE_TEST_API=1 \
|
||||
-DCBM_CONFIG_EDIT_PATH_ENABLE_TEST_API=1 \
|
||||
-DCBM_DIAGNOSTICS_ENABLE_TEST_API=1 -DCBM_ENABLE_TEST_SEAMS=1
|
||||
# The build system is the single source of truth for "is this binary
|
||||
# instrumented": compiler-specific probes (__SANITIZE_ADDRESS__) miss
|
||||
@@ -98,10 +119,13 @@ endif
|
||||
KOTLIN_DEDUP_TEST_DEFINE = -DCBM_KOTLIN_DEDUP_TEST_API=1
|
||||
CALL_REFERENCE_LOOKUP_TEST_DEFINE = -DCBM_CALL_REFERENCE_LOOKUP_TEST_API=1
|
||||
INCREMENTAL_TEST_DEFINE = -DCBM_INCREMENTAL_TEST_API=1
|
||||
COVERAGE_MARKER_TEST_DEFINE = -DCBM_COVERAGE_MARKER_TEST_API=1
|
||||
CFLAGS_TEST = $(CFLAGS_COMMON) $(EDITOR_TEST_DEFINES) $(SANITIZED_DEFINE) \
|
||||
$(KOTLIN_DEDUP_TEST_DEFINE) $(CALL_REFERENCE_LOOKUP_TEST_DEFINE) \
|
||||
$(INCREMENTAL_TEST_DEFINE) -g -O1 $(SANITIZE)
|
||||
CXXFLAGS_TEST = $(CXXFLAGS_COMMON) $(SANITIZED_DEFINE) -g -O1 $(SANITIZE) $(CXX_STDLIB_FLAGS)
|
||||
$(INCREMENTAL_TEST_DEFINE) $(COVERAGE_MARKER_TEST_DEFINE) $(TEST_COVERAGE_DEFINE) \
|
||||
-g -O1 $(SANITIZE) $(TEST_COVERAGE_FLAGS)
|
||||
CXXFLAGS_TEST = $(CXXFLAGS_COMMON) $(SANITIZED_DEFINE) -g -O1 $(SANITIZE) $(TEST_COVERAGE_FLAGS) \
|
||||
$(CXX_STDLIB_FLAGS)
|
||||
|
||||
# TSan (can't combine with ASan)
|
||||
TSAN_SANITIZE = -fsanitize=thread -fno-omit-frame-pointer
|
||||
@@ -118,6 +142,7 @@ TSAN_SANITIZE = -fsanitize=thread -fno-omit-frame-pointer
|
||||
# macro of ours.
|
||||
CFLAGS_TSAN = $(CFLAGS_COMMON) $(EDITOR_TEST_DEFINES) $(KOTLIN_DEDUP_TEST_DEFINE) \
|
||||
$(CALL_REFERENCE_LOOKUP_TEST_DEFINE) $(INCREMENTAL_TEST_DEFINE) \
|
||||
$(COVERAGE_MARKER_TEST_DEFINE) \
|
||||
-DCBM_SANITIZED_BUILD=1 -g -O1 $(TSAN_SANITIZE)
|
||||
CXXFLAGS_TSAN = $(CXXFLAGS_COMMON) -DCBM_SANITIZED_BUILD=1 -g -O1 \
|
||||
$(TSAN_SANITIZE)
|
||||
@@ -213,6 +238,75 @@ ELF_HARDENING_FLAGS := -Wl,-z,noexecstack -Wl,-z,separate-code \
|
||||
endif
|
||||
endif
|
||||
|
||||
# MEMWASTE=1: the waste-sanitizer flavour (src/foundation/mem_events.h). Built
|
||||
# like production -- -O2, mimalloc, the real allocator routing -- plus the event
|
||||
# layer and its observers, because waste measured under ASan's allocator would
|
||||
# describe ASan. Frame pointers stay so the macOS observer can walk to the
|
||||
# caller. On Linux this is also what finally LINKS the --wrap observation shim:
|
||||
# its flags and source were defined here but never reached a link line, so the
|
||||
# Linux profiler had been reporting nothing.
|
||||
#
|
||||
# Every object is built this way, VENDORED ones included (grammars, the
|
||||
# tree-sitter runtime, SQLite, tre, lz4, zstd, the C++ preprocessor): waste in
|
||||
# a vendored parser is waste in this program. mimalloc alone is left as it is --
|
||||
# its reads and writes are the allocator's own bookkeeping, not program data.
|
||||
#
|
||||
# MEMWASTE_ACCESS=1 adds the access lane (reads and writes of every tracked
|
||||
# block through the compiler's load/store callbacks; clang only). It builds at
|
||||
# -O1 without vectorisation: at -O2 the callbacks describe the OPTIMISED program,
|
||||
# and reads the optimiser removed would be reported as never read.
|
||||
# MEMWASTE_PROFILE=1 adds clang's execution counters (-fprofile-instr-generate)
|
||||
# for the CPU scaling lane (scripts/memwaste-scaling.py).
|
||||
# Each flavour needs its own BUILD_DIR: vendored objects are not rebuilt when
|
||||
# only the flavour changes.
|
||||
#
|
||||
# The libc wrappers (mem_override_libc.c) need --wrap on Linux and Windows;
|
||||
# macOS defines the symbols instead. STATIC=1 (musl) is not a memwaste target.
|
||||
MEMWASTE ?= 0
|
||||
MEMWASTE_ACCESS ?= 0
|
||||
MEMWASTE_PROFILE ?= 0
|
||||
ifeq ($(MEMWASTE_ACCESS),1)
|
||||
override MEMWASTE := 1
|
||||
endif
|
||||
ifeq ($(MEMWASTE_PROFILE),1)
|
||||
override MEMWASTE := 1
|
||||
endif
|
||||
MEMWASTE_CFLAGS :=
|
||||
MEMWASTE_VENDOR_CFLAGS :=
|
||||
MEMWASTE_LDFLAGS :=
|
||||
MEMWASTE_SRCS :=
|
||||
MEMWASTE_ALLOC_SYMS_LINUX := malloc calloc realloc free strdup strndup posix_memalign \
|
||||
aligned_alloc memalign
|
||||
MEMWASTE_LIBC_SYMS_LINUX := memcpy memmove memset memcmp strlen strcmp strncmp \
|
||||
__memcpy_chk __memmove_chk __memset_chk read __read_chk write \
|
||||
pread pwrite pread64 pwrite64 open open64 __open_2 __open64_2 \
|
||||
stat lstat fopen fopen64 fread __fread_chk fwrite opendir readdir \
|
||||
pthread_mutex_lock
|
||||
MEMWASTE_LIBC_SYMS_WIN := memcpy memmove memset memcmp strlen strcmp strncmp _wfopen fread fwrite
|
||||
ifeq ($(MEMWASTE),1)
|
||||
MEMWASTE_CFLAGS := -DCBM_MEMWASTE=1 -fno-omit-frame-pointer
|
||||
MEMWASTE_VENDOR_CFLAGS := -DCBM_MEMWASTE=1 -fno-omit-frame-pointer
|
||||
ifeq ($(IS_LINUX),yes)
|
||||
MEMWASTE_LDFLAGS := $(foreach sym,$(MEMWASTE_ALLOC_SYMS_LINUX) $(MEMWASTE_LIBC_SYMS_LINUX),-Wl,--wrap=$(sym))
|
||||
MEMWASTE_SRCS := src/foundation/mem_override_posix.c
|
||||
endif
|
||||
ifeq ($(IS_MINGW),yes)
|
||||
MEMWASTE_LDFLAGS := $(foreach sym,$(MEMWASTE_LIBC_SYMS_WIN),-Wl,--wrap=$(sym))
|
||||
endif
|
||||
endif
|
||||
ifeq ($(MEMWASTE_ACCESS),1)
|
||||
MEMWASTE_ACCESS_FLAGS := -DCBM_MEMWASTE_ACCESS=1 -O1 -fno-vectorize -fno-slp-vectorize \
|
||||
-fsanitize-coverage=inline-bool-flag,trace-loads,trace-stores
|
||||
MEMWASTE_CFLAGS += $(MEMWASTE_ACCESS_FLAGS)
|
||||
MEMWASTE_VENDOR_CFLAGS += $(MEMWASTE_ACCESS_FLAGS)
|
||||
endif
|
||||
ifeq ($(MEMWASTE_PROFILE),1)
|
||||
MEMWASTE_PROFILE_FLAGS := -DCBM_MEMWASTE_PROFILE=1 -fprofile-instr-generate -fprofile-update=atomic
|
||||
MEMWASTE_CFLAGS += $(MEMWASTE_PROFILE_FLAGS)
|
||||
MEMWASTE_VENDOR_CFLAGS += $(MEMWASTE_PROFILE_FLAGS)
|
||||
MEMWASTE_LDFLAGS += $(MEMWASTE_PROFILE_FLAGS)
|
||||
endif
|
||||
|
||||
# The POSIX wrap shim exists only so the profiler can observe allocations. It
|
||||
# must never reach a sanitized build: the Linux/macOS test builds are CRT+ASan,
|
||||
# and redirecting malloc into mimalloc underneath ASan's own interception mixes
|
||||
@@ -222,7 +316,7 @@ endif
|
||||
# instead of the system libstdc++ (scripts/msan.sh overrides both vars).
|
||||
CXX_STDLIB ?= -lstdc++
|
||||
CXX_STDLIB_FLAGS ?=
|
||||
LDFLAGS = -lm $(CXX_STDLIB) -lpthread -lz $(WIN32_LIBS) $(STATIC_FLAGS) $(MIMALLOC_WRAP_FLAGS) $(ELF_HARDENING_FLAGS)
|
||||
LDFLAGS = -lm $(CXX_STDLIB) -lpthread -lz $(WIN32_LIBS) $(STATIC_FLAGS) $(MIMALLOC_WRAP_FLAGS) $(MEMWASTE_LDFLAGS) $(ELF_HARDENING_FLAGS)
|
||||
LDFLAGS_TEST = -lm $(CXX_STDLIB) -lpthread -lz $(SANITIZE) $(WIN32_LIBS) $(MIMALLOC_WRAP_FLAGS) $(ELF_HARDENING_FLAGS)
|
||||
LDFLAGS_TSAN = -lm $(CXX_STDLIB) -lpthread -lz $(TSAN_SANITIZE) $(WIN32_LIBS) $(MIMALLOC_WRAP_FLAGS) $(ELF_HARDENING_FLAGS)
|
||||
|
||||
@@ -237,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 \
|
||||
@@ -245,11 +340,17 @@ FOUNDATION_SRCS = \
|
||||
src/foundation/compat_fs.c \
|
||||
src/foundation/compat_regex.c \
|
||||
src/foundation/mem.c \
|
||||
src/foundation/mem_core.c \
|
||||
src/foundation/mem_events.c \
|
||||
src/foundation/mem_override_darwin.c \
|
||||
src/foundation/mem_override_libc.c \
|
||||
src/foundation/diagnostics.c \
|
||||
src/foundation/profile.c \
|
||||
src/foundation/dump_verify.c \
|
||||
src/foundation/limits.c \
|
||||
src/foundation/index_policy.c \
|
||||
src/foundation/subprocess.c \
|
||||
src/foundation/git_env.c \
|
||||
src/foundation/sha256.c \
|
||||
src/foundation/secure_random.c \
|
||||
src/foundation/macos_acl.c \
|
||||
@@ -260,6 +361,7 @@ 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 \
|
||||
@@ -271,11 +373,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
|
||||
@@ -318,13 +448,15 @@ PREPROCESSOR_SRC = $(CBM_DIR)/preprocessor.cpp
|
||||
SQLITE_WRITER_SRC = $(CBM_DIR)/sqlite_writer.c
|
||||
|
||||
# Store module (new)
|
||||
STORE_SRCS = src/store/store.c src/store/architecture_projection.c
|
||||
STORE_SRCS = src/store/store.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 src/mcp/architecture_jobs.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 = \
|
||||
@@ -343,7 +475,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)
|
||||
@@ -355,6 +498,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 \
|
||||
@@ -362,6 +506,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 \
|
||||
@@ -370,6 +515,22 @@ PIPELINE_SRCS = \
|
||||
src/pipeline/pass_gitdiff.c \
|
||||
src/pipeline/pass_configures.c \
|
||||
src/pipeline/pass_configlink.c \
|
||||
src/pipeline/pass_doclinks.c \
|
||||
src/pipeline/doc_links.c \
|
||||
src/pipeline/doc_links_cs.c \
|
||||
src/pipeline/doc_links_md.c \
|
||||
src/pipeline/doc_links_pdf.c \
|
||||
src/pipeline/doc_links_rst.c \
|
||||
src/pipeline/doc_links_adoc.c \
|
||||
src/pipeline/doc_links_go.c \
|
||||
src/pipeline/doc_links_jvm.c \
|
||||
src/pipeline/doc_links_rs.c \
|
||||
src/pipeline/doc_links_perl.c \
|
||||
src/pipeline/doc_links_ts.c \
|
||||
src/pipeline/doc_links_php.c \
|
||||
src/pipeline/doc_links_c.c \
|
||||
src/pipeline/pass_diagrams.c \
|
||||
src/pipeline/doc_links_msbuild.c \
|
||||
src/pipeline/pass_route_nodes.c \
|
||||
src/pipeline/pass_enrichment.c \
|
||||
src/pipeline/pass_envscan.c \
|
||||
@@ -404,10 +565,10 @@ WATCHER_SRCS = src/watcher/watcher.c
|
||||
GIT_SRCS = src/git/git_context.c
|
||||
|
||||
# CLI module (new)
|
||||
CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c src/cli/client_adapter.c \
|
||||
CLI_SRCS = src/cli/cli.c src/cli/runtime_settings.c src/cli/progress_sink.c src/cli/hook_augment.c src/cli/client_adapter.c \
|
||||
src/cli/agent_clients.c src/cli/agent_profiles.c \
|
||||
src/cli/config_json_like.c src/cli/config_toml_edit.c src/cli/config_yaml_edit.c \
|
||||
src/cli/config_text_edit.c src/cli/activation_transaction.c
|
||||
src/cli/config_text_edit.c src/cli/config_edit_path.c src/cli/activation_transaction.c
|
||||
|
||||
# UI module (graph visualization)
|
||||
UI_SRCS = \
|
||||
@@ -475,10 +636,19 @@ ifneq ($(filter yes,$(IS_MINGW) $(IS_LINUX)),)
|
||||
MIMALLOC_OVERRIDE_DEFINE := -DMI_MALLOC_OVERRIDE=1
|
||||
GLOBAL_OVERRIDE_DEFINE := -DCBM_MEM_GLOBAL_OVERRIDE=1
|
||||
endif
|
||||
# MI_DEBUG=0: the production allocator is mimalloc's RELEASE build. Its header
|
||||
# defaults to MI_DEBUG=2 unless NDEBUG is defined, and nothing here defined it,
|
||||
# so every shipped binary up to v0.11.0 ran mimalloc's debug build: internal
|
||||
# assertions, fine-grained statistics, padding on every block and a memset of
|
||||
# every freed block. The waste sanitizer measured it on the Go corpus as 92 GB
|
||||
# (macOS) and 444 GB (Linux, where mimalloc also serves libc's malloc) of byte
|
||||
# work inside the allocator (2026-09-17). Committed-bytes accounting, which the
|
||||
# memory budget reads, is kept in every build (subproc stats, not MI_STAT).
|
||||
# The TEST build below keeps mimalloc's debug checks.
|
||||
MIMALLOC_CFLAGS = -std=c11 -O2 -w \
|
||||
-Ivendored/mimalloc/include \
|
||||
-Ivendored/mimalloc/src \
|
||||
-DMI_OVERRIDE=1 \
|
||||
-DMI_OVERRIDE=1 -DMI_DEBUG=0 \
|
||||
$(MIMALLOC_OVERRIDE_DEFINE)
|
||||
MIMALLOC_CFLAGS_TEST = -std=c11 -g -O1 -w \
|
||||
-Ivendored/mimalloc/include \
|
||||
@@ -499,7 +669,7 @@ MIMALLOC_CFLAGS_TEST = -std=c11 -g -O1 -w \
|
||||
SQLITE3_OMIT_FLAGS = -DSQLITE_OMIT_LOAD_EXTENSION
|
||||
SQLITE3_SRC = vendored/sqlite3/sqlite3.c
|
||||
SQLITE3_CFLAGS = -std=c11 -O2 -w -DSQLITE_DQS=0 -DSQLITE_THREADSAFE=1 -DSQLITE_ENABLE_FTS5 \
|
||||
$(SQLITE3_OMIT_FLAGS)
|
||||
$(SQLITE3_OMIT_FLAGS) $(MEMWASTE_VENDOR_CFLAGS)
|
||||
SQLITE3_CFLAGS_TEST = -std=c11 -g -O1 -w -DSQLITE_DQS=0 -DSQLITE_THREADSAFE=1 -DSQLITE_ENABLE_FTS5 \
|
||||
$(SQLITE3_OMIT_FLAGS)
|
||||
|
||||
@@ -511,7 +681,7 @@ TRE_CFLAGS = -std=c11 -g -O1 -w -Ivendored/tre
|
||||
YYJSON_SRC = vendored/yyjson/yyjson.c
|
||||
|
||||
# All production sources
|
||||
PROD_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DAEMON_SRCS) $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) $(TRACES_SRCS) $(WATCHER_SRCS) $(GIT_SRCS) $(CLI_SRCS) $(UI_SRCS) $(YYJSON_SRC)
|
||||
PROD_SRCS = $(MEMWASTE_SRCS) $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DAEMON_SRCS) $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) $(TRACES_SRCS) $(WATCHER_SRCS) $(GIT_SRCS) $(CLI_SRCS) $(UI_SRCS) $(YYJSON_SRC)
|
||||
EXISTING_C_SRCS = $(EXTRACTION_SRCS) $(LSP_SRCS) $(TS_RUNTIME_SRC) \
|
||||
$(GRAMMAR_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC)
|
||||
|
||||
@@ -533,12 +703,15 @@ PROJECT_HDRS = $(wildcard src/*.h src/*/*.h $(CBM_DIR)/*.h $(CBM_DIR)/lsp/*.h te
|
||||
|
||||
TEST_FOUNDATION_SRCS = \
|
||||
tests/test_main.c \
|
||||
tests/test_coverage_children.c \
|
||||
tests/test_coverage_parent_failure.c \
|
||||
tests/test_arena.c \
|
||||
tests/test_hash_table.c \
|
||||
tests/test_dyn_array.c \
|
||||
tests/test_str_intern.c \
|
||||
tests/test_log.c \
|
||||
tests/test_str_util.c \
|
||||
tests/test_index_policy.c \
|
||||
tests/test_workspace.c \
|
||||
tests/test_platform.c \
|
||||
tests/test_diagnostics.c \
|
||||
@@ -550,6 +723,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 \
|
||||
@@ -561,6 +736,9 @@ 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_store_search.c \
|
||||
tests/test_store_arch.c \
|
||||
tests/test_architecture_projection.c \
|
||||
@@ -574,6 +752,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 = \
|
||||
@@ -591,12 +786,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_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
|
||||
|
||||
@@ -635,17 +841,17 @@ 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
|
||||
|
||||
TEST_MEM_SRCS = tests/test_mem.c
|
||||
TEST_MEM_SRCS = tests/test_mem.c tests/test_mem_events.c
|
||||
|
||||
TEST_UI_SRCS = tests/test_ui.c
|
||||
TEST_HTTPD_SRCS = tests/test_httpd.c
|
||||
@@ -715,6 +921,7 @@ TEST_REPRO_SRCS = \
|
||||
tests/repro/repro_issue787.c \
|
||||
tests/repro/repro_issue842.c \
|
||||
tests/repro/repro_issue964.c \
|
||||
tests/repro/repro_issue1692.c \
|
||||
tests/repro/repro_invariant_calls.c \
|
||||
tests/repro/repro_invariant_graph.c \
|
||||
tests/repro/repro_invariant_breadth.c \
|
||||
@@ -749,7 +956,14 @@ BUILD_DIR = build/c
|
||||
# ── Object file compilation (grammars need relaxed warnings) ─────
|
||||
|
||||
# Grammar + tree-sitter runtime: compiled without -Werror (upstream code has warnings)
|
||||
GRAMMAR_CFLAGS = -std=c11 -D_DEFAULT_SOURCE -O2 -w -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC)
|
||||
# $(TEST_SEAM_DEFINE): these prod objects (grammars, ts_runtime, lsp_all) are
|
||||
# linked into the same binary as the CFLAGS_PROD sources, so both halves must
|
||||
# agree on the seam switch. lsp_all holds seam code of its own (ts_lsp.c's
|
||||
# budget probes, any seam counter a resolver defines): without the define a
|
||||
# TEST_SEAMS=1 binary silently lacks those seams, and a seam symbol that cbm.c
|
||||
# references fails to link. GRAMMAR_CFLAGS_TEST always carries the define.
|
||||
GRAMMAR_CFLAGS = -std=c11 -D_DEFAULT_SOURCE -O2 -w -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC) \
|
||||
$(TEST_SEAM_DEFINE) $(MEMWASTE_VENDOR_CFLAGS)
|
||||
GRAMMAR_CFLAGS_TEST = -std=c11 -D_DEFAULT_SOURCE -g -O1 -w -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC) \
|
||||
-DCBM_ENABLE_TEST_SEAMS=1 $(SANITIZED_DEFINE) $(SANITIZE)
|
||||
GRAMMAR_CFLAGS_TSAN = -std=c11 -D_DEFAULT_SOURCE -g -O1 -w -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC) \
|
||||
@@ -767,7 +981,7 @@ PP_OBJ_TSAN = $(BUILD_DIR)/tsan_preprocessor.o
|
||||
|
||||
# ── Targets ──────────────────────────────────────────────────────
|
||||
|
||||
.PHONY: test test-par test-repro test-foundation test-tsan test-daemon-smoke cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-tidy-diff lint-cppcheck lint-format security
|
||||
.PHONY: test-coverage-flush test test-par test-repro test-foundation test-tsan test-daemon-smoke test-runner-cov cbm-cov cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-tidy-diff lint-cppcheck lint-format security fuzz
|
||||
|
||||
$(BUILD_DIR):
|
||||
mkdir -p $(BUILD_DIR)
|
||||
@@ -782,19 +996,36 @@ test-foundation: $(BUILD_DIR)/test-foundation
|
||||
|
||||
# ── Grammar/TS/LSP object files (compiled with relaxed warnings) ─
|
||||
|
||||
$(BUILD_DIR)/%.o: $(CBM_DIR)/%.c | $(BUILD_DIR)
|
||||
# Each grammar_<lang>.c is a unity object that #includes its own vendored
|
||||
# parser.c and, where the grammar has one, scanner.c. Those includes were
|
||||
# prerequisites of nothing, so editing a vendored scanner left the object
|
||||
# untouched and the edit silently absent from the binary: on 2026-09-19 a fix
|
||||
# to properties/scanner.c appeared not to work at all, and `nm` showed the old
|
||||
# symbol still sitting in prod_grammar_properties.o. MANIFEST.md's "Local
|
||||
# source patches" table means patching a vendored scanner is expected, so this
|
||||
# trap is reachable by design. Same reasoning as LSP_UNITY_DEPS and
|
||||
# TS_RUNTIME_DEPS above; secondary expansion is what lets one pattern rule name
|
||||
# each grammar's own directory ($$* is the stem, grammar_properties ->
|
||||
# properties). Non-grammar objects match no directory and gain no prerequisite.
|
||||
GRAMMAR_VENDOR_DIR = $(CBM_DIR)/vendored/grammars/$$(subst grammar_,,$$*)
|
||||
GRAMMAR_UNITY_DEPS = $$(wildcard $(GRAMMAR_VENDOR_DIR)/*.c) \
|
||||
$$(wildcard $(GRAMMAR_VENDOR_DIR)/*.h) \
|
||||
$$(wildcard $(GRAMMAR_VENDOR_DIR)/tree_sitter/*.h)
|
||||
.SECONDEXPANSION:
|
||||
|
||||
$(BUILD_DIR)/%.o: $(CBM_DIR)/%.c $(GRAMMAR_UNITY_DEPS) | $(BUILD_DIR)
|
||||
$(CC) $(GRAMMAR_CFLAGS_TEST) -c -o $@ $<
|
||||
|
||||
$(BUILD_DIR)/ts_runtime.o: $(TS_RUNTIME_DEPS) | $(BUILD_DIR)
|
||||
$(CC) $(GRAMMAR_CFLAGS_TEST) -c -o $@ $<
|
||||
|
||||
$(BUILD_DIR)/lsp_all.o: $(LSP_UNITY_DEPS) | $(BUILD_DIR)
|
||||
$(CC) $(GRAMMAR_CFLAGS_TEST) $(SANITIZE) -c -o $@ $<
|
||||
$(CC) $(GRAMMAR_CFLAGS_TEST) $(SANITIZE) $(TEST_COVERAGE_FLAGS) -c -o $@ $<
|
||||
|
||||
$(BUILD_DIR)/preprocessor.o: $(CBM_DIR)/preprocessor.cpp | $(BUILD_DIR)
|
||||
$(CXX) $(CXXFLAGS_TEST) -w -I$(CBM_DIR)/vendored -c -o $@ $<
|
||||
|
||||
$(BUILD_DIR)/tsan_%.o: $(CBM_DIR)/%.c | $(BUILD_DIR)
|
||||
$(BUILD_DIR)/tsan_%.o: $(CBM_DIR)/%.c $(GRAMMAR_UNITY_DEPS) | $(BUILD_DIR)
|
||||
$(CC) $(GRAMMAR_CFLAGS_TSAN) -c -o $@ $<
|
||||
|
||||
$(BUILD_DIR)/tsan_ts_runtime.o: $(TS_RUNTIME_DEPS) | $(BUILD_DIR)
|
||||
@@ -840,7 +1071,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>)
|
||||
@@ -870,8 +1103,8 @@ $(BUILD_DIR)/tre.o: $(TRE_SRC) | $(BUILD_DIR)
|
||||
$(BUILD_DIR)/tsan_tre.o: $(TRE_SRC) | $(BUILD_DIR)
|
||||
$(CC) $(TRE_CFLAGS) $(TSAN_SANITIZE) -c -o $@ $<
|
||||
|
||||
$(BUILD_DIR)/prod_tre.o: $(TRE_SRC) | $(BUILD_DIR)
|
||||
$(CC) $(TRE_CFLAGS) -O2 -c -o $@ $<
|
||||
$(BUILD_DIR)/prod_tre.o: $(TRE_SRC) $(BUILD_DIR)/.build-config | $(BUILD_DIR)
|
||||
$(CC) $(TRE_CFLAGS) -O2 $(MEMWASTE_VENDOR_CFLAGS) -c -o $@ $<
|
||||
endif
|
||||
|
||||
# Vendored LZ4 (test build)
|
||||
@@ -1024,29 +1257,36 @@ TS_RUNTIME_OBJ_PROD = $(BUILD_DIR)/prod_ts_runtime.o
|
||||
LSP_OBJ_PROD = $(BUILD_DIR)/prod_lsp_all.o
|
||||
PP_OBJ_PROD = $(BUILD_DIR)/prod_preprocessor.o
|
||||
|
||||
$(BUILD_DIR)/prod_%.o: $(CBM_DIR)/%.c | $(BUILD_DIR)
|
||||
# Every prod object whose flags carry a BUILD_CONFIG_SIG variable
|
||||
# (TEST_SEAM_DEFINE via GRAMMAR_CFLAGS, MEMWASTE_VENDOR_CFLAGS) depends on the
|
||||
# $(BUILD_DIR)/.build-config stamp. These objects are compiled once and linked
|
||||
# into every later binary, so without the stamp a TEST_SEAMS or MEMWASTE flip
|
||||
# relinks the PREVIOUS flavour: a release binary keeps seam code compiled into
|
||||
# lsp_all, or a seams binary misses it. prod_mimalloc.o and the unixcoder blob
|
||||
# take no config variable and stay off the stamp.
|
||||
$(BUILD_DIR)/prod_%.o: $(CBM_DIR)/%.c $(GRAMMAR_UNITY_DEPS) $(BUILD_DIR)/.build-config | $(BUILD_DIR)
|
||||
$(CC) $(GRAMMAR_CFLAGS) -c -o $@ $<
|
||||
|
||||
$(BUILD_DIR)/prod_ts_runtime.o: $(TS_RUNTIME_DEPS) | $(BUILD_DIR)
|
||||
$(BUILD_DIR)/prod_ts_runtime.o: $(TS_RUNTIME_DEPS) $(BUILD_DIR)/.build-config | $(BUILD_DIR)
|
||||
$(CC) $(GRAMMAR_CFLAGS) -c -o $@ $<
|
||||
|
||||
$(BUILD_DIR)/prod_lsp_all.o: $(LSP_UNITY_DEPS) | $(BUILD_DIR)
|
||||
$(CC) $(GRAMMAR_CFLAGS) -c -o $@ $<
|
||||
$(BUILD_DIR)/prod_lsp_all.o: $(LSP_UNITY_DEPS) $(BUILD_DIR)/.build-config | $(BUILD_DIR)
|
||||
$(CC) $(GRAMMAR_CFLAGS) $(TEST_COVERAGE_FLAGS) -c -o $@ $<
|
||||
|
||||
$(BUILD_DIR)/prod_preprocessor.o: $(CBM_DIR)/preprocessor.cpp | $(BUILD_DIR)
|
||||
$(BUILD_DIR)/prod_preprocessor.o: $(CBM_DIR)/preprocessor.cpp $(BUILD_DIR)/.build-config | $(BUILD_DIR)
|
||||
$(CXX) $(CXXFLAGS_PROD) -w -I$(CBM_DIR)/vendored -c -o $@ $<
|
||||
|
||||
# Vendored LZ4 (compiled separately, not unity-built via lz4_store.c)
|
||||
LZ4_OBJ_PROD = $(BUILD_DIR)/prod_lz4.o $(BUILD_DIR)/prod_lz4hc.o
|
||||
$(BUILD_DIR)/prod_lz4.o: $(CBM_DIR)/vendored/lz4/lz4.c | $(BUILD_DIR)
|
||||
$(CC) -std=c11 -D_DEFAULT_SOURCE -O2 -w -I$(CBM_DIR) -c -o $@ $<
|
||||
$(BUILD_DIR)/prod_lz4hc.o: $(CBM_DIR)/vendored/lz4/lz4hc.c | $(BUILD_DIR)
|
||||
$(CC) -std=c11 -D_DEFAULT_SOURCE -O2 -w -I$(CBM_DIR)/vendored/lz4 -c -o $@ $<
|
||||
$(BUILD_DIR)/prod_lz4.o: $(CBM_DIR)/vendored/lz4/lz4.c $(BUILD_DIR)/.build-config | $(BUILD_DIR)
|
||||
$(CC) -std=c11 -D_DEFAULT_SOURCE -O2 -w $(MEMWASTE_VENDOR_CFLAGS) -I$(CBM_DIR) -c -o $@ $<
|
||||
$(BUILD_DIR)/prod_lz4hc.o: $(CBM_DIR)/vendored/lz4/lz4hc.c $(BUILD_DIR)/.build-config | $(BUILD_DIR)
|
||||
$(CC) -std=c11 -D_DEFAULT_SOURCE -O2 -w $(MEMWASTE_VENDOR_CFLAGS) -I$(CBM_DIR)/vendored/lz4 -c -o $@ $<
|
||||
|
||||
# Vendored zstd (compiled separately, not unity-built via zstd_store.c)
|
||||
ZSTD_OBJ_PROD = $(BUILD_DIR)/prod_zstd.o
|
||||
$(BUILD_DIR)/prod_zstd.o: $(CBM_DIR)/vendored/zstd/zstd.c | $(BUILD_DIR)
|
||||
$(CC) -std=c11 -D_DEFAULT_SOURCE -O2 -w -I$(CBM_DIR)/vendored/zstd -c -o $@ $<
|
||||
$(BUILD_DIR)/prod_zstd.o: $(CBM_DIR)/vendored/zstd/zstd.c $(BUILD_DIR)/.build-config | $(BUILD_DIR)
|
||||
$(CC) -std=c11 -D_DEFAULT_SOURCE -O2 -w $(MEMWASTE_VENDOR_CFLAGS) -I$(CBM_DIR)/vendored/zstd -c -o $@ $<
|
||||
|
||||
OBJS_VENDORED_PROD = $(MIMALLOC_OBJ_PROD) $(SQLITE3_OBJ_PROD) $(TRE_OBJ_PROD) $(GRAMMAR_OBJS_PROD) $(TS_RUNTIME_OBJ_PROD) $(LSP_OBJ_PROD) $(PP_OBJ_PROD) $(LZ4_OBJ_PROD) $(ZSTD_OBJ_PROD) $(UNIXCODER_OBJ)
|
||||
|
||||
@@ -1061,14 +1301,15 @@ MAIN_SRC = src/main.c
|
||||
# configuration and it is rewritten only when that content changes, so ordinary
|
||||
# incremental builds are unaffected. (Single-quoted: the signature may contain
|
||||
# the escaped quotes of -DCBM_VERSION, which are literal inside '', but must not
|
||||
# contain a literal single quote.)
|
||||
# contain a literal single quote.) The config-flavoured prod objects above
|
||||
# depend on the stamp for the same reason as the binary.
|
||||
# SANITIZE belongs here: it changes the instrumentation compiled into every
|
||||
# object, so a build that only changes it MUST rebuild. Its absence made
|
||||
# `scripts/test.sh SANITIZE= ...` -- the documented way to get a plain build
|
||||
# for trap debugging -- silently re-run the previously instrumented binary,
|
||||
# which during this bisect produced a "crashes without sanitizers too" result
|
||||
# that was pure artifact and sent the investigation down the wrong path.
|
||||
BUILD_CONFIG_SIG := TEST_SEAMS=$(TEST_SEAMS)|CFLAGS_EXTRA=$(CFLAGS_EXTRA)|SANITIZE=$(SANITIZE)
|
||||
BUILD_CONFIG_SIG := TEST_SEAMS=$(TEST_SEAMS)|CFLAGS_EXTRA=$(CFLAGS_EXTRA)|SANITIZE=$(SANITIZE)|MEMWASTE=$(MEMWASTE)|MEMWASTE_ACCESS=$(MEMWASTE_ACCESS)|MEMWASTE_PROFILE=$(MEMWASTE_PROFILE)|TEST_COVERAGE=$(TEST_COVERAGE)
|
||||
.PHONY: build-config-check
|
||||
build-config-check:
|
||||
$(BUILD_DIR)/.build-config: build-config-check | $(BUILD_DIR)
|
||||
@@ -1197,8 +1438,17 @@ lint-no-suppress:
|
||||
lint: lint-tidy lint-cppcheck lint-format lint-no-suppress
|
||||
@echo "=== All linters passed ==="
|
||||
|
||||
# Memory-core linter: memory is allocated through src/foundation/mem_core.h,
|
||||
# not through raw malloc/calloc/realloc/free/strdup. A checked-in baseline
|
||||
# (scripts/memory-core-baseline.txt) records each file's raw-site count and the
|
||||
# gate goes red the moment any file grows. Files only ever go down; lower the
|
||||
# baseline line in the same change that migrates the file.
|
||||
lint-memory-core:
|
||||
@echo "=== memory-core linter ==="
|
||||
@python3 scripts/lint-memory-core.py
|
||||
|
||||
# CI linters (no clang-tidy — platform-dependent, enforced locally via pre-commit)
|
||||
lint-ci: lint-cppcheck lint-format lint-no-suppress
|
||||
lint-ci: lint-cppcheck lint-format lint-no-suppress lint-memory-core
|
||||
@echo "=== CI linters passed ==="
|
||||
|
||||
# ── Local memory-diagnostic lanes (not PR-CI gates by decision: the diag
|
||||
@@ -1258,6 +1508,106 @@ test-lsan:
|
||||
cd $(CURDIR) && ASAN_OPTIONS="detect_leaks=1:halt_on_error=1" \
|
||||
./build/lsan/test-runner $(LSAN_SUITES)
|
||||
|
||||
# ── Per-test coverage build (test-impact map) ───────────────────
|
||||
# The instrumented runner and product binary (TEST_COVERAGE=1 above), in their
|
||||
# own build directory. Homebrew LLVM rather than Apple clang: the raw profile
|
||||
# format is versioned with the compiler, so llvm-profdata and llvm-cov have to
|
||||
# come from the toolchain that built the binary.
|
||||
# LLVM_PROFILE_FILE=/dev/null CBM_TEST_COVERAGE_DIR=<dir> \
|
||||
# build/cov/test-runner [suite...]
|
||||
# leaves one profile set per test (tests/test_main.c). The runner names every
|
||||
# profile itself, but the profile runtime creates its default file before main
|
||||
# runs: started without LLVM_PROFILE_FILE it leaves an empty default.profraw in
|
||||
# the working directory.
|
||||
# No C suite runs the product binary -- they re-exec the runner itself. The
|
||||
# shell process regressions do, and they find the instrumented one the way they
|
||||
# find any other: CBM_TEST_BINARY=build/cov/codebase-memory-mcp, with
|
||||
# LLVM_PROFILE_FILE set by whoever collects the profiles.
|
||||
COV_LLVM_BIN ?= /opt/homebrew/opt/llvm/bin
|
||||
COV_BUILD_DIR ?= build/cov
|
||||
COV_MAKE = $(MAKE) -f Makefile.cbm CC=$(COV_LLVM_BIN)/clang CXX=$(COV_LLVM_BIN)/clang++ \
|
||||
BUILD_DIR=$(COV_BUILD_DIR) SANITIZE= TEST_COVERAGE=1
|
||||
test-runner-cov:
|
||||
$(COV_MAKE) $(COV_BUILD_DIR)/test-runner
|
||||
cbm-cov:
|
||||
$(COV_MAKE) $(COV_BUILD_DIR)/codebase-memory-mcp TEST_SEAMS=1
|
||||
|
||||
# Genuine LLVM write failures must fail the parent coverage run.
|
||||
test-coverage-flush: test-runner-cov
|
||||
@evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-flush.XXXXXX") && \
|
||||
printf 'Coverage flush evidence: %s/proof\n' "$$evidence_dir" && \
|
||||
python3 tests/test_coverage_flush.py \
|
||||
--runner "$(COV_BUILD_DIR)/test-runner" \
|
||||
--llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \
|
||||
--output-dir "$$evidence_dir/proof"
|
||||
|
||||
.PHONY: test-coverage-children
|
||||
# Genuine child profile/marker and setup-reducer controls; evidence is retained.
|
||||
test-coverage-children: test-runner-cov
|
||||
@evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-children.XXXXXX") && \
|
||||
printf 'Coverage child evidence: %s/proof\n' "$$evidence_dir" && \
|
||||
python3 tests/test_coverage_children.py \
|
||||
--runner "$(COV_BUILD_DIR)/test-runner" \
|
||||
--llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \
|
||||
--reducer scripts/test-impact/coverage-map.py \
|
||||
--output-dir "$$evidence_dir/proof"
|
||||
|
||||
.PHONY: test-coverage-parent-failure
|
||||
# Positive default plus genuine owned EMFILE; all evidence is retained.
|
||||
test-coverage-parent-failure: test-runner-cov
|
||||
@evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-parent-failure.XXXXXX") && \
|
||||
printf 'Coverage parent failure evidence: %s/proof\n' "$$evidence_dir" && \
|
||||
python3 tests/test_coverage_parent_failure.py \
|
||||
--runner "$(COV_BUILD_DIR)/test-runner" \
|
||||
--llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \
|
||||
--reducer scripts/test-impact/coverage-map.py \
|
||||
--output-dir "$$evidence_dir/proof"
|
||||
|
||||
.PHONY: test-coverage-terminal
|
||||
# A readable stale terminal profile cannot hide a failed parent write.
|
||||
test-coverage-terminal: test-runner-cov
|
||||
@evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-terminal.XXXXXX") && \
|
||||
printf 'Coverage terminal evidence: %s/proof\n' "$$evidence_dir" && \
|
||||
python3 tests/test_coverage_terminal.py \
|
||||
--runner "$(COV_BUILD_DIR)/test-runner" \
|
||||
--llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \
|
||||
--helper tests/test_coverage_children.py \
|
||||
--reducer scripts/test-impact/coverage-map.py \
|
||||
--output-dir "$$evidence_dir/proof"
|
||||
|
||||
.PHONY: test-coverage-live-v2
|
||||
# Native full-identity transport remains explicitly unadmitted; evidence is retained.
|
||||
test-coverage-live-v2: test-runner-cov
|
||||
@evidence_dir=$$(mktemp -d "$(COV_BUILD_DIR)/coverage-live-v2.XXXXXX") && \
|
||||
printf 'Coverage live-v2 evidence: %s/proof\n' "$$evidence_dir" && \
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 tests/test_coverage_live_v2.py \
|
||||
--collector scripts/test-impact/coverage-map.py \
|
||||
--runner "$(COV_BUILD_DIR)/test-runner" \
|
||||
--llvm-profdata "$(COV_LLVM_BIN)/llvm-profdata" \
|
||||
--output "$$evidence_dir/proof" $(if $(COV_LIVE_V2_CASE),--case "$(COV_LIVE_V2_CASE)")
|
||||
|
||||
# ── Coverage-guided fuzzing (libFuzzer) ─────────────────────────
|
||||
# One binary, three targets (tests/fuzz/cbm_fuzz.c): the per-file extractor,
|
||||
# the Cypher parser, the TOML/YAML/JSON config editors. ASan+UBSan as in the
|
||||
# test lane, plus fuzzer-no-link on EVERY object -- the vendored grammars and
|
||||
# the tree-sitter runtime feed coverage back, which is where the parser state
|
||||
# space lives. Needs a clang that ships libFuzzer (Homebrew LLVM on macOS; Apple
|
||||
# clang does not). Run through scripts/fuzz.sh.
|
||||
FUZZ_CC ?= clang
|
||||
FUZZ_CXX ?= clang++
|
||||
FUZZ_SANITIZE = -fsanitize=address,undefined,fuzzer-no-link -fno-omit-frame-pointer
|
||||
FUZZ_SRC = tests/fuzz/cbm_fuzz.c
|
||||
fuzz:
|
||||
$(MAKE) -f Makefile.cbm build/fuzz/cbm-fuzz BUILD_DIR=build/fuzz \
|
||||
CC=$(FUZZ_CC) CXX=$(FUZZ_CXX) SANITIZE="$(FUZZ_SANITIZE)"
|
||||
|
||||
$(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 $@ \
|
||||
$(FUZZ_SRC) $(PROD_SRCS) \
|
||||
$(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \
|
||||
$(OBJS_VENDORED_TEST) \
|
||||
$(LDFLAGS_TEST)
|
||||
|
||||
# Path-sensitive memory analysis: leak paths, null derefs, uninitialized reads.
|
||||
# Non-gating: triage findings against the code (this lane's first run produced
|
||||
# 9 real fixes and 6 recorded false positives — see the analyzer batch commit).
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
# codebase-memory-mcp: local MCP server for code search, lower token usage and visualization
|
||||
# codebase-memory-mcp
|
||||
|
||||
[](https://github.com/DeusData/codebase-memory-mcp/releases/latest)
|
||||
[](LICENSE)
|
||||
[](https://github.com/DeusData/codebase-memory-mcp/actions/workflows/dry-run.yml)
|
||||
[](#tests)
|
||||
[](#tests)
|
||||
[](#tests)
|
||||
[](#mcp-tools)
|
||||
[](https://github.com/DeusData/codebase-memory-mcp/actions/workflows/dry-run.yml)
|
||||
[](https://github.com/DeusData/codebase-memory-mcp)
|
||||
[](https://github.com/DeusData/codebase-memory-mcp)
|
||||
[](#hybrid-lsp)
|
||||
[](#hybrid-lsp)
|
||||
[](https://github.com/DeusData/codebase-memory-mcp)
|
||||
[](https://github.com/DeusData/codebase-memory-mcp)
|
||||
[](https://github.com/DeusData/codebase-memory-mcp/releases/latest)
|
||||
@@ -17,70 +14,42 @@
|
||||
[](https://github.com/DeusData/codebase-memory-mcp/releases/latest)
|
||||
[](https://arxiv.org/abs/2603.27277)
|
||||
|
||||
**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**.
|
||||
**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.
|
||||
|
||||
**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).
|
||||
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.
|
||||
|
||||
**CBM Atlas 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.
|
||||
> **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.
|
||||
|
||||
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.
|
||||
|
||||
> **Atlas development preview:** This README describes `feat/codeatlas-web`, the branch behind [PR #2068](https://github.com/DeusData/codebase-memory-mcp/pull/2068), on top of [PR #1860](https://github.com/DeusData/codebase-memory-mcp/pull/1860). Atlas is not included in the current v0.11.0 release or `main`. Use the [branch build instructions](#build-from-source) to run the interface shown below. The release installer still provides the released graph UI.
|
||||
|
||||
[Quick start](#quick-start) · [Explore Atlas](#cbm-atlas-code-visualization-and-source-navigation) · [Chat with your codebase locally](#chat-with-your-codebase-locally) · [MCP tools](#mcp-tools) · [Build this branch](#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 this Atlas frontend preview.
|
||||
|
||||
> **Local processing:** Source analysis and graph storage run on your machine. Atlas 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, selects `src/graph_buffer`, opens the area, and reads `graph_buffer.c` from its source panel. Height can show source size; hotspot gravity reflects static fan-in. [Still image](docs/images/atlas-architecture.png).
|
||||
> **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.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/atlas-architecture.webp" alt="CBM Atlas architecture walkthrough: zoom into source folders, select an area, inspect a file, and open its source" width="1000">
|
||||
<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>
|
||||
</p>
|
||||
|
||||
**Galaxy.** Explore the code knowledge graph in 3D. Search for a symbol, follow its neighbors, and return to the source reader. Edge pulses show relationship direction; they do not represent live execution. [Still image](docs/images/atlas-galaxy.png).
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/atlas-galaxy.webp" alt="Interactive code knowledge graph in CBM Atlas Galaxy, showing symbol connections and camera navigation" width="1000">
|
||||
</p>
|
||||
|
||||
**Explore.** Browse files in an IDE-style workspace, read syntax-highlighted source in Monaco, and keep the code graph alongside it. Select code to inspect its callers and dependencies or [ask the local chat about it](#chat-with-your-codebase-locally).
|
||||
|
||||
<p align="center"><img src="docs/images/atlas-explore.png" alt="CBM Atlas Explore: file tree, TypeScript source in the Monaco reader, and a code graph side by side" 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/atlas-system-structure.png" alt="CBM Atlas system structure visualization with code components and directed dependencies" 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/atlas-behavior.png" alt="CBM Atlas Behavior view showing possible calls from SpatialArchitecture and the indexed source behind them" width="1000"></p>
|
||||
|
||||
</details>
|
||||
|
||||
These captures use public source from this repository at `4ff6feea`. See [capture details](docs/images/README.md) for the indexed scope and recording setup.
|
||||
|
||||
## Why codebase-memory-mcp
|
||||
|
||||
- **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 with embedded UI and integration templates 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. 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 Atlas**: 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, 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.
|
||||
- **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.
|
||||
- **CBM Atlas** — the built-in human interface at `localhost:9749`: a reading IDE over the index (explorer and read-only Monaco reader, a semantic twin that follows the caret, flow, bug-hunt and change-scope reading modes, the 3D galaxy, source-cited answers, project management), 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.
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Install the latest release** (macOS / Linux). For the Atlas preview shown above, [build this branch](#build-from-source).
|
||||
**One-line install** (macOS / Linux):
|
||||
```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.
|
||||
With graph visualization UI:
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash
|
||||
```
|
||||
|
||||
**Windows** (PowerShell):
|
||||
```powershell
|
||||
@@ -103,14 +72,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>
|
||||
@@ -135,14 +104,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 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.
|
||||
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.
|
||||
|
||||
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`):
|
||||
|
||||
@@ -158,85 +127,34 @@ 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 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.
|
||||
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.
|
||||
|
||||
The `cli` frontend runs one tool and exits. On this branch, it dispatches the tool through the coordination daemon, starting a session-managed daemon when needed. A permanent daemon can stay warm between commands. Indexing is supervised by the daemon. See [CLI Mode](#cli-mode) for examples.
|
||||
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.
|
||||
|
||||
### CBM Atlas: code visualization and source navigation
|
||||
### CBM Lens Beta — the human interface
|
||||
|
||||
Atlas is the browser frontend on this branch, built from [CodeAtlasWeb](graph-ui/README.md) by [Bernhard Jackiewicz](https://github.com/BernhardJackiewicz). Build with `--with-ui`, then start the local server:
|
||||
The MCP tools are the agent's interface to the code graph; **CBM Lens Beta** is the human's. It is built into the binary — every install on every channel has it:
|
||||
|
||||
```bash
|
||||
./build/c/codebase-memory-mcp daemon start --open --port=9749
|
||||
codebase-memory-mcp --ui=true --port=9749
|
||||
```
|
||||
|
||||
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 `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.
|
||||
|
||||
**Open your first repository**
|
||||
CBM Lens builds on **CodeAtlasWeb**, a reading IDE built by [Bernhard Jackiewicz](https://github.com/BernhardJackiewicz) on the CBM read surface (design and tracking in #1964). It runs air-gapped on loopback, reaches no cloud, and every panel names where its facts come from; what the index did not record is shown as a gap, never filled in with a guess.
|
||||
|
||||
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.
|
||||
When something in CBM Lens does not load, the page has already written it down: its console output, uncaught errors and failed requests go to `${CBM_CACHE_DIR}/logs/ui.log` (default `~/.cache/codebase-memory-mcp/logs/ui.log`, one JSON line per entry, rotated once at 5 MiB). The projects panel (alt+p) shows the tail under "This server", `GET http://localhost:9749/api/ui-log?lines=200` returns it, and a bug report can attach the file.
|
||||
|
||||
To bookmark a view, use `?project=<name>&workspace=architecture`. Other workspace values are `explore`, `galaxy`, `adr`, `coverage`, and `system`.
|
||||
- **Explorer and reader** — the indexed file tree and a read-only Monaco reader whose source comes from the index.
|
||||
- **Semantic twin** — follows the caret: what the symbol under it holds, calls, raises and touches, as facts or as pseudocode, pitched at who is reading.
|
||||
- **Reading modes** — `[w]hy am I here` picks a way in (hunt a bug, scope a change, understand the project, pick an entry point); **flow** walks the calls in the order they run; **bug hunt** compares the expected path into a symbol with what `ingest_traces` observed; **change scope** shows what a change reaches and what covers it.
|
||||
- **Galaxy** — the persistent 3D graph with focus following in both directions, and a hierarchy view of the entry subgraph.
|
||||
- **Command line** — search by meaning while you type; a line ending in `?` builds a source-cited answer from indexed fact cards, with an optional local-model wording pass (a llama-server sidecar you start yourself; off by default, and no model or binary ships).
|
||||
- **Live agents** — an opt-in overlay of AI agents working in the same repository, fed by a loopback bridge over tool-hook events (`graph-ui/agents/`).
|
||||
- **`[p]rojects`** — index a repository, check or remove an index, edit the project's decision record, and see the server's processes and log.
|
||||
- **`[?]help`** — what it reads, what it cannot do, and every key it listens to, derived from the wiring itself.
|
||||
|
||||
| Workspace | What you can do |
|
||||
|-----------|-----------------|
|
||||
| **Explore** | Navigate the indexed file tree and read source in Monaco. The semantic twin follows the caret and shows indexed facts about the current symbol. |
|
||||
| **Galaxy** | Search and focus graph nodes, inspect neighbors, switch to hierarchy, and open source. 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** beside the source reader to ask about the code you are reading. For example: "What does this function return?", "Explain how this file handles errors", or "What should I check before changing this selection?" The model uses the current file, or the exact text you have selected. You can also attach graph evidence through **Add context**.
|
||||
|
||||
1. Open a file in **Explore**, then click **Chat**.
|
||||
2. Choose a local model. Setup shows its download size and license. The default Qwen2.5 Coder 0.5B model downloads about 567 MB and requires 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.
|
||||
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.
|
||||
|
||||
The screenshot shows a real local-model answer about `hotspotIdentity` in this repository, with the source open beside the conversation.
|
||||
|
||||
<p align="center"><img src="docs/images/atlas-chat.png" alt="Chat with your codebase locally in CBM Atlas: Qwen model answering a question about the open TypeScript file beside the source reader" width="1000"></p>
|
||||
|
||||
The conversation stays in the current browser session. Each new message uses the source currently open in the reader; earlier source snapshots are not silently accumulated. If the prompt exceeds the model's context limit, Atlas asks you to reduce it instead of truncating the code. This chat explains the context you give it; it cannot edit files or run tools. Check its answers against the source. Browser AI is still part of this development preview: the capture check succeeded with a short selection, while a whole-file attempt failed with a browser-model memory error.
|
||||
|
||||
#### Search and graph answers
|
||||
|
||||
Open Search with `Cmd+K` or `Ctrl+K` to find symbols, search by meaning, or ask a question such as `Who calls @myFunction?`, using a symbol from your project. Atlas builds an answer from indexed facts and provides source links. You can inspect the evidence cards and adjust the neighbor depth. These graph answers work without a model; their optional local-model wording pass is separate from the browser chat above.
|
||||
|
||||
**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.
|
||||
Open a project with `?project=<name>` in the address, or pick it in the projects panel. The frontend's own README, plan, honesty rules and recorded proof runs live under `graph-ui/`.
|
||||
|
||||
### Auto-Index
|
||||
|
||||
@@ -248,9 +166,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
|
||||
|
||||
@@ -268,7 +190,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.
|
||||
|
||||
@@ -280,23 +202,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 a compact summary by default. Request specific `aspects` for routes, hotspots, dependencies, clusters, or cycles. `system_structure` provides the projection used by Atlas and must be requested on its own.
|
||||
- **Architecture overview**: `get_architecture` returns languages, packages, entry points, routes, hotspots, boundaries, layers, and clusters in a single call
|
||||
- **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 candidates**: find functions with no indexed callers, excluding entry points. Check dynamic dispatch, coverage gaps, and framework conventions before removing code.
|
||||
- **Dead code detection**: Finds functions with zero callers, excluding entry points
|
||||
- **Cypher-like queries**: `MATCH (f:Function)-[:CALLS]->(g) WHERE f.name = 'main' RETURN g.name`
|
||||
|
||||
### Code search
|
||||
- **Semantic code search** (`search_graph` with `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).
|
||||
### Search
|
||||
- **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
|
||||
@@ -308,67 +237,62 @@ The install script placed beside the binary is **reported, not deleted**: uninst
|
||||
|
||||
### 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)
|
||||
- `SPAWNS` — the caller starts another program (`subprocess.run`, `exec.Command`, `posix_spawn`, `Process.Start`, ...); the target is a `Process` node named after the program, or `<dynamic>` when the program is not a literal
|
||||
- `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
|
||||
- **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, 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)
|
||||
- **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)
|
||||
- **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.*"`
|
||||
- **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`
|
||||
|
||||
### 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
|
||||
|
||||
Share a compressed graph snapshot so teammates can reuse an indexed baseline.
|
||||
Commit a single compressed file to your repo and your teammates skip the reindex.
|
||||
|
||||
`.codebase-memory/graph.db.zst` is a zstd-compressed snapshot of the knowledge graph that lives next to your source. Set `persistence=true` on `index_repository` to create or refresh it. This is off by default; local indexes persist regardless. Existing shared artifacts can still be imported and maintained. When a teammate indexes a clone with an existing artifact, CBM imports that baseline and applies their local changes incrementally.
|
||||
`.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; 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.
|
||||
|
||||
- **Format**: SQLite database, indexes stripped, `VACUUM INTO` compacted, then zstd 1.5.7 compressed (8 to 13:1 ratio typical)
|
||||
- **Format**: SQLite database, indexes stripped, `VACUUM INTO` compacted, then zstd 1.5.7 compressed (8–13:1 ratio typical)
|
||||
- **Two tiers**:
|
||||
- **Best** (`zstd -9` + index strip + `VACUUM INTO`); written on explicit `index_repository` with `persistence=true`
|
||||
- **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, 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:
|
||||
- **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 — 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:
|
||||
```gitattributes
|
||||
.codebase-memory/graph.db.zst filter=lfs diff=lfs merge=lfs -text
|
||||
```
|
||||
Track only the `.zst`; `artifact.json` is small and carries the schema version. The attribute applies to future commits only. Removing existing blobs requires a separate history migration. Two costs to weigh before adopting it: GitHub meters LFS storage and bandwidth, and its objects cannot be pruned without contacting support; and every teammate needs `git lfs install`; without it their checkout leaves a pointer file where the artifact should be, the integrity-checked import refuses it, and they fall back to a full reindex.
|
||||
- **Optional**: shared export is opt-in, and Git commits remain your choice. Add `.codebase-memory/` to `.gitignore` if you prefer everyone to reindex from scratch.
|
||||
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.
|
||||
|
||||
The artifact provides a shared baseline while each developer keeps a local query database.
|
||||
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.
|
||||
|
||||
## How It Works
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
```
|
||||
You: "what calls ProcessOrder?"
|
||||
@@ -380,11 +304,11 @@ codebase-memory-mcp: executes graph query, returns structured results
|
||||
Agent: presents the call chain in plain English
|
||||
```
|
||||
|
||||
With MCP, your coding agent translates a question into tool calls. Atlas can also use an optional browser model for explanations; this does not change how the backend indexes or queries code.
|
||||
**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.
|
||||
|
||||
## Performance
|
||||
|
||||
Published backend benchmarks on Apple M3 Pro. These are separate from the Atlas UI checks:
|
||||
Benchmarked on Apple M3 Pro:
|
||||
|
||||
| Operation | Time | Notes |
|
||||
|-----------|------|-------|
|
||||
@@ -398,28 +322,28 @@ Published backend benchmarks on Apple M3 Pro. These are separate from the Atlas
|
||||
|
||||
**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**: 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.
|
||||
**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**.
|
||||
|
||||
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
|
||||
|
||||
@@ -433,7 +357,7 @@ When you open a memory/performance issue, **attach the `.ndjson` trajectory**: i
|
||||
| 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`.
|
||||
|
||||
@@ -498,7 +422,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
|
||||
|
||||
@@ -509,47 +433,28 @@ You: "Install this MCP server: https://github.com/DeusData/codebase-memory-mcp"
|
||||
### Build from Source
|
||||
|
||||
<details>
|
||||
<summary>Prerequisites: C/C++ compiler, zlib, and Node.js for Atlas</summary>
|
||||
<summary>Prerequisites: C compiler + zlib</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** | — | 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 --branch feat/codeatlas-web --single-branch https://github.com/DeusData/codebase-memory-mcp.git
|
||||
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 # without the UI (development only)
|
||||
# Binary at: build/c/codebase-memory-mcp (codebase-memory-mcp.exe on Windows)
|
||||
```
|
||||
|
||||
The `--with-ui` build embeds Atlas 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.
|
||||
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.
|
||||
|
||||
#### Tests
|
||||
|
||||
The Atlas branch at `4ff6feea` contains **10,804 C and UI tests**, plus package-wrapper tests and additional checks. The badges show test counts for this branch, not an all-passing result. Native and Go counts below use the macOS configuration; platform-specific registrations differ.
|
||||
|
||||
| Suite | Count | README review result |
|
||||
|---|---:|---|
|
||||
| Native C | 7,982 distinct cases across 142 suites | Counted from the configured test sources and suite registry; full suite not rerun |
|
||||
| UI unit tests (Vitest) | 2,822 tests in 203 files | 2,821 passed, 1 failed |
|
||||
| Go package wrapper | 26 top-level tests | 25 passed, 1 Windows-only test skipped |
|
||||
| npm package wrapper | 10 tests | All passed |
|
||||
| Python package wrapper | 28 tests | All passed |
|
||||
| Frontend scaffold checks | 218 test declarations in 39 files | Source and stored-evidence checks; not rerun |
|
||||
| Shell guards | 32 scripts referenced by `scripts/test.sh` | Language-count check passed; remaining guards not rerun |
|
||||
|
||||
The C count follows [`ALL_TEST_SRCS`](Makefile.cbm) and the [suite registry](tests/test_main.c), with macros expanded for the local build. It counts each test function once: 7,996 registrations include 14 duplicates. Separate bug-reproduction runners and repeated sanitizer runs are not added to that count. The frontend scaffold checks include historical capture assertions, so their count does not imply a fresh browser test run.
|
||||
|
||||
The UI failure is in `src/i18n/messages.test.ts`: three existing interface strings need translation keys. The package-wrapper badge totals the 26 Go, 10 npm, and 28 Python tests above.
|
||||
|
||||
Run the C test suite:
|
||||
Run the test suite (8,060 tests across 141 suites):
|
||||
|
||||
```bash
|
||||
scripts/test.sh # full: clean sanitizer build + all suites + guards
|
||||
@@ -557,21 +462,7 @@ scripts/test.sh --suites <name> # one suite, incremental, seconds
|
||||
build/c/test-runner --list-suites # what is available
|
||||
```
|
||||
|
||||
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:
|
||||
`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:
|
||||
|
||||
```bash
|
||||
scripts/ci/smoke-artifact.sh <linux|darwin|windows> <amd64|arm64>
|
||||
@@ -599,7 +490,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>
|
||||
|
||||
@@ -614,9 +505,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 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.
|
||||
- **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.
|
||||
|
||||
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
|
||||
@@ -629,12 +520,12 @@ overwrite user-modified agents.
|
||||
| Agent | Activation | MCP config | Durable context / augmentation |
|
||||
|-------|------------|------------|--------------------------------|
|
||||
| Claude Code | Detected | `~/.claude.json` | Skill + three exact-tool graph agents; `SessionStart`, `SubagentStart`, non-blocking `PreToolUse` for `Grep`/`Glob`/`Bash`, and post-`Read` coverage |
|
||||
| Codex CLI | Detected | `$CODEX_HOME/config.toml` | `AGENTS.md`, skill, three read-only agents; `SessionStart` + `SubagentStart` |
|
||||
| Codex CLI | Detected | `$CODEX_HOME/config.toml` | Managed `AGENTS.md` activation pointer, skill, three read-only agents; `SessionStart` + `SubagentStart` |
|
||||
| Gemini CLI | Detected | `.gemini/settings.json` | `GEMINI.md`, three explicit read/graph-tool subagents; `BeforeTool`, `AfterTool` `read_file` coverage, and `SessionStart` |
|
||||
| Zed | Detected | platform `settings.json` (JSONC) | `AGENTS.md` + shared skill |
|
||||
| OpenCode | Detected | `$OPENCODE_CONFIG` or resolved global config | `AGENTS.md`, skill, three deny-by-default read-only agents; 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 | — | `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 |
|
||||
@@ -669,11 +560,16 @@ 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 | — | `~/.pi/agent/AGENTS.md` + skill; MCP/subagents require an explicit reviewed extension |
|
||||
| IBM Bob IDE | Conditional | Existing `~/.bob/mcp.json` | Shared rule + IDE skill; no invented hook or agent |
|
||||
| Oh My Pi (omp) | Detected | Effective agent directory (`OMP_PROFILE` / `PI_CODING_AGENT_DIR`; default `~/.omp/agent/mcp.json`) | Skill and three direct-MCP graph-tool subagents (Scout/Verify/Auditor); preserves user `AGENTS.md` |
|
||||
| Sourcegraph Cody | Explicit opt-in | Existing `$CBM_CODY_CONFIG_PATH` | MCP only |
|
||||
|
||||
For Codex, install keeps only a tiny managed activation pointer in global
|
||||
`$CODEX_HOME/AGENTS.md`; all detailed behavior lives in the installed `codebase-memory` skill.
|
||||
Fresh installs create the pointer, upgrades replace the legacy full managed block while preserving
|
||||
all user-owned bytes, and uninstall removes only the managed pointer.
|
||||
|
||||
### Sessions, compaction, and subagents
|
||||
|
||||
Hooks installed by this project are fail-open and context-only. Claude Code's
|
||||
@@ -763,7 +659,7 @@ no longer a suitable automatic global target.
|
||||
|
||||
## CLI Mode
|
||||
|
||||
Every MCP tool can be invoked as a one-shot command. This branch routes tool execution through the local coordination daemon. The CLI joins it for the request and exits afterward; the daemon supervises any indexing worker. Use `daemon start` to keep a daemon warm between commands, or let a session-managed daemon start and stop as needed.
|
||||
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.
|
||||
|
||||
@@ -794,7 +690,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
|
||||
|
||||
@@ -802,30 +698,57 @@ JSON arguments can also be piped on stdin, for tools that take arguments. A tool
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `index_repository` | Index a repository into the graph. Auto-sync keeps it fresh after that. |
|
||||
| `list_projects` | List indexed projects. Add `detail="stats"` for node, edge, and database-size counts. |
|
||||
| `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 readiness, counts, and coverage diagnostics for a project. |
|
||||
| `check_index_coverage` | Check exact paths or scopes for coverage and freshness, with paged diagnostics. |
|
||||
| `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` | Trace callers, callees, data flow, or cross-service paths (alias: `trace_call_path`). Depth 1 to 15; tests are excluded by default. |
|
||||
| `trace_path` | BFS traversal — who calls a function and what it calls (alias: `trace_call_path`). Depth 1-5. |
|
||||
| `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. |
|
||||
| `get_code_snippet` | Read source for a symbol; large containers can return a paged outline. |
|
||||
| `get_file_outline` | Read the symbols and structure of an indexed file. |
|
||||
| `compare_graphs` | Compare two indexed project snapshots. Use `detect_changes` for a Git diff within one project. |
|
||||
| `compare_graphs` | Compare two indexed snapshots: node/edge additions and removals between a base and a target. |
|
||||
| `get_code_snippet` | Read source code for a function by qualified name. |
|
||||
| `get_file_outline` | Declaration outline of one repository-relative file in source order, with optional label filter and paging. |
|
||||
| `get_architecture` | Codebase overview: languages, packages, routes, hotspots, clusters, ADR. |
|
||||
| `search_code` | Grep-like text search within indexed project files. |
|
||||
| `manage_adr` | CRUD for Architecture Decision Records (`get` reads, `update` replaces the whole document, `set_sections` rewrites only the named sections and leaves every other byte untouched, `sections` lists headings). Query modes do not wait behind a same-project reindex; writes remain serialized. |
|
||||
| `ingest_traces` | Store observed call paths or caller/callee pairs under a run label. Both endpoints must resolve to indexed symbols; unresolved names are reported. |
|
||||
| `ingest_traces` | Ingest runtime traces to validate HTTP_CALLS edges. |
|
||||
|
||||
`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.
|
||||
|
||||
@@ -837,7 +760,9 @@ JSON arguments can also be piped on stdin, for tools that take arguments. A tool
|
||||
|
||||
### Edge Types
|
||||
|
||||
`CONTAINS_PACKAGE`, `CONTAINS_FOLDER`, `CONTAINS_FILE`, `DEFINES`, `DEFINES_METHOD`, `IMPORTS`, `CALLS`, `CALL_REFERENCE`, `HTTP_CALLS`, `ASYNC_CALLS`, `IMPLEMENTS`, `HANDLES`, `USAGE`, `CONFIGURES`, `WRITES`, `MEMBER_OF`, `TESTS`, `USES_TYPE`, `FILE_CHANGES_WITH`
|
||||
`CONTAINS_PACKAGE`, `CONTAINS_FOLDER`, `CONTAINS_FILE`, `DEFINES`, `DEFINES_METHOD`, `IMPORTS`, `CALLS`, `CALL_REFERENCE`, `HTTP_CALLS`, `ASYNC_CALLS`, `SPAWNS`, `IMPLEMENTS`, `HANDLES`, `USAGE`, `CONFIGURES`, `REFERENCES_FILE`, `MENTIONS`, `WRITES`, `MEMBER_OF`, `TESTS`, `USES_TYPE`, `FILE_CHANGES_WITH`
|
||||
|
||||
`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
|
||||
|
||||
@@ -849,7 +774,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`.
|
||||
|
||||
@@ -869,23 +794,30 @@ codebase-memory-mcp config set auto_index true # auto-index on session
|
||||
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 watcher_enabled false # stop the watcher thread entirely (default: true)
|
||||
codebase-memory-mcp config set watch_non_git true # also poll non-git project roots (default: false)
|
||||
codebase-memory-mcp config set index_max_files 250000 # optional per-index source-file limit
|
||||
codebase-memory-mcp config set index_max_source_mb 16384 # optional per-index source-size limit
|
||||
codebase-memory-mcp config reset auto_index # reset to default
|
||||
```
|
||||
|
||||
The two `index_max_*` settings default to `off`. Exceeding one fails the complete
|
||||
index attempt rather than publishing a partial graph; an existing serving index
|
||||
is preserved. See [Index resource limits](docs/INDEX_RESOURCE_LIMITS.md).
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CBM_ALLOWED_ROOT` | *(unset)* | Confine `index_repository` to paths within this directory. When set, a `repo_path` that resolves (after symlink / `..` resolution) outside this root is refused, and the same check now applies to the graph UI's `POST /api/index` route rather than only to the MCP tool. Unset imposes no *containment* restriction; but see the always-on limits below, which apply whether or not this is set. Useful when the server may be driven by an untrusted caller, e.g. agentic or multi-tenant deployments. |
|
||||
| `CBM_ALLOWED_ROOT` | *(unset)* | Confine `index_repository` to paths within this directory. When set, a `repo_path` that resolves (after symlink / `..` resolution) outside this root is refused, and the same check now applies to the graph UI's `POST /api/index` route rather than only to the MCP tool. Unset imposes no *containment* restriction — but see the always-on limits below, which apply whether or not this is set. Useful when the server may be driven by an untrusted caller, e.g. agentic or multi-tenant deployments. |
|
||||
| `CBM_CACHE_DIR` | `~/.cache/codebase-memory-mcp` | Override the database storage directory. All project indexes and config are stored here. One account can use only one canonical cache root at a time; close active CBM sessions/commands before switching it. |
|
||||
| `CBM_DIAGNOSTICS` | `false` | Set to `1` or `true` to enable the shared daemon's periodic `snapshot.json` and retained `trajectory.ndjson` below a fresh owner-private directory in the system temp directory. Exact paths are logged by `diagnostics.start`. |
|
||||
| `CBM_DOWNLOAD_URL` | *(GitHub releases)* | Override the download URL for updates. Used for testing or self-hosted deployments. |
|
||||
| `CBM_LOG_LEVEL` | 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_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_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 to 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–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 CLI tool calls use the same daemon. A permanent daemon must be stopped explicitly before restarting it with changed environment settings.
|
||||
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, 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.
|
||||
|
||||
```bash
|
||||
# Store indexes in a custom directory
|
||||
@@ -926,6 +858,7 @@ 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"` |
|
||||
@@ -935,7 +868,7 @@ SQLite databases stored at `~/.cache/codebase-memory-mcp/`. Persists across rest
|
||||
|
||||
**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.
|
||||
|
||||
@@ -956,14 +889,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, with coverage varying by language and construct.
|
||||
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.
|
||||
|
||||
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, 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):
|
||||
162 languages, all parsed via vendored tree-sitter grammars compiled into the binary. Benchmarked against 64 real open-source repositories (78 to 49K nodes):
|
||||
|
||||
| Tier | Score | Languages |
|
||||
|------|-------|-----------|
|
||||
@@ -971,7 +904,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, 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.
|
||||
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.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -987,9 +920,8 @@ src/
|
||||
discover/ File discovery (.gitignore, .cbmignore, symlink handling)
|
||||
watcher/ Background auto-sync (git polling, adaptive intervals)
|
||||
traces/ Runtime trace ingestion
|
||||
ui/ Local HTTP server + embedded Atlas frontend
|
||||
ui/ Local HTTP server + verified external 3D-UI asset pack
|
||||
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
|
||||
```
|
||||
|
||||
@@ -997,29 +929,24 @@ 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**: the native backend uses libraries vendored at compile time, and the frontend is embedded by the UI build
|
||||
- **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 release scans
|
||||
### v0.7.0 VirusTotal scans
|
||||
|
||||
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.
|
||||
| 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) |
|
||||
|
||||
| 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).
|
||||
Scan links for every release are also included in the GitHub Release notes automatically.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+115
-5
@@ -60,15 +60,22 @@ 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
|
||||
- Nine further grammars (`arkts`, `assembly`, `cfml`,
|
||||
`cfscript`, `dotenv`, `javascript`, `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/`.
|
||||
|
||||
### tree-sitter-plsql
|
||||
|
||||
@@ -131,6 +138,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
|
||||
|
||||
+60
-2
@@ -88,7 +88,16 @@ 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. |
|
||||
| `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
|
||||
@@ -116,6 +125,14 @@ Current keys:
|
||||
> `auto_index` still runs, and `index_repository` stays available for manual
|
||||
> reindexing.
|
||||
|
||||
The two `index_max_*` settings are independent and disabled by default. They
|
||||
apply to explicit indexing, automatic indexing, and watcher re-indexing, but not
|
||||
to `cross-repo-intelligence`, which does not scan repository source files.
|
||||
Equality is allowed; exceeding either setting fails the complete index request
|
||||
and preserves any previously serving database. See
|
||||
[Index resource limits](INDEX_RESOURCE_LIMITS.md) for counting, validation, and
|
||||
error-response details.
|
||||
|
||||
## 3. UI Settings
|
||||
|
||||
The optional built-in graph UI stores its settings in:
|
||||
@@ -190,7 +207,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
|
||||
@@ -211,6 +242,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Index resource limits
|
||||
|
||||
Index resource limits are optional operator controls for repositories whose
|
||||
discovery breadth is not known in advance. They are disabled by default so
|
||||
existing large-repository workloads retain their current behavior.
|
||||
|
||||
## Discovery settings
|
||||
|
||||
| Key | Default | Accepted value | Protects |
|
||||
|---|---:|---:|---|
|
||||
| `index_max_files` | `off` | `off` or `1..10000000` | Accepted source-file count |
|
||||
| `index_max_source_mb` | `off` | `off` or `1..1048576` | Accepted source-file bytes |
|
||||
|
||||
Set or reset them with the normal configuration command:
|
||||
|
||||
```bash
|
||||
codebase-memory-mcp config set index_max_files 250000
|
||||
codebase-memory-mcp config set index_max_source_mb 16384
|
||||
codebase-memory-mcp config reset index_max_files
|
||||
```
|
||||
|
||||
Values use base-10 integers. MiB means 1,048,576 bytes. Empty values, zero,
|
||||
negative values, suffixes, trailing characters, and values outside the stated
|
||||
ranges are rejected without changing the stored value.
|
||||
|
||||
## Counting and failure semantics
|
||||
|
||||
`index_max_files` counts a file only after it passes directory pruning, ignore
|
||||
rules, filename and suffix filters, language detection, and the existing
|
||||
per-file size rule. `index_max_source_mb` sums the filesystem sizes of that same
|
||||
accepted set.
|
||||
|
||||
Equality is allowed. The first file or byte that makes an observed value greater
|
||||
than its limit stops discovery. CBM discards the partial file list and does not
|
||||
publish a partial graph as a complete index.
|
||||
|
||||
For an explicit MCP request the error payload contains:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"code": "resource_limit_exceeded",
|
||||
"stage": "discovery",
|
||||
"resource": "files",
|
||||
"observed": 250001,
|
||||
"limit": 250000,
|
||||
"unit": "files",
|
||||
"retryable": true,
|
||||
"serving_index_preserved": true,
|
||||
"message": "Index discovery exceeded index_max_files"
|
||||
}
|
||||
```
|
||||
|
||||
The previous database remains available because publication occurs only after a
|
||||
complete discovery and successful staged build. If no previous database exists,
|
||||
`serving_index_preserved` is false.
|
||||
|
||||
## Trust and compatibility
|
||||
|
||||
Limits are read from the CLI-managed `_config.db`; they are not MCP request
|
||||
arguments. A supervised parent replaces any caller-supplied internal policy
|
||||
before spawning its worker, and the worker rejects a missing or incomplete
|
||||
parent policy.
|
||||
|
||||
These settings do not replace or increase `auto_index_limit`, change the 512 MiB
|
||||
single-file cap, alter workspace-root authorization, or affect
|
||||
`cross-repo-intelligence`. With both settings `off`, discovery follows the
|
||||
existing unbounded path.
|
||||
+6
-1
@@ -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`.
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# PR 2068: local hook setup review and verification
|
||||
|
||||
This delivery addendum records an independent review of the explicit agent setup flow. No coding-agent or model session was started. No account was created, and no personal client configuration was changed.
|
||||
|
||||
## Implemented boundary
|
||||
|
||||
The **Connect your agent** disclosure reads the selected project's repository root only when opened. It offers a download of the bundled Python hook and a shell-quoted installation command. Downloading or copying the command does not install anything. The user must review and execute it. The installer changes only that repository's `.claude/hooks/cbm-atlas-trace.py` and `.claude/settings.local.json`; the browser does not run it.
|
||||
|
||||
The installer preserves unrelated settings and hooks, rejects malformed or symlinked destinations, and is idempotent for its exact configuration. A review found that an existing identical command with a different matcher could previously produce two executions. The installer now refuses any conflicting entry referencing its installed script, including the ordinary relative-path form. Seven Python tests pass, covering merge preservation, repeat installation, conflicting matchers/timeouts, path quoting, destination rejection, sequence allocation and delivery acknowledgment.
|
||||
|
||||
A second review finding concerned source evidence: searching `old_string` after an edit can find a different, unchanged occurrence. The hook no longer reads source files to guess edit positions. Only explicit `Read` ranges are recorded; other operations retain their path without an invented line span.
|
||||
|
||||
## Actual local process verification
|
||||
|
||||
[The recorded result](../../graph-ui/verification/pr-2068/agent-setup-integration.json) identifies the exact hook SHA-256 and the explicitly marked test project `TEST-pr2068-hook-install-e87f2114`. A temporary repository with spaces and single quotes contained existing unrelated settings. The real installer process preserved them, and its second invocation reported no change. The generated shell command executed successfully against that temporary path.
|
||||
|
||||
An actual Python hook process received a synthetic, explicitly marked `Read` input after the verification program read its temporary fixture file. Its display name was deliberately overridden to **TEST local hook process; no agent session**. It delivered the requested lines 3 through 4 to `http://127.0.0.1:9749/api/agent-events`. A read-only SQLite query found event ID 8. Neither the fixture source contents nor the supplied test tool response appeared in the event.
|
||||
|
||||
The verifier requeued that same captured event in its isolated outbox and ran the real `--flush` process. Exactly one event remained in the daemon database, and the outbox became empty. The successful initial process took 0.074 seconds; this is one observed run, not a performance claim. An earlier attempt during concurrent browser work exceeded the producer's 0.75-second acknowledgment timeout after persistence; its outbox correctly retained the event. Immediate acknowledgment is therefore not assumed by the delivery design.
|
||||
|
||||
All temporary client configuration and outbox files were removed. The clearly labeled test event remains in the isolated development daemon's bounded journal. This proves the local producer-to-daemon path and retry behavior, not a completed real Claude Code session or support for other clients.
|
||||
|
||||
## Privacy and protocol review
|
||||
|
||||
The producer posts only to a loopback HTTP origin, disables environment proxies and redirects, and uses the existing daemon port. External hosts, credentials, URL paths and query parameters are rejected. Installation performs no network request. Normal execution records tool names, timestamps, paths, optional requested line spans, and at most 180 characters of command/search metadata. Such metadata may itself be sensitive; it remains local and is disclosed before installation. No issue submission, telemetry upload, account request, or LLM call is part of this setup.
|
||||
|
||||
## Narrow style-gate interpretation
|
||||
|
||||
The requested real client integration must use the actual client name and its configuration protocol. These operational references are not authorship attribution. [The gate helper](../../graph-ui/tools/lib/operational-client-reference.mjs) therefore permits only recognized client terms in four explicit setup/hook source and test files, plus four exact installation lines in the frontend README. It does not exempt whole files or generally allow product names. Authorship patterns are still checked unconditionally, including on otherwise permitted lines.
|
||||
|
||||
Two gate regression tests pass: valid protocol references are accepted only in those locations, while arbitrary names, other paths, vendor names, and `written by`/coauthor/generated-with claims remain rejected. The full style gate reports zero long-dash violations, zero attribution matches or patterns, and zero hardcoded chrome strings.
|
||||
|
||||
The earlier guide browser's original RPC evidence is preserved losslessly as [compressed JSON](../../graph-ui/verification/pr-2068/repair-guide-review.json.gz); [its summary](../../graph-ui/verification/pr-2068/repair-guide-review.json) retains screenshot, request counts and the original-byte SHA-256. Repository-authored source excerpts are evidence rather than application copy. The screenshot predates the subsequent guide ordering and layout fixes.
|
||||
@@ -1,106 +0,0 @@
|
||||
# Coverage and repeated worker warnings: observed repository data
|
||||
|
||||
Read-only checks against the actual daemon on 127.0.0.1:9749 and its SQLite
|
||||
project `cbm-pr2068`, generation `2026-09-09T19:51:51Z`, agree on 281 recorded
|
||||
coverage rows. This is a count of diagnostic records, not a repository-wide
|
||||
coverage denominator.
|
||||
|
||||
| Recorded category | Count | Meaning supported by the data |
|
||||
| --- | ---: | --- |
|
||||
| `parse_partial` | 85 | The index parser reported source ranges it could not reliably interpret. These files are not intentional exclusions. |
|
||||
| `not_indexed_dir` | 9 | Explicitly excluded subtrees: `.git`, build output, dependencies/vendor directories and `private`. The stored reason is `excluded subtree`. |
|
||||
| `not_indexed_file` | 187 | 179 files excluded by `ignored-suffix` and 8 by `gitignore`. Examples include PNG screenshots and generated TypeScript build metadata. |
|
||||
| Other skipped/read/extract failure rows | 0 | None recorded for this project in this generation. This does not prove every undiscovered file was considered. |
|
||||
|
||||
The 85 parser records group into 46 under `src`, 16 under `tools`, 12 under
|
||||
`tests`, 5 under `scripts`, 3 under `internal`, 2 under `graph-ui` and 1 under
|
||||
`pkg`. These are source-location groups, not assigned causes.
|
||||
|
||||
A parser gap is not itself proof of a compilation or runtime error. Concrete
|
||||
examples distinguish the evidence:
|
||||
|
||||
- `tests/fixtures/plsql/create_type_as_object_limitation.tps:1` explicitly
|
||||
documents a known upstream grammar limitation for `CREATE TYPE ... AS
|
||||
OBJECT`. `tests/test_extraction.c:1345` positively tests that limitation and
|
||||
the missing Class definition. This is a deliberate test fixture of a real
|
||||
parser limitation, not an exclusion.
|
||||
- `src/pipeline/pipeline.c:247` uses `CBM_TLS` in declarations; the macro expands
|
||||
to standard C11 `_Thread_local` in `src/foundation/compat.h:25`. Preprocessor
|
||||
token handling is a plausible explanation for this parser finding. No
|
||||
subtree investigation was performed to establish the exact cause.
|
||||
- `graph-ui/src/galaxy/coverage-shadow-scene.test.tsx:10` contains a generic
|
||||
asynchronous import expression accepted by the TypeScript/Vitest checks.
|
||||
The coverage range alone does not establish invalid TypeScript.
|
||||
- `graph-ui/src/galaxy/hierarchy-layout.ts:249` includes a literal NUL byte in
|
||||
template text. The exact reason the index parser flags it remains unknown.
|
||||
- `src/cli/cli.c` has a broad reported range, lines 1-13337. It remains visible
|
||||
as a substantial evidence limitation; this investigation does not dismiss
|
||||
that range or claim the graph extracted every symbol in the file.
|
||||
|
||||
## Why the warning count was larger than the file count
|
||||
|
||||
The captured activity database held 177 `index.worker` records: 172 warnings
|
||||
and 5 errors. Of those warnings, 170 were two occurrences each of the same 85
|
||||
project parser diagnostics. For example, `src/cli/cli.c` was logged at
|
||||
19:32:04 UTC and again at 19:51:43 UTC during separate indexing activity. The
|
||||
remaining two warnings were one explicit test-fixture partial parse and one
|
||||
unattributed pipeline-route fallback. The five errors were the explicit
|
||||
permission-denied test-fixture attempts.
|
||||
|
||||
These durable event rows are not 177 current frontend failures or 172 distinct
|
||||
unparsed files. The indexer emits a warning for each recorded partial parse on
|
||||
each relevant run. Retained event counts therefore depend on history and the
|
||||
response limit. The current coverage table is the source for the current
|
||||
unique path records; errors and warning history remain separate evidence.
|
||||
|
||||
## Confirmed frontend defects and implementation
|
||||
|
||||
`index_status` intentionally returned summary counts with zero detail rows and
|
||||
`truncated: true`. `check_index_coverage` returned all 281 rows with a complete
|
||||
root-scope reading. The join nevertheless carried the summary's truncation
|
||||
warnings into the UI after the omitted rows had already been retrieved.
|
||||
|
||||
A second defect affected genuinely larger responses: the production daemon
|
||||
puts `has_more` and `next_offset` on the response envelope. The client only
|
||||
read those fields inside each scope, so it would stop after one page. Existing
|
||||
mock tests had modeled the wrong wire location. It also retained every earlier
|
||||
page's `has_more` as an unresolved warning after later pages arrived.
|
||||
|
||||
The shared reader now accepts the real envelope continuation for its single
|
||||
root scope. The join verifies the final page, consistent totals and distinct
|
||||
path/kind rows before declaring a recorded listing complete. Only summary
|
||||
omissions that have actually been covered are cleared. Duplicate pages,
|
||||
missing rows, unavailable coverage, partial subtree queries and the page cap
|
||||
retain uncertainty. An index-generation change between the summary and pages
|
||||
fails the reading instead of combining snapshots. The 85 parser records and
|
||||
196 explicit exclusions remain visible.
|
||||
|
||||
The diagnosis panel explains the distinction between parser limitations,
|
||||
malformed input and program failures. A complete recorded listing explicitly
|
||||
retains the existing parser limitations and exclusions.
|
||||
|
||||
## Executed verification
|
||||
|
||||
- 64 focused shared coverage/parser/diagnosis tests passed, including real
|
||||
envelope pagination, duplicate/missing rows, completed abbreviation
|
||||
recovery, unavailable coverage and generation changes.
|
||||
- The updated production TypeScript reader was bundled and run against the
|
||||
real read-only RPC. A verification-only scope limit of 100 forced three
|
||||
pages: offsets 0, 100 and 200 returned 100, 100 and 81 entries. Result:
|
||||
281 rows, `listingComplete: true`, no unresolved truncations, 85 partial
|
||||
parses still present, generation unchanged.
|
||||
- The coordinated frontend build passed, including 2387 frontend tests.
|
||||
Full TypeScript checking (`npx tsc -b --pretty false`) and
|
||||
`git diff --check` also passed.
|
||||
|
||||
[Captured RPC, SQLite and updated-client evidence](../../graph-ui/verification/pr-2068/coverage-investigation.json)
|
||||
contains the original data, classified counts and real pagination result. No
|
||||
indexer code or repository index was mutated for this investigation. The newly embedded UI was checked at 20:15:31 UTC on the same local daemon.
|
||||
It displayed 85 partial parses and 196 intentional exclusions, explicitly
|
||||
confirmed that all recorded entries were loaded, and showed no false
|
||||
`Incomplete list` warning. There were 100 same-origin browser requests, no
|
||||
external attempts or JavaScript page errors, and no index mutation.
|
||||
|
||||
[Actual diagnosis screenshot](../../graph-ui/verification/pr-2068/coverage-after-diagnosis.png)
|
||||
and [browser verification](../../graph-ui/verification/pr-2068/coverage-ui-check.json)
|
||||
record the final rendered result and its screenshot hash.
|
||||
@@ -1,104 +0,0 @@
|
||||
# Daemon- und Agent-Setup-Reparaturen nach dem Bedienvergleich
|
||||
|
||||
Der echte Vorher-/Nachher-Vergleich fand einen Bedienfehler: System → Logs filterte
|
||||
nur die neuesten 200 Zeilen im Browser. 49 vorhandene SQLite-Fehler wurden dadurch
|
||||
als 0 sichtbare Fehler dargestellt. System verwendet jetzt projektweiten,
|
||||
daemonweiten oder unbekannten Scope und Schweregrad-Schwellen serverseitig vor
|
||||
LIMIT; eine begrenzte Textsuche durchsucht ebenfalls den gespeicherten Bestand.
|
||||
Ein Scopewechsel zeigt keine alten Scope-Daten, auch nicht vor Reacts Effekten.
|
||||
|
||||
Alle angenommenen Frontend-Loglevel landen jetzt im gemeinsamen SQLite-Journal.
|
||||
Schema 4 ergänzt die Herkunft `frontend` und einen auslaufende Ereignisse
|
||||
überdauernden Sequenzstand. `/api/ui-log` liest seine bisherigen JSON-Zeilen aus
|
||||
dieser Quelle; `path` nennt jetzt `activity.db`, `export_path` den weitergeführten
|
||||
lokalen JSONL-Kompatibilitätsexport. Der Export ist keine zweite Statusquelle.
|
||||
Session/Sequenz-Wiederholungen erzeugen weder doppelte Journalzeilen noch doppelte
|
||||
Exportzeilen, auch nach Payload-Retention. Ein fehlgeschlagener Journal-Write
|
||||
antwortet mit HTTP 503. Einmalig werden höchstens die letzten 256 KiB von je
|
||||
`ui.log.1` und `ui.log` importiert; Empfangszeiten bleiben alt und unbekannte
|
||||
Projektzuordnung bleibt unbekannt. Teilweise Importe sind explizit gekennzeichnet.
|
||||
|
||||
Der Benutzer erhält unter Agents → **Connect your agent** den tatsächlichen
|
||||
Python-Hook und einen anhand des vorhandenen `/api/repo-info` erzeugten lokalen
|
||||
Installbefehl. Der Installer verändert nur die explizit gewählte Projektkonfiguration,
|
||||
erhält fremde Einstellungen und verweigert widersprüchliche Hooks oder Symlinks.
|
||||
Er startet keinen Client, ruft kein LLM auf und richtet kein Konto ein. Die Oberfläche
|
||||
unterscheidet laufendes History-Polling vom Zeitpunkt der letzten aufgezeichneten
|
||||
Werkzeugaktivität. Frühere Prüfeinträge werden nicht als gegenwärtige Arbeit bezeichnet.
|
||||
Unterstützt ist hier Claude Code auf macOS/Linux mit Python 3; andere Clients benötigen
|
||||
einen eigenen Adapter. Eine produktive neue LLM-Sitzung wurde nicht ausgeführt.
|
||||
|
||||
Ein zweiter echter Browserfehler war eine abgebrochene 11.070.807-Byte-Mapantwort
|
||||
(`ERR_CONTENT_LENGTH_MISMATCH`). Nur Repository-Map-Antworten oberhalb 1 MiB
|
||||
bekommen jetzt maximal fünf Sekunden Sendezeit. Normale Antworten behalten eine
|
||||
Sekunde und das sofortige Stop-Interrupt-Verhalten. Die JSON-Map wird direkt
|
||||
übertragen, ohne zusätzliche Formatierungskopie. Kompression und ein kleineres
|
||||
semantisches Antwortformat bleiben mögliche spätere Optimierungen.
|
||||
|
||||
Die Repository-Map gibt zusätzlich gespeicherte Resolvermetadaten `strategy` und
|
||||
`confidence` unverändert weiter. Fehlende oder falsch typisierte Werte werden
|
||||
nicht ergänzt; sie sind keine Schadenswahrscheinlichkeit.
|
||||
|
||||
## Tatsächliche Prüfungen
|
||||
|
||||
- `build/c/test-runner httpd`: **83 bestanden, 0 fehlgeschlagen, 1 bestehender
|
||||
Windows-Skip**, mit ASan/UBSan. Abgedeckt sind vollständige große Antworten
|
||||
trotz vorübergehend blockiertem Leser, unveränderte Defaultfrist/Interrupt,
|
||||
Schema-/Legacy-Import, volle JSON-Evidenz, Retention/alte Retries,
|
||||
Scope/Suchfilter vor LIMIT und Neustarts.
|
||||
[Ungekürztes Laufprotokoll](../../graph-ui/verification/pr-2068/native-log-transport-repair.log.gz)
|
||||
- `build/c/test-runner ui`: **40 bestanden**, einschließlich vorhandener Impact-
|
||||
und erweiterter Map-Evidenztests für `unique_name`, `confidence: 0.75`, rohe
|
||||
Zeilenmetadaten sowie fehlende/falsch typisierte Angaben.
|
||||
[Laufprotokoll](../../graph-ui/verification/pr-2068/native-map-resolution-repair.log.gz)
|
||||
- System/Logadapter/kompakte Alerts: **41 Tests bestanden**, anschließend TypeScript
|
||||
ohne Fehler. Die erste Native-Prüfung fand vertauschte `duplicates`/`dropped`
|
||||
Antwortwerte; diese wurden korrigiert. Der Zwischenlauf mit dem irrtümlichen
|
||||
Flag `--suites` wird nicht als erfolgreicher Gesamtlauf gezählt.
|
||||
- Der echte Indexer verarbeitete erneut das ausdrücklich markierte lokale Fixture.
|
||||
Nicht lesbares `unreadable.c` erzeugte neue fehlgeschlagene Jobs und sichtbare
|
||||
ERROR-Ereignisse **#8986 in System** sowie **#9018 in Galaxy**, jeweils ohne
|
||||
Neuladen. System zeigte **57/57** daemonweite Fehler, **11/11** projektbezogene
|
||||
Fehler und **3/3** Suchtreffer für `semantic_manifest.err`. Die Werte wurden
|
||||
am aufgezeichneten Ereignis-Cursor direkt in SQLite bestätigt.
|
||||
[Browser-/HTTP-/SQLite-Nachweis](../../graph-ui/verification/pr-2068/repair-errors-e2e.json)
|
||||
- Lokale Diagnose, echte Parserzeile und ausschließlich bearbeiteter lokaler
|
||||
Download erneut geprüft. Keine externe Browseranfrage, kein Browserfehler;
|
||||
Dateirechte des Fixtures anschließend wiederhergestellt.
|
||||
|
||||
Die neuen Screenshots sind echte Aufnahmen des markierten Fehlerfixtures:
|
||||
[gefilterte System-Historie](../../graph-ui/verification/pr-2068/repair-errors-system-filtered-history.png),
|
||||
[Systemfehler](../../graph-ui/verification/pr-2068/repair-errors-after-system.png),
|
||||
[Galaxyfehler](../../graph-ui/verification/pr-2068/repair-errors-after-galaxy.png),
|
||||
[Diagnose](../../graph-ui/verification/pr-2068/repair-errors-local-diagnosis.png),
|
||||
[lokaler Bericht](../../graph-ui/verification/pr-2068/repair-errors-local-report.png).
|
||||
|
||||
Die Hook-Konfiguration folgt der verifizierten
|
||||
[offiziellen Claude-Hook-Schnittstelle](https://code.claude.com/docs/en/hooks).
|
||||
Die Definition von PostToolUse wird benutzt, ohne aus aufgezeichneten Werkzeugaufrufen
|
||||
Absichten, Ergebnisse oder eine aktive Agentensitzung abzuleiten.
|
||||
|
||||
Die abschließende Browserprüfung lief am 9. September 2026 um 21:45 UTC gegen
|
||||
den neu gestarteten Daemon und den erneut indexierten Arbeitsbaum. Ein frischer
|
||||
Browser öffnete das sichtbare Agenten-Setup; der tatsächlich heruntergeladene Hook
|
||||
stimmte byteweise mit dem geprüften Source überein. Keine Clientkonfiguration
|
||||
wurde verändert, kein Client oder LLM gestartet und kein Agentenereignis erzeugt.
|
||||
Das frühere lokale TEST-Hookereignis **ID 8** war nach dem echten Daemonneustart
|
||||
unverändert genau einmal in Schema 4 und in der HTTP-Abfrage vorhanden.
|
||||
|
||||
Der Browser war vor diesem Neustart noch nicht verbunden; der erste Versuch fiel
|
||||
in die Unterbrechung und ist gesondert dokumentiert. Deshalb wurde die
|
||||
Browserwiederverbindung anschließend durch bewusstes Offline/Online geprüft:
|
||||
ohne Reload, identische Generation, Cursor und vier ausdrücklich markierte alte
|
||||
Prüfereignisse, keine doppelten Zeilen. Die Verbindung erholte sich in diesem
|
||||
einzelnen Lauf nach 2.855 ms. Das ist ein Ablaufnachweis, kein Benchmark.
|
||||
|
||||
Im selben Lauf entstand der lokale Diagnosebericht erst nach **Run local diagnosis**.
|
||||
Der bearbeitete Download enthielt ausschließlich den absichtlich redigierten
|
||||
Text. Die Prüfung erfasste keine externe oder Bridge-Anfrage und keinen
|
||||
JavaScript-Fehler; vier bewusst provozierte Offline-Netzwerkfehler sind erhalten.
|
||||
[Vollständiger Nachweis](../../graph-ui/verification/pr-2068/final-agent-diagnosis-restart.json),
|
||||
[Setup und echter Download](../../graph-ui/verification/pr-2068/final-agents-setup-download.png),
|
||||
[wiederverbundener Browser](../../graph-ui/verification/pr-2068/final-agents-connection-recovered.png),
|
||||
[Diagnose vor Freigabe](../../graph-ui/verification/pr-2068/final-coverage-before-diagnosis-action.png),
|
||||
[lokaler Bericht nach Aktion](../../graph-ui/verification/pr-2068/final-coverage-local-report-after-action.png).
|
||||
@@ -1,188 +0,0 @@
|
||||
# PR 2068: implementierter Stand und Abnahme
|
||||
|
||||
**Korrigierte Abnahmebewertung:** Der anschließende
|
||||
[direkte Produktvergleich](pr-2068-product-audit.md) zeigt wesentliche offene
|
||||
Nutzerabläufe und Integrationslücken. Der ursprüngliche Auftrag ist nicht
|
||||
abgeschlossen. Die folgenden Nachweise belegen implementierte Bausteine;
|
||||
sie sind kein Nachweis einer bereits ausreichend verständlichen Repository-Karte.
|
||||
|
||||
Stand: 9. September 2026. Implementierung auf `feat/codeatlas-web`, Basis des PR
|
||||
`feat/atlas-r1`, ursprünglicher HEAD `80f4f41f9bcb9daf60afc5f607bcbcba24a4b196`.
|
||||
Die Änderungen liegen lokal im vorgesehenen Branch. Kein Push, keine Änderungen
|
||||
an anderen Branches, keine Accounts, Uploads oder Issue-Erstellung.
|
||||
|
||||
Nach dem ersten Abnahmestand wurde die vom Nutzer gemeldete Vermischung alter
|
||||
Testfehler mit dem ausgewählten Projekt korrigiert. Der
|
||||
[Feedback-Bericht](pr-2068-feedback-fixes.md) dokumentiert die anschließend
|
||||
implementierte Projektzuordnung, Historienansicht und vollständige Coverage-
|
||||
Pagination einschließlich neuer Browserprüfungen. Die ursprünglichen Testläufe
|
||||
unten bleiben als zeitlich zugeordnete Nachweise erhalten.
|
||||
|
||||
## Nutzwert und tatsächliche Umsetzung
|
||||
|
||||
| Entwicklerfrage | Implementierte Antwort und nächste Handlung | Evidenz / Grenzen |
|
||||
| --- | --- | --- |
|
||||
| Was macht dieses Repository, wo beginne ich? | Architect öffnet mit Repository-Karte, direktem README-Einstieg, indexierten Einstiegskandidaten und verbundenen Quellbereichen. Bereich → Datei → Symbol; vorhandene Übersicht, Routen, Hotspots und Import-Lesereihenfolge bleiben erreichbar. | README und tatsächliche Quellpositionen. Quellbereiche sind ausdrücklich heuristische Navigationsgruppen; keine behaupteten fachlichen Grenzen oder Ausführungsreihenfolgen. |
|
||||
| Wie arbeiten Bereiche zusammen? | Getrennte eingehende/ausgehende Beziehungen nach Kantentyp. Aggregierte Verbindungen lassen sich bis zu einzelnen Kanten, Deklarationen und erfassten Aufrufzeilen aufklappen. | SQLite-Kanten mit Identitäten. CALLS, IMPORTS, USAGE und Typbeziehungen bleiben unterscheidbar; keine Umdeutung von Imports zu Datenfluss. |
|
||||
| Warum ist meine Auswahl relevant? | Auswahlkontext zeigt Caller, Beziehungen und begrenzte statische Pfade ab Einstiegskandidaten. Quellcode-Link und Impact folgen derselben Auswahl. | Graphbefunde und tatsächlich erfasste Werkzeugereignisse sind getrennt. Fehlende Verbindungen erzeugen einen ehrlichen Leerzustand; keine LLM-Abhängigkeit. |
|
||||
| Was könnte eine Änderung betreffen? | Integriertes Change-Impact-Widget für Datei oder Symbol: priorisierte direkte/transitive Abhängigkeiten, aufklappbare Graphpfade, Testkandidaten, separate Git-Co-Changes und nächste Prüfungen. | Transparente Heuristik und eigenständige Datenqualitätsanzeige; kein Prozentwert für Defekte. Git-Befunde führen zu konkreten Hashes und kopierbaren lokalen Prüfkommandos. |
|
||||
| Welche Arbeit wurde tatsächlich beobachtet? | Agent Overview liest das persistierte Journal über den Daemon. Ereignisse bleiben nach Wiederverbindung/Neustart erhalten und führen zur betroffenen Quelle. | Der bestehende Werkzeug-Hook ist die Ereignisquelle; Einrichtung bleibt erforderlich. Ein Werkzeugaufruf beweist keine Absicht und kein erfolgreiches Ergebnis. |
|
||||
| Ist die Karte unvollständig oder ein Indexierungsauftrag gescheitert? | Gemeinsame priorisierte Fehleranzeige in allen Workspaces, Systems-Jobstatus und explizit geöffnete lokale Diagnose. Vorhandene Coverage-Kategorien und Parser-Zeilen bleiben erhalten. | Dieselben `index_status`-/`check_index_coverage`-Daten wie im Explorer. Fehler enthalten Zeit, Schwere, Quelle und Originalereignis. Keine erfundene Coverage-Prozentzahl. |
|
||||
|
||||
Die neue Oberfläche ist implementiert, kein Mockup. Eine formelle Studie mit
|
||||
projektfremden Erstnutzern wurde nicht durchgeführt; der vollständige navigierbare
|
||||
Ablauf wurde mit dem tatsächlichen Repository im Browser geprüft.
|
||||
|
||||
## Architektur und Datenwege
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
H[Konfigurierter Werkzeug-Hook] --> O[Lokale SQLite Outbox]
|
||||
O -->|POST /api/agent-events| D[Bestehender C-Daemon :9749]
|
||||
W[Indexierungsworker] -->|erfasste Diagnose / MCP-Ergebnis| D
|
||||
D <--> A[activity.db: Ereignisse und Logs]
|
||||
D <--> G[Vorhandene Projekt-SQLite: Graph und Coverage]
|
||||
D -->|begrenzter lokaler Worker| I[Git-Historie und struktureller Impact]
|
||||
D -->|HTTP / RPC und Polling, gleicher Port| U[Architect / Galaxy / Agents / Systems]
|
||||
U -->|bewusster Klick| R[Bearbeitbarer lokaler Diagnosebericht]
|
||||
```
|
||||
|
||||
- `src/ui/activity.c`: WAL-Journal unter dem bestehenden Cache-Verzeichnis,
|
||||
Migration über `user_version` auf Version 3. Persistente Generation und Cursor;
|
||||
Idempotenz über Projekt/Run/Sequenz; kompakte Retired-Run-Marker verhindern
|
||||
Wiederauftauchen bereits aus der Historie entfernter alter Ereignisse.
|
||||
- Aufbewahrung: 10.000 Agentenereignisse, 3.000 normale und 2.000 priorisierte
|
||||
Logeinträge. Fehler werden nicht durch eine Flut normaler Logs verdrängt.
|
||||
Kleine Deduplizierungsmarker bleiben über die Payload-Aufbewahrung hinaus bestehen.
|
||||
- Version 3 ergänzt die explizite Projektzuordnung von Logeinträgen. Ältere
|
||||
Meldungen behalten unbekannte Zuordnung; sie werden nicht aus Pfadtexten einem
|
||||
Projekt zugeschrieben. `/api/logs?project=NAME` filtert bereits in SQLite vor
|
||||
Limit und Cursor. Die projektübergreifende Historie bleibt separat erreichbar.
|
||||
Der anfängliche Historienbestand ist kein Zähler aktuell aktiver Fehler; neue
|
||||
Projektfehler erscheinen live. Quittieren löscht keine gespeicherten Ereignisse.
|
||||
- `/api/agent-events` und strukturierte `/api/logs` ergänzen den vorhandenen HTTP-
|
||||
Listener. Browser-Polling verwendet dieselbe Origin; SQLite ist die Datenquelle.
|
||||
Der frühere Agent-Bridge-Port 4142 ist im regulären Weg nicht mehr erforderlich.
|
||||
Bestehende MCP-Transporte bleiben erhalten. Indexierungsworker und lokale
|
||||
Analyseworker sind keine zusätzlichen HTTP-Server.
|
||||
- Der Python-Hook schreibt zuerst eine transaktionale Outbox und sendet begrenzt
|
||||
an den Loopback-Daemon. Nicht bestätigte Datensätze werden beim nächsten Hook
|
||||
oder expliziten `--flush` erneut versucht. Es gibt keinen zusätzlichen Retry-Dienst.
|
||||
- `/api/repository-map` liest eine konsistente Graphtransaktion mit maximal
|
||||
20.000 semantischen Knoten und 100.000 passenden Kanten. Dies ist unabhängig
|
||||
von der kleineren 3D-Rendergrenze. Snapshot, tatsächliche Mengen und Abschneidung
|
||||
werden mitgeliefert. Detailbereiche werden bei neuer Generation zurückgesetzt;
|
||||
Auswahl, Dokumentation und Quellposition werden anhand stabiler Quellidentität
|
||||
erneut aufgelöst. Fremde Projektzustände werden bereits beim Rendern abgegrenzt.
|
||||
- `/api/impact-analysis` arbeitet mit einer separaten SQLite-Leseverbindung und
|
||||
einem begrenzten Worker: 4 Hops, 600 Knoten, 3.000 Kanten, 128 Seed-Knoten,
|
||||
höchstens 60 angezeigte Befunde. Ein 30-Sekunden-Cache berücksichtigt Auswahl
|
||||
und Graphgeneration. Die Oberfläche bleibt währenddessen bedienbar.
|
||||
- Git läuft ohne Shell mit festen Argumenten, unveränderlichem HEAD, ohne Lazy
|
||||
Fetch und ohne konfigurierte Signaturverifikation. Höchstens 200 Commits über
|
||||
18 Monate, 8 Sekunden / 8 MiB; Merge- und Changesets über 30 Dateien werden
|
||||
getrennt gezählt und ausgeschlossen. Keine neue Git-Bibliothek.
|
||||
- Im vollständigen Fehlertest entdeckt und behoben: Ein sauber beendeter Worker
|
||||
konnte ein MCP-Ergebnis mit `isError: true` liefern, während der Auftrag als
|
||||
erfolgreich erschien. Der Coordinator unterscheidet nun Prozessbeendigung von
|
||||
fachlichem Indexierungserfolg und bewahrt die ursprüngliche MCP-Antwort.
|
||||
|
||||
Die Fehlermeldungen sind Daemon-Ereignisse; der Test erzeugte sie bewusst in einem
|
||||
separaten `pr2068-error-fixture`-Projekt. Sie bleiben als historische Einträge
|
||||
sichtbar und sind keine Behauptung, dass jede angezeigte Datei aktuell fehlschlägt.
|
||||
|
||||
## Tatsächlich ausgeführte Validierung
|
||||
|
||||
| Prüfung | Ergebnis / Nachweis |
|
||||
| --- | --- |
|
||||
| Ursprüngliches Frontend | 157 Dateien, 2.334 Unit-Tests erfolgreich, vor Implementierung. |
|
||||
| Vollständige native ASan/UBSan-Prüfung | **7.954 bestanden, 0 fehlgeschlagen, 7 Skips**, 141 Suites. [Vollständiges Protokoll](../../graph-ui/verification/pr-2068/native-full-tests.log.gz). Dieser Lauf liegt vor dem letzten Coordinator-Fix; dieser wurde anschließend gezielt erneut getestet. |
|
||||
| Finaler Coordinator-/HTTP-Regressionslauf | **129 bestanden, 1 bestehender Plattform-Skip**, einschließlich sauberem Worker-Exit mit fehlerhaftem MCP-Ergebnis; ASan/UBSan. [Protokoll](../../graph-ui/verification/pr-2068/native-final-job-status-tests.log.gz). |
|
||||
| SQLite/Agenten/Map/Impact-native Tests | Zusätzlich 176 bestanden, 1 bestehender Plattform-Skip im fokussierten Lauf, unter anderem Migration, Retention, Deduplizierung, Kantenpfade und Git-Ausschlüsse. [Protokoll](../../graph-ui/verification/pr-2068/native-activity-tests.log.gz). |
|
||||
| Frontend, TypeScript, Produktionsbuild | **166 Dateien / 2.377 Tests bestanden**, TypeScript und eingebetteter Produktionsbuild erfolgreich. Siehe [Unit-Protokoll](../../graph-ui/verification/pr-2068/frontend-unit-tests.log.gz), [Build-Protokoll](../../graph-ui/verification/pr-2068/frontend-build.log.gz). |
|
||||
| Frontend-Vertragsprüfungen | 203/203 bestanden. Style-/Promise-Gates ebenfalls erfolgreich. [Akzeptanzprotokoll](../../graph-ui/verification/pr-2068/frontend-acceptance.log.gz), [Style](../../graph-ui/verification/pr-2068/stylegate.json), [Promise](../../graph-ui/verification/pr-2068/promises.json). |
|
||||
| Reale Repository-Navigation | README → Bereich → Beziehung/Kanten-ID → Auswahlkontext/Impact → tatsächlicher Reader; Auswahl bleibt beim Workspace-Wechsel erhalten. Keine Browser-Ausnahmen oder externen Anfragen. [Maschinenlesbarer Ablauf](../../graph-ui/verification/pr-2068/map-e2e.json). |
|
||||
| Agenten vom Hook bis zum Browser | SQLite-Outbox, Zustellung, doppelter Retry, verspätete Sequenzen, Offline/Reconnect, Reload und echter Daemon-Neustart geprüft. Generation und IDs blieben unverändert. [Vor Neustart](../../graph-ui/verification/pr-2068/agents-before-restart.json), [danach](../../graph-ui/verification/pr-2068/agents-after-restart.json). |
|
||||
| Echte Indexierungsfehler / lokale Diagnose | Synthetische, ausdrücklich markierte Dateien durch echten Indexer verarbeitet. Fehler live in Systems und Galaxy; gespeicherte Parser-Lücke und Ausschluss gegen SQLite geprüft. Bericht erst nach Klick, bearbeiteter Download, keine externe Anfrage. [Reproduktionsanleitung](pr-2068-error-repro.md), [Ablauf](../../graph-ui/verification/pr-2068/errors-e2e.json). |
|
||||
| Reale Impact-Evidenz | Angezeigte Graphkanten gegen SQLite, gemeinsame Änderungen gegen lokale Git-Commits und Antwortgeneration gegen Map überprüft. [Prüfbefunde](../../graph-ui/verification/pr-2068/impact-evidence-check.json), [Originalantwort](../../graph-ui/verification/pr-2068/impact-real-response.json). |
|
||||
|
||||
Ausführbare Browserprüfungen liegen in `graph-ui/tools/pr-2068-{map,agents,errors}-e2e.mjs`.
|
||||
Sie setzen den isolierten lokalen Daemon auf 9749 voraus. Agenten-/Fehlerdateien
|
||||
sind als Testdaten gekennzeichnet. Erforderliche künstliche Dateirechte werden
|
||||
im `finally`-Pfad wiederhergestellt.
|
||||
|
||||
Der vollständige C-Lint wurde ausgeführt, ist aber **nicht grün**: `cppcheck` fehlt
|
||||
im lokalen System; clang-tidy meldet umfangreiche bestehende Befunde. Die bereits
|
||||
im HEAD vorhandenen Formatabweichungen wurden nicht pauschal umgeschrieben.
|
||||
Im globalen Lauf gefundene neue Befunde wurden korrigiert; neue UI-C-Dateien zusätzlich mit fokussierten
|
||||
Clang-Analyzer-/Korrektheitsprüfungen und Format-/Werror-Prüfungen kontrolliert,
|
||||
weil der vorhandene globale Lint die UI-Quellen nicht umfasst. Ein sauberer
|
||||
globaler Lint-Gate-Nachweis ist damit ausdrücklich offen. Die neuen UI-C-Dateien
|
||||
erfüllen auch nicht sämtliche zusätzlichen clang-tidy-Stilregeln (unter anderem
|
||||
Komplexität und benannte Zahlen); die fokussierten Korrektheitsprüfungen sind grün.
|
||||
|
||||
Das vollständige `scripts/test.sh` entfernt über `scripts/clean.sh` auch
|
||||
`graph-ui/node_modules` und `dist`; sie wurden anschließend mit dem bestehenden
|
||||
Lockfile wiederhergestellt. Die nativen Testtargets behandelten Leerzeichen im
|
||||
Workspacepfad nicht korrekt; deren `CURDIR`-Aufrufe sind nun gequotet. Der gesamte
|
||||
bereits gebaute Testsatz wurde mit dem vorgesehenen parallelen Runner ausgeführt.
|
||||
|
||||
## Echte Vorher-/Nachher-Aufnahmen
|
||||
|
||||
Alle Aufnahmen entstanden im tatsächlichen Browser; keine generierten Bilder.
|
||||
|
||||
| Ablauf | Vorher | Implementierter Stand |
|
||||
| --- | --- | --- |
|
||||
| Architect | [Ursprüngliche Übersicht](../../graph-ui/verification/pr-2068/before-architecture.png) | [Repository-Karte](../../graph-ui/verification/pr-2068/after-architecture.png), [Beziehung](../../graph-ui/verification/pr-2068/after-architecture-relationship.png) |
|
||||
| Auswahl und Änderung | Ausgangspunkt war der generische Einstieg-Chooser | [Auswahlkontext](../../graph-ui/verification/pr-2068/after-selection-context.png), [Impact einer real geänderten Datei](../../graph-ui/verification/pr-2068/after-context-and-impact.png), [Quellcode](../../graph-ui/verification/pr-2068/after-source-evidence.png), [README-Einstieg](../../graph-ui/verification/pr-2068/after-project-readme.png) |
|
||||
| System | [Ursprünglicher System-View](../../graph-ui/verification/pr-2068/before-system.png) | [Live-Indexierungsfehler](../../graph-ui/verification/pr-2068/errors-after-system.png) |
|
||||
| Galaxy | [Ursprünglicher Galaxy-View](../../graph-ui/verification/pr-2068/before-galaxy.png) | [Live-Fehler im Galaxy-View](../../graph-ui/verification/pr-2068/errors-after-galaxy.png) |
|
||||
| Agenten | [Ursprünglicher Agent-View](../../graph-ui/verification/pr-2068/before-agents.png) | [Hook-Evidenz](../../graph-ui/verification/pr-2068/agents-hook-evidence.png), [nach Daemon-Neustart](../../graph-ui/verification/pr-2068/agents-daemon-restart.png) |
|
||||
| Lokale Diagnose | Kein entsprechender integrierter Berichtsablauf | [Diagnose](../../graph-ui/verification/pr-2068/errors-local-diagnosis.png), [bearbeitbarer lokaler Bericht](../../graph-ui/verification/pr-2068/errors-local-report.png) |
|
||||
|
||||
## Rechercheentscheidungen
|
||||
|
||||
Der [separate Recherchebericht](pr-2068-research.md) dokumentiert Originalquellen,
|
||||
Revisionen, Lizenzbedingungen, Browserinteraktionen und deren echte Screenshots.
|
||||
|
||||
- Archify: Übersicht/Detail und gerichtete Pfade; Interaktionsidee übernommen,
|
||||
separaten Diagrammcompiler nicht integriert.
|
||||
- Graphify: sichtbare Herkunft, Heuristik und Aggregation; keine zusätzlichen
|
||||
Python-/Graphbibliotheken oder CDN-Abhängigkeiten übernommen.
|
||||
- GitNexus: fokussierte Teilgraphen; keine Codeübernahme insbesondere wegen
|
||||
PolyForm Noncommercial, keine LLM-Abhängigkeit und keine erfundenen Ersatzpfade.
|
||||
- Vom Nutzer bestätigtes `colbymchenry/codegraph`: Einstiegsmöglichkeiten,
|
||||
Caller/Callee-Quellnavigation, konsistente Auswahl; kein Svelte-/Serverimport.
|
||||
- Weitere Codegraph-/Code-Maat-Ansätze: historische Co-Changes separat,
|
||||
Ausschluss großer Changesets. Keine Defektwahrscheinlichkeiten und keine
|
||||
zusätzliche JVM-/Indexer-/SQLite-Laufzeit.
|
||||
|
||||
Archify und das Graphify-Beispiel wurden tatsächlich interaktiv ausprobiert.
|
||||
GitNexus-/CodeGraph-Reader wurden im konkreten Frontend-/API-Quellcode untersucht;
|
||||
kein vollständiger lokaler Reader-Betrieb dieser beiden Projekte wird behauptet.
|
||||
Die gewünschte separate Archify-Galerie läuft auf 127.0.0.1:9750; sie zeigt
|
||||
Originalbeispiele, keine aus unserem Repository abgeleiteten Abläufe.
|
||||
|
||||
## Bewusste Grenzen
|
||||
|
||||
- Fachliche Verantwortlichkeiten werden nicht aus Verzeichnisnamen erfunden.
|
||||
Die Karte verwendet belegte Symbole, Dokumentation, Beziehungen und ausdrücklich
|
||||
heuristische Quellbereiche. Statische Pfade beweisen keine Laufzeitausführung;
|
||||
Präprozessor-/Plattformvarianten können im bestehenden Index zusammenfallen.
|
||||
- Die Karte und Impact sind begrenzt. Fehlende Kanten, Testpfade, Git-Historie oder
|
||||
unvollständige Coverage werden nicht als geringe Gefahr interpretiert.
|
||||
- Der Index speichert derzeit keine zuverlässige zugehörige Git-Revision. Die
|
||||
Oberfläche zeigt dies zusammen mit dem tatsächlichen Indexzeitpunkt, HEAD,
|
||||
uncommitteten Dateien und möglichen Generationsunterschieden. Keine Garantie
|
||||
zeilengleicher Datenstände allein durch zeitliche Nähe.
|
||||
- Impact untersucht strukturelle Abhängigkeiten der ausgewählten Datei bzw.
|
||||
des Symbols. Es beweist keinen Defekt durch eine konkrete geänderte Codezeile.
|
||||
Historische Umbenennungen werden nicht verfolgt; flache/fehlende Historie ist
|
||||
sichtbar. Git muss die verwendete Option `--no-lazy-fetch` unterstützen.
|
||||
- Agenten-Hooks müssen für den jeweiligen Client konfiguriert sein. Das Werkzeug-
|
||||
Journal ist kein Zugriff auf interne Agentenüberlegungen oder beliebige nicht
|
||||
instrumentierte Clients. Die Testereignisse sind entsprechend gekennzeichnet.
|
||||
- Die vorhandene Coverage-Erfassung kann unvollständige Listen liefern. Kategorien,
|
||||
absolute erfasste Mengen und Grenzen werden gezeigt; keine globale Prozentzahl.
|
||||
- Globale C-Lint-Bereinigung und eine formelle Erstnutzerstudie bleiben offen.
|
||||
Eine automatische Diagnose-/Issue-/Upload-Funktion wurde nicht hinzugefügt.
|
||||
@@ -1,125 +0,0 @@
|
||||
# PR 2068: real index-error and local-diagnosis verification
|
||||
|
||||
This exercise uses an explicitly synthetic repository at
|
||||
`/tmp/cbm-2068-error-fixture`, project `pr2068-error-fixture`, against the actual
|
||||
compiled application on `http://127.0.0.1:9749`. It does not modify the real
|
||||
`cbm-pr2068` repository or its index. The daemon uses the isolated cache
|
||||
`/tmp/cbm-2068-cache`; the browser, MCP requests, index requests and event polling
|
||||
all use that one daemon origin.
|
||||
|
||||
## Reproduce
|
||||
|
||||
With the isolated daemon already running, from `graph-ui`:
|
||||
|
||||
```sh
|
||||
node tools/pr-2068-errors-e2e.mjs
|
||||
```
|
||||
|
||||
The script refuses a pre-existing fixture directory unless its explicit test
|
||||
marker matches. It creates these test inputs:
|
||||
|
||||
- `readable.c`: valid C function.
|
||||
- `partial.c`: malformed C declaration on line 2, producing the real indexer's
|
||||
`parse_partial` coverage record, with source range 2-2.
|
||||
- `excluded.c`: excluded by an actual `.cbmignore` rule, producing
|
||||
`not_indexed_file`, reason `cbmignore`.
|
||||
- `unreadable.c`: initially readable so a real index can be established; then
|
||||
permission mode `000`, with an independent Node file-read check for `EACCES`.
|
||||
|
||||
After the first successful index, the script opens the real application and
|
||||
closes the first-visit welcome with its normal button. While System, and then
|
||||
Galaxy, is already visible, it requests a new index with the inaccessible input.
|
||||
It requires the resulting job to report `error`, matches a new persisted
|
||||
`ui.index.done` error by its monotonic event ID, and waits for that same event to
|
||||
be visible in the shared priority feed without reloading the page. It then
|
||||
opens the local diagnosis panel, verifies that no report exists before the
|
||||
explicit button click, runs the existing coverage RPCs, edits the report and
|
||||
verifies that the downloaded file contains exactly that edited text.
|
||||
|
||||
All browser requests are recorded. The browser route rejects any origin other
|
||||
than the local daemon (local Blob/data URLs are allowed). SQLite evidence is
|
||||
read with `mode=ro` from the real `activity.db` and the fixture's project DB.
|
||||
The script restores only its owned test file's permissions to `0600` in a
|
||||
`finally` block. It deliberately leaves the clearly named test project and
|
||||
fixture available for inspection.
|
||||
|
||||
## Observed failure and correction
|
||||
|
||||
The first two harness attempts exposed harness setup omissions: `index_status`
|
||||
defaults to a human-readable format, so the script now explicitly requests
|
||||
`format: "json"`, as the production client does; and a fresh browser must close
|
||||
the welcome dialog before clicking the workspace tabs. Those attempts are
|
||||
preserved separately and are not claimed as successful UI tests.
|
||||
|
||||
The third attempt exposed a real application bug. With `unreadable.c` at mode
|
||||
`000`, SQLite event 747 recorded an `index.worker` error containing
|
||||
`semantic_manifest.err` and the inaccessible file path. The following supervisor
|
||||
result reported `clean`, and event 749 incorrectly recorded `ui.index.done
|
||||
rc=ok`; `/api/index-status` returned `done`.
|
||||
|
||||
The coordinator had treated a non-null response from a cleanly exited worker
|
||||
as success, ignoring the valid MCP result's `isError: true`. The pipeline itself
|
||||
had correctly reported an aborted refresh that preserved the previous database.
|
||||
The coordinator now reads the MCP success/error result, and a regression
|
||||
test covers clean worker exits carrying aborted, failed and malformed results. The strict end-to-end job assertion remains in the script.
|
||||
The original contradictory log rows are retained in
|
||||
`graph-ui/verification/pr-2068/errors-e2e-attempt3.json`.
|
||||
|
||||
## Final integrated run
|
||||
|
||||
The coordinated C/UI rerun completed on 2026-09-09 at 19:52:04 UTC. Both real
|
||||
permission-failure reindexes returned `error`. Persisted completion events 2265
|
||||
and 2285 appeared in System and Galaxy respectively while each view was already
|
||||
open, without a page reload. System also displayed the failed index task. The
|
||||
compact priority feed measured 128.5 px at a 1560 × 1100 viewport.
|
||||
|
||||
The browser recorded 32 same-daemon-origin requests, no external attempts and no
|
||||
JavaScript page errors. Merely opening the diagnosis produced no report and no
|
||||
requests. The explicit diagnosis action used local coverage RPCs; the callback
|
||||
refreshed the shared project reading. Editing and downloading preserved exactly
|
||||
the edited text. Read-only SQLite queries matched the displayed event IDs and
|
||||
the two actual coverage rows. Four real screenshots and their SHA-256 hashes
|
||||
are recorded in `graph-ui/verification/pr-2068/errors-e2e.json`.
|
||||
|
||||
Visual inspection found one remaining evidence loss: the existing shared
|
||||
coverage parser discarded structured `ranges` from `check_index_coverage`,
|
||||
causing the partial parse to show no recorded reason despite source line 2 being
|
||||
known. The shared parser now preserves validated range endpoints as `Source
|
||||
lines: 2-2`, without replacing an existing detail. The focused shared-parser and
|
||||
diagnosis suites passed 54 tests. The final embedded-UI rerun passed the explicit source-range assertion. The
|
||||
diagnosis view and the local report both show `Source lines: 2-2`. All four
|
||||
current screenshot hashes were independently verified after capture.
|
||||
|
||||
The preserved fixture index generation is `2026-09-09T19:29:28Z`; this is the
|
||||
previous successful index, not the time of either failed refresh. The diagnosis
|
||||
was checked at `2026-09-09T19:52:03.390Z`. The original graph still contains
|
||||
10 nodes and 9 relationships.
|
||||
|
||||
| Actual browser capture | Verified behavior |
|
||||
| --- | --- |
|
||||
| [System](../../graph-ui/verification/pr-2068/errors-after-system.png) | Live red error with event ID and a failed index job |
|
||||
| [Galaxy](../../graph-ui/verification/pr-2068/errors-after-galaxy.png) | Same daemon error feed while the graph remains navigable |
|
||||
| [Local diagnosis](../../graph-ui/verification/pr-2068/errors-local-diagnosis.png) | Actual parser source range and explicit exclusion rule |
|
||||
| [Editable local report](../../graph-ui/verification/pr-2068/errors-local-report.png) | Reviewable local draft; copy and download controls |
|
||||
|
||||
[Machine-readable evidence](../../graph-ui/verification/pr-2068/errors-e2e.json)
|
||||
contains the requests, API results, exact SQLite rows, screenshot hashes and
|
||||
post-run row comparisons. No further source changes followed this final run.
|
||||
|
||||
## Interpretation boundaries
|
||||
|
||||
An unreadable input is rejected while building the semantic manifest, before
|
||||
new coverage is published. The old graph and its old coverage are intentionally
|
||||
preserved. Therefore the permission error is evidenced by the worker/job logs;
|
||||
the `parse_partial` and `.cbmignore` rows describe the preceding successful
|
||||
index. They must not be relabeled as a new `read failed` coverage row.
|
||||
|
||||
The existing path freshness check compares size and modification time. A
|
||||
permission-only change may still report `metadata_match`; that is not a
|
||||
content-hash or readability guarantee. The diagnosis UI states this limit and
|
||||
keeps generation/recording metadata visible. Coverage counts describe recorded
|
||||
paths, including directories, and do not establish a complete file denominator.
|
||||
|
||||
Local report generation, editing and download are tested here. No issue,
|
||||
external report, telemetry submission or account is created. A local report is
|
||||
not authorization to publish its contents.
|
||||
@@ -1,117 +0,0 @@
|
||||
# PR 2068: Korrekturen nach Prüfung der Fehleranzeige
|
||||
|
||||
Der spätere [Produkt-Audit](pr-2068-product-audit.md) ergänzt diesen Bericht:
|
||||
Die Projekt-/Historienkorrektur ist implementiert, aber der bestehende
|
||||
System-Logs-Filter und weitere Nutzerabläufe bleiben unvollständig.
|
||||
|
||||
Stand: 9. September 2026, Browsernachweise bis 20:16:25 UTC, abschließender
|
||||
vollständiger Unit-Lauf um 20:17:59 UTC. Dieser Nachtrag
|
||||
ergänzt den [ursprünglichen Implementierungsbericht](pr-2068-delivery.md).
|
||||
Alle Prüfungen verwenden den lokalen Daemon auf `127.0.0.1:9749`.
|
||||
|
||||
## Implementiert
|
||||
|
||||
**Projektzuordnung und Historie.** `activity.db` migriert additiv auf
|
||||
`user_version=3`: `daemon_logs.project` und ein Projekt-/Schweregrad-/ID-Index
|
||||
ermöglichen Filterung vor der Mengenbegrenzung. Bestehende Einträge behalten
|
||||
ihre IDs und bleiben ohne nachträglich erratene Projektzuordnung erhalten.
|
||||
`GET /api/logs?project=…` liefert ausschließlich diesem Projekt zugeordnete
|
||||
Zeilen; der Daemon-Verlauf und `scope=unattributed` bleiben ausdrücklich
|
||||
abrufbar. Die vorhandene begrenzte Aufbewahrung bleibt wirksam.
|
||||
|
||||
Worker und Indexierungsabschluss liefern die Projektzuordnung an der Quelle.
|
||||
Das Frontend erfasst sie pro Logeintrag vor Pufferung und Wiederholungen;
|
||||
RPC-/API-Fehler behalten die Zuordnung ihrer Anfrage. Eine explizite Zuordnung
|
||||
im Eintrag hat Vorrang vor dem Batch. Ein explizit leerer Wert bleibt unbekannt
|
||||
und wird nicht dem gerade offenen Projekt zugewiesen. Die Anwendung meldet
|
||||
auch ein automatisch ausgewähltes Projekt an den Logproduzenten, wenn kein
|
||||
Projektparameter in der URL steht.
|
||||
|
||||
Die gemeinsame Ereignisleiste startet eingeklappt mit dem aktuellen Projekt.
|
||||
Historische Fehler werden nicht als neu aufgetretene Frontendfehler angezeigt.
|
||||
`History` öffnet die vorhandenen Einträge; `All daemon history` wechselt zum
|
||||
aufgeklappten Verlauf aller Projekte mit sichtbarer Herkunft beziehungsweise
|
||||
fehlender Zuordnung. `This project only` kehrt zur eingeklappten Projektsicht
|
||||
zurück. Ein neu empfangener Projektfehler erscheint weiterhin unmittelbar rot,
|
||||
mit ERROR, Zeitpunkt, Quelle und Ereignis-ID. `Acknowledge new events` entfernt
|
||||
den Hinweis auf neue Ereignisse, löscht aber weder Historie noch Fehlerursache.
|
||||
Beim Wechsel der Datenquelle werden alte Ergebnisse nicht als Daten des neuen
|
||||
Projekts übernommen; fehlgeschlagene Aktualisierungen behalten nur die eigene
|
||||
letzte Abfrage.
|
||||
|
||||
**Vollständigkeit der Diagnose.** Die gemeinsame Coverage-Quelle verarbeitet
|
||||
die tatsächliche Pagination von `check_index_coverage`, einschließlich der
|
||||
Fortsetzung im Antwort-Envelope. Ein vollständig gelesener Wurzelbereich ersetzt
|
||||
die abgekürzten Listen von `index_status`; Duplikate, fehlende Zeilen,
|
||||
Generationswechsel und erreichte Grenzen bleiben ausdrücklich erkennbar.
|
||||
Für `cbm-pr2068` sind **281 erfasste Einträge geladen: 85 teilweise geparste
|
||||
Pfade und 196 bewusste Ausschlüsse**. Die unzutreffende Warnung vor einer
|
||||
unvollständigen Liste entfällt. Parserlücken und Ausschlüsse bleiben sichtbar;
|
||||
eine vollständige Liste bescheinigt keine vollständige Repository-Indexierung
|
||||
und ein partieller Parse keinen Programmfehler. Die
|
||||
[separate Untersuchung](pr-2068-coverage-investigation.md) enthält konkrete
|
||||
Quellbeispiele und die erfolgreiche echte Drei-Seiten-Abfrage.
|
||||
|
||||
Es wurden keine Fehlerlogs gelöscht, Diagnoseinhalte übertragen, Issues erstellt
|
||||
oder zusätzlichen Listener für diese Funktionen eingeführt. Ein frischer
|
||||
Browserlauf mit Workspace-Wechseln und zwei Reloads reproduzierte keinen
|
||||
Frontendabsturz und keinen fehlgeschlagenen Request. Vorhandene
|
||||
`Failed to fetch`-Einträge werden weiterhin als tatsächliche Anfragefehler
|
||||
gespeichert; aus der Nachricht allein lässt sich ihre Ursache nicht sicher
|
||||
ableiten. Es wurde keine pauschale Unterdrückung von Verbindungsfehlern ergänzt.
|
||||
|
||||
## Tatsächlich ausgeführte Prüfungen
|
||||
|
||||
| Prüfung | Ergebnis und Nachweis |
|
||||
| --- | --- |
|
||||
| Abschließender vollständiger Frontend-Unit-Lauf | **2.388 bestanden, 166 Dateien**, einschließlich des letzten Race-Regressionsfalls. [Finales Protokoll](../../graph-ui/verification/pr-2068/feedback-unit-final-tests.log.gz). Der vorherige vollständige Lauf mit [2.387 Fällen](../../graph-ui/verification/pr-2068/feedback-unit-tests.log.gz) bleibt separat erhalten. |
|
||||
| Zusätzlicher gezielter Ereignisleisten-Lauf | **8 bestanden**, einschließlich verspäteter A-Antwort nach Wechsel zu B und anschließend fehlgeschlagener B-Aktualisierung. [Protokoll](../../graph-ui/verification/pr-2068/feedback-alert-final-tests.log.gz) |
|
||||
| Native Scope-/Worker-Regressionen | **139 bestanden, 1 vorhandener Windows-Plattform-Skip**, ASan/UBSan; Suites `httpd`, `daemon_application`, `index_supervisor`. [Protokoll](../../graph-ui/verification/pr-2068/feedback-native-scope-tests.log.gz) |
|
||||
| Frontend-Vertragstests | **203 bestanden, 0 fehlgeschlagen**. [Protokoll](../../graph-ui/verification/pr-2068/feedback-acceptance-tests.log.gz) |
|
||||
| Builds und ergänzende Gates | TypeScript-Prüfung, Style-/Promise-Gates, [Frontend-Build](../../graph-ui/verification/pr-2068/feedback-ui-build.log.gz) und [eingebetteter nativer Build](../../graph-ui/verification/pr-2068/feedback-embed-build.log.gz) erfolgreich. Die bestehende Vite-Warnung zum großen Hauptbundle bleibt. |
|
||||
| Zwei echte Projektfenster | Neuer ausdrücklich synthetischer Fehler **#4125** nur im Fixture-Projekt; Gegenprobe mit widersprechendem Batch-Projekt und explizit unbekannter Zuordnung. Quittierung behält denselben Datensatz, Reload macht ihn nicht erneut zum neuen Alarm. Alter unattributierter Fixture-Fehler **#747** bleibt erhalten. Null Page-Errors, Console-Errors, fehlgeschlagene oder externe Requests. [Browserbericht](../../graph-ui/verification/pr-2068/feedback-e2e.json) |
|
||||
| Tatsächlicher Indexierungsfehler | Markiertes Fixture-Projekt, `unreadable.c` mit `chmod 000`, echtes `EACCES`, jeweils neuer fehlgeschlagener Indexierungsauftrag. **#4391** erschien live in Systems, **#4412** in Galaxy, beide korrekt `project=pr2068-error-fixture`. Keine Seitenneuladung. [Browser-, HTTP- und SQLite-Nachweis](../../graph-ui/verification/pr-2068/errors-e2e.json) |
|
||||
| Bewusste lokale Diagnose | Öffnen erzeugt keinen Bericht. Erst `Run local diagnosis` erstellt den bearbeitbaren lokalen Text; echte Parserzeile `2-2` bleibt erhalten, Download enthält exakt den zuvor redigierten Text. Keine externen Requests. Fixture-Dateirechte anschließend nachweislich wieder `0600`. |
|
||||
|
||||
Der erste Feedback-Testlauf enthielt einen zu breiten Vergleich seines eigenen
|
||||
Sessionmarkers mit vollständigen Log-JSONs: Eine korrekt zugeordnete normale
|
||||
Warnung enthielt denselben Marker in ihrer Audit-URL. Der Test vergleicht jetzt
|
||||
die vollständigen synthetischen Ereignismeldungen. Der erste Lauf ist mit dieser
|
||||
Harness-Korrektur [separat dokumentiert](../../graph-ui/verification/pr-2068/feedback-e2e-first-run.json).
|
||||
Die Produktzuordnung war dabei bereits korrekt.
|
||||
|
||||
Die ursprünglichen Fehler-Testberichte und Screenshots wurden vor dem erneuten
|
||||
Lauf unverändert gesichert und unter
|
||||
`graph-ui/verification/pr-2068/before-feedback-errors/` aufbewahrt. Die
|
||||
folgenden Links zeigen den erneut geprüften Stand. Die Ereignisse aus beiden
|
||||
Regressionen bleiben bewusst als gekennzeichnete Fixture-Historie erhalten.
|
||||
|
||||
## Echte Screenshots des geprüften Stands
|
||||
|
||||
- [Projektsicht ohne historische Fremdalarme](../../graph-ui/verification/pr-2068/feedback-after-project-events.png)
|
||||
- [281 erfasste Coverage-Einträge vollständig geladen](../../graph-ui/verification/pr-2068/feedback-after-coverage.png)
|
||||
- [Daemon-Historie mit alten, nicht zugeordneten Ereignissen](../../graph-ui/verification/pr-2068/feedback-after-daemon-history.png)
|
||||
- [Neuer Fehler im Fixture-Projekt](../../graph-ui/verification/pr-2068/feedback-after-fixture-live-error.png)
|
||||
- [Anderes Projekt bleibt ohne diesen Alarm](../../graph-ui/verification/pr-2068/feedback-after-other-project-unaffected.png)
|
||||
- [Quittiertes Ereignis bleibt in der Historie](../../graph-ui/verification/pr-2068/feedback-after-acknowledged-history.png)
|
||||
- [Echter Indexierungsfehler live in Systems](../../graph-ui/verification/pr-2068/errors-after-system.png)
|
||||
- [Echter Indexierungsfehler live in Galaxy](../../graph-ui/verification/pr-2068/errors-after-galaxy.png)
|
||||
|
||||
Reproduzierbar mit `node tools/pr-2068-feedback-e2e.mjs` und anschließend
|
||||
`node tools/pr-2068-errors-e2e.mjs` aus `graph-ui`. Beide erwarten den bestehenden
|
||||
isolierten PR-Daemon; sie starten keinen Server. Der zweite Lauf verändert nur
|
||||
das eindeutig markierte Testverzeichnis und stellt Dateirechte im `finally`
|
||||
wieder her. Historien sind begrenzt, die realen Coverage-Zahlen beziehen sich
|
||||
auf den dokumentierten Indexstand; spätere Reindexierungen oder abgelaufene
|
||||
Fixture-Einträge erfordern entsprechend aktualisierte Testvorbedingungen.
|
||||
|
||||
## Vergleich mit dem ursprünglichen Frontend
|
||||
|
||||
Auf Nutzerwunsch läuft das unveränderte Frontend aus Commit
|
||||
`80f4f41f9bcb9daf60afc5f607bcbcba24a4b196` separat auf
|
||||
<http://127.0.0.1:9751/?project=cbm-pr2068>. Die aktuelle eingebettete Anwendung
|
||||
bleibt auf <http://127.0.0.1:9749/?project=cbm-pr2068>. Die Vergleichsvorschau
|
||||
verwendet einen isolierten Git-Export und denselben aktuellen Daemon/Index.
|
||||
Sie vergleicht also die Frontends anhand derselben Daten; sie ist keine
|
||||
Wiederherstellung des früheren Backend- oder Indexzustands. Der zusätzliche
|
||||
Loopback-Port dient ausschließlich der ausdrücklich gewünschten Vergleichsansicht.
|
||||
@@ -1,64 +0,0 @@
|
||||
# PR 2068: Abschlussbericht und Übergabe
|
||||
|
||||
Übergabe vorbereitet am 10. September 2026 (Europe/Berlin), ausschließlich auf `feat/codeatlas-web`. Der [englische Maintainer-Einstieg](pr-2068-maintainer-handoff.md) fasst Umsetzung, Übernahme und offene Arbeiten zusammen. Die folgenden Browserbefunde stammen aus der zweiten Reparaturrunde vom 9. September; die Abschlussprüfung nach der erneuten Anmeldung ist separat belegt.
|
||||
|
||||
Stand: 9. September 2026, nach dem kritischen [Produkt-Audit](pr-2068-product-audit.md) und den anschließenden Reparaturen. Der damalige Eindruck, dass sich im normalen Bedienablauf zu wenig verbessert hatte, war berechtigt. Einzelne neue APIs und Widgets waren vorhanden, aber wesentliche Einstiege waren kaputt oder unzusammenhängend. Dieser Bericht beschreibt den anschließend tatsächlich implementierten und im Browser geprüften Stand.
|
||||
|
||||
## Vergleichsaufnahmen und lokaler Einstieg
|
||||
|
||||
- [Aktuelle Architecture-Übersicht auf 9749](http://127.0.0.1:9749/?project=cbm-pr2068&workspace=architecture).
|
||||
- Originalfrontend des aufgezeichneten Vergleichslaufs auf Port 9751, Revision `80f4f41f9bcb9daf60afc5f607bcbcba24a4b196`. Beide Frontends verwendeten denselben Daemon/Index. Der Vergleich bildet keine historische Backend-Installation ab. Port 9751 war ausschließlich ein lokaler Prüfserver; die Screenshots bleiben unabhängig von seiner Laufzeit nachvollziehbar.
|
||||
- [Vorher](../../graph-ui/verification/pr-2068/final-before-architecture.png) und [nachher](../../graph-ui/verification/pr-2068/final-after-architecture.png), frisch mit Playwright bei 1440×1000 aufgenommen. [Vergleichsprotokoll](../../graph-ui/verification/pr-2068/final-comparison.json).
|
||||
|
||||
Entwicklung ausschließlich auf `feat/codeatlas-web`, PR-Basis `feat/atlas-r1`; bestehende Änderungen bleiben erhalten. Kein Branchwechsel oder Zurücksetzen. Eingebettetes Frontend der geprüften Reparaturrunde: `index-nfAv5dSK.js`; derselbe Asset-Hash entstand beim erneuten Abschlussbuild. Der abschließende reale Indexlauf dieser Runde endete ohne Fehler: 42.649 gespeicherte Knoten, 177.320 Kanten, Generation `u08b7127147560895g2`. Diese Gesamtzahlen sind nicht gleich dem begrenzten Ausschnitt der Karte oder dem Dateibaum. Später erzeugte Testberichte sind keine Behauptung über diesen Indexzeitpunkt.
|
||||
|
||||
## Was jetzt konkret funktioniert
|
||||
|
||||
| Entwicklerfrage | Implementierter Ablauf und überprüfbare Evidenz |
|
||||
| --- | --- |
|
||||
| Was macht das Repository, wo beginne ich? | Die Architecture-Übersicht liest die Autorenbeschreibung aus README und Beschreibungen aus tatsächlichen Dateiheadern. Bereiche zeigen belegte Abhängigkeiten und ausdrücklich abgeleitete Grenzen. Einstiegskandidaten sind nach Application, Frontend, Tests und Tools unterscheidbar. Aus einem Einstieg lassen sich begrenzte statische CALLS-Pfade öffnen. |
|
||||
| Worauf beruht eine Architekturbeziehung? | Eine Beziehung führt unmittelbar zu ihren Endpunkten, Kanten-IDs, vorhandenen Auflösungsmetadaten und gültigen Quellpositionen. Der Quellen-Drawer bewahrt Karte oder Änderungsanalyse. Ungültige Indexzeilen werden als unbestätigt markiert, nicht als korrekte Aufrufstellen präsentiert. |
|
||||
| Warum ist diese Auswahl relevant? | Selection context zeigt konkrete eingehende/ausgehende Beziehungen und vorhandene Einstiegspfade. Aufgezeichnete Agentenaktivität ist separat, mit ehrlichem Leerzustand. Der nächste Schritt führt zur Quelle oder in die gemeinsame Änderungsanalyse. |
|
||||
| Was könnte meine Änderung betreffen? | Ein gemeinsamer Change-impact-Dialog für aktuelle Auswahl, Working Tree und Since ref. Echte geänderte Dateien lassen sich auf Symbole eingrenzen. Strukturelle Abhängigkeiten, Testkandidaten, historische Co-Changes und Datenqualität sind getrennt. Pfade, Quellstellen, Commit-IDs und kopierbare lokale Git-Prüfkommandos machen Befunde überprüfbar. |
|
||||
| Sind Fehler oder Lücken tatsächlich erfasst? | System filtert projektbezogene SQLite-Logs vor dem Zeilenlimit. System und Galaxy erhalten echte Fehler live. Die ruhige Kopfzeile unterscheidet Historie von aktueller Störung. Die vorhandene Coverage-Diagnose zeigt partielle Parses, Ausschlüsse und weitere erfasste Gründe; ein bearbeitbarer Bericht entsteht erst nach ausdrücklicher lokaler Aktion. |
|
||||
| Woher kommen Agenteninformationen? | Sichtbares lokales Hook-Setup, Download und prüfbares Installationskommando. Ereignisse laufen über denselben Daemon-Port in SQLite. Die Anzeige unterscheidet verbundenes History-Polling vom Zeitpunkt des letzten Werkzeugaufrufs und behauptet keine aktuelle Agentenarbeit aus alten Ereignissen. |
|
||||
|
||||
Zusätzlich repariert: `rows_refs`-Dekompression und paginierte RPC-Ergebnisse, exakte Dateipfadsuche einschließlich sofortigem Enter und verspäteter Antworten, Quellenauflösung ohne zuvor geladene Karte, EOF-nahe Quellseiten, überfüllter Impact-Inspector, fehlgeschlagener Working-tree-Einstieg und abgebrochene große Mapantworten.
|
||||
|
||||
## Technische Grundlage
|
||||
|
||||
Der bestehende Daemon bedient eingebettetes Frontend, RPC, Repository-Karte, Impact, Logs und Agentenereignisse auf Port 9749. MCP-Transporte bleiben erhalten. SQLite-Activity-Schema 4 nutzt WAL, begrenzte Speicherung, Cursor und dauerhafte Sequenz-Deduplizierung auch nach Payload-Retention. Alle UI-Logs lesen dieselbe SQLite-Quelle; JSONL bleibt ein lokaler Export mit begrenztem einmaligem Altimport. Es wurde kein zweiter Produktlistener oder notwendiger Bridge-Prozess eingeführt.
|
||||
|
||||
Browser aktualisieren über begrenztes Polling auf demselben Port. Git-/Graphanalysen haben Arbeitsgrenzen und einen kurzen Cache; Git-Co-Changes werden nicht als persistierte zweite Graphwahrheit geführt. Repository-Karten über 1 MiB erhalten gezielt eine begrenzte Sendezeit von fünf Sekunden; gewöhnliche HTTP-Antworten behalten ihre bisherigen Grenzen. Keine neuen npm-Pakete oder Frameworks wurden eingeführt.
|
||||
|
||||
## Tatsächlich ausgeführte Prüfung
|
||||
|
||||
- Erneute Abschlussprüfung: [2473 Unit-Tests in 175 Dateien](../../graph-ui/verification/pr-2068/handoff-unit.log.gz), [203 Acceptance-Checks](../../graph-ui/verification/pr-2068/handoff-acceptance.log.gz), [Produktionsbuild](../../graph-ui/verification/pr-2068/handoff-build.log.gz), [Style-Gate](../../graph-ui/verification/pr-2068/handoff-style.log.gz), [Promise-Gate](../../graph-ui/verification/pr-2068/handoff-promises.log.gz), [fünf Operational-Reference-Tests](../../graph-ui/verification/pr-2068/handoff-client-gate.log.gz) und [sieben lokale Hook-Tests](../../graph-ui/verification/pr-2068/handoff-hook.log.gz) bestanden. Ein nachträglich umbenannter Hook-Download war im Gate noch unter dem alten Dateinamen hinterlegt; die exakte Zuordnung und Bytegleichheit sind jetzt geprüft. Ein wortgetreuer Diagnose-Download ist als JSON-Zitat erhalten. Der ältere fehlgeschlagene Style-Lauf bleibt als historisches Protokoll erhalten und wird nicht als grün ausgegeben.
|
||||
- Originalprotokolle sind platzsparend als `.log.gz` beigelegt; [Archivmanifest mit Prüfsummen](../../graph-ui/verification/pr-2068/log-archives.json). Dekomprimieren liefert die unveränderten Originalbytes. Screenshots und JSON-Ablaufprotokolle bleiben direkt lesbar.
|
||||
- Nach der erneuten Anmeldung wurde der lokale Prüfdaemon wieder gestartet und der vollständige Kartenablauf nochmals bestanden: [separater Browserlauf zur Übergabe](../../graph-ui/verification/pr-2068/handoff-recheck/repair-map-e2e.json), sechs Aufnahmen, keine Browserausnahmen oder externen Requests. Frühere Browserprotokolle wurden dabei nicht überschrieben.
|
||||
- Native HTTP-/Journal-/Transporttests: [83 bestanden, ein bestehender Windows-only-Skip](../../graph-ui/verification/pr-2068/native-log-transport-repair.log.gz), unter ASan/UBSan. Native UI-/Map-/Impacttests: [40 bestanden](../../graph-ui/verification/pr-2068/native-map-resolution-repair.log.gz). Das ist die gezielte finale native Prüfung, keine Behauptung über einen erneut vollständig ausgeführten Gesamttestlauf.
|
||||
- Der globale C-Lint ist weiterhin **nicht grün**: `cppcheck` fehlt im lokalen Setup, clang-tidy meldet umfangreiche Befunde; auch die neuen UI-C-Dateien erfüllen nicht sämtliche zusätzlichen Komplexitäts-/Stilregeln. Der [frühere vollständige Lintlauf](../../graph-ui/verification/pr-2068/native-lint.log.gz) und die [Abgrenzung der fokussierten Korrektheitsprüfungen](pr-2068-delivery.md) bleiben gültige Einschränkungen. Ein erfolgreicher Build und die gezielten nativen Tests ersetzen keinen grünen globalen Lint-Gate.
|
||||
- [Karte](../../graph-ui/verification/pr-2068/repair-map-e2e.json): frische Sitzung, Projektbeschreibung, `src/main.c`, echter Pfad, sofort sichtbare Kanten, `project_lock.c:61` tatsächlich geladen und markiert, Rückkehr, Daemonbereich, Symbol und gemeinsamer Impact. [Quelle neben Karte](../../graph-ui/verification/pr-2068/repair-source-beside-map.png).
|
||||
- [Suche und Explore](../../graph-ui/verification/pr-2068/explore-context-e2e.json): 144 Funktionen und acht Klassen in `application.c`; tatsächlicher Monaco-Quelltext, Auswahlkontext und derselbe Impact-Dialog. Sofortiges Enter öffnet keine Ersatzdatei. Eine unveränderte echte ältere RPC-Antwort, 26ms nach Pfadwechsel freigegeben, überschreibt die aktuelle Suche nicht. Ein tatsächlicher HTTP-Abbruch wird daraus nicht behauptet.
|
||||
- [Änderungsanalyse](../../graph-ui/verification/pr-2068/change-analysis-e2e.json): 325/325 Worktreepfade und 339/339 Pfade seit `HEAD~1` im aufgezeichneten Lauf. Für das reale Symbol `application_project_lock_release_fully`: 17 strukturell erreichbare Betroffene und sieben Testkandidaten, prüfbare Pfade, Testquelle, Git-Evidenz, Datenqualität und Fokuswiederherstellung. [1024×800](../../graph-ui/verification/pr-2068/change-analysis-after-1024.png) ohne horizontale Überläufe.
|
||||
- [Echte Indexfehler](../../graph-ui/verification/pr-2068/repair-errors-e2e.json): ausdrücklich markierte lokale EACCES-Testfälle wurden persistiert und erschienen ohne Reload in System und Galaxy. System-Filter stimmten mit SQLite überein: 57/57 Fehler, 11/11 Projektfehler und 3/3 Texttreffer zum dokumentierten Zeitpunkt. Die Dateiberechtigungen wurden wiederhergestellt.
|
||||
- [Lokaler Hook-Prozess](../../graph-ui/verification/pr-2068/agent-setup-integration.json): echter Python-Aufruf in temporärer Konfiguration, explizites TEST-Ereignis, Retry erzeugt keine zweite Zeile. Keine LLM-Sitzung. [Nach Neustart und bei Browser-Reconnect](../../graph-ui/verification/pr-2068/final-agent-diagnosis-restart.json) bleibt das Ereignis erhalten; Offline/Online stellt dieselben Zeilen ohne Reload oder Duplikate wieder her. Der Browser war beim echten Daemon-Neustart noch nicht verbunden; diese zwei Nachweise werden getrennt ausgewiesen.
|
||||
- Derselbe finale Browserlauf prüft Setup-Download, unveränderte persönliche Konfiguration, bewusst gestartete Diagnose und ausschließlich lokal heruntergeladenen bearbeiteten Bericht. Keine externen Requests oder Bridge-Anfragen. Die geprüften finalen Produktabläufe meldeten keine Browserausnahmen.
|
||||
- [Responsive Kartenprüfung](../../graph-ui/verification/pr-2068/final-comparison.json): 1024×800, Quellen-Drawer vollständig im Fenster, Escape kehrt zur Karte zurück; direkter Architecture-Link überschreibt eine zuvor gespeicherte Explore-Auswahl.
|
||||
|
||||
Zwischenzeitliche Fehlläufe wurden nicht als bestanden umetikettiert: etwa [ungültige Callsite](../../graph-ui/verification/pr-2068/repair-map-callsite-failure.json) und [EOF-Fehler](../../graph-ui/verification/pr-2068/repair-map-eof-failure.json). Die abschließenden erfolgreichen Läufe liegen separat vor.
|
||||
|
||||
## Recherche und verbleibende Grenzen
|
||||
|
||||
Der [quellenbezogene Rechercheabgleich](pr-2068-final-review.md) nennt tatsächliche Browserinteraktionen, Quellcodeprüfungen, Lizenzen und Integrationsentscheidungen. Archify und ein Graphify-Beispiel wurden mit Diagrammdaten bedient; GitNexus und das bestätigte CodeGraph überwiegend am Frontend-/API-Code untersucht. Übertragen wurden Einstiegshierarchie, ausgewählte Pfade, Quellenlesen bei erhaltener Ansicht und getrennte historische Evidenz. Keine Fremdviewer, zusätzlichen Datenbankstacks oder fremden Quelltexte wurden integriert.
|
||||
|
||||
Die Grenzen bleiben sichtbar und sind keine gelieferten Zukunftsfeatures:
|
||||
|
||||
1. Eine nach Quellpfaden gegliederte, mit Autorenzitaten erklärte Karte ist keine automatisch validierte Domänenarchitektur. Ein Header beschreibt seine Datei und belegt nicht die gesamte Verantwortung seines Bereichs. Einstiegserkennung und Rollen bleiben Heuristiken. Ein Erstnutzer-Onboarding-Test wurde nicht durchgeführt; vollständige Erfüllung dieses qualitativen Abnahmekriteriums wird deshalb nicht behauptet.
|
||||
2. Statische CALLS-Pfade zeigen mögliche Erreichbarkeit, keine Laufzeitausführung oder Datenflüsse. Ein bestehender C-Präprozessor-/Indexkoordinatenfehler kann falsche Kantenzeilen liefern. Die UI kennzeichnet nachweisbar ungültige Zeilen; die zugrunde liegende Parserursache wurde nicht repariert.
|
||||
3. Der aktuelle Index enthält 85 partielle Parse-Pfade und 300 absichtlich ausgeschlossene Pfadrecords. Diese Zählung enthält auch Ordner und ist keine Prozent-Coverage. Nicht entdeckte Eingaben können fehlen. Diagnose macht diese Grenzen sichtbar, behebt jedoch keine Grammatik- oder Ausschlussregeln automatisch.
|
||||
4. Diff-Hunks werden nicht automatisch semantisch Symbolen zugeordnet; die Eingrenzung erfolgt sichtbar manuell. Historie ist begrenzt, kann dünn sein und bezieht sich auf committed Versionen. Indexrevision und Worktreegleichheit können unbekannt bleiben. Ein heuristischer Risikohinweis ist keine kalibrierte Defektwahrscheinlichkeit.
|
||||
5. Das produktive Agent-Setup muss der Nutzer bewusst aktivieren. Nachgewiesen sind der echte lokale Hook-Prozess, Persistenz und Anzeige; es wurde keine produktive Agenten-/LLM-Sitzung zum Test gestartet. Diagnose und Veröffentlichung bleiben getrennt, ohne automatische externe Übermittlung.
|
||||
6. Globale C-Lint-Bereinigung und die vollständige plattformübergreifende PR-/Release-Prüfung sind weiterhin offen. Die hier bestandenen lokalen Prüfungen werden nicht als allgemeine Merge- oder Releasefreigabe ausgegeben.
|
||||
|
||||
Zur manuellen Prüfung: den Daemon mit eingebettetem Frontend starten, das Repository indexieren, Architecture öffnen, `main` aus `src/main.c` wählen, den statischen Pfad und seine Quelle öffnen. Danach Search → `src/daemon/application.c` → ein Symbol → Change impact → Working tree. System/Agents und `Inspect index coverage` ergänzen denselben lokalen Datenstand. Start- und Prüfkommandos stehen im [Maintainer-Einstieg](pr-2068-maintainer-handoff.md); lokale URLs und temporäre Verzeichnisse aus den Originalprotokollen sind keine Voraussetzung für die Codeübernahme.
|
||||
@@ -1,38 +0,0 @@
|
||||
# PR 2068: abschließender Recherche- und Produktabgleich
|
||||
|
||||
Stand: 9. September 2026, nach den Korrekturen des Produkt-Audits. Dieser Bericht ergänzt den [früheren kritischen Audit](pr-2068-research-audit.md); dessen damalige Defizite und fehlgeschlagene Browserläufe bleiben als Verlauf erhalten. Geprüft wurden der aktuelle Implementierungscode, die lokal vorhandenen Originalquellen und Lizenzen der [festgehaltenen Fremdrevisionen](pr-2068-research.md), echte Screenshots und Ablaufprotokolle. Dieser Arbeitsschritt ändert ausschließlich Dokumentation.
|
||||
|
||||
**Die Recherche hat inzwischen konkrete Interaktionen beeinflusst:** Einstieg und Projektbeschreibung, begrenzte Aufrufpfade, Quellenlesen bei erhaltener Ausgangsansicht sowie eine gemeinsame Änderungsanalyse. Es wurde kein Wettbewerber-Frontend integriert. Der frühere Anspruch, bereits eine vollständige Architektur-Onboarding-Lösung geliefert zu haben, war zu weitgehend; auch die jetzige Umsetzung bleibt an die tatsächlich erfasste Evidenz gebunden.
|
||||
|
||||
## Was untersucht und was übertragen wurde
|
||||
|
||||
| Projekt / Lizenz der geprüften Revision | Tatsächliche Untersuchung | Konkrete Übertragung und bewusste Grenze |
|
||||
| --- | --- | --- |
|
||||
| **[Archify](https://github.com/tt-a1i/archify)**, [MIT](https://github.com/tt-a1i/archify/blob/10722002bb8777ecb639d93c49586fae4adf3ae4/LICENSE) | **Browser mit Diagrammdaten:** Kapitel gewählt und Route von User zu Tool Call geöffnet. [Echter Route-Screenshot](../../graph-ui/verification/pr-2068/research-archify-route.png). Runtime-Vertrag zusätzlich gelesen. | [RepositoryMap](../../graph-ui/src/architecture/RepositoryMap.tsx) bietet nun Entry-Auswahl, sichtbare gerichtete Schrittfolgen und unmittelbare Kanten-Evidenz. [SourceEvidenceDrawer](../../graph-ui/src/architecture/SourceEvidenceDrawer.tsx) hält die Ausgangskarte beim Quellenlesen offen. Das übernimmt Orientierung und schrittweise Vertiefung. Keine Übernahme des Diagrammcompilers, seiner Renderarchitektur oder seiner autorenverfassten Beispielabläufe. CBM erzeugt begrenzte statische CALLS-Pfade, keine bestätigten Laufzeitabläufe. |
|
||||
| **[GitNexus](https://github.com/abhigyanpatwari/GitNexus)**, [PolyForm Noncommercial 1.0.0](https://github.com/abhigyanpatwari/GitNexus/blob/b60c21d05df6af6235ce3f61603ca86469fba8fc/LICENSE) | **Browser nur Einstieg:** wartete auf einen lokalen Fremdserver. Kein funktionierender Prozessgraph ausprobiert. Prozesse, Schritte und Fokus wurden im tatsächlichen `ProcessesPanel.tsx` gelesen. | Begrenzte, auswählbare Pfade dienen derselben Entwicklerfrage nach überschaubaren Zusammenhängen. Ein GitNexus-Prozessmodell oder entsprechender Galaxy-Teilgraphfokus wurde nicht implementiert. Keine Übernahme von Quellcode, Sigma/Graphology, LangChain oder zusätzlichem Datenbank-/Serverstack. Die nichtkommerzielle Lizenz wurde nicht als allgemeine Integrationsfreigabe behandelt. |
|
||||
| **[Graphify-Labs/graphify](https://github.com/Graphify-Labs/graphify)**, [Apache-2.0](https://github.com/Graphify-Labs/graphify/blob/3f82bf7f837a07fb0f7668fbdbd5662801906942/LICENSE), zusätzliche MIT-/NOTICE-Hinweise | **Browser mit Beispieldaten:** Datei gesucht und im Inspector geöffnet; Nachbarklick scheiterte im eingecheckten HTML-Beispiel. [Aufnahme](../../graph-ui/verification/pr-2068/research-graphify-example-selected.png). Der aktuelle Exporter wurde separat gelesen; sein Zustand wurde nicht mit dem alten Beispiel gleichgesetzt. | Kartenbereiche sind ausdrücklich heuristische Grenzen; Beziehungen nennen Typ, Endpunkte und vorhandene Kanten-/Quellenbelege. Graphbefunde, historische gemeinsame Änderungen und Agentenbeobachtungen bleiben getrennt. Keine vollständige per-Kante-Konfidenzdarstellung nachgebaut. Kein Python/NetworkX-/vis-network-Stack und kein CDN-Viewer übernommen. |
|
||||
| **[colbymchenry/codegraph](https://github.com/colbymchenry/codegraph)**, [MIT](https://github.com/colbymchenry/codegraph/blob/3ed73bc127323e63153bf6ec8354afa82ce36aaf/LICENSE) | **Quellcode:** `HomeView.svelte`, Entry-API und `SymbolView.svelte`. Öffentliches Dokumentationsportal geöffnet, aber kein Reader mit Graphdaten ausprobiert. Dies ist das vom Nutzer bestätigte CodeGraph-Projekt. | [RepositoryGuide](../../graph-ui/src/architecture/repository-guide.ts) unterscheidet Application, Frontend, Tests und Tools als erkennbare Heuristiken; Installer und Hooks erhalten nicht automatisch dieselbe Rolle wie Anwendungsstarts. README- und Dateiheader-Zitate führen zu konkreten Zeilen. Der Drawer bietet Quelle und Navigationskontext gemeinsam. Kein Svelte-/XYFlow-Import, kein nachgebautes Caller–Code–Callee-Kantenlayout. |
|
||||
| **[lzehrung/codegraph](https://github.com/lzehrung/codegraph)**, MIT; zusätzliche Quelle | **Nur Quellcode:** Synchronisierung von Baum, Graphfokus und Inspector sowie getrennte eingehende/ausgehende Referenzen. | Gemeinsame Auswahl in Explore, Architecture und Galaxy; derselbe Quellen-Drawer und dieselbe Änderungsanalyse aus unterschiedlichen Einstiegen. Kein zusätzlicher Viewer, Indexer oder Sigma-/Graphology-Renderer. |
|
||||
| **[optave/ops-codegraph-tool](https://github.com/optave/ops-codegraph-tool)**, Apache-2.0, und **[Code Maat](https://github.com/adamtornhill/code-maat)**, GPL-3.0; zusätzliche Quellen | **Nur Quellcode:** Co-Change-Paarbildung, Merge-/Changeset-Filter, Trennung struktureller und historischer Aussagen. Dokumentierte Mindestdaten wurden gelesen; kein eigener Fremd-CLI-Lauf. | [ChangeAnalysisWorkspace](../../graph-ui/src/impact/ChangeAnalysisWorkspace.tsx) verbindet Auswahl, Working Tree und Since ref mit getrennten Ansichten für Abhängigkeiten, Testkandidaten, Git-Historie und Datenqualität. [atlas_impact.c](../../src/ui/atlas_impact.c) begrenzt Graph-/Git-Arbeit, verwirft Merge- und übergroße Commits und erhält die Revision. Kein Clojure/JVM- oder zweiter Tree-sitter-/SQLite-Stack. Kein fremder Code und keine Defektwahrscheinlichkeit übernommen. Die Historienanalyse hat einen kurzen Speicher-Cache, keine persistierte Co-Change-Paartabelle. |
|
||||
|
||||
Die Lizenzen wurden an den untersuchten Revisionen gelesen; sie begründen keine Behauptung über zukünftige Lizenzänderungen oder die Sicherheit eines gesamten Abhängigkeitsbaums. Das Frontend-Paketmanifest und Lockfile enthalten gegenüber PR-HEAD keine neuen Abhängigkeiten für diese Funktionen. Die Entscheidungen beruhen auf beobachteten Interaktionen und technischer Passung, nicht auf Sternzahlen.
|
||||
|
||||
## Abgleich mit echten CBM-Abläufen
|
||||
|
||||
Der eigene frische Browserlauf öffnete ohne vorbereiteten Speicherzustand die Architecture-Übersicht und lud tatsächlich das README sowie zwölf weitere Quellzitate: [Screenshot](../../graph-ui/verification/pr-2068/repair-guide-review.png), [Metadaten und unveränderter Rohbeleg](../../graph-ui/verification/pr-2068/repair-guide-review.json). Er hatte keine Browser-/Konsolenfehler und keine externen Requests. Diese Aufnahme liegt vor den nachfolgenden Layout- und Zitatreihenfolgekorrekturen. Die README-Leistungsbehauptungen sind als Autorenbeschreibung zitiert; sie sind keine von diesem Auftrag gemessenen Benchmarks.
|
||||
|
||||
Zusätzlich wurden die folgenden tatsächlichen Teamläufe und ihre Screenshots ausgewertet:
|
||||
|
||||
- **Änderungsanalyse vollständig durchlaufen:** [Protokoll](../../graph-ui/verification/pr-2068/change-analysis-e2e.json), abgeschlossen 21:31:59 UTC. Frischer Einstieg über Working Tree, echte Datei und Symbolauswahl, strukturelle Pfade, Testquelle, Git-Befund, lokaler Prüfkommando-Entwurf, Datenqualität, Since ref sowie [1024×800-Darstellung](../../graph-ui/verification/pr-2068/change-analysis-after-1024.png). Das angezeigte Prüfkommando wurde nur kopiert und nicht durch die UI ausgeführt. Fehlende Testbeziehungen werden nicht als Sicherheit bewertet.
|
||||
- **Explore-Auswahl und gemeinsamer Analyse-Einstieg vollständig durchlaufen:** [Protokoll](../../graph-ui/verification/pr-2068/explore-context-e2e.json), abgeschlossen 21:46:08 UTC. Ein sofortiges Enter während der Vollpfadsuche öffnete keine Ersatzdatei. Eine unveränderte echte Antwort der vorherigen Anfrage nach `src/main.c` wurde absichtlich 26 Millisekunden nach dem Wechsel zu `src/daemon/application.c` freigegeben; sie überschrieb die neue Auswahl nicht. Anschließend wurden 144 Funktionen und acht Klassen, Reader, Auswahlkontext und der gemeinsame Impact-Einstieg geprüft. Keine Browserausnahmen und keine externen Requests wurden aufgezeichnet.
|
||||
- **Vollständigen Kartenablauf bestanden:** [Protokoll](../../graph-ui/verification/pr-2068/repair-map-e2e.json), abgeschlossen 21:45:13 UTC. Die sechs echten Aufnahmen zeigen [Übersicht](../../graph-ui/verification/pr-2068/repair-architecture-overview.png), [statischen Pfad](../../graph-ui/verification/pr-2068/repair-static-call-paths.png), [sofort sichtbare Kanten-Evidenz](../../graph-ui/verification/pr-2068/repair-call-path-evidence.png), [markierte Quelle neben der erhaltenen Karte](../../graph-ui/verification/pr-2068/repair-source-beside-map.png), [Bereichsbeschreibung](../../graph-ui/verification/pr-2068/repair-daemon-responsibilities.png) und [gemeinsamen Analyse-Einstieg](../../graph-ui/verification/pr-2068/repair-shared-analysis-entry.png). Keine Browserausnahmen und keine externen Requests wurden aufgezeichnet. Der frühere EOF-Fehllauf bleibt als [separates Protokoll](../../graph-ui/verification/pr-2068/repair-map-eof-failure.json) und [Screenshot](../../graph-ui/verification/pr-2068/repair-map-eof-failure.png) erhalten; [Ursache und Originalantwort](../../graph-ui/verification/pr-2068/repair-drawer-eof-api.json) dokumentieren die überprüfte Korrektur.
|
||||
|
||||
Der Quellen-Drawer löst Dateien bei Bedarf auch gezielt über Module-/File-Abfragen auf. Eine zuvor vollständig geladene Architekturkarte ist keine Voraussetzung mehr. Projekt-/Auswahlwechsel, Pagination, Quellenidentität, Zeilenzählung und Fokuswiederherstellung werden geprüft. Diese funktionalen Reparaturen sind notwendig, damit die recherchierte Interaktionsidee im normalen Arbeitsablauf überhaupt erreichbar ist; bloß vorhandene Komponenten hatten dafür nicht genügt.
|
||||
|
||||
## Ehrliche verbleibende Grenzen
|
||||
|
||||
Die Übersicht enthält jetzt belegte Beschreibungen und handlungsfähige Einstiegspfade. Ihre Bereiche bleiben jedoch nach Quellpfaden gruppiert. Ein Dateiheader belegt die beschriebene Datei, keine vollständige Domänenverantwortlichkeit des umgebenden Bereichs. Fehlende oder wenig hilfreiche Dokumentation wird nicht durch erfundene Architekturtexte ersetzt. Automatisch erkannte Einstiegskandidaten und ihre Rollen können weiterhin unpassend sein; sie sind keine nachgewiesene Startreihenfolge.
|
||||
|
||||
Archifys frei verfasste Kapitel, beliebige Start-/Zielrouten und eine vollständig benannte Domänenkarte wurden nicht nachgebaut. Die Kartenpfade sind auf fünf Aufrufe und 500 besuchte Symbole begrenzt; Importbeziehungen werden nicht zu Datenfluss erklärt. Die Impact-Ansicht gruppiert Änderungen nach Pfad, bewertet aber keine automatisch identifizierten semantischen Diff-Hunks. Ein manuell ausgewähltes Symbol wird als solche Eingrenzung kenntlich gemacht. Historische gemeinsame Änderungen beweisen weder funktionale Abhängigkeit noch spätere Defekte.
|
||||
|
||||
Es gab keinen Nutzertest mit neuen Projektentwicklern und keinen kontrollierten Vergleich derselben Aufgabe in mehreren Wettbewerbern. Daher wird weder eine gemessene Onboarding-Beschleunigung noch vollständige Erfüllung des Architect-Abnahmekriteriums allein aus Screenshots oder Testzahlen behauptet. Die bestandenen Browserläufe belegen die beschriebenen konkreten Abläufe, keine darüber hinausgehende Nutzerstudie.
|
||||
@@ -1,43 +0,0 @@
|
||||
# Product repair after browser audit
|
||||
|
||||
Repair verification completed on 9 September 2026 at 21:47 UTC. The checked items below are tested software flows, not a first-time developer usability study or a claim of complete semantic architecture inference. See `pr-2068-final-delivery.md` for current evidence and remaining limits. The execution notes below preserve intermediate findings chronologically, including failures that were subsequently repaired. The original frontend at port 9751 shares the current daemon/index; it is a frontend comparison, not a historical backend deployment.
|
||||
|
||||
Required manual flows before delivery:
|
||||
|
||||
- [x] Show repository purpose and source documentation for component responsibilities, with source links (author descriptions; source areas remain inferred).
|
||||
- [x] Distinguish product entry points from test/tool entry candidates and follow a real static path across components (role classification is heuristic).
|
||||
- [x] Open a relationship at its valid call site while keeping the map and selection in place; mark invalid sites unverified.
|
||||
- [x] Find an exact file path and load its actual functions/classes, including compact RPC responses; reject stale and inexact provisional results.
|
||||
- [x] Use one shared change-analysis workspace from map and Explore; analyze a selection and real working-tree changes.
|
||||
- [x] Follow structural paths, test candidates and Git evidence; inspect bounds, revision and uncertainty.
|
||||
- [x] Read the same persisted project errors in System and Galaxy; filter before limiting rows.
|
||||
- [x] Produce explicitly TEST-labelled local hook activity on the daemon port; verify persistence after an actual restart and browser offline/online recovery without duplicates. No real LLM session was launched.
|
||||
- [x] Diagnose index gaps deliberately, with no upload, report generation or issue creation without a separate explicit action.
|
||||
- [x] Verify keyboard navigation, 1024×800 and 1440×1000 viewports, honest missing-data states and no browser errors in these flows.
|
||||
|
||||
Implementation ownership: repository/source navigation and shared layout (root); RPC/search compatibility (research agent); unified impact/change-set analysis (impact agent); persistent logs, System filters and agent producer setup (daemon agent). No new framework or package is planned. Existing dirty work is preserved; nothing is pushed.
|
||||
|
||||
Final reports: `repair-map-e2e.json`, `explore-context-e2e.json`, `change-analysis-e2e.json`, `repair-errors-e2e.json`, `final-agent-diagnosis-restart.json`, `final-comparison.json` in `graph-ui/verification/pr-2068/`. The final frontend unit run passes 175 files / 2473 tests. Earlier green suites did not establish these product flows; the final reports do.
|
||||
|
||||
## Executed repair checks (still in progress)
|
||||
|
||||
- Production RPC check reads all 144 functions and 8 classes in `src/daemon/application.c`; exact file search returns that file first. Evidence: `provider-search-fix.json`.
|
||||
- Whole UI unit suite on 9 September at 23:15 local time: 175 files / 2469 tests pass. Subsequent changes require a final rerun.
|
||||
- Two initial repaired-browser runs exposed `net::ERR_CONTENT_LENGTH_MISMATCH` on the 11,070,807-byte repository map response. The failed browser request was inspected directly before the transport repair (later runs replace the rolling `repair-map-e2e.json` artifact). The server's one-second absolute write deadline was too short for the large response under browser load; the map response now has a bounded five-second deadline. Ordinary responses retain their previous deadline. Browser confirmation is pending in the current run.
|
||||
- Source evidence previously failed in a fresh Explore/impact session without its file in the bounded map. A targeted project/path Module lookup now precedes source reading when necessary. The real API fallback was verified at `tests/test_daemon_application.c:4185`; see `source-evidence-fallback.json`.
|
||||
- The first 1024×800 impact check exposed only 89px available for findings. Duplicate selection/header content was removed; the next browser run checks the resulting space.
|
||||
- SQLite activity schema 4 is running on the same port, with a local pre-migration backup at `/tmp/cbm-2068-activity-before-v4-1788987970.db`. Existing history was preserved. No new external agent inference was started.
|
||||
|
||||
Current live build: port 9749. Original frontend comparison: port 9751. Map, impact and System/Galaxy error flows are being verified concurrently, using the real repository plus the explicitly marked local error fixture.
|
||||
|
||||
|
||||
## Latest integration evidence
|
||||
|
||||
- HTTP/journal native suite: 83 pass, 1 existing Windows-only skip under ASan/UBSan; native UI/map/impact suite: 40 pass. See `native-log-transport-repair.log` and `native-map-resolution-repair.log`.
|
||||
- Whole frontend unit suite: 175 files / 2470 tests pass; the separate operational-client gate tests: 2 pass. Acceptance scaffold: 203 pass. Style and promises gates pass.
|
||||
- System/Galaxy live-error workflow passes with real, explicitly marked local EACCES failures. System severity, project and text filters match SQLite exactly (57/57, 11/11 and 3/3 in the recorded run). No page errors or external requests; `repair-errors-e2e.json`.
|
||||
- Working-tree/since-ref/selection/test-source/Git-copy flow passes after the actual repository reindex (`ub2c1b42d30fb1da2g2`). At 1024×800 it keeps 190px of findings visible and has no horizontal page overflow; `change-analysis-e2e.json`.
|
||||
- Real local hook process and retries pass, with an explicitly TEST-labelled event, no LLM invocation and no personal client configuration changes; `agent-setup-integration.json`.
|
||||
- Remaining map blocker found by the browser: an EOF newline was always stripped from snippet text even when the daemon's reported source range includes that final empty line. The strict reader then withheld valid line numbers. Exact API reproduction exists; deterministic range-aware normalization is being verified before the final map rerun.
|
||||
|
||||
The map also exposed a pre-existing index coordinate limitation: a `unique_name` CALLS edge from `main` reports a line beyond its declaration. Out-of-range sites are now labelled unverified and have no site navigation button. Declaration navigation remains available. Resolver method and confidence are shown as index metadata, not as calibrated probabilities. This repairs the UI claim; it does not repair the underlying C preprocessor coordinate mapping.
|
||||
@@ -1,59 +0,0 @@
|
||||
# PR 2068: maintainer handoff from Bernhard
|
||||
|
||||
Prepared on 10 September 2026 (Europe/Berlin). All development stayed on `feat/codeatlas-web`, starting at `80f4f41f9bcb9daf60afc5f607bcbcba24a4b196`; the PR base remains `feat/atlas-r1`. No other branch was changed. This is a continuation of [PR #2068](https://github.com/DeusData/codebase-memory-mcp/pull/2068), not a replacement PR or a merge approval.
|
||||
|
||||
The first implementation pass did not meet the intended product flows. A critical [product audit](pr-2068-product-audit.md) reproduced broken search, source navigation and disconnected analysis entry points. The second pass fixed those failures and exercised the integrated application with Playwright against this repository's real daemon and index. Earlier failures remain separately recorded. The [full final report](pr-2068-final-delivery.md) distinguishes working software, executed checks and remaining limitations.
|
||||
|
||||
## Implemented and exercised
|
||||
|
||||
| Developer task | Delivered behavior | Main implementation |
|
||||
| --- | --- | --- |
|
||||
| Understand a repository and choose a starting point | README/header source quotes, source areas, entry candidates, bounded static call paths, progressive navigation and explicit evidence limits | `graph-ui/src/architecture/`, `src/ui/atlas_repository.c` |
|
||||
| Inspect why a selection matters | Incoming/outgoing relationships, entry paths and separately observed agent activity, with direct source and analysis actions | `graph-ui/src/why/SelectionContext.tsx`, shared `SourceEvidenceDrawer` |
|
||||
| Assess a file, symbol or change | One analysis workspace for selection, working tree and comparison ref; structural paths, test candidates and bounded Git co-change evidence; transparent uncertainty | `graph-ui/src/impact/`, `src/ui/atlas_impact.c` |
|
||||
| Use agent activity and logs through one daemon | SQLite activity schema 4, WAL, retention, cursor ordering and persistent deduplication; same-port polling; explicit local hook setup | `src/ui/activity.c`, `src/ui/http_server.c`, `graph-ui/src/agents/` |
|
||||
| Recognize failures and inspect incomplete indexing | Persisted project errors in System and Galaxy; filters before limits; existing coverage diagnostics and user-initiated local report review/download | `graph-ui/src/diagnostics/`, `src/daemon/application.c`, `src/pipeline/pipeline.c` |
|
||||
|
||||
No new npm dependency or rendering framework was added. Existing MCP transports remain. Agent history does not imply current activity or agent intent. Static reachability is not runtime execution or data flow; co-changes are not functional dependencies or calibrated defect probabilities. Diagnostics do not upload reports or create issues automatically.
|
||||
|
||||
The real-browser repair pass also corrected compact RPC reference decoding and pagination, complete-path search including immediate Enter and delayed responses, source lookup outside the loaded map, EOF pagination, unverified callsite coordinates, small-window analysis layout, and truncated large repository-map HTTP responses. The remaining C parser coordinate defect is disclosed below.
|
||||
|
||||
## Validation and evidence
|
||||
|
||||
- Repeated at handoff: **2473 frontend tests in 175 files**, **203 portable acceptance checks**, production build, style and promise gates, **5 operational-reference checks**, and **7 local hook tests**, all passing. The production asset remained `index-nfAv5dSK.js`. Logs are in [the archive manifest](../../graph-ui/verification/pr-2068/log-archives.json); `.log.gz` files decompress to the original bytes.
|
||||
- A final style failure came from the hook evidence being renamed while the scanner still expected its previous name. The scanner now names the actual download and a regression check compares its bytes with the canonical hook. The original local diagnostic download is preserved verbatim in a JSON evidence wrapper; it was not rewritten to satisfy the style rule.
|
||||
- Recorded final native repair suites: **83 HTTP/journal/transport tests passed, one existing Windows-only skip**, and **40 UI/map/impact tests passed**, under ASan/UBSan. These are the prior repair-round results; the handoff does not claim a new full native or cross-platform run.
|
||||
- [Repository map flow](../../graph-ui/verification/pr-2068/repair-map-e2e.json): real entry, path, edge evidence, correctly highlighted source line, return to the retained map and shared change analysis.
|
||||
- [Handoff browser recheck](../../graph-ui/verification/pr-2068/handoff-recheck/repair-map-e2e.json): after restarting the local verification daemon, the same complete map flow passed again with six fresh screenshots, no page exceptions and no external requests. The earlier browser records were preserved.
|
||||
- [Explore and search](../../graph-ui/verification/pr-2068/explore-context-e2e.json): 144 functions and eight classes in `application.c`, source/context/impact navigation and a delayed genuine search-response race.
|
||||
- [Change analysis](../../graph-ui/verification/pr-2068/change-analysis-e2e.json): real working-tree and ref changes, file/symbol scope, dependency paths, test source, Git evidence, keyboard focus and 1024x800 layout.
|
||||
- [Live errors](../../graph-ui/verification/pr-2068/repair-errors-e2e.json): clearly labelled local indexing failures reached System and Galaxy without reload; displayed filters matched SQLite.
|
||||
- [Hook setup](../../graph-ui/verification/pr-2068/agent-setup-integration.json) and [restart/reconnect](../../graph-ui/verification/pr-2068/final-agent-diagnosis-restart.json): real local TEST-labelled producer, retained event after an actual daemon restart, and browser offline/online recovery without duplication. No production agent or LLM session was launched.
|
||||
- [Before](../../graph-ui/verification/pr-2068/final-before-architecture.png), [after](../../graph-ui/verification/pr-2068/final-after-architecture.png), and [comparison protocol](../../graph-ui/verification/pr-2068/final-comparison.json). Both frontends used the same current backend/index, so this is a frontend comparison. Port 9751 was only the temporary comparison server.
|
||||
|
||||
## Continue locally
|
||||
|
||||
Use the PR discussion for the published delivery reference. The handoff is based on the exact PR head above and preserves the existing implementation; do not reset a newer maintainer checkout to this base. Review and apply the commits on `feat/codeatlas-web` using an ordinary fast-forward or an explicitly reviewed integration if that branch has advanced.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
scripts/ci/test-ui.sh
|
||||
node --test graph-ui/tests/scaffold/operational-client-reference.test.mjs
|
||||
python3 graph-ui/agents/hooks/test_atlas_trace.py
|
||||
make -f Makefile.cbm cbm-with-ui
|
||||
```
|
||||
|
||||
Start the resulting binary with `--ui=true --port=9749` through the usual MCP/daemon setup, then index this checkout. The recorded product runners under `graph-ui/tools/pr-2068-*.mjs` use project name `cbm-pr2068`, port 9749 and this repository's actual symbols; update those inputs for another local project. The comparison runner additionally needs the original frontend on 9751. Recorded worktree counts describe their test moment and will change after commits are applied. No local cache, database, personal client settings or temporary session wrapper is required for the code handoff.
|
||||
|
||||
Review source commit `4372d9fada2b79e668da32193fb1c398436576dd` first. The subsequent report/evidence commit contains the larger screenshot and archived-log collection. One small hook-download fixture stays with the source because its byte-identity regression check uses it.
|
||||
|
||||
## Open work and acceptance limits
|
||||
|
||||
1. **Global C lint is not green.** The recorded full lint run lacks local `cppcheck` and contains extensive clang-tidy findings, including complexity/style findings in the new C files. Resolve and rerun the required native lint gates before treating this as merge-ready. Passing compilation and targeted sanitizer suites do not replace that gate.
|
||||
2. **The C preprocessor/index coordinate cause remains unfixed.** The UI suppresses demonstrably impossible callsite links and preserves the reported number as unverified evidence. Repair the parser mapping with a reproducer before claiming accurate callsites generally.
|
||||
3. **Architecture boundaries and entry roles remain heuristics.** Source quotes are author descriptions, not independently verified subsystem responsibilities. No first-time-developer usability study established the qualitative onboarding criterion.
|
||||
4. **Coverage and history remain bounded.** The recorded index had 85 partial parse paths and 300 intentionally excluded path records, including directories. Those are not coverage percentages. Diff hunks are narrowed to symbols manually; missing history or index freshness is shown as uncertainty.
|
||||
5. **Platform/release validation remains maintainer work.** No new Linux/Windows release ladder, production LLM session or automatic semantic diff mapping is claimed. The PR must stay stacked on its intended base until the maintainer integrates it.
|
||||
|
||||
The [research review](pr-2068-final-review.md) records verified project identities, actual browser interactions versus source inspection, licenses and decisions. Navigation hierarchy, selected paths, source reading without losing context and separate historical evidence influenced the implementation. No competitor frontend, additional database stack or noncommercial/GPL source was incorporated.
|
||||
@@ -1,148 +0,0 @@
|
||||
# PR 2068: tatsächlicher Produktstand nach direktem Browservergleich
|
||||
|
||||
Stand: 9. September 2026. Dieser Audit korrigiert die Abnahmebewertung des
|
||||
früheren Lieferberichts. Technische Bausteine sind implementiert, die verlangte
|
||||
verständliche und handlungsorientierte Repository-Karte ist noch nicht fertig.
|
||||
Erfolgreiche Unit-Tests und erreichbare Komponenten beweisen diese Produktqualität
|
||||
nicht. In diesem Audit wurden keine Produktdateien geändert.
|
||||
|
||||
## Verfahren und Vergleichsgrenze
|
||||
|
||||
Mit Playwright wurden frische Browserkontexte auf 9751 und 9749 geöffnet,
|
||||
der normale Willkommensdialog bedient und danach die sichtbaren UI-Einstiege
|
||||
angeklickt. Keine Workspace-Vorauswahl über LocalStorage und keine gemockten
|
||||
Graphantworten. Der Root-Lauf verwendet 1440 × 1000 Pixel und dokumentiert auch
|
||||
notwendiges Scrollen. Die beiden Seiten liefern unterschiedliche Frontendbuilds.
|
||||
|
||||
- **Vorher, 9751:** Originalfrontend aus `80f4f41f9bcb9daf60afc5f607bcbcba24a4b196`.
|
||||
- **Aktuell, 9749:** eingebetteter Build mit unseren Änderungen.
|
||||
- Beide lesen denselben aktuellen Daemon und Repository-Index. Das ist ein
|
||||
Frontendvergleich, kein erneuter Betrieb der alten Backendarchitektur.
|
||||
- Die vorhandenen vier Agentereignisse sind ausdrücklich gekennzeichnete
|
||||
frühere Prüfdaten. Der Audit hat keine neuen Ereignisfixtures erzeugt.
|
||||
|
||||
Geprüfte Wege: alle fünf Architecture-Tabs, Bereich → Beziehung → Quellzeile,
|
||||
README-Einstieg, Galaxy, Explore-Auswahl, alter und neuer Impact-Einstieg,
|
||||
Tools-Kontextauswahl, Agents sowie System/Logs/ERROR.
|
||||
|
||||
[Architecture-Ablauf und Geometrie](../../graph-ui/verification/pr-2068/audit-architecture.json)
|
||||
sowie [Auswahl-/Impact-Vergleich](../../graph-ui/verification/pr-2068/audit-impact.json)
|
||||
und [Agents-/System-Ablauf samt SQLite-Abgleich](../../graph-ui/verification/pr-2068/audit-daemon-views.json)
|
||||
enthalten die tatsächlichen Klicks, Antworten und Aufnahmen. Neustart und
|
||||
Fehlerinjektion wurden für diesen Audit nicht wiederholt; deren früher ausgeführte
|
||||
Nachweise bleiben im Feedback-Bericht getrennt dokumentiert.
|
||||
|
||||
## Was gebaut wurde und was davon noch nicht reicht
|
||||
|
||||
| Anforderung | Tatsächlich neu vorhanden | Abnahmegrenze |
|
||||
| --- | --- | --- |
|
||||
| Branch und Bestand verstehen | PR-Stand, Daemon-/Frontend-Datenwege, Build-/Testkommandos und reale Ausgangsaufnahmen wurden untersucht. Keine fremden Branchänderungen oder Pushes. | Keine offene Implementierung in diesem Vorbereitungspunkt; keine neue Leistung durch bereits vorher vorhandene Ansichten behaupten. |
|
||||
| Repository-Karte | Neuer Overview mit Bereich/Datei/Symbol-Hierarchie, README-Einstieg, echten gerichteten Beziehungen, Kanten-ID und Quellzeile. Bereich → CALLS-Kante → Quellzeile funktioniert im frischen Audit. | **Teilweise erfüllt.** Bereiche entstehen weiterhin aus Dateipfaden. Verständliche Verantwortlichkeiten, Projektzweck und benannte belegte Systemabläufe fehlen. Einstiegskandidaten sind unzureichend priorisiert. |
|
||||
| Ein Daemon/Port, persistente Agentereignisse | Agenten-API, SQLite-WAL-Journal, Migration bis Version 3, Cursor/Deduplizierung und projektbezogene Logs. Aktuelles Agent-Polling verwendet 9749, der alte Client versucht 4142. | **Technische Grundlage implementiert.** Keine echte laufende Agentenarbeit im ausgewählten Projekt nachgewiesen: vorhanden sind vier ältere Prüfeinträge. Ein eingerichteter produktiver Hook bleibt erforderlich. Nicht sämtliche Frontendlogs sind auf die SQLite-Quelle vereinheitlicht; ein alter JSONL-Logweg bleibt vorhanden. |
|
||||
| Auswahlbezogener Kontext | Caller, gerichtete Beziehungen, begrenzter statischer Einstiegspfad, getrennte aufgezeichnete Agentereignisse und Quelllinks. | **Teilweise erfüllt.** Zusätzlicher Inspector-Baustein, kein durchgängig erklärter aufgabenbezogener Ablauf. Der frühere Intent-Chooser bleibt umbenannt bestehen. Gleichzeitige unterschiedliche Einstiegspunkte und textreiche Ausgabe erschweren das Finden des Nutzens. |
|
||||
| Fehler und Indexdiagnose | Persistente projektbezogene Fehlerhistorie, Live-Hinweise, bestehende Coverage-Abfrage mit korrigierter Pagination, bewusst gestarteter bearbeitbarer lokaler Bericht. 85 partielle Parses und 196 Ausschlüsse sind vollständig als erfasste Datensätze verfügbar. | **Teilweise erfüllt.** System → Logs → ERROR filtert weiterhin nur einen ungefilterten 200er-Tail. Im Audit waren dort null Treffer bei 49 gespeicherten Fehlern. Die neue Historienleiste bietet einen separaten Zugang, vereinheitlicht den naheliegenden Logs-Weg aber nicht. Parserlücken selbst sind nicht behoben. |
|
||||
| Impact-Widget | Direkte/transitive strukturelle Befunde, getrennte Git-Co-Changes, begrenzte Analyse/Cache, heuristische Risikofaktoren, Datenqualität und Quellziele. | **Teilweise erfüllt.** Der sichtbare Einstieg `change scope` öffnet weiter das alte Panel. Das neue Widget ist separat. Keine Interpretation konkreter Diff-Hunks/geänderter Verträge und keine kompakte Priorisierung betroffener Subsysteme. Testbezüge sind Graphbefunde mit heuristischer Testklassifikation. |
|
||||
|
||||
## Konkrete Auffindbarkeits- und Verständlichkeitsprobleme
|
||||
|
||||
1. **Viel von dem sichtbaren Produkt gab es vorher bereits.** Navigation, Explore,
|
||||
Galaxy, Agentenlayout und die Architecture-Tabs bleiben weitgehend gleich.
|
||||
Die neue Karte sitzt im Overview, die übrigen Tabs wurden überwiegend ergänzt
|
||||
oder beibehalten. [Vorher](../../graph-ui/verification/pr-2068/audit-before-architecture-overview.png),
|
||||
[aktuell](../../graph-ui/verification/pr-2068/audit-after-architecture-overview.png).
|
||||
2. **Die Übersicht erklärt keine fachlichen Rollen.** Die vier Startkarten sind
|
||||
`App`, `main`, `GalaxyPanel` und `graph-ui/tools/smoke-w10.mjs:main`.
|
||||
Die ersten Bereiche sind `tests`, `internal/cbm`, `src/foundation` und
|
||||
`src/daemon`. Beziehungen und Zählungen sind real; daraus folgt für einen
|
||||
Neuling noch keine Erklärung des Systems.
|
||||
3. **Ein erfolgreicher Klick kann unsichtbar bleiben.** Nach `387 edges · inspect`
|
||||
beginnt die erzeugte Beziehungsevidenz bei ungefähr y=1302 im 1000 Pixel hohen
|
||||
Browserfenster. Es erfolgt kein Sprung oder Fokus auf das Ergebnis.
|
||||
[Direkt nach Klick](../../graph-ui/verification/pr-2068/audit-after-relationship-immediate.png),
|
||||
[erst nach Scrollen sichtbar](../../graph-ui/verification/pr-2068/audit-after-relationship-scrolled.png).
|
||||
4. **Der README-Einstieg ist als Onboarding ungeeignet aufbereitet.** Er öffnet
|
||||
Markdown-Quelltext mit Badge-URLs im bestehenden Editor. Daneben erklärt der
|
||||
generische Kontext fehlende Graphabhängigkeiten und unbekanntes Risiko.
|
||||
Das hilft wenig beim ersten Verstehen des Projekts.
|
||||
[Echte Ansicht](../../graph-ui/verification/pr-2068/audit-after-readme-entry.png).
|
||||
5. **Fehleransichten widersprechen sich im Bedienergebnis.** SQLite hält den
|
||||
Fehlerverlauf, der offensichtliche ERROR-Filter des Logs-Tabs findet ihn nicht.
|
||||
[Aktueller Leerzustand](../../graph-ui/verification/pr-2068/audit-after-system-errors-filter.png).
|
||||
6. **Der prominente Impact-Einstieg bleibt defekt.** `change scope → Working tree`
|
||||
scheitert im ursprünglichen und im aktuellen Frontend am selben Backend-Weg:
|
||||
`detect_changes: base_branch or HEAD is not a commit`. Das neue Widget hat
|
||||
einen separaten Einstieg und ersetzt diesen naheliegenden Ablauf nicht.
|
||||
[Fehler im aktuellen Frontend](../../graph-ui/verification/pr-2068/audit-after-legacy-impact.png).
|
||||
7. **Das neue Impact-Widget funktioniert, ist aber ungünstig untergebracht.**
|
||||
Für `application_project_lock_release_fully` in der tatsächlich lokal
|
||||
geänderten `src/daemon/application.c` zeigt es zwei direkte und 17 insgesamt
|
||||
erreichbare Betroffene, sieben heuristisch klassifizierte Testkandidaten und
|
||||
zwei Dateien aus einem gemeinsamen Commit. Der Graphpfad nennt eine echte
|
||||
Kanten-ID, ein Test-Button navigiert zu `tests/test_daemon_application.c`.
|
||||
Fünf Datenqualitätsgrenzen werden offen angezeigt. Die langen Befunde liegen
|
||||
im nur ungefähr 299 Pixel hohen eigenen Inspector-Scrollbereich; der übrige
|
||||
rechte Bereich bleibt vom Galaxy-Graphen belegt. Git-Evidenz ist lesbar, aber
|
||||
Commit-Hash und lokales Kommando haben dort keinen Link oder Kopierbutton.
|
||||
[Widget](../../graph-ui/verification/pr-2068/audit-after-impact.png),
|
||||
[Graphpfad](../../graph-ui/verification/pr-2068/audit-after-impact-path.png),
|
||||
[Git-Befund](../../graph-ui/verification/pr-2068/audit-after-impact-git.png).
|
||||
8. **Der bestehende Datei-Inspector liefert widersprüchliche Teilergebnisse.**
|
||||
Bei `application.c` steht `No symbols found` mit angeblich fehlgeschlagenen
|
||||
Function-/Class-Abfragen, während der neue Graphkontext bereits 246
|
||||
Caller-Kanten aus fünf Dateien findet. Exakt wiederholte Backend-Abfragen
|
||||
liefern tatsächlich 127 Function- und acht Class-Zeilen mit `rows_refs`-
|
||||
Kompression. Eine Formatinkompatibilität im Frontend ist damit ein begründeter
|
||||
Verdacht; die Ursache wurde in diesem Audit nicht abschließend repariert.
|
||||
Der Reader lädt den Quelltext später;
|
||||
frühe Loading-Aufnahmen beweisen keinen dauerhaften Ladefehler. Vollständige
|
||||
Pfadsuche priorisiert zunächst `application_internal.h` vor der gesuchten
|
||||
Datei; der dokumentierte Vergleich weicht anschließend sichtbar auf den
|
||||
Explorer aus. Auch solche gewöhnlichen Einstiegsprobleme muss die integrierte
|
||||
Lösung behandeln, statt nur einen vorbereiteten Kartenpfad erfolgreich zu testen.
|
||||
|
||||
Zur Abgrenzung: `Since ref → HEAD` zeigte in der Browseraufnahme noch einen
|
||||
Ladezustand. Die anschließende exakt wiederholte Backend-Abfrage lieferte
|
||||
234 Änderungen und 252 Impact-Befunde mit begrenztem Antwortumfang. Daraus wird
|
||||
kein Fehlen jeglicher Backend-Diffanalyse abgeleitet. Die bestätigte Lücke ist
|
||||
deren fehlende Verbindung mit dem neuen Widget sowie der konkret fehlgeschlagene
|
||||
Working-tree-Bedienweg.
|
||||
|
||||
## Was die Recherche tatsächlich beigetragen hat
|
||||
|
||||
Die Recherche fand statt; sie war in Tiefe und Umsetzung ungleichmäßig.
|
||||
Archify und ein Graphify-Beispiel wurden tatsächlich mit Diagrammdaten bedient.
|
||||
GitNexus und das vom Nutzer bestätigte Codegraph wurden überwiegend anhand
|
||||
konkreter Frontend-/API-Quellen untersucht, nicht vollständig lokal mit einem
|
||||
Repository ausprobiert. Eine vergleichende Onboarding-Aufgabe über dieselben
|
||||
Repository-Daten oder eine Erstnutzerprüfung wurde nicht durchgeführt.
|
||||
|
||||
Übernommen wurden Hierarchie/Evidenznavigation, die Trennung von statischen
|
||||
Befunden und abgeleiteten Gruppen sowie unabhängige historische Co-Changes.
|
||||
Archifys sofort sichtbarer Pfadfokus bei erhaltenem Überblick, GitNexus-artige
|
||||
Prozessnavigation und eine rollenbezogene Startübersicht wurden nicht geliefert.
|
||||
Keine zusätzliche Viewer-/Framework-Abhängigkeit wurde übernommen.
|
||||
|
||||
Der [kritische Rechercheabgleich](pr-2068-research-audit.md) belegt diese Aussagen
|
||||
pro Projekt mit Originalquellen, Code und echten Aufnahmen. Die wesentliche
|
||||
Schwäche liegt in der unzureichenden Übersetzung der Recherche in zusammenhängende
|
||||
Nutzerabläufe, nicht im vollständigen Fehlen von Recherche oder Implementierung.
|
||||
|
||||
## Noch zu implementierende Abschnitte, keine gelieferten Features
|
||||
|
||||
1. Eine erste Repository-Seite mit belegtem Projektzweck, verständlich benannten
|
||||
Verantwortlichkeiten und priorisierten Einstiegsmöglichkeiten. Anwendung,
|
||||
Infrastruktur, Tests und Entwicklungswerkzeuge müssen in ihrer Rolle erkennbar
|
||||
sein, statt pauschal nach Knotengrad zu erscheinen.
|
||||
2. Ein sichtbarer nachvollziehbarer Weg vom Systembestandteil über Beziehungen
|
||||
bis zur Quelle, mit erhaltenem Überblick und sofort erkennbarem Klickergebnis.
|
||||
3. Ein gemeinsamer Kontext-/Impact-Einstieg für dieselbe Datei, dasselbe Symbol
|
||||
und denselben Änderungsumfang; den konkurrierenden alten Weg zusammenführen.
|
||||
4. Fehlerfilter über den persistierten Bestand vereinheitlichen und die reale
|
||||
Agentenquelle im Setup nachvollziehbar anschließen. Leere oder alte Aktivität
|
||||
muss eindeutig von tatsächlich aktueller Arbeit unterscheidbar sein.
|
||||
5. Dieselben konkreten Entwickleraufgaben anschließend vom normalen Einstieg
|
||||
aus prüfen, einschließlich Auffindbarkeit, Rollenverständnis, Quellenprüfung
|
||||
und nächster Änderungshandlung. Bestehende technische Tests bleiben wichtig,
|
||||
ersetzen diese Prüfung jedoch nicht.
|
||||
|
||||
Der vollständige ursprüngliche Auftrag ist damit **nicht abgeschlossen**.
|
||||
@@ -1,132 +0,0 @@
|
||||
# PR 2068: kritischer Abgleich von Recherche und Produkt
|
||||
|
||||
Stand: 9. September 2026. Read-only-Audit des Produktcodes; nur dieser Bericht
|
||||
wurde geschrieben. Verglichen wurden der aktuelle Working Tree und PR-HEAD
|
||||
`80f4f41f9bcb9daf60afc5f607bcbcba24a4b196`, die geklonten Originalquellen der im
|
||||
[Recherchebericht](pr-2068-research.md) festgehaltenen Revisionen sowie echte
|
||||
Recherche- und CBM-Aufnahmen. Relevante GitHub-Originalquellen wurden erneut
|
||||
geöffnet. Dieser Audit führte keinen zusätzlichen interaktiven CBM-Browserlauf
|
||||
aus; die Browserbefunde stammen aus den verlinkten Aufnahmen und Protokollen.
|
||||
Der Hauptauftrag hat während dieses Audits beide Frontendstände mit demselben
|
||||
aktuellen Daemon/Index erneut bedient; dessen
|
||||
[Vergleichslauf](../../graph-ui/verification/pr-2068/audit-architecture.json)
|
||||
von 20:26–20:27 UTC wurde hier zusätzlich gelesen und die relevanten Aufnahmen
|
||||
visuell geprüft.
|
||||
|
||||
**Urteil:** Die Recherche ist teilweise in echte Funktionen eingeflossen. Am
|
||||
klarsten ist die Übertragung bei gerichteter Evidenznavigation und der Trennung
|
||||
von Graphbefund und Git-Historie. Das zentrale Onboarding-Versprechen einer
|
||||
verständlichen Systemkarte ist damit noch nicht eingelöst. Die wichtigsten
|
||||
Bestandteile werden weiterhin überwiegend als Verzeichnisse und Symbolnamen
|
||||
präsentiert; ihre Verantwortlichkeiten und ihr Zusammenwirken muss der Leser
|
||||
selbst rekonstruieren. Das frühere Recherchefazit beschrieb eine sinnvolle
|
||||
Kombination von Ideen, keine bereits erreichte Produktqualität. Es wäre falsch,
|
||||
es im Abschluss als vollständige Umsetzung dieser Ideen zu verwenden.
|
||||
|
||||
## Was aus welcher Recherche tatsächlich angekommen ist
|
||||
|
||||
| Quelle und nachweislich untersuchte Interaktion | Umsetzung im aktuellen CBM-Code | Fehlender Teil / ursprünglicher Anspruch |
|
||||
| --- | --- | --- |
|
||||
| **Archify**, MIT. Im Browser tatsächlich Kapitel gewählt und eine gerichtete Route zwischen zwei Knoten geöffnet: [Aufnahme](../../graph-ui/verification/pr-2068/research-archify-route.png). Der [Originalvertrag](https://github.com/tt-a1i/archify/blob/10722002bb8777ecb639d93c49586fae4adf3ae4/archify/references/viewer-runtime.md) hält Kapitel, Fokus und Beziehungen am selben Diagrammmodell. Es ist eine autorenverfasste Demo. | **Teilweise.** [RepositoryMap](../../graph-ui/src/architecture/RepositoryMap.tsx) bietet Übersicht → Bereich → Datei → Symbole mit Breadcrumbs. [selection-context.ts](../../graph-ui/src/why/selection-context.ts) sucht einen tatsächlichen CALLS-Pfad zu einem Entry-Kandidaten, maximal vier Kanten / 500 Symbole; die UI verlinkt die Evidenz. | Kein benannter Systemablauf, keine Kapitel, keine Auswahl beliebiger Start-/Zielpunkte und kein hervorgehobener Pfad im erhaltenen Kartenüberblick. Die Pfadausgabe ist eine vertikale Kantenliste. Das Weglassen des Archify-Compilers erklärt die fehlende Runtime-Abhängigkeit, begründet aber nicht das Ausbleiben dieser eigenen Produktinteraktion. |
|
||||
| **GitNexus**, [korrekte URL](https://github.com/abhigyanpatwari/GitNexus), PolyForm Noncommercial. Nur der öffentliche Einstieg wurde geöffnet; er wartete auf einen lokalen Server. Prozesssuche, Prozessschritte und Graphfokus wurden ausschließlich in [ProcessesPanel.tsx](https://github.com/abhigyanpatwari/GitNexus/blob/b60c21d05df6af6235ce3f61603ca86469fba8fc/gitnexus-web/src/components/ProcessesPanel.tsx) gelesen. | **Teilweise als allgemeine Idee.** Bereichsauswahl und Beziehungsliste begrenzen die angezeigte Information. Der Kontextpfad nutzt ausschließlich echte CALLS-Kanten; der im Fremdcode erwähnte lineare Ersatzpfad bei fehlenden Kanten wurde nicht nachgebaut. | Kein entsprechendes Prozessmodell oder Prozessbrowser in der neuen RepositoryMap und keine neue Prozessauswahl, die den Galaxy-Graphen auf einen zugehörigen Teilgraphen fokussiert. „Fokussierte Teilgraphen übernommen“ wäre als Beschreibung der sichtbaren Interaktion zu weitgehend. Ein funktionierender GitNexus-Graph wurde von uns nicht ausprobiert. |
|
||||
| **Graphify-Labs/graphify**, Apache-2.0 mit weiteren Lizenzhinweisen. Das eingecheckte HTML-Beispiel wurde tatsächlich durchsucht und eine Datei ausgewählt: [Aufnahme](../../graph-ui/verification/pr-2068/research-graphify-example-selected.png). Der damalige Nachbarklick scheiterte; das ist kein Nachweis eines Fehlers im aktuellen Exporter. Dessen [Quellcode](https://github.com/Graphify-Labs/graphify/blob/3f82bf7f837a07fb0f7668fbdbd5662801906942/graphify/exporters/html.py) unterscheidet extrahierte, abgeleitete und aggregierte Kanten. | **Teilweise.** Die Karte bezeichnet Verzeichnisbereiche als abgeleitete Grenzen, nennt Beziehungstypen und erhält konkrete Endpunkte, Kanten-IDs und gegebenenfalls Quellzeilen. Keine neue CDN-/Viewer-Abhängigkeit. | Keine vergleichbare Herkunfts-/Konfidenzkennzeichnung jeder einzelnen Kante im Graphen. Die neue UI unterscheidet vor allem statische Indexkanten von heuristischer Gruppierung. Suche/Datei-Inspector und ein großer Graph waren in CBM bereits vorhanden; ihre Existenz ist kein neuer Research-Erfolg. |
|
||||
| **colbymchenry/codegraph**, MIT, vom Nutzer ausdrücklich bestätigt. Tatsächlicher [HomeView](https://github.com/colbymchenry/codegraph/blob/3ed73bc127323e63153bf6ec8354afa82ce36aaf/ui/src/views/HomeView.svelte), Entry-API und [SymbolView](https://github.com/colbymchenry/codegraph/blob/3ed73bc127323e63153bf6ec8354afa82ce36aaf/ui/src/views/SymbolView.svelte) gelesen, kein laufender Reader mit Graphdaten ausprobiert. Startlisten unterscheiden Routen, ausführende Dateien, Tests und Hubs; Caller und Callees werden neben konkreten Codezeilen angeordnet. | **Teilweise.** „Where to start“, README-Einstieg, ausgewählte Symbole und eingehende/ausgehende Evidenz sind erreichbar. [repository-source.ts](../../graph-ui/src/architecture/repository-source.ts) löst die Auswahl gegen den aktuellen Snapshot auf. | Die neue Startliste trennt diese Rollen nicht: sie nimmt vier `status === 'entry'`-Kandidaten nach `out_calls`. Der reale Lauf bot App, main, GalaxyPanel und ein Smoke-Test-main nebeneinander an. Kein neues Caller–Code–Callee-Layout mit zeilenbezogenen Verbindungen. Die bestehenden Entry-/Routes-/Hotspots-Tabs sind kein neu übernommenes Feature. |
|
||||
| **lzehrung/codegraph**, zusätzliche MIT-Quelle. [app.js](https://github.com/lzehrung/codegraph/blob/dd58ad3641007a4da90712aef0114d552a8aa492/src/viewer/app.js) verbindet Baumauswahl und Inspector mit gerichteten Referenzlisten; nur Quellcode untersucht. | **Weitgehend auf Komponentenebene.** Architecture und Galaxy erhalten denselben neuen Auswahlkontext und Impact-Baustein aus [App.tsx](../../graph-ui/src/App.tsx). Quellziele verwenden die vorhandene Reader-Navigation. | Kein durchgängiger visueller Kartenpfad: `navigateEvidence` wechselt in Explore. Der Kartenüberblick und die Quellstelle werden nicht gemeinsam präsentiert. Gemeinsame Daten und Navigation sind vorhanden, ein nahtloser orientierender Leseablauf daraus noch nicht vollständig. |
|
||||
| **optave/ops-codegraph-tool**, zusätzliche Apache-2.0-Quelle. [cochange.ts](https://github.com/optave/ops-codegraph-tool/blob/427c96f6da08fed88559075ef3c3c9eaa2122319/src/features/cochange.ts) und diff-impact-Quelle gelesen, kein eigener CLI-/UI-Lauf. | **Konkret umgesetzt.** [atlas_impact.c](../../src/ui/atlas_impact.c) berechnet begrenzte rückwärts gerichtete CALLS-/IMPORTS-Pfade und separat lokale Co-Changes. [SelectionImpactWidget](../../graph-ui/src/impact/SelectionImpactWidget.tsx) zeigt Pfad-IDs, Quellziele, Commitreferenzen, Datenstand und Einschränkungen. | Keine Analyse einzelner Diff-Hunks oder semantisch geänderter Verträge. Die Auswahl ist Datei/Symbol; uncommittete Änderungen werden als Zustand erkannt. Historienbefunde sind im Daemon kurzzeitig im Speicher gecacht, nicht wie beim Fremdprojekt als Co-Change-Paare in SQLite persistiert. Der ursprüngliche Recherchetext darf nicht als Nachweis eines solchen persistenten Caches gelesen werden. |
|
||||
| **Code Maat**, zusätzliche GPL-3.0-Quelle. [coupling_algos.clj](https://github.com/adamtornhill/code-maat/blob/50537abb8dffa1b1ba4e91a605ee5c558c01b224/src/code_maat/analysis/coupling_algos.clj) filtert große Changesets und berücksichtigt Mindestdaten; nur Quelle gelesen. | **Konkret umgesetzt.** CBM schließt Merge-Commits und Änderungen mit mehr als 30 Dateien aus, begrenzt auf 200 Commits / 548 Tage und nennt Ausschlüsse. [selectionRisk](../../graph-ui/src/impact/selection-impact.ts) benennt Regeln und Unsicherheit getrennt; keine Defektwahrscheinlichkeit. | Keine kalibrierte Prognose, keine Defekthistorie und keine historische Testwirksamkeitsanalyse. Das ist eine fachlich angemessene Begrenzung, kein noch fehlendes Prozent-Feature. Die Übertragbarkeit der Regeln ist belegt; ihre konkreten Schwellen sind eigene Heuristiken. |
|
||||
|
||||
Es gibt keinen neuen Eintrag in CBMs Frontend-Paketmanifest oder Lockfile. Die
|
||||
Entscheidung gegen zusätzliche Viewer-/Framework-/Datenbank-Stacks wurde also
|
||||
tatsächlich eingehalten. Daraus folgt weder, dass jede sinnvolle Interaktionsidee
|
||||
umgesetzt ist, noch dass Lizenzen die fehlende eigene Umsetzung verhindert hätten.
|
||||
|
||||
## Warum der sichtbare Unterschied klein bleibt
|
||||
|
||||
Der PR-HEAD hatte bereits die fünf Architecture-Tabs, Modul-Karten, Layer- und
|
||||
Community-Listen, ein Abhängigkeitsdiagramm und Quelllinks. Das ist in der echten
|
||||
[Vorher-Aufnahme](../../graph-ui/verification/pr-2068/before-architecture.png) und
|
||||
in `git show HEAD:graph-ui/src/architecture/ArchitecturePanel.tsx` überprüfbar.
|
||||
Neu sind die RepositoryMap innerhalb des Overview-Tabs und konkrete
|
||||
Kanten-Evidenz in Dependencies; die vorhandene Zusammenfassung liegt nun in einer
|
||||
Disclosure. Rahmen, Raster, Farben und Tabstruktur bleiben weitgehend gleich.
|
||||
In `GalaxyPanel.tsx` ergänzt der Produktdiff einen Auswahl-Aside, keine neue
|
||||
Graphdarstellung.
|
||||
|
||||
Die älteren Vorher-/Nachher-Aufnahmen stammen aus verschiedenen Indexständen.
|
||||
Ihre geänderten Knoten-/Dateizahlen messen keinen Produktfortschritt. Der neue
|
||||
Vergleich hält den Daemon und Index gleich und zeigt erneut die beschriebene
|
||||
Grenze: [Overview vorher](../../graph-ui/verification/pr-2068/audit-before-architecture-overview.png),
|
||||
[Overview nachher](../../graph-ui/verification/pr-2068/audit-after-architecture-overview.png).
|
||||
|
||||
Der [aktuelle Aufnahmezustand nach der Log-Korrektur](../../graph-ui/verification/pr-2068/audit-after-architecture-overview.png)
|
||||
zeigt die verbleibende Produktlücke deutlich: Namen wie `internal/cbm`,
|
||||
`src/foundation` und `src/daemon` plus Zahlen und Funktionsnamen. In
|
||||
[repository-map.ts](../../graph-ui/src/architecture/repository-map.ts) bildet
|
||||
`areaOf` die Bereiche ausschließlich aus dem ersten beziehungsweise zweiten
|
||||
Pfadsegment. Graphkanten beeinflussen Rangfolge und Beziehungen, nicht die
|
||||
inhaltliche Benennung oder die Grenzen. Die optionale Layer-Zeile nach dem Öffnen
|
||||
eines Bereichs ist ein zusätzliches Heuristikergebnis, keine Erklärung seiner
|
||||
Verantwortlichkeit. Die UI kann bereits mehr als ein Dateibaum, beantwortet aber
|
||||
„Was macht dieses System und wie arbeitet es?“ noch nicht auf Übersichtsebene.
|
||||
|
||||
Der neue Nutzen erscheint zudem überwiegend nach mehreren Aktionen: Bereich
|
||||
öffnen, Relation wählen, Evidenz aufklappen oder Symbol auswählen. Dann entstehen
|
||||
lange Listen. Im frischen Browserlauf blieb die neu erzeugte Beziehungsevidenz
|
||||
nach „inspect“ unterhalb des sichtbaren Bereichs: Beginn ungefähr bei y=1302 in
|
||||
einem 1000 Pixel hohen Viewport. Der Nutzer musste zusätzlich scrollen.
|
||||
[Unmittelbare Aufnahme](../../graph-ui/verification/pr-2068/audit-after-relationship-immediate.png),
|
||||
[nach zusätzlichem Scrollen](../../graph-ui/verification/pr-2068/audit-after-relationship-scrolled.png).
|
||||
Das passt exakt zum Code: Der Klick ruft in `RepositoryMapView` nur `setEvidence`
|
||||
auf und rendert die Liste unter dem Bereich; `scrollIntoView` reagiert allein
|
||||
auf `selection?.id`. Archifys erhaltener Überblick plus sofort sichtbarer
|
||||
Fokusbefund ist hier gerade nicht angekommen.
|
||||
|
||||
Auch der README-Einstieg ist funktional, aber als Onboarding schwach:
|
||||
[aktuelle Aufnahme](../../graph-ui/verification/pr-2068/audit-after-readme-entry.png).
|
||||
Er öffnet ungefilterten Markdown-Quelltext einschließlich vieler Badge-URLs in
|
||||
Explore. Der daneben eingeblendete generische Hinweis auf fehlende Graphkanten
|
||||
und unbewiesenes niedriges Risiko beantwortet beim Lesen der Projekteinführung
|
||||
keine hilfreiche Frage. `onNavigate(readme)` und der bedingungslos für jede
|
||||
ausgewählte Datei eingefügte Kontext erklären diesen sichtbaren Zustand.
|
||||
|
||||
[SelectionContext](../../graph-ui/src/why/SelectionContext.tsx)
|
||||
liefert echte Caller-/Pfadinformation statt der früheren generischen Frage.
|
||||
Es liest jedoch keinen aktuellen Aufgabentext und korreliert Agentenereignisse
|
||||
nur mit derselben Datei; eine Erklärung „gelesen, anschließend abhängiges Symbol
|
||||
geändert“ wird nicht berechnet. Das ist ehrlich, aber enger als das Zielbild.
|
||||
|
||||
Impact beantwortet eine konkrete Entwicklerfrage bereits substanzieller. Die
|
||||
[reale Aufzeichnung](../../graph-ui/verification/pr-2068/map-e2e.json) enthält für
|
||||
`cbm_daemon_application_new` einen statischen Einstiegspfad, direkte und
|
||||
transitive Betroffene sowie getrennte Git-Befunde. Trotzdem bleibt die Ausgabe
|
||||
symbolorientiert und textreich: keine priorisierte Zusammenfassung betroffener
|
||||
Subsysteme, kein visueller Zusammenhang von Änderung und Systemablauf. Der
|
||||
[Impact-Screenshot](../../graph-ui/verification/pr-2068/after-context-and-impact.png)
|
||||
belegt diese Darstellung. Sein alter globaler Fehlerbanner ist inzwischen
|
||||
korrigiert; er darf nicht als aktueller Fehlerzustand ausgegeben werden.
|
||||
|
||||
## Korrektur des bisherigen Abschlussanspruchs
|
||||
|
||||
- **Implementiert:** stufenweises Öffnen von Quellbereichen, konkrete gerichtete
|
||||
Beziehungsevidenz, ein Auswahlkontext mit echtem begrenztem CALLS-Pfad und
|
||||
getrennter Agentenbeobachtung, sowie Auswahl-Impact mit getrennten Graph- und
|
||||
Git-Befunden. Diese Aussagen sind im Code und in echten Ablaufprotokollen
|
||||
verankert.
|
||||
- **Noch nicht eingelöst:** eine Übersicht, aus der ein Neuling die
|
||||
Verantwortlichkeiten und das Zusammenwirken der Hauptbestandteile erklären
|
||||
kann; gut unterschiedene Einstiegstypen; ein anschaulicher Ablauf-/Pfadfokus
|
||||
mit erhaltenem Überblick; eine kompakte Priorisierung der betroffenen
|
||||
Komponenten. Das Architect-Abnahmekriterium darf deshalb nicht als vollständig
|
||||
erfüllt gelten.
|
||||
- **Recherchegrenze:** Zwei Fremdansichten wurden mit Graph-/Diagrammdaten
|
||||
tatsächlich bedient, die übrigen relevanten Interaktionen überwiegend anhand
|
||||
von Quellcode geprüft. Es gab keinen Vergleich mit Neulingen, keine gemessene
|
||||
Onboarding-Verbesserung und keinen belastbaren Vergleich über dieselbe
|
||||
Repository-Aufgabe in mehreren Tools. Testzahlen ersetzen keinen dieser
|
||||
Nachweise.
|
||||
|
||||
Der nächste Produktabschnitt müsste die bereits vorhandenen Beziehungen in eine
|
||||
überschaubare, benannte Systemerklärung mit überprüfbaren Einstiegspfaden
|
||||
übersetzen. Das ist eine verbleibende Implementierungsaufgabe, kein durch diesen
|
||||
Audit geliefertes Feature. Weitere Bibliotheken oder automatisch erzeugter Text
|
||||
allein würden die festgestellte Lücke nicht schließen.
|
||||
@@ -1,216 +0,0 @@
|
||||
# PR 2068: überprüfbare Produktrecherche
|
||||
|
||||
Recherche am 9. September 2026. Untersucht wurden öffentliche Original-Repositories,
|
||||
deren konkrete Frontend-/Analysequellen und mehrere im Browser erreichbare Oberflächen.
|
||||
Es wurden keine Accounts erstellt, keine privaten Repositorydaten hochgeladen und
|
||||
keine Fremdimplementierungen oder neuen Laufzeitabhängigkeiten übernommen. Die
|
||||
Entscheidungen unten sind Implementierungsleitlinien; sie sind kein Nachweis, dass
|
||||
ein CBM-Abnahmekriterium bereits erfüllt ist.
|
||||
|
||||
## Namen und untersuchter Stand
|
||||
|
||||
| Nutzereingabe | Verifikation | Untersuchte Revision / Lizenz |
|
||||
| --- | --- | --- |
|
||||
| `gitnexus` | [abhigyanpatwari/GitNexus](https://github.com/abhigyanpatwari/GitNexus) ist das Originalrepository der gefundenen gleichnamigen Forks; die Projekt-Homepage verweist auf `gitnexus.vercel.app`. | `b60c21d05df6af6235ce3f61603ca86469fba8fc`; **PolyForm Noncommercial 1.0.0**, laut tatsächlicher [LICENSE](https://github.com/abhigyanpatwari/GitNexus/blob/b60c21d05df6af6235ce3f61603ca86469fba8fc/LICENSE). Kein permissiv lizenziertes Open-Source-Bauteil für CBM. |
|
||||
| `gtaphyfy` | Für die exakte Schreibweise zunächst kein belastbarer Projekttreffer. **Im Gespräch ausdrücklich bestätigt:** [Graphify-Labs/graphify](https://github.com/Graphify-Labs/graphify). | Default-Branch `v8`, `3f82bf7f837a07fb0f7668fbdbd5662801906942`; Apache-2.0, zusätzliche MIT-Hinweise in `LICENSE-MIT`/`NOTICE`. |
|
||||
| `coedgraph` | **Vom Nutzer mit konkreter URL aufgelöst:** [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph). Repository, echte Svelte-Oberfläche und zugehörige Server-API-Quellen untersucht; siehe eigener Abschnitt. | `3ed73bc127323e63153bf6ec8354afa82ce36aaf`, MIT, HEAD vom 9. September 2026. |
|
||||
| Zusätzliche Codegraph-Inspiration | Unabhängig untersucht: [lzehrung/codegraph](https://github.com/lzehrung/codegraph) und [optave/ops-codegraph-tool](https://github.com/optave/ops-codegraph-tool). Die alte URL `optave/codegraph` leitet auf Letzteres um. Beide sind **nicht** das vom Nutzer gemeinte CodeGraph. | lzehrung: `dd58ad3641007a4da90712aef0114d552a8aa492`, MIT. optave: `427c96f6da08fed88559075ef3c3c9eaa2122319`, Apache-2.0. |
|
||||
| Explizite Archify-URL | [tt-a1i/archify](https://github.com/tt-a1i/archify), eindeutige Zuordnung. | `10722002bb8777ecb639d93c49586fae4adf3ae4`, MIT. |
|
||||
| Zusätzliche Impact-Inspiration | [adamtornhill/code-maat](https://github.com/adamtornhill/code-maat), eigenständige Recherchequelle. | `50537abb8dffa1b1ba4e91a605ee5c558c01b224`, GPL-3.0 laut README, `project.clj` und Quelltextkopf. |
|
||||
|
||||
Die anfängliche Mehrdeutigkeit wurde nicht stillschweigend aufgelöst. Nach der
|
||||
Klarstellung des Nutzers sind die drei gemeinten Projekte nun eindeutig zugeordnet.
|
||||
|
||||
## Was tatsächlich ausprobiert wurde
|
||||
|
||||
Alle Screenshots sind unveränderte Playwright-Aufnahmen mit Chromium, 1440×1000.
|
||||
Sie zeigen **Fremdprojekte**, keine behauptete CBM-Implementierung. Metadaten und
|
||||
Dateiprüfsummen stehen in [research-captures.json](../../graph-ui/verification/pr-2068/research-captures.json).
|
||||
|
||||
| Oberfläche / Daten | Tatsächliche Interaktion und Befund | Screenshot |
|
||||
| --- | --- | --- |
|
||||
| [Archify: Agent Tool Call Workflow](https://tt-a1i.github.io/archify/gallery/artifacts/agent-tool-call.workflow.html), öffentliches **autorenverfasstes Beispieldiagramm**, kein aus CBM extrahierter Ablauf | Übersicht geöffnet; Kapitel „Request to result“ gewählt und Anzeige `1 / 3` abgewartet. Sichtbare Verantwortlichkeitsbereiche und Knotennamen erklären die Abfolge. | [Übersicht](../../graph-ui/verification/pr-2068/research-archify-overview.png), [Kapitel](../../graph-ui/verification/pr-2068/research-archify-chapter.png) |
|
||||
| Dieselbe Archify-Demo | PATH geöffnet, `User`, dann `Tool Call` geklickt. Ergebnis: sechs benannte Knoten, fünf gerichtete Schritte. Panel bezeichnet die Beziehung ausdrücklich als kürzesten **autorenverfassten** Pfad. | [Ausgewählte Route](../../graph-ui/verification/pr-2068/research-archify-route.png) |
|
||||
| [GitNexus Web](https://gitnexus.vercel.app) | Öffentliche Seite geladen. Tatsächliche Oberfläche fordert einen lokalen `gitnexus serve` auf Port 4747 an und wartet auf ihn; keine anonyme Beispieldatenansicht angeboten. Kein Login verlangt. Lokalen Fremdserver nicht installiert. Graph-/Prozess-Interaktionen daher **nur im Quellcode untersucht**. | [Realer Einstiegszustand](../../graph-ui/verification/pr-2068/research-gitnexus-entry.png) |
|
||||
| Graphify: eingechecktes [RSL Siege Manager Beispiel](https://github.com/Graphify-Labs/graphify/blob/3f82bf7f837a07fb0f7668fbdbd5662801906942/worked/rsl-siege-manager/graph.html), lokal als HTML geöffnet | Vorhandene Beispieldaten: 1886 Knoten, 3876 Kanten, 141 Communities. Suche `main.py`, exakten Treffer gewählt; Inspector nennt `bot/app/main.py` und fünf Nachbarn. Danach erster Nachbar geklickt: Inspector wechselte **nicht**, Browser meldete `Unexpected end of input` durch fehlerhaftes Inline-`onclick`. Der aktuelle Exporter enthält inzwischen event-listener-basierten Code; der Fehler ist nur am eingecheckten Beispiel reproduziert, nicht am aktuellen Exporter. | [Ausgewählte Beispieldatei](../../graph-ui/verification/pr-2068/research-graphify-example-selected.png) |
|
||||
| [colbymchenry/codegraph Website](https://colbymchenry.github.io/codegraph/) | Im Browser geöffnet: öffentliche Produkt-/Dokumentationseinstiegsseite, kein interaktiver Reader mit Repositorydaten. Die relevanten Reader-Interaktionen wurden deshalb **im tatsächlichen Svelte- und API-Quellcode** untersucht; keine lokale Fremdindexierung gestartet. | Kein Screenshot als vermeintlicher Reader-Nachweis. |
|
||||
|
||||
Die Graphify-Beispieldatei lädt vis-network von unpkg. Es wurden dabei nur diese
|
||||
öffentlichen Beispieldaten geöffnet. Diese CDN-Abhängigkeit ist für das lokale
|
||||
CBM-Produkt kein geeignetes Integrationsmuster. Screenshot und DOM wurden geprüft;
|
||||
eine vollständige Fremdprodukt-Testsuite oder Sicherheitsaudit wurde nicht ausgeführt.
|
||||
|
||||
## Beobachtung → Nutzerproblem → Entscheidung
|
||||
|
||||
### Archify: geführte Ebenen und gerichtete Beziehungen
|
||||
|
||||
**Beobachtung:** Das ausprobierte Diagramm verbindet sichtbare Bereiche, benannte
|
||||
Knoten, auswählbare Kapitel und einen expliziten gerichteten Pfad. Übersicht und
|
||||
Detail behalten denselben Kontext. Die [Runtime-Beschreibung](https://github.com/tt-a1i/archify/blob/10722002bb8777ecb639d93c49586fae4adf3ae4/archify/references/viewer-runtime.md)
|
||||
unterscheidet Lesetiefen; die Demo enthält tatsächliche Knoten-/Kantenidentitäten.
|
||||
|
||||
**Gelöstes Problem:** „Wie komme ich vom Einstieg zu diesem Bestandteil?“ lässt
|
||||
sich Schritt für Schritt erklären, ohne einen kompletten Graphen zu interpretieren.
|
||||
|
||||
**Übertragung / Aufwand / Lizenz:** Kleine eigene React-Übersicht mit benannten
|
||||
Bereichen, Beziehungsliste und Evidenznavigation: mittlerer Aufwand. MIT ermöglicht
|
||||
grundsätzlich Wiederverwendung mit Hinweisen; der separate JSON-Diagrammcompiler
|
||||
würde jedoch eine zweite Modell-/Renderarchitektur einführen.
|
||||
|
||||
**Entscheidung:** Interaktionsidee übernehmen, Compiler nicht integrieren. CBM
|
||||
muss aus Graphkanten belegte Pfade bilden und Cluster als Heuristik kennzeichnen.
|
||||
Autorenverfasste Demo-Abläufe sind kein Beleg für automatisch erkannte Datenflüsse.
|
||||
|
||||
### GitNexus: Prozesse als navigierbare Teilgraphen
|
||||
|
||||
**Quellcodebeobachtung:** [ProcessesPanel.tsx](https://github.com/abhigyanpatwari/GitNexus/blob/b60c21d05df6af6235ce3f61603ca86469fba8fc/gitnexus-web/src/components/ProcessesPanel.tsx)
|
||||
trennt Prozesse innerhalb und zwischen Communities, erlaubt Suche und fokussiert
|
||||
Teilgraphen. Es lädt Prozessschritte mit Dateipfaden und fragt `CALLS`-Kanten ab.
|
||||
Der Fehlerpfad erwähnt allerdings eine lineare Ersatzdarstellung, wenn Kantenabfragen
|
||||
fehlschlagen. Das wurde im Code gelesen, nicht im laufenden Graphen getestet.
|
||||
|
||||
**Gelöstes Problem:** Ein benannter Einstieg in einen überschaubaren Teilgraphen
|
||||
ist leichter handhabbar als eine Menge unbeschrifteter Punkte.
|
||||
|
||||
**Übertragung / Aufwand / Lizenz:** Eigene auf echten Kanten basierende
|
||||
Beziehungsnavigation: mittel. PolyForm Noncommercial schließt eine ungeprüfte
|
||||
Codeübernahme aus. Der Webstack enthält unter anderem Sigma/Graphology, Mermaid
|
||||
und LangChain-Anbindungen; das wäre für die vorhandene CBM-Oberfläche unnötig groß.
|
||||
|
||||
**Entscheidung:** Fokussierte Teilgraphen als Produktidee nutzen. Kein Fremdcode,
|
||||
kein LLM für Grundkontext, keine linearen Scheinpfade bei fehlenden Kanten.
|
||||
|
||||
### Graphify: Herkunft einer Kante sichtbar machen
|
||||
|
||||
**Beobachtung:** Der aktuelle [HTML-Exporter](https://github.com/Graphify-Labs/graphify/blob/3f82bf7f837a07fb0f7668fbdbd5662801906942/graphify/exporters/html.py)
|
||||
unterscheidet extrahierte und abgeleitete Beziehungen durch Linien und Metadaten.
|
||||
Bei großen Graphen aggregiert er Communities mit explizitem `AGGREGATED`-Typ.
|
||||
Im tatsächlich geöffneten Beispiel führt die Suche zum Datei-Inspector; generische
|
||||
Community-Nummern und die große Knotenmenge erklären allein keine Verantwortung.
|
||||
|
||||
**Gelöstes Problem:** „Was ist beobachtet, was ist abgeleitet?“; Suchtreffer
|
||||
bekommen eine überprüfbare Herkunft.
|
||||
|
||||
**Übertragung / Aufwand / Lizenz:** Evidenzart als Text plus Linien-/Badge-Stil:
|
||||
klein. Apache-2.0 mit vorhandenen Drittanbieterhinweisen; Python/NetworkX und
|
||||
vis-network brauchen wir für diese Interaktion nicht zusätzlich.
|
||||
|
||||
**Entscheidung:** Evidenzkennzeichnung, Aggregationshinweis und Quellnavigation
|
||||
übernehmen. Keine generischen Community-Nummern als alleinige Architekturbegriffe;
|
||||
kein CDN oder Inline-HTML aus Repositorytext. Den reproduzierten Beispiel-Linkfehler
|
||||
als Anlass nehmen, echte Navigationsaktionen statt nur sichtbarer Links zu testen.
|
||||
|
||||
### lzehrung/codegraph: Graph und Dateinavigation synchron halten
|
||||
|
||||
**Quellcodebeobachtung:** [app.js](https://github.com/lzehrung/codegraph/blob/dd58ad3641007a4da90712aef0114d552a8aa492/src/viewer/app.js)
|
||||
verbindet Baumauswahl, Graphfokus und Inspector. Eingehende und ausgehende
|
||||
Datei-/Symbolreferenzen sind getrennt und führen zurück zum jeweiligen Baumelement.
|
||||
Externe Pakete und Symbole lassen sich explizit einblenden. Die Visualisierung
|
||||
verwendet lokal mitgelieferte Sigma-/Graphology-Assets. Kein eigener Browserlauf.
|
||||
|
||||
**Gelöstes Problem:** „Was verwendet dieses Element und wer verwendet es?“ ohne
|
||||
Orientierungsverlust beim Wechsel zwischen Abhängigkeiten.
|
||||
|
||||
**Übertragung / Aufwand / Lizenz:** Gemeinsame Elementauswahl und bidirektionale
|
||||
Referenzlisten: klein bis mittel. MIT, zusätzliche vendorte Lizenzdateien. Die
|
||||
eingebauten Graphbibliotheken wären eine weitere Rendertechnik in CBM.
|
||||
|
||||
**Entscheidung:** Einheitliche Auswahl und explizite Richtung umsetzen;
|
||||
vorhandene React-/Graphkomponenten weiterverwenden. Kein Viewer-/Indexerimport.
|
||||
|
||||
### colbymchenry/codegraph: Einstieg und zeilengenaue Aufrufbeziehungen
|
||||
|
||||
**Quellcodebeobachtung:** [HomeView.svelte](https://github.com/colbymchenry/codegraph/blob/3ed73bc127323e63153bf6ec8354afa82ce36aaf/ui/src/views/HomeView.svelte)
|
||||
gibt bereits ohne Auswahl konkrete Einstiege. Die zugehörige
|
||||
[entrypoints.ts](https://github.com/colbymchenry/codegraph/blob/3ed73bc127323e63153bf6ec8354afa82ce36aaf/src/ui-server/api/entrypoints.ts)
|
||||
liefert getrennt Routen, auf Dateiebene aufrufende Dateien, Tests und häufig
|
||||
referenzierte Symbole. Sie begrenzt die Ergebniszahl je Ordner, kennzeichnet
|
||||
Zählgrenzen und bindet den Cache an den Indexstand. Ein Hub wird dabei ausdrücklich
|
||||
nicht als zuerst ausgeführter Code interpretiert.
|
||||
|
||||
**Gelöstes Problem:** „Wo fange ich an?“ bekommt überprüfbare Auswahlmöglichkeiten,
|
||||
ohne Einstiegspunkt, Test und zentrale Abhängigkeit gleichzusetzen.
|
||||
|
||||
**Weitere belegte Interaktion:** [SymbolView.svelte](https://github.com/colbymchenry/codegraph/blob/3ed73bc127323e63153bf6ec8354afa82ce36aaf/ui/src/views/SymbolView.svelte)
|
||||
ordnet Caller, Originalcode und Callees um konkrete Aufrufzeilen an. Bei veralteten
|
||||
Symbol-IDs versucht es eine erneute Auflösung. Der gemeinsame
|
||||
[Live-Adapter](https://github.com/colbymchenry/codegraph/blob/3ed73bc127323e63153bf6ec8354afa82ce36aaf/ui/src/lib/live.svelte.ts)
|
||||
verteilt Indexänderungen über `/api/events`. Das beschreibt Quellcodeverhalten,
|
||||
keinen von uns ausgeführten Reader-End-to-End-Test.
|
||||
|
||||
**Übertragung / Aufwand / Lizenz:** Einstiegsliste und gerichtete Referenz-Evidenz
|
||||
sind im vorhandenen CBM-Modell mit mittlerem Aufwand möglich. MIT; der Reader nutzt
|
||||
Svelte 5 und `@xyflow/svelte`, was gegenüber vorhandenen React-Komponenten eine
|
||||
zusätzliche Framework-/Layoutabhängigkeit wäre.
|
||||
|
||||
**Entscheidung:** Konkrete Einstiege, gemeinsame Auswahl und revisionsbewusste
|
||||
Navigation übernehmen; keine Svelte-Komponenten oder zusätzlichen Server
|
||||
integrieren. Nicht übertragbar ohne weitere Evidenz: behauptete Testausführung,
|
||||
garantierte Vollständigkeit und eine Rangfolge als tatsächlicher Laufzeitablauf.
|
||||
|
||||
### optave/ops-codegraph-tool: Historie als eigene Evidenzquelle
|
||||
|
||||
**Quellcodebeobachtung:** [cochange.ts](https://github.com/optave/ops-codegraph-tool/blob/427c96f6da08fed88559075ef3c3c9eaa2122319/src/features/cochange.ts)
|
||||
liest Git ohne Merge-Commits, begrenzt Changesets, zählt gemeinsame Änderungen und
|
||||
persistiert Paare samt Historienstand in SQLite. [diff-impact.ts](https://github.com/optave/ops-codegraph-tool/blob/427c96f6da08fed88559075ef3c3c9eaa2122319/src/domain/analysis/diff-impact.ts)
|
||||
führt historischen und strukturellen Befund getrennt. Kein CLI-/UI-Lauf.
|
||||
|
||||
**Gelöstes Problem:** „Welche Dateien sollte ich zusätzlich prüfen, weil sie in
|
||||
der Vergangenheit wiederholt zusammen geändert wurden?“
|
||||
|
||||
**Übertragung / Aufwand / Lizenz:** Begrenzter lokaler Git-Lauf plus Cache in der
|
||||
vorhandenen SQLite-Struktur: mittel. Apache-2.0. Zweiter Tree-sitter-/SQLite-Stack
|
||||
wäre Doppelarbeit; die unabhängige Implementierung benötigt keine neue Bibliothek.
|
||||
|
||||
**Entscheidung:** Historie getrennt ausweisen, Merge-/Massenänderungen ausfiltern,
|
||||
Revision, Umfang und fehlende Historie anzeigen. Gemeinsame Änderungen nicht als
|
||||
funktionale Abhängigkeit oder Ausfallwahrscheinlichkeit ausgeben.
|
||||
|
||||
### Code Maat: große Commits verfälschen Kopplung
|
||||
|
||||
**Quellcodebeobachtung:** [coupling_algos.clj](https://github.com/adamtornhill/code-maat/blob/50537abb8dffa1b1ba4e91a605ee5c558c01b224/src/code_maat/analysis/coupling_algos.clj)
|
||||
filtert übergroße Changesets vor Paarbildung. Dokumentierte Mindestanzahlen für
|
||||
Revisionen und gemeinsame Änderungen machen die Datenbasis sichtbar. Clojure-CLI,
|
||||
keine grafische Oberfläche; nicht gestartet.
|
||||
|
||||
**Gelöstes Problem:** Ein Formatierungs-/Migrationscommit soll nicht praktisch alle
|
||||
Dateien zu scheinbar eng gekoppelten Komponenten machen.
|
||||
|
||||
**Übertragung / Aufwand / Lizenz:** Eigene einfache Filter-/Zählregeln: klein.
|
||||
GPL-3.0 und JVM/Clojure sind zusätzliche Gründe gegen Code-/Runtimeübernahme.
|
||||
|
||||
**Entscheidung:** Mindestdatenbasis und Ausschlüsse transparent anzeigen. Eine in
|
||||
der README verwendete Risiko-/Chance-Deutung von Kopplungsprozenten wird bewusst
|
||||
nicht übernommen: Co-change beschreibt vergangene Zusammenarbeit, keine Defekte.
|
||||
|
||||
## Wartung, Kompatibilität und Grenzen
|
||||
|
||||
Die geprüften HEAD-Commits liegen bei Archify, GitNexus, Graphify und den beiden
|
||||
Codegraph-Projekten im September 2026; bei Code Maat im Juli 2025. Das sind
|
||||
reproduzierbare Aktivitätsindikatoren, keine Qualitäts- oder Wartungsgarantie.
|
||||
Lizenzdateien und relevante Manifeste wurden gelesen. Keine externe Abhängigkeit
|
||||
wird hinzugefügt; deshalb war kein Abhängigkeitsaudit als Integrationsfreigabe
|
||||
erforderlich. Es wurde insbesondere keine Sicherheit allein aus Popularität,
|
||||
Sternen oder Marketing abgeleitet.
|
||||
|
||||
Die umsetzbare Kombination ist eine gemeinsame Auswahl mit stufenweiser
|
||||
Repositoryübersicht, expliziten Relationstypen, überprüfbarer Quellnavigation,
|
||||
separater historischer Evidenz und sichtbarer Unvollständigkeit. Die genannten
|
||||
Ansätze lösen unterschiedliche Entwicklerfragen; keiner ersetzt den vollständigen
|
||||
CBM-Datenweg und dessen eigene Integrationstests.
|
||||
|
||||
## User-requested local Archify preview
|
||||
|
||||
On 2026-09-09, after the user explicitly requested a separate browser port, served
|
||||
the unmodified checked-out Archify `docs` directory at `127.0.0.1:9750`.
|
||||
`GET /gallery/artifacts/agent-tool-call.workflow.html` returned HTTP 200, and
|
||||
macOS `open` opened that URL in the user's browser. The gallery is at
|
||||
`http://127.0.0.1:9750/gallery.html`. These are Archify-authored example diagrams,
|
||||
not an analysis of Codebase Memory MCP. This optional comparison server is
|
||||
separate from the application; the application remains on its one daemon port.
|
||||
|
||||
Reproduce the preview from the research checkout with:
|
||||
|
||||
```sh
|
||||
python3 -m http.server 9750 --bind 127.0.0.1 --directory /tmp/cbm-pr2068-research/archify/docs
|
||||
```
|
||||
@@ -1,61 +0,0 @@
|
||||
# PR 2068: repository map implementation record
|
||||
|
||||
## Starting state (2026-09-09)
|
||||
|
||||
The supplied workspace was empty. Cloned `DeusData/codebase-memory-mcp`,
|
||||
`feat/codeatlas-web`, at `80f4f41f9bcb9daf60afc5f607bcbcba24a4b196`.
|
||||
The checkout was clean. PR base is `feat/atlas-r1`. No other branch, user
|
||||
worktree, remote, or Git history has been modified.
|
||||
|
||||
Read PR description, all five discussion comments and changed-file list;
|
||||
reviewed the architecture/front-end and daemon seams in the local diff/source.
|
||||
The latest branch also contains the exploration, Architecture, Activity and
|
||||
System workspaces, beyond the original PR description. `CONTRIBUTING.md`
|
||||
supplies build/test/security conventions. There is no checked-in AGENTS.md.
|
||||
The user's explicit instruction authorizes implementation on this existing PR.
|
||||
|
||||
The C daemon coordinates MCP clients and owns HTTP on one listener. Graph
|
||||
databases are per-project SQLite. Agent hooks currently write JSONL; a Node
|
||||
bridge on 4142 forwards SSE. UI logs POST to the daemon but persist separately
|
||||
as a rotated file; `/api/logs` reads a bounded process-memory ring. Architecture
|
||||
uses `get_architecture` and indexed files/callable sweeps. Group boundaries
|
||||
carry counts but no drill-through to their constituent edges. The older impact
|
||||
modal follows changed-file callers but does not use Git co-change evidence.
|
||||
“Why are you here?” is an onboarding task chooser, not contextual relevance.
|
||||
Coverage already joins `index_status` and paginated `check_index_coverage`;
|
||||
this is the diagnostic source to preserve.
|
||||
|
||||
## Verification setup
|
||||
|
||||
Production embedded baseline built with `scripts/build.sh --with-ui
|
||||
BUILD_DIR=build/baseline`. Actual repository indexed via `POST /api/index`,
|
||||
project `cbm-pr2068`, isolated cache `/tmp/cbm-2068-cache` and rendezvous
|
||||
`/tmp/cbm-2068-runtime`. One daemon HTTP listener at 127.0.0.1:9749. User
|
||||
configuration and existing indexes remain untouched. Browser artifacts live
|
||||
under `graph-ui/verification/pr-2068`; generated screenshots are actual browser
|
||||
captures, not illustrations.
|
||||
|
||||
Baseline frontend: **157 files / 2334 tests passed**, `npm run test:unit`.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Durable local agent/log store with bounded same-port polling and replay.
|
||||
2. Bounded structural impact and Git history, evidence and uncertainty apart.
|
||||
3. Repository → subsystem candidate → file → symbol and relationship evidence.
|
||||
4. Selection context from static graph and separately observed tool events.
|
||||
5. Shared visible failure feed and explicit local diagnostic review.
|
||||
6. End-to-end browser checks, sanitizer/unit/contract gates and final limitations.
|
||||
|
||||
No new frontend framework or model dependency is planned. Research findings,
|
||||
identity ambiguity and licensing decisions are recorded separately.
|
||||
|
||||
## Delivered implementation
|
||||
|
||||
The implemented behavior, final tests, real before/after screenshots, research
|
||||
decisions and limitations are recorded in [pr-2068-delivery.md](pr-2068-delivery.md).
|
||||
The full native run passed 7,954 tests across 141 suites, with seven skips.
|
||||
A subsequent genuine unreadable-file E2E exposed a clean-worker / failed-tool
|
||||
status mismatch; the coordinator fix passed 129 focused sanitizer tests.
|
||||
Final frontend: 166 files / 2,377 unit tests, 203 acceptance contracts, TypeScript,
|
||||
production build, style and promise gates passed. Global C lint remains open
|
||||
for the documented baseline/style/tooling reasons. No push was performed.
|
||||
+4
-4
@@ -56,7 +56,7 @@
|
||||
"featureList": [
|
||||
"Indexes 158 programming languages via vendored tree-sitter grammars",
|
||||
"Hybrid LSP semantic type resolution for Python, TypeScript/JavaScript, PHP, C#, Go, C/C++, Java, Kotlin, and Rust",
|
||||
"15 MCP tools for structural search, call-path tracing, targeted coverage checks, and Cypher graph queries",
|
||||
"17 MCP tools for structural search, call-path tracing, targeted coverage checks, and Cypher graph queries",
|
||||
"Semantic vector code search via bundled nomic-embed-code embeddings (no API key, fully local)",
|
||||
"Semantic graph edges (SEMANTICALLY_RELATED) and near-clone detection (SIMILAR_TO, MinHash + LSH)",
|
||||
"Cross-service linking for HTTP, gRPC, GraphQL, tRPC, and pub/sub channels with confidence scoring",
|
||||
@@ -728,7 +728,7 @@
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>Auto-sync</h3>
|
||||
<p>A background watcher detects changes and re-indexes incrementally. No manual reindex after editing files.</p>
|
||||
<p>A background watcher detects changes in the project your MCP session is working in and re-indexes it incrementally. No manual reindex after editing files there.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>Team-shared graph artifact</h3>
|
||||
@@ -739,8 +739,8 @@
|
||||
<p>The optional UI variant adds one verified, content-addressed frontend pack and serves an interactive 3D graph at <code>localhost:9749</code> to explore nodes, edges, and clusters visually.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>15 MCP tools</h3>
|
||||
<p><code>search_graph</code>, <code>trace_path</code>, <code>detect_changes</code>, <code>query_graph</code> (Cypher), <code>get_architecture</code>, <code>get_code_snippet</code>, <code>check_index_coverage</code>, <code>manage_adr</code>, and 7 more.</p>
|
||||
<h3>17 MCP tools</h3>
|
||||
<p><code>search_graph</code>, <code>trace_path</code>, <code>detect_changes</code>, <code>query_graph</code> (Cypher), <code>get_architecture</code>, <code>get_code_snippet</code>, <code>check_index_coverage</code>, <code>manage_adr</code>, and 9 more.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>Cypher graph queries</h3>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
packages = forAllSystems (pkgs: rec {
|
||||
default = pkgs.stdenv.mkDerivation {
|
||||
pname = "codebase-memory-mcp";
|
||||
version = "0.10.8";
|
||||
version = "0.11.0";
|
||||
|
||||
src = ./.;
|
||||
|
||||
|
||||
@@ -47,3 +47,14 @@ llm/.pid
|
||||
.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
|
||||
|
||||
@@ -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.
|
||||
@@ -1,213 +0,0 @@
|
||||
# INVENTAR: CBM Atlas (PR 1860, Branch feat/atlas-r1)
|
||||
|
||||
Stand 2026-08-28. Quelle: lokaler Clone unter ./cbm (Commit bda46086), gebaut
|
||||
mit `make -f Makefile.cbm cbm-with-ui` auf macOS arm64 (Apple Clang, Node
|
||||
v20.19.5, npm 10.8.2). Alle Belege sind Datei:Zeile relativ zu ./cbm.
|
||||
Empirische Befunde wurden gegen den laufenden Dev-Server (Port 4200,
|
||||
isoliertes HOME) verifiziert. Dieses Inventar ist die Entscheidungsgrundlage
|
||||
fuer W1; die daraus folgenden Luecken stehen in UPSTREAM-ASKS.md.
|
||||
|
||||
## 1. Frontend-Framework und Toolchain (graph-ui)
|
||||
|
||||
- React 19 SPA in TypeScript 5.7, gebaut mit Vite 6 (`tsc -b && vite build`,
|
||||
graph-ui/package.json:8), Tailwind CSS 4, shadcn-Style-Primitives unter
|
||||
graph-ui/src/components/ui (components.json: style new-york, lucide).
|
||||
- 3D-Stack: three ~0.183 via @react-three/fiber 9.5, @react-three/drei 10.7,
|
||||
@react-three/postprocessing (graph-ui/package.json:14-27).
|
||||
- Tests: Vitest 4 + Testing Library + jsdom (graph-ui/vite.config.ts:35-39),
|
||||
ko-lokalisiert neben den Komponenten, inkl. zh-Suiten.
|
||||
- Kein Router, kein State-Framework: App.tsx macht eigenes Query-Param-Routing
|
||||
(tab, project, node, region, sym, path, flow, #wiki-Hash) via
|
||||
pushState/popstate. Tabs sind eine geschlossene TabId-Union (overview,
|
||||
modules, graph, flows, changes, dashboard, symbol, stats, control;
|
||||
graph-ui/src/lib/types.ts:121-130). Ein neuer Tab heisst: Union erweitern,
|
||||
TAB_IDS in App.tsx:22-32, i18n-Label, Render-Zweig. Keine Registry.
|
||||
- i18n handgerollt: EIN typisiertes messages-Objekt mit en- und zh-Baeumen
|
||||
(graph-ui/src/lib/i18n.ts:5-727, `as const` erzwingt Paritaet), Sprachwahl
|
||||
ueber /api/ui-config-Override, sonst Accept-Language-Ranking
|
||||
(i18n.ts:731-776). Keine separaten Locale-Dateien.
|
||||
- Serverzugriff zweigleisig: callTool() postet JSON-RPC auf /rpc
|
||||
(graph-ui/src/api/rpc.ts:15-47, parst MCP result.content[0].text), REST-GETs
|
||||
auf die /api-Routen (graph-ui/src/lib/atlas.ts). Das Frontend selbst ruft
|
||||
nur 7 MCP-Tools ueber /rpc.
|
||||
- Dev-Modus des PR selbst: Vite-Proxy fuer /rpc und /api auf 127.0.0.1:9749
|
||||
mit Origin-Rewrite (graph-ui/vite.config.ts). Das ist das Vorbild fuer
|
||||
unseren eigenen Dev-Betrieb.
|
||||
- Dark-Mode hart verdrahtet (graph-ui/index.html:2), kein Theme-Umschalter.
|
||||
|
||||
## 2. /rpc-Allowlist und Request-Format
|
||||
|
||||
- Endpoint: POST /rpc, Content-Type application/json, Body im MCP-Format
|
||||
`{"jsonrpc":"2.0","id":N,"method":"tools/call","params":{"name":...,
|
||||
"arguments":{...}}}`. Nur method == "tools/call" passiert den Filter
|
||||
(src/ui/http_server.c:2360).
|
||||
- Allowlist UI_RPC_READ_TOOLS (src/ui/http_server.c:2310-2314), exakt 12
|
||||
Read-Tools: list_projects, get_code_snippet, get_graph_schema,
|
||||
search_graph, search_code, trace_path, trace_call_path, get_architecture,
|
||||
query_graph, detect_changes, check_index_coverage, index_status.
|
||||
- Sonderfall manage_adr: nur in den Read-Modi get und sections erlaubt
|
||||
(rpc_manage_adr_is_read, http_server.c:2327-2347).
|
||||
- Alle Schreib-Tools (index_repository, delete_project, ingest_traces,
|
||||
manage_adr update) sind fuer /rpc gesperrt; Antwort 403 mit -32601
|
||||
"UI RPC method is not allowed" (empirisch bestaetigt).
|
||||
- Duplizierte Top-Level-Keys im Body werden via json_unique_member als
|
||||
mehrdeutig abgelehnt; leerer/uebergrosser Body: 400, -32600.
|
||||
- Die HTTP-MCP-Instanz laeuft ohne Background-Tasks
|
||||
(http_server.c:2763) und mit installiertem Daemon-Mutations-Guard
|
||||
(http_server.c:2922-2923).
|
||||
- query_graph dahinter: read-only Cypher-Subset (src/cypher/cypher.c) mit
|
||||
MATCH/OPTIONAL MATCH, Multi-Typ-Relationen, variablen Hops, WHERE-Baum,
|
||||
Aggregaten, ORDER BY, SKIP/LIMIT, WITH, UNION, UNWIND, CASE; Ceiling 100k
|
||||
Zeilen; r.line/r.args/r.confidence generisch via json_extract auf
|
||||
properties_json. CREATE/SET/MERGE werden abgelehnt.
|
||||
- get_code_snippet: NUR ueber indexierte Symbole (qualified_name ist
|
||||
Pflicht, empirisch bestaetigt), Zeilenfenster mit hartem 500-Zeilen-Cap,
|
||||
Pfad-Containment via realpath gegen die Projekt-Root. search_code full:
|
||||
60-Zeilen-Fenster.
|
||||
- NACHTRAG 2026-08-28 (empirisch): get_code_snippet auf dem Modul-Knoten
|
||||
(<projekt>.<pfad.ohne.endung>, Label Module) liefert die GANZE Datei
|
||||
(source mit start_line 1..N). Der Reader laedt Dateien darum ueber
|
||||
Modul-Snippets; die Fenster-Semantik oberhalb des 500-Zeilen-Caps wird
|
||||
im W2-Smoke gemessen und in verification/w2/reader.json dokumentiert.
|
||||
- MCP-Kern insgesamt: 15 Tools in statischer TOOLS[]-Tabelle
|
||||
(src/mcp/mcp.c:376-711) plus Alias trace_call_path (mcp.c:11564); drei
|
||||
Tool-Profile all/analysis/scout; tools/list paginiert (Seitengroesse 8).
|
||||
|
||||
## 3. /api-Routen (30 benannte Routen plus / und /assets/*)
|
||||
|
||||
GET-Routen (src/ui/http_server.c:2545-2714): /__cbm/ui-readiness
|
||||
(HMAC-Challenge), /api/layout, /api/tree, /api/symbol-history, /api/who,
|
||||
/api/symbol, /api/metrics, /api/why, /api/handout, /api/blast, /api/bridges,
|
||||
/api/scent, /api/trace, /api/impact, /api/flows, /api/flow, /api/repo-info,
|
||||
/api/index-status, /api/ui-config, /api/browse, /api/adr,
|
||||
/api/project-health, /api/processes, /api/logs, /api/ui-log (Schwanz der
|
||||
Frontend-Logdatei). POST: /rpc, /api/index, /api/adr, /api/ui-log (das
|
||||
Frontend schreibt Konsole, Fehler und gescheiterte Anfragen in
|
||||
`<cache_dir>/logs/ui.log`). DELETE: /api/project. Dazu / und /assets/* aus
|
||||
dem Embedding.
|
||||
- /api/tree liefert den indexierten Datei-Baum aus dem Graphen (empirisch
|
||||
bestaetigt), NICHT das Dateisystem.
|
||||
- /api/browse ist ein Verzeichnis-Picker fuer die Index-Anlage (empirisch:
|
||||
liefert dirs/parent/roots, keine Dateiinhalte).
|
||||
- Einen Datei-Streaming-Endpoint gibt es NICHT (kein /api/file; Luecke, siehe
|
||||
UPSTREAM-ASKS.md). mtime wird nirgends geprueft.
|
||||
- Nicht gematchte Pfade: 404; ohne eingebettetes Frontend antwortet GET /
|
||||
mit 404 "no frontend embedded".
|
||||
|
||||
## 4. Server-Betrieb
|
||||
|
||||
- Ein Binary, drei Betriebsarten: (a) ohne Argumente stdio-MCP-Server als
|
||||
Thin-Client eines per-Account-Daemons, (b) `daemon start|stop|status`,
|
||||
(c) `cli <tool>` als daemonlose Einmal-Kommandos (stdin-JSON).
|
||||
- Die UI gehoert dem Daemon. `daemon start --open --port=9749` startet
|
||||
permanent, aktiviert UI-Config und oeffnet den Browser; alternativ
|
||||
persistieren `--ui=true --port=N` beim MCP-Start dieselbe Config.
|
||||
Default-Port 9749 (src/ui/config.h:13).
|
||||
- Empirisch: im stdio-Modus startet der HTTP-Server erst NACH einem
|
||||
MCP-initialize-Handshake auf stdin; stdin-EOF beendet den Prozess. Fuer
|
||||
Tests: stdin offen halten, initialize senden, dann auf GET / == 200 pollen.
|
||||
- WICHTIG fuer Tests: --ui/--port sind PERSISTIERTE Settings. Testlaeufe
|
||||
brauchen ein isoliertes HOME, sonst landet die Config im echten
|
||||
Nutzerprofil. Der Index liegt als <projekt>.db unter
|
||||
${CBM_CACHE_DIR:-~/.cache/codebase-memory-mcp}.
|
||||
- Indexiert wird per MCP-Tool, `cli index_repository`, POST /api/index oder
|
||||
optional auto_index (default aus). Git-basierter Polling-Watcher fuer
|
||||
Inkremental-Reindexing im Daemon.
|
||||
- Fixture-Messung (Korrektur 2026-08-29, Audit-Befund 16: UNSERE Kopie
|
||||
mit HERKUNFT.md, 10 .ts- plus Konfig-Dateien): index 76 Knoten /
|
||||
178 Kanten, deutlich
|
||||
unter 1s; Server-Readiness nach Handshake ~2-3s.
|
||||
|
||||
## 5. CORS, Host-Politik, Bindung
|
||||
|
||||
- Bindung hart auf 127.0.0.1 (httpd.c:248, htonl(0x7F000001)); httpd.c ist
|
||||
ein eigener single-threaded HTTP/1.1-Transport.
|
||||
- /rpc und /api verlangen Host-Header exakt 127.0.0.1:PORT oder
|
||||
localhost:PORT (Schutz gegen DNS-Rebinding, http_server.c:2403-2414).
|
||||
- Origin-Politik: NUR die eigene Server-Origin wird reflektiert; ein anderer
|
||||
localhost-Port ist bewusst ein anderes Principal und bekommt 403
|
||||
"forbidden origin" (http_server.c:84-119; empirisch bestaetigt).
|
||||
Preflight-OPTIONS laeuft ebenfalls erst nach dem Host/Origin-Gate.
|
||||
- Keine Auth auf /api und /rpc; Schutzmodell = Loopback-Bind + Host-Check +
|
||||
Origin-Check + Content-Type-Zwang.
|
||||
- Konsequenz fuer CodeAtlasWeb: same-origin Auslieferung durch den C-Server
|
||||
(Embedding) oder Dev-Proxy nach dem Vorbild des PR (Vite-Proxy mit
|
||||
Origin-Rewrite). Direkter Cross-Port-fetch ist unmoeglich.
|
||||
|
||||
## 6. Traces (ingest_traces, beobachtete Kanten)
|
||||
|
||||
- ingest_traces ist auf diesem Branch ECHT (v0.9.0 war ein Stub): Array aus
|
||||
Call-Pfaden qualifizierter Namen (2..256 Hops) oder caller/callee-Paaren,
|
||||
optional count und Run-Label. Nur Paare, deren beide Endpunkte indexierte
|
||||
Symbole sind, werden gespeichert; bis 10 unaufgeloeste Namen werden
|
||||
zurueckgemeldet.
|
||||
- Ablage qn-keyed (ueberlebt Reindexing) in observed_calls (count,
|
||||
first_seen, last_seen, Label) und observed_paths (path_json), Cap 200000
|
||||
Paare pro Projekt mit Ganz-Run-Eviction; Ingest in einer Transaktion
|
||||
(mcp.c:11433-11514).
|
||||
- Lesepfad HEUTE: ausschliesslich GET /api/trace und GET /api/flow haengen
|
||||
pro Hop ein observed-Objekt {count,label,last_seen} an
|
||||
(cbm_atlas_attach_observed, http_server.c; Memo-Cap 64 Knoten). KEIN
|
||||
observed-Flag an Graph-Kanten, KEINE query_graph/Cypher-Exposition (kein
|
||||
r.observed), /rpc erlaubt ingest_traces nicht.
|
||||
- observed_paths ist aktuell write-only (kein SELECT im ganzen src/): die im
|
||||
Schema-Kommentar angekuendigte Ganz-Pfad-Anzeige existiert noch nicht.
|
||||
- Frontend: FlowsTab zeigt ObservedChip "observed xN", eine
|
||||
Alle-Hops-beobachtet-Zeile und einen Freshness-Footer
|
||||
(graph-ui/src/lib/observed.ts, FlowsTab.tsx).
|
||||
|
||||
## 7. Galaxy (3D-Graph)
|
||||
|
||||
- Rendering: three.js via @react-three/fiber, Bloom via postprocessing; kein
|
||||
Client-Force-Layout. ALLE Positionen kommen fertig vom Server:
|
||||
GET /api/layout aus layout3d.c (Verzeichnis-Cluster-Ring, Z aus Call-Tiefe,
|
||||
ankergebundenes ForceAtlas2 mit Barnes-Hut-Octree), Regionen aus
|
||||
layout_regions.c (geseedetes Leiden, golden-angle Kugel-Seed).
|
||||
- Determinismus: Hash-geseedete Jitter-LCG, stabile Knotenreihenfolge
|
||||
(ORDER BY name,id), geseedetes Leiden; Regionen serverseitig pro
|
||||
(project, indexed_at) gecacht. Stabil ueber Reloads bei unveraendertem
|
||||
Index. Ein expliziter Determinismus-Test fehlt (nur Konstruktion).
|
||||
- Interaktion heute: Raycast-Klick (Highlight + Nachbarn + Detail-Panel +
|
||||
URL-Deep-Link ?node=®ion=), Hover, Sidebar-Suche, Region-Scent,
|
||||
OrbitControls mit Damping, Idle-Autorotation, semantischer Zoom
|
||||
(ApproachWatcher), 2D-Minimap mit Kamera-Marker.
|
||||
- Es gibt BEREITS eine flyTo-Kamera-Animation: CameraAnimator lerpt Kamera
|
||||
und OrbitControls-Target mit ease-out cubic. Focus-Follow (Anforderung 3)
|
||||
kann darauf aufsetzen.
|
||||
- Kapselung: GraphScene ist eigenstaendig und rein props-gesteuert (eigener
|
||||
Canvas, cameraTarget/highlightedIds als Props); GraphTab haelt die Szene
|
||||
nach erstem Besuch gemountet (invisible statt display:none, WebGL-Kontext
|
||||
bleibt erhalten; App.tsx:81-87). Kein Event-Bus: Fokus von aussen geht
|
||||
heute nur ueber Props oder URL-Parameter. ErrorBoundary um die Szene.
|
||||
- Fuer ein zweites Panel relevant: DPR geklemmt auf [1,1.5], antialias aus,
|
||||
Composer-Multisampling 0 (GraphScene.tsx:73-74).
|
||||
|
||||
## 8. Embedding und Auslieferung (fuer unsere Integration)
|
||||
|
||||
- Build-Kette: `make -f Makefile.cbm cbm-with-ui` = frontend (npm ci + vite
|
||||
build) -> scripts/embed-frontend.sh dist -> Objektdateien + generiertes
|
||||
src/ui/embedded_assets.c -> Link (Makefile.cbm:1068-1081).
|
||||
- embed-frontend.sh sammelt ALLE Dateien unter dist/ per find ein: es gibt
|
||||
KEINE gepinnte Asset-Liste. Neue Dateien in dist/ werden beim naechsten
|
||||
embed automatisch eingebettet und vom Server ausgeliefert
|
||||
(strcmp-Lookup-Tabelle in embedded_assets.c).
|
||||
- Auslieferung: GET / = index.html (no-cache), /assets/* immutable, strikte
|
||||
Airgap-CSP ohne externe Hosts, nosniff.
|
||||
- Integrationspfad fuer CodeAtlasWeb ohne jede Quell-Aenderung am Clone:
|
||||
eigene Assets zusaetzlich nach cbm/graph-ui/dist/ legen und embed + Link
|
||||
neu laufen lassen (Build-Artefakte, kein Source-Change, git-Tree des
|
||||
Clones bleibt clean). Offizieller Upstream-Weg bleibt Ask 5.
|
||||
|
||||
## 9. Konsequenzen fuer W1 (Architektur-Festlegungen aus dem Inventar)
|
||||
|
||||
1. Unser Stack passt sich dem PR an: React 19 + TypeScript + Vite 6
|
||||
(+ Tailwind 4 optional; unser Terminal-Look ist ohnehin eigenes CSS).
|
||||
2. Transport: fetch auf /rpc (tools/call) + /api-Routen; Dev und Tests ueber
|
||||
Vite-Proxy mit Origin-Rewrite nach PR-Vorbild; Ports >= 4200.
|
||||
3. Quelltext fuer den Reader kommt bis zur Erfuellung von Ask 1 per
|
||||
get_code_snippet (symbolweise, 500-Zeilen-Cap) plus ehrlicher
|
||||
KnowledgeState-Anzeige fuer nicht abgedeckte Dateiteile.
|
||||
4. Focus-Follow baut auf CameraAnimator/props-gesteuertem GraphScene auf
|
||||
(Portierung der Anbindung, nicht der Szene).
|
||||
5. Traces fuer den BUG-Wizard kommen aus /api/trace und /api/flow, nicht aus
|
||||
Cypher (bis Ask 4 erfuellt ist).
|
||||
@@ -1,344 +0,0 @@
|
||||
# CodeAtlasWeb: Plan v1 (2026-08-28)
|
||||
|
||||
Das lokale Verstehens-Frontend auf dem CBM-Stack. Unabhängiges Projekt,
|
||||
eigener Ordner, eigenes Repo. CodeAtlasIDE dient NUR als Referenz und wird
|
||||
von hier aus nie verändert.
|
||||
|
||||
## 1. Mission
|
||||
|
||||
Martins Zielbild umsetzen: Das Produkt läuft komplett lokal über localhost,
|
||||
ausgeliefert vom CBM-C-Server (PR 1860, Branch feat/atlas-r1), ohne Electron,
|
||||
air-gapped per Default. Es sieht aus wie eine IDE, ist aber eine Lese-IDE:
|
||||
Man guckt, versteht und navigiert; editiert wird woanders. Der Look ist der
|
||||
CODEATLAS-Terminal-Stil (siehe Abschnitt 4). Die Verstehens-Logik kommt als
|
||||
Portierung aus CodeAtlasIDE, nicht als Neuerfindung.
|
||||
|
||||
Vier Sonderanforderungen von Bernhard/Martin (2026-08-28), alle verbindlich:
|
||||
|
||||
1. **Komplett lokal, kleine Modelle**: kein teures Cloud-LLM. Zwei lokale
|
||||
Modellklassen (etwa 1B und etwa 4B) plus ein Modus, in dem LLM komplett
|
||||
aus ist und alles deterministisch bleibt.
|
||||
2. **Optimierte LLM-Übergaben**: Das kleine Modell ist dumm; der Graph ist
|
||||
schlau. Ein Context-Compiler bereitet die Graph-Fakten so als Text auf,
|
||||
dass auch ein 1B-Modell kluge, kontextüberblickende Antworten geben kann.
|
||||
3. **Graph-Fenster mit Focus-Follow**: Der 3D-Graph (Galaxy aus PR 1860) ist
|
||||
dauerhaft in einem Fenster/Panel. Klick auf eine Funktion im Code oder im
|
||||
Twin dreht/zoomt die Kamera zu genau diesem Knoten.
|
||||
4. **Mehrere Einstiegsmodi statt Lernkurs**: Ziel ist nicht Coding lernen.
|
||||
Modus A: Projekt von Grund auf verstehen (geführte Reihenfolge, Configs
|
||||
zuerst). Modus B: Maintainer wählt selbst einen Entry Point (Route,
|
||||
Symbol, Suche) und startet DORT, ohne Config-Pflichtprogramm. Dazu die
|
||||
bestehenden Frage-Modi (Bug, Change) als Einstiege.
|
||||
|
||||
Arbeitsweise: komplett testgetrieben (icca-harness gilt, siehe Abschnitt 9).
|
||||
|
||||
## 2. Referenzen (lesen, nie verändern)
|
||||
|
||||
- **Referenz-Repo**: /Users/bernhard/Desktop/CodeAtlasIDE (Theia-IDE,
|
||||
v1.0.0-Stand). Von dort werden Logik-Module portiert. NIE von hier aus
|
||||
editieren, committen oder bauen. Die wichtigsten portierbaren Module:
|
||||
- theia-extensions/codeatlas-views/src/browser/twin/render-model.ts
|
||||
(Twin-Ansichtsmodell, 4 Tiefen, Ehrlichkeitsregeln)
|
||||
- .../pseudocode/pseudocode-builder.ts und flow-model.ts
|
||||
- .../diagrams/mermaid-builder.ts, .../impact/risk-rules.ts
|
||||
- .../search/semantic-search.ts (deterministisches Ranking)
|
||||
- theia-extensions/codeatlas-core/src/common/semantic-ir.ts
|
||||
(Fact/KnowledgeState/Evidence: das Ehrlichkeits-Typsystem)
|
||||
- theia-extensions/codeatlas-intelligence/src/node/ir/semantic-ir-builder.ts
|
||||
und provider/cypher.ts (die Fragen an den Graphen; werden auf /rpc
|
||||
umgezogen)
|
||||
- .../ai/data-policy-enforcer.ts (Payload-Bau, Sanitisierung, Leak-Scan)
|
||||
- .../tours/tour-generator.ts (deterministischer Topsort, Modus A)
|
||||
- tests/scaffold/*.test.mjs und tools/smoke-*.mjs als Muster für
|
||||
eingefrorene Abnahmetests und Playwright-Beweisläufe
|
||||
- **CBM Atlas (PR 1860)**: https://github.com/DeusData/codebase-memory-mcp/pull/1860
|
||||
Branch feat/atlas-r1. Beschaffen mit:
|
||||
`gh repo clone DeusData/codebase-memory-mcp cbm -- --branch feat/atlas-r1`
|
||||
(alternativ `git fetch origin pull/1860/head:atlas-r1`). Liefert: C-Server
|
||||
mit --ui, /rpc-Read-only-Allowlist, Galaxy (3D), Flows/Trace, Symbol-View,
|
||||
Changes, Dashboard, Metric-Wiki, i18n, echtes ingest_traces. Build:
|
||||
`make -f Makefile.cbm cbm-with-ui`. ERSTE AUFGABE: Frontend-Framework und
|
||||
/rpc-Surface inventarisieren, bevor irgendetwas geplant festgezurrt wird.
|
||||
- **Engine (Release-Stand)**: /Users/bernhard/.local/bin/codebase-memory-mcp
|
||||
(v0.9.0, CLI-per-Call). Gilt nur als Fallback; das Zielsystem ist der
|
||||
Server aus dem PR-Branch.
|
||||
- **Look-Referenz**: zwei Screenshots vom 2026-08-28 (17:15/17:16), Original
|
||||
unter /var/folders/.../NSIRD_screencaptureui_*/Bildschirmfoto*.png, besser:
|
||||
Bernhard legt Kopien nach ./design/ ab. Beschreibung in Abschnitt 4.
|
||||
|
||||
## 3. Architektur
|
||||
|
||||
```
|
||||
Browser (localhost) lokale Prozesse
|
||||
+--------------------------------+ +---------------------------+
|
||||
| SPA im CBM-Atlas-Frontend | <-> | CBM-C-Server (PR 1860) |
|
||||
| Reader (Monaco, read-only) | | --ui (statisches UI) |
|
||||
| Semantic Twin + Steps | | /rpc (Graph-Queries) |
|
||||
| Galaxy (Focus-Follow) | | /api/file (Datei-Stream) |
|
||||
| Flows/Wizard/Suche/Modi | +---------------------------+
|
||||
| Atlas-Chat (lokal) | <-> | llama-server (Sidecar) |
|
||||
+--------------------------------+ | 1B + 4B GGUF, optional |
|
||||
+---------------------------+
|
||||
```
|
||||
|
||||
Grundsätze:
|
||||
- **Kein eigenes Node-Backend.** Provider, IR-Builder und Context-Compiler
|
||||
laufen als TypeScript im Browser; Transport ist fetch auf /rpc und die
|
||||
Datei-Streaming-Route des C-Servers. Die Provider-Abstraktion aus
|
||||
CodeAtlasIDE wird übernommen (Interface identisch, Transport neu), damit
|
||||
die portierten Module unverändert funktionieren.
|
||||
- **C-Server bleibt Martins Hoheit.** Fehlende Endpoints werden als
|
||||
Upstream-Asks gesammelt (Abschnitt 8), nicht selbst in C gehackt. Bis ein
|
||||
Ask erfüllt ist, wird die Lücke ehrlich im UI benannt (KnowledgeState
|
||||
'unsupported'), niemals umschifft durch Behauptungen.
|
||||
- **Monaco read-only** als Code-Reader (MIT, läuft in jedem Browser):
|
||||
Syntax-Highlighting client-seitig, unsere Schritt-Badges als Decorations,
|
||||
Cursor-/Klick-Events an den Twin. Kein LSP, kein Editieren in v1.
|
||||
- **Air-gapped per Default**: Ohne Zutun des Nutzers verlässt kein Byte die
|
||||
Maschine. Der llama-Sidecar ist localhost-only. Ein Netz-Deny-Gate wie in
|
||||
CodeAtlasIDE (lsof-Sampling, 0 outbound) wird von Anfang an mitgeführt.
|
||||
- **Ports**: UI/RPC nutzt den Port des C-Servers; llama-server auf 4141;
|
||||
Test-Instanzen ab 4200. NIEMALS 3000-3019 und niemals 3001 (belegt durch
|
||||
das Nachbarprojekt CodeAtlasIDE).
|
||||
|
||||
## 4. Look and Feel (aus den Referenz-Screenshots)
|
||||
|
||||
Terminal-Ästhetik, ernst und ruhig, keine Verspieltheit:
|
||||
- Grundton fast schwarz (#0b0f0e-Bereich), Panels minimal abgesetzt,
|
||||
1px-Rahmen in dunklem Grün-Grau.
|
||||
- Akzent: Phosphor-Grün für Marke ("CODEATLAS"), aktive Tabs, Primäraktionen
|
||||
([finish]), Schritt-Badges; Sekundärakzent Cyan/Blau für Symbolnamen,
|
||||
Rot/Magenta sparsam für Fehlerpfade ("may raise").
|
||||
- ALLES monospace. Menüzeile im Stil `[f]ile [e]dit [v]iew [a]tlas
|
||||
[t]erminal [?]help`: jeder Menüpunkt trägt seinen Buchstaben-Shortcut,
|
||||
Keyboard-first ist Pflicht, Maus ist optional.
|
||||
NACHTRAG 2026-08-29 (Bernhards Anweisung "entferne alles, was Dinge
|
||||
verspricht, die wir nicht können"): Die Zeile führt nur noch Punkte,
|
||||
hinter denen etwas liegt. `[f]ile`, `[e]dit`, `[v]iew` und `[t]erminal`
|
||||
sind entfernt, weil dieses Produkt eine Lese-IDE ist: es editiert
|
||||
nicht, es führt nichts aus, es hat kein Terminal. Geblieben sind
|
||||
`[a]tlas` mit seinen vier Aktionen und `[?]help`, das eine eingebaute
|
||||
Hilfeseite öffnet, welche genau diese Grenzen zuerst benennt. Die
|
||||
Buchstaben-Shortcuts liegen seither auf Alt/Option, damit Tippen in
|
||||
der Kommandozeile ankommt statt versehentlich ein Panel zu öffnen.
|
||||
- Kopfzeile: Marke + Versions-Chip (z.B. `v0.1.0-dirty`), rechts
|
||||
Status-Chips. Fußzeile: dauerhafte Kommandozeile
|
||||
`> type a command or ask the atlas...` (Command-Palette und Chat-Eingang
|
||||
in einem), darunter Statusleiste.
|
||||
- Editor-Gutter mit nummerierten Schritt-Badges (1), (2) an den Call-Sites,
|
||||
synchron zum STEPS-Panel rechts (CodeAtlasIDE-P6-Verhalten als Vorbild).
|
||||
- Rechtes Panel "SEMANTIC_TWIN": flow(symbol)-Kopf, ASCII/SVG-Sequenzkasten,
|
||||
prev/next-Stepper, STEPS-Liste, aufklappbare Fakten (query, may raise).
|
||||
- Untere Karte für Touren/Modi: Schrittzähler `STEP 10/10` mit
|
||||
Blockzeichen-Fortschritt, Prosa-Erklärung, Aktionen `[<-] prev
|
||||
[Enter] finish [q] exit [d] diagram`.
|
||||
- Ehrlichkeitszeilen bleiben sichtbar wie im Vorbild ("Nobody wrote that
|
||||
description. CodeAtlas put it together from what the index recorded...").
|
||||
|
||||
## 5. Lokale LLM-Strategie (Anforderung 1)
|
||||
|
||||
- **Runtime**: llama.cpp `llama-server` (MIT) als einziger Inferenzprozess,
|
||||
OpenAI-kompatibles HTTP auf localhost:4141. Start/Stop übernimmt das
|
||||
Produkt (Sidecar-Manager im UI sichtbar: Modell, Status, RAM). Kein
|
||||
Cloud-Fallback. Erste Iteration darf ein lokal installiertes Ollama als
|
||||
Alternative erkennen, aber llama-server ist der Referenzpfad.
|
||||
- **Modellwahl per Recherche, nicht per Vorurteil (Nutzeranweisung
|
||||
2026-08-28)**: Welche konkreten Modelle die 1B- und die 4B-Klasse
|
||||
besetzen, entscheidet eine eigene Recherche zu Beginn von W5 (Stand
|
||||
August 2026), NICHT eine Vorfestlegung. Vielleicht ist es Google/Gemma,
|
||||
vielleicht Qwen, vielleicht etwas Neues. Vorgehen:
|
||||
- Web-Recherche über seriöse Quellen (offizielle Modell-Releases,
|
||||
lmarena/LMSYS, Hugging-Face-Leaderboards, unabhängige Evals; SEO-
|
||||
Statistikseiten zählen nicht) über die Kandidatenfelder der Klassen
|
||||
~0.5-2B und ~3-5B (z.B. Qwen3, Gemma 3, Llama 3.x, Phi-4-mini,
|
||||
SmolLM3, und was August 2026 sonst aktuell ist).
|
||||
- Kriterien in dieser Reihenfolge: (1) Instruction-Following- und
|
||||
Text-Verständnis-Benchmarks der Klasse, (2) kommerzielle Lizenz
|
||||
(Apache/MIT bevorzugt; Gemma-Terms und Llama-Lizenz sind dokumentierte
|
||||
Findings, kein K.O., aber begründungspflichtig), (3) GGUF-
|
||||
Verfügbarkeit und Qualität quantisiert (Q4_K_M), (4) Tempo auf Apple
|
||||
Silicon, (5) Deutsch- und Englisch-Qualität.
|
||||
- Ergebnis als ADR mit Quellen committen; zusätzlich muss die eigene
|
||||
Eval-Suite (Abschnitt 6, 40 goldene Fragen) die Top-2-Kandidaten je
|
||||
Klasse GEGENEINANDER messen, und der Sieger der eigenen Eval schlägt
|
||||
den Benchmark-Ruf. Modell-Downloads sind der einzige erlaubte
|
||||
Netzzugriff des Projekts und passieren einmalig, dokumentiert, vor dem
|
||||
Air-gap-Beweis.
|
||||
- Bis die Recherche gelaufen ist, gilt Qwen3 (1.7B/4B, Apache-2.0) nur
|
||||
als Platzhalter für Architektur-Annahmen (Kontextfenster-Budgets).
|
||||
- **Opt-out ist ein Produktmodus**: Schalter "LLM: aus" (Default bei
|
||||
Erststart). Aus heißt aus: Sidecar wird nicht gestartet, kein Prozess,
|
||||
keine Ports, UI zeigt überall die deterministischen Texte. Die
|
||||
Policy-Mechanik aus CodeAtlasIDE (committete .codeatlas/policy.json
|
||||
schlägt Nutzerpräferenz) wird sinngemäß übernommen: eine Firma kann
|
||||
LLM-aus erzwingen.
|
||||
- **Budget-Realismus**: 1B bekommt maximal ~3k Token Kontext, 4B maximal
|
||||
~8k. Der Context-Compiler (Abschnitt 6) MUSS darunter bleiben; Antworten
|
||||
sind kurz, extraktiv, zitierend. Kein Agent, keine Tools, kein Editieren.
|
||||
- **Modellbeschaffung: der Leser wählt, das Programm liefert nichts mit
|
||||
(Nachtrag W10, 2026-08-29, Anforderung Martin)**. Zwei Sätze weiter oben
|
||||
stehen "Start/Stop übernimmt das Produkt" und Qwen3 als Platzhalter. Beides
|
||||
ist überholt und bleibt als Planungsstand stehen; hier steht, was gilt:
|
||||
- Das Produkt startet keinen Prozess. Eine SPA ohne eigenes Backend kann
|
||||
das nicht (ADR 0001, Abschnitt "Engine-Entscheidung"); `llm/start.sh`
|
||||
startet, die Oberfläche erkennt, zeigt und leitet an.
|
||||
- `llm/start.sh` fährt in der Vorgabe den Router-Modus über ein
|
||||
Cache-Verzeichnis (`--models-dir`, `--models-max`, `--models-autoload`)
|
||||
und hat keinen fest verdrahteten Dateinamen mehr in seiner Vorgabe. Das
|
||||
Modell kommt als Parameter oder aus der Umgebung
|
||||
(`ATLAS_MODEL`, `ATLAS_MODELS_DIR`, `ATLAS_CTX`).
|
||||
- Das Einstellungen-Panel zeigt, was im Cache des Lesers liegt, schaltet
|
||||
ohne Neustart zwischen diesen Modellen um (Feld `model` im Request) und
|
||||
stellt für alles andere den fertigen `-hf`-Aufruf bereit, mit dem Satz
|
||||
dazu, dass er ins Netz geht und wohin er lädt. Die Anwendung selbst lädt
|
||||
nichts herunter und zeigt deshalb auch keinen Fortschritt.
|
||||
- Die sechs Kandidaten der W5-Eval bleiben als Vorschlag mit ihren
|
||||
gemessenen Zahlen; sie sind Empfehlung, nicht Festlegung. Die Wahlen
|
||||
`class-a` bis `class-b-gemma` bleiben als Reproduktionsgriffe der Eval.
|
||||
- **Alles, was Rechenzeit kostet, an einem Ort (Nachtrag W10, 2026-08-29,
|
||||
Nutzerwunsch)**: "2D/3D oder sowas sollte immer zentral in einem
|
||||
Settings-Menü drin sein, nicht alles auf einer Oberfläche, wegen
|
||||
Rechenleistung falls jemand keine so starke Maschine hat." Der 2D/3D-
|
||||
Umschalter, die einzelnen Effekte (Leuchthöfe, Kantendichte,
|
||||
Beschriftungsentfernung), das Sparprofil und der Bildratendeckel sitzen im
|
||||
Einstellungen-Panel und sonst nirgends. Jede dieser Einstellungen nennt
|
||||
ihren Effekt in Zahlen, die auf der Maschine des Lesers an derselben Szene
|
||||
gemessen wurden (Bildrate vorher und nachher), nie als Versprechen; wo
|
||||
nichts messbar ist, sagt das Panel genau das. Schalter ohne Rechenkosten
|
||||
(Legende, Twin-Detailstufe, Kantenart-Filter) bleiben, wo sie sind:
|
||||
gebündelt wird das Teure, nicht alles.
|
||||
|
||||
## 6. Context-Compiler (Anforderung 2, das Kern-IP dieses Projekts)
|
||||
|
||||
Prinzip: Das Modell denkt nicht, es formuliert. Alles Denken passiert
|
||||
deterministisch im Graphen, der Compiler übersetzt es in dichte, eindeutige
|
||||
Textform. Bausteine:
|
||||
|
||||
1. **Frage-Klassifikation ohne LLM**: Regelbasiert (Frageform, @mentions,
|
||||
aktiver Fokus, aktiver Modus) auf eine von ~8 Frageklassen (Was-ist-das,
|
||||
Wer-ruft, Was-passiert-wenn, Wo-Entry, Warum-Fehler, Vergleich,
|
||||
Überblick, Sonstiges).
|
||||
2. **Fakten-Paket pro Frageklasse**: Jede Klasse hat ein festes Rezept,
|
||||
welche Graph-Fakten geholt werden (IR des Fokus, Callers/Callees mit
|
||||
Zeilen, Routen, Fehlerpfade, Trace-Beobachtungen, Closure-Auszug). Die
|
||||
Rezepte portieren die semantic-ir-builder-Fragen auf /rpc.
|
||||
3. **Kompression in Karten**: Fakten werden zu nummerierten Karten
|
||||
verdichtet (K1..Kn), je Karte maximal 3 Zeilen, kanonisches Vokabular
|
||||
("K3: createUser (services/userService.ts:23) ruft validateUser [Zeile
|
||||
24], wirft ValidationError [beobachtet 2x zur Laufzeit]"). Zahlenbudget
|
||||
pro Modellklasse hart im Code, Überschuss wird mit ehrlicher Notiz
|
||||
gekappt ("7 weitere Aufrufer nicht gelistet").
|
||||
4. **Antwortvertrag im Prompt**: Das Modell darf nur Karten zitieren
|
||||
(Format: Aussage [K3]), muss bei fehlender Karte "dazu liegt keine Karte
|
||||
vor, hole X mit @X" sagen, darf nichts erfinden, keine Ratschläge, keine
|
||||
Code-Änderungen. UI rendert [K3]-Zitate als Klick auf die Quelle.
|
||||
5. **Eval-Suite von Anfang an**: ~40 goldene Fragen gegen das
|
||||
Demo-Fixture mit erwarteten Kernaussagen (Substring-Checks), gefahren
|
||||
gegen 1B und 4B als Regressionstest; zusätzlich der harte Test, dass
|
||||
JEDE Behauptung ein [K]-Zitat trägt (Antworten ohne Zitat = rot).
|
||||
Deterministisch via Temperatur 0 und festem Seed.
|
||||
|
||||
## 7. Die weiteren Kernstücke
|
||||
|
||||
- **Galaxy mit Focus-Follow (Anforderung 3)**: Die 3D-Galaxy aus PR 1860
|
||||
wird eingebettet, nicht neu gebaut. Neu: eine Selektions-API (Kamera
|
||||
fliegt/zoomt animiert zum Knoten, Knoten pulsiert kurz), gefüttert von
|
||||
Reader-Klicks, Twin-Steps, Suche und Chat-Zitaten; Rückrichtung: Klick im
|
||||
Graph fokussiert Reader+Twin. Stabiles Layout (im PR vorhanden) ist
|
||||
Voraussetzung, sonst ist die Kamerafahrt wertlos.
|
||||
- **Einstiegsmodi (Anforderung 4)**, beim ersten Öffnen und jederzeit per
|
||||
Befehl:
|
||||
- "Neu im Projekt": Topsort-Tour (Port aus tour-generator.ts), Configs
|
||||
und Typen zuerst, wie gehabt.
|
||||
- "Ich kenne das Projekt": Entry-Point-Wahl (Liste der Entry Points und
|
||||
Routen + Bedeutungssuche), Tour startet DORT als Vorwärts-Closure-Walk
|
||||
statt beim Fundament. Kein Config-Pflichtprogramm.
|
||||
- "Bug jagen": der BUG-Wizard (statisch vs beobachtet, Port aus C26).
|
||||
- "Change abschätzen": Blast-Radius (Changes-Tab aus PR 1860 + unsere
|
||||
Risk-Rules).
|
||||
Wortwahl neutral halten (keine Lern-/Schul-Vokabeln); die Checkliste
|
||||
bleibt als stille Fortschrittsanzeige erhalten, wird aber nicht als
|
||||
Lernfunktion vermarktet.
|
||||
- **Bestehende PR-1860-Views** (Overview, Dashboard, Wiki, Flows) bleiben
|
||||
unangetastet nutzbar; CodeAtlasWeb ergänzt Reader+Twin+Modi+Chat als neue
|
||||
Tabs im selben Chrome und übernimmt dessen i18n- und Ehrlichkeits-
|
||||
Standards (Wort-Budgets, Denominatoren, Caps).
|
||||
- **Keine Lernversion in diesem Projekt** (Nutzerentscheidung 2026-08-28):
|
||||
Das P8-Teaching-System (Assignments, Quiz-Engine, Lehrer-Fluss) wird
|
||||
ausschliesslich im IDE-Projekt (CodeAtlasIDE) gebaut und ist hier
|
||||
explizit Non-Goal. CodeAtlasWeb behaelt nur die neutralen Einstiegsmodi
|
||||
aus diesem Abschnitt.
|
||||
|
||||
## 8. Upstream-Asks an Martin (früh klären, blockieren sonst)
|
||||
|
||||
1. Datei-Streaming-Endpoint (Datei lesen, relativer Pfad, Bytes + mtime;
|
||||
sein "über den C-Server auf die Platte streamen").
|
||||
2. /rpc-Abdeckung für die IR-Fragen (Callers/Callees mit r.line/args/
|
||||
confidence, USAGE, RAISES, CONFIGURES, Routen, IMPORTS, TESTS,
|
||||
detect_changes). Inventar in W0 ergibt die konkrete Lückenliste.
|
||||
3. Symbol-Deklarations-Auflösung (unser resolveSymbolNamed-Bedarf:
|
||||
search + Deklarationszeile in einem Call, sonst 2 Roundtrips).
|
||||
4. ingest_traces-Query-Surface (beobachtete Kanten pro Symbol abfragbar).
|
||||
5. Static-Serving der neuen Frontend-Routen unter --ui (falls sein Build
|
||||
die Asset-Liste pinnt).
|
||||
6. CORS/localhost-Bindung: /rpc strikt auf 127.0.0.1 (air-gap).
|
||||
|
||||
## 9. Arbeitsregeln (verbindlich)
|
||||
|
||||
- icca-harness für jeden Zyklus: Contract, eingefrorene Abnahmetests, Red
|
||||
bestätigt, Delegation an Opus-Implementierer, Gates, Commit erst nach
|
||||
Commit-Gate. Keine Ausnahmen für "nur Frontend".
|
||||
- Eigenes privates GitHub-Repo unter BernhardJackiewicz (bei Erstellung
|
||||
bestätigen lassen), Push nach jedem Commit-Gate. NIEMALS ins
|
||||
CodeAtlasIDE-Repo pushen. Martins Repo (DeusData/codebase-memory-mcp)
|
||||
ist absolut push-tabu: keine Pushes, keine PRs, keine Kommentare, keine
|
||||
Issues durch den Agenten, unter keinen Umständen. Upstream-Material wird
|
||||
ausschließlich lokal vorbereitet und an Bernhard übergeben; er reicht
|
||||
alles (auch Beiträge zu PR 1860) selbst händisch ein.
|
||||
- Referenz-Repo und dessen Vorschau (Port 3001!) sind tabu: nichts killen,
|
||||
nichts bauen, nichts ändern. Kopieren von Modulen: nur per Datei-Kopie
|
||||
hierher, mit Herkunftsnotiz im Header (gleicher Urheber, kein
|
||||
Lizenzproblem, aber Nachvollziehbarkeit).
|
||||
- Playwright-Klickstrecken + Screenshot-Baselines + Netz-Deny-Gate ab dem
|
||||
ersten UI-Zyklus (Muster aus tools/ des Referenz-Repos).
|
||||
- Deutsch antworten, keine em/en-Dashes, keine Claude/AI-Attribution im
|
||||
Repo, Anthropic-Key existiert in diesem Projekt NICHT (es gibt keinen
|
||||
Cloud-LLM-Pfad).
|
||||
|
||||
## 10. Phasenplan (Zyklen, jeder mit eigenem Contract)
|
||||
|
||||
- **W0 Inventar + Spike (zuerst, alles weitere hängt daran)**: PR-Branch
|
||||
clonen, `make -f Makefile.cbm cbm-with-ui` bauen, Frontend-Framework und
|
||||
/rpc-Allowlist dokumentieren; Spike: eine Datei via C-Server streamen und
|
||||
in Monaco read-only mit einer Decoration anzeigen; Lückenliste für
|
||||
Abschnitt 8 schreiben. AC: INVENTAR.md committet, Spike-Screenshot,
|
||||
Upstream-Ask-Liste an Bernhard übergeben.
|
||||
- **W1 Fundament**: Repo-Gerüst (Vite + Framework passend zum PR-Frontend),
|
||||
RpcTransport + Provider-Port + IR-Builder-Port gegen /rpc, Fact/
|
||||
KnowledgeState übernommen; Unit-Suite läuft; Netz-Deny-Gate steht.
|
||||
- **W2 Reader + Twin**: Monaco read-only, Datei-Baum, Cursor->Symbol->Twin
|
||||
(Render-Model-Port, 4 Tiefen), Schritt-Badges + STEPS-Sync, Look nach
|
||||
Abschnitt 4 als Design-System (Tokens + Baselines).
|
||||
- **W3 Galaxy Focus-Follow + Suche**: Einbettung, Selektions-API,
|
||||
Kamerafahrt, Rückrichtung; Bedeutungssuche (Port) im Fußzeilen-Command.
|
||||
- **W4 Einstiegsmodi**: Modus-Dialog, Topsort-Tour-Port, Entry-Point-Modus
|
||||
(Vorwärts-Walk), BUG-Wizard-Port (mit Trace-Anzeige aus dem echten
|
||||
ingest_traces des Branches), Changes/Blast-Radius-Anbindung.
|
||||
- **W5 Lokal-LLM**: Sidecar-Manager, Opt-out-Modus, Context-Compiler v1
|
||||
(Frageklassen, Karten, Zitate), Atlas-Eingabezeile als Chat, Eval-Suite
|
||||
(40 goldene Fragen, beide Modelle).
|
||||
- **W6 Härtung + Abnahme**: Air-gap-Beweis (0 outbound über gesamten
|
||||
Klickpfad), Performance-Budgets (Twin warm < 800ms via /rpc), i18n-Angleich
|
||||
an PR-Standards, unabhängiges Audit mit frischem Kontext, Demo-Video-
|
||||
Klickstrecke für Martin.
|
||||
|
||||
Reihenfolge W2/W3 darf tauschen, W5 braucht W1+W2. Jede Phase endet mit
|
||||
Bericht + Tag wie im Referenzprojekt. Das P8-Teaching-System ist bewusst
|
||||
NICHT Teil dieses Projekts (lebt im IDE-Projekt).
|
||||
|
||||
## 11. Erste Aufgabe der frischen Session
|
||||
|
||||
W0 starten: Lies zuerst diese Datei und CLAUDE.md komplett, dann PR-Branch
|
||||
beschaffen, bauen, Inventar schreiben. Bei Repo-Anlage (GitHub, privat)
|
||||
einmal bestätigen lassen. Nichts am Referenz-Repo und nichts an Port 3001
|
||||
anfassen.
|
||||
+106
-252
@@ -1,278 +1,132 @@
|
||||
# CodeAtlasWeb
|
||||
# CBM Lens Beta
|
||||
|
||||
## In this repository
|
||||
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.
|
||||
|
||||
This directory is the frontend that `codebase-memory-mcp --ui=true` serves.
|
||||
It came in as CodeAtlasWeb by Bernhard Jackiewicz (design and tracking in
|
||||
issue #1964); the text below this section is his README, kept as written.
|
||||
What the maintainers added on top:
|
||||
## Explore a project
|
||||
|
||||
- **Build and embed.** `make -f Makefile.cbm cbm-with-ui` runs `npm ci`
|
||||
(with `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`) and `npm run build` here, then
|
||||
embeds `dist/` into the binary. The dev server (`npm run dev`) listens on
|
||||
5173 and proxies `/rpc` and `/api` to a server on 9749, which is the
|
||||
contract `tests/test_ui_dev_proxy_security.sh` pins.
|
||||
- **CSP.** The served page may reach the server itself plus the loopback
|
||||
service explicitly allowed for optional local inference (`src/ui/http_server.h`,
|
||||
`CBM_UI_CSP_VALUE`). Nothing else, and a test holds that.
|
||||
- **Add project index** (project dropdown or alt+p, `src/projects/`): choose
|
||||
a repository with the daemon's local folder picker, name its index, follow
|
||||
indexing status, then open it. Files are indexed in place, not uploaded.
|
||||
The dialog can close while indexing continues; reopen its activity in the
|
||||
project dropdown. Status comes from the daemon, without invented percentages.
|
||||
This replaces the broad project-management dialog; coverage details stay in
|
||||
Coverage and daemon activity stays in System.
|
||||
- **The ADR workspace** (`?workspace=adr`, `src/adr/`): read, create, and
|
||||
explicitly save one Markdown decision document per project. Its sections
|
||||
can hold multiple decisions. This uses the existing `GET/POST /api/adr`
|
||||
store shared with `manage_adr`; it does not edit repository files.
|
||||
Unpublished drafts are retained per project in this browser tab's session
|
||||
storage. Before saving, the UI compares the current server document with
|
||||
the editing baseline and retains the draft if it detects a change. The
|
||||
existing API has no atomic version check, so this is not a document lock.
|
||||
The editor limits new saves to 8,000 UTF-8 bytes for compatibility with
|
||||
MCP section updates, and checks the 16 KiB serialized request limit.
|
||||
Editable preview examples live in `fixtures/adr/`; they are never
|
||||
automatically inserted into real projects or used as inferred graph facts.
|
||||
- **The frontend log** (`src/app/ui-log.ts`, installed in `src/main.tsx`):
|
||||
the console keeps printing, and a copy of every console line, uncaught
|
||||
error, unhandled rejection and failed `/rpc` or `/api` call is batched
|
||||
to `POST /api/ui-log`, which the server appends as JSON lines to
|
||||
`<cache_dir>/logs/ui.log` (rotating once at 5 MiB). `GET /api/ui-log`
|
||||
tails it for diagnostics, and a bug report can attach the file.
|
||||
The caught request failures reach the log
|
||||
through `src/provider/error-observer.ts`, the seam the two clients
|
||||
announce on before they throw.
|
||||
- **Gates in CI** (`.github/workflows/_test.yml`, job `test-ui`, which
|
||||
runs `scripts/ci/test-ui.sh`; run that script locally for the identical
|
||||
leg): `npm run test:unit`, `npm run check:style`,
|
||||
`npm run check:promises`, `npm run test:acceptance` and
|
||||
`npm run build`. `test:acceptance` is
|
||||
every frozen check except the two release-binding files
|
||||
(`release-current`, `release-proof-binding`), which tie the recorded
|
||||
release report to the commits of the original repository and cannot
|
||||
pass here. The browser proofs (`npm run smoke:*`) need a running server
|
||||
and Playwright browsers and stay a local venue; run them by hand with
|
||||
`npx playwright install chromium` first.
|
||||
- **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.
|
||||
|
||||
Eine lokale Lese-IDE fuer ein indiziertes Repository: Baum, Editor,
|
||||
Galaxie, Erklaerungen. Sie laeuft im Browser, sie hat kein eigenes Backend, und
|
||||
sie ist per Vorgabe abgeschottet. Der genehmigte Plan steht in `PLAN.md`, die
|
||||
Projektregeln in `CLAUDE.md`, der aufgenommene Fremdcode in `THIRD_PARTY.md`.
|
||||
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.
|
||||
|
||||
## Was hier laeuft
|
||||
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.
|
||||
|
||||
```
|
||||
npm run build # tsc + vite, Ergebnis in dist/
|
||||
npm run test:unit # vitest
|
||||
node --test tests/scaffold/ # die eingefrorenen Abnahmetests
|
||||
npm run check:style # lange Striche, Attribution, hartkodierte Texte
|
||||
npm run check:promises # Saetze, die etwas zusagen, und Flaechen ohne Wirkung
|
||||
## 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
|
||||
```
|
||||
|
||||
Die Beweislaeufe je Zyklus heissen `npm run smoke:<zyklus>` und schreiben ihr
|
||||
Ergebnis nach `verification/`. Jeder von ihnen laeuft unter einem
|
||||
Netz-Deny-Gate; was dabei an Verbindungen hinausging, steht als
|
||||
`outboundViolations` im Artefakt.
|
||||
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.
|
||||
|
||||
Der Analyse-Server (`cbm/`) wird getrennt gebaut und indiziert die Repositories.
|
||||
Diese Oberflaeche indiziert nichts selbst: sie liest `/api` und `/rpc`.
|
||||
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.
|
||||
|
||||
## Die Vorschau: nach jedem Bau neu starten
|
||||
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.
|
||||
|
||||
Eine Vorschau-Instanz besteht aus zwei Prozessen: dem C-Server auf einem
|
||||
eigenen Port und einem kleinen Auslieferer, der `dist/` bedient und `/api` sowie
|
||||
`/rpc` an den Server weiterreicht (`tools/lib/static-proxy.mjs`).
|
||||
## Experimental agent activity
|
||||
|
||||
**Der Auslieferer KOPIERT `dist/` beim Start.** Er tut das mit Absicht: sonst
|
||||
liefe ein `npm run build` mitten in eine offene Seite hinein und ersetzte
|
||||
Dateien, waehrend der Browser sie laedt. Die Folge ist aber, dass er spaetere
|
||||
Bauten **nie** sieht. Wer baut und dann die alte Vorschau neu laedt, sieht den
|
||||
Stand von vorhin, und nichts an der Oberflaeche sagt das.
|
||||
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.
|
||||
|
||||
Also gilt, ohne Ausnahme:
|
||||
|
||||
1. `npm run build`
|
||||
2. die laufende Vorschau beenden (SIGTERM an ihren Prozess)
|
||||
3. die Vorschau neu starten
|
||||
4. **pruefen, was wirklich ausgeliefert wird**, bevor irgendetwas beurteilt wird
|
||||
|
||||
Der vierte Schritt ist der, den man weglassen moechte, und genau der, der den
|
||||
Fehler findet. Er vergleicht die Bundle-Kennung im ausgelieferten `index.html`
|
||||
mit der im gebauten `dist/`:
|
||||
|
||||
```
|
||||
ls dist/assets/index-*.js # was der Bau erzeugt hat
|
||||
curl -s http://127.0.0.1:<ui-port>/ | grep -o 'index-[A-Za-z0-9_-]*\.js'
|
||||
```
|
||||
|
||||
Stimmen die beiden Kennungen nicht ueberein, liefert die Vorschau einen alten
|
||||
Bau aus, und jeder Befund an ihr ist ein Befund ueber Code von vorhin.
|
||||
|
||||
Warum das hier steht: zwischen 13:08 und 23:10 am 2026-08-29 sind fuenf Zyklen
|
||||
gebaut und nicht gesehen worden, weil die Vorschau vom Vormittag weiterlief. Ein
|
||||
Ablauf, der nur im Kopf einer Sitzung steht, ist beim naechsten Mal weg.
|
||||
|
||||
## Explorer chat context
|
||||
|
||||
Local browser chat automatically includes the current reader source: the exact
|
||||
marked text when a selection exists, otherwise the loaded file. A caret alone
|
||||
does not narrow the context. The source is a single replaceable data block in
|
||||
the system message, followed by conversation history. Each new message captures
|
||||
the current source; previous source blocks are not appended to the conversation.
|
||||
Optional graph evidence remains explicitly attachable.
|
||||
|
||||
The composer shows which file or selection will be used. Missing source and
|
||||
partial file loads are disclosed. The exact request is token-counted before
|
||||
generation; oversized files are rejected without silently shortening them.
|
||||
Mark a smaller range or start a new conversation to reduce context. Navigation
|
||||
does not alter an in-flight request, and Retry replays its original snapshot.
|
||||
Model loading remains opt-in, and inference runs in the browser.
|
||||
|
||||
## Die Agentenebene: was live arbeitende Agenten auf dem Graphen zeigen
|
||||
|
||||
Agent activity is experimental and hidden in normal builds. To include its
|
||||
workspace, hook setup, graph overlay, commands, and display controls, explicitly
|
||||
build with `VITE_EXPERIMENTAL_AGENTS=true npm run build` (or start the dev server
|
||||
with `VITE_EXPERIMENTAL_AGENTS=true npm run dev`). The value must be exactly
|
||||
`true`; URL parameters and saved preferences cannot enable it. Without this
|
||||
flag, activity polling is disabled and an old Agents workspace selection opens
|
||||
Explore. Browser-local chat is independent of this feature.
|
||||
|
||||
This is recorded tool activity from configured adapters, not automatic agent
|
||||
observability. The bundled adapter supports Claude Code PostToolUse; other
|
||||
clients need their own adapter. The flag does not install hooks or delete any
|
||||
existing events in the daemon's SQLite store. Even in an enabled build, the
|
||||
reader must turn activity on before the browser polls for events.
|
||||
|
||||
Arbeiten KI-Agenten in demselben Repository, kann die Galaxie sie zeigen: je
|
||||
Agent ein kleiner leuchtender Koerper, der den Symbolknoten umkreist, an dem er
|
||||
gerade arbeitet, und unten rechts ein kompaktes Instrument, das erklaert, was
|
||||
man sieht. Der Weg dorthin hat drei Stationen.
|
||||
|
||||
### Recorded tool activity through the daemon
|
||||
|
||||
`agents/hooks/atlas-trace.py` is the supported PostToolUse producer. Configure
|
||||
`ATLAS_PROJECT` to the exact indexed project name, `ATLAS_AGENT_NAME` for its
|
||||
label, and (only for a custom port) `ATLAS_DAEMON_URL=http://127.0.0.1:PORT`.
|
||||
The **Connect your agent** disclosure in Agent activity downloads this exact
|
||||
hook and generates a project-specific install command using `/api/repo-info`.
|
||||
On macOS/Linux with Python 3, run the reviewed command:
|
||||
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
|
||||
```
|
||||
|
||||
Installation is explicit and local: it preserves unrelated Claude settings,
|
||||
adds a PostToolUse entry to `.claude/settings.local.json`, and copies the hook
|
||||
into `.claude/hooks/`. It refuses conflicting hooks, symlinks and malformed
|
||||
configuration. It makes no network request or account change. Start a new Claude
|
||||
Code session after installation, then load recorded activity in the browser.
|
||||
Other coding clients require their own event adapter; they are not automatically
|
||||
observed. For manual configuration, run `python3 <path>/atlas-trace.py` in the
|
||||
client's PostToolUse hook. Its JSON tool event arrives on stdin. During normal
|
||||
hook execution it always exits zero and does not change the tool call's result.
|
||||
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.
|
||||
|
||||
The hook records timestamp, tool name, run ID, sequence number, path and optional
|
||||
line span/command. The hook records no file contents and no tool output. Commands
|
||||
and paths may still contain sensitive information: the entire data path stays
|
||||
on this workstation. The hook refuses external destinations, proxies and redirects.
|
||||
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 SQLite outbox at `~/.atlas-trace/events.jsonl.outbox.sqlite3`
|
||||
allocates sequence numbers safely across concurrent hooks. `ATLAS_TRACE_FILE`
|
||||
changes this path prefix. Up to 10,000 pending events survive daemon downtime;
|
||||
old pending entries expire at that limit. Delivery retries on the next hook,
|
||||
or explicitly with `python3 <repo>/graph-ui/agents/hooks/atlas-trace.py --flush`.
|
||||
A flush delivers up to 100 pending events from one project. No background bridge
|
||||
or additional listener is required. The old JSONL watcher and replay bridge remain
|
||||
historical fixture tools; they are not connected to the application by default.
|
||||
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.
|
||||
|
||||
Daemon log provenance is explicit. `GET /api/logs?project=NAME&min_level=warn`
|
||||
returns only records attributed to that exact indexed project; the filter applies
|
||||
before pagination and counts. Omitting `project` keeps the daemon-wide history;
|
||||
`scope=unattributed` shows records without known project ownership. Migration v3
|
||||
adds a nullable project column without assigning old records to the current
|
||||
repository. Paths, page URLs and message text are never used to guess ownership.
|
||||
Each record exposes `project: string | null`; responses expose `scope` and the
|
||||
selected `project`. A failed scoped database read returns an error instead of
|
||||
substituting unrelated memory-ring logs. New UI entries capture optional project
|
||||
ownership when recorded, before buffering; an explicit empty/null entry project
|
||||
stays unattributed even if a batch carries a project.
|
||||
## Activity and diagnostic APIs
|
||||
|
||||
`POST /api/agent-events` accepts `{project, events: [...]}` with at most 100 events.
|
||||
Required event fields are `ts`, `agent`, `run`, integer `seq`, `phase: start|end`
|
||||
and `tool`. Optional fields include `path`, `lines`, `detail`, `source`, `intent`
|
||||
(self-report), and explicit `replay`. Unknown fields are discarded.
|
||||
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 owns `<cache>/activity.db`, independent of replaceable project indexes.
|
||||
Its additive migrations use SQLite WAL and a busy timeout. Events have monotonic
|
||||
daemon cursors and unique `(project, run, seq)` identities. A small per-run retired sequence floor prevents expired events from reappearing after a delayed retry.
|
||||
10,000 events are retained globally; expired history is explicitly incomplete.
|
||||
A database generation distinguishes a restart (same state) from a replaced database.
|
||||
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.
|
||||
|
||||
### The view and logs
|
||||
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`.
|
||||
|
||||
Agent activity is off until enabled by the reader. It then polls
|
||||
`GET /api/agent-events?project=NAME&after=CURSOR&limit=200` over the same origin and
|
||||
port as the graph and MCP HTTP API. Failed requests retain the last accepted
|
||||
cursor; reconnects do not duplicate retained rows. Changing repositories resets
|
||||
the browser's activity state. `?agents=PORT` no longer selects another server.
|
||||
|
||||
Daemon log callbacks and every accepted frontend log level persist in the same SQLite
|
||||
journal. `GET /api/logs` preserves its `lines`/`total` response and adds structured
|
||||
`records`, receipt timestamps, severity, source, durable IDs and a database
|
||||
generation. `min_level=warn` selects warnings and errors, `min_level=error` only errors.
|
||||
`q=TEXT` applies a literal, ASCII case-insensitive substring search to timestamps,
|
||||
sources, messages and explicit project names. Scope, severity, search, counts and
|
||||
cursors all apply before the display limit. System Logs uses these same filters; 2,000 warning/error records
|
||||
are reserved independently of 3,000 routine records. `after=CURSOR` reads forward,
|
||||
while requests without a cursor return a recent snapshot. A failed SQLite store
|
||||
is reported as `persistent:false`, with the bounded in-memory fallback available.
|
||||
`GET /api/ui-log` now reads frontend JSON lines from that same journal. Its
|
||||
`path` names `activity.db`, `export_path` names the compatibility JSONL file, and
|
||||
`source: "sqlite"` / `persistent: true` identify the source. Version 4 adds explicit
|
||||
frontend origin and retains full bounded JSON evidence. On first open, at most the
|
||||
last 256 KiB of each old `ui.log.1` and `ui.log` are imported in one transaction;
|
||||
original receipt timestamps are preserved, project ownership remains unknown,
|
||||
and a partial import is disclosed. No log is reassigned to the selected project.
|
||||
|
||||
UI posts retain the existing `accepted`, `dropped`, `path` and `file_error` fields,
|
||||
adding `duplicates`, `persistent` and `storage_error`; a failed journal write
|
||||
returns HTTP 503 for retry. A retired per-session sequence floor prevents expired
|
||||
UI events from returning on an old retry. JSONL remains a rotating local export
|
||||
for existing `tail` workflows; changing it does not change the HTTP history.
|
||||
No local report or event is published externally by these routes.
|
||||
|
||||
Was die Ansicht zeigt und was sie ausdruecklich nicht zeigt:
|
||||
|
||||
- **Ein Agent ist ein Koerper, kein Knoten.** Die Ebene legt sich ueber den
|
||||
Graphen; Knoten- und Kantenfarben bleiben unangetastet. Die Farbe eines
|
||||
Agenten kommt aus seiner Kennung und ist nach einem Reload dieselbe; dazu
|
||||
traegt jeder Koerper einen Buchstaben, damit die Unterscheidung nicht allein
|
||||
an der Farbe haengt.
|
||||
- **Die Art der Arbeit steht in Form und Verhalten**: lesen ein weiter, ruhiger
|
||||
Orbit; schreiben ein enger; suchen kurze Pings an den Knoten, deren Name das
|
||||
Suchmuster traegt; testen ein pulsierender Koerper mit einer gestrichelten
|
||||
Linie zum geprueften Bereich, sofern der Befehl eine Datei nennt, die der
|
||||
Index kennt.
|
||||
- **Die Zuordnung ist so genau, wie sie sein kann.** Datei plus Zeilenbereich
|
||||
trifft den engsten passenden Symbolknoten, nur Datei den Modulknoten, ein
|
||||
Knoten ohne Endzeile wird als unsicher gekennzeichnet, und was sich nicht
|
||||
verorten laesst, steht im Instrument samt dem Rohereignis, statt zu
|
||||
verschwinden.
|
||||
- **Nichts wird gedeutet.** Kein Fortschritt, keine Prozentzahl, keine
|
||||
Bewertung. Eine Absichtszeile erscheint nur, wenn das Ereignis ein
|
||||
`intent`-Feld mitbringt, das der Agent selbst geschrieben hat, und dann
|
||||
gekennzeichnet als Selbstauskunft.
|
||||
- **Der Leser ist auch ein Akteur.** Welches Symbol er oeffnet, laeuft als
|
||||
eigener Akteur "you" durch dieselbe Ebene. Diese Ereignisse entstehen im
|
||||
Browser und gehen NICHT in die Ereignisdatei.
|
||||
|
||||
Der Schalter, der die ganze Ebene abschaltet, liegt im Einstellungen-Panel in
|
||||
der Gruppe "Drawing, and what it costs", zusammen mit allem anderen, was
|
||||
Rechenzeit kostet, und nennt dort seinen auf dieser Maschine gemessenen Effekt
|
||||
auf die Bildrate.
|
||||
|
||||
Die aufgezeichnete Datei, gegen die der Beweislauf faehrt, liegt unter
|
||||
`fixtures/agent-events/`; woraus sie entstanden ist und was daran geaendert
|
||||
wurde, steht vollstaendig in `fixtures/agent-events/HERKUNFT.md`.
|
||||
`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.
|
||||
|
||||
+80
-159
@@ -1,178 +1,99 @@
|
||||
# Fremder Code in CodeAtlasWeb
|
||||
# CBM Lens third-party notices
|
||||
|
||||
Diese Datei nennt jeden Bestandteil dieses Repositorys, der nicht hier
|
||||
entstanden ist, sagt woher er kommt, unter welcher Lizenz er steht und was an
|
||||
ihm geaendert wurde. Sie ist keine Formalie: der Kern der 3D-Ansicht ist
|
||||
uebernommener Code, und wer das aus den Dateien allein erschliessen muesste,
|
||||
wuerde es nicht tun.
|
||||
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.
|
||||
|
||||
Kurzfassung: alles unten ist MIT oder Apache-2.0, mit einer Zlib-Ausnahme, und die ist
|
||||
ausgewiesen (`postprocessing`, Zlib).
|
||||
## Galaxy source
|
||||
|
||||
## 1. Die Galaxie: DeusData/codebase-memory-mcp, graph-ui
|
||||
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`.
|
||||
|
||||
**Lizenz:** MIT License, Copyright (c) 2025 DeusData.
|
||||
**Quelle:** https://github.com/DeusData/codebase-memory-mcp, Branch
|
||||
`feat/atlas-r1` (lokaler Clone unter `cbm/`, Stand bda46086),
|
||||
Verzeichnis `graph-ui/src`.
|
||||
**Uebernommen am:** 2026-08-28.
|
||||
**Lizenztext:** vollstaendig im Clone unter `cbm/LICENSE`. Er erlaubt die
|
||||
Weiterverwendung samt Aenderung und verlangt, dass Copyright-Vermerk und
|
||||
Lizenztext erhalten bleiben. Genau dafuer traegt jede der sieben Dateien unten
|
||||
ihren eigenen Kopf mit Lizenz, Copyright, Quelle, Datum und Aenderungsliste;
|
||||
diese Datei ist die Uebersicht darueber, nicht ihr Ersatz.
|
||||
| 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` |
|
||||
|
||||
Uebernommen wurden sieben Dateien, die zusammen die Szene ergeben. Sie liegen
|
||||
unter `src/galaxy/`:
|
||||
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.
|
||||
|
||||
| Datei hier | Original in `graph-ui/src` | Was sie tut |
|
||||
|---|---|---|
|
||||
| `src/galaxy/GraphScene.tsx` | `components/GraphScene.tsx` | Canvas, Licht, Bloom, OrbitControls, Kamerafahrt, `computeCameraTarget` |
|
||||
| `src/galaxy/NodeCloud.tsx` | `components/NodeCloud.tsx` | die Knoten als Instanz-Kugeln oder Punkt-Sprites, samt Klick- und Hover-Raycast |
|
||||
| `src/galaxy/EdgeLines.tsx` | `components/EdgeLines.tsx` | die Kanten als ein additiv gemischtes `lineSegments`, Farbe je Kantentyp |
|
||||
| `src/galaxy/NodeLabels.tsx` | `components/NodeLabels.tsx` | Namensschilder als Canvas-Texturen auf den groessten Knoten |
|
||||
| `src/galaxy/HaloLayer.tsx` | `components/HaloLayer.tsx` | Coronas auf den zwoelf groessten Knoten |
|
||||
| `src/galaxy/types.ts` | `lib/types.ts` | die Datentypen der Layout-Antwort |
|
||||
| `src/galaxy/density.ts` | `lib/density.ts` | die Dichte-Skalen, die verhindern, dass eine grosse Galaxie zu einem weissen Fleck wird |
|
||||
## Packages
|
||||
|
||||
**Nicht uebernommen** wurde `components/NodeTooltip.tsx`: die Hover-Karte des
|
||||
Originals ist mit Tailwind-Klassen gebaut, deren Farben aus einem `@theme`-Block
|
||||
kommen, den dieses Projekt nicht hat. An ihrer Stelle steht
|
||||
`src/galaxy/NodeTooltipCard.tsx`, eigener Code in den Tokens dieses Projekts
|
||||
(`src/styles/tokens.css`), eingehaengt ueber die `renderTooltip`-Prop, die das
|
||||
Original selbst vorsieht.
|
||||
| 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 |
|
||||
|
||||
**Die Aenderungen an den uebernommenen Dateien**, alle auch im Kopf der
|
||||
jeweiligen Datei:
|
||||
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.
|
||||
|
||||
1. `GraphScene.tsx`: der Idle-Beobachter griff den Canvas mit
|
||||
`document.querySelector("canvas")` und damit den ersten im ganzen Dokument.
|
||||
In dieser Anwendung steht Monaco daneben, also greift er jetzt seinen
|
||||
eigenen ueber `useThree().gl.domElement`.
|
||||
2. `GraphScene.tsx`: `sceneRadius` lief bei jedem Render ueber alle Knoten und
|
||||
liegt jetzt in `useMemo` an den Knoten.
|
||||
3. `GraphScene.tsx`: die Zweige fuer `linked_projects` (Satelliten-Galaxien
|
||||
fremder Projekte) und `missed` (Geister-Cluster nicht indizierter Dateien)
|
||||
sind entfernt, samt der zugehoerigen Props und Typen.
|
||||
4. `GraphScene.tsx`: `ApproachWatcher` (Semantic Zoom) und `ViewTargetReporter`
|
||||
(Minimap-Feed) sind entfernt, samt der Props `onApproachNode` und
|
||||
`onViewTarget`. Beide bedienen Panels, die dieses Projekt nicht uebernimmt.
|
||||
5. `GraphScene.tsx`: der Import von `NodeTooltip` ist entfernt, siehe oben.
|
||||
6. `types.ts`: nur die vier Typen der Szene sind geblieben.
|
||||
7. `density.ts`: die localStorage-Funktionen sind entfernt; die Skalen selbst
|
||||
sind unveraendert.
|
||||
8. Alle sieben: Importpfade, Formatierung (4 Leerzeichen, einfache
|
||||
Anfuehrungszeichen) und die langen Gedankenstriche in den Kommentaren. Kein
|
||||
Verhalten haengt daran.
|
||||
## CodeAtlasIDE provenance
|
||||
|
||||
Was NICHT geaendert wurde, ist die Rechnung: Farbgebung, Skalierung,
|
||||
Kantenintensitaet, Kameraziel und Bloom sind Zeile fuer Zeile die des
|
||||
Originals. Eine eigene Kurve waere eine zweite Wahrheit ueber dasselbe Bild.
|
||||
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.
|
||||
|
||||
## 2. Die 3D-Bibliotheken
|
||||
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.
|
||||
|
||||
Diese Pakete kommen ueber `package.json` und liegen nicht im Repository. Sie
|
||||
sind exakt gepinnt, weil die uebernommene Szene gegen genau diese Fassungen
|
||||
geschrieben ist (dieselben Fassungen, die `graph-ui/package-lock.json` fuehrt).
|
||||
## Optional browser models
|
||||
|
||||
| Paket | Fassung | Lizenz | wofuer |
|
||||
|---|---|---|---|
|
||||
| `three` | 0.183.2 | MIT | die 3D-Bibliothek selbst |
|
||||
| `@react-three/fiber` | 9.5.0 | MIT | `Canvas`, `useThree`, `useFrame` |
|
||||
| `@react-three/drei` | 10.7.7 | MIT | nur `OrbitControls` und `Html` |
|
||||
| `@react-three/postprocessing` | 3.0.4 | MIT | `EffectComposer`, `Bloom` |
|
||||
| `postprocessing` | 6.38.3 | **Zlib** | Peer der Zeile darueber |
|
||||
| `three-stdlib` | 2.36.1 | MIT | nur der Typ von `OrbitControls` (devDependency) |
|
||||
| `@types/three` | 0.183.1 | MIT | Typen (devDependency) |
|
||||
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.
|
||||
|
||||
Die Zlib-Lizenz von `postprocessing` ist die einzige Nicht-MIT-Lizenz in dieser
|
||||
Liste. Sie ist ebenfalls freizuegig und verlangt im Wesentlichen, die Herkunft
|
||||
nicht falsch darzustellen und geaenderte Fassungen als geaendert zu kennzeichnen.
|
||||
Dieses Projekt aendert das Paket nicht, es benutzt es.
|
||||
The following metadata was recorded on 2026-09-08. Download sizes include tokenizer
|
||||
and configuration files and do not describe GPU memory requirements.
|
||||
|
||||
Die uebrigen Abhaengigkeiten des Projekts stehen unter MIT (React, Vite,
|
||||
Vitest, Monaco) beziehungsweise Apache-2.0 (TypeScript, Playwright;
|
||||
Korrektur 2026-08-29, Audit-Befund 7) und sind kein uebernommener Code, sondern
|
||||
Werkzeuge und Bibliotheken in ihrer veroeffentlichten Form.
|
||||
|
||||
## 3. Portierungen aus CodeAtlasIDE
|
||||
|
||||
`~/Desktop/CodeAtlasIDE` ist ein Projekt desselben Urhebers (Bernhard
|
||||
Jackiewicz) und keine fremde Quelle; die Module, die von dort kommen, tragen
|
||||
ihre Herkunftsnotiz im Dateikopf (Provider-Schicht, Semantic IR, Twin,
|
||||
`src/search/semantic-search.ts`). Sie stehen hier nur der Vollstaendigkeit
|
||||
halber: lizenzrechtlich ist daran nichts zu regeln.
|
||||
|
||||
## 4. Uebernommene Idee: der Topsort-Rundgang
|
||||
|
||||
`src/tours/tour-generator.ts` ist eine Portierung aus CodeAtlasIDE (Abschnitt 3)
|
||||
und traegt dort wie hier eine zweite, schwaechere Herkunftsangabe im Kopf: die
|
||||
Idee, einen Einstiegs-Rundgang aus der Struktur eines Projekts abzuleiten statt
|
||||
ihn von einem Modell schreiben zu lassen, stammt dem Konzept nach von
|
||||
Understand-Anything (MIT). Uebernommen ist der Gedanke, kein Quelltext: der
|
||||
Kahn-Lauf, die Rollenworte, die Saetze und der Zyklusbruch sind eigener Code.
|
||||
Die Angabe steht hier, weil eine Konzept-Anleihe nichts ist, was man erst dann
|
||||
nennt, wenn jemand fragt.
|
||||
|
||||
## 5. Optionale lokale Quelltext-Erklaerung
|
||||
|
||||
Die Browser-Laufzeit wird lokal mit dem Frontend ausgeliefert; Modellgewichte
|
||||
sind nicht enthalten. Ein Download erfolgt erst nach ausdruecklicher
|
||||
Aktivierung im Browser. Der Quelltext bleibt fuer die Erklaerung im Browser.
|
||||
|
||||
| Bestandteil | Fassung | Lizenz | Verwendung |
|
||||
|---|---|---|---|
|
||||
| `@huggingface/transformers` | 4.2.0 | Apache-2.0 | Textgenerierung und Chat-Tokenizer im Web Worker |
|
||||
| `onnxruntime-web` | 1.26.0-dev.20260416-b7804b056c | MIT | WebGPU-Laufzeit; MJS und WASM werden als lokale Assets gepackt |
|
||||
| `sharp` | 0.35.0 | Apache-2.0 | Transitive Node-Abhaengigkeit mit gezieltem Override; nicht Teil der Browser-Laufzeit |
|
||||
|
||||
Das optionale Modell ist
|
||||
[`onnx-community/Qwen2.5-Coder-0.5B-Instruct`](https://huggingface.co/onnx-community/Qwen2.5-Coder-0.5B-Instruct),
|
||||
festgelegt auf Revision `f0292f665fd307846ff3c318a91a1bc29d091492`
|
||||
und Variante `q4f16`. Die gewaehlte ONNX-Datei umfasst 554.935.833 Byte;
|
||||
mit Konfiguration und Tokenizer sind es 566.366.194 Byte. Der in den
|
||||
Hugging-Face-Metadaten angegebene SHA-256 der Gewichte ist
|
||||
`60c076ac0d3910881fe0cad75997e803459a5f71bb74bcbcd82407ef83db7ba0`;
|
||||
die Laufzeit berechnet diesen Hash nicht erneut.
|
||||
|
||||
Die Modellkarte der ONNX-Konvertierung nennt keine eigene Lizenz.
|
||||
Das offizielle Basismodell
|
||||
[`Qwen/Qwen2.5-Coder-0.5B-Instruct`](https://huggingface.co/Qwen/Qwen2.5-Coder-0.5B-Instruct)
|
||||
ist unter Apache-2.0 veroeffentlicht. Der Download verwendet ausschliesslich
|
||||
`huggingface.co` und dessen fuer diese Revision geprueften Weiterleitungs-Host
|
||||
`us.aws.cdn.hf.co`. Remote-Skripte werden nicht geladen.
|
||||
|
||||
Weitere Browser-Modelle (Metadaten und Download-Hosts am 2026-09-08 geprueft):
|
||||
|
||||
| Modell | Feste Revision | Download inkl. Tokenizer/Konfiguration | Lizenz |
|
||||
|---|---|---|---|
|
||||
| `onnx-community/Qwen3-0.6B-ONNX` | `da1453100cf3ff33ef56d17983fc7a8648706db6` | 578.917.626 Byte | Apache-2.0 |
|
||||
| `LiquidAI/LFM2.5-1.2B-Instruct-ONNX` | `10f72e70abf67ac0fd7ebf15bc5854726891d864` | 763.763.755 Byte | Liquid AI License; kommerzielle Bedingungen oberhalb 10 Mio. USD Jahresumsatz pruefen |
|
||||
| `onnx-community/Qwen3.5-2B-ONNX-OPT` | `2ea7886f48b926aca97de8b0e041ffca7e3ebaa9` | 1.402.850.762 Byte | Apache-2.0 |
|
||||
|
||||
Alle Varianten verwenden `q4f16`. Fuer Qwen3.5 werden nur Decoder und
|
||||
Text-Embeddings geladen; Vision-Dateien sind nicht in der Download-Freigabe.
|
||||
Die vollstaendigen Dateilisten und veroeffentlichten LFS-Hashes stehen in
|
||||
`src/browser-ai/model-policy.ts`. Hashes werden nicht erneut berechnet.
|
||||
Die angegebenen Bytes sind keine Aussage ueber GPU-Speicherbedarf.
|
||||
|
||||
Die Modellarchitekturen werden von Transformers.js 4.2.0 unterstuetzt. Das
|
||||
ist kein Benchmark oder Kompatibilitaetsnachweis fuer jedes Browser/GPU-Paar.
|
||||
Es wurden fuer diese Aenderung keine Modellgewichte heruntergeladen und
|
||||
keine Inferenz-Benchmarks ausgefuehrt. Der Browser begrenzt den gesamten
|
||||
Kontext auf 8192 Token inklusive 512 reservierter Antwort-Token; gezaehlt
|
||||
wird mit dem gewaehlten Chat-Template und Tokenizer. Eingaben werden nicht
|
||||
abgeschnitten. Qwen3 und Qwen3.5 laufen standardmaessig ohne Thinking.
|
||||
|
||||
## 6. Markdown-Darstellung im lokalen Chat
|
||||
|
||||
Die Chat-Darstellung verwendet die folgenden unveraenderten npm-Pakete.
|
||||
Die Lizenztexte stammen aus der jeweiligen installierten Datei `license`.
|
||||
|
||||
| Paket | Version | Lizenz | Copyright |
|
||||
| Model | Fixed revision | Download bytes | Recorded license |
|
||||
| --- | --- | --- | --- |
|
||||
| react-markdown | 10.1.0 | MIT | Espen Hovlandsdal |
|
||||
| remark-gfm | 4.0.1 | MIT | Titus Wormer |
|
||||
| [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
|
||||
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
# Upstream-Asks an Martin (CBM, PR 1860 / feat/atlas-r1)
|
||||
|
||||
Stand 2026-08-28, abgeleitet aus INVENTAR.md (Belege dort, Datei:Zeile
|
||||
relativ zum Clone). WICHTIG: Diese Liste wird ausschliesslich lokal gepflegt.
|
||||
Bernhard reicht alles selbst haendisch bei Martin ein; aus diesem Projekt
|
||||
heraus gibt es keine Pushes, PRs, Issues oder Kommentare Richtung
|
||||
DeusData/codebase-memory-mcp.
|
||||
|
||||
## Ask 1: Datei-Streaming-Endpoint (PRIO 1, blockiert den Reader)
|
||||
|
||||
Was fehlt: Ein Read-only-Endpoint, der eine indexierte Projektdatei komplett
|
||||
liefert (relativer Pfad rein, Bytes + mtime raus), z. B.
|
||||
GET /api/file?project=...&path=....
|
||||
Befund: Es gibt keinen solchen Endpoint (Routenliste http_server.c:2545-2714).
|
||||
/api/browse ist nur ein Verzeichnis-Picker; get_code_snippet verlangt
|
||||
qualified_name (symbolweise, harter 500-Zeilen-Cap, realpath-Containment);
|
||||
search_code full ist auf ein 60-Zeilen-Fenster gekappt. mtime prueft heute
|
||||
kein Pfad.
|
||||
Wunsch: gleiche Sicherheitsmechanik wie get_code_snippet (Containment gegen
|
||||
die Projekt-Root), plus mtime/size im Header fuer Cache-Invalidierung.
|
||||
NACHTRAG 2026-08-28: get_code_snippet auf dem Modul-Knoten liefert bereits
|
||||
die ganze Datei; der Reader nutzt das als Interim-Weg. Die Ask bleibt fuer
|
||||
den Rest bestehen: mtime/size fuer Cache-Invalidierung, verlaessliche
|
||||
Fenster-Semantik fuer Dateien ueber dem 500-Zeilen-Cap, und nicht
|
||||
indexierte Dateien (z.B. reine Assets), die kein Modul-Knoten abdeckt.
|
||||
|
||||
## Ask 2: Traces-Query-Surface via /rpc (PRIO 2)
|
||||
|
||||
Was fehlt: Beobachtete Kanten pro Symbol abfragbar ueber die /rpc-Flaeche
|
||||
(z. B. eigenes Read-Tool oder r.observed in query_graph).
|
||||
Befund: observed_calls/observed_paths werden nur von GET /api/trace und
|
||||
GET /api/flow angehaengt (cbm_atlas_attach_observed, Memo-Cap 64 Knoten);
|
||||
kein observed-Flag an Graph-Kanten, keine Cypher-Exposition; /rpc verbietet
|
||||
ingest_traces (richtig so), bietet aber auch keinen Lese-Ersatz.
|
||||
Zusatzbefund fuer Martin: observed_paths ist aktuell write-only (kein SELECT
|
||||
im ganzen src/); die im Schema-Kommentar (store.c:328-330) angekuendigte
|
||||
Ganz-Pfad-Anzeige existiert noch nicht.
|
||||
Interim bei uns: BUG-Wizard und Twin lesen observed nur ueber /api/trace und
|
||||
/api/flow; alles andere zeigt 'unsupported'.
|
||||
NACHTRAG 2026-08-28 (W4-Befund, verschaerft die Ask): cbm_atlas_attach_observed
|
||||
haengt observed NUR an statisch benachbarte Hops. Eine ingestierte Kante OHNE
|
||||
statisches Index-Gegenstueck (unser Testfall: listUsers -> validateUser) wird
|
||||
gespeichert (pairs_stored zaehlt sie), ist danach aber ueber KEINE Route mehr
|
||||
lesbar. Genau diese Kanten sind fuer die Bug-Jagd die interessantesten
|
||||
(beobachtet, aber vom Index nicht erwartet). Wunsch praezisiert: ein Read-Weg
|
||||
fuer observed-Kanten UNABHAENGIG von statischen Nachbarschaften (z.B. eigenes
|
||||
Tool observed_edges(project, symbol?) oder r.observed in query_graph).
|
||||
|
||||
## Ask 3: Offizieller Platz fuer Zusatz-Frontends unter --ui (PRIO 2)
|
||||
|
||||
Was fehlt: Ein gesegneter Weg, zusaetzliche statische Frontend-Routen mit
|
||||
auszuliefern (unser Reader/Twin-UI), ohne den graph-ui-Quellbaum anzufassen.
|
||||
Befund: embed-frontend.sh pinnt KEINE Asset-Liste (find ueber dist/), neue
|
||||
Dateien in dist/ werden automatisch eingebettet und serviert; CORS/Origin
|
||||
sind strikt same-server (http_server.c:84-119), ein anderer localhost-Port
|
||||
ist bewusst ein fremdes Principal (403 "forbidden origin"). Ein
|
||||
Cross-Port-Frontend ist damit ausgeschlossen; same-origin ist der einzige
|
||||
Produktweg.
|
||||
Wunsch: entweder ein dokumentierter extra-Assets-Ordner fuer den embed-Step
|
||||
oder eine offizielle Route fuer ein zweites SPA-Bundle.
|
||||
Interim bei uns: Dev/Tests ueber Vite-Proxy mit Origin-Rewrite (PR-Vorbild,
|
||||
graph-ui/vite.config.ts); Produktbeweis durch zusaetzliche Dateien in dist/
|
||||
vor dem embed (reine Build-Artefakte, kein Source-Change im Clone).
|
||||
|
||||
## Ask 4: Symbol-Aufloesung Cursor -> Deklaration (PRIO 3, vermutlich erfuellt)
|
||||
|
||||
Bedarf: Unser resolveSymbolNamed (Suche + Deklarationszeile in EINEM Call).
|
||||
Befund: search_graph liefert bereits qualified_name, Label, Datei und
|
||||
Zeilenbereich in einem Roundtrip (empirisch: "createUser ... Function
|
||||
src/services/userService.ts 23-36"). Das deckt den Bedarf voraussichtlich;
|
||||
offen ist nur, ob bei Namenskollisionen ein exakter Name-Filter noetig wird
|
||||
(exclude_entry_points/include_connected existieren bereits im Schema,
|
||||
mcp.c:441-442).
|
||||
Kein Handlungsbedarf, solange W1/W2 keine Gegenbeispiele finden.
|
||||
|
||||
## Ask 5: search_graph ohne is_test/is_exported in der flachen Form (PRIO 3)
|
||||
|
||||
Befund aus dem W1-Port: die flache, maschinenlesbare search_graph-Form
|
||||
(Parameter query, BM25 mit search_mode/rank) traegt KEINE is_test- und
|
||||
is_exported-Spalten; die name_pattern-Form liefert sie vermutlich, kommt
|
||||
aber als nach Modulen gruppierte Anzeigeform, die sich nicht sauber
|
||||
maschinell lesen laesst. Wunsch: entweder die flache Form um is_test und
|
||||
is_exported ergaenzen oder eine format=json-Variante fuer name_pattern.
|
||||
Interim bei uns: Suche laeuft ueber BM25; die beiden Flags bleiben ehrlich
|
||||
undefined statt erraten.
|
||||
NACHTRAG 2026-08-28 (W3): dieselbe flache Form traegt auch kein Fan-in. Der
|
||||
Rangfolge fehlt damit ihr Gleichstandsbrecher. Wir nehmen ihn ersatzweise aus
|
||||
`in_calls` der Layout-Antwort (/api/layout), weil dieselbe Oberflaeche sie
|
||||
ohnehin geladen hat; ohne geladenes Layout hat jeder Kandidat kein Fan-in,
|
||||
was nur bei Gleichstand etwas aendert. Ein `fan_in` in der Suchzeile waere
|
||||
der saubere Weg und wuerde die Kopplung zwischen Suche und Galaxie loesen.
|
||||
|
||||
## Bereits erfuellt (keine Asks, nur Anerkennung)
|
||||
|
||||
- Bindung strikt auf 127.0.0.1: httpd.c:248 (htonl(0x7F000001)), dazu
|
||||
Host-Header-Allowlist gegen DNS-Rebinding und Origin-Gate vor OPTIONS.
|
||||
Unser Air-gap-Ask aus der Planungsphase ist damit gegenstandslos.
|
||||
- /rpc-Read-only-Allowlist (12 Tools + manage_adr get/sections) mit
|
||||
Mutations-Guard und deaktivierten Background-Tasks: genau die Flaeche, die
|
||||
unser Provider-Port braucht (query_graph inkl. r.line/r.args/r.confidence).
|
||||
- ingest_traces ist echt (v0.9.0-Stub ersetzt), qn-keyed und reindex-fest.
|
||||
|
||||
## Kleine Hinweise (fuer Martins Review, keine Blocker fuer uns)
|
||||
|
||||
- /api/why mit guards=1 parst Caller-Dateien von Pfaden aus der DB ohne
|
||||
eigenen within-root-Check (Kontrast: get_code_snippet kanonisiert per
|
||||
realpath). Vermutlich unkritisch (DB ist lokal), aber inkonsistent.
|
||||
- traces.h:5-6 nennt OTLP-Helfer "Used by the MCP ingest_traces handler",
|
||||
der Handler nutzt sie nicht (nur test-gedeckter Code).
|
||||
- TabBar.tsx in graph-ui wird nirgends importiert (toter Baustein).
|
||||
|
||||
## Stand 2026-08-29 (Fortschreibung nach W4 bis W7)
|
||||
|
||||
Diese Liste wurde zuletzt am 2026-08-28 geschrieben; das Inventar hat
|
||||
seither dazugelernt. Was sich geaendert hat, in der Reihenfolge der Asks:
|
||||
|
||||
- **Ask 1 (Datei-Streaming)**: unveraendert offen, aber entschaerft. Der
|
||||
Reader laedt ganze Dateien ueber den Modul-Knoten
|
||||
(get_code_snippet mit der Modul-QN); der 500-Zeilen-Cap greift und die
|
||||
Fenster-Argumente werden vom Server ignoriert (gemessen, siehe
|
||||
verification/w2/reader.json: windowSemantics "ignored"). Der Rest der
|
||||
Ask bleibt: mtime und Groesse fuer Cache-Invalidierung, verlaessliche
|
||||
Fenster-Semantik oberhalb des Caps, und Dateien ohne Modul-Knoten.
|
||||
- **Ask 2 (Traces lesen)**: durch W4b praezisiert und wichtiger geworden.
|
||||
cbm_atlas_attach_observed haengt beobachtete Aufrufe NUR an statisch
|
||||
benachbarte Hops. Eine ingestierte Kante ohne statisches Gegenstueck
|
||||
(unser Testfall listUsers -> validateUser) wird gespeichert
|
||||
(pairs_stored zaehlt sie), ist danach aber ueber keine Route mehr
|
||||
lesbar. Genau diese Kanten sind bei der Fehlersuche die
|
||||
interessantesten: beobachtet, aber vom Index nicht erwartet. Der
|
||||
BUG-Wizard nennt seine zweite Liste deshalb ehrlich "Observed, not on
|
||||
the expected chains" und fragt den Index je Zeile einzeln.
|
||||
- **Ask 3 (Platz fuer Zusatz-Frontends)**: unveraendert offen und
|
||||
weiterhin der Grund, warum unsere Flaechen und die PR-1860-Views nicht
|
||||
in einem Chrome zusammenliegen (so auch im unabhaengigen Audit als
|
||||
bewusst akzeptierte Teil-Erfuellung dokumentiert).
|
||||
- **Ask 4 (Symbol-Aufloesung)**: erledigt, kein Handlungsbedarf.
|
||||
search_graph liefert Name, QN, Datei und Zeilenbereich in einem
|
||||
Roundtrip; W1 bis W7 haben kein Gegenbeispiel gefunden.
|
||||
- **Ask 5 (Suchzeile ohne Flags)**: unveraendert offen; wir nehmen den
|
||||
Fan-in ersatzweise aus dem geladenen Layout, was Suche und Galaxie
|
||||
koppelt.
|
||||
|
||||
Neu dazugelernt, ohne dass eine Ask noetig waere (Anerkennung):
|
||||
|
||||
- Der Coverage-Store ist eine eigene, vollstaendige Quelle neben dem
|
||||
Code-Graphen. check_index_coverage mit scopes ["."] listet paginiert,
|
||||
index_status fuehrt parse_partial, skipped und not_indexed, und die
|
||||
Metadaten zaehlen ignorierte Dateien mit. Gemessen in W4d: eine
|
||||
gitignorierte Datei erscheint mit Grund "gitignore" und status
|
||||
"excluded"; eine kaputte TS-Datei landet mit Zeilenbereichen in
|
||||
parse_partial. Damit steht der Explorer auf der Datei-Wahrheit und
|
||||
nicht auf dem Graphen. Martins Einschaetzung dazu war richtig, unsere
|
||||
urspruengliche Vermutung einer Luecke war es nicht.
|
||||
- ingest_traces speichert qn-keyed und ueberlebt Reindexing; die
|
||||
Zaehler und Run-Label kommen im Wizard und an den Flow-Hops an.
|
||||
|
||||
Uebergabe bleibt wie gehabt: Bernhard reicht alles selbst haendisch ein.
|
||||
@@ -21,12 +21,6 @@ import tempfile
|
||||
|
||||
DEFAULT_FILE = os.path.join(os.path.expanduser('~'), '.atlas-trace', 'events.jsonl')
|
||||
|
||||
# Was von einem Befehl oder einem Suchmuster aufgeschrieben wird. Laenger waere
|
||||
# in einem Instrument von 320 Pixeln ohnehin nicht zu lesen, und kuerzer
|
||||
# verloere die Datei, um die es geht.
|
||||
DETAIL_CAP = 180
|
||||
|
||||
|
||||
def trace_file():
|
||||
return os.environ.get('ATLAS_TRACE_FILE') or DEFAULT_FILE
|
||||
|
||||
@@ -228,12 +222,6 @@ def main():
|
||||
if not isinstance(args, dict):
|
||||
args = {}
|
||||
file_path = args.get('file_path') or args.get('notebook_path') or ''
|
||||
detail = (
|
||||
args.get('command')
|
||||
or args.get('pattern')
|
||||
or args.get('description')
|
||||
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)))
|
||||
@@ -246,7 +234,8 @@ def main():
|
||||
'phase': 'end',
|
||||
'tool': tool,
|
||||
'path': str(file_path or args.get('path') or ''),
|
||||
'detail': str(detail)[:DETAIL_CAP],
|
||||
# Commands, patterns and descriptions may contain credentials.
|
||||
'detail': '',
|
||||
'source': 'tool-hook',
|
||||
}
|
||||
span = line_span(file_path, args) if tool == 'Read' else None
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""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
|
||||
@@ -31,6 +32,66 @@ class TraceTests(unittest.TestCase):
|
||||
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
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 659 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 MiB |
@@ -1,226 +1,70 @@
|
||||
# ADR 0001: Lokale Modellklassen fuer CodeAtlasWeb (Stand August 2026)
|
||||
# ADR 0001: Optional local inference and model evidence
|
||||
|
||||
Status: Kandidaten festgelegt per Web-Recherche 2026-08-28; endgueltige
|
||||
Besetzung entscheidet die eigene Eval-Suite (Head-to-Head, siehe unten).
|
||||
Die vollstaendige Recherche mit allen Quellen liegt in
|
||||
verification/w5/modellrecherche.md (36 nummerierte Quellen).
|
||||
## Decision
|
||||
|
||||
## Kontext
|
||||
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.
|
||||
|
||||
PLAN §5: zwei lokale Modellklassen (~1B und ~4B) fuer kurze, extraktive,
|
||||
zitierende Antworten ueber Fakten-Karten des Context-Compilers; Runtime
|
||||
llama.cpp llama-server auf Apple Silicon, GGUF Q4_K_M, Kontextbudget 3k
|
||||
(Klasse A) und 8k (Klasse B) Token; Kriterienreihenfolge laut Plan:
|
||||
(1) Instruction-Following/Verstaendnis, (2) kommerzielle Lizenz,
|
||||
(3) GGUF-Qualitaet, (4) Tempo auf Apple Silicon, (5) Deutsch+Englisch.
|
||||
Modellwahl per Recherche, nicht per Vorurteil (Nutzeranweisung 2026-08-28);
|
||||
der Platzhalter Qwen3 aus der Planungsphase ist damit abgeloest.
|
||||
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.
|
||||
|
||||
## Lage August 2026 (Kurzfassung)
|
||||
## Sidecar model sources
|
||||
|
||||
- Qwen3.5-Small (Maerz 2026, 0.8B/2B/4B/9B, Apache 2.0) und Gemma 4
|
||||
(April 2026, erstmals Apache 2.0) haben die Klasse umgewaelzt.
|
||||
- Explizit geprueft und NICHT existent: SmolLM4, Phi-5 (nur Geruechte).
|
||||
Llama 3.2 ist fachlich ueberholt und lizenzseitig ohne Not.
|
||||
- llama.cpp: Qwen3.5 braucht einen aktuellen Build (hybride
|
||||
Gated-DeltaNet-Attention); MTP-Speedup seit 2026-05-16 im Main.
|
||||
Konsequenz: llama-server b10675 aus Quellen gebaut (Metal, RPC aus),
|
||||
Lade-Smoke mit Qwen3.5-2B erfolgreich.
|
||||
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`.
|
||||
|
||||
## Entscheidung: Kandidatenpaare fuer die Eval
|
||||
| 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 |
|
||||
|
||||
Klasse A (~3k Budget), vier Kandidaten:
|
||||
1. Qwen3.5-2B (Apache 2.0, Q4_K_M 1.28GB (models/, 1280835840 Bytes), IFEval 78.6 Thinking/61.2
|
||||
Non-Thinking, MMLU-Redux 79.6, 201 Sprachen)
|
||||
2. LFM2.5-1.2B-Instruct (LFM Open License v1.0: frei unter 10 Mio. USD
|
||||
Jahresumsatz; klassenbestes IFEval 86.23; Q4_K_M 0.73GB (730895168 Bytes))
|
||||
3. MiniCPM5-1B (NUTZERNOMINIERUNG Bernhard 2026-08-28, verifiziert
|
||||
2026-08-28: OpenBMB, Release 2026-05-19, Apache 2.0, LlamaForCausalLM
|
||||
nativ in llama.cpp, offizielles GGUF Q4_K_M 0.69GB (688065920 Bytes), IFEval 80.4,
|
||||
MMLU-Redux 70.1, Kontext 131k. RISIKO: offiziell nur EN/ZH,
|
||||
Deutsch unbelegt und anekdotisch schwach; die deutschen
|
||||
Eval-Fragen entscheiden das empirisch.)
|
||||
4. Qwen2.5-Coder-1.5B-Instruct (NUTZERNOMINIERUNG Bernhard 2026-08-28:
|
||||
Code-Spezialist. Apache 2.0, offizielles GGUF Q4_K_M 1.12GB (1117320768 Bytes),
|
||||
Qwen2-Architektur nativ in llama.cpp, Kontext 32k. Aeltere
|
||||
Generation (2024) mit schwaecherem generellem Instruction-Following
|
||||
als die 2026er-Kandidaten; die Hypothese "Code-Spezialisierung
|
||||
hilft beim Formulieren aus Code-Fakten-Karten" wird empirisch
|
||||
gemessen statt verworfen.)
|
||||
Fallback bei Lizenz-Aus: Granite 4.0-1B (Apache 2.0, IFEval 78.5).
|
||||
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.
|
||||
|
||||
Klasse B (~8k Budget):
|
||||
1. Qwen3.5-4B (Apache 2.0, IFEval 89.8, MMLU-Pro 79.1, Q4_K_M 2.83GB (2834975040 Bytes), MTP)
|
||||
2. Gemma 4 E4B-it (Apache 2.0 seit Gemma 4, MMLU-Pro 69.4, MMMLU 76.6,
|
||||
Q4_K_M 4.98GB (4977171584 Bytes), offizielle QAT-Quants)
|
||||
Fallback bei Tempo-Aus des E4B: Ministral 3 3B Instruct 2512 (Apache 2.0,
|
||||
2.15GB).
|
||||
## Evaluation limits
|
||||
|
||||
## Lizenz-Notizen
|
||||
`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.
|
||||
|
||||
Apache 2.0: Qwen3.5, Gemma 4, Ministral 3, Granite. MIT: Phi-4-mini.
|
||||
LFM Open License v1.0: kommerziell frei nur unter 10 Mio. USD Umsatz;
|
||||
fuer die aktuelle Situation ausreichend, aber strategisches Risiko bei
|
||||
Buendelung/Weiterverkauf: dokumentiert, nie Default ohne Eval-Sieg UND
|
||||
bewusste Lizenzabnahme.
|
||||
|
||||
## Bekannte Risiken (in der Eval zu messen)
|
||||
|
||||
- Qwen3.5 Thinking-Modus: hoher Token-Verbrauch und dokumentierte
|
||||
Halluzinationsneigung ohne Kontext; unser Betrieb ist Non-Thinking mit
|
||||
Fakten-Karten; die Zitiertreue-Metrik (jede Aussage traegt [K]) ist der
|
||||
harte Filter.
|
||||
- Fuer fast alle Kandidaten fehlen seriöse tok/s-Belege auf M-Serie
|
||||
(nur LFM2.5-2.6B: 220 tok/s auf M5 Max); Tempo misst die Eval selbst.
|
||||
- Gemma 4 E4B: 8B Gesamtparameter, groesster Speicher der Klasse.
|
||||
|
||||
## Engine-Entscheidung (Recherche 2026-08-28, Martins Pruefauftrag)
|
||||
|
||||
Gefragt war (Martin, Voicemail 2): eine leichtgewichtige JS-/Browser-
|
||||
Inferenz-Engine statt des llama.cpp-Sidecars. Benchmark-basierte
|
||||
Antwort (Volltext mit Quellen: verification/w5/modellrecherche.md, Teil 2):
|
||||
|
||||
- ENTSCHEIDUNG: llama-server (Metal, 127.0.0.1:4141) bleibt der
|
||||
Referenzpfad. Gleiche Maschine, gleiches Modell: nativ ~1.5x beim
|
||||
Decode und ueber 2x beim Prefill schneller als der beste Browser-Weg
|
||||
(ggml-WebGPU-Paper 2026); unser Workload (Karten im Prompt, kurze
|
||||
Antworten) ist Prefill-dominiert. Dazu: GGUFs direkt nutzbar, kein
|
||||
2GB-Split fuers 4B-Modell, Prozess-Robustheit (Tab-Crash reisst
|
||||
nichts mit), triviale Air-gap-Story.
|
||||
- ZUKUNFTSOPTION mit klarem Kandidaten: wllama mit dem offiziellen
|
||||
ggml-WebGPU-Backend (gleiche GGUFs, gleiche ggml-Basis, ~52 tok/s
|
||||
Decode 1B q4 auf M3 im Browser belegt). Neubewertungs-Trigger: die
|
||||
Prefill-Luecke schliesst sich UND 4B-Handling (Split + Buffer-Limits)
|
||||
ist im Release-Stand stabil. WebLLM (Konvertierungszwang, duenne
|
||||
Release-Kadenz), transformers.js (dokumentierte WebGPU-Korrektheits-
|
||||
bugs in der 1B-Klasse), MediaPipe (maintenance-only) und WebNN
|
||||
(Origin Trial) scheiden heute aus.
|
||||
- Damit der Wechsel jederzeit billig bleibt: der Chat-Transport ist
|
||||
eine OpenAI-kompatible Abstraktion, die Engine ist Konfiguration.
|
||||
|
||||
## Konsequenz
|
||||
|
||||
Die Eval-Suite (44 goldene Fragen gegen das Demo-Fixture, Temperatur 0,
|
||||
fester Seed, Zitat-Pflicht) faehrt alle Kandidaten je Klasse
|
||||
GEGENEINANDER; der Eval-Sieger schlaegt den Benchmark-Ruf. Ergebnis wird
|
||||
in diesem ADR nachgetragen (Abschnitt "Eval-Ergebnis", W5).
|
||||
|
||||
Downloads: einmalig am 2026-08-28 dokumentiert (SHA256SUMS in
|
||||
models/SHA256SUMS), einziger Netzzugriff des Projekts neben npm/Clone
|
||||
zur Bauzeit; danach air-gapped.
|
||||
|
||||
## Eval-Ergebnis (2026-08-29, Head-to-Head, bindend)
|
||||
|
||||
44 goldene Fragen (23 deutsch), Temperatur 0, Seed 42; Werte aus dem
|
||||
aufgezeichneten Lauf (verification/w5/eval.json). Ein zweiter kompletter
|
||||
Lauf reproduzierte passRate und Zitattreue laut Implementierungsbericht
|
||||
exakt, ist aber NICHT als eigenes Artefakt aufgezeichnet (Korrektur
|
||||
2026-08-29 nach Audit-Befund 3):
|
||||
|
||||
| Modell | Klasse | passRate | Zitattreue | tok/s |
|
||||
|---|---|---|---|---|
|
||||
| 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 (Nominierung) | A | 0.250 | 0.545 | 171 |
|
||||
| Qwen2.5-Coder-1.5B (Nominierung) | A | 0.227 | 0.705 | 84 |
|
||||
| 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 |
|
||||
|
||||
ENTSCHEIDUNG: Klasse A = Qwen3.5-2B, Klasse B = gemma-4-E4B (beide ueber
|
||||
den harten Grenzen 0.6/0.9). Die Nominierungen wurden vollstaendig
|
||||
mitgefahren; MiniCPM5-1B ist das schnellste Modell des Feldes, faellt
|
||||
aber bei Instruktionstreue und Zitierdisziplin ab; die Code-Spezialisten-
|
||||
Hypothese des Coders bestaetigte sich nicht. Sechs dokumentierte
|
||||
Compiler-Iterationen (eval.json extras.iterations), keine Frage wurde
|
||||
entschaerft; der groesste Einzelsprung war, die Kartennummern selbst in
|
||||
Zitat-Syntax [K1] zu setzen.
|
||||
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.
|
||||
|
||||
## Quellen der Nutzernominierungen (Nachtrag 2026-08-29, Audit-Befund 4)
|
||||
## Engine boundary
|
||||
|
||||
Die zwei Nominierungen stehen nicht in verification/w5/modellrecherche.md
|
||||
(die dokumentiert die urspruengliche Vier-Kandidaten-Recherche); ihre
|
||||
Verifikation lief separat am 2026-08-28:
|
||||
- MiniCPM5-1B: https://huggingface.co/openbmb/MiniCPM5-1B (Modellkarte:
|
||||
1.08B, Apache-2.0, LlamaForCausalLM, EN/ZH),
|
||||
https://huggingface.co/openbmb/MiniCPM5-1B-GGUF (Q4_K_M),
|
||||
https://github.com/OpenBMB/MiniCPM (Release 2026-05-19),
|
||||
https://benchlm.ai/models/minicpm5-1b (IFEval 80.4, MMLU-Redux 70.1).
|
||||
Deutsch offiziell unbelegt; die deutschen Eval-Fragen massen es.
|
||||
- Qwen2.5-Coder-1.5B-Instruct:
|
||||
https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct-GGUF
|
||||
(offizielles GGUF, Apache-2.0, Qwen2-Architektur, 2024er-Generation).
|
||||
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.
|
||||
|
||||
## Nachtrag W10 (2026-08-29): entschieden wurde die Engine, nicht ein Modell
|
||||
|
||||
Dieser Nachtrag nimmt nichts von dem zurueck, was oben steht. Er sagt, was
|
||||
oben eine Entscheidung war und was ein Messergebnis, weil beides bisher im
|
||||
selben Abschnitt stand und darum gleich verbindlich aussah.
|
||||
|
||||
**Ausloeser.** Martin, 2026-08-29: "Fuer mich waere wichtig, dass das Modell
|
||||
das Binary nicht gross verseucht. Man bietet in der UI das Feature so an,
|
||||
dass man sich von zum Beispiel Hugging Face selber ein Modell aussuchen
|
||||
kann, das wird dann im local cache gedownloaded. Dann sitzt das nicht im
|
||||
Binary, und dann auch easy mit groesseren LLMs. Kann der Nutzer selber
|
||||
konfigurieren. Also quasi im Settings-Menue." Der Nutzer stimmt zu:
|
||||
"kannst qwen drin lassen, aber das muss auch noch geaendert werden".
|
||||
|
||||
**Was bindend bleibt.** Die Engine. llama-server auf 127.0.0.1 bleibt der
|
||||
Referenzpfad, aus den oben gemessenen Gruenden (Prefill-Vorsprung, GGUF
|
||||
direkt, kein 2GB-Split, Prozess-Robustheit, Air-gap). Der Chat-Transport
|
||||
bleibt die OpenAI-kompatible Abstraktion, die Engine bleibt Konfiguration.
|
||||
Die Neubewertungs-Trigger fuer wllama gelten unveraendert.
|
||||
|
||||
**Was ab hier Empfehlung ist und nicht Festlegung.** Die Zeile
|
||||
"ENTSCHEIDUNG: Klasse A = Qwen3.5-2B, Klasse B = gemma-4-E4B" im Abschnitt
|
||||
"Eval-Ergebnis". Sie war eine Entscheidung, solange das Programm ein Modell
|
||||
mitbrachte. Das tut es nicht mehr. Die sechs Zeilen der Eval-Tabelle sind
|
||||
weiterhin gemessen, weiterhin reproduzierbar und weiterhin das Beste, was
|
||||
dieses Projekt ueber diese sechs Dateien sagen kann; sie sind ab jetzt die
|
||||
Auskunft, die der Leser bekommt, wenn er im Einstellungen-Panel zwischen
|
||||
Vorschlaegen waehlt. Sie sind nicht mehr die Antwort auf die Frage, welches
|
||||
Modell laeuft. Diese Frage beantwortet der Leser.
|
||||
|
||||
**Warum das kein Rueckzieher ist.** Die Eval hat gemessen, was sie messen
|
||||
konnte: sechs Dateien, 44 Fragen, eine Maschine, ein Tag. Sie hat nie
|
||||
gemessen, was ein 9B-Modell auf einer groesseren Maschine tut oder was ein
|
||||
Modell tut, das es im August 2026 noch nicht gab. Eine Zahl aus einem
|
||||
Head-to-Head zur Produkteigenschaft zu machen hiesse, die Stichprobe fuer
|
||||
die Welt zu halten. Als Empfehlung mit sichtbarer Herkunft sagt dieselbe
|
||||
Zahl genau so viel, wie sie deckt.
|
||||
|
||||
**Was daraus folgt (umgesetzt in W10).** llm/start.sh faehrt in der Vorgabe
|
||||
den Router-Modus ueber ein Cache-Verzeichnis (`--models-dir`, `--models-max`,
|
||||
`--models-autoload`, gemessen an vendor/llama, llama-server 0.3.0-dev, build
|
||||
b1-90c26fc) und hat keinen fest verdrahteten Dateinamen mehr in seiner
|
||||
Vorgabe. Die sechs Wahlen `class-a` bis `class-b-gemma` bleiben als
|
||||
Reproduktionsgriffe dieser Eval bestehen, damit die Laeufe von W5 und die
|
||||
Regressionspruefung in tools/eval-check.mjs wiederholbar bleiben. Sie sind
|
||||
Belege, keine Vorgabe.
|
||||
|
||||
**Grenze, die dabei nicht verschoben wurde.** Die Oberflaeche laedt nichts
|
||||
herunter, weil sie es nicht kann: eine SPA ohne eigenes Backend startet
|
||||
keinen Prozess und schreibt nichts auf die Platte. Sie waehlt aus, was im
|
||||
Cache liegt, und stellt fuer alles andere den fertigen `-hf`-Aufruf bereit,
|
||||
mit dem Satz dazu, dass er ins Netz geht und wohin er laedt. Ein
|
||||
Fortschrittsbalken fuer einen Download, den ein anderer Prozess faehrt,
|
||||
waere eine Anzeige ohne Messung.
|
||||
|
||||
**Offen geblieben.** Ob ein groesseres Modell (9B und darueber) auf dieser
|
||||
Maschine im Budget dieses Produkts brauchbar ist, hat niemand gemessen.
|
||||
Das Panel behauptet darueber nichts; es zeigt zu den sechs gemessenen
|
||||
Kandidaten die gemessenen Zahlen und zu allem anderen nur den Weg, es
|
||||
selbst zu probieren.
|
||||
|
||||
## Korrektur-Notizen (2026-08-29, nach dem unabhaengigen Audit)
|
||||
|
||||
- Groessenangaben oben auf die tatsaechlichen Dateien in models/
|
||||
umgestellt (Dezimal-GB + exakte Bytes; die fruehere Mischung aus
|
||||
HF-MiB-Angaben und Dezimal-GB war inkonsistent, Audit-Befund 5).
|
||||
- Die harten Eval-Grenzen (Sieger je Klasse: passRate >= 0.6, Zitattreue
|
||||
>= 0.9) sind seit dem Finding-Fix in tools/eval-llm.mjs als
|
||||
erzwungenes Gate hinterlegt und in eval.json dokumentiert
|
||||
(Audit-Befund 6); tools/eval-check.mjs prueft die Sieger als
|
||||
Regressionslauf im Release-Gate (Audit-Befund 9).
|
||||
- Der Lade-Smoke in verification/w5/models.json testet MiniCPM5-1B als
|
||||
kleinstes Modell; der Klasse-A-Sieger Qwen3.5-2B ist durch den
|
||||
Chat-Smoke und die Eval selbst belegt (Klarstellung zu Befund 18).
|
||||
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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 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
|
||||
## 001: Explain architecture from evidence
|
||||
|
||||
**Status:** Proposed · **Scope:** Architecture workspace
|
||||
|
||||
@@ -22,7 +22,7 @@ Combine indexed graph relationships with source evidence. Keep the repository ma
|
||||
|
||||
**Implementation references:** `graph-ui/src/architecture/`, `src/store/architecture_projection.c`.
|
||||
|
||||
## 002 — Keep browser AI optional
|
||||
## 002: Keep browser AI optional
|
||||
|
||||
**Status:** Proposed · **Scope:** Local chat
|
||||
|
||||
@@ -44,7 +44,7 @@ Run the chat model locally in the browser after explicit setup. Download weights
|
||||
|
||||
**Implementation references:** `graph-ui/src/browser-ai/`, `graph-ui/src/reader/MonacoReader.tsx`.
|
||||
|
||||
## 003 — Keep agent activity experimental
|
||||
## 003: Keep agent activity experimental
|
||||
|
||||
**Status:** Proposed · **Scope:** Agents workspace
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 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
|
||||
## 001: Review services as deployment boundaries
|
||||
|
||||
**Status:** Proposed · **Scope:** Routes review
|
||||
|
||||
@@ -20,7 +20,7 @@ Use detected service and container boundaries to organize the routes review. Kee
|
||||
- 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
|
||||
## 002: Follow a request through its evidence
|
||||
|
||||
**Status:** Proposed · **Scope:** Cross-service investigation
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> Preview example for voting-services. This proposed review note is test content, not an upstream architectural decision.
|
||||
|
||||
## 001 — Distinguish declared and observed connections
|
||||
## 001: Distinguish declared and observed connections
|
||||
|
||||
**Status:** Proposed · **Scope:** Service map review
|
||||
|
||||
|
||||
@@ -1,166 +1,56 @@
|
||||
# Herkunft dieser Ereignisdatei
|
||||
# Agent-event fixture provenance
|
||||
|
||||
`w11a-replay.jsonl` ist die Aufzeichnung, gegen die der Beweislauf von W11a
|
||||
faehrt (`npm run smoke:w11a`). Sie liegt im Repository, weil eine Ansicht ueber
|
||||
laufende Agenten sonst nur dann messbar waere, wenn zufaellig ein Agent laeuft;
|
||||
byte-stabil, weil zwei Laeufe an derselben Datei dieselben Bilder ergeben
|
||||
muessen.
|
||||
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.
|
||||
|
||||
## Woraus sie entstanden ist
|
||||
## `w11a-replay.jsonl`
|
||||
|
||||
Aus einer **echten** Aufzeichnung: einer `events.jsonl` im Heimatverzeichnis
|
||||
des Agenten-Werkzeugs, geschrieben von einem Werkzeug-Hook in der Nacht vom 29.
|
||||
auf den 30. August 2026, waehrend an diesem Repository gearbeitet wurde. Die
|
||||
Datei liegt nicht im Repository (sie enthaelt Pfade und Befehle einer fremden
|
||||
Maschine); was von ihr uebernommen wurde, steht hier vollstaendig.
|
||||
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:
|
||||
|
||||
**Uebernommen, unveraendert:**
|
||||
```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
|
||||
```
|
||||
|
||||
- Die **Form**: acht Felder je Zeile (`ts`, `agent`, `run`, `seq`, `phase`,
|
||||
`tool`, `path`, `detail`), eine Zeile JSON je Werkzeugaufruf, angehaengt in
|
||||
der Reihenfolge, in der die Aufrufe endeten.
|
||||
- Die **Zeitabstaende**: 44 gemessene Abstaende aus einem zusammenhaengenden
|
||||
Fenster der Aufzeichnung (ab ihrer 57. Zeile, dem ersten Fenster von 45
|
||||
Ereignissen ohne eine Pause ueber zwei Minuten). Sie stehen in der Reihenfolge
|
||||
da, in der sie gemessen wurden: 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
|
||||
Millisekunden. Daher die ungleichmaessige Dichte: eine Kette aus drei
|
||||
Sekunden, dann eine halbe Minute Ruhe. Ein Takt aus gleichen Abstaenden waere
|
||||
ein Bild, das so nie entsteht.
|
||||
- Die **Mischung der Werkzeuge**: in der Aufzeichnung ueberwiegen Befehle,
|
||||
danach kommen Aenderungen, dann Lesen. Dieselbe Ordnung steht hier.
|
||||
- Der **Anker der Zeit**: `ts` der ersten Zeile ist der echte Zeitstempel des
|
||||
Fensteranfangs (1788038561000).
|
||||
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:
|
||||
|
||||
**Geaendert, und warum:**
|
||||
- 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.
|
||||
|
||||
1. **Die Pfade.** In der Aufzeichnung stehen absolute Pfade dieses Repositories
|
||||
und eines Arbeitsverzeichnisses unter `/private/tmp`. Ein Ereignis laesst
|
||||
sich nur verorten, wenn der Index den Pfad kennt, und der Index dieses
|
||||
Beweislaufs ist `fixtures/atlas-sample`. Jeder Pfad hier ist darum ein Pfad
|
||||
jener Fixture, repo-relativ, so wie `/api/layout` ihn fuehrt. Genau ein Pfad
|
||||
ist mit Absicht **nicht** darin enthalten (`package.json`): er belegt den
|
||||
Fall "nicht im Graphen verortbar", der im Instrument stehen bleiben muss,
|
||||
statt zu verschwinden.
|
||||
2. **Die Namen der Agenten.** Die Aufzeichnung fuehrt einen einzigen Namen, den
|
||||
des Werkzeugs, das den Hook aufgerufen hat. Er steht hier aus zwei Gruenden
|
||||
nicht: das Repository nennt kein Werkzeug und kein Modell als Urheber
|
||||
(PLAN.md, CLAUDE.md), und die Ansicht ist erst dann messbar, wenn mehr als
|
||||
ein Koerper im Bild steht. Die drei Namen `implementer`, `checker` und
|
||||
`explorer` sind die Rollen, die in diesem Projekt wirklich getrennt
|
||||
arbeiten. Ihre `run`-Kennungen sind erfunden und haben die Laenge der echten
|
||||
(acht Zeichen).
|
||||
3. **Die Zeilenbereiche (`lines`).** Der Hook der Aufzeichnung hat sie nicht
|
||||
geschrieben; der Hook dieses Repositories (`agents/hooks/atlas-trace.py`)
|
||||
schreibt sie, weil die genaue Zuordnung an ihnen haengt. Die Bereiche hier
|
||||
sind die echten Bereiche der Symbole in `fixtures/atlas-sample`, abgelesen
|
||||
an `/api/layout` (`start_line`, `end_line`), nicht geschaetzt.
|
||||
4. **Eine Luecke in der Reihenfolge.** Der Lauf `b7e4d19c` zaehlt von 6 auf 9
|
||||
weiter. Zwei Ereignisse fehlen also, und das Instrument muss es sagen, statt
|
||||
eine lueckenlose Geschichte zu zeichnen. Die Luecke ist mit Absicht
|
||||
eingesetzt; in der Aufzeichnung gibt es keine, weil dort nichts abgerissen
|
||||
ist.
|
||||
5. **Zwei `intent`-Felder.** In der Aufzeichnung kommt das Feld kein einziges
|
||||
Mal vor, weil kein Agent es geschrieben hat. Es steht hier genau zweimal,
|
||||
damit der Beweislauf beide Seiten der Ehrlichkeitsregel messen kann: ein
|
||||
Ereignis ohne `intent` erzeugt nie eine Absichtszeile, eines mit `intent`
|
||||
erzeugt sie immer, gekennzeichnet als Selbstauskunft des Agenten. Wer diese
|
||||
zwei Zeilen im Bild sieht, sieht eine Aussage des Agenten ueber sich selbst
|
||||
und keine Messung.
|
||||
6. **Die `detail`-Texte.** Sie beschreiben, was an der Fixture getan wurde. In
|
||||
der Aufzeichnung stehen dort die echten Befehle und die Beschreibungen der
|
||||
Werkzeugaufrufe; sie waeren hier Saetze ueber Dateien, die der Index nicht
|
||||
kennt.
|
||||
The result contains 45 events, three runs, four work types, one two-event sequence
|
||||
gap, two self-reports and one path outside the index.
|
||||
|
||||
## Was NICHT darin steht
|
||||
## `w11b-replay.jsonl`
|
||||
|
||||
Kein Dateiinhalt. Weder die Aufzeichnung noch diese Datei traegt eine Zeile
|
||||
Quelltext: was ein Ereignis sagt, ist welches Werkzeug welchen Pfad in welchem
|
||||
Zeilenbereich beruehrt hat, und was der Befehl war. Das ist die Grenze des
|
||||
Formats und nicht eine Einstellung daran.
|
||||
Entirely constructed test data, not a session recording. Its paths and line spans
|
||||
come from `fixtures/atlas-sample`; timestamps and activity are deliberately chosen.
|
||||
|
||||
## Die Zahlen dieser Datei
|
||||
- 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`.
|
||||
|
||||
45 Zeilen, 3 Laeufe, 4 Arten von Arbeit (Lesen, Schreiben, Suchen, Testen),
|
||||
1 Luecke von 2 Ereignissen, 2 Selbstauskuenfte, 1 Pfad ausserhalb des Index.
|
||||
|
||||
# `w11b-replay.jsonl`: die zweite Aufzeichnung
|
||||
|
||||
Sie ist die Datei, gegen die der Beweislauf von W11b faehrt
|
||||
(`npm run smoke:w11b`). Der wichtigste Satz zuerst, damit niemand sie fuer
|
||||
etwas haelt, was sie nicht ist:
|
||||
|
||||
**Diese Datei ist KONSTRUIERT und nicht gemessen.** Sie ist keine Aufzeichnung
|
||||
einer echten Sitzung wie `w11a-replay.jsonl`. Sie stellt die Lagen her, die
|
||||
W11b messen muss, und sie tut es absichtlich und nachlesbar. Ihre Zeitabstaende
|
||||
sind gewaehlt und nicht aufgezeichnet.
|
||||
|
||||
## Was daran echt ist
|
||||
|
||||
- **Die Pfade und die Zeilenbereiche.** Jeder Pfad ist ein Pfad von
|
||||
`fixtures/atlas-sample`, repo-relativ, so wie `/api/layout` ihn fuehrt. Jeder
|
||||
Zeilenbereich ist der echte Bereich des Symbols in jener Fixture
|
||||
(`start_line`, `end_line`), abgelesen an der Quelle und nicht geschaetzt.
|
||||
- **Die Form.** Dieselben acht Felder je Zeile wie in der echten Aufzeichnung,
|
||||
eine Zeile JSON je Werkzeugaufruf, in der Reihenfolge, in der die Aufrufe
|
||||
endeten.
|
||||
|
||||
## Was daran gebaut ist, und wofuer
|
||||
|
||||
1. **Acht Agenten** (`implementer`, `checker`, `explorer`, `reviewer`,
|
||||
`tracer`, `indexer`, `packer`, `planner`). AC7 verlangt die Bildrate bei
|
||||
acht Agenten; zusammen mit dem Leser sind es neun Akteure, und der Deckel
|
||||
der gezeichneten Koerper liegt bei acht. Damit wird er wirklich erreicht,
|
||||
und das Instrument muss es sagen, statt still den aeltesten Koerper fallen
|
||||
zu lassen.
|
||||
2. **Zwei Ruhephasen, und warum es zwei sein muessen.** `explorer` liefert sein
|
||||
letztes Ereignis 75 Sekunden vor dem Ende, `planner` 85 Sekunden davor.
|
||||
Beide sind damit ruhig (ueber eine Minute ohne Ereignis) und trotzdem im
|
||||
Bild (unter drei Minuten). Zwei, weil der Deckel bei acht gezeichneten
|
||||
Koerpern liegt und den Akteur mit dem aeltesten Ereignis zurueckhaelt: mit
|
||||
nur einer Ruhephase waere genau der ruhige Akteur der ungezeichnete, und AC6
|
||||
liesse sich an seinem Koerper nicht messen. So traegt `planner` den Deckel
|
||||
und `explorer` die Ruhe.
|
||||
3. **Ein Schreib-Bruch.** `implementer` aendert am Ende fuenfmal in 1.8
|
||||
Sekunden dieselbe Stelle (`createUser`, Zeilen 23 bis 36). AC3b verlangt
|
||||
daraus GENAU EINE Welle, und der Beweislauf zaehlt sie. Der Bruch steht am
|
||||
Ende der Datei, weil die Wiedergabe die Zeitstempel auf die Gegenwart
|
||||
schiebt: ein Bruch in der Mitte waere beim Messen laengst vorbei.
|
||||
4. **Drei Akteure am selben Symbol.** `reviewer` (schreibt), `indexer` (sucht)
|
||||
und `tracer` (liest) enden alle drei auf `hotspotScan` in
|
||||
`src/repo/db.ts`, Zeilen 39 bis 54. Ihre Bahnradien muessen sich
|
||||
unterscheiden und deterministisch aus Art und Kennung folgen; der
|
||||
Beweislauf rechnet sie unabhaengig nach.
|
||||
5. **Ein weiter Weg.** `implementer` beruehrt in der letzten Minute fuenfzehn
|
||||
verschiedene Symbole, also mehr als die zehn, die eine Spur traegt. Damit
|
||||
ist der Deckel der Spur (6 bis 10 Knoten) wirklich erreicht und nicht nur
|
||||
theoretisch.
|
||||
6. **Viel und wenig Arbeit nebeneinander.** `implementer` liefert in der
|
||||
letzten Minute fuenfzehn Ereignisse, `packer` zwei. Der Puls haengt an
|
||||
dieser Zahl: einer atmet schnell und kraeftig, einer langsam und schwach,
|
||||
`planner` gar nicht. Ohne diesen Abstand waere AC3b nicht messbar, sondern
|
||||
nur behauptet.
|
||||
7. **Ein Pfad ausserhalb des Index** (`package.json`, von `planner`). Er belegt
|
||||
weiter den Fall "nicht im Graphen verortbar", der im Instrument stehen
|
||||
bleiben muss, statt zu verschwinden.
|
||||
8. **Eine Selbstauskunft.** Genau ein Ereignis traegt ein `intent`-Feld. Ein
|
||||
Ereignis ohne `intent` erzeugt nie eine Absichtszeile, eines mit `intent`
|
||||
immer, gekennzeichnet als Selbstauskunft des Agenten.
|
||||
|
||||
## Was NICHT darin steht
|
||||
|
||||
Kein Dateiinhalt, kein Fortschritt, keine Bewertung. Was ein Ereignis sagt, ist
|
||||
welches Werkzeug welchen Pfad in welchem Zeilenbereich beruehrt hat.
|
||||
|
||||
## Die Zahlen dieser Datei
|
||||
|
||||
73 Zeilen, 8 Laeufe, 4 Arten von Arbeit, 1 Schreib-Bruch aus 5 Ereignissen,
|
||||
2 Ruhephasen (75 und 85 Sekunden), 1 Pfad ausserhalb des Index,
|
||||
1 Selbstauskunft. Die Datei umspannt 145 Sekunden. Die letzten Ereignisse
|
||||
liegen, vom Ende her gerechnet, bei 0 (implementer), 3.8 (tracer), 4.4
|
||||
(indexer), 5.0 (reviewer), 7.0 (packer), 10.0 (checker), 75.0 (explorer) und
|
||||
85.0 Sekunden (planner). Die beiden ruhigen liegen dabei bewusst nicht weit
|
||||
auseinander: ein Akteur faellt drei Minuten nach seinem letzten Ereignis ganz
|
||||
aus dem Bild, und der Beweislauf muss beide vorher gemessen haben.
|
||||
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.
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
# Herkunft dieses Korpus
|
||||
# Large-source fixture provenance
|
||||
|
||||
Generiert am 2026-08-28 fuer CodeAtlasWeb. Nichts daran ist kopiert:
|
||||
`src/big.ts` ist deterministisch erzeugt (100 kleine exportierte Funktionen
|
||||
plus ein `runAll`, das sie der Reihe nach faehrt), `src/support.ts` und
|
||||
`src/index.ts` sind von Hand geschrieben und klein gehalten.
|
||||
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.
|
||||
|
||||
Zweck: **Cap-Messung**. `get_code_snippet` kappt jeden Schnipsel bei 500
|
||||
Zeilen (`MCP_SNIPPET_MAX_LINES`, cbm/src/mcp/mcp.c) und meldet das mit
|
||||
`source_clipped`. `src/big.ts` hat mit 717 Zeilen sicher mehr, damit der
|
||||
Reader beweisen muss, dass er den fehlenden Rest ehrlich benennt statt eine
|
||||
gekappte Datei als ganze Datei auszugeben.
|
||||
|
||||
Warum ein eigener Korpus und nicht `fixtures/atlas-sample`: an dessen Zahlen
|
||||
haengt der W1b-Smoke (73 Knoten, 175 Kanten, 6 Callees, 3 Caller). Eine Datei
|
||||
mehr dort wuerde diese Zahlen verschieben und einen bestandenen Beweislauf
|
||||
umschreiben. Dieser Korpus wird nur fuer die Cap-Messung indiziert.
|
||||
|
||||
Nicht bauen, nicht installieren, nicht ausfuehren. Der Korpus ist
|
||||
Messmaterial.
|
||||
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.
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
# Herkunft dieses Korpus
|
||||
# Sample fixture provenance
|
||||
|
||||
Kopiert am 2026-08-28 aus dem Referenz-Repo CodeAtlasIDE
|
||||
(`/Users/bernhard/Desktop/CodeAtlasIDE/fixtures/atlas-sample`), gleicher
|
||||
Urheber (Bernhard Jackiewicz).
|
||||
Copied from Bernhard Jackiewicz's CodeAtlasIDE project on 2026-08-28,
|
||||
`fixtures/atlas-sample`, by the same author.
|
||||
|
||||
Zweck: Demo- und Test-Korpus. Die zwölf TypeScript- und Konfig-Dateien sind
|
||||
absichtlich klein und stabil gehalten, damit die Fakten, die der Graph über
|
||||
sie meldet, reproduzierbar bleiben und Abnahmetests darauf festnageln können.
|
||||
|
||||
Das Referenz-Repo bleibt read-only: hier wird kopiert, dort nie geändert.
|
||||
Änderungen an diesem Korpus gelten nur für CodeAtlasWeb und werden nicht
|
||||
zurückgespielt.
|
||||
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.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>CodeAtlas</title>
|
||||
<title>CBM Lens Beta</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "codeatlasweb",
|
||||
"name": "cbm-lens",
|
||||
"version": "0.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codeatlasweb",
|
||||
"name": "cbm-lens",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "4.2.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "codeatlasweb",
|
||||
"name": "cbm-lens",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+121
-101
@@ -121,9 +121,8 @@ import AtlasChrome, { CLOSE_COMMAND_SEARCH_EVENT, OPEN_COMMAND_SEARCH_EVENT } fr
|
||||
import { AtlasTreeIndexDetails } from './app/AtlasTree';
|
||||
import ArchitecturePanel from './architecture/ArchitecturePanel';
|
||||
import AdrWorkspace from './adr/AdrWorkspace';
|
||||
import SelectionContextPanel from './why/SelectionContext';
|
||||
import ChangeAnalysisWorkspace from './impact/ChangeAnalysisWorkspace';
|
||||
import type { SelectionImpactTarget } from './impact/selection-impact';
|
||||
import SelectionContextPanel, { type SelectionScope } from './why/SelectionContext';
|
||||
import FileImpactSummary from './impact/FileImpactSummary';
|
||||
import SourceEvidenceDrawer, { fileQualifiedName, type SourceEvidenceTarget } from './architecture/SourceEvidenceDrawer';
|
||||
import { setUiLogProject } from './app/ui-log-install';
|
||||
import DiagnosticsPanel from './diagnostics/DiagnosticsPanel';
|
||||
@@ -131,11 +130,13 @@ import { resolveRepositorySelection, useRepositorySnapshot } from './architectur
|
||||
import ActivityPanel from './agents/ActivityPanel';
|
||||
import WelcomePanel from './app/WelcomePanel';
|
||||
import BrowserChatDock from './browser-ai/BrowserChatDock';
|
||||
import { switchKeepingAgent } from './browser-ai/agent-handover';
|
||||
import { browserChatHistoryProjectKey } from './browser-ai/chat-history-cache';
|
||||
import type { BrowserChatAttachment } from './browser-ai/BrowserChatDock';
|
||||
import { browserGraphContext } from './browser-ai/graph-context';
|
||||
import { readerChatContext, type ReaderSourceOrigin } from './browser-ai/reader-chat-context';
|
||||
import type { BrowserChatContext } from './browser-ai/chat-model';
|
||||
import { selectedGraphContext } from './galaxy/selected-node';
|
||||
import { selectionEvidenceContext } from './galaxy/selection-evidence';
|
||||
import SystemWorkspace from './system/SystemWorkspace';
|
||||
import type { Workspace, Guidance } from './app/workspace-strings';
|
||||
import { allowedWorkspace, initialWorkspace, workspaceStrings as workspaceText } from './app/workspace-strings';
|
||||
@@ -145,7 +146,6 @@ import { AtlasApi, TREE_ROUTE } from './app/atlas-api';
|
||||
import { ATLAS_BUILD_SUFFIX, ATLAS_VERSION } from './app/build-info';
|
||||
import { messages } from './i18n/messages';
|
||||
import {
|
||||
commandLineIntent,
|
||||
KEY_LISTENER_OPTIONS,
|
||||
menuShortcutFor,
|
||||
tourKeyForEvent,
|
||||
@@ -269,7 +269,6 @@ import type { BugWizardStatus } from './traces/BugWizard';
|
||||
import { bugPaths, resolveHop } from './traces/bug-paths';
|
||||
import type { BugPathNode, BugPathsDto } from './traces/bug-paths';
|
||||
import { BUG_WIZARD_MENU_LABEL } from './traces/bug-wizard-strings';
|
||||
import { IMPACT_MENU_LABEL } from './impact/impact-strings';
|
||||
import { SIDECAR_ORIGIN } from './llm/sidecar';
|
||||
import type { CacheModel, SidecarReading, SidecarState } from './llm/sidecar';
|
||||
import AtlasChatPanel from './chat/AtlasChatPanel';
|
||||
@@ -307,11 +306,12 @@ import type { PolicyReading } from './llm/policy';
|
||||
import { resolveLlmState } from './llm/llm-state';
|
||||
import { llmChipValue } from './llm/strings';
|
||||
import SettingsPanel from './settings/SettingsPanel';
|
||||
import ConfigPanel from './settings/ConfigPanel';
|
||||
import type { SettingsMeasurement } from './settings/SettingsPanel';
|
||||
import AddProjectIndexDialog from './projects/AddProjectIndexDialog';
|
||||
import ProjectSwitcher from './projects/ProjectSwitcher';
|
||||
import type { ProjectsSource } from './projects/ProjectsPanel';
|
||||
import { projectHref } from './projects/projects-model';
|
||||
import { ProjectWindows, useParamInAddress, type ProjectWindow } from './app/project-windows';
|
||||
import { MODEL_SUGGESTIONS, fetchCommand } from './settings/model-catalog';
|
||||
import { modelKey, readModelPreference, recordModelPreference } from './settings/model-preference';
|
||||
import {
|
||||
@@ -345,17 +345,8 @@ export const EAGER_LEVEL_BUDGET = 40;
|
||||
export const TWIN_CARET_DEBOUNCE_MS = 250;
|
||||
|
||||
/**
|
||||
* Womit das Panel startet.
|
||||
*
|
||||
* Kein eingebautes Profil, und das ist eine Aussage: die fuenf Presets des
|
||||
* Referenzprojekts hoeren zu einer Werkbank mit fuenf Panels, und jedes von
|
||||
* ihnen schaltet Linsen ab, die dort ein anderes Panel beantwortet. Hier gibt
|
||||
* es nur den Twin, also waere jedes der fuenf eine Startlage, in der eine
|
||||
* Sektion fehlt und niemand sagt, warum. Die Tiefe zwei zeigt die
|
||||
* aufgezeichneten Fakten, so wie design/design.png es zeigt; die Linsen sind
|
||||
* alle, die dieses Backend beantworten kann. Runtime und Changes sind aus, weil
|
||||
* sie hier nur ihre eigene Unfertigkeit melden koennten, und der Leser kann sie
|
||||
* mit einem Klick einschalten und genau das lesen.
|
||||
* Start with the supported facts at depth two. Runtime and Changes remain
|
||||
* opt-in because their unavailable states otherwise add noise to the panel.
|
||||
*/
|
||||
export const TWIN_PROFILE: PresentationProfile = {
|
||||
id: 'custom:codeatlasweb-twin',
|
||||
@@ -746,10 +737,34 @@ declare global {
|
||||
var __atlasLlm: AtlasLlmSeam | undefined;
|
||||
}
|
||||
|
||||
/** Was ein Projektfenster dem naechsten mitgibt (K24): den Arbeitsbereich, die Kopfzeile des Agenten und ob der Chat offen ist. */
|
||||
interface WindowCarry {
|
||||
workspace: Workspace;
|
||||
chatOpen: boolean;
|
||||
agentState: 'off' | 'loading' | 'active' | 'busy' | 'error';
|
||||
agentModelName: string;
|
||||
agentHasOpened: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jedes Projekt bekommt ein frisches Fenster in derselben Seite (K24, Kopf von
|
||||
* src/app/project-windows.tsx). Der Agent reicht beim Wechsel sein geladenes
|
||||
* Modell an das Fenster des neuen Projekts weiter. Die Adresse behaelt dabei
|
||||
* den Arbeitsbereich und die Grenzen des Walks, nichts vom alten Projekt (A5).
|
||||
*/
|
||||
const PAGE_PARAMS = ['workspace', CLOSURE_DEPTH_PARAM, CLOSURE_CAP_PARAM] as const;
|
||||
export default function App(): JSX.Element {
|
||||
return <ProjectWindows<WindowCarry> around={switchKeepingAgent} keepParams={PAGE_PARAMS}>
|
||||
{shown => <ProjectWindowView key={shown.key} projectWindow={shown} />}
|
||||
</ProjectWindows>;
|
||||
}
|
||||
|
||||
function ProjectWindowView({ projectWindow }: { projectWindow: ProjectWindow<WindowCarry> }): JSX.Element {
|
||||
const carried = projectWindow.carried;
|
||||
const [workspace, setWorkspace] = useState<Workspace>(() => {
|
||||
try {
|
||||
const requested = new URLSearchParams(window.location.search).get('workspace');
|
||||
// A project switch keeps the workspace in view; Back and Forward restore the one of their entry (K24).
|
||||
const requested = new URLSearchParams(window.location.search).get('workspace') ?? carried?.workspace ?? null;
|
||||
let saved: string | null = null;
|
||||
try { saved = localStorage.getItem('cbm.workspace'); } catch { /* URL navigation works without storage. */ }
|
||||
return initialWorkspace(requested, saved);
|
||||
@@ -757,22 +772,40 @@ export default function App(): JSX.Element {
|
||||
});
|
||||
const changeWorkspace = (value: Workspace): void => {
|
||||
value = allowedWorkspace(value);
|
||||
if (value !== workspace) {
|
||||
setProactiveSelection(undefined); setChatGraphSelection(undefined); setChatAttachment(undefined);
|
||||
}
|
||||
setWorkspace(value);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('workspace', value);
|
||||
window.history.replaceState(window.history.state, '', url);
|
||||
try { localStorage.setItem('cbm.workspace', value); } catch { /* Session-only preference. */ }
|
||||
};
|
||||
// The address always names the workspace in view, also one carried over a project switch, so a reload keeps it (A5).
|
||||
useParamInAddress('workspace', workspace);
|
||||
const [welcomeOpen, setWelcomeOpen] = useState(() => {
|
||||
try { return localStorage.getItem('cbm.workspace.setup') !== 'done'; } catch { return false; }
|
||||
});
|
||||
const [browserAiOpen, setBrowserAiOpen] = useState(false);
|
||||
// The agent's lamp and the chat stay as they were across a project switch (K24).
|
||||
const [browserAiOpen, setBrowserAiOpen] = useState(carried?.chatOpen ?? false);
|
||||
const [localAgentState, setLocalAgentState] = useState<'off' | 'loading' | 'active' | 'busy' | 'error'>(carried?.agentState ?? 'off');
|
||||
const [agentSettingsRequest, setAgentSettingsRequest] = useState(0);
|
||||
const [agentModelName, setAgentModelName] = useState(carried?.agentModelName ?? '');
|
||||
const agentHasOpened = useRef(carried?.agentHasOpened ?? false);
|
||||
useEffect(() => {
|
||||
projectWindow.report({ workspace, chatOpen: browserAiOpen, agentState: localAgentState, agentModelName, agentHasOpened: agentHasOpened.current });
|
||||
});
|
||||
const onAgentStateChange = useCallback((state: 'off' | 'loading' | 'active' | 'busy' | 'error') => {
|
||||
setLocalAgentState(state);
|
||||
if ((state === 'active' || state === 'busy') && !agentHasOpened.current) {
|
||||
agentHasOpened.current = true; setBrowserAiOpen(true);
|
||||
}
|
||||
}, []);
|
||||
const openAgentSettings = () => { setAgentSettingsRequest(value => value + 1); };
|
||||
const [codeDetailsOpen, setCodeDetailsOpen] = useState(false);
|
||||
const [readerSelection, setReaderSelection] = useState<ReaderSelection>();
|
||||
const [pinnedCode, setPinnedCode] = useState<SelectedCodeSnapshot>();
|
||||
const [fileSymbols, setFileSymbols] = useState<FileSymbolResult & { project: string; path: string; status: 'loading' | 'ready' | 'error' }>({ project: '', path: '', symbols: [], message: '', status: 'ready' });
|
||||
const [chatAttachment, setChatAttachment] = useState<BrowserChatAttachment>();
|
||||
const [chatGraphSelection, setChatGraphSelection] = useState<BrowserChatContext>();
|
||||
const [proactiveSelection, setProactiveSelection] = useState<{ scope: string; context: BrowserChatContext }>();
|
||||
const [galaxySelection, setGalaxySelection] = useState<GraphNode>();
|
||||
const [galaxySelectionProject, setGalaxySelectionProject] = useState('');
|
||||
const finishSetup = (): void => {
|
||||
@@ -781,16 +814,17 @@ export default function App(): JSX.Element {
|
||||
try { localStorage.setItem('cbm.workspace.setup', 'done'); } catch { /* Session-only preference. */ }
|
||||
};
|
||||
const guidance: Guidance = 'brief';
|
||||
const client = useMemo(() => new RpcIntelligenceClient({}), []);
|
||||
const api = useMemo(() => new AtlasApi({}), []);
|
||||
// Requests of this window end with it: a switch to another project aborts them (K24).
|
||||
const client = useMemo(() => new RpcIntelligenceClient({ signal: projectWindow.signal }), [projectWindow.signal]);
|
||||
const api = useMemo(() => new AtlasApi({ signal: projectWindow.signal }), [projectWindow.signal]);
|
||||
const [diagnosticsPath, setDiagnosticsPath] = useState<string>();
|
||||
|
||||
/*
|
||||
* What the projects panel asks the server. The list comes over /rpc like
|
||||
* every other read of this window; the rest are the /api routes the panel
|
||||
* names in its own text. Opening a project reloads the page with the
|
||||
* query the start-up code reads, because every panel of this window is
|
||||
* keyed to the project it started with.
|
||||
* names in its own text. Opening a project replaces this window with a
|
||||
* fresh one for that project in the same page, because every panel of this
|
||||
* window is keyed to the project it started with (K24).
|
||||
*/
|
||||
const projectsSource = useMemo<ProjectsSource>(
|
||||
() => ({
|
||||
@@ -808,11 +842,17 @@ export default function App(): JSX.Element {
|
||||
}),
|
||||
[client, api],
|
||||
);
|
||||
const openProject = useCallback((name: string) => {
|
||||
window.location.assign(projectHref(name));
|
||||
}, []);
|
||||
// Opening a project stays in this page and keeps a loaded agent (K24).
|
||||
const openProject = projectWindow.open;
|
||||
|
||||
const [project, setProject] = useState('');
|
||||
const selectionScope = `${project}:${workspace}`;
|
||||
const onSelectionEvidence = useCallback((context: BrowserChatContext | undefined) => {
|
||||
setProactiveSelection(previous => context
|
||||
? previous?.scope === selectionScope && previous.context.text === context.text ? previous : { scope: selectionScope, context }
|
||||
: undefined);
|
||||
}, [selectionScope]);
|
||||
useEffect(() => { setProactiveSelection(undefined); setChatGraphSelection(undefined); setChatAttachment(undefined); }, [project]);
|
||||
const [projectDetail, setProjectDetail] = useState('resolving project ...');
|
||||
const [serverOk, setServerOk] = useState<boolean | undefined>(undefined);
|
||||
const [counts, setCounts] = useState<{ nodes?: number; edges?: number }>({});
|
||||
@@ -1109,7 +1149,6 @@ export default function App(): JSX.Element {
|
||||
|
||||
const [galaxyOn, setGalaxyOn] = useState(true);
|
||||
const [layout, setLayout] = useState<GraphData | undefined>(undefined);
|
||||
const [analysisSelection, setAnalysisSelection] = useState<{ project: string; target?: SelectionImpactTarget }>();
|
||||
const [sourceEvidence, setSourceEvidence] = useState<{ project: string; target: SourceEvidenceTarget }>();
|
||||
const [hits, setHits] = useState<RankedHit[]>(noHits);
|
||||
const [selectedHit, setSelectedHit] = useState(0);
|
||||
@@ -1387,7 +1426,6 @@ export default function App(): JSX.Element {
|
||||
const [bugMessage, setBugMessage] = useState('');
|
||||
const bugTicket = useRef(0);
|
||||
|
||||
const impactOpen = explainOpen && explainTab === 'change';
|
||||
// ------------------------------------------ Das lokale Modell (W5a) ----
|
||||
|
||||
/*
|
||||
@@ -2327,7 +2365,7 @@ export default function App(): JSX.Element {
|
||||
* Wie oft der Index ein Symbol erreicht, aus dem geladenen Layout.
|
||||
*
|
||||
* Die flache Suchform des Servers traegt keine solche Spalte
|
||||
* (UPSTREAM-ASKS.md, Ask 5), das Layout traegt sie an jedem Knoten. Solange
|
||||
* des Fan-in; das Layout traegt sie an jedem Knoten. Solange
|
||||
* keines geladen ist, hat kein Kandidat eine Zahl, und das entscheidet nur
|
||||
* bei Gleichstand.
|
||||
*/
|
||||
@@ -2635,7 +2673,6 @@ export default function App(): JSX.Element {
|
||||
tour === undefined
|
||||
&& !entryOpen
|
||||
&& !bugOpen
|
||||
&& !impactOpen
|
||||
&& project.length > 0
|
||||
&& !whyDismissed
|
||||
&& (whyReopened || (!whyAnswer.asked && activePath.length === 0));
|
||||
@@ -2745,7 +2782,7 @@ export default function App(): JSX.Element {
|
||||
} else if (intent === 'bug') {
|
||||
openExplain('bug');
|
||||
} else if (intent === 'change') {
|
||||
openExplain('change');
|
||||
changeWorkspace('explore');
|
||||
}
|
||||
},
|
||||
[openExplain, project, changeWorkspace, store],
|
||||
@@ -3258,7 +3295,7 @@ export default function App(): JSX.Element {
|
||||
context: {
|
||||
focusName: twinSymbol?.name,
|
||||
focusQualifiedName: twinSymbol?.qualifiedName,
|
||||
mode: bugOpen ? 'bug' : impactOpen ? 'change' : entryOpen ? 'entry'
|
||||
mode: bugOpen ? 'bug' : entryOpen ? 'entry'
|
||||
: tour !== undefined ? 'tour' : 'none',
|
||||
},
|
||||
...(twinSymbol === undefined ? {} : { focus: twinSymbol }),
|
||||
@@ -3276,7 +3313,7 @@ export default function App(): JSX.Element {
|
||||
}, id).then(put);
|
||||
},
|
||||
[
|
||||
bugOpen, chatDepth, entryOpen, impactOpen, llmFacts, llmMode, llmModel, llmState,
|
||||
bugOpen, chatDepth, entryOpen, llmFacts, llmMode, llmModel, llmState,
|
||||
observedInto, project, provider, tour, twinSymbol,
|
||||
],
|
||||
);
|
||||
@@ -3668,17 +3705,6 @@ export default function App(): JSX.Element {
|
||||
if (guard.walkRunning && playerIntent(event.key) !== 'none') {
|
||||
return;
|
||||
}
|
||||
const intent = commandLineIntent(event, target);
|
||||
if (intent === undefined) {
|
||||
return;
|
||||
}
|
||||
// Abbestellt, damit das Zeichen nicht zweimal ankommt: einmal von
|
||||
// hier und einmal von der Vorgabe, sobald das Feld den Fokus hat.
|
||||
event.preventDefault();
|
||||
window.dispatchEvent(new Event(OPEN_COMMAND_SEARCH_EVENT));
|
||||
if (intent.kind === 'type') {
|
||||
setCommand((current) => current + intent.text);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown, KEY_LISTENER_OPTIONS);
|
||||
return () => window.removeEventListener('keydown', onKeyDown, KEY_LISTENER_OPTIONS);
|
||||
@@ -4256,17 +4282,6 @@ export default function App(): JSX.Element {
|
||||
openExplain('bug');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'impact',
|
||||
shortcut: 'c',
|
||||
label: IMPACT_MENU_LABEL,
|
||||
title: messages.menu.impact,
|
||||
onSelect: () => {
|
||||
setEntryOpen(false);
|
||||
setWhyReopened(false);
|
||||
openExplain('change');
|
||||
},
|
||||
},
|
||||
/*
|
||||
* Der Weg zurueck aus AC3, als Menuepunkt.
|
||||
*
|
||||
@@ -4318,8 +4333,8 @@ export default function App(): JSX.Element {
|
||||
{
|
||||
key: 'projects',
|
||||
shortcut: 'p',
|
||||
label: 'Add project index',
|
||||
title: 'Choose a repository folder to index',
|
||||
label: messages.projects.addIndexLabel,
|
||||
title: messages.projects.addIndexTitle,
|
||||
onSelect: () => setProjectsOpen((open) => !open),
|
||||
},
|
||||
/*
|
||||
@@ -4489,8 +4504,6 @@ export default function App(): JSX.Element {
|
||||
: node.file_path === inspector.filePath && (node.label === 'File' || node.label === 'Module'));
|
||||
const exploreEvidence = ({ filePath, line, name }: SourceEvidenceTarget): void => {
|
||||
setSourceEvidence(undefined);
|
||||
setAnalysisSelection(undefined);
|
||||
if (impactOpen) collapseExplain();
|
||||
changeWorkspace('explore');
|
||||
setPinnedCode(undefined);
|
||||
const target = twinTargetOf({ filePath, startLine: line, name: name ?? filePath, kind: 'unknown' });
|
||||
@@ -4508,24 +4521,33 @@ export default function App(): JSX.Element {
|
||||
setGalaxySelection(undefined);
|
||||
setGalaxySelectionProject('');
|
||||
setChatGraphSelection(undefined);
|
||||
setProactiveSelection(undefined);
|
||||
};
|
||||
const inspectorImpactTarget = inspector.filePath ? { filePath: inspector.filePath,
|
||||
qualifiedName: inspector.symbol?.qualifiedName, id: inspector.symbol ? inspectorNode?.id : undefined,
|
||||
name: inspector.symbol?.name, line: inspectorNode?.start_line } : undefined;
|
||||
const mapImpactTarget = activeGalaxySelection?.file_path ? { filePath: activeGalaxySelection.file_path,
|
||||
qualifiedName: ['File', 'Module'].includes(activeGalaxySelection.label) ? undefined : activeGalaxySelection.qualified_name,
|
||||
id: ['File', 'Module'].includes(activeGalaxySelection.label) ? undefined : activeGalaxySelection.id,
|
||||
name: activeGalaxySelection.name, line: activeGalaxySelection.start_line } : undefined;
|
||||
const openSelectionImpact = (target?: SelectionImpactTarget) => setAnalysisSelection({ project, target });
|
||||
const readerImpactTarget = activePath ? { filePath: activePath,
|
||||
qualifiedName: markedGraphRange ? readerFocusSymbol?.qualifiedName : undefined,
|
||||
name: markedGraphRange ? readerFocusSymbol?.name : undefined } : undefined;
|
||||
const selectMapNode = (node: GraphNode): void => {
|
||||
setGalaxySelection(node);
|
||||
setGalaxySelectionProject(project);
|
||||
};
|
||||
const mapSelectionPanel = selectionUnavailable ? <p role="status" className="repo-map-note">{selectionUnavailableText}</p> : activeGalaxySelection && (workspace === 'architecture' || workspace === 'galaxy') && <>
|
||||
<SelectionContextPanel graph={evidenceGraph} selected={activeGalaxySelection}
|
||||
path={activeGalaxySelection.file_path ?? ''} agents={experimentalAgentsEnabled ? agents.state : undefined} onNavigate={navigateEvidence}
|
||||
onImpact={() => openSelectionImpact(mapImpactTarget)} />
|
||||
path={activeGalaxySelection.file_path ?? ''} agents={experimentalAgentsEnabled ? agents.state : undefined} onNavigate={navigateEvidence} />
|
||||
</>;
|
||||
/*
|
||||
* Galaxy reads its selection from the loaded scope (hand test K13). The
|
||||
* repository snapshot is capped at 20,000 nodes by file path; a symbol in
|
||||
* the scope but behind that cap is still a valid selection here.
|
||||
*/
|
||||
const galaxySelectionPanel = (scope: SelectionScope | undefined) => {
|
||||
const raw = galaxySelectionProject === project ? galaxySelection : undefined;
|
||||
const inScope = raw && scope?.graph.nodes.some(node => raw.qualified_name
|
||||
? node.qualified_name === raw.qualified_name && node.file_path === raw.file_path : node.id === raw.id);
|
||||
const selected = inScope ? raw : activeGalaxySelection;
|
||||
if (!selected) return selectionUnavailable ? <p role="status" className="repo-map-note">{selectionUnavailableText}</p> : undefined;
|
||||
return <SelectionContextPanel graph={evidenceGraph} selected={selected} scope={scope}
|
||||
path={selected.file_path ?? ''} agents={experimentalAgentsEnabled ? agents.state : undefined} onNavigate={navigateEvidence} />;
|
||||
};
|
||||
const followInspectorTarget = (target: SymbolRef): void => {
|
||||
setPinnedCode(undefined);
|
||||
followTarget(target);
|
||||
@@ -4568,7 +4590,8 @@ export default function App(): JSX.Element {
|
||||
<GalaxyPanel
|
||||
project={project}
|
||||
onClearSelection={clearGraphSelection}
|
||||
selectionPanel={workspace === 'galaxy' ? mapSelectionPanel : undefined}
|
||||
onSelectionEvidence={workspace === 'galaxy' ? onSelectionEvidence : undefined}
|
||||
selectionPanel={workspace === 'galaxy' ? galaxySelectionPanel : undefined}
|
||||
visible={workspace === 'galaxy' || (workspace === 'explore' && galaxyOn)}
|
||||
workspaceExpanded={workspace === 'galaxy'}
|
||||
focusQualifiedName={workspace === 'galaxy' ? activeGalaxySelection?.qualified_name : markedGraphRange ? readerFocusSymbol?.qualifiedName : undefined}
|
||||
@@ -4580,16 +4603,12 @@ export default function App(): JSX.Element {
|
||||
onSelectNode={workspace === 'galaxy' ? (node) => {
|
||||
setGalaxySelection(node);
|
||||
setGalaxySelectionProject(project);
|
||||
setChatGraphSelection(selectedGraphContext(layout, node, project, crypto.randomUUID()));
|
||||
} : undefined}
|
||||
onSelectShadowNode={(node) => {
|
||||
setGalaxySelection(undefined);
|
||||
setChatGraphSelection({ id: crypto.randomUUID(), label: workspaceText.coverageShadowSelection(node.name),
|
||||
text: JSON.stringify({ project, source: '/api/layout missed_graph', layer: 'coverage-shadow',
|
||||
selected: { id: node.sourceId, name: node.name, path: node.file_path, kind: node.label },
|
||||
coverage: 'Not fully indexed. Detailed reasons, source ranges, and freshness are unavailable in this layout.',
|
||||
limitation: 'This is coverage metadata, not a code symbol or proof that code is absent.' }, null, 2) });
|
||||
setBrowserAiOpen(true);
|
||||
onSelectionEvidence(selectionEvidenceContext({ project, view: 'galaxy-coverage', source: '/api/layout missed_graph',
|
||||
label: messages.galaxy.coverageSelection(node.name), selected: { id: node.sourceId, name: node.name, path: node.file_path, kind: node.label },
|
||||
limitations: 'Coverage metadata only, not a code symbol. Detailed reasons, source ranges, relationships and freshness are unavailable.' }));
|
||||
}}
|
||||
onLayout={onLayout}
|
||||
walk={workspace === 'galaxy' ? undefined : walk}
|
||||
@@ -4635,7 +4654,7 @@ export default function App(): JSX.Element {
|
||||
walkStep: tourStep,
|
||||
walkSteps: tour?.document.steps.length ?? 0,
|
||||
chatTurns: chatTurns.length,
|
||||
});
|
||||
}).filter(tab => tab.id !== 'change');
|
||||
explainTabsRef.current = explainTabList;
|
||||
|
||||
/*
|
||||
@@ -4771,6 +4790,9 @@ export default function App(): JSX.Element {
|
||||
ir: matchingInspectorIr(liveCode.ir, symbol, project, activePath), selection }, crypto.randomUUID()));
|
||||
setBrowserAiOpen(true);
|
||||
};
|
||||
// The chat grounds explanations in the selected symbol's source, read like "Read source evidence" (K14).
|
||||
const readSymbolSource = useCallback((qualifiedName: string, window: { maxLines: number }) =>
|
||||
client.getCodeSnippet(project, qualifiedName, window), [client, project]);
|
||||
const clearAttachment = (id: string): void => {
|
||||
setChatAttachment(current => current?.id === id ? undefined : current);
|
||||
};
|
||||
@@ -4784,36 +4806,30 @@ export default function App(): JSX.Element {
|
||||
projectSwitcher={<ProjectSwitcher currentProject={project} listProjects={projectsSource.listProjects}
|
||||
onSelectProject={openProject} onAddProject={() => setProjectsOpen(true)} indexActivity={indexActivity} />}
|
||||
onWorkspaceChange={value => { setDiagnosticsPath(undefined); changeWorkspace(value); }}
|
||||
onOpenBrowserAi={() => setBrowserAiOpen(open => !open)}
|
||||
onOpenBrowserAi={openAgentSettings}
|
||||
onToggleBrowserAi={() => setBrowserAiOpen(open => !open)}
|
||||
agentState={localAgentState} agentModelName={agentModelName}
|
||||
onOpenSystem={() => changeWorkspace('system')}
|
||||
daemonState={serverOk === undefined ? 'checking' : serverOk ? 'connected' : 'disconnected'}
|
||||
chatOpen={browserAiOpen}
|
||||
chatDock={<BrowserChatDock open={browserAiOpen} onClose={() => setBrowserAiOpen(false)}
|
||||
showCollapsed={workspace === 'explore'} onOpen={() => setBrowserAiOpen(true)}
|
||||
historyKey={project ? browserChatHistoryProjectKey(window.location.origin, project) : undefined}
|
||||
proactiveSelection={proactiveSelection?.scope === selectionScope ? proactiveSelection.context : undefined}
|
||||
selectionScope={selectionScope} onAgentStateChange={onAgentStateChange} onAgentModelChange={setAgentModelName} settingsRequest={agentSettingsRequest}
|
||||
readerContext={workspace === 'explore' ? currentReaderContext : undefined}
|
||||
pendingContext={chatGraphSelection} onContextConsumed={clearChatGraphSelection} onContextRemoved={clearChatGraphSelection}
|
||||
context={browserGraphContext(inspector.ir, project, inspector.filePath, inspector.symbol ? workspacePathOf(inspector.symbol.uri) : '')}
|
||||
attachment={chatAttachment} onAttachmentConsumed={clearAttachment} onAttachmentRemoved={clearAttachment} />}
|
||||
attachment={chatAttachment} onAttachmentConsumed={clearAttachment} onAttachmentRemoved={clearAttachment} readSource={readSymbolSource} />}
|
||||
readerActions={<>
|
||||
<button type="button" disabled={!liveSelection} aria-keyshortcuts="Control+Shift+L Meta+Shift+L"
|
||||
onClick={() => { if (liveSelection) attachSelection(liveSelection); }}>{workspaceText.askSelection}</button>
|
||||
<button type="button" disabled={!inspector.filePath} onClick={() => openSelectionImpact(inspectorImpactTarget)}>{workspaceText.assessImpact}</button>
|
||||
<button type="button" aria-expanded={codeDetailsOpen} onClick={() => setCodeDetailsOpen(open => !open)}>{workspaceText.codeDetails}</button>
|
||||
</>}
|
||||
readerSummary={workspace === 'explore' && activePath ? <FileImpactSummary
|
||||
project={project} target={readerImpactTarget}
|
||||
onOpen={target => exploreEvidence({ filePath: target.filePath, line: target.line, name: target.name })} /> : undefined}
|
||||
readerDetails={codeDetailsOpen ? selectedCode : undefined}
|
||||
globalOverlay={<>
|
||||
{(impactOpen || analysisSelection?.project === project) && <div className="atlas-analysis-overlay" role="dialog" aria-label={workspaceText.changeAnalysis} onKeyDown={event => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Escape') { setAnalysisSelection(undefined); if (impactOpen) collapseExplain(); }
|
||||
}}><ChangeAnalysisWorkspace key={project} project={project} client={client}
|
||||
selectedTarget={analysisSelection?.project === project ? analysisSelection.target : (workspace === 'architecture' || workspace === 'galaxy') ? mapImpactTarget : inspectorImpactTarget}
|
||||
initialMode={analysisSelection?.project === project ? 'selection' : 'worktree'} expectedGeneration={repositoryReading.snapshot?.generation}
|
||||
onOpenSource={target => navigateEvidence(target.filePath, target.line, target.name)}
|
||||
onDiagnose={() => {
|
||||
setDiagnosticsPath((workspace === 'architecture' || workspace === 'galaxy') ? activeGalaxySelection?.file_path : activePath || undefined);
|
||||
setAnalysisSelection(undefined); if (impactOpen) collapseExplain();
|
||||
changeWorkspace('coverage');
|
||||
}} onClose={() => { setAnalysisSelection(undefined); if (impactOpen) collapseExplain(); }} /></div>}
|
||||
{sourceEvidence?.project === project && <SourceEvidenceDrawer key={`${project}:${sourceEvidence.target.filePath}:${sourceEvidence.target.line ?? 1}`}
|
||||
project={project} target={sourceEvidence.target} graph={evidenceGraph} client={client}
|
||||
onClose={() => setSourceEvidence(undefined)} onExplore={exploreEvidence} />}
|
||||
@@ -4824,7 +4840,7 @@ export default function App(): JSX.Element {
|
||||
onOpenProject={openProject} onClose={() => setProjectsOpen(false)} />
|
||||
{helpOpen && <HelpOverlay onClose={() => setHelpOpen(false)} />}
|
||||
{settingsOpen && <SettingsPanel project={project} state={llmState} facts={llmFacts}
|
||||
onOpenBrowserModels={() => { setSettingsOpen(false); setBrowserAiOpen(true); }}
|
||||
onOpenBrowserModels={() => { setSettingsOpen(false); openAgentSettings(); }}
|
||||
router={llmRouter} models={llmModels} selectedModel={selectedModel}
|
||||
onSelectModel={chooseModel}
|
||||
display={display} onDisplay={changeDisplay}
|
||||
@@ -4837,7 +4853,9 @@ export default function App(): JSX.Element {
|
||||
workspacePanel={<>
|
||||
<div hidden={workspace !== 'architecture'}>
|
||||
<ArchitecturePanel projectName={project} overview={overview} active={workspace === 'architecture'}
|
||||
escapeTaken={helpOpen || entryOpen || overlayOpen || settingsOpen || projectsOpen}
|
||||
onClearSelection={clearGraphSelection}
|
||||
onSelectionEvidence={onSelectionEvidence}
|
||||
coverage={coverageAsked && coverageMeta.generation === repositoryReading.snapshot?.generation ? coverage : undefined}
|
||||
readSource={readMapSource}
|
||||
graph={repositoryReading.snapshot} graphGeneration={repositoryReading.snapshot?.generation} selection={activeGalaxySelection} selectionPanel={mapSelectionPanel}
|
||||
@@ -4858,7 +4876,9 @@ export default function App(): JSX.Element {
|
||||
<AdrWorkspace key={project} project={project} source={projectsSource} active={workspace === 'adr'} />
|
||||
</div>
|
||||
<div hidden={workspace !== 'system'}>
|
||||
<SystemWorkspace api={api} project={project} active={workspace === 'system'} version={ATLAS_VERSION}
|
||||
<SystemWorkspace api={api} listProjects={projectsSource.listProjects} project={project} active={workspace === 'system'} version={ATLAS_VERSION}
|
||||
configuration={active => <ConfigPanel embedded active={active} service={api} project={project} display={display} onDisplay={changeDisplay}
|
||||
onOpenBrowserModels={openAgentSettings} onOpenDisplay={() => setSettingsOpen(true)} onClose={() => {}} />}
|
||||
onOpenProjects={() => setProjectsOpen(true)} />
|
||||
</div>
|
||||
<div hidden={workspace !== 'coverage'}>
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -5,6 +5,7 @@ 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 {
|
||||
@@ -27,13 +28,15 @@ interface EditorState {
|
||||
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 };
|
||||
phase: 'idle', error: '', notice: draft ? s.restored : '', reviewing: false, reads: 0 };
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [storageAvailable, setStorageAvailable] = useState(true);
|
||||
@@ -60,7 +63,7 @@ function ProjectAdrWorkspace({ project, active, source }: AdrWorkspaceProps): JS
|
||||
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: '' }));
|
||||
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;
|
||||
@@ -154,6 +157,14 @@ function ProjectAdrWorkspace({ project, active, source }: AdrWorkspaceProps): JS
|
||||
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">
|
||||
@@ -164,7 +175,7 @@ function ProjectAdrWorkspace({ project, active, source }: AdrWorkspaceProps): JS
|
||||
<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>
|
||||
</> : <>
|
||||
<button type="button" disabled={state.phase === 'loading'} onClick={() => setReload(value => value + 1)}>{s.refresh}</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>
|
||||
|
||||
@@ -2,7 +2,8 @@ export const adrStrings = {
|
||||
label: 'Architecture decisions',
|
||||
noProject: 'Select a project to read its architecture decisions.',
|
||||
loading: 'Loading decisions…',
|
||||
refresh: 'Refresh',
|
||||
/** 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',
|
||||
|
||||
@@ -18,6 +18,8 @@ it('keeps unmapped evidence visible and only opens recorded graph locations', as
|
||||
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');
|
||||
|
||||
@@ -18,7 +18,7 @@ interface Props {
|
||||
project?: string; api?: Pick<AtlasApi, 'repoInfo'>;
|
||||
graph?: GraphData; onToggle: () => void; onOpenNode: (node: GraphNode) => void;
|
||||
}
|
||||
const emptyFilter: ActivityFilter = { agent: '', run: '', kind: '', query: '' };
|
||||
const emptyFilter: ActivityFilter = { agent: '', run: '', kind: '' };
|
||||
const rowKey = (row: AgentEvent): string => `${row.agent}:${eventKey(row)}`;
|
||||
|
||||
export default function ActivityPanel(props: Props): JSX.Element {
|
||||
@@ -50,7 +50,6 @@ export default function ActivityPanel(props: Props): JSX.Element {
|
||||
{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">
|
||||
<input aria-label={s.search} placeholder={s.placeholder} value={filter.query} onChange={(event) => set({ query: event.target.value })} />
|
||||
<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>
|
||||
|
||||
@@ -10,13 +10,13 @@ const event = (patch: Partial<AgentEvent>): AgentEvent => ({
|
||||
const state = [event({}), event({ seq: 2, ts: 2000, tool: 'Edit', path: 'missing.ts' }),
|
||||
event({ agent: 'Ben', run: 'two', ts: 3000, path: '' })]
|
||||
.reduce(withEvent, emptyAgentsState());
|
||||
const all = { agent: '', run: '', kind: '' as const, query: '' };
|
||||
const all = { agent: '', run: '', kind: '' as const };
|
||||
|
||||
it('keeps unmapped events and orders recorded activity newest first', () => {
|
||||
expect(activityRows(state, all).map((row) => row.path)).toEqual(['', 'missing.ts', 'src/main.ts']);
|
||||
});
|
||||
it('intersects agent, run, work kind and text filters', () => {
|
||||
expect(activityRows(state, { agent: 'Ada', run: 'one', kind: 'write', query: 'MISSING' }))
|
||||
it('intersects agent, run and work kind filters', () => {
|
||||
expect(activityRows(state, { agent: 'Ada', run: 'one', kind: 'write' }))
|
||||
.toEqual([event({ seq: 2, ts: 2000, tool: 'Edit', path: 'missing.ts' })]);
|
||||
expect(activityRows(state, { ...all, agent: 'Ben', run: 'one' })).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -2,14 +2,11 @@ import type { AgentEvent, WorkKind } from './agent-event';
|
||||
import { workKindOf } from './agent-event';
|
||||
import type { AgentsState } from './agent-store';
|
||||
|
||||
export interface ActivityFilter { agent: string; run: string; kind: WorkKind | ''; query: string }
|
||||
export interface ActivityFilter { agent: string; run: string; kind: WorkKind | '' }
|
||||
export function activityRows(state: AgentsState, filter: ActivityFilter): AgentEvent[] {
|
||||
const query = filter.query.trim().toLocaleLowerCase();
|
||||
return state.actors.filter((actor) => !actor.you).flatMap((actor) => actor.events)
|
||||
.filter((event) => (!filter.agent || event.agent === filter.agent)
|
||||
&& (!filter.run || event.run === filter.run)
|
||||
&& (!filter.kind || workKindOf(event.tool, event.detail) === filter.kind)
|
||||
&& (!query || [event.path, event.tool, event.detail, event.agent, event.run]
|
||||
.join(' ').toLocaleLowerCase().includes(query)))
|
||||
&& (!filter.kind || workKindOf(event.tool, event.detail) === filter.kind))
|
||||
.sort((a, b) => b.ts - a.ts || b.seq - a.seq || a.agent.localeCompare(b.agent));
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export const activityStrings = {
|
||||
title: 'Agent activity', subtitle: 'Follow recorded work and inspect the code it touches.',
|
||||
connect: 'Load recorded activity', disconnect: 'Disconnect', agent: 'Agent', run: 'Run', kind: 'Activity',
|
||||
allAgents: 'All agents', allRuns: 'All runs', allKinds: 'All activity', search: 'Filter activity',
|
||||
placeholder: 'Search tools, paths, or details…', events: 'Recorded events', actors: 'Agents seen',
|
||||
allAgents: 'All agents', allRuns: 'All runs', allKinds: 'All activity',
|
||||
events: 'Recorded events', actors: 'Agents seen',
|
||||
gaps: 'Missing sequence numbers', dropped: 'Connection drops', unmapped: 'Unmapped',
|
||||
noSource: 'Load recorded activity from this daemon. Hooks send events to /api/agent-events on the same port.',
|
||||
noEvents: 'No recorded activity for this repository. Configure the tool hook with the indexed project name; tool calls are observed, never inferred.', noMatch: 'No activity matches these filters.',
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import AtlasChrome, { COMMAND_PLACEHOLDER, MENU_ITEMS, splitMenuLabel } from './AtlasChrome';
|
||||
import AtlasChrome, { splitMenuLabel } from './AtlasChrome';
|
||||
import type { AtlasChromeProps } from './AtlasChrome';
|
||||
import type { TreeRow } from './tree-model';
|
||||
|
||||
@@ -81,9 +81,80 @@ const testId = (id: string): HTMLElement | null => container.querySelector(`[dat
|
||||
|
||||
describe('AtlasChrome', () => {
|
||||
|
||||
it('leaves general configuration out of the top bar', async () => {
|
||||
const onOpenConfig = vi.fn();
|
||||
await render(props({ onOpenConfig, configOpen: true, onOpenBrowserAi: vi.fn(), projectSwitcher: <button>Project</button> }));
|
||||
expect(testId('atlas-header')?.querySelector('[aria-label="Open configuration"]')).toBeNull();
|
||||
expect(testId('atlas-header')?.lastElementChild?.getAttribute('aria-label')).toBe('Local agent');
|
||||
expect(onOpenConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a robot with a status lamp and keeps the model name in the tooltip and the label', async () => {
|
||||
const settings = vi.fn(), toggle = vi.fn();
|
||||
await render(props({ agentState: 'active', agentModelName: 'Qwen 3.5 0.8B', onOpenBrowserAi: settings, onToggleBrowserAi: toggle }));
|
||||
const button = container.querySelector<HTMLButtonElement>('.atlas-browser-ai-action')!;
|
||||
expect(button.textContent).toBe('');
|
||||
expect(button.querySelector('svg[aria-hidden="true"]')).not.toBeNull();
|
||||
expect(button.querySelector(':scope > span[aria-hidden="true"]')).not.toBeNull();
|
||||
expect(button.dataset.state).toBe('active');
|
||||
expect(button.getAttribute('aria-label')).toBe('Local agent settings: Agent active, Qwen 3.5 0.8B');
|
||||
expect(button.title).toBe('Agent active · Qwen 3.5 0.8B. Configure the browser-local agent.');
|
||||
await act(async () => button.click());
|
||||
expect(settings).toHaveBeenCalledOnce();
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
expect(container.querySelector('[role="menu"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('omits the model from the label when no model is selected', async () => {
|
||||
await render(props({ agentState: 'off', onOpenBrowserAi: vi.fn() }));
|
||||
expect(container.querySelector('.atlas-browser-ai-action')?.getAttribute('aria-label')).toBe('Local agent settings: Agent off');
|
||||
});
|
||||
|
||||
it.each([['off', 'Agent off'], ['active', 'Agent active'], ['busy', 'Agent working'], ['loading', 'Agent loading'], ['error', 'Agent error']] as const)('shows truthful local agent state %s and opens settings beside the project', async (agentState, label) => {
|
||||
const settings = vi.fn();
|
||||
await render(props({ agentState, onOpenBrowserAi: settings, projectSwitcher: <button>Project</button> }));
|
||||
const button = container.querySelector<HTMLButtonElement>('.atlas-browser-ai-action')!;
|
||||
expect(button.dataset.state).toBe(agentState);
|
||||
expect(button.getAttribute('aria-label')).toBe(`Local agent settings: ${label}`);
|
||||
expect(button.title).toContain(label);
|
||||
expect(button.parentElement?.previousElementSibling?.textContent).toBe('Project');
|
||||
await act(async () => button.click());
|
||||
expect(settings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(['explore', 'architecture', 'galaxy', 'system', 'adr', 'coverage', 'agents'] as const)('groups independent configuration and chat controls in the top bar for %s', async workspace => {
|
||||
const settings = vi.fn(), toggle = vi.fn();
|
||||
const next = (chatOpen: boolean) => props({ workspace, chatOpen, onOpenBrowserAi: settings, onToggleBrowserAi: toggle, chatDock: <input aria-label="Chat draft" defaultValue="conversation draft" /> });
|
||||
await render(next(false));
|
||||
const group = testId('atlas-header')?.querySelector('[role="group"][aria-label="Local agent"]');
|
||||
expect(group?.querySelectorAll('button')).toHaveLength(2);
|
||||
const draft = container.querySelector<HTMLInputElement>('[aria-label="Chat draft"]')!;
|
||||
draft.value = 'retained draft';
|
||||
const settingsButton = group?.querySelector<HTMLButtonElement>('.atlas-browser-ai-action')!;
|
||||
const openButton = group?.querySelector<HTMLButtonElement>('[aria-label="Open chat"]')!;
|
||||
expect(openButton.getAttribute('aria-expanded')).toBe('false');
|
||||
await act(async () => settingsButton.click());
|
||||
expect(settings).toHaveBeenCalledOnce();
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
await act(async () => openButton.click());
|
||||
expect(toggle).toHaveBeenCalledOnce();
|
||||
expect(settings).toHaveBeenCalledOnce();
|
||||
await render(next(true));
|
||||
const hideButton = testId('atlas-header')?.querySelector<HTMLButtonElement>('[aria-label="Hide chat"]')!;
|
||||
expect(hideButton.getAttribute('aria-expanded')).toBe('true');
|
||||
await act(async () => hideButton.click());
|
||||
expect(toggle).toHaveBeenCalledTimes(2);
|
||||
expect(settings).toHaveBeenCalledOnce();
|
||||
await render(next(false));
|
||||
expect(container.querySelector('[aria-label="Chat draft"]')).toBe(draft);
|
||||
expect(draft.value).toBe('retained draft');
|
||||
expect(container.querySelector('.atlas-agent-reopen')).toBeNull();
|
||||
expect(container.querySelector('[role="menu"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('traegt jede Testmarke, an der der Beweislauf das Chrome erkennt', async () => {
|
||||
await render(props());
|
||||
for (const id of ['atlas-header', 'atlas-menu', 'atlas-tabs', 'atlas-command',
|
||||
for (const id of ['atlas-header', 'atlas-tabs',
|
||||
'atlas-statusbar', 'atlas-tree', 'atlas-breadcrumb']) {
|
||||
expect(testId(id), `${id} fehlt`).not.toBeNull();
|
||||
}
|
||||
@@ -91,8 +162,9 @@ describe('AtlasChrome', () => {
|
||||
|
||||
it('zeigt Marke und Versions-Chip in der Kopfzeile', async () => {
|
||||
await render(props());
|
||||
expect(container.querySelector('.atlas-brand')?.textContent).toBe('CODEATLAS');
|
||||
expect(testId('atlas-version')?.textContent).toBe('v0.0.0-dirty');
|
||||
expect(container.querySelector('.atlas-brand')?.textContent).toBe('CBM LENS Beta');
|
||||
expect(container.querySelector('.atlas-brand-beta')?.textContent).toBe('Beta');
|
||||
expect(testId('atlas-version')?.textContent).toBe('v0.0.0');
|
||||
});
|
||||
|
||||
it('keeps project switching at the end of the header without a duplicate project chip', async () => {
|
||||
@@ -106,18 +178,10 @@ describe('AtlasChrome', () => {
|
||||
expect(header?.querySelector('[data-chip="sym"]')?.textContent).toContain('76');
|
||||
});
|
||||
|
||||
/*
|
||||
* Der Zustand des Arbeitsbaums steht neben der Fassung, nicht in ihr.
|
||||
*
|
||||
* Der Chip beantwortet "welche Fassung ist das" und `v1.0.0-dirty` waere
|
||||
* darauf eine Antwort, die es als Release nicht gibt. Ohne Zusatz steht
|
||||
* daneben nichts: ein leeres Element waere ein Platz, an dem jemand eine
|
||||
* Angabe vermutet.
|
||||
*/
|
||||
it('haelt den Bau-Zusatz getrennt vom Versions-Chip', async () => {
|
||||
it('omits build diagnostics from the header', async () => {
|
||||
await render(props({ version: 'v1.0.0', buildSuffix: 'dirty' }));
|
||||
expect(testId('atlas-version')?.textContent).toBe('v1.0.0');
|
||||
expect(testId('atlas-version-suffix')?.textContent).toBe('dirty');
|
||||
expect(testId('atlas-version-suffix')).toBeNull();
|
||||
});
|
||||
|
||||
it('zeigt gar kein Zusatz-Element, wenn der Baum sauber war', async () => {
|
||||
@@ -126,113 +190,25 @@ describe('AtlasChrome', () => {
|
||||
expect(testId('atlas-version-suffix')).toBeNull();
|
||||
});
|
||||
|
||||
/*
|
||||
* Die Menuezeile nach W7a (Nutzerauftrag 2026-08-29): sie zeichnet nur, was
|
||||
* eine Verdrahtung hat. Ein Punkt ohne Verdrahtung ist kein blasser Knopf
|
||||
* mit Tooltip mehr, sondern gar kein Knopf.
|
||||
*/
|
||||
it('zeichnet nur Menuepunkte, hinter denen etwas liegt', async () => {
|
||||
const onSelect = vi.fn();
|
||||
await render(props({
|
||||
menus: { a: { title: 'atlas: hide the galaxy panel (alt+a)', state: 'on', onSelect } },
|
||||
}));
|
||||
const buttons = [...(testId('atlas-menu')?.querySelectorAll('button') ?? [])];
|
||||
expect(buttons.map((button) => button.getAttribute('data-menu'))).toEqual(['a']);
|
||||
expect(buttons).toHaveLength(1);
|
||||
expect(MENU_ITEMS.length).toBeGreaterThan(1);
|
||||
expect(testId('atlas-menu')?.textContent).toContain('[a]tlas');
|
||||
expect(testId('atlas-menu')?.textContent).not.toContain('[?]help');
|
||||
});
|
||||
|
||||
it('sagt in der Zeile, dass die Klammer Alt verlangt', async () => {
|
||||
await render(props());
|
||||
expect(testId('atlas-menu-legend')?.textContent).toContain('alt');
|
||||
});
|
||||
|
||||
it('macht die verdrahteten Menuepunkte anklickbar', async () => {
|
||||
const onSelect = vi.fn();
|
||||
await render(props({
|
||||
menus: { a: { title: 'atlas: hide the galaxy panel (alt+a)', state: 'on', onSelect } },
|
||||
}));
|
||||
const atlas = container.querySelector('[data-menu="a"]') as HTMLButtonElement;
|
||||
expect(atlas.getAttribute('aria-disabled')).toBeNull();
|
||||
expect(atlas.getAttribute('data-state')).toBe('on');
|
||||
expect(atlas.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(atlas.getAttribute('data-hint') ?? '').toContain('galaxy');
|
||||
await act(async () => {
|
||||
atlas.click();
|
||||
});
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
/*
|
||||
* Die Menuezeile nach W7b (Nutzerbefund 2026-08-29, Screenshot): `[a]tlas`
|
||||
* und `[?]help` trugen einen Rahmen, die vier Eintraege dazwischen einen
|
||||
* gepunkteten Unterstrich in kleinerer Schrift, und dadurch sahen sie aus
|
||||
* wie Beschriftungen zwischen zwei Bedienelementen. Sie sind aber alle
|
||||
* dasselbe, also sehen sie jetzt auch so aus. Geprueft wird die Struktur,
|
||||
* nicht die Farbe: eine Klasse, ein Element, ein Buchstabe in Klammern.
|
||||
*/
|
||||
it('gibt den fensterweiten Eintraegen dieselbe Gestalt', async () => {
|
||||
const acts = Object.fromEntries(
|
||||
['a', 'why', 'bug', 'impact', 'llm', 'help'].map((key) => [key, vi.fn()]),
|
||||
);
|
||||
await render(props({
|
||||
menus: {
|
||||
a: {
|
||||
title: 'atlas: hide the galaxy panel (alt+a)',
|
||||
state: 'on',
|
||||
onSelect: acts['a'],
|
||||
extras: [
|
||||
{ key: 'why', label: '[w]hy am I here', title: 'w', onSelect: acts['why'] },
|
||||
{ key: 'bug', label: '[b]ug hunt', title: 'b', onSelect: acts['bug'] },
|
||||
{ key: 'impact', label: '[c]hange scope', title: 'c', onSelect: acts['impact'] },
|
||||
{ key: 'llm', label: '[l]lm off', title: 'l', onSelect: acts['llm'] },
|
||||
],
|
||||
},
|
||||
'?': { title: 'help', state: 'off', onSelect: acts['help'] },
|
||||
},
|
||||
}));
|
||||
const entries = [...(testId('atlas-menu')?.querySelectorAll('[data-menu]') ?? [])];
|
||||
expect(entries.map((node) => node.getAttribute('data-menu')))
|
||||
.toEqual(['a', 'a-why', 'a-llm', '?']);
|
||||
for (const entry of entries) {
|
||||
expect(entry.tagName, entry.getAttribute('data-menu') ?? '').toBe('BUTTON');
|
||||
expect(entry.getAttribute('class'), entry.getAttribute('data-menu') ?? '')
|
||||
.toBe('atlas-menu-item');
|
||||
expect(entry.querySelector('.atlas-menu-key')?.textContent, entry.textContent ?? '')
|
||||
.toMatch(/^\[[a-z?]\]$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('laesst jeden sichtbaren Eintrag mit der Maus ausloesen', async () => {
|
||||
const acts = Object.fromEntries(
|
||||
['a', 'why', 'bug', 'impact', 'llm', 'help'].map((key) => [key, vi.fn()]),
|
||||
);
|
||||
await render(props({
|
||||
menus: {
|
||||
a: {
|
||||
title: 'atlas',
|
||||
state: 'on',
|
||||
onSelect: acts['a'],
|
||||
extras: [
|
||||
{ key: 'why', label: '[w]hy am I here', title: 'w', onSelect: acts['why'] },
|
||||
{ key: 'bug', label: '[b]ug hunt', title: 'b', onSelect: acts['bug'] },
|
||||
{ key: 'impact', label: '[c]hange scope', title: 'c', onSelect: acts['impact'] },
|
||||
{ key: 'llm', label: '[l]lm off', title: 'l', onSelect: acts['llm'] },
|
||||
],
|
||||
},
|
||||
'?': { title: 'help', state: 'off', onSelect: acts['help'] },
|
||||
},
|
||||
}));
|
||||
for (const entry of [...(testId('atlas-menu')?.querySelectorAll('[data-menu]') ?? [])]) {
|
||||
await act(async () => {
|
||||
(entry as HTMLButtonElement).click();
|
||||
});
|
||||
}
|
||||
for (const [name, act_] of Object.entries(acts)) {
|
||||
expect(act_, name).toHaveBeenCalledTimes(name === 'bug' || name === 'impact' ? 0 : 1);
|
||||
}
|
||||
it.each(['on', 'off'] as const)('keeps removed tool menus absent even when legacy actions are supplied (%s)', async state => {
|
||||
const onSelect = vi.fn(); const extra = vi.fn(); const help = vi.fn();
|
||||
await render(props({ menus: {
|
||||
a: { title: 'Legacy tools', state, onSelect, extras: [
|
||||
{ key: 'why', label: '[w]hy am I here', title: 'Where to start', onSelect: extra },
|
||||
{ key: 'bug', label: '[b]ug hunt', title: 'Bug hunt', onSelect: extra },
|
||||
{ key: 'impact', label: '[c]hange scope', title: 'Impact', onSelect: extra },
|
||||
{ key: 'llm', label: '[l]lm off', title: 'Model', onSelect: extra },
|
||||
] },
|
||||
'?': { title: 'Help', state, onSelect: help },
|
||||
} }));
|
||||
expect(testId('atlas-menu')).toBeNull();
|
||||
expect(testId('atlas-menu-legend')).toBeNull();
|
||||
expect(container.querySelector('[data-menu]')).toBeNull();
|
||||
expect(container.querySelector('.atlas-tools-menu')).toBeNull();
|
||||
expect(testId('fake-reader')).not.toBeNull();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
expect(extra).not.toHaveBeenCalled();
|
||||
expect(help).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('laesst ein Etikett ohne Klammer ganz stehen, statt eine zu erfinden', () => {
|
||||
@@ -241,71 +217,22 @@ describe('AtlasChrome', () => {
|
||||
expect(splitMenuLabel('plain')).toEqual({ key: '', rest: 'plain' });
|
||||
});
|
||||
|
||||
it('haelt die Kommandozeile fokussierbar und zeigt Platzhalter und Hinweis', async () => {
|
||||
it('does not render a global command bar, search dialog or legacy search results', async () => {
|
||||
const onCommandChange = vi.fn();
|
||||
await render(props({ onCommandChange }));
|
||||
const input = testId('atlas-command-input') as HTMLInputElement;
|
||||
expect(input.placeholder).toBe(COMMAND_PLACEHOLDER);
|
||||
expect(input.placeholder).toContain('type a command or ask the atlas');
|
||||
input.focus();
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(container.querySelector('.atlas-command-hint')?.textContent)
|
||||
.toContain('search by meaning');
|
||||
await render(props({ onCommandChange, commandValue: 'old query', commandOverlay: <div data-testid="fake-overlay" /> }));
|
||||
expect(testId('atlas-command')).toBeNull();
|
||||
expect(testId('atlas-command-input')).toBeNull();
|
||||
expect(testId('fake-overlay')).toBeNull();
|
||||
expect(container.querySelector('dialog')).toBeNull();
|
||||
expect(container.querySelector('.atlas-command-hint')).toBeNull();
|
||||
expect(onCommandChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/*
|
||||
* AC10 (Nutzer-Screenshot 2026-08-29): der native Tooltip legte sich beim
|
||||
* Zeigen ueber den Anfang der Zeile, also ueber den Text, den der Leser
|
||||
* gerade tippt. Was er sagte, steht jetzt in der Hilfe.
|
||||
*/
|
||||
it('haengt keinen nativen Tooltip mehr an die Zeile', async () => {
|
||||
await render(props());
|
||||
expect((testId('atlas-command-input') as HTMLInputElement).hasAttribute('title')).toBe(false);
|
||||
expect(testId('atlas-command')?.hasAttribute('title')).toBe(false);
|
||||
});
|
||||
|
||||
/*
|
||||
* AC9: die Zeile sagt, ob sie den Fokus hat. Ohne das war der Zustand, in
|
||||
* dem Tippen frueher ins Leere lief, unsichtbar.
|
||||
*/
|
||||
it('zeigt im DOM, ob die Zeile den Fokus hat', async () => {
|
||||
await render(props());
|
||||
expect(testId('atlas-command')?.getAttribute('data-focused')).toBe('false');
|
||||
const input = testId('atlas-command-input') as HTMLInputElement;
|
||||
await act(async () => {
|
||||
input.focus();
|
||||
});
|
||||
expect(testId('atlas-command')?.getAttribute('data-focused')).toBe('true');
|
||||
await act(async () => {
|
||||
input.blur();
|
||||
});
|
||||
expect(testId('atlas-command')?.getAttribute('data-focused')).toBe('false');
|
||||
});
|
||||
|
||||
it('forwards command keys while Escape closes search and restores its trigger', async () => {
|
||||
const handled = vi.fn();
|
||||
await render(props({ onCommandKeyDown: handled, children: <button data-testid="reader-focus">Reader</button> }));
|
||||
const trigger = testId('reader-focus') as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
trigger.focus();
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
});
|
||||
const input = testId('atlas-command-input') as HTMLInputElement;
|
||||
await act(async () => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })));
|
||||
expect(handled).toHaveBeenCalledOnce();
|
||||
await act(async () => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })));
|
||||
expect(handled).toHaveBeenCalledOnce();
|
||||
expect(container.querySelector<HTMLDialogElement>('dialog')?.open).toBe(false);
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
|
||||
it('zeigt das Suchfenster in der Kommandozeile und die Galaxie neben dem Twin', async () => {
|
||||
await render(props({
|
||||
commandOverlay: <div data-testid="fake-overlay" />,
|
||||
twin: <aside data-testid="fake-twin" />,
|
||||
galaxy: <section data-testid="fake-galaxy" />,
|
||||
}));
|
||||
expect(testId('atlas-command')?.querySelector('[data-testid="fake-overlay"]')).not.toBeNull();
|
||||
it('preserves the reader and galaxy when obsolete command props are supplied', async () => {
|
||||
await render(props({ commandOverlay: <div data-testid="fake-overlay" />,
|
||||
twin: <aside data-testid="fake-twin" />, galaxy: <section data-testid="fake-galaxy" /> }));
|
||||
expect(testId('fake-reader')).not.toBeNull();
|
||||
expect(testId('fake-overlay')).toBeNull();
|
||||
const side = container.querySelector('.atlas-side');
|
||||
expect(side?.querySelector('[data-testid="fake-twin"]')).not.toBeNull();
|
||||
expect(side?.querySelector('[data-testid="fake-galaxy"]')).not.toBeNull();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Das Chrome: Kopfzeile, Menue, Tabs, Explorer, Breadcrumb, Reader-Flaeche,
|
||||
* Kommandozeile, Statusleiste. Vorlage ist design/design.png.
|
||||
* Workspace chrome: header, menu, tabs, explorer, breadcrumbs, reader,
|
||||
* command line, and status bar.
|
||||
*
|
||||
* Rein darstellend, und das ist der Punkt: der Reader kommt als `children`
|
||||
* herein, nicht als Import. So laesst sich das ganze Chrome in jsdom pruefen,
|
||||
@@ -15,7 +15,7 @@
|
||||
* Verdrahtung wird NICHT gezeichnet. Ein Tooltip macht aus einer Attrappe keine
|
||||
* Auskunft, sondern eine Attrappe mit Fussnote, und ein Menue, das sich
|
||||
* anklicken laesst und schweigt, ist die Sorte stille Luege, gegen die dieses
|
||||
* Produkt gebaut ist (PLAN.md Abschnitt 6, Ehrlichkeitsregeln).
|
||||
* Produkt gebaut ist.
|
||||
*
|
||||
* Das ist hier die Notbremse und nicht der Normalfall: welche Punkte es gibt,
|
||||
* entscheidet `messages.menu.items`, und dass jeder davon einen Buchstaben in
|
||||
@@ -37,6 +37,7 @@ import { LAYOUT_DEFAULT } from '../layout/layout-model';
|
||||
import Splitter from '../layout/Splitter';
|
||||
import { availableWorkspaces, workspaceStrings } from './workspace-strings';
|
||||
import type { Workspace, Guidance } from './workspace-strings';
|
||||
import './agent-status.css';
|
||||
|
||||
/** Ein Menuepunkt: sein Buchstaben-Kuerzel und der Rest des Wortes. */
|
||||
export interface MenuItem {
|
||||
@@ -139,13 +140,19 @@ export interface TabDescriptor {
|
||||
|
||||
export interface AtlasChromeProps {
|
||||
projectSwitcher?: ReactNode;
|
||||
onOpenConfig?: () => void;
|
||||
configOpen?: boolean;
|
||||
onOpenBrowserAi?: () => void;
|
||||
agentState?: 'off' | 'loading' | 'active' | 'busy' | 'error';
|
||||
agentModelName?: string;
|
||||
onToggleBrowserAi?: () => void;
|
||||
chatOpen?: boolean;
|
||||
chatDock?: ReactNode;
|
||||
/** Persistent code context, shared by the Explore sidebar and chat column. */
|
||||
selectionInspector?: ReactNode;
|
||||
readerActions?: ReactNode;
|
||||
readerDetails?: ReactNode;
|
||||
readerSummary?: ReactNode;
|
||||
onOpenSystem?: () => void;
|
||||
daemonState?: 'connected' | 'disconnected' | 'checking';
|
||||
globalOverlay?: ReactNode;
|
||||
@@ -157,16 +164,7 @@ export interface AtlasChromeProps {
|
||||
guidance?: Guidance;
|
||||
/** Versions-Chip, zur Buildzeit injiziert. Nur die Fassung, ohne Zusatz. */
|
||||
version: string;
|
||||
/**
|
||||
* Der Zusatz neben dem Chip: `dirty`, wenn beim Bauen etwas uncommitted war.
|
||||
*
|
||||
* Ein eigenes Element und nicht ein Anhaengsel im Chip, weil es eine andere
|
||||
* Aussage ist: der Chip nennt die Fassung, der Zusatz den Zustand des
|
||||
* Baums, aus dem sie gebaut wurde. Zusammengeschrieben liest sich das wie
|
||||
* eine Fassung namens "1.0.0-dirty", die es als Release nie gibt. Fehlt der
|
||||
* Zusatz, steht dort nichts: ein leeres Element waere ein Platz, an dem
|
||||
* jemand eine Angabe vermutet.
|
||||
*/
|
||||
/** Build diagnostics are accepted for compatibility but not shown in the header. */
|
||||
buildSuffix?: string;
|
||||
chips: Chip[];
|
||||
tabs: TabDescriptor[];
|
||||
@@ -533,16 +531,12 @@ export default function AtlasChrome(props: AtlasChromeProps): JSX.Element {
|
||||
* taete dasselbe fuer das Auge, aber nicht fuer den Beweislauf, der lesen
|
||||
* koennen soll, was die Oberflaeche ueber sich behauptet.
|
||||
*/
|
||||
const [commandFocused, setCommandFocused] = useState(false);
|
||||
const [chatWidth, setChatWidth] = useState(420);
|
||||
const [graphShare, setGraphShare] = useState(1 / 3);
|
||||
const [chatColumnHeight, setChatColumnHeight] = useState(600);
|
||||
const chatColumn = useRef<HTMLDivElement>(null);
|
||||
const graphSpace = Math.max(1, chatColumnHeight - 8);
|
||||
const graphHeight = graphSpace * graphShare;
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const searchDialog = useRef<HTMLDialogElement>(null);
|
||||
const searchOpener = useRef<HTMLElement | null>(null);
|
||||
const inlineGalaxyHost = useRef<HTMLDivElement>(null);
|
||||
const chatGalaxyHost = useRef<HTMLDivElement>(null);
|
||||
const inlineInspectorHost = useRef<HTMLDivElement>(null);
|
||||
@@ -588,52 +582,24 @@ export default function AtlasChrome(props: AtlasChromeProps): JSX.Element {
|
||||
return () => { galaxyMount?.remove(); };
|
||||
}, [galaxyMount, graphBelowChat]);
|
||||
|
||||
const openSearch = useCallback(() => {
|
||||
if (!searchDialog.current?.open) {
|
||||
searchOpener.current = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement : null;
|
||||
}
|
||||
setSearchOpen(true);
|
||||
if (searchDialog.current?.open) searchDialog.current.querySelector<HTMLInputElement>('.atlas-command-input')?.focus();
|
||||
}, []);
|
||||
const closeSearch = useCallback(() => setSearchOpen(false), []);
|
||||
useEffect(() => {
|
||||
const onKey = (event: globalThis.KeyboardEvent): void => {
|
||||
const focusGalaxySearch = () => {
|
||||
props.onWorkspaceChange?.('galaxy');
|
||||
requestAnimationFrame(() => window.dispatchEvent(new Event('cbm:focus-galaxy-search')));
|
||||
};
|
||||
const onKey = (event: globalThis.KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openSearch();
|
||||
focusGalaxySearch();
|
||||
}
|
||||
};
|
||||
window.addEventListener(OPEN_COMMAND_SEARCH_EVENT, openSearch);
|
||||
window.addEventListener(CLOSE_COMMAND_SEARCH_EVENT, closeSearch);
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
window.addEventListener(OPEN_COMMAND_SEARCH_EVENT, focusGalaxySearch);
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener(OPEN_COMMAND_SEARCH_EVENT, openSearch);
|
||||
window.removeEventListener(CLOSE_COMMAND_SEARCH_EVENT, closeSearch);
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
window.removeEventListener(OPEN_COMMAND_SEARCH_EVENT, focusGalaxySearch);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [openSearch, closeSearch]);
|
||||
useLayoutEffect(() => {
|
||||
const dialog = searchDialog.current;
|
||||
if (dialog === null) return;
|
||||
if (searchOpen) {
|
||||
if (!dialog.open) {
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal();
|
||||
else dialog.setAttribute('open', '');
|
||||
}
|
||||
dialog.querySelector<HTMLInputElement>('.atlas-command-input')?.focus();
|
||||
} else if (dialog.open) {
|
||||
if (typeof dialog.close === 'function') dialog.close();
|
||||
else dialog.removeAttribute('open');
|
||||
const opener = searchOpener.current;
|
||||
if (opener?.isConnected && !dialog.contains(opener)) opener.focus();
|
||||
else dialog.closest('.atlas-shell')?.querySelector<HTMLButtonElement>('[data-workspace-tab][aria-selected="true"]')?.focus();
|
||||
}
|
||||
}, [searchOpen]);
|
||||
const examples = props.commandExamples ?? [];
|
||||
const showExamples =
|
||||
commandFocused && examples.length > 0 && props.commandValue.trim().length === 0;
|
||||
}, [props.onWorkspaceChange]);
|
||||
|
||||
/*
|
||||
* Die Masse mit ihrer Vorgabe, an einer Stelle.
|
||||
@@ -647,28 +613,13 @@ export default function AtlasChrome(props: AtlasChromeProps): JSX.Element {
|
||||
const hasSide =
|
||||
hasInspector || props.twin !== undefined || (props.galaxy !== undefined && !graphBelowChat) || props.llm !== undefined;
|
||||
|
||||
const stopTabKeys = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeSearch();
|
||||
return;
|
||||
}
|
||||
props.onCommandKeyDown?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="atlas-shell" data-workspace={props.workspace ?? 'explore'} data-guidance={props.guidance ?? 'brief'} data-chat-open={showChatColumn} data-has-status={props.workspaceStatus !== undefined}>
|
||||
<header className="atlas-header" data-testid="atlas-header">
|
||||
<h1 className="atlas-brand">{messages.app.brand}</h1>
|
||||
<h1 className="atlas-brand">{messages.app.brand}{' '}<span className="atlas-brand-beta">{messages.app.releaseLabel}</span></h1>
|
||||
<span className="atlas-version" data-testid="atlas-version">
|
||||
{props.version}
|
||||
{props.version.replace(/-dirty$/, '')}
|
||||
</span>
|
||||
{(props.buildSuffix ?? '').length > 0 && (
|
||||
<span className="atlas-version-suffix" data-testid="atlas-version-suffix">
|
||||
{props.buildSuffix}
|
||||
</span>
|
||||
)}
|
||||
{/*
|
||||
* Sechs Eintraege, EINE Gestalt.
|
||||
*
|
||||
@@ -712,7 +663,18 @@ export default function AtlasChrome(props: AtlasChromeProps): JSX.Element {
|
||||
))}
|
||||
</div>
|
||||
{props.projectSwitcher}
|
||||
{props.onOpenBrowserAi !== undefined && <button type="button" className="atlas-browser-ai-action" aria-expanded={props.chatOpen === true} onClick={props.onOpenBrowserAi}>{workspaceStrings.browserAi}</button>}
|
||||
{(props.onOpenBrowserAi !== undefined || props.onToggleBrowserAi !== undefined) && <div className="atlas-agent-controls" role="group" aria-label={workspaceStrings.agentControls}>
|
||||
{props.onOpenBrowserAi !== undefined && <button type="button" className="atlas-browser-ai-action" data-state={props.agentState ?? 'off'}
|
||||
aria-label={workspaceStrings.agentSettings(workspaceStrings.agentStatus[props.agentState ?? 'off'], props.agentModelName || undefined)}
|
||||
title={workspaceStrings.agentSettingsTitle(workspaceStrings.agentStatus[props.agentState ?? 'off'], props.agentModelName || undefined)} onClick={props.onOpenBrowserAi}>
|
||||
{/* Derselbe Strich und dasselbe Raster wie der Chat-Schalter daneben; den Zustand traegt die Lampe. */}
|
||||
<svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M10 3v3" /><circle cx="10" cy="2.5" r="1" fill="currentColor" stroke="none" /><rect x="3.5" y="6" width="13" height="10" rx="2.5" /><path d="M1.5 10v3M18.5 10v3" /><circle cx="7.5" cy="10.5" r="1.2" fill="currentColor" stroke="none" /><circle cx="12.5" cy="10.5" r="1.2" fill="currentColor" stroke="none" /><path d="M8 13.5h4" /></svg>
|
||||
<span aria-hidden="true" />
|
||||
</button>}
|
||||
{props.onToggleBrowserAi !== undefined && <button type="button" className="atlas-agent-chat-toggle" aria-expanded={props.chatOpen === true} aria-label={props.chatOpen ? workspaceStrings.hideChat : workspaceStrings.openChat} title={props.chatOpen ? workspaceStrings.hideChat : workspaceStrings.openChat} onClick={props.onToggleBrowserAi}>
|
||||
<svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="2" y="3" width="16" height="14" rx="2" /><path d="M12 3v14" /><path className="atlas-agent-chat-toggle-pane" d="M12 3h4a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-4z" stroke="none" /></svg>
|
||||
</button>}
|
||||
</div>}
|
||||
</header>
|
||||
|
||||
{props.workspaceStatus}
|
||||
@@ -767,6 +729,7 @@ export default function AtlasChrome(props: AtlasChromeProps): JSX.Element {
|
||||
)}
|
||||
{props.readerActions !== undefined && <div className="atlas-reader-actions">{props.readerActions}</div>}
|
||||
</div>
|
||||
{props.readerSummary}
|
||||
{props.readerDetails && <section className="atlas-reader-details" aria-label={workspaceStrings.codeDetails}>{props.readerDetails}</section>}
|
||||
{props.coverageNote !== undefined && (
|
||||
<p
|
||||
@@ -839,154 +802,7 @@ export default function AtlasChrome(props: AtlasChromeProps): JSX.Element {
|
||||
* Zeigen ueber den Anfang der Zeile. Was er sagte, steht in der
|
||||
* Hilfe ([?]help).
|
||||
*/}
|
||||
<dialog ref={searchDialog} className="atlas-command-palette" aria-labelledby="atlas-search-title"
|
||||
onCancel={(event) => { event.preventDefault(); closeSearch(); }}
|
||||
onClose={closeSearch}
|
||||
onClick={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
if (event.clientX < bounds.left || event.clientX > bounds.right ||
|
||||
event.clientY < bounds.top || event.clientY > bounds.bottom) closeSearch();
|
||||
}}>
|
||||
<header className="atlas-search-heading">
|
||||
<h2 id="atlas-search-title">{workspaceStrings.searchTitle}</h2>
|
||||
<button type="button" onClick={closeSearch} aria-label={workspaceStrings.searchClose}>×</button>
|
||||
</header>
|
||||
<div
|
||||
className="atlas-command"
|
||||
data-testid="atlas-command"
|
||||
data-hint-keep="command line"
|
||||
data-focused={commandFocused}
|
||||
data-examples={showExamples}
|
||||
onFocus={() => setCommandFocused(true)}
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
setCommandFocused(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="atlas-search-options">{props.commandOverlay}</div>
|
||||
{showExamples && (
|
||||
<div
|
||||
className="atlas-command-examples"
|
||||
data-testid="atlas-command-examples"
|
||||
role="group"
|
||||
aria-label={messages.command.examplesLabel}
|
||||
>
|
||||
<span className="atlas-command-examples-label">
|
||||
{workspaceStrings.searchExamples}
|
||||
</span>
|
||||
{examples.map((example) => (
|
||||
<button
|
||||
key={example.id}
|
||||
type="button"
|
||||
className="atlas-command-example"
|
||||
data-testid="atlas-command-example"
|
||||
data-example={example.id}
|
||||
data-symbol={example.symbol}
|
||||
onMouseDown={(event) => {
|
||||
// Vor dem Blur des Feldes: ein Klick, der
|
||||
// erst nach dem Fokusverlust wirkt, traefe
|
||||
// eine Liste, die dann schon zu waere.
|
||||
event.preventDefault();
|
||||
props.onCommandExample?.(example.text);
|
||||
}}
|
||||
onClick={() => props.onCommandExample?.(example.text)}
|
||||
>
|
||||
<span className="atlas-command-example-text">{example.text}</span>
|
||||
<span className="atlas-command-example-note">{example.note}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className="atlas-command-prompt" data-testid="atlas-command-prompt">
|
||||
{'>'}
|
||||
</span>
|
||||
<input
|
||||
ref={props.commandInputRef}
|
||||
className="atlas-command-input"
|
||||
data-testid="atlas-command-input"
|
||||
aria-label={messages.command.ariaLabel}
|
||||
placeholder={props.commandPlaceholder ?? COMMAND_PLACEHOLDER}
|
||||
value={props.commandValue}
|
||||
onChange={(event) => props.onCommandChange(event.target.value)}
|
||||
onKeyDown={stopTabKeys}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<span className="atlas-command-hint">{props.commandHint}</span>
|
||||
</div>
|
||||
|
||||
<nav className="atlas-menu" data-testid="atlas-menu" onClick={event => { if ((event.target as HTMLElement).closest('button')) closeSearch(); }} aria-label={messages.menu.ariaLabel}>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
const wiring = props.menus?.[item.key];
|
||||
return wiring === undefined ? null : (
|
||||
<span className="atlas-menu-group" key={item.key}>
|
||||
<Hint name={`menu-${item.key}`} text={wiring.title}>
|
||||
<button
|
||||
type="button"
|
||||
className="atlas-menu-item"
|
||||
data-state={wiring.state}
|
||||
data-menu={item.key}
|
||||
data-testid="atlas-menu-item"
|
||||
aria-pressed={wiring.state === 'on'}
|
||||
onClick={wiring.onSelect}
|
||||
>
|
||||
<span className="atlas-menu-key">[{item.key}]</span>
|
||||
{item.rest}
|
||||
</button>
|
||||
</Hint>
|
||||
{(wiring.extras ?? [])
|
||||
.filter((extra) => extra.key !== 'bug' && extra.key !== 'impact')
|
||||
.map((extra) => {
|
||||
const parts = splitMenuLabel(extra.label);
|
||||
return (
|
||||
<Hint
|
||||
key={extra.key}
|
||||
name={`menu-${item.key}-${extra.key}`}
|
||||
text={extra.title}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="atlas-menu-item"
|
||||
/*
|
||||
* `off` und nicht ein dritter
|
||||
* Wert: ein Eintrag der
|
||||
* Atlas-Zeile schaltet eine
|
||||
* Flaeche auf, die beim Zeichnen
|
||||
* der Zeile noch zu ist. `on`
|
||||
* waere die Behauptung, sie
|
||||
* stuende schon da.
|
||||
*/
|
||||
data-state="off"
|
||||
data-menu={`${item.key}-${extra.key}`}
|
||||
data-testid="atlas-menu-item"
|
||||
onClick={extra.onSelect}
|
||||
>
|
||||
{parts.key.length > 0 && (
|
||||
<span className="atlas-menu-key">[{parts.key}]</span>
|
||||
)}
|
||||
{parts.rest}
|
||||
</button>
|
||||
</Hint>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{/*
|
||||
* Was die Klammer bedeutet, in der Zeile selbst.
|
||||
*
|
||||
* Seit dem 2026-08-29 gilt ein Menuebuchstabe nur mit
|
||||
* Alt/Option (src/app/keyboard.ts nennt den Grund: blanke
|
||||
* Buchstaben gehoeren dem Tippen). Ein `[a]` ohne diese
|
||||
* Angabe waere genau die Sorte Versprechen, die dieser
|
||||
* Zyklus aus der Zeile entfernt hat.
|
||||
*/}
|
||||
<span className="atlas-menu-legend" data-testid="atlas-menu-legend">
|
||||
{messages.menu.legend}
|
||||
</span>
|
||||
</nav>
|
||||
</dialog>
|
||||
|
||||
<div className="atlas-statusbar" data-testid="atlas-statusbar">
|
||||
{props.status.filter((chip) => chip.label === messages.statusbar.chipServer).map((chip) => (
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* ohne Server pruefbar ist: was hier steht, ist Anordnung und Tastenlogik, und
|
||||
* beides braucht kein /api.
|
||||
*
|
||||
* Tastatur ist Pflicht, nicht Zugabe (PLAN.md Abschnitt 4: keyboard-first).
|
||||
* Tastatur ist Pflicht, nicht Zugabe.
|
||||
* Die Liste selbst nimmt den Fokus, die Zeilen tragen einen wandernden Cursor.
|
||||
* Waeren die Zeilen einzeln fokussierbar, muesste man sich durch einen ganzen
|
||||
* Baum tabben, um an den Editor zu kommen.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
.atlas-agent-controls {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--atlas-line);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.atlas-agent-controls .atlas-browser-ai-action {
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
min-width: 36px;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: 5px 0 0 5px;
|
||||
}
|
||||
|
||||
.atlas-agent-controls .atlas-browser-ai-action:hover {
|
||||
background: var(--atlas-select);
|
||||
color: var(--atlas-fg);
|
||||
}
|
||||
|
||||
.atlas-agent-controls .atlas-browser-ai-action > span {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 5px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
box-shadow: 0 0 0 2px var(--atlas-bg);
|
||||
}
|
||||
|
||||
.atlas-agent-controls .atlas-browser-ai-action[data-state=active] > span {
|
||||
box-shadow: 0 0 0 2px var(--atlas-bg), 0 0 8px var(--atlas-glow);
|
||||
}
|
||||
|
||||
.atlas-agent-chat-toggle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 36px;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--atlas-line);
|
||||
border-radius: 0 5px 5px 0;
|
||||
background: transparent;
|
||||
color: var(--atlas-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.atlas-agent-chat-toggle:hover, .atlas-agent-chat-toggle[aria-expanded="true"] {
|
||||
background: var(--atlas-select);
|
||||
color: var(--atlas-phosphor);
|
||||
}
|
||||
|
||||
.atlas-agent-chat-toggle-pane { fill: transparent; }
|
||||
.atlas-agent-chat-toggle[aria-expanded="true"] .atlas-agent-chat-toggle-pane { fill: currentColor; opacity: .3; }
|
||||
.atlas-agent-controls button:focus-visible { outline: 2px solid var(--atlas-phosphor); outline-offset: 2px; }
|
||||
@@ -7,6 +7,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AtlasApi, AtlasApiError } from './atlas-api';
|
||||
import { observeErrors, type ErrorReport } from '../provider/error-observer';
|
||||
|
||||
interface FetchCall {
|
||||
url: string;
|
||||
@@ -34,6 +35,37 @@ function api(reply: { ok?: boolean; status?: number; body: string }): { api: Atl
|
||||
}
|
||||
|
||||
describe('AtlasApi, the projects routes', () => {
|
||||
it.each(['', 'http://127.0.0.1:9749'])('attributes failed requests to their decoded project with base %s', async (base) => {
|
||||
const reports: ErrorReport[] = [];
|
||||
const stopObserving = observeErrors((report) => reports.push(report));
|
||||
const calls: FetchCall[] = [];
|
||||
const client = new AtlasApi({
|
||||
base,
|
||||
fetch: recordingFetch({ ok: false, status: 503, body: '{"error":"unavailable"}' }, calls),
|
||||
});
|
||||
try {
|
||||
await expect(client.logs(20, 'warn', 'a repo&revision=2')).rejects.toBeInstanceOf(AtlasApiError);
|
||||
expect(calls[0]?.url).toBe(`${base}/api/logs?lines=20&min_level=warn&project=a+repo%26revision%3D2`);
|
||||
expect(reports).toEqual([expect.objectContaining({
|
||||
source: 'api', level: 'error', project: 'a repo&revision=2',
|
||||
})]);
|
||||
} finally {
|
||||
stopObserving();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not invent a project for failed requests without one', async () => {
|
||||
const reports: ErrorReport[] = [];
|
||||
const stopObserving = observeErrors((report) => reports.push(report));
|
||||
const { api: client } = api({ ok: false, status: 503, body: '{"error":"unavailable"}' });
|
||||
try {
|
||||
await expect(client.logs(20)).rejects.toBeInstanceOf(AtlasApiError);
|
||||
expect(reports).toEqual([expect.objectContaining({ project: '' })]);
|
||||
} finally {
|
||||
stopObserving();
|
||||
}
|
||||
});
|
||||
|
||||
it('encodes history filters without interpreting search as URL parameters', async () => {
|
||||
const { api: client, calls } = api({ body: '{"lines":[],"total":0,"query":"path&level=info"}' });
|
||||
const result = await client.logs(200, 'error', undefined, { scope: 'unattributed', query: 'path&level=info' });
|
||||
@@ -81,8 +113,8 @@ describe('AtlasApi, the projects routes', () => {
|
||||
});
|
||||
|
||||
it('reads the health verdict', async () => {
|
||||
const { api: client, calls } = api({ body: '{"status":"healthy","nodes":1,"edges":2,"size_bytes":3}' });
|
||||
expect(await client.projectHealth('p')).toEqual({ status: 'healthy', nodes: 1, edges: 2, sizeBytes: 3, reason: '' });
|
||||
const { api: client, calls } = api({ body: '{"status":"healthy","nodes":1,"edges":2,"size_bytes":3,"indexed_at":"2026-10-01T12:00:00Z","watch_registered":true,"watcher_running":false}' });
|
||||
expect(await client.projectHealth('p')).toEqual({ status: 'healthy', nodes: 1, edges: 2, sizeBytes: 3, indexedAt: '2026-10-01T12:00:00Z', watchRegistered: true, watcherRunning: false, reason: '' });
|
||||
expect(calls[0]?.url).toBe('http://127.0.0.1:9749/api/project-health?name=p');
|
||||
});
|
||||
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
*
|
||||
* Same-origin ist der Normalfall. Der Server nimmt `/api` nur an, wenn der
|
||||
* Host-Kopf genau seine eigene Loopback-Adresse nennt und der Origin-Kopf
|
||||
* entweder fehlt oder dieselbe Adresse nennt (INVENTAR.md Abschnitt 5). Ein
|
||||
* entweder fehlt oder dieselbe Adresse nennt. Ein
|
||||
* `base` ist darum nur fuer Node-Aufrufer da, die keinen Origin senden.
|
||||
*/
|
||||
|
||||
import { hasErrorObservers, reportError } from '../provider/error-observer';
|
||||
import { readConfigSnapshot, type ConfigSnapshot } from '../settings/config-model';
|
||||
import { readTreeLevel } from './tree-model';
|
||||
import type { TreeLevel } from './tree-model';
|
||||
import {
|
||||
@@ -48,6 +49,8 @@ export interface AtlasApiOptions {
|
||||
base?: string;
|
||||
/** Ersetzbares fetch, damit Tests ohne Netz laufen. */
|
||||
fetch?: typeof globalThis.fetch;
|
||||
/** Abbruchsignal fuer jeden Aufruf: das Projektfenster bricht so beim Wechsel ab (K24). */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Was `/api/repo-info` ueber ein Projekt weiss. Leere Felder heissen "unbekannt". */
|
||||
@@ -84,7 +87,7 @@ function announce(error: AtlasApiError, detail?: string): AtlasApiError {
|
||||
const refused = error.status >= 400 && error.status < 500;
|
||||
reportError({
|
||||
source: 'api',
|
||||
project: new URL(error.route, 'http://daemon.invalid').searchParams.get('project') ?? '',
|
||||
project: new URL(error.route, 'http://127.0.0.1').searchParams.get('project') ?? '',
|
||||
level: refused ? 'warn' : 'error',
|
||||
message: error.message,
|
||||
detail,
|
||||
@@ -99,6 +102,14 @@ export class AtlasApi {
|
||||
|
||||
constructor(private readonly options: AtlasApiOptions = {}) { }
|
||||
|
||||
async configuration(): Promise<ConfigSnapshot> {
|
||||
return readConfigSnapshot(await this.getJson('/api/config'));
|
||||
}
|
||||
|
||||
async saveConfiguration(revision: string, changes: Record<string, string | null>): Promise<ConfigSnapshot> {
|
||||
return readConfigSnapshot(await this.request('POST', '/api/config', { revision, changes }));
|
||||
}
|
||||
|
||||
private getJson(route: string): Promise<unknown> {
|
||||
return this.request('GET', route);
|
||||
}
|
||||
@@ -119,10 +130,14 @@ export class AtlasApi {
|
||||
init.headers = { Accept: 'application/json', 'Content-Type': 'application/json' };
|
||||
init.body = JSON.stringify(payload);
|
||||
}
|
||||
if (this.options.signal !== undefined) init.signal = this.options.signal;
|
||||
response = await doFetch(url, init);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw announce(new AtlasApiError(route, 0, `${route} war nicht erreichbar: ${message}`));
|
||||
const failure = new AtlasApiError(route, 0, `${route} war nicht erreichbar: ${message}`);
|
||||
// Ein abgebrochener Aufruf ist die Sache des Aufrufers und kein Fehler des Servers.
|
||||
if (this.options.signal?.aborted) throw failure;
|
||||
throw announce(failure);
|
||||
}
|
||||
const body = await response.text();
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { IMPACT_MENU_LABEL } from '../impact/impact-strings';
|
||||
import { llmMenuLabel } from '../llm/strings';
|
||||
import { BUG_WIZARD_MENU_LABEL } from '../traces/bug-wizard-strings';
|
||||
import { WHY_MENU_LABEL } from '../why/why-model';
|
||||
@@ -170,7 +169,6 @@ describe('jeder Eintrag der Atlas-Zeile traegt einen Buchstaben, der etwas tut',
|
||||
const entries = [
|
||||
{ name: 'why', label: WHY_MENU_LABEL, expected: 'w' },
|
||||
{ name: 'bug', label: BUG_WIZARD_MENU_LABEL, expected: 'b' },
|
||||
{ name: 'impact', label: IMPACT_MENU_LABEL, expected: 'c' },
|
||||
{ name: 'llm off', label: llmMenuLabel('off'), expected: 'l' },
|
||||
{ name: 'llm on', label: llmMenuLabel('ready'), expected: 'l' },
|
||||
];
|
||||
@@ -193,7 +191,7 @@ describe('jeder Eintrag der Atlas-Zeile traegt einen Buchstaben, der etwas tut',
|
||||
* Buchstaben duerfen auch nicht im Vorbeigehen wieder belegt werden.
|
||||
*/
|
||||
it('belegt keinen Buchstaben der geloeschten Attrappen', () => {
|
||||
for (const letter of ['f', 'e', 'v', 't']) {
|
||||
for (const letter of ['f', 'e', 'v', 't', 'c']) {
|
||||
expect(WIRED_MENU_SHORTCUTS, `${letter} gehoerte einer Attrappe`).not.toContain(letter);
|
||||
}
|
||||
expect(WIRED_MENU_SHORTCUTS).toContain('a');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Vom Dateipfad zum qualifizierten Namen, den der Graph fuer diese Datei fuehrt.
|
||||
*
|
||||
* Der Reader braucht diese Umrechnung, weil der Server keinen Endpunkt hat, der
|
||||
* eine Datei ausliefert (siehe INVENTAR.md Abschnitt 3 und Ask 1). Was es gibt,
|
||||
* in diesem Providerpfad eine Datei ausliefert. Was es gibt,
|
||||
* ist `get_code_snippet` auf einem indizierten Symbol, und der Modul-Knoten ist
|
||||
* das Symbol, das die ganze Datei umspannt.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, useEffect } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ProjectWindows, useParamInAddress, type ProjectWindow } from './project-windows';
|
||||
|
||||
/*
|
||||
* K24: a project switch stays in the page. Each project gets a fresh window (fresh state in
|
||||
* every panel, as after a reload), the address changes with the history API, Back and Forward
|
||||
* switch the same way, and requests of the old window are cancelled.
|
||||
*/
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.history.replaceState(null, '', '/?project=django-demo&workspace=galaxy');
|
||||
container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container);
|
||||
});
|
||||
afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.restoreAllMocks(); });
|
||||
|
||||
interface Carry { chatOpen: boolean }
|
||||
const mounted: string[] = [];
|
||||
const unmounted: string[] = [];
|
||||
let current: ProjectWindow<Carry> | undefined;
|
||||
function Window({ window: shown }: { window: ProjectWindow<Carry> }) {
|
||||
current = shown;
|
||||
useEffect(() => { mounted.push(shown.project); return () => { unmounted.push(shown.project); }; }, []);
|
||||
useEffect(() => { shown.report({ chatOpen: shown.project === 'django-demo' }); });
|
||||
return <p data-project={shown.project}>{shown.project}:{String(shown.carried?.chatOpen)}</p>;
|
||||
}
|
||||
const shownProject = () => container.querySelector('p')?.getAttribute('data-project');
|
||||
|
||||
describe('an in-page project switch (K24)', () => {
|
||||
beforeEach(() => { mounted.length = 0; unmounted.length = 0; current = undefined; });
|
||||
|
||||
it('opens another project without reloading: new address, a fresh window, the old one cancelled', async () => {
|
||||
const push = vi.spyOn(window.history, 'pushState');
|
||||
await act(async () => root.render(<ProjectWindows<Carry>>{shown => <Window key={shown.key} window={shown} />}</ProjectWindows>));
|
||||
expect(shownProject()).toBe('django-demo');
|
||||
const first = current!;
|
||||
await act(async () => first.open('cbm'));
|
||||
expect(push).toHaveBeenCalledOnce();
|
||||
expect(window.location.search).toBe('?project=cbm');
|
||||
expect(shownProject()).toBe('cbm');
|
||||
expect(mounted).toEqual(['django-demo', 'cbm']);
|
||||
expect(unmounted).toEqual(['django-demo']);
|
||||
// The old window's requests are cancelled; the new window's are not.
|
||||
expect(first.signal.aborted).toBe(true);
|
||||
expect(current!.signal.aborted).toBe(false);
|
||||
// What the old window reported travels to the new one.
|
||||
expect(container.textContent).toBe('cbm:true');
|
||||
});
|
||||
|
||||
it('stays put for the project already shown', async () => {
|
||||
const push = vi.spyOn(window.history, 'pushState');
|
||||
await act(async () => root.render(<ProjectWindows<Carry>>{shown => <Window key={shown.key} window={shown} />}</ProjectWindows>));
|
||||
await act(async () => current!.open('django-demo'));
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
expect(mounted).toEqual(['django-demo']);
|
||||
});
|
||||
|
||||
it('switches back and forward with the browser history in the page', async () => {
|
||||
await act(async () => root.render(<ProjectWindows<Carry>>{shown => <Window key={shown.key} window={shown} />}</ProjectWindows>));
|
||||
await act(async () => current!.open('cbm'));
|
||||
await act(async () => {
|
||||
window.history.replaceState(null, '', '/?project=django-demo&workspace=galaxy');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
expect(shownProject()).toBe('django-demo');
|
||||
expect(mounted).toEqual(['django-demo', 'cbm', 'django-demo']);
|
||||
// A popstate that keeps the project (another query parameter) keeps the window.
|
||||
await act(async () => {
|
||||
window.history.replaceState(null, '', '/?project=django-demo&workspace=explore');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
expect(mounted).toHaveLength(3);
|
||||
});
|
||||
|
||||
/* Hand test 2026-10-04 (A5): the switcher turned "?project=django-demo&workspace=galaxy" into "?project=cbm". */
|
||||
it('keeps the page-wide parameters it is given, the workspace among them, and nothing project specific', async () => {
|
||||
window.history.replaceState(null, '', '/?project=django-demo&workspace=galaxy&codeatlasClosureDepth=4&file=django%2Fshortcuts.py');
|
||||
await act(async () => root.render(<ProjectWindows<Carry> keepParams={['workspace', 'codeatlasClosureDepth', 'codeatlasClosureCap']}>
|
||||
{shown => <Window key={shown.key} window={shown} />}</ProjectWindows>));
|
||||
await act(async () => current!.open('atlas sample'));
|
||||
expect(window.location.search).toBe('?project=atlas%20sample&workspace=galaxy&codeatlasClosureDepth=4');
|
||||
expect(shownProject()).toBe('atlas sample');
|
||||
// Back and Forward of the browser still switch between both projects, each with its own address.
|
||||
const popped = () => new Promise<void>(resolve => window.addEventListener('popstate', () => resolve(), { once: true }));
|
||||
await act(async () => { const done = popped(); window.history.back(); await done; });
|
||||
expect(window.location.search).toBe('?project=django-demo&workspace=galaxy&codeatlasClosureDepth=4&file=django%2Fshortcuts.py');
|
||||
expect(shownProject()).toBe('django-demo');
|
||||
await act(async () => { const done = popped(); window.history.forward(); await done; });
|
||||
expect(shownProject()).toBe('atlas sample');
|
||||
});
|
||||
|
||||
it('writes the workspace in view into the address without a new history entry', async () => {
|
||||
window.history.replaceState(null, '', '/?project=django-demo');
|
||||
const length = window.history.length;
|
||||
function Workspace({ name }: { name: string }) { useParamInAddress('workspace', name); return null; }
|
||||
await act(async () => root.render(<Workspace name="architecture" />));
|
||||
expect(window.location.search).toBe('?project=django-demo&workspace=architecture');
|
||||
await act(async () => root.render(<Workspace name="galaxy" />));
|
||||
expect(window.location.search).toBe('?project=django-demo&workspace=galaxy');
|
||||
expect(window.history.length).toBe(length);
|
||||
});
|
||||
|
||||
it('commits the switch inside the frame it is given, so the old window is gone and the new one there when it ends', async () => {
|
||||
const seen: string[] = [];
|
||||
const around = (commit: () => void) => { seen.push(`before:${mounted.join(',')}`); commit(); seen.push(`after:${mounted.join(',')}|${unmounted.join(',')}`); };
|
||||
await act(async () => root.render(<ProjectWindows<Carry> around={around}>{shown => <Window key={shown.key} window={shown} />}</ProjectWindows>));
|
||||
await act(async () => current!.open('cbm'));
|
||||
expect(seen).toEqual(['before:django-demo', 'after:django-demo,cbm|django-demo']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Ein Projektwechsel bleibt in der Seite (K24).
|
||||
*
|
||||
* Bis K24 lud ein Wechsel die ganze Seite neu, weil jedes Panel dieses Fensters
|
||||
* an das Projekt gebunden ist, mit dem es startete. Mit dem Neuladen gingen der
|
||||
* Worker des lokalen Agenten und seine Gewichte mit, und die Lampe stand
|
||||
* sekundenlang auf "loading". Jetzt bekommt jedes Projekt ein frisches Fenster:
|
||||
* derselbe Baum unter einem neuen Schluessel, also frischer Zustand in jedem
|
||||
* Panel wie nach einem Neuladen, aber ohne die Seite zu verlassen.
|
||||
*
|
||||
* Drei Entscheidungen:
|
||||
*
|
||||
* 1. **Ein neuer Schluessel statt eines Zuruecksetzens.** Das Fenster haelt
|
||||
* rund hundert Zustaende, und jeder vergessene waere eine Anzeige des alten
|
||||
* Projekts im neuen. Ein neuer Schluessel vergisst keinen. Was das alte
|
||||
* Fenster dem neuen mitgibt, meldet es ausdruecklich (`report`), und nur
|
||||
* das kommt an (`carried`).
|
||||
* 2. **Die Adresse wechselt mit pushState.** Zurueck und Vor des Browsers
|
||||
* wechseln das Projekt ebenso in der Seite (popstate); ein popstate, das das
|
||||
* Projekt behaelt, laesst das Fenster stehen.
|
||||
* 3. **Das alte Fenster ist weg, wenn der Wechsel endet.** Der Wechsel wird mit
|
||||
* flushSync festgeschrieben, innerhalb des Rahmens `around`, in dem der
|
||||
* Agent sein Modell weiterreicht (browser-ai/agent-handover.ts). Danach
|
||||
* bricht das Signal des alten Fensters ab: was es noch fragt, geht nicht
|
||||
* mehr hinaus, und was noch antwortet, landet in einem ausgehaengten Baum.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState, type JSX, type ReactNode } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { projectHref } from '../projects/projects-model';
|
||||
|
||||
/** Das Projekt aus `?project=`, leer, wenn die Adresse keins nennt. */
|
||||
export function projectOfLocation(search = window.location.search): string {
|
||||
return new URLSearchParams(search).get('project') ?? '';
|
||||
}
|
||||
|
||||
/*
|
||||
* Handtest vom 2026-10-04 (A5): der Wechsel machte aus
|
||||
* "?project=django-demo&workspace=galaxy" die Adresse "?project=cbm", und ein
|
||||
* Neuladen danach konnte einen anderen Arbeitsbereich zeigen. Die Adresse des
|
||||
* neuen Projekts behaelt nun die Parameter der ganzen Seite, die der Aufrufer
|
||||
* nennt (den Arbeitsbereich, die Grenzen des Walks), und nichts, was zum alten
|
||||
* Projekt gehoert.
|
||||
*/
|
||||
export function projectSwitchHref(project: string, search: string, keep: readonly string[]): string {
|
||||
const from = new URLSearchParams(search);
|
||||
const kept = keep.flatMap(name => { const value = from.get(name); return value === null ? [] : [`&${encodeURIComponent(name)}=${encodeURIComponent(value)}`]; });
|
||||
return `${projectHref(project)}${kept.join('')}`;
|
||||
}
|
||||
|
||||
/** Haelt einen Parameter der Adresse auf dem Wert, den die Seite zeigt, ohne neuen Eintrag im Verlauf (A5: der Arbeitsbereich). */
|
||||
export function useParamInAddress(name: string, value: string): void {
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.get(name) === value) return;
|
||||
url.searchParams.set(name, value);
|
||||
window.history.replaceState(window.history.state, '', url);
|
||||
}, [name, value]);
|
||||
}
|
||||
|
||||
export interface ProjectWindow<C> {
|
||||
/** Wechselt mit jedem Projekt; als React-Schluessel des Fensters gedacht. */
|
||||
key: number;
|
||||
/** Das Projekt der Adresse beim Oeffnen des Fensters, leer ohne `?project=`. */
|
||||
project: string;
|
||||
/** Bricht ab, sobald ein anderes Projekt das Fenster ersetzt hat. */
|
||||
signal: AbortSignal;
|
||||
/** Was das vorige Fenster zuletzt gemeldet hat. */
|
||||
carried?: C;
|
||||
/** Oeffnet ein anderes Projekt in dieser Seite. */
|
||||
open: (project: string) => void;
|
||||
/** Meldet, was ein naechstes Fenster mitbekommen soll. */
|
||||
report: (carry: C) => void;
|
||||
}
|
||||
|
||||
interface Shown<C> { key: number; project: string; controller: AbortController; carried?: C }
|
||||
|
||||
export function ProjectWindows<C>({ children, around = commit => commit(), keepParams = [] }: {
|
||||
children: (window: ProjectWindow<C>) => ReactNode;
|
||||
around?: (commit: () => void) => void;
|
||||
/** Parameter der ganzen Seite, die ein Wechsel in die neue Adresse mitnimmt (A5). */
|
||||
keepParams?: readonly string[];
|
||||
}): JSX.Element {
|
||||
const [shown, setShown] = useState<Shown<C>>(() => ({ key: 0, project: projectOfLocation(), controller: new AbortController() }));
|
||||
const current = useRef(shown); current.current = shown;
|
||||
const carry = useRef<C | undefined>(undefined);
|
||||
const show = useCallback((project: string) => {
|
||||
const previous = current.current;
|
||||
if (project === previous.project) return;
|
||||
const next: Shown<C> = { key: previous.key + 1, project, controller: new AbortController(), ...carry.current === undefined ? {} : { carried: carry.current } };
|
||||
current.current = next;
|
||||
around(() => flushSync(() => setShown(next)));
|
||||
previous.controller.abort();
|
||||
}, [around]);
|
||||
const keep = useRef(keepParams); keep.current = keepParams;
|
||||
const open = useCallback((project: string) => {
|
||||
if (project === current.current.project) return;
|
||||
window.history.pushState(null, '', projectSwitchHref(project, window.location.search, keep.current));
|
||||
show(project);
|
||||
}, [show]);
|
||||
const report = useCallback((value: C) => { carry.current = value; }, []);
|
||||
useEffect(() => {
|
||||
const back = () => show(projectOfLocation());
|
||||
window.addEventListener('popstate', back);
|
||||
return () => window.removeEventListener('popstate', back);
|
||||
}, [show]);
|
||||
return <>{children({ key: shown.key, project: shown.project, signal: shown.controller.signal, carried: shown.carried, open, report })}</>;
|
||||
}
|
||||
@@ -14,9 +14,8 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { messages } from '../i18n/messages';
|
||||
import { experimentalAgentsEnabled } from './feature-flags';
|
||||
import { overlayIntent } from '../search/overlay-model';
|
||||
import { playerIntent } from '../tours/tour-player';
|
||||
import { FOCUS_COMMAND_KEY, RESERVED_BARE_SHORTCUTS, menuShortcutFor } from './keyboard';
|
||||
import { RESERVED_BARE_SHORTCUTS, menuShortcutFor } from './keyboard';
|
||||
import { ATLAS_SHORTCUTS, PROBED_KEYS, WIRED_MENU_SHORTCUTS, needsAlt, shortcutId } from './shortcuts';
|
||||
|
||||
describe('die Menuezeile traegt nur, was etwas tut', () => {
|
||||
@@ -90,13 +89,10 @@ describe('die Tastenliste der Hilfe ist die Verdrahtung selbst', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('liest die Tasten des Suchfensters aus overlayIntent', () => {
|
||||
const search = ATLAS_SHORTCUTS.filter((entry) => entry.scope === 'search').map((entry) => entry.key);
|
||||
for (const key of PROBED_KEYS) {
|
||||
const wired = overlayIntent(key) !== 'none';
|
||||
expect(search.includes(key), `${key}: Verdrahtung ${wired}, Hilfe ${search.includes(key)}`)
|
||||
.toBe(wired);
|
||||
}
|
||||
it('documents only the dedicated Galaxy search shortcut', () => {
|
||||
expect(ATLAS_SHORTCUTS.filter(entry => entry.scope === 'galaxy')).toEqual([{ scope: 'galaxy', key: 'Cmd/Ctrl+K' }]);
|
||||
expect(ATLAS_SHORTCUTS.some(entry => entry.key === '/')).toBe(false);
|
||||
expect(WIRED_MENU_SHORTCUTS).not.toContain('c');
|
||||
});
|
||||
|
||||
it('nimmt die Menuetasten aus genau der Liste, die das Fenster hoert', () => {
|
||||
@@ -114,11 +110,6 @@ describe('die Tastenliste der Hilfe ist die Verdrahtung selbst', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('nennt die Taste, die die Kommandozeile holt', () => {
|
||||
const line = ATLAS_SHORTCUTS.filter((entry) => entry.scope === 'line').map((entry) => entry.key);
|
||||
expect(line).toEqual([FOCUS_COMMAND_KEY]);
|
||||
});
|
||||
|
||||
it('sagt zu jeder Taste, was sie tut, und zu keiner anderen', () => {
|
||||
const documented = Object.keys(messages.help.shortcutDoes)
|
||||
.filter(id => experimentalAgentsEnabled || id !== 'mnemonic:g').sort();
|
||||
|
||||
@@ -1,36 +1,11 @@
|
||||
/**
|
||||
* Welche Tasten in dieser Oberflaeche etwas tun, an einer Stelle.
|
||||
*
|
||||
* Bis zum 2026-08-29 stand die Liste der Menuebuchstaben in App.tsx, direkt
|
||||
* neben den Handlungen, die sie ausloest. Das war richtig, solange sie nur
|
||||
* einen Leser hatte. Seit die Hilfe (src/help/HelpOverlay.tsx) eine Tabelle
|
||||
* aller Tastenkuerzel zeigt, hat sie zwei, und ein zweiter Ort fuer dieselbe
|
||||
* Liste waere genau der Fehler, gegen den die Hilfe geschrieben ist: eine
|
||||
* Tabelle, die eine Taste verspricht, die nichts mehr tut.
|
||||
*
|
||||
* ## Warum die drei Bereiche verschieden entstehen
|
||||
*
|
||||
* Die Menuebuchstaben stehen hier als Liste, weil sie hier entstehen: der Griff
|
||||
* am Fenster in App.tsx liest genau diese Liste, und was nicht darin steht,
|
||||
* wird nicht gehoert.
|
||||
*
|
||||
* Die Tasten des Walks und des Suchfensters stehen NICHT als Liste da. Sie
|
||||
* werden erfragt: `playerIntent` und `overlayIntent` sind die Funktionen, die
|
||||
* entscheiden, was eine Taste dort bedeutet, und dieses Modul probiert sie
|
||||
* gegen ein Alphabet moeglicher Tasten durch. Damit ist die Hilfe kein zweiter
|
||||
* Katalog, sondern eine Ablesung: verschwindet ein `case` aus einer der beiden
|
||||
* Funktionen, verschwindet die Zeile aus der Hilfe, ohne dass jemand daran
|
||||
* denken muss.
|
||||
*/
|
||||
|
||||
import { overlayIntent } from '../search/overlay-model';
|
||||
/** Shared menu and workspace shortcut catalog used by keyboard handling and help. */
|
||||
import { playerIntent } from '../tours/tour-player';
|
||||
import { FOCUS_COMMAND_KEY, RESERVED_BARE_SHORTCUTS } from './keyboard';
|
||||
import { RESERVED_BARE_SHORTCUTS } from './keyboard';
|
||||
|
||||
/**
|
||||
* Wo eine Taste gilt.
|
||||
*
|
||||
* Fuenf Bereiche, und der Unterschied zwischen den ersten beiden ist genau der,
|
||||
* Vier Bereiche, und der Unterschied zwischen den ersten beiden ist genau der,
|
||||
* den ein Leser wissen muss: ein `mnemonic` traegt Alt/Option und gilt auch
|
||||
* waehrend des Tippens, eine `bare` Taste gilt nur, solange nirgends getippt
|
||||
* wird. Ein Bereich mehr ist billiger als eine Tabelle, die beides gleich
|
||||
@@ -38,7 +13,7 @@ import { FOCUS_COMMAND_KEY, RESERVED_BARE_SHORTCUTS } from './keyboard';
|
||||
*/
|
||||
import { experimentalAgentsEnabled } from './feature-flags';
|
||||
|
||||
export type ShortcutScope = 'mnemonic' | 'bare' | 'line' | 'walk' | 'search';
|
||||
export type ShortcutScope = 'mnemonic' | 'bare' | 'walk' | 'galaxy';
|
||||
|
||||
/** Eine Taste, die etwas tut, und der Bereich, in dem sie es tut. */
|
||||
export interface AtlasShortcut {
|
||||
@@ -52,10 +27,10 @@ export interface AtlasShortcut {
|
||||
*
|
||||
* `a` klappt die Galaxie auf und zu; die Eintraege der Atlas-Zeile tragen seit
|
||||
* dem 2026-08-29 ihre eigenen: `w` die Frage nach dem Warum, `b` den
|
||||
* BUG-Assistenten, `c` die Aenderungsansicht, `l` den Schalter des lokalen
|
||||
* BUG-Assistenten, `l` den Schalter des lokalen
|
||||
* Modells, `r` seit W8 den Weg zurueck zum Vorgabe-Layout, `s` seit W10 das
|
||||
* Einstellungen-Panel, `g` seit W11a den Live-Modus der Agenten, `p` das
|
||||
* Projekte-Panel (Index anlegen, entfernen, Entscheidungsakte, Serverzustand).
|
||||
* Dialog zum Anlegen eines Projektindexes.
|
||||
* `?` schlaegt seit W7a die Hilfe auf und wieder zu.
|
||||
*
|
||||
* Warum das vorher nicht so war und warum es jetzt so ist: die Zeile war ein
|
||||
@@ -70,7 +45,7 @@ export interface AtlasShortcut {
|
||||
* gepflegte Liste, denn eine gepflegte Liste ist genau die Stelle, an der ein
|
||||
* Punkt ohne Verdrahtung wieder hereinrutscht.
|
||||
*/
|
||||
export const WIRED_MENU_SHORTCUTS: readonly string[] = ['a', 'w', 'b', 'c', 'l', 'r', 's', 'g', 'p', '?']
|
||||
export const WIRED_MENU_SHORTCUTS: readonly string[] = ['a', 'w', 'b', 'l', 'r', 's', 'g', 'p', '?']
|
||||
.filter(key => experimentalAgentsEnabled || key !== 'g');
|
||||
|
||||
/**
|
||||
@@ -102,8 +77,7 @@ export function needsAlt(shortcut: AtlasShortcut): boolean {
|
||||
|
||||
/**
|
||||
* Jede Taste, die diese Oberflaeche hoert, in der Reihenfolge, in der die Hilfe
|
||||
* sie zeigt: erst die Menuekuerzel, dann die Kommandozeile, dann der Walk, dann
|
||||
* das Suchfenster.
|
||||
* sie zeigt: Menuekuerzel, Galaxy-Suche, dann der Walk.
|
||||
*
|
||||
* Auch die Aufteilung in `mnemonic` und `bare` wird abgelesen und nicht
|
||||
* gepflegt: welche Taste ohne Alt/Option gilt, weiss keyboard.ts, und diese
|
||||
@@ -114,9 +88,8 @@ export const ATLAS_SHORTCUTS: readonly AtlasShortcut[] = [
|
||||
scope: (RESERVED_BARE_SHORTCUTS.includes(key) ? 'bare' : 'mnemonic') as ShortcutScope,
|
||||
key,
|
||||
})),
|
||||
{ scope: 'line', key: FOCUS_COMMAND_KEY },
|
||||
{ scope: 'galaxy', key: 'Cmd/Ctrl+K' },
|
||||
...probed('walk', playerIntent),
|
||||
...probed('search', overlayIntent),
|
||||
];
|
||||
|
||||
/** Der Schluessel, unter dem der Katalog den Satz zu dieser Taste fuehrt. */
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { reportError } from '../provider/error-observer';
|
||||
import { UiLogBuffer } from './ui-log';
|
||||
import { repeatCount, UiLogBuffer } from './ui-log';
|
||||
import type { UiLogPayload } from './ui-log';
|
||||
import { describeArgs, installUiLog, safeStringify } from './ui-log-install';
|
||||
import type { UiLogHandle } from './ui-log-install';
|
||||
@@ -77,6 +77,26 @@ describe('installUiLog', () => {
|
||||
expect(all[0]?.detail).toContain('build v');
|
||||
});
|
||||
|
||||
it('posts a line under the address it was written on, also after a project switch in the page (K24)', async () => {
|
||||
window.history.replaceState(null, '', '/?project=django-demo&workspace=galaxy');
|
||||
const posts: UiLogPayload[] = [];
|
||||
const quiet = { debug: () => {}, log: () => {}, info: () => {}, warn: () => {}, error: () => {} } as unknown as Console;
|
||||
try {
|
||||
handle = installUiLog({ console: quiet, session: 'switch', transport: { send: async (payload) => { posts.push(payload); return true; } } });
|
||||
// The project switch changes the address with pushState and keeps the page (app/project-windows.tsx).
|
||||
window.history.pushState(null, '', '/?project=cbm&workspace=galaxy');
|
||||
quiet.warn('after the switch');
|
||||
await handle.buffer.flush();
|
||||
await handle.buffer.flush();
|
||||
expect(posts.map((post) => [post.page, post.entries.map((entry) => [entry.message, entry.project])])).toEqual([
|
||||
['/?project=django-demo&workspace=galaxy', [['session switch started on /?project=django-demo&workspace=galaxy', 'django-demo']]],
|
||||
['/?project=cbm&workspace=galaxy', [['after the switch', 'cbm']]],
|
||||
]);
|
||||
} finally {
|
||||
window.history.replaceState(null, '', '/');
|
||||
}
|
||||
});
|
||||
|
||||
it('lets the console print as before and records what it printed', async () => {
|
||||
const { buffer, posts, printed, fakeConsole } = install();
|
||||
const failure = new Error('twin failed');
|
||||
@@ -210,4 +230,28 @@ describe('httpUiLogTransport', () => {
|
||||
});
|
||||
expect(await failing.send({ page: '/', session: 's', entries: [] }, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('sends 120 identical console warnings through the transport as one entry with a count', async () => {
|
||||
const bodies: UiLogPayload[] = [];
|
||||
const fetch = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return new Response('{}'); });
|
||||
const beaconed: Promise<void>[] = [];
|
||||
const beacon = vi.fn((_url: string, body: Blob) => { beaconed.push(new Promise<string>((resolve) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.readAsText(body); }).then((text) => { bodies.push(JSON.parse(text)); })); return true; });
|
||||
const buffer = new UiLogBuffer({ page: '/?project=cbm', session: 'three', transport: httpUiLogTransport({ fetch: fetch as typeof globalThis.fetch, beacon }),
|
||||
schedule: () => 0, cancel: () => undefined, bufferMax: 200, batchMax: 200 });
|
||||
const printed: unknown[] = [];
|
||||
const fakeConsole = { debug: () => undefined, log: () => undefined, info: () => undefined, error: () => undefined,
|
||||
warn: (...args: unknown[]) => printed.push(args[0]) } as unknown as Console;
|
||||
handle = installUiLog({ buffer, console: fakeConsole, session: 'three', getProject: () => 'cbm' });
|
||||
const clock = 'THREE.THREE.Clock: This module has been deprecated. Please use THREE.Timer instead.';
|
||||
for (let i = 0; i < 120; i++) fakeConsole.warn(clock);
|
||||
await buffer.flush();
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
await vi.waitFor(() => expect(beaconed).toHaveLength(1));
|
||||
await Promise.all(beaconed);
|
||||
// The console still prints every call; the server gets the first in full and the counts.
|
||||
expect(printed).toHaveLength(120);
|
||||
const posted = bodies.flatMap((body) => body.entries).filter((entry) => entry.message === clock);
|
||||
expect(posted.map((entry) => repeatCount(entry.detail) ?? 1)).toEqual([1, 10, 100, 120]);
|
||||
expect(beacon).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,8 +139,10 @@ export function installUiLog(options: InstallUiLogOptions = {}): UiLogHandle {
|
||||
const page = `${target.location.pathname}${target.location.search}`;
|
||||
const getProject = options.getProject ?? (() => activeProject
|
||||
?? new URLSearchParams(target.location.search).get('project') ?? '');
|
||||
// A project switch changes the address and keeps the page (K24): each line names the address it was written on.
|
||||
const getPage = (): string => `${target.location.pathname}${target.location.search}`;
|
||||
const buffer = options.buffer
|
||||
?? new UiLogBuffer({ page, session, getProject, transport: options.transport ?? httpUiLogTransport() });
|
||||
?? new UiLogBuffer({ page, getPage, session, getProject, transport: options.transport ?? httpUiLogTransport() });
|
||||
|
||||
let recording = false;
|
||||
const guarded = (fn: () => void): void => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { capField, UI_LOG_FIELD_MAX, UiLogBuffer } from './ui-log';
|
||||
import { capField, repeatCount, UI_LOG_FIELD_MAX, UiLogBuffer } from './ui-log';
|
||||
import type { UiLogPayload, UiLogTransport } from './ui-log';
|
||||
|
||||
interface Timer {
|
||||
@@ -80,6 +80,37 @@ describe('UiLogBuffer', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('posts each entry under the page it was recorded on, also when the address changed in the page (K24)', async () => {
|
||||
// A project switch stays in the page and only changes the address; the server files a post's entries under its one page.
|
||||
let page = '/?project=django-demo&workspace=galaxy';
|
||||
const posts: { payload: UiLogPayload; final: boolean }[] = [];
|
||||
const buffer = new UiLogBuffer({
|
||||
page, getPage: () => page, session: 'switch', getProject: () => new URLSearchParams(page.slice(1)).get('project') ?? '',
|
||||
transport: { send: async (payload, final) => { posts.push({ payload, final }); return true; } },
|
||||
schedule: () => 0, cancel: () => undefined,
|
||||
});
|
||||
buffer.record('warn', 'console', 'before the switch');
|
||||
page = '/?project=cbm&workspace=galaxy';
|
||||
buffer.record('warn', 'console', 'after the switch');
|
||||
buffer.record('info', 'console', 'still after');
|
||||
await buffer.flush();
|
||||
await buffer.flush();
|
||||
expect(posts.map(({ payload }) => [payload.page, payload.entries.map((entry) => [entry.message, entry.project])])).toEqual([
|
||||
['/?project=django-demo&workspace=galaxy', [['before the switch', 'django-demo']]],
|
||||
['/?project=cbm&workspace=galaxy', [['after the switch', 'cbm'], ['still after', 'cbm']]],
|
||||
]);
|
||||
expect(buffer.page).toBe('/?project=cbm&workspace=galaxy');
|
||||
// A page that is going away sends the entries of every page it was on.
|
||||
buffer.record('warn', 'console', 'cbm again');
|
||||
page = '/?project=django-demo&workspace=explore';
|
||||
buffer.record('warn', 'console', 'django-demo again');
|
||||
await buffer.flush(true);
|
||||
expect(posts.slice(2).map(({ payload, final }) => [payload.page, payload.entries.map((entry) => entry.message), final])).toEqual([
|
||||
['/?project=cbm&workspace=galaxy', ['cbm again'], true],
|
||||
['/?project=django-demo&workspace=explore', ['django-demo again'], true],
|
||||
]);
|
||||
});
|
||||
|
||||
it('queues entries with a sequence and a timestamp, and posts them as one batch on the timer', async () => {
|
||||
const { buffer, posts, timers, fire } = harness();
|
||||
buffer.record('error', 'rpc', 'get_code_snippet returned no source', { detail: 'HTTP 200' });
|
||||
@@ -171,6 +202,45 @@ describe('UiLogBuffer', () => {
|
||||
expect(timers.every((timer) => timer.cancelled)).toBe(true);
|
||||
});
|
||||
|
||||
it('records a repeated identical entry once and counts the repeats instead of queueing each one', async () => {
|
||||
const posts: UiLogPayload[] = [];
|
||||
const buffer = new UiLogBuffer({ page: '/', session: 'repeats', schedule: () => 0, cancel: () => undefined, bufferMax: 200, batchMax: 200,
|
||||
transport: { send: async (payload) => { posts.push(payload); return true; } } });
|
||||
const clock = 'THREE.THREE.Clock: This module has been deprecated. Please use THREE.Timer instead.';
|
||||
for (let i = 0; i < 25; i++) buffer.record('warn', 'console', clock, { project: 'cbm' });
|
||||
buffer.record('warn', 'console', 'a different warning', { project: 'cbm' });
|
||||
buffer.record('warn', 'console', clock, { project: 'django-demo' });
|
||||
await buffer.flush();
|
||||
const sent = posts.flatMap((post) => post.entries);
|
||||
// The first one in full, one count at ten, and the same text in another project as its own first entry.
|
||||
expect(sent.map((entry) => [entry.message === clock ? 'clock' : entry.message, entry.project, repeatCount(entry.detail)])).toEqual([
|
||||
['clock', 'cbm', undefined], ['clock', 'cbm', 10], ['a different warning', 'cbm', undefined], ['clock', 'django-demo', undefined]]);
|
||||
// Leaving the page reports the exact total.
|
||||
await buffer.flush(true);
|
||||
expect(posts.at(-1)?.entries.map((entry) => [entry.project, repeatCount(entry.detail)])).toEqual([['cbm', 25]]);
|
||||
expect(buffer.stats().sent).toBe(5);
|
||||
});
|
||||
|
||||
it('counts only truly identical entries: the same message from another place or with another detail is sent in full', async () => {
|
||||
const posts: UiLogPayload[] = [];
|
||||
const buffer = new UiLogBuffer({ page: '/', session: 'places', schedule: () => 0, cancel: () => undefined, bufferMax: 200, batchMax: 200,
|
||||
transport: { send: async (payload) => { posts.push(payload); return true; } } });
|
||||
const typeError = "Uncaught TypeError: Cannot read properties of undefined (reading 'x')";
|
||||
buffer.record('error', 'window', typeError, { project: 'cbm', url: 'a.js', line: 10, col: 3, stack: 'at A (a.js:10)' });
|
||||
buffer.record('error', 'window', typeError, { project: 'cbm', url: 'b.js', line: 99, col: 7, stack: 'at B (b.js:99)' });
|
||||
buffer.record('error', 'rpc', '/rpc query_graph: HTTP 500', { project: 'cbm', detail: 'body one' });
|
||||
buffer.record('error', 'rpc', '/rpc query_graph: HTTP 500', { project: 'cbm', detail: 'body two' });
|
||||
for (let i = 0; i < 11; i++) buffer.record('error', 'window', typeError, { project: 'cbm', url: 'b.js', line: 99, col: 7, stack: 'at B (b.js:99)' });
|
||||
await buffer.flush(true);
|
||||
const sent = posts.flatMap((post) => post.entries);
|
||||
// Both places and both response bodies reach the log in full; the twelve from b.js:99 become one entry and its count.
|
||||
expect(sent.map((entry) => [entry.source, entry.url ?? entry.detail?.split('\n').at(-1), entry.line, repeatCount(entry.detail)])).toEqual([
|
||||
['window', 'a.js', 10, undefined], ['window', 'b.js', 99, undefined], ['rpc', 'body one', undefined, undefined],
|
||||
['rpc', 'body two', undefined, undefined], ['window', 'b.js', 99, 10], ['window', 'b.js', 99, 12]]);
|
||||
// A count names the place it counts, so System › Logs can put it beside its first entry.
|
||||
expect(sent.at(-1)).toMatchObject({ url: 'b.js', line: 99, col: 7, stack: 'at B (b.js:99)' });
|
||||
});
|
||||
|
||||
it('does not throw when the transport does', async () => {
|
||||
const throwing: UiLogTransport = {
|
||||
send: async () => {
|
||||
|
||||
+108
-7
@@ -30,6 +30,19 @@
|
||||
* - **Capped fields.** Every text field is cut at UI_LOG_FIELD_MAX
|
||||
* characters, the same cap the server applies; a cut is marked so nobody
|
||||
* reads a truncated stack as a complete one.
|
||||
* - **Once per session, then counted.** An entry identical to an earlier
|
||||
* one of this session (level, source, project, message, detail, stack and
|
||||
* place) is counted, not queued. A deprecation warning that a library
|
||||
* prints on every scene mount (THREE.Clock from @react-three/fiber) would
|
||||
* otherwise push the real messages out of System › Logs. The count goes
|
||||
* out as one more entry with the same fields at 10, 100, 1000 repeats and
|
||||
* when the page is left (`final`), so the journal holds a handful of
|
||||
* lines, not hundreds. The same message from another place or with
|
||||
* another response body is another entry and is recorded in full.
|
||||
* - **One page per post.** The server files every entry of a post under
|
||||
* the post's page. A project switch stays in the page and only changes
|
||||
* the address (app/project-windows.tsx), so each entry keeps the page it
|
||||
* was recorded on, and a batch never spans two of them.
|
||||
*
|
||||
* Time and timers are injectable so a test runs in one tick.
|
||||
*/
|
||||
@@ -92,9 +105,13 @@ export const UI_LOG_BATCH_MAX = 25;
|
||||
export const UI_LOG_FLUSH_MS = 1500;
|
||||
export const UI_LOG_BUFFER_MAX = 200;
|
||||
export const UI_LOG_BACKOFF_MS: readonly number[] = [2000, 5000, 15000, 60000];
|
||||
/** Distinct entries counted per session; beyond this, new texts are recorded one by one again. */
|
||||
export const UI_LOG_REPEAT_KEYS_MAX = 256;
|
||||
|
||||
export interface UiLogOptions {
|
||||
page: string;
|
||||
/** The page as it is now, sampled when recording like the project; `page` without it. */
|
||||
getPage?: () => string;
|
||||
session: string;
|
||||
transport: UiLogTransport;
|
||||
/** Sampled when recording, never when sending a delayed or retried batch. */
|
||||
@@ -108,13 +125,48 @@ export interface UiLogOptions {
|
||||
backoffMs?: readonly number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The detail of a repeat count. The server keeps only the known fields of an
|
||||
* entry, so the count travels in `detail`, in words a reader understands and
|
||||
* in a form System › Logs reads back (repeatCount). The detail of the counted
|
||||
* entry follows on the next line, so the count stays beside its own entry.
|
||||
*/
|
||||
export function repeatDetail(count: number, detail?: string): string {
|
||||
const words = `${count} identical entries in this session; only the first is recorded in full`;
|
||||
return detail ? capField(`${words}\n${detail}`) : words;
|
||||
}
|
||||
|
||||
/** The count of a repeat entry, undefined for an ordinary one. */
|
||||
export function repeatCount(detail: string | undefined): number | undefined {
|
||||
const match = /^(\d+) identical entries in this session\b/.exec(detail ?? '');
|
||||
return match ? Number(match[1]) : undefined;
|
||||
}
|
||||
|
||||
/** The detail of the entry a repeat count stands for; an ordinary detail as it is. */
|
||||
export function countedDetail(detail: string | undefined): string | undefined {
|
||||
if (repeatCount(detail) === undefined) return detail;
|
||||
const newline = detail!.indexOf('\n');
|
||||
return newline < 0 ? undefined : detail!.slice(newline + 1);
|
||||
}
|
||||
|
||||
/** Cut a text field at the shared cap and say so. */
|
||||
export function capField(text: string, max = UI_LOG_FIELD_MAX): string {
|
||||
return text.length > max ? `${text.slice(0, max)} [cut at ${max}]` : text;
|
||||
}
|
||||
|
||||
interface Repeat {
|
||||
entry: UiLogEntry;
|
||||
count: number;
|
||||
reported: number;
|
||||
}
|
||||
|
||||
const powerOfTen = (count: number): boolean => count >= 10 && /^10*$/.test(String(count));
|
||||
|
||||
export class UiLogBuffer {
|
||||
private readonly queue: UiLogEntry[] = [];
|
||||
/** The page each queued entry was recorded on. */
|
||||
private readonly pages = new WeakMap<UiLogEntry, string>();
|
||||
private readonly repeats = new Map<string, Repeat>();
|
||||
private seq = 0;
|
||||
private sent = 0;
|
||||
private dropped = 0;
|
||||
@@ -147,7 +199,7 @@ export class UiLogBuffer {
|
||||
}
|
||||
|
||||
get page(): string {
|
||||
return this.options.page;
|
||||
return this.options.getPage?.() ?? this.options.page;
|
||||
}
|
||||
|
||||
/** Queue one entry. Never throws and never writes to the console. */
|
||||
@@ -178,10 +230,34 @@ export class UiLogBuffer {
|
||||
if (typeof extra.col === 'number' && Number.isFinite(extra.col)) {
|
||||
entry.col = extra.col;
|
||||
}
|
||||
// Only a truly identical entry is counted: the same text from another place stays its own entry.
|
||||
const key = JSON.stringify([entry.level, entry.source, entry.project ?? '', entry.message, entry.detail ?? '',
|
||||
entry.stack ?? '', entry.url ?? '', entry.line ?? null, entry.col ?? null]);
|
||||
const repeat = this.repeats.get(key);
|
||||
if (repeat !== undefined) {
|
||||
this.seq -= 1;
|
||||
repeat.count += 1;
|
||||
if (powerOfTen(repeat.count)) {
|
||||
this.report(repeat);
|
||||
this.arm(this.flushMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.repeats.size < UI_LOG_REPEAT_KEYS_MAX) {
|
||||
this.repeats.set(key, { entry, count: 1, reported: 1 });
|
||||
}
|
||||
this.push(entry);
|
||||
this.arm(this.flushMs);
|
||||
}
|
||||
|
||||
/** Queue the running count of a repeated entry, with every field of its first occurrence. */
|
||||
private report(repeat: Repeat): void {
|
||||
const { ts: _ts, seq: _seq, detail, ...first } = repeat.entry;
|
||||
const entry: UiLogEntry = { ...first, ts: this.now().toISOString(), seq: ++this.seq, detail: repeatDetail(repeat.count, detail) };
|
||||
repeat.reported = repeat.count;
|
||||
this.push(entry);
|
||||
}
|
||||
|
||||
stats(): UiLogStats {
|
||||
return {
|
||||
queued: this.queue.length,
|
||||
@@ -193,8 +269,9 @@ export class UiLogBuffer {
|
||||
|
||||
/**
|
||||
* Send what is queued, one batch now and the rest on the timer. With
|
||||
* `final` the page is going away: the transport is told so, and nothing
|
||||
* is rescheduled.
|
||||
* `final` the page is going away: the transport is told so, nothing is
|
||||
* rescheduled, and a batch goes out for each page the entries were
|
||||
* recorded on.
|
||||
*/
|
||||
async flush(final = false): Promise<void> {
|
||||
if (this.timer !== undefined) {
|
||||
@@ -204,14 +281,28 @@ export class UiLogBuffer {
|
||||
if (this.inFlight && !final) {
|
||||
return;
|
||||
}
|
||||
if (final) {
|
||||
// The page is going away: every count not yet reported goes with it.
|
||||
for (const repeat of this.repeats.values()) {
|
||||
if (repeat.count > repeat.reported) {
|
||||
this.report(repeat);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
const batch = this.queue.splice(0, this.batchMax);
|
||||
// One page per post: the batch ends where an entry of another page starts.
|
||||
const page = this.pageOf(this.queue[0]);
|
||||
let size = 1;
|
||||
while (size < Math.min(this.batchMax, this.queue.length) && this.pageOf(this.queue[size]) === page) {
|
||||
size += 1;
|
||||
}
|
||||
const batch = this.queue.splice(0, size);
|
||||
if (this.droppedSinceFlush > 0) {
|
||||
const lost = this.droppedSinceFlush;
|
||||
this.droppedSinceFlush = 0;
|
||||
batch.unshift({
|
||||
const notice: UiLogEntry = {
|
||||
// Loss can span several projects. Do not attribute the aggregate
|
||||
// to whichever project happens to be open when delivery resumes.
|
||||
project: '',
|
||||
@@ -220,10 +311,12 @@ export class UiLogBuffer {
|
||||
level: 'warn',
|
||||
source: 'ui-log',
|
||||
message: `${lost} entries were dropped before this batch: the page keeps ${this.bufferMax} while the server does not answer`,
|
||||
});
|
||||
};
|
||||
this.pages.set(notice, page);
|
||||
batch.unshift(notice);
|
||||
}
|
||||
const payload: UiLogPayload = {
|
||||
page: this.options.page,
|
||||
page,
|
||||
session: this.options.session,
|
||||
entries: batch,
|
||||
};
|
||||
@@ -241,6 +334,9 @@ export class UiLogBuffer {
|
||||
this.backoffStep = 0;
|
||||
if (this.queue.length > 0 && !final) {
|
||||
this.arm(0);
|
||||
} else if (final && this.queue.length > 0 && this.pageOf(this.queue[0]) !== page) {
|
||||
// The page is going away: the entries of the other page it was on go with it.
|
||||
await this.flush(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -267,9 +363,14 @@ export class UiLogBuffer {
|
||||
this.dropped += 1;
|
||||
this.droppedSinceFlush += 1;
|
||||
}
|
||||
this.pages.set(entry, this.page);
|
||||
this.queue.push(entry);
|
||||
}
|
||||
|
||||
private pageOf(entry: UiLogEntry | undefined): string {
|
||||
return (entry === undefined ? undefined : this.pages.get(entry)) ?? this.options.page;
|
||||
}
|
||||
|
||||
private arm(ms: number): void {
|
||||
if (this.timer !== undefined || this.inFlight) {
|
||||
return;
|
||||
|
||||
@@ -167,33 +167,56 @@ it('replaces legacy model and twin controls when selected-code context is suppli
|
||||
expect(host.querySelector('[data-testid="legacy-twin-splitter"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('opens search on demand, retains its query, and returns keyboard focus on Escape', async () => {
|
||||
const props = { ...makeProps(), commandValue: 'index', onCommandKeyDown: vi.fn() };
|
||||
await act(async () => root.render(<AtlasChrome {...props} />));
|
||||
const trigger = host.querySelector<HTMLInputElement>('[aria-label="Preserved reader"]');
|
||||
expect(trigger).not.toBeNull();
|
||||
const dialog = host.querySelector<HTMLDialogElement>('dialog');
|
||||
expect(dialog?.open).toBe(false);
|
||||
await act(async () => {
|
||||
trigger!.focus();
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(dialog?.open).toBe(true);
|
||||
const input = host.querySelector<HTMLInputElement>('[data-testid="atlas-command-input"]')!;
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(input.value).toBe('index');
|
||||
await act(async () => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })));
|
||||
expect(dialog?.open).toBe(false);
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true, cancelable: true })));
|
||||
expect(dialog?.open).toBe(true);
|
||||
expect(input.value).toBe('index');
|
||||
it.each(['ctrlKey', 'metaKey'] as const)('routes %s+K to Galaxy and focuses its dedicated search after navigation', async modifier => {
|
||||
const onWorkspaceChange = vi.fn(); const focused = vi.fn();
|
||||
let frame: FrameRequestCallback | undefined;
|
||||
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { frame = callback; return 1; });
|
||||
window.addEventListener('cbm:focus-galaxy-search', focused);
|
||||
try {
|
||||
await act(async () => root.render(<AtlasChrome {...makeProps()} onWorkspaceChange={onWorkspaceChange} />));
|
||||
const reader = host.querySelector<HTMLInputElement>('[aria-label="Preserved reader"]')!;
|
||||
reader.focus();
|
||||
const event = new KeyboardEvent('keydown', { key: 'k', [modifier]: true, bubbles: true, cancelable: true });
|
||||
await act(async () => window.dispatchEvent(event));
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(onWorkspaceChange).toHaveBeenCalledExactlyOnceWith('galaxy');
|
||||
expect(focused).not.toHaveBeenCalled();
|
||||
expect(host.querySelector('dialog')).toBeNull();
|
||||
expect(host.querySelector('[data-testid="atlas-command-input"]')).toBeNull();
|
||||
await act(async () => frame!(0));
|
||||
expect(focused).toHaveBeenCalledOnce();
|
||||
expect(reader.value).toBe('selected source');
|
||||
} finally { window.removeEventListener('cbm:focus-galaxy-search', focused); raf.mockRestore(); }
|
||||
});
|
||||
|
||||
it('opens the command palette for existing keyboard and graph-action requests', async () => {
|
||||
await act(async () => root.render(<AtlasChrome {...makeProps()} />));
|
||||
await act(async () => window.dispatchEvent(new Event('cbm:open-command-search')));
|
||||
expect(host.querySelector<HTMLDialogElement>('dialog')?.open).toBe(true);
|
||||
it('routes legacy graph search requests to the dedicated Galaxy workspace', async () => {
|
||||
const onWorkspaceChange = vi.fn(); const focused = vi.fn();
|
||||
let frame: FrameRequestCallback | undefined;
|
||||
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { frame = callback; return 1; });
|
||||
window.addEventListener('cbm:focus-galaxy-search', focused);
|
||||
try {
|
||||
await act(async () => root.render(<AtlasChrome {...makeProps()} onWorkspaceChange={onWorkspaceChange} />));
|
||||
await act(async () => window.dispatchEvent(new Event('cbm:open-command-search')));
|
||||
expect(onWorkspaceChange).toHaveBeenCalledExactlyOnceWith('galaxy');
|
||||
await act(async () => frame!(0));
|
||||
expect(focused).toHaveBeenCalledOnce();
|
||||
expect(host.querySelector('dialog')).toBeNull();
|
||||
} finally { window.removeEventListener('cbm:focus-galaxy-search', focused); raf.mockRestore(); }
|
||||
});
|
||||
|
||||
it('shows compact reader context alongside preserved code without mounting a full impact report', async () => {
|
||||
const props = makeProps();
|
||||
await act(async () => root.render(<AtlasChrome {...props} workspace="explore" readerSummary={<aside aria-label="File impact">3 dependent files · 2 tests</aside>} />));
|
||||
const reader = host.querySelector<HTMLInputElement>('[aria-label="Preserved reader"]')!;
|
||||
const summary = host.querySelector('[aria-label="File impact"]')!;
|
||||
expect(summary.textContent).toContain('3 dependent files');
|
||||
expect(summary.compareDocumentPosition(reader) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(reader.closest('[hidden]')).toBeNull();
|
||||
expect(host.querySelector('[data-testid="impact-workspace"]')).toBeNull();
|
||||
await act(async () => root.render(<AtlasChrome {...props} workspace="explore" readerSummary={<aside aria-label="File impact">Selection · 1 dependent file</aside>} />));
|
||||
expect(host.querySelector('[aria-label="Preserved reader"]')).toBe(reader);
|
||||
expect(reader.value).toBe('selected source');
|
||||
expect(host.querySelector('[aria-label="File impact"]')?.textContent).toContain('Selection');
|
||||
});
|
||||
|
||||
it('resizes local chat by keyboard and exposes daemon navigation', async () => {
|
||||
@@ -233,35 +256,39 @@ it('keeps tools and explanation-depth controls out of the header', async () => {
|
||||
expect([...header.querySelectorAll('button')].some(button => button.textContent === 'Edges')).toBe(false);
|
||||
});
|
||||
|
||||
it('places Chat directly after the project selector and keeps its toggle wired', async () => {
|
||||
it('places separate agent settings and chat controls directly after the project selector', async () => {
|
||||
const onOpenBrowserAi = vi.fn();
|
||||
const onToggleBrowserAi = vi.fn();
|
||||
await act(async () => root.render(<AtlasChrome {...makeProps()} workspace="adr"
|
||||
projectSwitcher={<div data-testid="project-selector">Project</div>}
|
||||
onOpenBrowserAi={onOpenBrowserAi} chatOpen />));
|
||||
onOpenBrowserAi={onOpenBrowserAi} onToggleBrowserAi={onToggleBrowserAi} chatOpen />));
|
||||
const project = host.querySelector('[data-testid="project-selector"]')!;
|
||||
const chat = project.nextElementSibling as HTMLButtonElement;
|
||||
expect(chat.textContent).toBe('Chat');
|
||||
const controls = project.nextElementSibling!;
|
||||
expect(controls.getAttribute('aria-label')).toBe('Local agent');
|
||||
const settings = controls.querySelector<HTMLButtonElement>('.atlas-browser-ai-action')!;
|
||||
const chat = controls.querySelector<HTMLButtonElement>('[aria-label="Hide chat"]')!;
|
||||
expect(chat.getAttribute('aria-expanded')).toBe('true');
|
||||
await act(async () => chat.click());
|
||||
expect(onToggleBrowserAi).toHaveBeenCalledOnce();
|
||||
expect(onOpenBrowserAi).not.toHaveBeenCalled();
|
||||
await act(async () => settings.click());
|
||||
expect(onOpenBrowserAi).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps pending results visible until activation succeeds and refocuses an open search', async () => {
|
||||
const props = { ...makeProps(), onCommandKeyDown: (event: {preventDefault: () => void}) => event.preventDefault() };
|
||||
await act(async () => root.render(<AtlasChrome {...props} />));
|
||||
await act(async () => window.dispatchEvent(new Event('cbm:open-command-search')));
|
||||
const input = host.querySelector<HTMLInputElement>('[data-testid="atlas-command-input"]')!;
|
||||
const dialog = host.querySelector<HTMLDialogElement>('dialog')!;
|
||||
await act(async () => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })));
|
||||
expect(dialog.open).toBe(true);
|
||||
await act(async () => host.querySelector<HTMLButtonElement>('[aria-label="Close search"]')!.focus());
|
||||
await act(async () => window.dispatchEvent(new Event('cbm:open-command-search')));
|
||||
expect(document.activeElement).toBe(input);
|
||||
await act(async () => window.dispatchEvent(new Event('cbm:close-command-search')));
|
||||
expect(dialog.open).toBe(false);
|
||||
it('leaves reader typing and Enter untouched by removed command handlers', async () => {
|
||||
const onCommandChange = vi.fn(); const onCommandKeyDown = vi.fn();
|
||||
await act(async () => root.render(<AtlasChrome {...makeProps()} onCommandChange={onCommandChange} onCommandKeyDown={onCommandKeyDown} />));
|
||||
const reader = host.querySelector<HTMLInputElement>('[aria-label="Preserved reader"]')!;
|
||||
reader.focus();
|
||||
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
|
||||
await act(async () => reader.dispatchEvent(enter));
|
||||
expect(enter.defaultPrevented).toBe(false);
|
||||
expect(onCommandKeyDown).not.toHaveBeenCalled();
|
||||
expect(onCommandChange).not.toHaveBeenCalled();
|
||||
expect(document.activeElement).toBe(reader);
|
||||
expect(host.querySelector('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
it('keeps Explorer graph and chat draft mounted when chat folds', async () => {
|
||||
const props = makeProps();
|
||||
const galaxy = <section className="atlas-galaxy"><canvas /></section>;
|
||||
|
||||
@@ -48,6 +48,13 @@ export const workspaceStrings = {
|
||||
changeLater: 'Switch workspaces at any time.',
|
||||
localAi: 'Set up browser AI',
|
||||
browserAi: 'Chat',
|
||||
agentControls: 'Local agent',
|
||||
agentStatus: { off: 'Agent off', loading: 'Agent loading', active: 'Agent active', busy: 'Agent working', error: 'Agent error' },
|
||||
/** The header shows a robot and a lamp; state and model are read out and shown on hover. */
|
||||
agentSettings: (status: string, model?: string) => `Local agent settings: ${status}${model ? `, ${model}` : ''}`,
|
||||
agentSettingsTitle: (status: string, model?: string) => `${status}${model ? ` · ${model}` : ''}. Configure the browser-local agent.`,
|
||||
openChat: 'Open chat',
|
||||
hideChat: 'Hide chat',
|
||||
daemon: 'Daemon',
|
||||
daemonNavigation: (state: string) => `Open System: daemon ${state}`,
|
||||
chatWidth: 'Width of local chat',
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* Handtest K27: ein Zurueck und ein Vor fuer ganz Architecture, mit demselben
|
||||
* Verlaufsmodell wie Galaxy (K2). Die Szenen sind Attrappen mit Knoepfen fuer
|
||||
* Auswahl, Doppelklick und leere Flaeche; Analyse und Routen kommen aus
|
||||
* festen Antworten.
|
||||
*/
|
||||
import { act, type ReactNode } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import ArchitecturePanel, { type ArchitecturePanelProps } from './ArchitecturePanel';
|
||||
import type { ArchitectureSceneProps } from './ArchitectureScene';
|
||||
import type { ArchitectureOverviewDto } from '../core/intelligence-provider';
|
||||
import type { GraphData, GraphNode } from '../galaxy/types';
|
||||
import type { SystemSceneModel } from './system-architecture-model';
|
||||
import type { SystemArchitectureLoader, SystemProjection, SystemSymbol } from './system-architecture-source';
|
||||
|
||||
vi.mock('./ArchitectureScene', () => ({ ArchitectureScene: (props: ArchitectureSceneProps) => <div data-testid="scene">
|
||||
{props.model.nodes.map(node => <button key={node.id} data-node={node.id} onClick={() => props.onSelect(node.id)} onDoubleClick={() => props.onOpen?.(node.id)}>{node.label}</button>)}
|
||||
<button aria-label="Empty background" onClick={props.onClearSelection} /></div> }));
|
||||
vi.mock('./ContainerMap', () => ({ default: ({ filter, navigation }: { filter: string; navigation?: ReactNode }) =>
|
||||
<div data-testid="container-map" data-filter={filter}><div className="atlas-arch-context" data-testid="container-location">{navigation}</div></div> }));
|
||||
vi.mock('./route-graph-source', () => ({ loadRouteGraph: vi.fn(async () => ({ relationships: [], truncated: false, warnings: [] })) }));
|
||||
vi.mock('./BehaviorSourceEvidence', () => ({ default: () => <div /> }));
|
||||
vi.mock('../app/atlas-api', () => ({ AtlasApi: class { flows() { return Promise.resolve([]); } } }));
|
||||
vi.mock('./SystemArchitectureScene', () => ({ default: ({ model, onSelectNode, onExpandNode, onClearSelection, planar }: {
|
||||
model: SystemSceneModel; onSelectNode: (id: string) => void; onExpandNode?: (id: string) => void; onClearSelection?: () => void; planar?: boolean;
|
||||
}) => <div data-testid="system-scene" data-focus={model.focusId ?? ''} data-planar={String(Boolean(planar))}><button onClick={onClearSelection}>System background</button>
|
||||
{model.nodes.map(node => <button key={node.id} data-system-node={node.id} onClick={() => onSelectNode(node.id)} onDoubleClick={() => onExpandNode?.(node.id)}>{node.label}</button>)}</div> }));
|
||||
|
||||
const graphNode = (id: number, name: string, file_path: string, label = 'Function'): GraphNode =>
|
||||
({ id, label, name, qualified_name: `sample.${name}`, file_path, start_line: 3, x: 0, y: 0, z: 0, size: 1, color: '' });
|
||||
const graph: GraphData = { nodes: [graphNode(1, 'render', 'django/shortcuts.py'), graphNode(2, 'options', 'django/contrib/admin/options.py'),
|
||||
graphNode(3, 'setup', 'setup.py'), graphNode(10, '/edit/one/', 'app/urls.py', 'Route'), graphNode(11, '/edit/two/', 'app/urls.py', 'Route'),
|
||||
graphNode(12, '/home/', 'app/urls.py', 'Route')], edges: [], total_nodes: 6 };
|
||||
const overview: ArchitectureOverviewDto = { projectName: 'sample', totalSymbols: 6, totalRelations: 0, symbolKinds: [], relationKinds: [], languages: [],
|
||||
groups: [], boundaries: [], layers: [], clusters: [], entryPoints: [], routes: [], hotspots: [], files: ['django/shortcuts.py', 'django/contrib/admin/options.py', 'setup.py', 'app/urls.py'] };
|
||||
|
||||
const symbol = (id: number, component: string, name: string): SystemSymbol => ({
|
||||
id, name, qualified_name: `sample.${name}`, label: 'Function', file_path: `src/${component}.ts`, start_line: id * 10, component_id: component, group_id: `g:${component}`,
|
||||
});
|
||||
function projection(): SystemProjection {
|
||||
const main = symbol(1, 'api', 'main'), handle = symbol(2, 'service', 'handle'), save = symbol(3, 'store', 'save'), commit = symbol(4, 'store', 'commit');
|
||||
const components = [main, handle, save].map(item => ({ id: item.component_id, label: item.component_id, basis: 'interaction_community', member_count: 4, file_count: 1, representatives: [item], group_id: `g:${item.component_id}` }));
|
||||
const dependencies = [{ source: 'api', target: 'service', type: 'CALLS', count: 2, witnesses: [{ edge_id: 10, source: main, target: handle }] },
|
||||
{ source: 'service', target: 'store', type: 'CALLS', count: 1, witnesses: [{ edge_id: 11, source: handle, target: save }] }];
|
||||
return {
|
||||
schema_version: 1, status: 'ready', kind: 'static_projection', complete: true, components, dependencies, cycles: [], entrypoints: [main, handle],
|
||||
paths: [{ entrypoint_id: 1, nodes: [main, handle, save], edges: [{ id: 10, source_id: 1, target_id: 2, type: 'CALLS' }, { id: 11, source_id: 2, target_id: 3, type: 'CALLS' }] },
|
||||
{ entrypoint_id: 2, nodes: [handle, save], edges: [{ id: 11, source_id: 2, target_id: 3, type: 'CALLS' }] },
|
||||
{ entrypoint_id: 3, nodes: [save, commit], edges: [{ id: 12, source_id: 3, target_id: 4, type: 'CALLS' }] }],
|
||||
totals: { nodes: 12, files: 3, edges: 3, accounted_nodes: 12, structural_nodes: 0 }, limits: {}, warnings: [],
|
||||
overview: { complete: true, grouping_basis: 'common_source_directory_aggregate', components,
|
||||
groups: components.map(item => ({ id: `g:${item.id}`, label: item.label, component_count: 1, member_count: 4, file_count: 1, component_ids: [item.id], representatives: item.representatives })),
|
||||
connections: dependencies.map(edge => ({ ...edge, source: `g:${edge.source}`, target: `g:${edge.target}` })), totals: { groups: 3, components: 3 }, limits: {} },
|
||||
};
|
||||
}
|
||||
/** main reaches save on two indexed paths: through handle, and directly. */
|
||||
function twoPaths(): SystemProjection {
|
||||
const data = projection();
|
||||
const [main, , save] = data.paths[0]!.nodes;
|
||||
data.paths = [...data.paths, { entrypoint_id: 1, nodes: [main!, save!], edges: [{ id: 13, source_id: 1, target_id: 3, type: 'CALLS' }] }];
|
||||
return data;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let storageDescriptor: PropertyDescriptor | undefined;
|
||||
let loader: ReturnType<typeof vi.fn<SystemArchitectureLoader>>;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container);
|
||||
storageDescriptor = Object.getOwnPropertyDescriptor(window, 'localStorage');
|
||||
const contents = new Map<string, string>();
|
||||
Object.defineProperty(window, 'localStorage', { configurable: true, value: {
|
||||
getItem: (key: string) => contents.get(key) ?? null, setItem: (key: string, value: string) => { contents.set(key, value); },
|
||||
} });
|
||||
loader = vi.fn<SystemArchitectureLoader>().mockImplementation(async () => ({ status: 'ready', generation: 'g1', result: projection() }));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount()); container.remove();
|
||||
if (storageDescriptor) Object.defineProperty(window, 'localStorage', storageDescriptor);
|
||||
else delete (window as unknown as Record<string, unknown>).localStorage;
|
||||
});
|
||||
|
||||
async function render(changes: Partial<ArchitecturePanelProps> = {}): Promise<void> {
|
||||
await act(async () => root.render(<ArchitecturePanel projectName="sample" overview={overview} graph={graph} graphGeneration="g1"
|
||||
systemArchitectureLoader={loader} onNavigate={vi.fn()} active {...changes} />));
|
||||
await settle();
|
||||
}
|
||||
async function settle(): Promise<void> {
|
||||
for (let round = 0; round < 4; round++) await act(async () => { await vi.dynamicImportSettled(); await new Promise(resolve => setTimeout(resolve, 0)); });
|
||||
}
|
||||
const buttons = (scope: ParentNode = container) => [...scope.querySelectorAll<HTMLButtonElement>('button')];
|
||||
const byText = (text: string, scope?: ParentNode) => buttons(scope).find(button => button.textContent === text);
|
||||
async function press(target: HTMLElement | null | undefined): Promise<void> {
|
||||
expect(target).toBeTruthy();
|
||||
await act(async () => target!.click()); await settle();
|
||||
}
|
||||
const clickText = (text: string, scope?: ParentNode) => press(byText(text, scope));
|
||||
async function doubleClick(selector: string): Promise<void> {
|
||||
const target = container.querySelector(selector); expect(target).not.toBeNull();
|
||||
await act(async () => target!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))); await settle();
|
||||
}
|
||||
async function choose(label: string, value: string): Promise<void> {
|
||||
const select = container.querySelector<HTMLSelectElement>(`select[aria-label="${label}"]`); expect(select).not.toBeNull();
|
||||
await act(async () => { select!.value = value; select!.dispatchEvent(new Event('change', { bubbles: true })); }); await settle();
|
||||
}
|
||||
async function type(value: string): Promise<void> {
|
||||
const search = container.querySelector<HTMLInputElement>('input[type="search"]')!;
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(search, value);
|
||||
search.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
const history = () => container.querySelector<HTMLElement>('[role="group"][aria-label="Architecture history"]');
|
||||
const back = () => history()?.querySelector<HTMLButtonElement>('button[aria-label="Back"]') ?? null;
|
||||
const forward = () => history()?.querySelector<HTMLButtonElement>('button[aria-label="Forward"]') ?? null;
|
||||
const goBack = () => press(back());
|
||||
const goForward = () => press(forward());
|
||||
const view = () => container.querySelector('.atlas-arch-tab[aria-pressed="true"]')?.getAttribute('data-view');
|
||||
const trail = () => [...container.querySelectorAll('[aria-label="Architecture location"] button')].map(item => item.textContent);
|
||||
const planar = () => container.querySelector('[aria-label="Map camera"] button[aria-pressed="true"]')?.textContent;
|
||||
const perspective = () => container.querySelector('[aria-label="Routes perspective"] button[aria-pressed="true"]')?.textContent;
|
||||
const filter = () => container.querySelector<HTMLInputElement>('input[type="search"]')?.value;
|
||||
const focus = () => container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-focus');
|
||||
const heading = () => container.querySelector('.behavior-heading h2')?.textContent;
|
||||
const sceneLabels = () => [...container.querySelectorAll('[data-testid="scene"] [data-node]')].map(item => item.textContent);
|
||||
/** System structure and Behavior use the shared history rather than another local Back. */
|
||||
const inViewBack = () => buttons().find(button => button.textContent === '← Back' && !history()?.contains(button));
|
||||
const systemCamera = () => container.querySelector('[aria-label="System camera"] button[aria-pressed="true"]')?.textContent;
|
||||
const behaviorCamera = () => container.querySelector('[aria-label="Behavior camera"] button[aria-pressed="true"]')?.textContent;
|
||||
const chainPosition = () => container.querySelector('[aria-label="Walk the call chain"] span')?.textContent;
|
||||
const pathButtons = () => [...container.querySelectorAll<HTMLButtonElement>('[aria-label="Indexed paths"] button')];
|
||||
const chosenPath = () => pathButtons().findIndex(button => button.getAttribute('aria-pressed') === 'true') + 1;
|
||||
const lastEvidence = (listener: ReturnType<typeof vi.fn>) => listener.mock.lastCall?.[0] as { label: string; text: string } | undefined;
|
||||
const key = (init: KeyboardEventInit, target: EventTarget = window) => {
|
||||
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
|
||||
act(() => { target.dispatchEvent(event); });
|
||||
return event;
|
||||
};
|
||||
async function openArea(name: string): Promise<void> {
|
||||
await press(container.querySelector<HTMLButtonElement>(`[data-testid="scene"] [data-node="area:${name}"]`) ?? byText(name, container.querySelector('[data-testid="scene"]')!));
|
||||
await clickText('Open area →');
|
||||
}
|
||||
const selectView = (name: string) => press(container.querySelector<HTMLButtonElement>(`[data-view="${name}"]`));
|
||||
const position = () => history()?.getAttribute('data-position');
|
||||
async function openRouteGroup(): Promise<void> {
|
||||
await press(byText('/edit · 2', container.querySelector('[data-testid="scene"]')!));
|
||||
await clickText('Show these 2 routes →');
|
||||
}
|
||||
|
||||
describe('Architecture back and forward (K27)', () => {
|
||||
it('restores meaningful visits across views, with each visit’s camera and location', async () => {
|
||||
await render();
|
||||
expect(back()?.disabled).toBe(true);
|
||||
expect(back()?.title).toBe('Nothing to go back to yet');
|
||||
expect(forward()?.disabled).toBe(true);
|
||||
await openArea('django');
|
||||
expect(trail()).toEqual(['sample', 'django']);
|
||||
await clickText('Plan', container.querySelector('[aria-label="Map camera"]')!);
|
||||
expect(position()).toBe('2/2');
|
||||
expect(back()?.title).toBe('Back to Overview (Alt+Left)');
|
||||
await doubleClick('[data-node="file:django/shortcuts.py"]');
|
||||
expect(container.querySelector('[aria-label="Architecture location"]')?.textContent).toContain('shortcuts.py');
|
||||
expect(position()).toBe('3/3');
|
||||
expect(back()?.title).toBe('Back to Overview · django · Plan (Alt+Left)');
|
||||
await selectView('routes');
|
||||
await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!);
|
||||
expect(position()).toBe('3/3');
|
||||
await openRouteGroup();
|
||||
expect(position()).toBe('4/4');
|
||||
|
||||
await goBack();
|
||||
expect([view(), perspective(), filter()]).toEqual(['routes', 'Endpoints', '']);
|
||||
await goBack();
|
||||
expect([view(), trail(), planar()]).toEqual(['overview', ['sample', 'django'], 'Plan']);
|
||||
expect(forward()?.title).toBe('Forward to Routes · Endpoints · Plan (Alt+Right)');
|
||||
await goBack();
|
||||
expect([trail(), planar()]).toEqual([['sample'], '3D']);
|
||||
expect(back()?.disabled).toBe(true);
|
||||
await goForward(); await goForward(); await goForward();
|
||||
expect([view(), perspective(), filter(), planar()]).toEqual(['routes', 'Endpoints', '/edit', 'Plan']);
|
||||
expect(forward()?.disabled).toBe(true);
|
||||
|
||||
await goBack(); await goBack();
|
||||
await press(byText('sample', container.querySelector('[aria-label="Architecture location"]')!));
|
||||
expect(trail()).toEqual(['sample']);
|
||||
expect(back()?.title).toBe('Back to Overview · django · Plan (Alt+Left)');
|
||||
expect(forward()?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('replaces view and Routes perspective context while route-group drill-ins create stops', async () => {
|
||||
await render();
|
||||
await selectView('routes');
|
||||
expect(perspective()).toBe('Service map');
|
||||
expect(container.querySelector('[data-testid="container-map"]')).not.toBeNull();
|
||||
await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!);
|
||||
expect(sceneLabels()).toContain('/edit · 2');
|
||||
expect(position()).toBe('1/1');
|
||||
expect(back()?.disabled).toBe(true);
|
||||
await openRouteGroup();
|
||||
expect(filter()).toBe('/edit');
|
||||
expect(sceneLabels()).toEqual(['/edit/one/', '/edit/two/']);
|
||||
expect(back()?.title).toBe('Back to Routes · Endpoints (Alt+Left)');
|
||||
|
||||
await goBack();
|
||||
expect([view(), perspective(), filter()]).toEqual(['routes', 'Endpoints', '']);
|
||||
expect(back()?.disabled).toBe(true);
|
||||
await clickText('Service map', container.querySelector('[aria-label="Routes perspective"]')!);
|
||||
expect(position()).toBe('1/2');
|
||||
expect(container.querySelector('[data-testid="container-map"]')).not.toBeNull();
|
||||
expect(forward()?.disabled).toBe(false);
|
||||
await goForward();
|
||||
expect([view(), perspective(), filter()]).toEqual(['routes', 'Endpoints', '/edit']);
|
||||
expect(sceneLabels()).toEqual(['/edit/one/', '/edit/two/']);
|
||||
await goBack();
|
||||
expect([perspective(), filter()]).toEqual(['Service map', '']);
|
||||
});
|
||||
|
||||
it('saves every typed filter immediately without adding stops', async () => {
|
||||
await render();
|
||||
await selectView('routes');
|
||||
await type('/e'); await type('/ed'); await type('/edit');
|
||||
expect(filter()).toBe('/edit');
|
||||
expect(position()).toBe('1/1');
|
||||
expect(back()?.disabled).toBe(true);
|
||||
expect(forward()?.disabled).toBe(true);
|
||||
await type('');
|
||||
await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!);
|
||||
await openRouteGroup();
|
||||
await type('/edit/one');
|
||||
expect(position()).toBe('2/2');
|
||||
await goBack();
|
||||
expect(filter()).toBe('');
|
||||
expect(forward()?.title).toBe('Forward to Routes · Endpoints · /edit/one (Alt+Right)');
|
||||
await goForward();
|
||||
expect(filter()).toBe('/edit/one');
|
||||
expect(position()).toBe('2/2');
|
||||
});
|
||||
|
||||
it('records System structure focus and restores expanded groups without expansion-only stops', async () => {
|
||||
await render();
|
||||
await selectView('structure');
|
||||
expect(focus()).toBe('');
|
||||
expect(inViewBack()).toBeUndefined();
|
||||
expect(back()?.disabled).toBe(true);
|
||||
await doubleClick('[data-system-node="g:api"]');
|
||||
expect(focus()).toBe('g:api');
|
||||
expect(container.querySelector('[data-system-node="member:g:api:api"]')).not.toBeNull();
|
||||
expect(back()?.title).toBe('Back to System structure (Alt+Left)');
|
||||
await press(container.querySelector<HTMLButtonElement>('[data-system-node="g:api"]'));
|
||||
await clickText('Collapse group');
|
||||
expect(position()).toBe('2/2');
|
||||
expect(container.querySelector('[data-system-node="member:g:api:api"]')).toBeNull();
|
||||
await clickText('Expand group');
|
||||
expect(position()).toBe('2/2');
|
||||
await press(container.querySelector<HTMLButtonElement>('[data-system-node="g:service"]'));
|
||||
await clickText('Focus here');
|
||||
expect(focus()).toBe('g:service');
|
||||
expect(back()?.title).toBe('Back to System structure · api · 1 group open (Alt+Left)');
|
||||
expect(inViewBack()).toBeUndefined();
|
||||
|
||||
await goBack();
|
||||
expect(focus()).toBe('g:api');
|
||||
expect(container.querySelector('[data-system-node="member:g:api:api"]')).not.toBeNull();
|
||||
await goBack();
|
||||
expect(focus()).toBe('');
|
||||
expect(container.querySelector('[data-system-node="member:g:api:api"]')).toBeNull();
|
||||
expect(back()?.disabled).toBe(true);
|
||||
await goForward();
|
||||
expect([view(), focus()]).toEqual(['structure', 'g:api']);
|
||||
await clickText('Whole system');
|
||||
expect(focus()).toBe('');
|
||||
expect(forward()?.disabled).toBe(true);
|
||||
expect(back()?.title).toBe('Back to System structure · api · 1 group open (Alt+Left)');
|
||||
});
|
||||
|
||||
it('steps through Behavior starts and followed calls, and an automatic start is no extra step', async () => {
|
||||
await render();
|
||||
await selectView('structure');
|
||||
await doubleClick('[data-system-node="g:api"]');
|
||||
await selectView('behavior');
|
||||
expect(heading()).toBe('What can main call?');
|
||||
expect(position()).toBe('2/2');
|
||||
expect(back()?.title).toBe('Back to System structure (Alt+Left)');
|
||||
await choose('Behavior entry point', '2');
|
||||
expect(heading()).toBe('What can handle call?');
|
||||
expect(back()?.title).toBe('Back to Behavior · main (Alt+Left)');
|
||||
await doubleClick('[data-system-node="journey-choice:3"]');
|
||||
expect(heading()).toBe('What can save call?');
|
||||
expect(inViewBack()).toBeUndefined();
|
||||
expect(back()?.title).toBe('Back to Behavior · handle (Alt+Left)');
|
||||
expect(forward()?.disabled).toBe(true);
|
||||
|
||||
await goBack();
|
||||
expect(heading()).toBe('What can handle call?');
|
||||
expect(forward()?.title).toBe('Forward to Behavior · save · followed from handle (Alt+Right)');
|
||||
await goBack();
|
||||
expect(heading()).toBe('What can main call?');
|
||||
await goBack();
|
||||
expect([view(), focus()]).toEqual(['structure', '']);
|
||||
await goForward(); await goForward(); await goForward();
|
||||
expect(heading()).toBe('What can save call?');
|
||||
expect(container.querySelector<HTMLSelectElement>('select[aria-label="Behavior entry point"]')?.value).toBe('3');
|
||||
await clickText('System background');
|
||||
expect(heading()).toBe('What can handle call?');
|
||||
expect(back()?.title).toBe('Back to Behavior · save · followed from handle (Alt+Left)');
|
||||
});
|
||||
|
||||
it('names the shown Behavior start after the suggested start was rejected for a stale snapshot', async () => {
|
||||
let calls = 0;
|
||||
loader.mockImplementation(async request => {
|
||||
calls++;
|
||||
if (request.entryNodeId !== undefined && request.expectedGeneration === 'g1') return { status: 'failed', generation: 'g2', error: 'Entry snapshot changed.' };
|
||||
return { status: 'ready', generation: calls === 1 ? 'g1' : 'g2', result: projection() };
|
||||
});
|
||||
await render();
|
||||
await openArea('django');
|
||||
await selectView('behavior');
|
||||
expect(heading()).toBe('What can main call?');
|
||||
expect(position()).toBe('2/2');
|
||||
expect(back()?.title).toBe('Back to Overview (Alt+Left)');
|
||||
await choose('Behavior entry point', '2');
|
||||
expect(heading()).toBe('What can handle call?');
|
||||
expect(back()?.title).toBe('Back to Behavior · main (Alt+Left)');
|
||||
await goBack();
|
||||
expect(heading()).toBe('What can main call?');
|
||||
expect(forward()?.title).toBe('Forward to Behavior · handle (Alt+Right)');
|
||||
await goBack();
|
||||
expect(view()).toBe('overview');
|
||||
expect(forward()?.title).toBe('Forward to Behavior · main (Alt+Right)');
|
||||
});
|
||||
|
||||
it('restores System Plan or 3D with the focused visit without making camera or tab stops', async () => {
|
||||
await render();
|
||||
await selectView('structure');
|
||||
expect(systemCamera()).toBe('3D');
|
||||
expect(container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-planar')).toBe('false');
|
||||
await doubleClick('[data-system-node="g:api"]');
|
||||
await clickText('Plan', container.querySelector('[aria-label="System camera"]')!);
|
||||
expect(container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-planar')).toBe('true');
|
||||
expect(position()).toBe('2/2');
|
||||
await press(container.querySelector<HTMLButtonElement>('[data-system-node="g:service"]'));
|
||||
await clickText('Focus here');
|
||||
await selectView('overview');
|
||||
expect(position()).toBe('3/3');
|
||||
expect(back()?.title).toBe('Back to System structure · api · 1 group open · Plan (Alt+Left)');
|
||||
|
||||
await goBack();
|
||||
expect([view(), focus(), systemCamera()]).toEqual(['structure', 'g:api', 'Plan']);
|
||||
expect(container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-planar')).toBe('true');
|
||||
await goBack();
|
||||
expect([view(), focus(), systemCamera()]).toEqual(['structure', '', '3D']);
|
||||
await goForward(); await goForward();
|
||||
expect(view()).toBe('overview');
|
||||
await selectView('behavior');
|
||||
expect(behaviorCamera()).toBe('3D');
|
||||
expect(position()).toBe('3/3');
|
||||
});
|
||||
|
||||
it('records Behavior destinations and paths while retaining chain position and camera as context', async () => {
|
||||
loader.mockImplementation(async () => ({ status: 'ready', generation: 'g1', result: twoPaths() }));
|
||||
await render();
|
||||
await selectView('behavior');
|
||||
expect(heading()).toBe('What can main call?');
|
||||
expect(position()).toBe('1/1');
|
||||
await choose('Behavior destination', '3');
|
||||
expect(heading()).toBe('main → save');
|
||||
expect([chosenPath(), chainPosition()]).toEqual([1, '1 / 3']);
|
||||
await clickText('Next →');
|
||||
expect(chainPosition()).toBe('2 / 3');
|
||||
expect(position()).toBe('2/2');
|
||||
expect(back()?.title).toBe('Back to Behavior · main (Alt+Left)');
|
||||
await press(pathButtons()[1]);
|
||||
expect([chosenPath(), chainPosition()]).toEqual([2, '1 / 2']);
|
||||
expect(back()?.title).toBe('Back to Behavior · main → save (Alt+Left)');
|
||||
await clickText('Plan', container.querySelector('[aria-label="Behavior camera"]')!);
|
||||
expect(position()).toBe('3/3');
|
||||
expect(back()?.title).toBe('Back to Behavior · main → save (Alt+Left)');
|
||||
|
||||
await goBack();
|
||||
expect([heading(), behaviorCamera(), chosenPath(), chainPosition()]).toEqual(['main → save', '3D', 1, '2 / 3']);
|
||||
expect(forward()?.title).toBe('Forward to Behavior · main → save · Path 2 · Plan (Alt+Right)');
|
||||
await goBack();
|
||||
expect([heading(), container.querySelector('[aria-label="Walk the call chain"]')]).toEqual(['What can main call?', null]);
|
||||
expect(back()?.disabled).toBe(true);
|
||||
await goForward();
|
||||
expect([chosenPath(), chainPosition()]).toEqual([1, '2 / 3']);
|
||||
await goForward();
|
||||
expect([behaviorCamera(), chosenPath(), chainPosition()]).toEqual(['Plan', 2, '1 / 2']);
|
||||
await selectView('structure');
|
||||
expect(systemCamera()).toBe('3D');
|
||||
expect(position()).toBe('3/3');
|
||||
});
|
||||
|
||||
it('preserves the forward branch while typing and saves the latest text before a recent jump', async () => {
|
||||
await render();
|
||||
await selectView('routes');
|
||||
await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!);
|
||||
await openRouteGroup();
|
||||
await goBack();
|
||||
await type('/home');
|
||||
expect(position()).toBe('1/2');
|
||||
expect(forward()?.title).toBe('Forward to Routes · Endpoints · /edit (Alt+Right)');
|
||||
await goForward();
|
||||
expect(filter()).toBe('/edit');
|
||||
await goBack();
|
||||
expect(filter()).toBe('/home');
|
||||
await type('/admin');
|
||||
const recent = container.querySelector<HTMLDetailsElement>('details.atlas-arch-recent')!;
|
||||
const editPlace = [...recent.querySelectorAll<HTMLButtonElement>('[aria-label="Recently visited places"] li button')]
|
||||
.find(item => item.querySelector('span')?.textContent === 'Endpoints · /edit');
|
||||
await press(editPlace);
|
||||
expect(filter()).toBe('/edit');
|
||||
expect(position()).toBe('2/2');
|
||||
expect(forward()?.disabled).toBe(true);
|
||||
expect(back()?.title).toBe('Back to Routes · Endpoints · /admin (Alt+Left)');
|
||||
await goBack();
|
||||
expect(filter()).toBe('/admin');
|
||||
});
|
||||
|
||||
it('reports opened-area evidence only in its view and restores it with meaningful history visits', async () => {
|
||||
const onSelectionEvidence = vi.fn();
|
||||
await render({ onSelectionEvidence });
|
||||
await openArea('django');
|
||||
expect(lastEvidence(onSelectionEvidence)?.label).toBe('django');
|
||||
await selectView('routes');
|
||||
await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!);
|
||||
expect(sceneLabels()).toContain('/edit · 2');
|
||||
expect(lastEvidence(onSelectionEvidence)).toBeUndefined();
|
||||
await selectView('hotspots');
|
||||
expect(lastEvidence(onSelectionEvidence)).toBeUndefined();
|
||||
await selectView('overview');
|
||||
expect(lastEvidence(onSelectionEvidence)?.label).toBe('django');
|
||||
expect(position()).toBe('2/2');
|
||||
await doubleClick('[data-node="file:django/shortcuts.py"]');
|
||||
await selectView('routes');
|
||||
await goBack();
|
||||
expect([view(), trail()]).toEqual(['overview', ['sample', 'django']]);
|
||||
expect(lastEvidence(onSelectionEvidence)?.label).toBe('django');
|
||||
await goForward();
|
||||
expect([view(), perspective()]).toEqual(['routes', 'Endpoints']);
|
||||
expect(lastEvidence(onSelectionEvidence)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops the forward branch on a new focus after Back and keeps at most 25 real stops', async () => {
|
||||
await render();
|
||||
await selectView('structure');
|
||||
await doubleClick('[data-system-node="g:api"]');
|
||||
await doubleClick('[data-system-node="g:service"]');
|
||||
await goBack();
|
||||
expect(focus()).toBe('g:api');
|
||||
expect(forward()?.disabled).toBe(false);
|
||||
await selectView('behavior');
|
||||
expect(position()).toBe('2/3');
|
||||
expect(forward()?.disabled).toBe(false);
|
||||
await choose('Behavior entry point', '2');
|
||||
expect(forward()?.disabled).toBe(true);
|
||||
await goBack();
|
||||
expect(heading()).toBe('What can main call?');
|
||||
expect(forward()?.title).toBe('Forward to Behavior · handle (Alt+Right)');
|
||||
|
||||
for (let step = 0; step < 15; step++) {
|
||||
await choose('Behavior entry point', '2');
|
||||
await choose('Behavior entry point', '1');
|
||||
}
|
||||
expect(position()).toBe('25/25');
|
||||
let steps = 0;
|
||||
while (!back()!.disabled && steps < 100) { await goBack(); steps++; }
|
||||
expect(steps).toBe(24);
|
||||
});
|
||||
|
||||
it('answers Alt+Left and Alt+Right only in the active workspace and never while typing', async () => {
|
||||
await render();
|
||||
await openArea('django');
|
||||
await selectView('routes');
|
||||
let event = key({ key: 'ArrowLeft', altKey: true });
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
await settle();
|
||||
expect(view()).toBe('overview');
|
||||
event = key({ key: 'ArrowRight', altKey: true });
|
||||
await settle();
|
||||
expect(view()).toBe('routes');
|
||||
const search = container.querySelector<HTMLInputElement>('input[type="search"]')!;
|
||||
event = key({ key: 'ArrowLeft', altKey: true }, search);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(view()).toBe('routes');
|
||||
expect(key({ key: 'ArrowLeft' }).defaultPrevented).toBe(false);
|
||||
expect(key({ key: 'ArrowLeft', altKey: true, metaKey: true }).defaultPrevented).toBe(false);
|
||||
await render({ escapeTaken: true });
|
||||
expect(key({ key: 'ArrowLeft', altKey: true }).defaultPrevented).toBe(false);
|
||||
expect(view()).toBe('routes');
|
||||
await render({ active: false });
|
||||
expect(key({ key: 'ArrowLeft', altKey: true }).defaultPrevented).toBe(false);
|
||||
await settle();
|
||||
expect(view()).toBe('routes');
|
||||
});
|
||||
|
||||
it('jumps to a recent place as a new navigation and starts a fresh history per project', async () => {
|
||||
await render();
|
||||
await openArea('django');
|
||||
await doubleClick('[data-node="file:django/shortcuts.py"]');
|
||||
await selectView('routes');
|
||||
await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!);
|
||||
await openRouteGroup();
|
||||
await goBack();
|
||||
const recent = container.querySelector<HTMLDetailsElement>('details.atlas-arch-recent')!;
|
||||
expect(recent).not.toBeNull();
|
||||
expect(recent.querySelector('summary')?.getAttribute('aria-label')).toBe('Recent');
|
||||
const places = [...recent.querySelectorAll('[aria-label="Recently visited places"] li button')];
|
||||
expect(places.map(item => [item.querySelector('strong')?.textContent, item.querySelector('span')?.textContent ?? ''])).toEqual([
|
||||
['Routes', 'Endpoints'], ['Routes', 'Endpoints · /edit'], ['Overview', 'django'], ['Overview', ''],
|
||||
]);
|
||||
expect(places[0]?.getAttribute('aria-current')).toBe('true');
|
||||
await act(async () => { recent.open = true; });
|
||||
await press(places[2] as HTMLButtonElement);
|
||||
expect([view(), trail()]).toEqual(['overview', ['sample', 'django']]);
|
||||
expect(recent.open).toBe(false);
|
||||
expect(forward()?.disabled).toBe(true);
|
||||
expect(back()?.title).toBe('Back to Routes · Endpoints (Alt+Left)');
|
||||
|
||||
await render({ projectName: 'other', overview: { ...overview, projectName: 'other' } });
|
||||
expect(back()?.disabled).toBe(true);
|
||||
expect(forward()?.disabled).toBe(true);
|
||||
expect(container.querySelector('details.atlas-arch-recent')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Architecture history controls beside the current location', () => {
|
||||
const recentMenu = () => container.querySelector<HTMLDetailsElement>('details.atlas-arch-recent');
|
||||
async function withRecent(): Promise<HTMLDetailsElement> {
|
||||
await render();
|
||||
await openArea('django');
|
||||
const menu = recentMenu();
|
||||
expect(menu).not.toBeNull();
|
||||
await act(async () => { menu!.open = true; });
|
||||
return menu!;
|
||||
}
|
||||
|
||||
it('keeps compact, accessible history controls inside the current view and out of the subtab row', async () => {
|
||||
await render();
|
||||
await openArea('django');
|
||||
const contexts = [
|
||||
['overview', '[data-testid="spatial-architecture"]'],
|
||||
['routes', '[data-testid="container-map"]'],
|
||||
['hotspots', '[data-testid="spatial-architecture"]'],
|
||||
['structure', '[data-testid="system-architecture"]'],
|
||||
['behavior', '.behavior-journey'],
|
||||
];
|
||||
for (const [name, selector] of contexts) {
|
||||
await selectView(name!);
|
||||
const row = container.querySelector<HTMLElement>('.atlas-arch-tabrow')!;
|
||||
expect([...row.children].map(item => item.getAttribute('aria-label'))).toEqual(['Architecture views']);
|
||||
expect(row.querySelector('[aria-label="Architecture history"]')).toBeNull();
|
||||
expect(container.querySelectorAll('[aria-label="Architecture history"]')).toHaveLength(1);
|
||||
const currentView = container.querySelector(selector!);
|
||||
expect(currentView?.contains(history()!)).toBe(true);
|
||||
expect(history()?.parentElement).toBe(currentView?.querySelector(name === 'behavior' ? '.behavior-requests' : '.atlas-arch-context'));
|
||||
expect(back()?.textContent).toBe('');
|
||||
expect(forward()?.textContent).toBe('');
|
||||
expect(back()?.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(forward()?.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(back()?.getAttribute('aria-label')).toBe('Back');
|
||||
expect(forward()?.getAttribute('aria-label')).toBe('Forward');
|
||||
expect(recentMenu()?.querySelector('summary')?.getAttribute('aria-label')).toBe('Recent');
|
||||
expect(recentMenu()?.querySelector('summary svg')?.getAttribute('aria-hidden')).toBe('true');
|
||||
}
|
||||
await selectView('routes');
|
||||
await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!);
|
||||
expect(container.querySelector('[data-testid="spatial-architecture"]')?.contains(history()!)).toBe(true);
|
||||
expect(back()?.title).toBe('Back to Overview (Alt+Left)');
|
||||
});
|
||||
|
||||
it('closes Recent on a press elsewhere and on Escape, which stays with the menu', async () => {
|
||||
const menu = await withRecent();
|
||||
await act(async () => { container.querySelector('.atlas-arch-tabs')!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); });
|
||||
expect(menu.open).toBe(false);
|
||||
await act(async () => { menu.open = true; });
|
||||
expect(key({ key: 'Escape' }, menu.querySelector('summary')!).defaultPrevented).toBe(true);
|
||||
expect(menu.open).toBe(false);
|
||||
expect([view(), trail()]).toEqual(['overview', ['sample', 'django']]);
|
||||
});
|
||||
|
||||
it('closes Recent when Back or Alt+Right changes the place', async () => {
|
||||
const menu = await withRecent();
|
||||
await goBack();
|
||||
expect(trail()).toEqual(['sample']);
|
||||
expect(menu.open).toBe(false);
|
||||
await act(async () => { menu.open = true; });
|
||||
expect(key({ key: 'ArrowRight', altKey: true }).defaultPrevented).toBe(true);
|
||||
await settle();
|
||||
expect(trail()).toEqual(['sample', 'django']);
|
||||
expect(menu.open).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import type { ArchitecturePanelProps } from './ArchitecturePanel';
|
||||
import type { ArchitectureOverviewDto } from '../core/intelligence-provider';
|
||||
import { architectureText as text } from './strings';
|
||||
vi.mock('./ArchitectureScene', () => ({ ArchitectureScene: () => <div data-testid="scene" /> }));
|
||||
vi.mock('./ContainerMap', () => ({ default: () => <div data-testid="container-map" /> }));
|
||||
vi.mock('./ContainerMap', () => ({ default: ({ filter }: { filter: string }) => <div data-testid="container-map" data-filter={filter} /> }));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
@@ -59,12 +59,6 @@ async function click(selector: string): Promise<void> {
|
||||
await act(async () => target?.click());
|
||||
}
|
||||
|
||||
async function filter(value: string): Promise<void> {
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="search"]')!;
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
|
||||
await act(async () => { setter?.call(input, value); input.dispatchEvent(new Event('input', { bubbles: true })); });
|
||||
}
|
||||
|
||||
describe('architecture workspace', () => {
|
||||
it('loads system structure independently when the legacy summary is unavailable', async () => {
|
||||
const loader = vi.fn().mockResolvedValue({ status: 'failed', generation: 'g1', error: 'System analysis service unavailable.' });
|
||||
@@ -103,14 +97,21 @@ describe('architecture workspace', () => {
|
||||
expect(container.querySelector('select[aria-label="Entry point"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps module, layer, community and indexed-file evidence available in Overview', async () => {
|
||||
it('keeps the graph primary without duplicating the source guide or architecture search', async () => {
|
||||
await render({ graph: { nodes: [], edges: [], total_nodes: 0 } });
|
||||
expect(container.querySelector('[data-testid="spatial-architecture"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="atlas-architecture-content"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain('Source guide and complete findings');
|
||||
expect(container.querySelector('input[type="search"]')).toBeNull();
|
||||
expect(container.querySelector<HTMLDetailsElement>('.spatial-map-details')?.open).toBe(false);
|
||||
expect(container.querySelector<HTMLDetailsElement>('.spatial-node-list')?.open).toBe(false);
|
||||
});
|
||||
|
||||
it('provides a compact source fallback when the graph has not loaded', async () => {
|
||||
await render();
|
||||
expect(container.textContent).toContain('41');
|
||||
expect(container.textContent).toContain('Registered route handlers');
|
||||
expect(container.textContent).toContain('persist');
|
||||
expect(container.textContent).toContain('src/storage.ts');
|
||||
const statistics = [...container.querySelectorAll('details')].find(details => details.querySelector('summary')?.textContent === text.summaryDetails);
|
||||
expect(statistics?.open).toBe(false);
|
||||
expect(container.textContent).toContain('2 files · 2 source areas · 1 cross-area connections');
|
||||
expect(container.querySelectorAll('tbody tr')).toHaveLength(2);
|
||||
expect(container.textContent).not.toContain('Registered route handlers');
|
||||
});
|
||||
|
||||
it('opens a source location at the exact provider line and leaves unknown locations unlinked', async () => {
|
||||
@@ -123,54 +124,48 @@ describe('architecture workspace', () => {
|
||||
expect(missing?.textContent).toContain(text.unknown);
|
||||
});
|
||||
|
||||
it('retains source-derived route evidence when filtering by handler', async () => {
|
||||
await render();
|
||||
it('retains source-derived route evidence and pagination in the non-graph fallback', async () => {
|
||||
const data = overview();
|
||||
data.routes = Array.from({ length: 30 }, (_, index) => ({ ...data.routes[0], path: `/users/${index}` }));
|
||||
await render({ overview: data });
|
||||
await click('[data-view="routes"]');
|
||||
await filter('create');
|
||||
expect(container.querySelectorAll('tbody tr')).toHaveLength(1);
|
||||
expect(container.querySelectorAll('tbody tr')).toHaveLength(24);
|
||||
expect(container.querySelector('[data-origin="source"]')?.textContent).toBe(text.sourceOrigin);
|
||||
await filter('unrecorded');
|
||||
expect(container.textContent).toContain(text.noMatches);
|
||||
expect(container.querySelector('tbody')).toBeNull();
|
||||
const more = [...container.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === text.showMore)!;
|
||||
await act(async () => more.click());
|
||||
expect(container.querySelectorAll('tbody tr')).toHaveLength(30);
|
||||
});
|
||||
|
||||
it('restores configuration per project and prevents a cached project summary from leaking across the switch', async () => {
|
||||
await render();
|
||||
it('offers the filter in Routes only and hands it to the service and endpoint views', async () => {
|
||||
await render({ graph: { nodes: [], edges: [], total_nodes: 0 } });
|
||||
expect(container.querySelector('input[type="search"]')).toBeNull();
|
||||
await click('[data-view="routes"]');
|
||||
await filter('users');
|
||||
await act(async () => { await vi.dynamicImportSettled(); });
|
||||
const search = container.querySelector<HTMLInputElement>('input[type="search"]')!;
|
||||
expect(search.getAttribute('aria-label')).toBe(text.filter);
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(search, '/users');
|
||||
search.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
expect(container.querySelector('[data-testid="container-map"]')?.getAttribute('data-filter')).toBe('/users');
|
||||
await click('[data-view="hotspots"]');
|
||||
expect(container.querySelector('input[type="search"]')).toBeNull();
|
||||
await click('[data-view="routes"]');
|
||||
expect(container.querySelector<HTMLInputElement>('input[type="search"]')?.value).toBe('/users');
|
||||
});
|
||||
|
||||
it('restores the view per project while discarding old hidden search filters', async () => {
|
||||
window.localStorage.setItem('atlas.architecture.v1:sample', JSON.stringify({ version: 1, view: 'routes', filter: 'unrecorded' }));
|
||||
await render();
|
||||
expect(container.querySelector('[data-view="routes"]')?.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(container.querySelectorAll('tbody tr')).toHaveLength(1);
|
||||
expect(container.querySelector('input[type="search"]')).toBeNull();
|
||||
await render({ projectName: 'different' });
|
||||
expect(container.querySelector('[data-view="overview"]')?.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(container.querySelector('[data-testid="atlas-architecture-content"]')).toBeNull();
|
||||
await render();
|
||||
expect(container.querySelector('[data-view="routes"]')?.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(container.querySelector<HTMLInputElement>('input')?.value).toBe('users');
|
||||
const reset = [...container.querySelectorAll('button')].find(button => button.textContent === text.reset)!;
|
||||
await act(async () => reset.click());
|
||||
expect(container.querySelector('[data-view="overview"]')?.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(container.querySelector<HTMLInputElement>('input')?.value).toBe('');
|
||||
});
|
||||
|
||||
it('links the module card and keyboard-accessible dependency map to the same filter', async () => {
|
||||
await render();
|
||||
await click('button[aria-label="Show relationships for api"]');
|
||||
expect(container.querySelector('[data-view="overview"]')?.getAttribute('aria-pressed')).toBe('true');
|
||||
const target = container.querySelector<SVGGElement>('g[aria-label="Show relationships for storage"]')!;
|
||||
expect(target.getAttribute('tabindex')).toBe('0');
|
||||
await act(async () => target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })));
|
||||
expect(container.querySelector<HTMLInputElement>('input')?.value).toBe('storage');
|
||||
expect(container.querySelectorAll('[data-testid="architecture-dependency-details"] tbody tr')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('bounds the diagram while keeping every reported relationship reachable', async () => {
|
||||
const data = overview();
|
||||
data.boundaries = Array.from({ length: 30 }, (_, index) => ({ from: `module-${index}`, to: 'core', callCount: index + 1 }));
|
||||
await render({ overview: data });
|
||||
expect(container.querySelectorAll('[data-testid="architecture-dependency-details"] svg g[role="button"]')).toHaveLength(8);
|
||||
expect(container.textContent).toContain(text.mapLimit(8, 23));
|
||||
expect(container.querySelectorAll('[data-testid="architecture-dependency-details"] tbody tr')).toHaveLength(24);
|
||||
const more = [...container.querySelectorAll<HTMLButtonElement>('[data-testid="architecture-dependency-details"] button')].find(button => button.textContent === text.showMore)!;
|
||||
await act(async () => more.click());
|
||||
expect(container.querySelectorAll('[data-testid="architecture-dependency-details"] tbody tr')).toHaveLength(30);
|
||||
expect(JSON.parse(window.localStorage.getItem('atlas.architecture.v1:sample')!).filter).toBe('');
|
||||
});
|
||||
|
||||
it('distinguishes missing hotspot measurements from a measured zero', async () => {
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { lazy, Suspense, useEffect, useId, useMemo, useState } from 'react';
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { JSX, ReactNode } from 'react';
|
||||
import type { ArchitectureBoundary, ArchitectureHotspot, ArchitectureOverviewDto } from '../core/intelligence-provider';
|
||||
import type { ArchitectureHotspot, ArchitectureOverviewDto } from '../core/intelligence-provider';
|
||||
import {
|
||||
ARCHITECTURE_VIEWS, boundaryMap, DEFAULT_ARCHITECTURE_CONFIG,
|
||||
matchingArchitecture, readArchitectureConfig, saveArchitectureConfig,
|
||||
ARCHITECTURE_VIEWS, readArchitectureConfig, saveArchitectureConfig,
|
||||
} from './architecture-model';
|
||||
import type { ArchitectureView, ConfigStorage } from './architecture-model';
|
||||
import { architectureText as text } from './strings';
|
||||
import { architectureHistoryText as historyText, architectureText as text } from './strings';
|
||||
import { peekNavigation, type NavigationHistory } from '../graph/navigation-history';
|
||||
import { useDismissibleMenu } from '../graph/use-dismissible-menu';
|
||||
import {
|
||||
architectureEntryDetail, architectureEntryLabel, architectureEntryName, architectureHistoryOptions, initialArchitecturePlace,
|
||||
type ArchitectureHistoryEntry, type PlaceChange, type RoutesPerspective, type SpatialPlace, type SystemPlace,
|
||||
} from './architecture-history';
|
||||
import { useArchitectureHistory } from './use-architecture-history';
|
||||
import './architecture.css';
|
||||
import RepositoryMapView, { RelationshipEvidence } from './RepositoryMap';
|
||||
import { scenePaletteStyle } from './scene-palette';
|
||||
import type { RepositoryMapProps } from './RepositoryMap';
|
||||
import { repositoryMap } from './repository-map';
|
||||
import SpatialArchitecture from './SpatialArchitecture';
|
||||
import RoutesArchitecture from './RoutesArchitecture';
|
||||
import type { CoverageIndex } from '../app/tree-model';
|
||||
import type { SystemArchitectureLoader } from './system-architecture-source';
|
||||
import type { SelectionEvidenceListener } from '../galaxy/selection-evidence';
|
||||
|
||||
const SystemArchitecture = lazy(() => import('./SystemArchitecture'));
|
||||
|
||||
@@ -22,12 +28,15 @@ export interface ArchitecturePanelProps extends Pick<RepositoryMapProps, 'graph'
|
||||
projectName: string;
|
||||
graphGeneration?: string;
|
||||
active?: boolean;
|
||||
/** Another surface (help, settings, a dialog) takes the keys: Alt+Left and Alt+Right stay with it. */
|
||||
escapeTaken?: boolean;
|
||||
coverage?: CoverageIndex;
|
||||
overview?: ArchitectureOverviewDto;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
onRefresh?: () => void;
|
||||
onClearSelection?: () => void;
|
||||
onSelectionEvidence?: SelectionEvidenceListener;
|
||||
systemArchitectureLoader?: SystemArchitectureLoader;
|
||||
/** The declaration line is 1-based, as returned by the provider. */
|
||||
onNavigate: (filePath: string, line?: number, name?: string) => void;
|
||||
@@ -68,140 +77,6 @@ function SourceLink({ path, line, name, onNavigate }: {
|
||||
onClick={() => onNavigate(path, line, name)}>{path}{line !== undefined ? `:${line}` : ''}</button>;
|
||||
}
|
||||
|
||||
function BoundaryDiagram({ boundaries, onGroup }: {
|
||||
boundaries: ArchitectureBoundary[]; onGroup: (group: string) => void;
|
||||
}): JSX.Element | null {
|
||||
const markerId = useId().replace(/:/g, '');
|
||||
const map = useMemo(() => boundaryMap(boundaries), [boundaries]);
|
||||
if (map.groups.length === 0) return null;
|
||||
const positions = new Map(map.groups.map((group, index) => {
|
||||
const angle = -Math.PI / 2 + index * Math.PI * 2 / map.groups.length;
|
||||
return [group, { x: 330 + Math.cos(angle) * 215, y: 153 + Math.sin(angle) * 104 }];
|
||||
}));
|
||||
const maxCalls = Math.max(1, ...map.boundaries.map(boundary => boundary.callCount));
|
||||
return <div className="atlas-arch-map">
|
||||
<svg viewBox="0 0 660 320" role="group" aria-label={text.mapLabel}>
|
||||
<defs><marker id={markerId} markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 7 3.5, 0 7" />
|
||||
</marker></defs>
|
||||
{map.boundaries.map((boundary, index) => {
|
||||
const from = positions.get(boundary.from)!;
|
||||
const to = positions.get(boundary.to)!;
|
||||
const distance = Math.hypot(to.x - from.x, to.y - from.y);
|
||||
const dx = distance ? (to.x - from.x) / distance : 0;
|
||||
const dy = distance ? (to.y - from.y) / distance : 0;
|
||||
const start = { x: from.x + dx * 25, y: from.y + dy * 25 };
|
||||
const end = { x: to.x - dx * 30, y: to.y - dy * 30 };
|
||||
// Offset reciprocal edges so each arrow retains its direction.
|
||||
const control = { x: (start.x + end.x) / 2 - dy * 18, y: (start.y + end.y) / 2 + dx * 18 };
|
||||
const d = distance ? `M${start.x},${start.y} Q${control.x},${control.y} ${end.x},${end.y}`
|
||||
: `M${from.x - 15},${from.y - 18} C${from.x - 50},${from.y - 55} ${from.x + 50},${from.y - 55} ${from.x + 18},${from.y - 20}`;
|
||||
return <path key={`${boundary.from}:${boundary.to}:${index}`} d={d}
|
||||
markerEnd={`url(#${markerId})`} strokeWidth={1 + 2 * boundary.callCount / maxCalls}>
|
||||
<title>{`${boundary.from} → ${boundary.to}: ${boundary.callCount} ${text.calls}`}</title>
|
||||
</path>;
|
||||
})}
|
||||
{map.groups.map(group => {
|
||||
const position = positions.get(group)!;
|
||||
const label = group.length > 25 ? `${group.slice(0, 22)}...` : group;
|
||||
return <g key={group} role="button" tabIndex={0} aria-label={text.focusGroup(group)}
|
||||
onClick={() => onGroup(group)} onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onGroup(group); }
|
||||
}}>
|
||||
<title>{group}</title><circle cx={position.x} cy={position.y} r="24" />
|
||||
<text x={position.x} y={position.y + 4} data-map-count="true">{group.split(/[/.]/).filter(Boolean).at(-1)?.slice(0, 3) ?? group.slice(0, 3)}</text>
|
||||
<text x={position.x} y={position.y + 41}>{label}</text>
|
||||
</g>;
|
||||
})}
|
||||
</svg>
|
||||
{map.omittedGroups > 0 && <p className="atlas-arch-map-note">{text.mapLimit(map.groups.length, map.omittedGroups)}</p>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Overview({ data, filtered, filter, onGroup, onNavigate }: {
|
||||
data: ArchitectureOverviewDto; filtered: ArchitectureOverviewDto; filter: string;
|
||||
onGroup: (group: string) => void; onNavigate: ArchitecturePanelProps['onNavigate'];
|
||||
}): JSX.Element {
|
||||
const maxSymbols = Math.max(1, ...filtered.groups.map(group => group.symbolCount));
|
||||
const empty = filter.trim() ? text.noMatches : text.noData;
|
||||
return <>
|
||||
<div className="atlas-arch-metrics">
|
||||
{[[text.symbols, data.totalSymbols], [text.relations, data.totalRelations],
|
||||
[text.modules, data.groups.length], [text.files, data.files.length]].map(([label, count]) =>
|
||||
<div className="atlas-arch-metric" key={label}><strong>{Number(count).toLocaleString()}</strong><span>{label}</span></div>)}
|
||||
</div>
|
||||
<section>
|
||||
<SectionHeading title={text.modules} note={text.moduleNote} />
|
||||
<Collection key={`modules:${filter}`} items={filtered.groups} empty={empty}>
|
||||
{groups => <div className="atlas-arch-module-grid">{groups.map(group =>
|
||||
<button key={group.name} className="atlas-arch-module" onClick={() => onGroup(group.name)} aria-label={text.focusGroup(group.name)}>
|
||||
<strong>{group.name}</strong><span>{text.groupStats(group.symbolCount)}</span>
|
||||
<div className="atlas-arch-bar" aria-hidden="true"><i style={{ width: `${group.symbolCount / maxSymbols * 100}%` }} /></div>
|
||||
</button>)}</div>}
|
||||
</Collection>
|
||||
</section>
|
||||
<div className="atlas-arch-columns">
|
||||
<section><SectionHeading title={text.layers} note={text.layersNote} />
|
||||
<Collection key={`layers:${filter}`} items={filtered.layers} empty={empty} pageSize={8}>
|
||||
{layers => <div className="atlas-arch-detail-list">{layers.map((layer, index) =>
|
||||
<div key={`${layer.group}:${index}`} className="atlas-arch-detail"><div>
|
||||
<button className="atlas-arch-link" onClick={() => onGroup(layer.group)}>{layer.group}</button>
|
||||
{layer.reason && <p>{layer.reason}</p>}
|
||||
</div><span className="atlas-arch-badge">{layer.layer}</span></div>)}</div>}
|
||||
</Collection>
|
||||
</section>
|
||||
<section><SectionHeading title={text.clusters} note={text.clustersNote} />
|
||||
<Collection key={`clusters:${filter}`} items={filtered.clusters} empty={empty} pageSize={8}>
|
||||
{clusters => <div className="atlas-arch-detail-list">{clusters.map(cluster =>
|
||||
<div key={cluster.id} className="atlas-arch-detail"><div>
|
||||
<strong>{cluster.label || cluster.id}</strong><p>{cluster.topMembers.join(', ')}</p>
|
||||
{cluster.cohesion !== undefined && <p>{text.cohesion(cluster.cohesion)}</p>}
|
||||
</div><span className="atlas-arch-badge">{text.numberOfMembers(cluster.memberCount)}</span></div>)}</div>}
|
||||
</Collection>
|
||||
</section>
|
||||
</div>
|
||||
<section><SectionHeading title={text.filesTitle} />
|
||||
<Collection key={`files:${filter}`} items={filtered.files} empty={empty} pageSize={8}>
|
||||
{files => <div className="atlas-arch-detail-list atlas-arch-files">{files.map(file =>
|
||||
<SourceLink key={file} path={file} onNavigate={onNavigate} />)}</div>}
|
||||
</Collection>
|
||||
</section>
|
||||
<div className="atlas-arch-columns">
|
||||
<section><SectionHeading title={text.languages} /><div className="atlas-arch-count-list">{data.languages.map(language =>
|
||||
<span className="atlas-arch-badge" key={language.language}>{language.language} · {language.fileCount.toLocaleString()}</span>)}</div></section>
|
||||
<section><SectionHeading title={text.kindsTitle} /><div className="atlas-arch-count-list">{data.symbolKinds.map(kind =>
|
||||
<span className="atlas-arch-badge" key={kind.kind}>{kind.kind} · {kind.count.toLocaleString()}</span>)}</div></section>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
function Dependencies({ data, empty, onGroup, graph, onNavigate }: { data: ArchitectureOverviewDto; empty: string; onGroup: (group: string) => void } & Pick<ArchitecturePanelProps, 'graph' | 'onNavigate'>): JSX.Element {
|
||||
const boundaries = useMemo(() => [...data.boundaries].sort((a, b) => b.callCount - a.callCount), [data.boundaries]);
|
||||
const [chosen, setChosen] = useState<ArchitectureBoundary>();
|
||||
const edges = useMemo(() => graph && chosen ? repositoryMap(graph).evidence.filter(edge => edge.type === 'CALLS'
|
||||
&& edge.source.package_name === chosen.from && edge.target.package_name === chosen.to) : [], [graph, chosen]);
|
||||
return <>
|
||||
<section><SectionHeading title={text.dependencyTitle} note={text.dependencyNote} />
|
||||
<BoundaryDiagram boundaries={boundaries} onGroup={onGroup} />
|
||||
</section>
|
||||
<section><SectionHeading title={text.tableTitle} />
|
||||
<Collection items={boundaries} empty={empty} pageSize={24}>{rows =>
|
||||
<div className="atlas-arch-table-wrap"><table className="atlas-arch-table"><thead><tr>
|
||||
<th scope="col">{text.from}</th><th scope="col">{text.to}</th><th scope="col" className="atlas-arch-number">{text.calls}</th><th scope="col">{text.evidence}</th>
|
||||
</tr></thead><tbody>{rows.map((boundary, index) => <tr key={`${boundary.from}:${boundary.to}:${index}`}>
|
||||
<td><button className="atlas-arch-link" onClick={() => onGroup(boundary.from)}>{boundary.from}</button></td>
|
||||
<td><button className="atlas-arch-link" onClick={() => onGroup(boundary.to)}>{boundary.to}</button></td>
|
||||
<td className="atlas-arch-number">{boundary.callCount.toLocaleString()}</td>
|
||||
<td><button className="atlas-arch-link" onClick={() => setChosen(boundary)}>{text.inspectEdges}</button></td>
|
||||
</tr>)}</tbody></table></div>}
|
||||
</Collection>
|
||||
</section>
|
||||
{chosen && <section key={`${chosen.from}:${chosen.to}`}><SectionHeading title={`${chosen.from} → ${chosen.to}`} note={text.retainedEdges(edges.length, chosen.callCount)} />
|
||||
{edges.length ? <RelationshipEvidence edges={edges} onNavigate={onNavigate} /> : <Empty>{text.edgeEvidenceUnavailable}</Empty>}
|
||||
</section>}
|
||||
</>;
|
||||
}
|
||||
|
||||
function HotspotSignals({ hotspot }: { hotspot: ArchitectureHotspot }): JSX.Element {
|
||||
const signals = [hotspot.allocationInLoop && text.allocationInLoop,
|
||||
hotspot.scanInLoop && text.scanInLoop, hotspot.unguardedRecursion && text.unguardedRecursion].filter(Boolean);
|
||||
@@ -245,55 +120,111 @@ function Findings({ view, data, empty, onNavigate }: {
|
||||
</section>;
|
||||
}
|
||||
|
||||
function ArchitectureWorkspace({ projectName, overview, loading = false, error, onRefresh, onNavigate, graph, selection, selectionPanel, onSelect, onClearSelection, graphNote, graphGeneration, readSource, active = true, coverage, systemArchitectureLoader }: ArchitecturePanelProps): JSX.Element {
|
||||
const paletteStyle = scenePaletteStyle();
|
||||
|
||||
/** Compact exploration history beside the current scope. Tooltips name each destination. */
|
||||
function HistoryControls({ history, place, onGo, onJump }: {
|
||||
history: NavigationHistory<ArchitectureHistoryEntry>; place: ArchitectureHistoryEntry;
|
||||
onGo: (step: -1 | 1) => void; onJump: (entry: ArchitectureHistoryEntry) => void;
|
||||
}): JSX.Element {
|
||||
const back = peekNavigation(history, -1), forward = peekNavigation(history, 1);
|
||||
const here = architectureHistoryOptions.recentKey?.(place);
|
||||
const menu = useDismissibleMenu(architectureHistoryOptions.key(place));
|
||||
return <div className="atlas-arch-history" role="group" aria-label={historyText.group} data-position={`${history.index + 1}/${history.entries.length}`}>
|
||||
<button type="button" aria-label={historyText.back} disabled={!back} onClick={() => onGo(-1)}
|
||||
title={back ? historyText.backTo(architectureEntryLabel(back)) : historyText.noBack}>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m12 5-5 5 5 5" /></svg></button>
|
||||
<button type="button" aria-label={historyText.forward} disabled={!forward} onClick={() => onGo(1)}
|
||||
title={forward ? historyText.forwardTo(architectureEntryLabel(forward)) : historyText.noForward}>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m8 5 5 5-5 5" /></svg></button>
|
||||
{history.recent.length > 1 && <details className="atlas-arch-recent" ref={menu.ref}>
|
||||
<summary title={historyText.recentTitle} aria-label={historyText.recent}><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 6a7 7 0 1 1-1 7M4 2v4H0m10 0v4l3 2" /></svg></summary>
|
||||
<ul className="atlas-arch-recent-menu" aria-label={historyText.recentList}>{history.recent.map(entry => {
|
||||
const id = architectureHistoryOptions.recentKey?.(entry);
|
||||
const current = id === here;
|
||||
const detail = architectureEntryDetail(entry);
|
||||
return <li key={id}>
|
||||
<button type="button" disabled={current} aria-current={current ? 'true' : undefined} title={architectureEntryLabel(entry)} onClick={() => {
|
||||
menu.close();
|
||||
onJump(entry);
|
||||
}}><strong>{architectureEntryName(entry)}</strong>{detail && <span>{detail}</span>}</button>
|
||||
</li>;
|
||||
})}</ul>
|
||||
</details>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ArchitectureWorkspace({ projectName, overview, loading = false, error, onRefresh, onNavigate, graph, selectionPanel, onSelect, onClearSelection, onSelectionEvidence, graphNote, graphGeneration, active = true, escapeTaken = false, coverage, systemArchitectureLoader }: ArchitecturePanelProps): JSX.Element {
|
||||
const storage = useMemo(browserStorage, []);
|
||||
const [config, setConfig] = useState(() => readArchitectureConfig(storage, projectName));
|
||||
const [saved, setSaved] = useState(true);
|
||||
useEffect(() => { setSaved(saveArchitectureConfig(storage, projectName, config)); }, [config, projectName, storage]);
|
||||
/*
|
||||
* The place is the whole history entry (K27): subtab, opened area or file,
|
||||
* route perspective and filter, System structure focus and groups, Behavior
|
||||
* start, destination and position, and Plan or 3D of each scene. The views
|
||||
* below read their part of it and report changes back, so Back and Forward
|
||||
* restore every part at once. Only deliberate exploration adds a stop;
|
||||
* tabs and display controls update the current visit. A filter saved by an earlier
|
||||
* session would silently hide routes, so only the subtab is read back.
|
||||
*/
|
||||
const navigation = useArchitectureHistory(() => initialArchitecturePlace(readArchitectureConfig(storage, projectName).view), {
|
||||
active, escapeTaken, onRestore: (from, to) => { if (from.view !== to.view) onSelectionEvidence?.(undefined); },
|
||||
});
|
||||
const { place, navigate } = navigation;
|
||||
useEffect(() => { saveArchitectureConfig(storage, projectName, { view: place.view, filter: place.filter }); }, [place.view, place.filter, projectName, storage]);
|
||||
const setView = useCallback((view: ArchitectureView) => navigate(current => ({ ...current, view: view === 'dependencies' ? 'overview' : view }), true), [navigate]);
|
||||
const changeSpatial = useCallback<PlaceChange<SpatialPlace>>((change, automatic) =>
|
||||
navigate(current => ({ ...current, spatial: { ...current.spatial, ...change } }), automatic), [navigate]);
|
||||
const changeSystem = useCallback<PlaceChange<SystemPlace>>((change, automatic) =>
|
||||
navigate(current => ({ ...current, system: { ...current.system, ...change } }), automatic), [navigate]);
|
||||
const changePerspective = useCallback((routes: RoutesPerspective) => navigate(current => ({ ...current, routes }), true), [navigate]);
|
||||
// Opening a route group is navigation; typing only refines the current visit.
|
||||
const openRouteGroup = useCallback((filter: string) => navigate(current => ({ ...current, filter })), [navigate]);
|
||||
// A cached summary from another project must never appear here.
|
||||
const data = overview?.projectName && overview.projectName !== projectName ? undefined : overview;
|
||||
const filtered = useMemo(() => data ? matchingArchitecture(data, config.filter) : undefined, [data, config.filter]);
|
||||
const onGroup = (group: string) => setConfig({ view: 'overview', filter: group });
|
||||
const empty = config.filter.trim() ? text.noMatches : text.noData;
|
||||
const ready = Boolean(data && filtered && !loading && !error && projectName);
|
||||
const systemView = config.view === 'structure' || config.view === 'behavior' ? config.view : undefined;
|
||||
const SpatialView = config.view === 'routes' ? RoutesArchitecture : SpatialArchitecture;
|
||||
return <section className="atlas-architecture" data-testid="atlas-architecture" data-system-view={systemView} aria-label={text.title} aria-busy={!systemView && loading}>
|
||||
<nav className="atlas-arch-tabs" aria-label={text.navigation}>{ARCHITECTURE_VIEWS.map(view =>
|
||||
<button className="atlas-arch-tab" key={view} aria-pressed={config.view === view || (view === 'overview' && ['dependencies', 'entryPoints'].includes(config.view))} data-view={view}
|
||||
onClick={() => setConfig(current => ({ ...current, view }))}>{text.views[view]}</button>)}</nav>
|
||||
<div className="atlas-arch-toolbar">
|
||||
<label className="atlas-arch-filter"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="6.5" cy="6.5" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.4" /><path d="m10 10 4 4" stroke="currentColor" strokeWidth="1.4" /></svg>
|
||||
<input aria-label={text.filter} placeholder={text.filterPlaceholder} type="search" value={config.filter}
|
||||
onChange={event => setConfig(current => ({ ...current, filter: event.target.value }))} />
|
||||
</label><span className="atlas-arch-saved">{saved ? text.saved : text.sessionOnly}</span>
|
||||
<button className="atlas-arch-action" onClick={() => setConfig({ ...DEFAULT_ARCHITECTURE_CONFIG })}>{text.reset}</button>
|
||||
const ready = Boolean(data && !loading && !error && projectName);
|
||||
const systemView = place.view === 'structure' || place.view === 'behavior' ? place.view : undefined;
|
||||
useEffect(() => {
|
||||
if (active && (!projectName || (!systemView && (!ready || !graph)))) onSelectionEvidence?.(undefined);
|
||||
}, [active, projectName, systemView, ready, graph, onSelectionEvidence]);
|
||||
// Only the Routes view offers the filter, so no other view may receive a stale one.
|
||||
const filter = place.view === 'routes' ? navigation.filter : '';
|
||||
const historyControls = <HistoryControls history={navigation.history} place={place} onGo={navigation.go} onJump={navigation.jump} />;
|
||||
const spatialProps = !systemView && ready && data && graph ? {
|
||||
project: projectName, generation: graphGeneration, graph, overview: data,
|
||||
view: place.view === 'dependencies' || place.view === 'structure' || place.view === 'behavior' ? 'overview' as const : place.view,
|
||||
filter, onFilter: openRouteGroup, active, graphNote, coverage, onSelect, onClearSelection, onSelectionEvidence, selectionPanel, onNavigate, navigation: historyControls,
|
||||
onView: (view: ArchitectureView) => { onSelectionEvidence?.(undefined); setView(view); }, place: place.spatial, onPlace: changeSpatial,
|
||||
} : undefined;
|
||||
// Every scene below draws with the same palette; the DOM reads it as CSS variables.
|
||||
return <section className="atlas-architecture" data-testid="atlas-architecture" data-system-view={systemView} aria-label={text.title} aria-busy={!systemView && loading} style={paletteStyle}>
|
||||
<div className="atlas-arch-tabrow">
|
||||
<nav className="atlas-arch-tabs" aria-label={text.navigation}>{ARCHITECTURE_VIEWS.map(view =>
|
||||
<button className="atlas-arch-tab" key={view} aria-pressed={place.view === view || (view === 'overview' && ['dependencies', 'entryPoints'].includes(place.view))} data-view={view}
|
||||
onClick={() => { if (view !== place.view) onSelectionEvidence?.(undefined); setView(view); }}>{text.views[view]}</button>)}</nav>
|
||||
</div>
|
||||
{systemView && projectName ? <Suspense fallback={<div role="status"><Empty>Preparing system analysis…</Empty></div>}><SystemArchitecture
|
||||
project={projectName} generation={graphGeneration} view={systemView} filter={config.filter} active={active}
|
||||
graph={graph} onSelect={onSelect} onClearSelection={onClearSelection} onNavigate={onNavigate} loader={systemArchitectureLoader} /></Suspense> : null}
|
||||
project={projectName} generation={graphGeneration} view={systemView} filter={filter} active={active}
|
||||
graph={graph} onSelect={onSelect} onClearSelection={onClearSelection} onSelectionEvidence={onSelectionEvidence} onNavigate={onNavigate} loader={systemArchitectureLoader}
|
||||
place={place.system} onPlace={changeSystem} navigation={historyControls} /></Suspense> : null}
|
||||
{!systemView && !spatialProps && <div className="atlas-arch-context">{historyControls}<span>{projectName}</span></div>}
|
||||
{!systemView && (!projectName ? <Empty>{text.chooseProject}</Empty> : loading ? <div role="status"><Empty>{text.loading}</Empty></div>
|
||||
: error ? <div role="alert" className="atlas-arch-empty" data-error="true"><p>{text.loadFailed}</p><p className="atlas-arch-error-detail">{error}</p>
|
||||
{onRefresh && <button className="atlas-arch-action" onClick={onRefresh}>{text.retry}</button>}</div>
|
||||
: !data ? <Empty>{text.unavailable}</Empty> : null)}
|
||||
{systemView && !projectName && <Empty>{text.chooseProject}</Empty>}
|
||||
{!systemView && ready && data && graph && <SpatialView project={projectName} generation={graphGeneration} graph={graph} overview={data}
|
||||
view={config.view === 'dependencies' || config.view === 'structure' || config.view === 'behavior' ? 'overview' : config.view} filter={config.filter} active={active}
|
||||
graphNote={graphNote} coverage={coverage} onSelect={onSelect} onClearSelection={onClearSelection} selectionPanel={selectionPanel} onNavigate={onNavigate} onView={view => setConfig(current => ({ ...current, view: view === 'dependencies' ? 'overview' : view }))} />}
|
||||
{!systemView && ready && data && filtered && <div className="atlas-arch-content" key={`${config.view}:${config.filter}:${graphGeneration ?? ""}`} data-testid="atlas-architecture-content">
|
||||
<details open={!graph}><summary>Source guide and complete findings</summary>
|
||||
{config.view === 'overview' ? <><RepositoryMapView graph={graph} graphNote={graphNote} overview={data} filter={config.filter} onNavigate={onNavigate} onSelect={onSelect} selection={selection} selectionPanel={selectionPanel} readSource={readSource} />
|
||||
<details data-testid="architecture-dependency-details"><summary>Dependency details</summary><Dependencies data={filtered} empty={empty} onGroup={onGroup} graph={graph} onNavigate={onNavigate} /></details>
|
||||
<details data-testid="architecture-entry-details"><summary>Entry-point source evidence</summary><Findings view="entryPoints" data={filtered} empty={empty} onNavigate={onNavigate} /></details>
|
||||
<details><summary>{text.summaryDetails}</summary><Overview data={data} filtered={filtered} filter={config.filter} onGroup={onGroup} onNavigate={onNavigate} /></details></>
|
||||
: config.view === 'dependencies' ? <Dependencies data={filtered} empty={empty} onGroup={onGroup} graph={graph} onNavigate={onNavigate} />
|
||||
: <Findings view={config.view} data={filtered} empty={empty} onNavigate={onNavigate} />}
|
||||
</details>
|
||||
{spatialProps && place.view === 'routes' && <div className="atlas-arch-toolbar"><label className="atlas-arch-filter">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="6.5" cy="6.5" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.4" /><path d="m10 10 4 4" stroke="currentColor" strokeWidth="1.4" /></svg>
|
||||
<input aria-label={text.filter} placeholder={text.filterPlaceholder} type="search" value={navigation.filter} onChange={event => navigation.type(event.target.value)} /></label></div>}
|
||||
{spatialProps && (place.view === 'routes'
|
||||
? <RoutesArchitecture {...spatialProps} perspective={place.routes} onPerspective={changePerspective} />
|
||||
: <SpatialArchitecture {...spatialProps} />)}
|
||||
{!systemView && ready && data && !graph && <div className="atlas-arch-content" data-testid="atlas-architecture-content">
|
||||
<p className="atlas-arch-fallback-summary">{data.files.length.toLocaleString()} files · {data.groups.length.toLocaleString()} source areas · {data.boundaries.length.toLocaleString()} cross-area connections</p>
|
||||
<Findings view={place.view === 'overview' || place.view === 'dependencies' ? 'entryPoints' : place.view} data={data} empty={text.noData} onNavigate={onNavigate} />
|
||||
</div>}
|
||||
{!systemView && ready && <details className="atlas-arch-evidence"><summary>{text.evidenceSummary}</summary><p>{text.evidenceDetail}</p></details>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
/** A project switch remounts the workspace: a fresh history, as in Galaxy. */
|
||||
export default function ArchitecturePanel(props: ArchitecturePanelProps): JSX.Element {
|
||||
return <ArchitectureWorkspace key={props.projectName} {...props} />;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useEffect, useMemo, useRef, useState, type RefObject } from 'react';
|
||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||
import { Edges, Html, OrbitControls, OrthographicCamera } from '@react-three/drei';
|
||||
import { Box3, Color, CubicBezierCurve3, MOUSE, TOUCH, OrthographicCamera as ThreeOrthographicCamera, Quaternion, QuadraticBezierCurve3, Vector2, Vector3 } from 'three';
|
||||
import { Box3, Color, CubicBezierCurve3, MOUSE, TOUCH, OrthographicCamera as ThreeOrthographicCamera, QuadraticBezierCurve3, Vector2, Vector3 } from 'three';
|
||||
import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib';
|
||||
import type { SemanticEdge, SemanticGraph, SemanticNode, SemanticPlatform } from './semantic-graph';
|
||||
import { languageColor, measureSourceNode, sourceBrickHeight, sourceLanguage, sourceNodeSizePercent, type SourceCatalog, type SourceMeasure } from './source-metrics';
|
||||
import { languageColor, measureSourceNode, scopeBrickHeights, sourceLanguage, sourceNodeSizePercent, type SourceCatalog, type SourceMeasure } from './source-metrics';
|
||||
import { gravityPercent, gravityStrength, hotspotsForNode, type HotspotCatalog, type HotspotGroup } from './hotspot-map';
|
||||
import { labelEdges, labelInset, placeSceneLabels, type LabelBox, type SceneChip, type SecondaryLabel } from './label-space';
|
||||
import { edgeColor, isDirectedEdge } from '../graph/edge-style';
|
||||
import { EdgePulseLayer } from '../graph/EdgePulseLayer';
|
||||
import './architecture-scene.css';
|
||||
import { useGraphBackgroundReset } from '../graph/useGraphBackgroundReset';
|
||||
import { SCENE_MAX_TILT, SCENE_PALETTE } from './scene-palette';
|
||||
|
||||
export interface ArchitectureSceneProps {
|
||||
model: SemanticGraph;
|
||||
@@ -47,17 +49,29 @@ function centerOf(node: RenderNode): Vector3 {
|
||||
return new Vector3(...node.position).add(new Vector3(0, dimensions(node)[1] / 2, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* The corners a platform name may take, relative to the platform centre: the
|
||||
* front-left corner first (where it always sat), then the others. The label
|
||||
* manager picks the first one no chip or other name covers.
|
||||
*/
|
||||
function folderSpots(platform: SemanticPlatform): { local: [number, number, number]; align: 'start' | 'end' }[] {
|
||||
const x = platform.width / 2 - 3, front = platform.depth / 2 - 2, back = -platform.depth / 2 + 2;
|
||||
return [{ local: [-x, 0.12, front], align: 'start' }, { local: [-x, 0.12, back], align: 'start' },
|
||||
{ local: [x, 0.12, front], align: 'end' }, { local: [x, 0.12, back], align: 'end' }];
|
||||
}
|
||||
|
||||
/** Folder containment is scenery, never an extra dependency or selectable symbol. */
|
||||
function FolderPlatform({ platform }: { platform: SemanticPlatform }) {
|
||||
function FolderPlatform({ platform, spot = 0 }: { platform: SemanticPlatform; spot?: number }) {
|
||||
const place = folderSpots(platform)[Math.max(0, spot)]!;
|
||||
return <group position={platform.position}>
|
||||
<mesh position={[0, -0.225, 0]} raycast={() => null}>
|
||||
<boxGeometry args={[platform.width, 0.45, platform.depth]} />
|
||||
<meshStandardMaterial color="#536977" roughness={0.95} transparent opacity={0.11 + platform.level * 0.015} depthWrite={false} />
|
||||
<Edges color="#6e8a98" transparent opacity={0.3} />
|
||||
<meshStandardMaterial color={SCENE_PALETTE.plate} roughness={0.95} transparent opacity={0.11 + platform.level * 0.015} depthWrite={false} />
|
||||
<Edges color={SCENE_PALETTE.plateEdge} transparent opacity={0.3} />
|
||||
</mesh>
|
||||
<Html position={[-platform.width / 2 + 3, 0.12, platform.depth / 2 - 2]} zIndexRange={[5, 1]} style={{ pointerEvents: 'none' }}>
|
||||
<span className="architecture-folder-label" data-folder-path={platform.path} data-level={platform.level}
|
||||
data-position={platform.position.join(',')} data-width={platform.width} data-depth={platform.depth}
|
||||
<Html position={place.local} zIndexRange={[5, 1]} style={{ pointerEvents: 'none' }}>
|
||||
<span className={`architecture-folder-label${spot < 0 ? ' is-hidden' : ''}`} data-folder-id={platform.id} data-folder-path={platform.path} data-level={platform.level}
|
||||
data-position={platform.position.join(',')} data-width={platform.width} data-depth={platform.depth} data-align={place.align}
|
||||
title={platform.path}>{platform.path ? platform.path.split('/').at(-1) : platform.label}</span>
|
||||
</Html>
|
||||
</group>;
|
||||
@@ -88,10 +102,7 @@ function relationshipGeometry(source: RenderNode, target: RenderNode, laneOffset
|
||||
const curve = source.id === target.id
|
||||
? new CubicBezierCurve3(start.clone().add(new Vector3(1, 1, 0)), start.clone().add(new Vector3(10 + laneOffset, 10, 7)), start.clone().add(new Vector3(-10 - laneOffset, 10, 7)), end.clone().add(new Vector3(-1, 1, 0)))
|
||||
: new QuadraticBezierCurve3(start, midpoint, end);
|
||||
const arrowAt = 0.79;
|
||||
return { curve, points: curve.getPoints(32), arrowPosition: curve.getPoint(arrowAt),
|
||||
arrowRotation: new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), curve.getTangent(arrowAt).normalize()),
|
||||
labelPosition: curve.getPoint(0.5) };
|
||||
return { curve, points: curve.getPoints(32), labelPosition: curve.getPoint(0.5) };
|
||||
}
|
||||
|
||||
/** Direction comes from relationship semantics, never navigation or selection. */
|
||||
@@ -100,8 +111,8 @@ function Relationship({ edge, geometry, selected, emphasized, dimmed, showLabel,
|
||||
selected: boolean; emphasized: boolean; dimmed: boolean; showLabel: boolean;
|
||||
onSelect: () => void; onHover: (id?: string) => void;
|
||||
}) {
|
||||
const { curve, arrowPosition, arrowRotation, labelPosition } = geometry;
|
||||
const color = edgeColor(edge.type);
|
||||
const { curve, labelPosition } = geometry;
|
||||
const color = edge.tint ?? edgeColor(edge.type);
|
||||
const opacity = dimmed ? 0.06 : selected ? 1 : emphasized ? 0.95 : 0.55;
|
||||
return <group>
|
||||
<mesh onClick={event => { event.stopPropagation(); onSelect(); }}
|
||||
@@ -114,11 +125,6 @@ function Relationship({ edge, geometry, selected, emphasized, dimmed, showLabel,
|
||||
<tubeGeometry args={[curve, 24, selected ? 0.11 : emphasized ? 0.085 : 0.055, 4, false]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={opacity} depthWrite={false} />
|
||||
</mesh>
|
||||
{isDirectedEdge(edge.type) && <mesh position={arrowPosition} quaternion={arrowRotation}
|
||||
onClick={event => { event.stopPropagation(); onSelect(); }}>
|
||||
<coneGeometry args={[selected || emphasized ? 0.42 : 0.28, 1.1, 6]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={Math.min(1, opacity + 0.18)} />
|
||||
</mesh>}
|
||||
{showLabel && !dimmed && <Html position={labelPosition} center zIndexRange={[30, 20]}>
|
||||
<button type="button" className="architecture-edge-label" onClick={onSelect}
|
||||
style={{ borderColor: color }} aria-label={`Inspect ${edge.type} relationship, ${edge.count} indexed edges`}>
|
||||
@@ -135,7 +141,7 @@ function ArchitectureNode({ node, selected, highlighted, dimmed, labelVisible =
|
||||
onOpen?: () => void;
|
||||
}) {
|
||||
const color = node.tint ?? PALETTE[node.kind];
|
||||
const fill = useMemo(() => new Color('#27333e').lerp(new Color(color), selected ? 0.34 : highlighted ? 0.25 : 0.18), [color, selected, highlighted]);
|
||||
const fill = useMemo(() => new Color(SCENE_PALETTE.body).lerp(new Color(color), selected ? 0.34 : highlighted ? 0.25 : 0.18), [color, selected, highlighted]);
|
||||
const size = dimensions(node);
|
||||
const labelY = size[1] + 7;
|
||||
const measure = node.measure;
|
||||
@@ -145,6 +151,8 @@ function ArchitectureNode({ node, selected, highlighted, dimmed, labelVisible =
|
||||
const roofLanguages = node.tint && mixed ? measure!.languages.slice(0, 4) : [];
|
||||
const roofTotal = measure?.lines || measure?.files || 1;
|
||||
let roofOffset = -size[0] / 2;
|
||||
// Labels name a part relative to the opened area; the tooltip keeps its full path.
|
||||
const path = node.kind === 'area' || node.kind === 'file' ? node.filePath ?? node.areaPath : undefined;
|
||||
return <group position={node.position}>
|
||||
<mesh position={[0, size[1] / 2, 0]}
|
||||
onClick={event => { event.stopPropagation(); onSelect(); }}
|
||||
@@ -157,7 +165,7 @@ function ArchitectureNode({ node, selected, highlighted, dimmed, labelVisible =
|
||||
<meshStandardMaterial color={fill} roughness={0.85} metalness={0.05}
|
||||
emissive={color} emissiveIntensity={selected ? 0.08 : highlighted ? 0.035 : 0.008}
|
||||
transparent opacity={dimmed ? 0.14 : selected ? 0.65 : highlighted ? 0.55 : 0.42} depthWrite={false} />
|
||||
<Edges color={selected ? '#f0fff9' : color} transparent opacity={dimmed ? 0.12 : selected ? 1 : highlighted ? 0.85 : 0.48} />
|
||||
<Edges color={selected ? SCENE_PALETTE.selected : color} transparent opacity={dimmed ? 0.12 : selected ? 1 : highlighted ? 0.85 : 0.48} />
|
||||
</mesh>
|
||||
{roofLanguages.map(language => {
|
||||
const width = size[0] * (measure?.lines ? language.lines : language.files) / roofTotal;
|
||||
@@ -168,7 +176,7 @@ function ArchitectureNode({ node, selected, highlighted, dimmed, labelVisible =
|
||||
})}
|
||||
{selected && <mesh position={[0, 0.05, 0]} rotation={[-Math.PI / 2, 0, 0]} raycast={() => null}>
|
||||
<ringGeometry args={[Math.max(size[0], size[2]) * 0.65, Math.max(size[0], size[2]) * 0.65 + 0.12, 64]} />
|
||||
<meshBasicMaterial color="#ecfff7" transparent opacity={0.85} depthWrite={false} />
|
||||
<meshBasicMaterial color={SCENE_PALETTE.selected} transparent opacity={0.85} depthWrite={false} />
|
||||
</mesh>}
|
||||
<Html position={[0, labelY, 0]} center zIndexRange={selected ? [60, 50] : [20, 10]}>
|
||||
<button type="button" className={`architecture-node-label${selected ? ' is-selected' : ''}${dimmed ? ' is-dimmed' : ''}${labelVisible ? '' : ' is-hidden'}`}
|
||||
@@ -176,9 +184,9 @@ function ArchitectureNode({ node, selected, highlighted, dimmed, labelVisible =
|
||||
data-node-id={node.id} data-position={node.position.join(',')} data-label-visible={labelVisible}
|
||||
data-lines={measure?.lines} data-height={size[1]} data-language={measure?.languages[0]?.name} data-gravity={node.gravity ?? 0} data-size-percent={node.sizePercent} data-gravity-percent={node.gravityPercent}
|
||||
onPointerEnter={() => onHover(node.id)} onPointerLeave={() => onHover()}
|
||||
title={`${node.label}\n${node.detail}${measure ? `\n${lineText}\n${measure.languages.map(language => language.name).join(', ')} (from file types)` : ''}`}>
|
||||
title={`${node.label}${path && path !== node.label ? `\n${path}` : ''}\n${node.detail}${measure ? `\n${lineText}\n${measure.languages.map(language => language.name).join(', ')} (from file types)` : ''}`}>
|
||||
{selected && <span className="architecture-node-kind"><i style={{ background: color }} />{node.kindLabel ?? (node.kind === 'area' ? 'Source area' : node.kind)}</span>}
|
||||
<span className="architecture-node-title"><strong>{node.hotspot && <span className="architecture-hotspot-mark" title={`${node.hotspot.findings.length} ranked hotspots; strongest measured fan-in ${node.hotspot.maxFanIn ?? 'unavailable'}${node.gravityPercent === undefined ? '' : `; gravity ${Number(node.gravityPercent.toFixed(1))}%`}`}>◉ </span>}{node.label}</strong>{!measure && node.gravityPercent !== undefined && <small className="architecture-node-size" title="Gravity relative to strongest hotspot">{node.gravityPercent > 0 && node.gravityPercent < 0.1 ? '<0.1' : Number(node.gravityPercent.toFixed(1))}%</small>}</span>
|
||||
<span className="architecture-node-title"><strong>{node.hotspot && <span className="architecture-hotspot-mark" title={`${node.hotspot.findings.length} ranked hotspots; strongest measured fan-in ${node.hotspot.maxFanIn ?? 'unavailable'}${node.gravityPercent === undefined ? '' : `; gravity ${Number(node.gravityPercent.toFixed(1))}%`}`}>◉ </span>}{node.shortLabel ?? node.label}</strong>{!measure && node.gravityPercent !== undefined && <small className="architecture-node-size" title="Gravity relative to strongest hotspot">{node.gravityPercent > 0 && node.gravityPercent < 0.1 ? '<0.1' : Number(node.gravityPercent.toFixed(1))}%</small>}</span>
|
||||
{selected && measure && <span className="architecture-node-metric"><i style={{ background: color }} />{measure.lines === undefined ? '?' : `${measure.measuredFiles < measure.files ? '≥' : ''}${Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(measure.lines)}`} lines{mixed ? ' · mixed' : ''}</span>}
|
||||
{selected && <span className="architecture-node-detail">{node.detail}</span>}
|
||||
</button>
|
||||
@@ -186,26 +194,73 @@ function ArchitectureNode({ node, selected, highlighted, dimmed, labelVisible =
|
||||
</group>;
|
||||
}
|
||||
|
||||
/** Screen-space label density changes with zoom; source positions never do. */
|
||||
function HotspotLabels({ model, priorityId, onVisible }: { model: RenderGraph; priorityId?: string; onVisible: (ids: Set<string>) => void }) {
|
||||
const { camera, size } = useThree();
|
||||
/** Folder names claim space outermost first; the lane of outside areas first of all. */
|
||||
const folderPriority = (a: SemanticPlatform, b: SemanticPlatform) => Number(b.id === 'hierarchy:outside') - Number(a.id === 'hierarchy:outside')
|
||||
|| a.level - b.level || b.width * b.depth - a.width * a.depth || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
||||
|
||||
/**
|
||||
* Screen-space label density changes with zoom; source positions never do.
|
||||
* Label boxes are measured once per model, so culling follows the CSS of the
|
||||
* map that shows them (the compact Overview labels are far smaller than the
|
||||
* service map's); the estimate only covers a label not laid out yet.
|
||||
*
|
||||
* Without culling every chip stays, but the folder names still go where no
|
||||
* chip and no other name lies: the chips are placed first, then each folder
|
||||
* name takes a free corner of its platform or hides.
|
||||
*/
|
||||
function SceneLabels({ model, priorityId, selectedId, cull, onVisible, onFolders }: {
|
||||
model: RenderGraph; priorityId?: string; selectedId?: string; cull: boolean;
|
||||
onVisible: (ids: Set<string>) => void; onFolders: (spots: Map<string, number>) => void;
|
||||
}) {
|
||||
const { camera, size, gl, events, invalidate } = useThree();
|
||||
// Html labels mount where drei puts them: the event source, else the canvas parent.
|
||||
const labelHost = (events.connected as HTMLElement | undefined) ?? gl.domElement.parentElement;
|
||||
const previous = useRef('');
|
||||
const previousFolders = useRef('');
|
||||
const boxes = useRef(new Map<string, LabelBox>());
|
||||
const folderBoxes = useRef(new Map<string, { width: number; height: number }>());
|
||||
const attempts = useRef(0);
|
||||
useEffect(() => { boxes.current.clear(); folderBoxes.current.clear(); attempts.current = 0; }, [model]);
|
||||
useFrame(() => {
|
||||
const occupied: { left: number; right: number; top: number; bottom: number }[] = [];
|
||||
const ids = new Set<string>();
|
||||
const ordered = [...model.nodes].sort((a, b) => Number(b.id === priorityId) - Number(a.id === priorityId));
|
||||
for (const node of ordered) {
|
||||
const point = new Vector3(...node.position).add(new Vector3(0, dimensions(node)[1] + 7, 0)).project(camera);
|
||||
const x = (point.x + 1) * size.width / 2; const y = (1 - point.y) * size.height / 2;
|
||||
const width = Math.min(178, Math.max(78, node.label.length * 7 + (node.gravityPercent === undefined ? 26 : 68)));
|
||||
const halfHeight = node.id === priorityId ? 48 : 19;
|
||||
const rect = { left: x - width / 2 - 5, right: x + width / 2 + 5, top: y - halfHeight, bottom: y + halfHeight };
|
||||
if (node.id !== priorityId && (rect.right < 0 || rect.left > size.width || rect.bottom < 0 || rect.top > size.height
|
||||
|| occupied.some(other => rect.left < other.right && rect.right > other.left && rect.top < other.bottom && rect.bottom > other.top))) continue;
|
||||
ids.add(node.id); occupied.push(rect);
|
||||
const platforms = model.platforms ?? [];
|
||||
const unmeasured = () => model.nodes.some(node => !boxes.current.has(node.id)) || platforms.some(platform => !folderBoxes.current.has(platform.id));
|
||||
if (unmeasured()) {
|
||||
labelHost?.querySelectorAll<HTMLElement>('.architecture-node-label[data-node-id]').forEach(label => {
|
||||
// A selected label carries extra rows; it is the priority label and keeps its reserve below.
|
||||
if (!label.offsetWidth || label.classList.contains('is-selected')) return;
|
||||
const [edgeX, edgeY] = labelEdges(getComputedStyle(label));
|
||||
boxes.current.set(label.dataset.nodeId!, { width: label.offsetWidth, height: label.offsetHeight, edgeX, edgeY });
|
||||
});
|
||||
labelHost?.querySelectorAll<HTMLElement>('.architecture-folder-label[data-folder-id]').forEach(label => {
|
||||
if (label.offsetWidth) folderBoxes.current.set(label.dataset.folderId!, { width: label.offsetWidth, height: label.offsetHeight });
|
||||
});
|
||||
}
|
||||
// A measured label may overlap a neighbour by its padding, never by its text.
|
||||
const inset = labelInset(boxes.current.values());
|
||||
// Hotspot wells, then larger parts, claim their label space first.
|
||||
const chips: SceneChip[] = [...model.nodes].sort((a, b) => (b.gravity ?? 0) - (a.gravity ?? 0) || b.count - a.count).map(node => {
|
||||
const point = new Vector3(...node.position).add(new Vector3(0, dimensions(node)[1] + 7, 0)).project(camera);
|
||||
const box = boxes.current.get(node.id);
|
||||
return { id: node.id, x: (point.x + 1) * size.width / 2, y: (1 - point.y) * size.height / 2,
|
||||
width: box?.width ?? Math.min(178, Math.max(78, (node.shortLabel ?? node.label).length * 7 + (node.gravityPercent === undefined ? 26 : 68))),
|
||||
height: box?.height ?? 38, measured: Boolean(box) };
|
||||
});
|
||||
const names: SecondaryLabel[] = [...platforms].sort(folderPriority).map(platform => {
|
||||
const measured = folderBoxes.current.get(platform.id);
|
||||
const text = platform.path ? platform.path.split('/').at(-1) ?? '' : platform.label;
|
||||
return { id: platform.id, width: measured?.width ?? Math.min(150, text.length * 6.5 + 4), height: measured?.height ?? 14,
|
||||
spots: folderSpots(platform).map(({ local, align }) => {
|
||||
const point = new Vector3(...platform.position).add(new Vector3(...local)).project(camera);
|
||||
return { x: (point.x + 1) * size.width / 2, y: (1 - point.y) * size.height / 2, align };
|
||||
}) };
|
||||
});
|
||||
const { visible: ids, folders: spots } = placeSceneLabels(chips, names, { priorityId, selectedId, cull, width: size.width, height: size.height, inset });
|
||||
const key = [...ids].sort().join('|');
|
||||
if (previous.current !== key) { previous.current = key; onVisible(ids); }
|
||||
const folderKey = [...spots].map(([id, spot]) => `${id}:${spot}`).join('|');
|
||||
if (previousFolders.current !== folderKey) { previousFolders.current = folderKey; onFolders(spots); }
|
||||
// Labels mount a moment after the scene; a few extra frames pick up their real size.
|
||||
if (unmeasured() && attempts.current++ < 8) invalidate();
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -264,19 +319,24 @@ function FitArchitecture({ model, planar, resetKey, controls, active }: {
|
||||
}
|
||||
|
||||
export function ArchitectureScene({ model: graphModel, selectedId, selectedEdgeId, onSelect, onSelectEdge, onOpen, onClearSelection, active = true, planar = false, resetKey = 0, catalog, heightMetric = 'uniform', colorMetric = 'kind', hotspots, showHotspots = true, adaptiveLabels = false }: ArchitectureSceneProps) {
|
||||
const model: RenderGraph = useMemo(() => ({ ...graphModel, nodes: graphModel.nodes.map(node => {
|
||||
const measure = catalog ? measureSourceNode(node, catalog) : undefined;
|
||||
const sizePercent = catalog ? sourceNodeSizePercent(node, catalog) : undefined;
|
||||
const language = measure?.languages[0]?.name ?? (node.filePath ? sourceLanguage(node.filePath) : undefined);
|
||||
const hotspot = showHotspots && hotspots ? hotspotsForNode(node, hotspots) : undefined;
|
||||
return { ...node, measure, sizePercent, hotspot, gravity: hotspot && hotspots ? gravityStrength(hotspot.maxFanIn, hotspots.maxFanIn) : 0,
|
||||
gravityPercent: hotspot && hotspots ? gravityPercent(hotspot.maxFanIn, hotspots.maxFanIn) : undefined,
|
||||
height: catalog && heightMetric === 'lines' && ['area', 'file'].includes(node.kind) ? node.kind === 'area' && node.areaPath === '(root)' ? 0.35 : sourceBrickHeight(sizePercent, 100) : undefined,
|
||||
tint: node.tint ?? (colorMetric === 'language' && node.kind !== 'route' ? languageColor(language ?? 'Unknown') : undefined) };
|
||||
}) }), [graphModel, catalog, heightMetric, colorMetric, hotspots, showHotspots]);
|
||||
const model: RenderGraph = useMemo(() => {
|
||||
const measures = new Map(graphModel.nodes.map(node => [node.id, catalog ? measureSourceNode(node, catalog) : undefined]));
|
||||
const heights = catalog && heightMetric === 'lines' ? scopeBrickHeights(graphModel.nodes, node => measures.get(node.id)?.lines) : undefined;
|
||||
return { ...graphModel, nodes: graphModel.nodes.map(node => {
|
||||
const measure = measures.get(node.id);
|
||||
const sizePercent = catalog ? sourceNodeSizePercent(node, catalog) : undefined;
|
||||
const language = measure?.languages[0]?.name ?? (node.filePath ? sourceLanguage(node.filePath) : undefined);
|
||||
const hotspot = showHotspots && hotspots ? hotspotsForNode(node, hotspots) : undefined;
|
||||
return { ...node, measure, sizePercent, hotspot, gravity: hotspot && hotspots ? gravityStrength(hotspot.maxFanIn, hotspots.maxFanIn) : 0,
|
||||
gravityPercent: hotspot && hotspots ? gravityPercent(hotspot.maxFanIn, hotspots.maxFanIn) : undefined,
|
||||
height: heights?.get(node.id),
|
||||
tint: node.tint ?? (colorMetric === 'language' && node.kind !== 'route' ? languageColor(language ?? 'Unknown') : undefined) };
|
||||
}) };
|
||||
}, [graphModel, catalog, heightMetric, colorMetric, hotspots, showHotspots]);
|
||||
const [hoveredId, setHoveredId] = useState<string>();
|
||||
const [hoveredEdgeId, setHoveredEdgeId] = useState<string>();
|
||||
const [visibleHotspotLabels, setVisibleHotspotLabels] = useState<Set<string>>();
|
||||
const [folderSpotsShown, setFolderSpotsShown] = useState<Map<string, number>>();
|
||||
const background = useGraphBackgroundReset(() => { setHoveredId(undefined); setHoveredEdgeId(undefined); onClearSelection?.(); });
|
||||
const controls = useRef<OrbitControlsImpl | null>(null);
|
||||
const nodesById = useMemo(() => new Map(model.nodes.map(node => [node.id, node])), [model.nodes]);
|
||||
@@ -314,7 +374,7 @@ export function ArchitectureScene({ model: graphModel, selectedId, selectedEdgeI
|
||||
const geometry = edgeGeometry.get(edge.id);
|
||||
if (!geometry || !isDirectedEdge(edge.type)) return [];
|
||||
const emphasized = edge.id === focusEdgeId || edge.source === focusId || edge.target === focusId;
|
||||
return [{ id: edge.id, type: edge.type, points: geometry.points,
|
||||
return [{ id: edge.id, type: edge.type, points: geometry.points, color: edge.tint,
|
||||
opacity: related.size > 0 && !emphasized ? 0.04 : edge.id === selectedEdgeId ? 0.95 : emphasized ? 0.8 : 0.65 }];
|
||||
}), [model.edges, edgeGeometry, focusEdgeId, focusId, related, selectedEdgeId]);
|
||||
const floorY = Math.min(0, ...model.nodes.map(node => node.position[1])) - 0.14;
|
||||
@@ -324,13 +384,13 @@ export function ArchitectureScene({ model: graphModel, selectedId, selectedEdgeI
|
||||
<Canvas {...background} frameloop={active ? 'demand' : 'never'} dpr={[1, 1.5]}
|
||||
gl={{ antialias: true, alpha: false, powerPreference: 'low-power' }}
|
||||
role="img" aria-label={`${model.title}. ${model.positionMeaning}. Use the adjacent list to select nodes and inspect relationships.`}>
|
||||
<color attach="background" args={['#0b1118']} />
|
||||
<color attach="background" args={[SCENE_PALETTE.background]} />
|
||||
<OrthographicCamera makeDefault position={[80, 100, 100]} near={0.1} far={2000} zoom={5} />
|
||||
<ambientLight intensity={1.6} />
|
||||
<directionalLight position={[30, 80, 20]} intensity={2.4} color="#dcfff3" />
|
||||
<directionalLight position={[-20, 20, -30]} intensity={1.2} color="#a899ff" />
|
||||
<gridHelper args={[extent, Math.min(80, Math.ceil(extent / 12)), '#1c3440', '#15242e']} position={[0, floorY, 0]} />
|
||||
{model.platforms?.map(platform => <FolderPlatform key={platform.id} platform={platform} />)}
|
||||
<directionalLight position={[30, 80, 20]} intensity={2.4} color={SCENE_PALETTE.keyLight} />
|
||||
<directionalLight position={[-20, 20, -30]} intensity={1.2} color={SCENE_PALETTE.fillLight} />
|
||||
<gridHelper args={[extent, Math.min(80, Math.ceil(extent / 12)), SCENE_PALETTE.gridMajor, SCENE_PALETTE.gridMinor]} position={[0, floorY, 0]} />
|
||||
{model.platforms?.map(platform => <FolderPlatform key={platform.id} platform={platform} spot={folderSpotsShown?.get(platform.id)} />)}
|
||||
{model.nodes.filter(node => node.gravity).map(node => <GravityWell key={`gravity:${node.id}`} node={node} floorY={floorY} dimmed={related.size > 0 && !related.has(node.id)} />)}
|
||||
{model.edges.map(edge => {
|
||||
const geometry = edgeGeometry.get(edge.id);
|
||||
@@ -348,9 +408,9 @@ export function ArchitectureScene({ model: graphModel, selectedId, selectedEdgeI
|
||||
<OrbitControls ref={controls} makeDefault enabled={active} enableRotate={!planar} enableDamping={false}
|
||||
mouseButtons={{ LEFT: planar ? MOUSE.PAN : MOUSE.ROTATE, MIDDLE: MOUSE.DOLLY, RIGHT: MOUSE.PAN }}
|
||||
touches={{ ONE: planar ? TOUCH.PAN : TOUCH.ROTATE, TWO: TOUCH.DOLLY_PAN }}
|
||||
minZoom={0.15} maxZoom={60} rotateSpeed={0.5} zoomSpeed={0.8} maxPolarAngle={Math.PI / 2.05} />
|
||||
minZoom={0.15} maxZoom={60} rotateSpeed={0.5} zoomSpeed={0.8} maxPolarAngle={SCENE_MAX_TILT} zoomToCursor />
|
||||
<FitArchitecture model={model} planar={planar} resetKey={resetKey} controls={controls} active={active} />
|
||||
{(adaptiveLabels || model.view === 'hotspots') && <HotspotLabels model={model} priorityId={selectedId ?? hoveredId} onVisible={setVisibleHotspotLabels} />}
|
||||
<SceneLabels model={model} priorityId={selectedId ?? hoveredId} selectedId={selectedId} cull={adaptiveLabels || model.view === 'hotspots'} onVisible={setVisibleHotspotLabels} onFolders={setFolderSpotsShown} />
|
||||
</Canvas>
|
||||
<div className="architecture-scene-guide" aria-hidden="true"><span>{planar ? 'PLAN' : '3D MAP'}</span>{planar ? 'Drag to pan · Scroll to zoom' : 'Drag to orbit · Right-drag to pan · Scroll to zoom'}</div>
|
||||
</div>;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* Review of K43: with a filter, "Matching operations · 5 of 61" held six
|
||||
* entries, the last the current start that did not match. The current start
|
||||
* now has a group of its own, and the count of every group equals its
|
||||
* entries.
|
||||
*/
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import BehaviorJourney, { type BehaviorJourneyProps } from './BehaviorJourney';
|
||||
import { architectureText } from './strings';
|
||||
import type { SystemProjection, SystemSymbol } from './system-architecture-source';
|
||||
|
||||
vi.mock('./BehaviorSourceEvidence', () => ({ default: () => <div data-source-evidence /> }));
|
||||
vi.mock('./SystemArchitectureScene', () => ({ default: () => <div data-scene /> }));
|
||||
|
||||
const symbol = (id: number, name = `operation${id}`, file_path = `src/part${id % 2}.ts`): SystemSymbol => ({ id, name, qualified_name: `sample.${name}`, label: 'Function',
|
||||
component_id: `component${id % 2}`, file_path, start_line: id });
|
||||
const LOADDATA = 'django/core/management/commands/loaddata.py';
|
||||
const entries = [symbol(1), symbol(21, 'handle', 'django/core/management/commands/makemigrations.py'), symbol(22, 'handle', LOADDATA), symbol(23, 'as_sql', 'django/db/models/fields/json.py')];
|
||||
function fixture(): SystemProjection {
|
||||
const nodes = [symbol(1), symbol(2)];
|
||||
return { schema_version: 1, status: 'ready', kind: 'static_projection', complete: true, components: [], dependencies: [], cycles: [], entrypoints: [nodes[0]!],
|
||||
paths: [{ entrypoint_id: 1, nodes, edges: [{ id: 10, source_id: 1, target_id: 2, type: 'CALLS', callsite: { file_path: 'src/part1.ts', line: 20 } }] }],
|
||||
totals: {}, limits: {}, warnings: [] };
|
||||
}
|
||||
|
||||
let container: HTMLDivElement, root: Root;
|
||||
beforeEach(() => { (globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true; container = document.createElement('div'); document.body.append(container); root = createRoot(container); });
|
||||
afterEach(async () => { await act(async () => root.unmount()); container.remove(); });
|
||||
|
||||
const select = () => container.querySelector<HTMLSelectElement>('select[aria-label="Behavior entry point"]')!;
|
||||
const filterField = () => container.querySelector<HTMLInputElement>('input[aria-label="Filter operations"]')!;
|
||||
const groups = () => [...select().querySelectorAll('optgroup')].map(group => ({ label: group.label,
|
||||
options: [...group.querySelectorAll('option')].filter(option => !option.disabled).map(option => option.textContent) }));
|
||||
async function type(value: string) {
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(filterField(), value);
|
||||
filterField().dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
it('K43: the current start that does not match the filter stands in a group of its own, and each count equals its entries', async () => {
|
||||
const data = fixture();
|
||||
const props: BehaviorJourneyProps = { project: 'sample', generation: 'g1', data, entries, suggestedEntries: [1, 21],
|
||||
targets: [{ ...data.paths[0]!.nodes[1]!, distance: 1 }], entryId: 1, active: true, pending: false, filter: '',
|
||||
onRequest: vi.fn(), onRefresh: vi.fn(), onNavigate: vi.fn(), onSelectSymbol: vi.fn() };
|
||||
await act(async () => root.render(<BehaviorJourney {...props} />));
|
||||
|
||||
await type('loaddata');
|
||||
expect(groups()).toEqual([
|
||||
{ label: architectureText.behaviorStart.current, options: ['operation1 · src/part1.ts'] },
|
||||
{ label: 'Matching operations · 1 of 4', options: [`handle · ${LOADDATA}`] },
|
||||
]);
|
||||
expect(architectureText.behaviorStart.current).toBe('Current start, not matching the filter');
|
||||
// The field still shows the current start.
|
||||
expect(select().value).toBe('1');
|
||||
expect(select().selectedOptions[0]?.textContent).toBe('operation1 · src/part1.ts');
|
||||
|
||||
// A filter the current start matches: no group of its own, it stands among the matches.
|
||||
await type('operation1');
|
||||
expect(groups()).toEqual([
|
||||
{ label: 'Suggested', options: ['operation1 · src/part1.ts'] },
|
||||
{ label: 'Matching operations · 1 of 4', options: ['operation1 · src/part1.ts'] },
|
||||
]);
|
||||
// Nothing matches: the current start alone stands apart, the matches say so.
|
||||
await type('no such operation');
|
||||
expect(groups().map(group => group.label)).toEqual([architectureText.behaviorStart.current, 'Matching operations · 0 of 4']);
|
||||
expect(select().querySelector('optgroup:last-child option:last-child')?.textContent).toBe('No operation matches "no such operation"');
|
||||
});
|
||||
@@ -33,6 +33,26 @@ async function render(data: SystemProjection, changes: Partial<BehaviorJourneyPr
|
||||
async function click(text: string) { const button = [...container.querySelectorAll('button')].find(button => button.textContent === text); expect(button).toBeDefined(); await act(async () => button!.click()); }
|
||||
|
||||
describe('behavior journeys', () => {
|
||||
it('publishes the current call and path evidence, follows steps, and clears the background selection', async () => {
|
||||
const onSelectionEvidence = vi.fn();
|
||||
await render(fixture(), { targetId: 9, onSelectionEvidence });
|
||||
let snapshot = JSON.parse(onSelectionEvidence.mock.lastCall![0].text).evidence;
|
||||
expect(snapshot.selected.call).toMatchObject({ source_id: 1, target_id: 2, type: 'CALLS' });
|
||||
expect(snapshot.relationships.nodeCount).toBe(9);
|
||||
await click('Next →');
|
||||
snapshot = JSON.parse(onSelectionEvidence.mock.lastCall![0].text).evidence;
|
||||
expect(snapshot.selected.operation.id).toBe(2);
|
||||
expect(snapshot.scope.step).toBe(1);
|
||||
expect(snapshot.limitations.interpretation).toContain('not execution order');
|
||||
await click('Empty background');
|
||||
expect(onSelectionEvidence).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
it('keeps destination selection available without a separate search bar', async () => {
|
||||
const targets = Array.from({ length: 20 }, (_, index) => ({ ...symbol(index + 2), distance: 1 }));
|
||||
await render(fixture(), { targets });
|
||||
expect(container.querySelector('[aria-label="Find a destination"]')).toBeNull();
|
||||
expect(container.querySelectorAll('[aria-label="Behavior destination"] option')).toHaveLength(21);
|
||||
});
|
||||
it('opens with unordered immediate calls rather than the system overview or a fabricated full journey', async () => {
|
||||
await render(fixture());
|
||||
expect(container.querySelector('[data-scene]')?.getAttribute('data-scene')).toBe('journey');
|
||||
@@ -50,26 +70,55 @@ describe('behavior journeys', () => {
|
||||
expect([...container.querySelectorAll('[data-edge]')].map(item => item.getAttribute('data-edge'))).toEqual(['14', '15', '16', '17']);
|
||||
expect(container.querySelector('[aria-label="Call-chain position"]')?.getAttribute('value')).toBe('5');
|
||||
});
|
||||
it('double-clicking an operation requests its actual symbol and Back restores the previous scope', async () => {
|
||||
it('double-clicking an operation requests its actual symbol as a hop from the start; Back is the workspace Back, not one of its own (K27)', async () => {
|
||||
const props = await render(fixture());
|
||||
const button = container.querySelector('[data-node="journey-choice:2"]')!;
|
||||
await act(async () => button.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })));
|
||||
expect(props.onRequest).toHaveBeenLastCalledWith(expect.objectContaining({ id: 2 }), undefined);
|
||||
await click('← Back');
|
||||
expect(props.onRequest).toHaveBeenLastCalledWith(expect.objectContaining({ id: 1 }), undefined);
|
||||
expect(props.onRequest).toHaveBeenLastCalledWith(expect.objectContaining({ id: 2 }), undefined, { from: expect.objectContaining({ id: 1 }) });
|
||||
expect([...container.querySelectorAll('button')].some(item => item.textContent === '← Back')).toBe(false);
|
||||
// A second hop still names the operation the hops started from.
|
||||
const next = fixture(); next.entrypoints = [next.paths[0].nodes[1]];
|
||||
next.paths = [{ ...next.paths[0], entrypoint_id: 2, nodes: next.paths[0].nodes.slice(1), edges: next.paths[0].edges.slice(1) }];
|
||||
await render(next, { ...props, data: next, entryId: 2, from: fixture().entrypoints[0] });
|
||||
await act(async () => container.querySelector('[data-node="journey-choice:3"]')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })));
|
||||
expect(props.onRequest).toHaveBeenLastCalledWith(expect.objectContaining({ id: 3 }), undefined, { from: expect.objectContaining({ id: 1 }) });
|
||||
// A start picked in the field begins anew.
|
||||
await act(async () => { const select = container.querySelector<HTMLSelectElement>('[aria-label="Behavior entry point"]')!; select.value = '1'; select.dispatchEvent(new Event('change', { bubbles: true })); });
|
||||
expect(props.onRequest).toHaveBeenLastCalledWith(expect.objectContaining({ id: 1 }), undefined, {});
|
||||
});
|
||||
it('clears inspection and returns from a followed operation to the original entry on empty background', async () => {
|
||||
const clear = vi.fn(); const props = await render(fixture(), { onClearSelection: clear });
|
||||
await act(async () => container.querySelector('[data-node="journey-choice:2"]')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })));
|
||||
const next = fixture(); next.entrypoints = [next.paths[0].nodes[1]];
|
||||
next.paths = [{ ...next.paths[0], entrypoint_id: 2, nodes: next.paths[0].nodes.slice(1), edges: next.paths[0].edges.slice(1) }];
|
||||
await render(next, { ...props, data: next, entryId: 2 });
|
||||
await render(next, { ...props, data: next, entryId: 2, from: fixture().entrypoints[0] });
|
||||
await click('Empty background');
|
||||
expect(props.onRequest).toHaveBeenLastCalledWith(expect.objectContaining({ id: 1 }));
|
||||
expect(props.onRequest).toHaveBeenLastCalledWith(expect.objectContaining({ id: 1 }), undefined, {});
|
||||
expect(container.querySelector('[data-scene]')?.getAttribute('data-selected')).toBeNull();
|
||||
expect(container.querySelector('[aria-label="Behavior evidence inspector"]')?.textContent).toContain('Select an operation or a call');
|
||||
expect(clear).toHaveBeenCalledOnce();
|
||||
expect([...container.querySelectorAll('button')].find(item => item.textContent === '← Back')?.disabled).toBe(true);
|
||||
});
|
||||
it('reads where it stands and its camera from a lifted place and reports changes there (K27)', async () => {
|
||||
const onPlace = vi.fn();
|
||||
const props = await render(fixture(), { targetId: 9, place: { step: 3, planar: true }, onPlace });
|
||||
expect(container.querySelector('[aria-label="Walk the call chain"] span')?.textContent).toBe('4 / 9');
|
||||
expect(container.querySelector('[aria-label="Behavior camera"] button[aria-pressed="true"]')?.textContent).toBe('Plan');
|
||||
await click('Next →');
|
||||
expect(onPlace).toHaveBeenLastCalledWith({ step: 4 }, undefined);
|
||||
await click('3D');
|
||||
expect(onPlace).toHaveBeenLastCalledWith({ planar: false }, undefined);
|
||||
// The lifted place stays what it is handed: a new start brings its own, and Back brings the one it had.
|
||||
onPlace.mockClear();
|
||||
await render(fixture(), { ...props, targetId: undefined, place: { step: 3, planar: true }, onPlace });
|
||||
expect(onPlace).not.toHaveBeenCalled();
|
||||
});
|
||||
it('keeps its own place when rendered on its own, and starts the chain over for another destination', async () => {
|
||||
const props = await render(fixture(), { targetId: 9 });
|
||||
await click('Next →'); await click('Next →'); await click('Plan');
|
||||
expect(container.querySelector('[aria-label="Walk the call chain"] span')?.textContent).toBe('3 / 9');
|
||||
await render(fixture(), { ...props, targetId: undefined });
|
||||
await render(fixture(), { ...props, targetId: 9 });
|
||||
expect(container.querySelector('[aria-label="Walk the call chain"] span')?.textContent).toBe('1 / 9');
|
||||
expect(container.querySelector('[aria-label="Behavior camera"] button[aria-pressed="true"]')?.textContent).toBe('Plan');
|
||||
});
|
||||
it('shows exact argument expressions and declaration facts without inventing argument-to-parameter bindings', async () => {
|
||||
const data = fixture();
|
||||
@@ -90,6 +139,37 @@ describe('behavior journeys', () => {
|
||||
await render(data, { data: undefined, pending: false, error: 'Snapshot changed.' });
|
||||
expect(container.querySelector('[data-scene]')).toBeNull(); expect(container.textContent).toContain('Snapshot changed.');
|
||||
});
|
||||
it('pages through every direct callee the heading counts and says what lies beyond the drawing limit', async () => {
|
||||
const data = fixture();
|
||||
const callees = Array.from({ length: 14 }, (_, index) => symbol(index + 20));
|
||||
data.paths = callees.map((target, index) => ({ entrypoint_id: 1, nodes: [data.entrypoints[0], target], edges: [{ id: 500 + index, source_id: 1, target_id: target.id, type: 'CALLS' }] }));
|
||||
await render(data);
|
||||
expect(container.querySelector('.behavior-navigation')?.textContent).toContain('14 direct callees with returned evidence');
|
||||
expect(container.querySelector('[aria-label="Direct call pages"]')?.textContent).toContain('Calls 1 to 4 of 14');
|
||||
for (let page = 0; page < 3; page++) await click('More calls →');
|
||||
expect(container.querySelector('[aria-label="Direct call pages"]')?.textContent).toContain('Calls 13 to 14 of 14');
|
||||
// Every page draws its calls and the start beside them.
|
||||
expect(container.querySelectorAll('[data-node]')).toHaveLength(3);
|
||||
});
|
||||
it('names a start operation that calls itself instead of counting it as a callee or claiming a filter', async () => {
|
||||
const data = fixture();
|
||||
const callees = Array.from({ length: 6 }, (_, index) => symbol(index + 20));
|
||||
data.paths = [...callees.map((target, index) => ({ entrypoint_id: 1, nodes: [data.entrypoints[0], target], edges: [{ id: 500 + index, source_id: 1, target_id: target.id, type: 'CALLS' }] })),
|
||||
{ entrypoint_id: 1, nodes: [data.entrypoints[0], data.entrypoints[0]], edges: [{ id: 599, source_id: 1, target_id: 1, type: 'CALLS' }] }];
|
||||
await render(data);
|
||||
expect(container.querySelector('.behavior-navigation span')?.textContent).toBe('6 direct callees with returned evidence · also calls itself');
|
||||
const pages = container.querySelector('[aria-label="Direct call pages"]')?.textContent ?? '';
|
||||
expect(pages).toContain('Calls 1 to 4 of 6');
|
||||
expect(pages).not.toContain('matching the filter');
|
||||
});
|
||||
it('shows the operation the journey opened with in the Start field, not "Choose an operation"', async () => {
|
||||
// Without a requested entry the journey falls back to the first entry point (Django: main in manage.py-tpl).
|
||||
await render(fixture(), { entryId: undefined });
|
||||
const start = container.querySelector<HTMLSelectElement>('[aria-label="Behavior entry point"]')!;
|
||||
expect(container.querySelector('.behavior-heading h2')?.textContent).toBe('What can operation1 call?');
|
||||
expect(start.value).toBe('1');
|
||||
expect(start.selectedOptions[0]?.textContent).toBe('operation1 · src/part1.ts');
|
||||
});
|
||||
it('pages only real edges and component lanes within the selected path window', () => {
|
||||
const scene = behaviorJourney(fixture(), { entryId: 1, targetId: 9 }).scene;
|
||||
const page = journeyPage(scene, 6), ids = new Set(page.nodes.map(node => node.id));
|
||||
@@ -102,3 +182,49 @@ describe('behavior journeys', () => {
|
||||
expect(compact.edges.map(edge => edge.pathEdge!.id)).toEqual([14, 15]);
|
||||
});
|
||||
});
|
||||
|
||||
/* Hand test 2026-10-04 (A4): the "Start" list had no recognizable order, and "handle · …/loaddata.py" could not be found. */
|
||||
describe('Behavior start field', () => {
|
||||
const LOADDATA = 'django/core/management/commands/loaddata.py';
|
||||
const entries = [symbol(1), { ...symbol(21), name: 'handle', file_path: 'django/core/management/commands/makemigrations.py' },
|
||||
{ ...symbol(22), name: 'handle', file_path: LOADDATA }, { ...symbol(23), name: 'as_sql', file_path: 'django/db/models/fields/json.py' }];
|
||||
const select = () => container.querySelector<HTMLSelectElement>('select[aria-label="Behavior entry point"]')!;
|
||||
const filterField = () => container.querySelector<HTMLInputElement>('input[aria-label="Filter operations"]')!;
|
||||
const group = (index: number) => [...select().querySelectorAll('optgroup')][index];
|
||||
const options = (index: number) => [...group(index).querySelectorAll('option')].map(option => option.textContent);
|
||||
async function type(value: string) {
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(filterField(), value);
|
||||
filterField().dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
it('A4: offers the suggestions first, then every operation alphabetically, behind a filter field', async () => {
|
||||
await render(fixture(), { entries, suggestedEntries: [1, 22] });
|
||||
expect(filterField().placeholder).toBe('Filter operations…');
|
||||
expect(filterField().compareDocumentPosition(select()) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect([...select().querySelectorAll('optgroup')].map(item => item.label)).toEqual(['Suggested', 'All operations · 4']);
|
||||
expect(options(0)).toEqual(['operation1 · src/part1.ts', `handle · ${LOADDATA}`]);
|
||||
expect(options(1)).toEqual(['as_sql · django/db/models/fields/json.py', `handle · ${LOADDATA}`,
|
||||
'handle · django/core/management/commands/makemigrations.py', 'operation1 · src/part1.ts']);
|
||||
expect(select().value).toBe('1');
|
||||
expect(select().selectedOptions[0]?.textContent).toBe('operation1 · src/part1.ts');
|
||||
});
|
||||
|
||||
it('A4: narrows the list to what the filter names, keeps the chosen start, and starts the operation picked there', async () => {
|
||||
const props = await render(fixture(), { entries, suggestedEntries: [1, 21] });
|
||||
await type('loaddata');
|
||||
expect(group(1).label).toBe('Matching operations · 1 of 4');
|
||||
expect(options(1)).toEqual([`handle · ${LOADDATA}`]);
|
||||
// The chosen start stands apart while the filter does not match it (review of K43).
|
||||
expect(group(0).label).toBe('Current start, not matching the filter');
|
||||
expect(options(0)).toEqual(['operation1 · src/part1.ts']);
|
||||
expect(select().value).toBe('1');
|
||||
await act(async () => { select().value = '22'; select().dispatchEvent(new Event('change', { bubbles: true })); });
|
||||
expect(props.onRequest).toHaveBeenLastCalledWith(expect.objectContaining({ id: 22, file_path: LOADDATA }), undefined, {});
|
||||
await type('no such operation');
|
||||
expect(options(0)).toEqual(['operation1 · src/part1.ts']);
|
||||
expect(options(1)).toEqual(['No operation matches "no such operation"']);
|
||||
expect(group(1).querySelector('option:last-child')?.hasAttribute('disabled')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,48 @@
|
||||
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { lazy, Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { behaviorJourney } from './behavior-journey-model';
|
||||
import type { SystemSceneModel } from './system-architecture-model';
|
||||
import { projectionLimits, type SystemSceneModel } from './system-architecture-model';
|
||||
import { architectureText as text } from './strings';
|
||||
import type { JourneyPlace, PlaceChange } from './architecture-history';
|
||||
import { useLiftedPlace, useOnIdentityChange } from './lifted-place';
|
||||
import type { SystemProjection, SystemSymbol } from './system-architecture-source';
|
||||
import BehaviorSourceEvidence from './BehaviorSourceEvidence';
|
||||
import BehaviorSourceEvidence, { type BehaviorSourceSnapshot } from './BehaviorSourceEvidence';
|
||||
import { useSelectionEvidence, type SelectionEvidenceListener } from '../galaxy/selection-evidence';
|
||||
import { RefreshControl, type RefreshFeedback } from '../ui/refresh/refresh-feedback';
|
||||
import { operationChoices } from './operation-choices';
|
||||
import './behavior-journey.css';
|
||||
|
||||
const startText = text.behaviorStart;
|
||||
|
||||
const Scene = lazy(() => import('./SystemArchitectureScene'));
|
||||
export interface BehaviorJourneyProps {
|
||||
project: string; generation?: string; data?: SystemProjection; entries: SystemSymbol[];
|
||||
targets: (SystemSymbol & { distance: number })[]; entryId?: number; targetId?: number;
|
||||
active: boolean; pending: boolean; error?: string; filter: string;
|
||||
onRequest: (entry: SystemSymbol | undefined, targetId?: number) => void;
|
||||
onRequest: (entry: SystemSymbol | undefined, targetId?: number, detail?: BehaviorRequestDetail) => void;
|
||||
/** The start the followed calls began from (K27); empty background returns there. */
|
||||
from?: SystemSymbol;
|
||||
/**
|
||||
* Where the journey stands (path, operation, page of direct calls) and Plan or 3D, lifted to the workspace
|
||||
* for Back and Forward (K27). Rendered on its own, the journey keeps them itself.
|
||||
*/
|
||||
place?: JourneyPlace;
|
||||
onPlace?: PlaceChange<JourneyPlace>;
|
||||
navigation?: ReactNode;
|
||||
/** Reports the start shown when none was requested, so a Back or Forward tooltip can name it (K27). */
|
||||
onShownStart?: (name: string) => void;
|
||||
onClearSelection?: () => void;
|
||||
onSelectionEvidence?: SelectionEvidenceListener;
|
||||
onRefresh: () => void; onSelectSymbol: (symbol: SystemSymbol) => void;
|
||||
/** What the last Refresh did, shown beside its button (hand test 2026-10-04, A3). */
|
||||
refresh?: RefreshFeedback;
|
||||
/** The automatic start and the ranked suggestions, in their order, offered first in "Start" (hand test 2026-10-04, A4). */
|
||||
suggestedEntries?: number[];
|
||||
onNavigate: (path: string, line?: number, name?: string) => void;
|
||||
}
|
||||
|
||||
/** What a request carries besides start and destination: where followed calls began, and the destination's name. */
|
||||
export interface BehaviorRequestDetail { from?: SystemSymbol; targetName?: string }
|
||||
|
||||
/** Overlapping windows keep every operation legible and retain actual call adjacency. */
|
||||
export function journeyPage(scene: SystemSceneModel, step: number, choices = false, pathWindow = 5): SystemSceneModel {
|
||||
const size = choices ? 5 : Math.max(2, Math.min(5, pathWindow));
|
||||
@@ -35,18 +62,20 @@ export function journeyPage(scene: SystemSceneModel, step: number, choices = fal
|
||||
}
|
||||
|
||||
export default function BehaviorJourney({ project, generation, data, entries, targets, entryId, targetId, active, pending, error, filter,
|
||||
onRequest, onRefresh, onSelectSymbol, onNavigate, onClearSelection }: BehaviorJourneyProps) {
|
||||
const [pathIndex, setPathIndex] = useState(0), [step, setStep] = useState(0);
|
||||
const [branchPage, setBranchPage] = useState(0);
|
||||
onRequest, onRefresh, refresh, suggestedEntries, onSelectSymbol, onNavigate, onClearSelection, onSelectionEvidence, from, place: liftedPlace, onPlace, onShownStart, navigation }: BehaviorJourneyProps) {
|
||||
const [place, changePlace] = useLiftedPlace<JourneyPlace>(liftedPlace, onPlace, () => ({}));
|
||||
const { path: pathIndex = 0, step = 0, page: branchPage = 0, planar = false } = place;
|
||||
const [overview, setOverview] = useState(false);
|
||||
const [selection, setSelection] = useState<{ node?: string; edge?: string }>();
|
||||
const [planar, setPlanar] = useState(false), [resetKey, setResetKey] = useState(0);
|
||||
const [history, setHistory] = useState<{ entry: SystemSymbol; targetId?: number }[]>([]);
|
||||
const [targetSearch, setTargetSearch] = useState('');
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
const visual = useRef<HTMLDivElement>(null);
|
||||
const [pathWindow, setPathWindow] = useState(5);
|
||||
useEffect(() => { setSelection(undefined); setPathIndex(0); setStep(0); setBranchPage(0); }, [project, generation, entryId, targetId, filter]);
|
||||
useEffect(() => { setHistory([]); setTargetSearch(''); }, [project, generation]);
|
||||
const [sourceEvidence, setSourceEvidence] = useState<BehaviorSourceSnapshot>();
|
||||
const lifted = liftedPlace !== undefined;
|
||||
const top = { path: 0, step: 0, page: 0 };
|
||||
// A lifted journey is handed its position with every start, and Back hands the one it had; on its own it starts over.
|
||||
useOnIdentityChange(JSON.stringify([project, generation, entryId, targetId]), () => { setSelection(undefined); if (!lifted) changePlace(top); });
|
||||
useOnIdentityChange(filter, () => { setSelection(undefined); changePlace(top); });
|
||||
const journey = useMemo(() => data ? behaviorJourney(data, { entryId, targetId, pathIndex, filter }) : undefined,
|
||||
[data, entryId, targetId, pathIndex, filter]);
|
||||
const path = journey?.path;
|
||||
@@ -73,80 +102,117 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
|
||||
const related = symbol ? journey?.scene.edges.filter(edge => edge.pathEdge?.source_id === symbol.id) ?? [] : [];
|
||||
const targetOptions = [...new Map([...targets, ...(journey?.choices ?? []).map(item => ({ ...item, distance: item.distance ?? 1 }))]
|
||||
.filter(item => item.id !== (entryId ?? journey?.entry?.id)).map(item => [item.id, item])).values()];
|
||||
const matchingTargets = targetOptions.filter(item => item.id === targetId || !targetSearch.trim()
|
||||
|| `${item.name} ${item.file_path ?? ''}`.toLocaleLowerCase().includes(targetSearch.trim().toLocaleLowerCase()));
|
||||
const entry = journey?.entry ?? entries.find(item => item.id === entryId);
|
||||
const availableEntries = entry && !entries.some(item => item.id === entry.id) ? [entry, ...entries] : entries;
|
||||
const availableEntries = useMemo(() => entry && !entries.some(item => item.id === entry.id) ? [entry, ...entries] : entries, [entry, entries]);
|
||||
const [startQuery, setStartQuery] = useState('');
|
||||
const startChoices = useMemo(() => operationChoices(availableEntries, { suggested: suggestedEntries, query: startQuery, keep: entry?.id }),
|
||||
[availableEntries, suggestedEntries, startQuery, entry?.id]);
|
||||
const reportShown = useRef(onShownStart);
|
||||
reportShown.current = onShownStart;
|
||||
const shownStart = entryId === undefined && !pending ? entry?.name : undefined;
|
||||
useEffect(() => { if (shownStart !== undefined) reportShown.current?.(shownStart); }, [shownStart]);
|
||||
const selectedTarget = targetOptions.find(item => item.id === targetId) ?? path?.nodes.at(-1);
|
||||
const limits = data?.status === 'limited' ? projectionLimits(data) : [];
|
||||
const sourceKey = caller ? JSON.stringify([project, generation, caller.qualified_name, call?.callsite?.file_path ?? caller.file_path, call?.callsite?.line ?? caller.start_line]) : undefined;
|
||||
useSelectionEvidence(onSelectionEvidence, !overview && !pending && journey && symbol ? {
|
||||
project, generation, view: 'architecture-behavior', source: 'indexed behavior projection and call-site evidence',
|
||||
label: caller && callee ? `${caller.name} → ${callee.name}` : symbol.name,
|
||||
selected: { operation: symbol, caller, callee, call, component: components.get(symbol.component_id),
|
||||
currentSource: sourceEvidence?.key === sourceKey ? { ...sourceEvidence, provenance: 'Current local source; may differ from the indexed snapshot.' } : undefined },
|
||||
relationships: path ? { nodes: path.nodes, edges: path.edges, nodeCount: path.nodes.length, edgeCount: path.edges.length }
|
||||
: { count: related.length, calls: related.map(edge => edge.pathEdge) },
|
||||
scope: { entry, destination: selectedTarget, pathIndex, step: activeStep, mode: path ? 'connected-call-chain' : 'immediate-calls' },
|
||||
limitations: { analysis: data?.limits, warnings: data?.warnings, journey: journey.limits, counts: journey.counts,
|
||||
interpretation: 'Static call evidence only. Call-chain depth is not execution order. Branch feasibility, parameter binding, runtime values and data transformation are not established.' },
|
||||
} : undefined, active);
|
||||
/** Empty background leaves a destination and returns from followed calls to where they began. */
|
||||
function clearSelection() {
|
||||
const origin = history[0]?.entry ?? entry;
|
||||
setOverview(true); setSelection(undefined); setStep(0); setBranchPage(0); setPathIndex(0); setHistory([]);
|
||||
const origin = from ?? entry;
|
||||
setOverview(true); setSelection(undefined); changePlace(top);
|
||||
setResetKey(value => value + 1); onClearSelection?.();
|
||||
if (history.length || targetId !== undefined) onRequest(origin);
|
||||
if (from || targetId !== undefined) onRequest(origin, undefined, {});
|
||||
}
|
||||
function request(next: SystemSymbol | undefined, destination?: number, remember = true) {
|
||||
/**
|
||||
* Every request is a step of the shared Architecture history (K27). `origin` is where followed calls began:
|
||||
* kept for a destination and for further hops, dropped for a start picked in the field.
|
||||
*/
|
||||
function request(next: SystemSymbol | undefined, destination?: number, origin?: SystemSymbol) {
|
||||
setOverview(false);
|
||||
if (remember && entry) setHistory(items => [...items.slice(-19), { entry, targetId }]);
|
||||
setSelection(undefined); setStep(0); setPathIndex(0); setTargetSearch(''); onRequest(next, destination);
|
||||
// Lifted, the request itself is the new place and starts at the top.
|
||||
setSelection(undefined); if (!lifted) changePlace({ path: 0, step: 0 });
|
||||
const targetName = destination === undefined ? undefined : targetOptions.find(item => item.id === destination)?.name;
|
||||
onRequest(next, destination, { ...(origin && origin.id !== next?.id ? { from: origin } : {}), ...(targetName ? { targetName } : {}) });
|
||||
}
|
||||
function selectNode(id: string) {
|
||||
setOverview(false);
|
||||
const node = allNodes.find(item => item.id === id);
|
||||
setSelection({ node: id });
|
||||
if (path && node?.depth !== undefined) setStep(node.depth);
|
||||
if (!path && node) setBranchPage(Math.max(0, Math.floor((allNodes.indexOf(node) - 1) / 4)));
|
||||
if (path && node?.depth !== undefined) changePlace({ step: node.depth });
|
||||
if (!path && node) changePlace({ page: Math.max(0, Math.floor((allNodes.indexOf(node) - 1) / 4)) });
|
||||
if (node?.symbol) onSelectSymbol(node.symbol);
|
||||
}
|
||||
function selectEdge(id: string) {
|
||||
setOverview(false);
|
||||
const edge = journey?.scene.edges.find(item => item.id === id);
|
||||
setSelection({ edge: id });
|
||||
if (path && edge?.depth) setStep(edge.depth - 1);
|
||||
if (!path && edge) setBranchPage(Math.max(0, Math.floor((allNodes.findIndex(node => node.id === edge.target) - 1) / 4)));
|
||||
if (path && edge?.depth) changePlace({ step: edge.depth - 1 });
|
||||
if (!path && edge) changePlace({ page: Math.max(0, Math.floor((allNodes.findIndex(node => node.id === edge.target) - 1) / 4)) });
|
||||
}
|
||||
function selectStep(index: number) {
|
||||
setOverview(false);
|
||||
const node = allNodes[index];
|
||||
if (!node || !path) return;
|
||||
setStep(index); setSelection({ node: node.id }); if (node.symbol) onSelectSymbol(node.symbol);
|
||||
changePlace({ step: index }); setSelection({ node: node.id }); if (node.symbol) onSelectSymbol(node.symbol);
|
||||
}
|
||||
function follow(id: string) {
|
||||
const operation = allNodes.find(node => node.id === id)?.symbol;
|
||||
if (operation && operation.id !== entryId) request(operation);
|
||||
if (operation && operation.id !== entryId) request(operation, undefined, from ?? entry);
|
||||
}
|
||||
return <section className="system-architecture behavior-journey" aria-label="Behavior" data-testid="behavior-journey" aria-busy={pending}>
|
||||
<header className="behavior-heading"><div><span className="system-eyebrow">Behavior · source-guided exploration</span>
|
||||
<h2>{targetId !== undefined ? `${entry?.name ?? 'Operation'} → ${selectedTarget?.name ?? 'destination'}` : `What can ${entry?.name ?? 'this operation'} call?`}</h2>
|
||||
<p>{path ? 'Follow one recorded call chain across the parts it touches.' : 'Choose a starting operation. Explore its calls, or follow a path to a destination.'}</p></div>
|
||||
<button onClick={onRefresh}>Refresh</button></header>
|
||||
<RefreshControl labels={text.refreshFeedback.behavior} feedback={refresh} onRefresh={onRefresh} /></header>
|
||||
<div className="behavior-requests">
|
||||
<label>Start <select aria-label="Behavior entry point" value={entryId ?? ''} onChange={event => request(availableEntries.find(item => item.id === Number(event.target.value)))}>
|
||||
<option value="">Choose an operation…</option>{availableEntries.map(item => <option key={item.id} value={item.id}>{item.name} · {item.file_path ?? item.qualified_name}</option>)}
|
||||
{navigation}
|
||||
{/*
|
||||
* The field names the operation the journey shows, also when the projection chose it. Suggestions come
|
||||
* first, then every operation alphabetically; the filter before it narrows both (hand test 2026-10-04, A4).
|
||||
*/}
|
||||
<label className="behavior-start">{startText.label}
|
||||
<input type="search" aria-label={startText.filter} placeholder={startText.filterPlaceholder} value={startQuery} onChange={event => setStartQuery(event.target.value)} />
|
||||
<select aria-label={startText.field} value={entry?.id ?? ''} onChange={event => request(availableEntries.find(item => item.id === Number(event.target.value)))}>
|
||||
<option value="">{startText.choose}</option>
|
||||
{startChoices.current && <optgroup label={startText.current}><option value={startChoices.current.id}>{startChoices.current.label}</option></optgroup>}
|
||||
{startChoices.suggested.length > 0 && <optgroup label={startText.suggested}>{startChoices.suggested.map(item => <option key={item.id} value={item.id}>{item.label}</option>)}</optgroup>}
|
||||
<optgroup label={startQuery.trim() ? startText.matching(startChoices.matching, startChoices.total) : startText.all(startChoices.total)}>
|
||||
{startChoices.all.map(item => <option key={item.id} value={item.id}>{item.label}</option>)}
|
||||
{startQuery.trim() && !startChoices.matching && <option disabled value="-1">{startText.none(startQuery.trim())}</option>}
|
||||
</optgroup>
|
||||
</select></label>
|
||||
<label>Reach <select aria-label="Behavior destination" value={targetId ?? ''} disabled={!entry || pending} onChange={event => request(entry, event.target.value ? Number(event.target.value) : undefined, from)}>
|
||||
<option value="">Explore immediate calls</option>{targetOptions.map(item => <option key={item.id} value={item.id}>{item.name} · {item.file_path ?? ''}</option>)}
|
||||
</select></label>
|
||||
<label>Reach <select aria-label="Behavior destination" value={targetId ?? ''} disabled={!entry || pending} onChange={event => request(entry, event.target.value ? Number(event.target.value) : undefined)}>
|
||||
<option value="">Explore immediate calls</option>{matchingTargets.map(item => <option key={item.id} value={item.id}>{item.name} · {item.file_path ?? ''}</option>)}
|
||||
</select></label>
|
||||
{targetOptions.length > 15 && <input aria-label="Find a destination" placeholder="Find a destination…" value={targetSearch} onChange={event => setTargetSearch(event.target.value)} />}
|
||||
</div>
|
||||
<div className="behavior-navigation"><button disabled={!history.length} onClick={() => { const previous = history.at(-1)!; setHistory(items => items.slice(0, -1)); request(previous.entry, previous.targetId, false); }}>← Back</button>
|
||||
{targetId !== undefined && <button onClick={() => request(entry)}>Immediate calls</button>}
|
||||
<div className="behavior-navigation">{targetId !== undefined && <button onClick={() => request(entry, undefined, from)}>Immediate calls</button>}
|
||||
<span>{pending ? 'Updating this journey…' : path ? `${path.edges.length} call-chain hops · ${new Set(path.nodes.map(item => item.component_id)).size} components`
|
||||
: `${journey?.counts.directChoices ?? 0} direct callees with returned evidence`}</span>
|
||||
: text.directCallees(journey?.counts.directChoices ?? 0, journey?.counts.selfCalls ?? false)}</span>
|
||||
<span className="behavior-static-badge" title="Graph relationships describe possible calls. They do not prove execution order, path feasibility or actual runtime values.">Static evidence</span>
|
||||
</div>
|
||||
{error && <p role="alert">{error} <button onClick={onRefresh}>Try again</button></p>}
|
||||
{pending ? <div className="behavior-loading" role="status">Preparing the selected operation…</div> : !scene || !journey ? <div className="behavior-loading">Choose an operation to explore.</div> : <>
|
||||
{journey.paths.length > 1 && <div className="behavior-paths" role="group" aria-label="Indexed paths">{journey.paths.map((item, index) => <button key={index} aria-pressed={pathIndex === index} onClick={() => { setPathIndex(index); setStep(0); setSelection(undefined); }}>
|
||||
{journey.paths.length > 1 && <div className="behavior-paths" role="group" aria-label="Indexed paths">{journey.paths.map((item, index) => <button key={index} aria-pressed={pathIndex === index} onClick={() => { changePlace({ path: index, step: 0 }); setSelection(undefined); }}>
|
||||
Path {index + 1}<small>{item.edges.length} hops{new Set(item.nodes.map(node => node.id)).size < item.nodes.length ? ' · revisits a symbol' : ''}</small></button>)}</div>}
|
||||
<div className="behavior-stage"><div className="behavior-visual" ref={visual}>
|
||||
<div className="behavior-visual-toolbar"><span>{path ? 'CALL CHAIN →' : 'DIRECT CALLS · UNORDERED'}</span>
|
||||
<div className="system-view-switch" role="group" aria-label="Behavior camera"><button aria-pressed={!planar} onClick={() => setPlanar(false)}>3D</button><button aria-pressed={planar} onClick={() => setPlanar(true)}>Plan</button></div>
|
||||
<div className="system-view-switch" role="group" aria-label="Behavior camera"><button aria-pressed={!planar} onClick={() => changePlace({ planar: false })}>3D</button><button aria-pressed={planar} onClick={() => changePlace({ planar: true })}>Plan</button></div>
|
||||
<button onClick={() => setResetKey(value => value + 1)}>Fit</button></div>
|
||||
<div className="system-map behavior-map">
|
||||
{scene.nodes.length ? <Suspense fallback={<div className="behavior-loading">Preparing the journey…</div>}><Scene model={scene}
|
||||
selectedNode={selectedEdge ? undefined : selectedNode?.id} selectedEdge={selectedEdge?.id}
|
||||
onSelectNode={selectNode} onSelectEdge={selectEdge} onExpandNode={follow} onClearSelection={clearSelection}
|
||||
resetKey={resetKey} planar={planar} active={active} showConnectionLoad={false} presentation="journey" /></Suspense>
|
||||
: limits.length ? <div className="behavior-loading" role="status"><div className="behavior-limited"><strong>{text.behaviorLimited}</strong>{limits.map(limit => <p key={limit}>{limit}</p>)}</div></div>
|
||||
: <div className="behavior-loading">{filter ? 'No connected path matches this filter.' : targetId !== undefined ? 'No connected call-chain witness was returned for this destination.' : 'No direct call witnesses were returned for this operation.'}</div>}
|
||||
<div className="system-map-caption"><span>{path ? `Showing operations ${(scene.nodes[0]?.depth ?? 0) + 1} to ${(scene.nodes.at(-1)?.depth ?? 0) + 1} of ${path.nodes.length}` : 'Branches are possible calls, not an execution sequence.'}</span>
|
||||
<span>Double-click an operation to follow its calls</span></div>
|
||||
@@ -154,16 +220,17 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
|
||||
{path && <nav className="behavior-walk" aria-label="Walk the call chain"><button disabled={activeStep === 0} onClick={() => selectStep(activeStep - 1)}>← Previous</button>
|
||||
<input type="range" aria-label="Call-chain position" min={0} max={path.nodes.length - 1} value={activeStep} onChange={event => selectStep(Number(event.target.value))} />
|
||||
<span>{activeStep + 1} / {path.nodes.length}</span><button disabled={activeStep >= path.nodes.length - 1} onClick={() => selectStep(activeStep + 1)}>Next →</button></nav>}
|
||||
{!path && journey.scene.nodes.length > 5 && <nav className="behavior-walk" aria-label="Direct call pages"><button disabled={branchPage === 0} onClick={() => { setBranchPage(page => page - 1); setSelection(undefined); }}>← Earlier calls</button>
|
||||
<span>Calls {branchPage * 4 + 1} to {Math.min(branchPage * 4 + 4, journey.scene.nodes.length - 1)} of {journey.scene.nodes.length - 1} shown</span>
|
||||
<button disabled={(branchPage + 1) * 4 >= journey.scene.nodes.length - 1} onClick={() => { setBranchPage(page => page + 1); setSelection(undefined); }}>More calls →</button></nav>}
|
||||
{!path && journey.scene.nodes.length > 5 && <nav className="behavior-walk" aria-label="Direct call pages"><button disabled={branchPage === 0} onClick={() => { changePlace({ page: branchPage - 1 }); setSelection(undefined); }}>← Earlier calls</button>
|
||||
<span>{text.callPage(branchPage * 4 + 1, Math.min(branchPage * 4 + 4, journey.scene.nodes.length - 1), journey.scene.nodes.length - 1,
|
||||
journey.counts.omittedNodes, Boolean(filter.trim()) && journey.scene.nodes.length - 1 + journey.counts.omittedNodes < journey.counts.directChoices)}</span>
|
||||
<button disabled={(branchPage + 1) * 4 >= journey.scene.nodes.length - 1} onClick={() => { changePlace({ page: branchPage + 1 }); setSelection(undefined); }}>More calls →</button></nav>}
|
||||
</div><aside className="behavior-inspector" aria-label="Behavior evidence inspector">
|
||||
{caller ? <><span className="system-eyebrow">{call ? call.type.replaceAll('_', ' ') : path ? `Operation ${activeStep + 1}` : 'Starting operation'}</span>
|
||||
<h3>{caller.name}{callee && <><span className="behavior-call-arrow">↓</span>{callee.name}</>}</h3>
|
||||
<p>{call && callee ? caller.component_id === callee.component_id ? 'A call within the same component.' : `Crosses from ${components.get(caller.component_id)?.label ?? caller.component_id} into ${components.get(callee.component_id)?.label ?? callee.component_id}.`
|
||||
: components.get(caller.component_id)?.label ?? caller.file_path}</p>
|
||||
{call?.resolution && <p className="behavior-resolution">{call.resolution.strategy?.replaceAll('_', ' ') ?? 'Indexed resolution'}{call.resolution.candidates && call.resolution.candidates > 1 ? ` · ${call.resolution.candidates} candidate targets` : ''}</p>}
|
||||
<div className="behavior-inspector-actions">{symbol && symbol.id !== entryId && <button onClick={() => request(symbol)}>Follow calls from here</button>}
|
||||
<div className="behavior-inspector-actions">{symbol && symbol.id !== entryId && <button onClick={() => request(symbol, undefined, from ?? entry)}>Follow calls from here</button>}
|
||||
{symbol?.file_path && <button onClick={() => onNavigate(symbol.file_path!, symbol.start_line, symbol.name)}>Open definition ↗</button>}</div>
|
||||
<div className="behavior-contract" aria-label="Indexed call contract">
|
||||
{call?.arguments && <section><h4>Passed expressions</h4>{call.arguments.length ? <ol>{call.arguments.map((argument, index) => <li key={`${argument.i}:${index}`}><code>{argument.e}</code>
|
||||
@@ -174,14 +241,14 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
|
||||
<small>{(callee ?? symbol)!.parameters!.count} reported parameters</small></section>}
|
||||
{(callee ?? symbol)?.return_type && <section><h4>Declared return type</h4><code>{(callee ?? symbol)!.return_type}</code></section>}
|
||||
</div>
|
||||
<BehaviorSourceEvidence project={project} generation={generation} symbol={caller} call={call} active={active} onNavigate={onNavigate} />
|
||||
<BehaviorSourceEvidence project={project} generation={generation} symbol={caller} call={call} active={active} onNavigate={onNavigate} onSourceEvidence={setSourceEvidence} />
|
||||
{callee && <div className="behavior-next-operation"><span>Continues into</span><button onClick={() => { const node = allNodes.find(node => node.symbol?.id === callee.id && (node.depth ?? 0) > activeStep) ?? allNodes.find(node => node.symbol?.id === callee.id); if (node) selectNode(node.id); }}>{callee.name} →</button></div>}
|
||||
{!path && related.length > 0 && <div className="behavior-immediate-list"><h4>Calls from this operation</h4>{related.map(edge => <button key={edge.id} onClick={() => selectEdge(edge.id)}>
|
||||
{allNodes.find(node => node.id === edge.target)?.symbol?.name ?? edge.target}<small>{edge.pathEdge?.callsite ? `line ${edge.pathEdge.callsite.line}` : edge.type.toLowerCase().replaceAll('_', ' ')}</small></button>)}</div>}
|
||||
</> : <p>Select an operation or a call to inspect its evidence.</p>}
|
||||
</aside></div>
|
||||
{path && <ol className="behavior-operation-strip" aria-label="All operations in this call chain">{path.nodes.map((item, index) => <li key={`${index}:${item.id}`}>
|
||||
<button aria-label={`Select step ${index + 1}: ${item.name}`} aria-pressed={!overview && activeStep === index} onClick={() => selectStep(index)}><span>{index + 1}</span><strong>{item.name}</strong><small>{item.file_path}</small></button></li>)}</ol>}
|
||||
{path && <details className="behavior-limits"><summary>Call chain · {path.nodes.length} operations</summary><ol className="behavior-operation-strip" aria-label="All operations in this call chain">{path.nodes.map((item, index) => <li key={`${index}:${item.id}`}>
|
||||
<button aria-label={`Select step ${index + 1}: ${item.name}`} aria-pressed={!overview && activeStep === index} onClick={() => selectStep(index)}><span>{index + 1}</span><strong>{item.name}</strong><small>{item.file_path}</small></button></li>)}</ol></details>}
|
||||
<details className="behavior-limits"><summary>Evidence and limits{journey.limits.sampled ? ' · partial analysis' : ''}</summary>
|
||||
<p>Each arrow is a recorded invocation. Component lanes provide orientation; numbered operations follow call-chain depth. Neither proves execution order, branch feasibility, data transformation or runtime values. Opening a call reads its current local source.</p>
|
||||
<p>{journey.counts.omittedChoices} choices, {journey.counts.omittedNodes} nodes and {journey.counts.omittedEdges} edges omitted by display limits. {journey.counts.invalidPaths} disconnected or unsupported paths excluded.</p>
|
||||
|
||||
@@ -2,17 +2,21 @@ import { useEffect, useState } from 'react';
|
||||
import { callToolJson } from '../provider/rpc-transport';
|
||||
import { readCodeSnippet, type CodeSnippetResult } from '../provider/rpc-schemas';
|
||||
import type { SystemPathEdge, SystemSymbol } from './system-architecture-source';
|
||||
export interface BehaviorSourceSnapshot { key: string; source?: CodeSnippetResult; error?: string }
|
||||
|
||||
/** A small, current-source window. It never turns nearby syntax into an inferred guard. */
|
||||
export default function BehaviorSourceEvidence({ project, generation, symbol, call, active, onNavigate }: {
|
||||
export default function BehaviorSourceEvidence({ project, generation, symbol, call, active, onNavigate, onSourceEvidence }: {
|
||||
project: string; generation?: string; symbol: SystemSymbol; call?: SystemPathEdge; active: boolean;
|
||||
onNavigate: (path: string, line?: number, name?: string) => void;
|
||||
onSourceEvidence?: (snapshot: BehaviorSourceSnapshot) => void;
|
||||
}) {
|
||||
const line = call?.callsite?.line ?? symbol.start_line;
|
||||
const file = call?.callsite?.file_path ?? symbol.file_path;
|
||||
const key = JSON.stringify([project, generation, symbol.qualified_name, file, line]);
|
||||
const [reading, setReading] = useState<{ key: string; source?: CodeSnippetResult; error?: string }>();
|
||||
const current = reading?.key === key ? reading : undefined;
|
||||
useEffect(() => { if (active) onSourceEvidence?.({ key, source: current?.source, error: current?.error }); },
|
||||
[active, key, current?.source, current?.error, onSourceEvidence]);
|
||||
useEffect(() => {
|
||||
if (!active || !file || !symbol.qualified_name) return;
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -53,8 +53,63 @@ describe('service map interactions', () => {
|
||||
expect(button('Open declaration')).toBeUndefined();
|
||||
expect(clear).toHaveBeenCalledOnce();
|
||||
});
|
||||
it('says plainly that a project has no deployment files and leads to its endpoints', async () => {
|
||||
vi.mocked(loadContainerInventory).mockResolvedValue({ project: 'p', rootPath: '/p', files: new Map(), manifests: [], warnings: [] });
|
||||
const showEndpoints = vi.fn();
|
||||
await act(async () => root.render(<ContainerMap project="p" active filter="" onNavigate={vi.fn()} onShowEndpoints={showEndpoints} />));
|
||||
expect(container.textContent).toContain('No Docker Compose deployment files were found in this index. Endpoints shows the routes.');
|
||||
expect(container.textContent).not.toContain('Could not read');
|
||||
await act(async () => button('Show endpoints →').click());
|
||||
expect(showEndpoints).toHaveBeenCalledOnce();
|
||||
expect(loadContainerTopology).not.toHaveBeenCalled();
|
||||
});
|
||||
it('keeps a failed read distinct from a project without deployment files', async () => {
|
||||
vi.mocked(loadContainerInventory).mockRejectedValue(new Error('query_graph: Incomplete query results'));
|
||||
await act(async () => root.render(<ContainerMap project="p" active filter="" onNavigate={vi.fn()} onShowEndpoints={vi.fn()} />));
|
||||
expect(container.textContent).toContain('Could not read the indexed deployment files. Refresh to try again.');
|
||||
expect(container.textContent).not.toContain('No Docker Compose deployment files');
|
||||
expect(button('Show endpoints →')).toBeUndefined();
|
||||
});
|
||||
it('does not request deployment source while the workspace is inactive', async () => {
|
||||
await act(async () => root.render(<ContainerMap project="p" active={false} filter="" onNavigate={vi.fn()} />));
|
||||
expect(loadContainerTopology).not.toHaveBeenCalled(); expect(loadContainerInventory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/* Hand test 2026-10-04 (A3): a Refresh that reads everything again says so, and says what it found. */
|
||||
describe('service map refresh feedback', () => {
|
||||
const refresh = () => [...container.querySelectorAll('button')].find(element => /^Refresh(ing…)?$/.test(element.textContent ?? ''))!;
|
||||
const status = () => container.querySelector('[role="status"].atlas-refresh-status')?.textContent;
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('A3: reads the declared services again, busy meanwhile, then names the time and whether anything changed', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date(2026, 9, 4, 9, 5, 7));
|
||||
await act(async () => root.render(<ContainerMap project="p" active filter="" onNavigate={vi.fn()} />));
|
||||
let finish!: (value: ContainerReading) => void;
|
||||
vi.mocked(loadContainerTopology).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
||||
await act(async () => refresh().click());
|
||||
expect(refresh().textContent).toBe('Refreshing…');
|
||||
expect(refresh().getAttribute('aria-disabled')).toBe('true');
|
||||
await act(async () => finish(sample()));
|
||||
expect(refresh().textContent).toBe('Refresh');
|
||||
expect(status()).toBe('Up to date at 09:05:07: no changes since the last load');
|
||||
const changed = sample();
|
||||
changed.topology.services.pop();
|
||||
vi.mocked(loadContainerTopology).mockResolvedValueOnce(changed);
|
||||
await act(async () => refresh().click());
|
||||
expect(status()).toBe('Service map refreshed at 09:05:07');
|
||||
});
|
||||
|
||||
it('A3: also answers for a project without deployment files', async () => {
|
||||
vi.mocked(loadContainerInventory).mockResolvedValue({ project: 'p', rootPath: '/p', files: new Map(), manifests: [], warnings: [] });
|
||||
await act(async () => root.render(<ContainerMap project="p" active filter="" onNavigate={vi.fn()} />));
|
||||
let finish!: (value: Awaited<ReturnType<typeof loadContainerInventory>>) => void;
|
||||
vi.mocked(loadContainerInventory).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
||||
await act(async () => refresh().click());
|
||||
expect(refresh().textContent).toBe('Refreshing…');
|
||||
await act(async () => finish({ project: 'p', rootPath: '/p', files: new Map(), manifests: [], warnings: [] }));
|
||||
expect(refresh().textContent).toBe('Refresh');
|
||||
expect(status()).toMatch(/^Up to date at \d\d:\d\d:\d\d: no changes since the last load$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,20 @@ import { ArchitectureScene } from './ArchitectureScene';
|
||||
import { layoutContainers } from './container-layout';
|
||||
import { loadContainerInventory, loadContainerTopology, type ContainerReading, type ContainerSelection } from './container-source';
|
||||
import type { ServiceEvidence } from './container-topology';
|
||||
import { useSelectionEvidence, type SelectionEvidenceListener } from '../galaxy/selection-evidence';
|
||||
import { architectureText as text } from './strings';
|
||||
import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback';
|
||||
import './spatial-architecture.css';
|
||||
import './container-map.css';
|
||||
|
||||
interface Props { project: string; generation?: string; active: boolean; filter: string; onNavigate: (path: string, line?: number) => void; onClearSelection?: () => void }
|
||||
interface Props {
|
||||
project: string; generation?: string; active: boolean; filter: string; onNavigate: (path: string, line?: number) => void; onClearSelection?: () => void; onSelectionEvidence?: SelectionEvidenceListener;
|
||||
/** Leads from a project without deployment files to the routes it does have. */
|
||||
onShowEndpoints?: () => void;
|
||||
navigation?: ReactNode;
|
||||
}
|
||||
/** Reading the deployment inventory: a project without Compose files is an answer, not a failure. */
|
||||
type Discovery = keyof typeof text.serviceMap | undefined;
|
||||
class ContainerSceneBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
|
||||
state = { failed: false };
|
||||
static getDerivedStateFromError() { return { failed: true }; }
|
||||
@@ -20,10 +30,12 @@ const relativeSource = (absolute: string, selection: ContainerSelection) => {
|
||||
return absolute.startsWith(prefix) ? absolute.slice(prefix.length) : undefined;
|
||||
};
|
||||
|
||||
export default function ContainerMap({ project, generation, active, filter, onNavigate, onClearSelection }: Props) {
|
||||
export default function ContainerMap({ project, generation, active, filter, onNavigate, onClearSelection, onSelectionEvidence, onShowEndpoints, navigation }: Props) {
|
||||
const [available, setAvailable] = useState<ProjectEntry[]>([]);
|
||||
const [selections, setSelections] = useState<ContainerSelection[]>([]);
|
||||
const [discovery, setDiscovery] = useState('Finding indexed Compose definitions…');
|
||||
const [discovery, setDiscovery] = useState<Discovery>('reading');
|
||||
/** The `discoveryRevision` the current `discovery` answers, so a refresh can tell its own answer from the earlier one. */
|
||||
const [discovered, setDiscovered] = useState<number>();
|
||||
const [reading, setReading] = useState<{ key: string; result?: ContainerReading; error?: string }>();
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [discoveryRevision, setDiscoveryRevision] = useState(0);
|
||||
@@ -39,7 +51,7 @@ export default function ContainerMap({ project, generation, active, filter, onNa
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const controller = new AbortController(), client = new RpcIntelligenceClient({ signal: controller.signal });
|
||||
setDiscovery('Finding indexed Compose definitions…');
|
||||
setDiscovery('reading');
|
||||
void client.listProjects().then(async response => {
|
||||
const projects = response.projects;
|
||||
const current = projects.find(entry => entry.name === project);
|
||||
@@ -47,12 +59,21 @@ export default function ContainerMap({ project, generation, active, filter, onNa
|
||||
const inventory = await loadContainerInventory(current, client);
|
||||
if (controller.signal.aborted) return;
|
||||
setAvailable(projects); setSelections([{ inventory, manifest: inventory.manifests[0] ?? '' }]);
|
||||
setDiscovery(inventory.manifests.length ? '' : 'No indexed Compose file was found. The Endpoints view is still available.');
|
||||
}).catch(() => { if (!controller.signal.aborted) setDiscovery('Could not read the indexed deployment files. Refresh to try again.'); });
|
||||
setDiscovery(inventory.manifests.length ? undefined : 'none'); setDiscovered(discoveryRevision);
|
||||
}).catch(() => { if (!controller.signal.aborted) { setDiscovery('failed'); setDiscovered(discoveryRevision); } });
|
||||
return () => controller.abort();
|
||||
}, [project, active, generation, discoveryRevision]);
|
||||
const selectionKey = JSON.stringify(selections.map(item => [item.inventory.project, item.manifest]));
|
||||
const key = `${project}:${generation ?? ''}:${selectionKey}:${revision}`;
|
||||
// Settled once the inventory answered this revision and, when a Compose file is chosen, its services were read.
|
||||
const readsTopology = selections.some(item => item.manifest);
|
||||
const topologyReading = reading?.key === key ? reading : undefined;
|
||||
const shownTopology = topologyReading?.result?.topology;
|
||||
const shown = useMemo(() => [discovery, shownTopology], [discovery, shownTopology]);
|
||||
const refresh = useRefreshFeedback({ key: `${key}:${discoveryRevision}`,
|
||||
settled: discovered === discoveryRevision && discovery !== 'reading' && (!readsTopology || Boolean(topologyReading?.result || topologyReading?.error)),
|
||||
value: shown,
|
||||
error: discovery === 'failed' ? text.serviceMap.failed : topologyReading?.error });
|
||||
useEffect(() => {
|
||||
if (!active || !selections.length || selections[0].inventory.project !== project || !selections.some(item => item.manifest)) return;
|
||||
const controller = new AbortController(), client = new RpcIntelligenceClient({ signal: controller.signal });
|
||||
@@ -106,31 +127,45 @@ export default function ContainerMap({ project, generation, active, filter, onNa
|
||||
try {
|
||||
const inventory = await loadContainerInventory(entry, new RpcIntelligenceClient());
|
||||
setSelections(current => current.some(item => item.inventory.project === name) ? current : [...current, { inventory, manifest: inventory.manifests[0] ?? '' }]);
|
||||
} catch { setDiscovery('Could not read that project. Its index may be unavailable.'); }
|
||||
} catch { setDiscovery('compareFailed'); }
|
||||
finally { setAdding(false); }
|
||||
};
|
||||
const evidenceButton = (evidence: ServiceEvidence, index: number) => <button key={`${evidence.project}:${evidence.path}:${evidence.line}:${index}`} onClick={() => inspect(evidence)}>
|
||||
{evidence.summary}<small>{evidence.project} / {evidence.path}:{evidence.line}</small></button>;
|
||||
const incident = selectedService ? shownEdges.filter(edge => edge.source === selectedService.id || edge.target === selectedService.id) : [];
|
||||
const warnings = [...new Set([...(topology?.warnings ?? []), ...(result?.warnings ?? [])])];
|
||||
useSelectionEvidence(onSelectionEvidence, selectedService || selectedEdge ? {
|
||||
project, generation, view: 'architecture-services', source: 'selected indexed Compose manifests and bounded source inspection',
|
||||
label: selectedService?.name ?? `${servicesById.get(selectedEdge!.source)?.name} → ${servicesById.get(selectedEdge!.target)?.name}`,
|
||||
selected: { service: selectedService, connection: selectedEdge,
|
||||
...(selectedEdge ? { sourceService: servicesById.get(selectedEdge.source), targetService: servicesById.get(selectedEdge.target) } : {}),
|
||||
currentSource: source?.key === key ? { location: source.evidence, text: source.text, error: source.error,
|
||||
provenance: 'Current local source; may differ from the indexed snapshot.' } : undefined },
|
||||
relationships: selectedService ? { count: incident.length, items: incident } : undefined,
|
||||
scope: { manifests: selections.map(item => ({ project: item.inventory.project, manifest: item.manifest })), startupDependencies: startup, configuredDestinations: configured },
|
||||
limitations: { warnings, unresolvedDestinations: topology?.unresolved.length, omittedNodes: graph?.omittedNodes, omittedEdges: graph?.omittedEdges,
|
||||
interpretation: 'Static declarations, not live containers. Startup dependencies are not calls. Configured destinations do not prove execution; source ownership is inferred from build inputs.' },
|
||||
} : undefined, active);
|
||||
return <section className="spatial-architecture container-map" aria-label="Container service map">
|
||||
<header className="spatial-heading"><div><span className="spatial-eyebrow">Routes / Services</span><h2>How the services connect</h2>
|
||||
<p>One square per declared service. Follow a connection to see the configuration and code behind it.</p></div>
|
||||
<div className="spatial-camera-controls"><button aria-pressed={!planar} onClick={() => setPlanar(false)}>3D</button><button aria-pressed={planar} onClick={() => setPlanar(true)}>Plan</button>
|
||||
<button onClick={() => setResetKey(value => value + 1)}>Fit map</button><button onClick={() => {
|
||||
<button onClick={() => setResetKey(value => value + 1)}>{text.fitMap}</button><RefreshControl labels={text.refreshFeedback.services} feedback={refresh.feedback} onRefresh={() => {
|
||||
refresh.begin();
|
||||
setRevision(value => value + 1);
|
||||
if (!selections.length || !selections[0].inventory.manifests.length) setDiscoveryRevision(value => value + 1);
|
||||
}}>Refresh</button></div></header>
|
||||
}} /></div></header>
|
||||
<div className="container-projects" aria-label="Deployment sources">{selections.map((selection, index) => <label key={selection.inventory.project}><span>{selection.inventory.project}</span>
|
||||
<select aria-label={`Compose file for ${selection.inventory.project}`} value={selection.manifest} onChange={event => setSelections(current => current.map((item, i) => i === index ? { ...item, manifest: event.target.value } : item))}>
|
||||
<option value="">Source only</option>{selection.inventory.manifests.map(path => <option key={path}>{path}</option>)}</select>
|
||||
{index > 0 && <button aria-label={`Remove ${selection.inventory.project}`} onClick={() => setSelections(current => current.filter((_, i) => i !== index))}>×</button>}</label>)}
|
||||
{selections.length < 4 && <select aria-label="Compare indexed project" value="" disabled={adding} onChange={event => { void addProject(event.target.value); }}><option value="">{adding ? 'Reading project…' : '+ Compare project'}</option>
|
||||
{available.filter(entry => !selections.some(selection => selection.inventory.project === entry.name)).map(entry => <option key={entry.name} value={entry.name}>{entry.name}</option>)}</select>}</div>
|
||||
<div className="spatial-controls"><nav aria-label="Service navigation"><button onClick={clearSelection}>All services</button>{focus && <span> / {servicesById.get(focus)?.name} and neighbors</span>}</nav>
|
||||
<div className="spatial-controls"><div className="atlas-arch-context">{navigation}<nav aria-label="Service navigation"><button onClick={clearSelection}>All services</button>{focus && <span> / {servicesById.get(focus)?.name} and neighbors</span>}</nav></div>
|
||||
<div className="container-edge-key"><span><i className="container-call-key" />Calls</span><label><input type="checkbox" checked={configured} onChange={event => setConfigured(event.target.checked)} />Configured destinations</label>
|
||||
<label><input type="checkbox" checked={startup} onChange={event => setStartup(event.target.checked)} />Startup dependencies</label></div></div>
|
||||
{discovery && <p role="status" className="spatial-notice">{discovery}</p>}
|
||||
{discovery && <div role="status" className="spatial-notice container-discovery" data-discovery={discovery}><p>{text.serviceMap[discovery]}</p>
|
||||
{discovery === 'none' && onShowEndpoints && <button className="spatial-primary" onClick={onShowEndpoints}>{text.showEndpoints}</button>}</div>}
|
||||
{!discovery && selections.length > 0 && !selections.some(selection => selection.manifest) && <p className="spatial-notice">Select a Compose file to show its declared services.</p>}
|
||||
{reading?.key === key && !result && <p role="status" className="spatial-notice">{reading.error ?? 'Reading deployment definitions and bounded source evidence…'}</p>}
|
||||
{graph && topology && <><div className="spatial-map-layout"><div className="spatial-map">
|
||||
@@ -154,14 +189,16 @@ export default function ContainerMap({ project, generation, active, filter, onNa
|
||||
: selectedEdge ? <><span className="spatial-eyebrow">{evidenceLabel(selectedEdge.kind)}</span><h3>{servicesById.get(selectedEdge.source)?.name} → {servicesById.get(selectedEdge.target)?.name}</h3><p>{selectedEdge.protocol}</p>
|
||||
<p className="spatial-muted">{selectedEdge.kind === 'startup' ? 'The deployment declares a startup dependency. This does not establish a network call.' : selectedEdge.kind === 'configuration' ? 'Configuration names this destination. An executed call has not been established.' : 'Static source identifies this destination. The arrow shows the caller, not the direction of every payload.'}</p>
|
||||
<div className="spatial-members">{selectedEdge.evidence.map(evidenceButton)}</div></>
|
||||
: <><span className="spatial-eyebrow">Read the map</span><h3>Services, with evidence.</h3><p>Blue squares have candidate source files. Warm squares have an image declaration or unresolved source ownership.</p><p className="spatial-muted">Compare another indexed project to find connections over explicitly shared networks. Unresolved hosts remain findings, not guessed arrows.</p></>}
|
||||
: <><span className="spatial-eyebrow">Service map</span><h3>{topology.services.length} declared services</h3><p>Select a service or connection to inspect its source. Double-click a service to focus its neighbors.</p></>}
|
||||
{source?.key === key && <div className="container-source"><h4>{source.evidence.project} / {source.evidence.path}:{source.evidence.line}</h4><button onClick={() => setSource(undefined)}>Close source</button><pre>{source.error ?? source.text ?? 'Reading source…'}</pre></div>}
|
||||
</aside></div>
|
||||
<div className="spatial-bottom"><span>{graph.nodes.length} / {topology.services.length} services · {graph.edges.length} connections shown</span><span>{result?.filesRead} source files inspected · {topology.unresolved.length} unresolved findings</span></div>
|
||||
<div className="spatial-bottom"><span>{graph.nodes.length} / {topology.services.length} services · {graph.edges.length} connections shown</span>{!!topology.unresolved.length && <span>{topology.unresolved.length} unresolved destinations</span>}</div>
|
||||
<details className="spatial-map-details"><summary>Browse services and connection evidence</summary>
|
||||
<div className="container-service-list" aria-label="All declared services">{topology.services.map(service => <button key={service.id} aria-pressed={selectedService?.id === service.id} onClick={() => selectService(service.id)} onDoubleClick={() => { selectService(service.id); setFocus(service.id); }}>{service.name}<small>{service.project}{service.profiles?.length ? ' · optional' : ''}</small></button>)}</div>
|
||||
{!!layout?.cycles.length && <details className="spatial-coverage"><summary>Dependency cycles · {layout.cycles.length}</summary>{layout.cycles.map(cycle => <p key={cycle.join('|')}>{cycle.map(id => servicesById.get(id)?.name).join(' ↔ ')} · mutually reachable through detected calls or configured destinations</p>)}</details>}
|
||||
<details className="spatial-relationship-list"><summary>All connections · {shownEdges.length}</summary><div>{shownEdges.map(edge => <button key={edge.id} onClick={() => setSelected({ key, edge: edge.id })}>{servicesById.get(edge.source)?.name} → {servicesById.get(edge.target)?.name}<small>{evidenceLabel(edge.kind)} · {edge.protocol}</small></button>)}</div></details>
|
||||
{!!topology.unresolved.length && <details className="spatial-coverage"><summary>Unresolved destinations · {topology.unresolved.length}</summary><div className="spatial-members">{topology.unresolved.map(evidenceButton)}</div></details>}
|
||||
</details>
|
||||
</>}
|
||||
<details className="spatial-coverage"><summary>Coverage and interpretation{warnings.length ? ` · ${warnings.length} notes` : ''}</summary><p>One selected Compose file per project. Overrides, includes, host environment values, and running-container state are not evaluated. Source grouping is inferred from build inputs, not guaranteed exclusive ownership.</p>
|
||||
{warnings.map(warning => <p key={warning}>{warning}</p>)}</details>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import type { ArchitectureOverviewDto } from '../core/intelligence-provider';
|
||||
import { graphNodeName } from '../galaxy/node-names';
|
||||
import type { GraphData, GraphNode } from '../galaxy/types';
|
||||
import { areaConnections, areaOf, repositoryMap, type MapEvidence } from './repository-map';
|
||||
import { entryCandidates, entryRole, entryRoutes, sourceCandidates, type SourceReader, type SourceQuote } from './repository-guide';
|
||||
@@ -21,7 +22,8 @@ function Nodes({ nodes, onSelect, onNavigate }: Pick<RepositoryMapProps, 'onSele
|
||||
const [limit, setLimit] = useState(24);
|
||||
return <><ul className="repo-map-nodes">{nodes.slice(0, limit).map(node => <li key={node.id}>
|
||||
<button onClick={() => onSelect ? onSelect(node) : onNavigate(node.file_path!, node.start_line, node.name)}>
|
||||
<strong>{node.name}</strong><span>{node.label} · {node.file_path}{node.start_line ? `:${node.start_line}` : ''}</span>
|
||||
{/* Round 4 (N1): a Branch node reads "django-demo · detached HEAD", not "DETACHED". */}
|
||||
<strong>{graphNodeName(node)}</strong><span>{node.label} · {node.file_path}{node.start_line ? `:${node.start_line}` : ''}</span>
|
||||
</button>
|
||||
</li>)}</ul>{nodes.length > limit && <button className="atlas-arch-action" onClick={() => setLimit(limit + 24)}>More symbols ({limit} of {nodes.length})</button>}</>;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
import { lazy, Suspense, useState, type ComponentProps } from 'react';
|
||||
import SpatialArchitecture from './SpatialArchitecture';
|
||||
import type { RoutesPerspective } from './architecture-history';
|
||||
import { architectureText } from './strings';
|
||||
import './container-map.css';
|
||||
const ContainerMap = lazy(() => import('./ContainerMap'));
|
||||
const text = architectureText.routesPerspective;
|
||||
|
||||
type Props = ComponentProps<typeof SpatialArchitecture> & {
|
||||
/** Service map or Endpoints, lifted to the workspace for Back and Forward (K27). */
|
||||
perspective?: RoutesPerspective;
|
||||
onPerspective?: (perspective: RoutesPerspective) => void;
|
||||
};
|
||||
|
||||
/** The endpoint graph remains available independently of deployment metadata. */
|
||||
export default function RoutesArchitecture(props: ComponentProps<typeof SpatialArchitecture>) {
|
||||
const [mode, setMode] = useState<'services' | 'endpoints'>('services');
|
||||
return <><nav className="container-mode" aria-label="Routes perspective">
|
||||
<button aria-pressed={mode === 'services'} onClick={() => setMode('services')}>Service map</button>
|
||||
<button aria-pressed={mode === 'endpoints'} onClick={() => setMode('endpoints')}>Endpoints</button>
|
||||
</nav>{mode === 'services' ? <Suspense fallback={<p role="status">Preparing service map…</p>}><ContainerMap
|
||||
project={props.project} generation={props.generation} active={props.active} filter={props.filter} onNavigate={props.onNavigate} onClearSelection={props.onClearSelection} /></Suspense>
|
||||
export default function RoutesArchitecture({ perspective, onPerspective, ...props }: Props) {
|
||||
const [own, setOwn] = useState<RoutesPerspective>('services');
|
||||
const mode = perspective ?? own;
|
||||
const show = (next: RoutesPerspective) => {
|
||||
if (mode === next) return;
|
||||
props.onSelectionEvidence?.(undefined);
|
||||
if (onPerspective) onPerspective(next); else setOwn(next);
|
||||
};
|
||||
return <><nav className="container-mode" aria-label={text.label}>
|
||||
<button aria-pressed={mode === 'services'} onClick={() => show('services')}>{text.services}</button>
|
||||
<button aria-pressed={mode === 'endpoints'} onClick={() => show('endpoints')}>{text.endpoints}</button>
|
||||
</nav>{mode === 'services' ? <Suspense fallback={<p role="status">{text.preparing}</p>}><ContainerMap
|
||||
project={props.project} generation={props.generation} active={props.active} filter={props.filter} onNavigate={props.onNavigate} onClearSelection={props.onClearSelection} onSelectionEvidence={props.onSelectionEvidence}
|
||||
onShowEndpoints={() => show('endpoints')} navigation={props.navigation} /></Suspense>
|
||||
: <SpatialArchitecture {...props} />}</>;
|
||||
}
|
||||
|
||||
@@ -149,15 +149,18 @@ it('does not invent line evidence for a malformed source body', async () => {
|
||||
|
||||
it('preserves the actual Module EOF line reported by the source API and highlights its call site', async () => {
|
||||
// Recorded local project_lock.c response, start_line=53/end_line=190.
|
||||
// Its trailing empty segment is the indexed EOF line, not a page separator.
|
||||
// Its trailing empty segment is the indexed EOF line, not a page separator:
|
||||
// the range matches. The file itself ends at 189 (review of K13), so the
|
||||
// drawer neither draws an empty line 190 nor names it in the range.
|
||||
const ui = await mount(vi.fn(async () => page(53, { ...projectLockEof, source_mode: 'full',
|
||||
source_truncated: undefined, source_clipped: undefined, next_start_line: undefined })));
|
||||
await ui.render({ filePath: path, line: 61 });
|
||||
expect(ui.container.querySelector('[role=alert]')).toBeNull();
|
||||
expect(ui.container.querySelectorAll('code > span')).toHaveLength(138);
|
||||
expect(ui.container.querySelector('code > span:last-child .source-evidence-line-number')?.textContent).toBe('190');
|
||||
expect(ui.container.querySelectorAll('code > span')).toHaveLength(137);
|
||||
expect(ui.container.querySelector('code > span:last-child .source-evidence-line-number')?.textContent).toBe('189');
|
||||
expect(ui.container.querySelector('[data-selected=true]')?.textContent).toContain('cbm_private_lock_directory_close(directory)');
|
||||
expect(ui.container.textContent).toContain('Lines 53 to 190');
|
||||
expect(ui.container.textContent).toContain('Lines 53 to 189');
|
||||
expect(ui.container.textContent).toContain('end of file');
|
||||
expect([...ui.container.querySelectorAll('button')].find(button => button.textContent === 'Next lines')?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
@@ -179,3 +182,33 @@ it('focuses close, lets Escape close the modeless drawer, then restores its orig
|
||||
await ui.unmount();
|
||||
expect(document.activeElement).toBe(opener); opener.remove();
|
||||
});
|
||||
|
||||
it('says "end of file" when the last page reaches the end of the Module, so a disabled Next is not a mystery (hand test K13)', async () => {
|
||||
// django/contrib/postgres/aggregates/general.py: 65 lines; the Module range ends at 66, the empty segment after the last line break.
|
||||
const last = { start_line: 42, end_line: 66, source: `${Array.from({ length: 24 }, (_, i) => `line ${42 + i}`).join('\n')}\n`,
|
||||
source_truncated: false, source_clipped: false, next_start_line: undefined, original_end_line: undefined };
|
||||
const ui = await mount(vi.fn(async () => page(42, last)));
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
|
||||
const pager = ui.container.querySelector('nav[aria-label="Source pages"]')!;
|
||||
expect(ui.container.querySelector('[role=alert]')).toBeNull();
|
||||
// Review of K13: the range, the lines drawn and the title all end at the file's real last line, 65.
|
||||
expect(pager.textContent).toContain('Lines 42 to 65 · end of file');
|
||||
const numbers = [...ui.container.querySelectorAll('.source-evidence-line-number')].map(entry => entry.textContent);
|
||||
expect(numbers).toHaveLength(24);
|
||||
expect(numbers.at(-1)).toBe('65');
|
||||
const next = [...pager.querySelectorAll('button')].find(button => button.textContent === 'Next lines')!;
|
||||
expect(next.disabled).toBe(true);
|
||||
// The title names the file's real last line, not the empty end segment the range counts.
|
||||
expect(next.title).toBe('Line 65 is the last line of this file.');
|
||||
});
|
||||
|
||||
it('names the last line itself when a file ends without a final line break (hand test K13)', async () => {
|
||||
const last = { start_line: 42, end_line: 66, source: Array.from({ length: 25 }, (_, i) => `line ${42 + i}`).join('\n'),
|
||||
source_truncated: false, source_clipped: false, next_start_line: undefined, original_end_line: undefined };
|
||||
const ui = await mount(vi.fn(async () => page(42, last)));
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
|
||||
const next = [...ui.container.querySelectorAll('nav[aria-label="Source pages"] button')].find(button => button.textContent === 'Next lines')!;
|
||||
expect(next.getAttribute('title')).toBe('Line 66 is the last line of this file.');
|
||||
expect(ui.container.querySelector('nav[aria-label="Source pages"]')!.textContent).toContain('Lines 42 to 66 · end of file');
|
||||
expect([...ui.container.querySelectorAll('.source-evidence-line-number')].at(-1)?.textContent).toBe('66');
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user