feat(ui): integrate repository evidence and change analysis on one daemon

Add source-backed repository navigation, selection context and a shared change
analysis workspace for symbols, working-tree changes and Git comparisons.
Persist agent events and project logs through the existing daemon with SQLite
schema 4, bounded polling, retention and restart-safe deduplication.

Repair compact RPC decoding, path-search races, source pagination and identity,
large map responses, live error propagation and explicit local diagnosis flows.
Include the real-repository browser runners and focused regression coverage.

Validation: 2473 frontend tests, 203 acceptance checks, production build,
style/promise gates, 5 reference-boundary checks and 7 local hook tests pass.
The complete repository-map browser flow also passes after daemon restart.
Global C lint and cross-platform release validation remain open and are
recorded in the accompanying handoff report.

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz committed 2026-09-10 00:08:19 +02:00
1 parent 80f4f41f9b
commit 4372d9fada
130 files changed
+9744 -906

No files matched your search

+7 -4
View File
@@ -413,6 +413,8 @@ CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c src/cli/
UI_SRCS = \
src/ui/config.c \
src/ui/http_server.c \
src/ui/activity.c \
src/ui/atlas_repository.c \
src/ui/layout3d.c \
src/ui/layout_regions.c \
src/ui/atlas_api.c \
@@ -420,6 +422,7 @@ UI_SRCS = \
src/ui/atlas_handout.c \
src/ui/atlas_why.c \
src/ui/atlas_metrics.c \
src/ui/atlas_impact.c \
src/ui/httpd.c \
src/ui/embedded_stub.c
@@ -774,7 +777,7 @@ $(BUILD_DIR)/test-foundation: $(TEST_FOUNDATION_SRCS) $(FOUNDATION_SRCS) $(PROJE
$(CC) $(CFLAGS_TEST) -o $@ $(TEST_FOUNDATION_SRCS) $(FOUNDATION_SRCS) $(LDFLAGS_TEST)
test-foundation: $(BUILD_DIR)/test-foundation
cd $(CURDIR) && $(BUILD_DIR)/test-foundation
cd "$(CURDIR)" && $(BUILD_DIR)/test-foundation
# ── Grammar/TS/LSP object files (compiled with relaxed warnings) ─
@@ -914,13 +917,13 @@ $(BUILD_DIR)/test-runner: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_
$(LDFLAGS_TEST)
test: $(BUILD_DIR)/test-runner
cd $(CURDIR) && $(BUILD_DIR)/test-runner
cd "$(CURDIR)" && $(BUILD_DIR)/test-runner
# Parallel variant: every suite as its own process; identical gate quality
# (see the ZERO-LOSS CONTRACT in scripts/run-tests-parallel.sh — union guard
# against --list-suites + aggregated pass/fail/skip totals).
test-par: $(BUILD_DIR)/test-runner
cd $(CURDIR) && bash scripts/run-tests-parallel.sh $(BUILD_DIR)/test-runner
cd "$(CURDIR)" && bash scripts/run-tests-parallel.sh $(BUILD_DIR)/test-runner
# Focused native development is intentionally a separate, explicit target so
# an inherited TEST_SUITES value cannot silently narrow the gating test target.
@@ -928,7 +931,7 @@ TEST_SUITES ?=
test-focused: $(BUILD_DIR)/test-runner
@test -n "$(strip $(TEST_SUITES))" || \
(echo "TEST_SUITES is required for test-focused"; exit 2)
cd $(CURDIR) && $(BUILD_DIR)/test-runner $(TEST_SUITES)
cd "$(CURDIR)" && $(BUILD_DIR)/test-runner $(TEST_SUITES)
# ── Cumulative bug-reproduction runner (RED board by design, non-gating) ──
# Mirrors test-runner's link line but uses repro_main.c (own main + counters)
+81 -74
View File
@@ -12,9 +12,8 @@ What the maintainers added on top:
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 two loopback
services the reader can start: the local-model sidecar on 127.0.0.1:4141
and the agent bridge on 127.0.0.1:4142 (`src/ui/http_server.h`,
- **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.
- **The `[p]rojects` panel** (alt+p, `src/projects/`): index a repository,
check or remove an index, edit the decision record, read the server's
@@ -106,90 +105,98 @@ 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.
### 1. Die Quelle: eine Ereignisdatei
### Recorded tool activity through the daemon
Ein Werkzeug-Hook schreibt eine JSON-Zeile je Werkzeugaufruf, angehaengt an eine
Datei (JSONL). Die Hook-Skripte dieses Repositories liegen unter
`agents/hooks/`:
`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:
- `agents/hooks/atlas-trace.py` als PostToolUse-Hook. Er endet unter allen
Umstaenden mit 0: ein Protokoll, das die Arbeit aufhalten kann, waere
schlimmer als kein Protokoll.
- `agents/hooks/atlas-trace-watch.mjs` als zweite Quelle. Sie beobachtet
Schreibvorgaenge auf der Platte, sieht also **nur** Schreiben und weder Lesen
noch Suchen noch Testlaeufe, und kennzeichnet jedes Ereignis entsprechend
(`source: "fs"`).
Wohin geschrieben wird, sagt `ATLAS_TRACE_FILE`; ohne die Variable ist es
`~/.atlas-trace/events.jsonl`. Wie der Agent heisst, sagt `ATLAS_AGENT_NAME`.
Einrichtung in der Hook-Konfiguration des Agenten-Werkzeugs (bei Werkzeugen mit
einem `PostToolUse`-Hook ist das eine `settings.json` in deren
Heimatverzeichnis):
```json
{
"hooks": {
"PostToolUse": [
{ "matcher": "*",
"hooks": [{ "type": "command",
"command": "python3 <repo>/agents/hooks/atlas-trace.py" }] }
]
}
}
```sh
python3 ~/Downloads/cbm-atlas-trace.py --install-claude --root /absolute/repository --project INDEXED_NAME --daemon-url http://127.0.0.1:9749
```
**Das Format**, eine Zeile je Ereignis:
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.
| Feld | Bedeutung |
| --- | --- |
| `ts` | Millisekunden seit 1970 |
| `agent` | Anzeigename des Agenten |
| `run` | Kennung eines Laufs |
| `seq` | fortlaufend je Lauf |
| `phase` | `start` oder `end` |
| `tool` | Werkzeugname |
| `path` | optional, repo-relativer Pfad |
| `lines` | optional, `[von, bis]` |
| `detail` | optional, Befehl oder Suchmuster |
| `intent` | optional, Selbstauskunft des Agenten |
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.
Unbekannte Felder werden durchgereicht und nicht als Fehler behandelt: jede
andere Quelle, die dasselbe Format schreibt, wird gelesen.
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.
**Die Ereignisdatei enthaelt Pfade, Zeilenbereiche und Werkzeugnamen und
keine Dateiinhalte.** Kein Quelltext, kein Ausgabetext, kein Diff. Das ist die
Grenze des Formats und keine Einstellung daran: wer die Datei liest, liest, WO
gearbeitet wurde, und nicht, WAS dort steht. Sie kann trotzdem verraten, an
welchen Dateien jemand arbeitet und welche Befehle er faehrt; sie gehoert
deshalb nicht in ein Repository und steht in `.gitignore`.
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.
### 2. Die Bruecke
`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.
```
node tools/agent-bridge.mjs # Vorgabe: ~/.atlas-trace/events.jsonl, Port 4142
node tools/agent-bridge.mjs --file <pfad> --port <port>
node tools/agent-bridge.mjs --replay fixtures/agent-events/w11a-replay.jsonl --port <port>
```
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.
Sie verfolgt die Datei und reicht sie als Server-Sent-Events weiter. Sie **liest
nur**: es gibt keine Route, die etwas hinzufuegt, sie legt die Datei nicht an
und sie loescht sie nicht. Sie spricht ausschliesslich Loopback und weist jede
Verbindung ab, die nicht von 127.0.0.1 kommt.
### The view and logs
Der Wiedergabemodus (`--replay`) liest die Datei einmal und schweigt danach, bis
jemand `POST /replay/advance?count=N` ruft. Das ist die Naht, mit der der
Beweislauf den Takt setzt, statt auf eine Wanduhr zu warten.
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.
### 3. Die Ansicht
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.
Der Live-Modus ist **aus**, bis der Leser ihn einschaltet: im Menue mit
`[g] live agents` (alt+g) oder mit der Zeile `live agents` in der
Kommandozeile. Solange er aus ist, geht keine einzige Anfrage an die Bruecke.
Eingeschaltet ohne laufende Bruecke sagt das Instrument den Zustand und nennt
den Befehl, der sie startet.
Einen anderen Port als 4142 nennt die Adresszeile: `?agents=<port>`.
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:
+192 -103
View File
@@ -1,64 +1,23 @@
#!/usr/bin/env python3
"""Eine JSON-Zeile je Werkzeugaufruf, im Format, das die Bruecke liest.
"""Record tool metadata in a local outbox and POST it to the existing daemon.
Aufruf: als PostToolUse-Hook. Das Werkzeug reicht sein Ereignis als JSON auf
der Standardeingabe herein; dieses Skript haengt eine Zeile an die
Ereignisdatei und endet.
## Drei Regeln, und keine davon ist Geschmack
1. **Es endet immer mit 0, unter allen Umstaenden.** Ein Protokoll, das die
Arbeit aufhalten kann, ist schlimmer als kein Protokoll: ein Hook, der einen
Fehler wirft, bricht den Werkzeugaufruf ab, den er nur beobachten sollte.
Jeder Zweig hier faellt darum still durch.
2. **Keine Dateiinhalte, nie.** Was in eine Zeile kommt, ist: wann, wer, welcher
Lauf, welche Nummer, welches Werkzeug, welcher Pfad, welcher Zeilenbereich
und der Befehl beziehungsweise das Suchmuster. Kein Quelltext, kein
Ausgabetext, kein Diff. Das ist die Grenze des Formats und nicht eine
Einstellung daran: wer die Ereignisdatei liest, liest, WO gearbeitet wurde,
und nicht, WAS dort steht.
3. **Der Zeilenbereich wird gemessen, nicht geraten.** Bei `Read` steht er im
Aufruf (`offset`, `limit`). Bei `Edit` steht er nirgends, also wird er
gesucht: die Stelle des ersetzten Textes in der Datei, umgerechnet in
Zeilennummern. Dafuer wird die Datei gelesen und sofort wieder vergessen;
in die Zeile kommen zwei Zahlen und kein Zeichen des Inhalts. Findet sich
die Stelle nicht, fehlt das Feld, statt eine Zahl zu erfinden.
## Warum `seq` fortlaufend ist und nicht aus der Uhr kommt
Weil die Oberflaeche daran erkennt, dass ihr etwas fehlt. Bei einem Abriss
nimmt sie mit der zuletzt gesehenen Nummer je Lauf wieder auf, und aus der
Differenz wird die Meldung "2 Ereignisse verpasst". Eine Nummer aus der Uhr
(etwa Millisekunden modulo einer Million) sieht fortlaufend aus, laeuft aber
ueber und springt zurueck; die Oberflaeche muesste dann raten, ob eine Luecke
eine Luecke ist. Der Zaehler steht deshalb neben der Ereignisdatei in
`<datei>.seq` und gilt je Lauf.
## Einrichtung
In der Hook-Konfiguration des Agenten-Werkzeugs (eine `settings.json` in
dessen Heimatverzeichnis):
{
"hooks": {
"PostToolUse": [
{ "matcher": "*",
"hooks": [{ "type": "command",
"command": "python3 <repo>/agents/hooks/atlas-trace.py" }] }
]
}
}
Wohin geschrieben wird, sagt `ATLAS_TRACE_FILE`; ohne die Variable ist es
`~/.atlas-trace/events.jsonl`. Wie der Agent heisst, sagt `ATLAS_AGENT_NAME`;
ohne sie steht dort `agent`.
Configure ATLAS_PROJECT to the indexed project name and ATLAS_DAEMON_URL when
using a nondefault daemon port. No bridge, listener or external transmission.
Pending events survive downtime and retry on the next hook or --flush.
The hook always returns zero so observing work cannot break the tool call.
"""
import json
import os
import sys
import time
import sqlite3
import uuid
import urllib.request
import urllib.parse
import argparse
import pathlib
import shlex
import tempfile
DEFAULT_FILE = os.path.join(os.path.expanduser('~'), '.atlas-trace', 'events.jsonl')
@@ -72,64 +31,188 @@ def trace_file():
return os.environ.get('ATLAS_TRACE_FILE') or DEFAULT_FILE
def next_seq(path, run):
"""Die naechste Nummer dieses Laufs, fortlaufend und ohne Ueberlauf.
def open_outbox():
path = trace_file() + '.outbox.sqlite3'
os.makedirs(os.path.dirname(os.path.abspath(path)), mode=0o700, exist_ok=True)
db = sqlite3.connect(path, timeout=0.5)
os.chmod(path, 0o600)
db.executescript("""
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS counters(run TEXT PRIMARY KEY, seq INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS pending(id INTEGER PRIMARY KEY, project TEXT NOT NULL,
payload TEXT NOT NULL);
""")
return db
Der Zaehler liegt als kleines JSON neben der Ereignisdatei. Zwei Agenten,
die gleichzeitig schreiben, koennen sich hier ueberholen; das ist genau der
Fall, den die Oberflaeche als Luecke meldet, und eine Sperre dafuer waere
ein Protokoll, das die Arbeit anhaelt.
"""
counter_path = path + '.seq'
counters = {}
try:
with open(counter_path, 'r', encoding='utf-8') as handle:
loaded = json.load(handle)
if isinstance(loaded, dict):
counters = loaded
except Exception:
counters = {}
value = counters.get(run)
number = (value if isinstance(value, int) and value > 0 else 0) + 1
counters[run] = number
try:
with open(counter_path, 'w', encoding='utf-8') as handle:
json.dump(counters, handle)
except Exception:
pass
return number
def enqueue(db, project, row):
# Counter allocation and enqueue are one SQLite transaction: parallel hooks
# cannot assign the same run/sequence pair and silently discard an event.
with db:
db.execute('BEGIN IMMEDIATE')
db.execute('INSERT INTO counters VALUES(?,1) ON CONFLICT(run) DO UPDATE SET seq=seq+1',
(row['run'],))
row['seq'] = db.execute('SELECT seq FROM counters WHERE run=?', (row['run'],)).fetchone()[0]
db.execute('INSERT INTO pending(project,payload) VALUES(?,?)',
(project, json.dumps(row, ensure_ascii=False)))
# A prolonged daemon outage cannot fill the disk without bound. Sequence
# gaps remain visible when older undelivered metadata expires.
db.execute('DELETE FROM pending WHERE id IN (SELECT id FROM pending ORDER BY id DESC LIMIT -1 OFFSET 10000)')
def flush(db):
origin = os.environ.get('ATLAS_DAEMON_URL', 'http://127.0.0.1:9749')
parsed = urllib.parse.urlsplit(origin)
if (parsed.scheme != 'http' or parsed.hostname not in ('127.0.0.1', 'localhost')
or parsed.username or parsed.password or parsed.path not in ('', '/')
or parsed.query or parsed.fragment):
return # Never forward local metadata to an external endpoint.
first = db.execute('SELECT project FROM pending ORDER BY id LIMIT 1').fetchone()
if not first:
return
rows = db.execute('SELECT id,payload FROM pending WHERE project=? ORDER BY id LIMIT 100',
first).fetchall()
data = json.dumps({'project': first[0], 'events': [json.loads(row[1]) for row in rows]}).encode()
request = urllib.request.Request(origin.rstrip('/') + '/api/agent-events', data=data,
headers={'Content-Type': 'application/json'})
# No environment proxy, redirects, or second server; post only to loopback.
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
with opener.open(request, timeout=0.75) as response:
result = json.load(response)
if response.status != 200 or result.get('accepted', 0) + result.get('duplicates', 0) != len(rows):
return
with db:
db.executemany('DELETE FROM pending WHERE id=?', [(row[0],) for row in rows])
def line_span(file_path, args):
"""Der beruehrte Zeilenbereich, gemessen oder gar nicht.
"""Use an explicit requested source range, never infer it after an edit.
`Read` nennt ihn selbst. `Edit` und `Write` nennen ihn nicht, also wird die
Stelle des alten Textes in der Datei gesucht und in Zeilennummern
umgerechnet. Nichts davon kommt in die Zeile ausser den zwei Zahlen.
PostToolUse runs after mutation. Searching old_string could locate a different,
unchanged occurrence and falsely attribute that line to the observed edit.
"""
offset = args.get('offset')
limit = args.get('limit')
if isinstance(offset, int) and offset > 0:
if isinstance(limit, int) and limit > 0:
if isinstance(offset, int) and not isinstance(offset, bool) and offset > 0:
if isinstance(limit, int) and not isinstance(limit, bool) and limit > 0:
return [offset, offset + limit - 1]
return [offset, offset]
needle = args.get('old_string')
if not isinstance(needle, str) or len(needle) == 0 or not file_path:
return None
return None
def daemon_origin(value):
parsed = urllib.parse.urlsplit(value)
if (parsed.scheme != 'http' or parsed.hostname not in ('127.0.0.1', 'localhost')
or parsed.username or parsed.password or parsed.path not in ('', '/')
or parsed.query or parsed.fragment):
raise ValueError('The daemon URL must be a loopback HTTP origin.')
return value.rstrip('/')
def install_claude(root, project, origin):
"""Explicit, project-local install; preserve unrelated settings and hooks.
No package downloads or network requests. Refuse symlinks, malformed settings,
and conflicting older installations rather than replace user configuration.
"""
root = pathlib.Path(root).resolve(strict=True)
if not root.is_dir() or not project or len(project.encode()) > 255:
raise ValueError('Choose the indexed repository root and exact project name.')
origin = daemon_origin(origin)
directory = root / '.claude'
hook_dir = directory / 'hooks'
settings = directory / 'settings.local.json'
target = hook_dir / 'cbm-atlas-trace.py'
if any(path.is_symlink() for path in (directory, hook_dir, settings, target)):
raise ValueError('Refusing symlinked hook or settings paths.')
original = settings.read_bytes() if settings.exists() else None
config = json.loads(original) if original is not None else {}
if not isinstance(config, dict):
raise ValueError('Existing settings must be a JSON object.')
hooks = config.setdefault('hooks', {})
if not isinstance(hooks, dict):
raise ValueError('Existing hooks must be an object.')
entries = hooks.setdefault('PostToolUse', [])
if not isinstance(entries, list):
raise ValueError('Existing PostToolUse hooks must be an array.')
command = ('ATLAS_PROJECT=' + shlex.quote(project) + ' ATLAS_AGENT_NAME=' + shlex.quote('Claude Code')
+ ' ATLAS_DAEMON_URL=' + shlex.quote(origin)
+ ' python3 ' + shlex.quote(str(target)))
proposed = {'matcher': '', 'hooks': [{'type': 'command', 'command': command, 'timeout': 3}]}
installed = proposed in entries
for entry in entries:
for handler in entry.get('hooks', []) if isinstance(entry, dict) else []:
if not isinstance(handler, dict):
continue
try:
words = shlex.split(str(handler.get('command', '')))
except ValueError:
words = []
references_target = any(word in (str(target), '.claude/hooks/cbm-atlas-trace.py',
'./.claude/hooks/cbm-atlas-trace.py') for word in words)
if references_target and entry != proposed:
raise ValueError('An existing Atlas hook differs. Review its matcher, command and timeout before changing it.')
source = pathlib.Path(__file__).read_bytes()
if target.exists() and target.read_bytes() != source:
raise ValueError('An existing Atlas hook file differs. Review it before replacing it.')
if not installed:
entries.append(proposed)
encoded = (json.dumps(config, indent=2, ensure_ascii=False) + '\n').encode()
# A changed file is never overwritten after our read. Directory operations
# happen only after the complete, reviewable configuration was validated.
directory.mkdir(mode=0o700, exist_ok=True)
hook_dir.mkdir(mode=0o700, exist_ok=True)
if (settings.read_bytes() if settings.exists() else None) != original:
raise ValueError('Settings changed during setup; retry after reviewing them.')
created_hook = not target.exists()
if created_hook:
with target.open('xb') as handle:
handle.write(source)
target.chmod(0o600)
if not installed:
descriptor, temporary = tempfile.mkstemp(prefix='.cbm-atlas-settings-', dir=directory)
try:
with os.fdopen(descriptor, 'wb') as handle:
handle.write(encoded)
if (settings.read_bytes() if settings.exists() else None) != original:
raise ValueError('Settings changed during setup; nothing was replaced.')
os.replace(temporary, settings)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
return {'installed': True, 'changed': not installed or created_hook,
'settings': str(settings), 'hook': str(target), 'project': project,
'daemon': origin, 'next': 'Start a new Claude Code session in this repository, perform a tool call, then load activity.'}
def setup_main():
parser = argparse.ArgumentParser(description='Install the local Code Atlas Claude Code tool hook.')
parser.add_argument('--install-claude', action='store_true', required=True)
parser.add_argument('--root', required=True, help='Exact indexed repository root')
parser.add_argument('--project', required=True, help='Exact indexed project name')
parser.add_argument('--daemon-url', default='http://127.0.0.1:9749')
args = parser.parse_args()
try:
with open(file_path, 'r', encoding='utf-8') as handle:
body = handle.read()
except Exception:
return None
at = body.find(needle)
if at < 0:
return None
start = body.count('\n', 0, at) + 1
return [start, start + needle.count('\n')]
result = install_claude(args.root, args.project, args.daemon_url)
except Exception as error:
print('Atlas setup: ' + str(error), file=sys.stderr)
return 1
print(json.dumps(result, ensure_ascii=False))
return 0
def main():
if '--flush' in sys.argv:
try:
with open_outbox() as db:
flush(db)
except Exception:
pass
return 0
try:
payload = json.load(sys.stdin)
except Exception:
@@ -151,28 +234,34 @@ def main():
or args.get('description')
or ''
)
run = str(payload.get('session_id') or 'session')[:8]
run = str(payload.get('session_id') or uuid.uuid4())
root = str(payload.get('cwd') or os.getcwd())
project = os.environ.get('ATLAS_PROJECT', os.path.basename(root.rstrip(os.sep)))
row = {
'ts': int(time.time() * 1000),
'agent': os.environ.get('ATLAS_AGENT_NAME', 'agent'),
'run': run,
'seq': next_seq(path, run),
'seq': 0,
'phase': 'end',
'tool': tool,
'path': str(file_path or args.get('path') or ''),
'detail': str(detail)[:DETAIL_CAP],
'source': 'tool-hook',
}
span = line_span(file_path, args)
span = line_span(file_path, args) if tool == 'Read' else None
if span is not None:
row['lines'] = span
with open(path, 'a', encoding='utf-8') as handle:
handle.write(json.dumps(row, ensure_ascii=False) + '\n')
if os.path.isabs(row['path']):
row['path'] = os.path.relpath(row['path'], root)
with open_outbox() as db:
enqueue(db, project, row)
flush(db)
except Exception:
pass
return 0
if __name__ == '__main__':
sys.exit(main())
sys.exit(setup_main() if '--install-claude' in sys.argv else main())
+157
View File
@@ -0,0 +1,157 @@
"""Reproducible local producer tests; no external network or agent accounts."""
import concurrent.futures
import importlib.util
import json
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location('atlas_trace', Path(__file__).with_name('atlas-trace.py'))
hook = importlib.util.module_from_spec(spec)
spec.loader.exec_module(hook)
class TraceTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.environment = patch.dict(os.environ, {
'ATLAS_TRACE_FILE': os.path.join(self.directory.name, 'events'),
'ATLAS_DAEMON_URL': 'http://127.0.0.1:9749',
})
self.environment.start()
def tearDown(self):
self.environment.stop()
self.directory.cleanup()
def record(self, number):
with hook.open_outbox() as db:
hook.enqueue(db, 'fixture', {'run': 'concurrent-run', 'seq': 0, 'ts': number,
'agent': 'fixture', 'tool': 'Read', 'phase': 'end'})
def test_concurrent_hooks_allocate_unique_sequences_and_keep_pending_after_failure(self):
with hook.open_outbox():
pass
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(self.record, range(20)))
with hook.open_outbox() as db:
rows = db.execute('SELECT payload FROM pending').fetchall()
self.assertEqual(sorted(json.loads(row[0])['seq'] for row in rows), list(range(1, 21)))
with patch.object(hook.urllib.request, 'build_opener', side_effect=OSError('offline')):
with self.assertRaises(OSError):
hook.flush(db)
self.assertEqual(db.execute('SELECT count(*) FROM pending').fetchone()[0], 20)
def test_external_destinations_and_credentials_are_never_contacted(self):
self.record(1)
for origin in ('https://example.com', 'http://example.com',
'http://127.0.0.1.evil.test', 'http://secret@localhost:9749',
'http://localhost:9749/redirect', 'http://localhost:9749?secret=1'):
with patch.dict(os.environ, {'ATLAS_DAEMON_URL': origin}), \
patch.object(hook.urllib.request, 'build_opener') as network, \
hook.open_outbox() as db:
hook.flush(db)
network.assert_not_called()
self.assertEqual(db.execute('SELECT count(*) FROM pending').fetchone()[0], 1)
def test_explicit_setup_preserves_settings_is_idempotent_and_escapes_names(self):
root = Path(self.directory.name) / "repo with 'quote'"
(root / '.claude').mkdir(parents=True)
settings = root / '.claude/settings.local.json'
original = {'permissions': {'allow': ['Read']}, 'hooks': {'PostToolUse': [
{'matcher': 'Read', 'hooks': [{'type': 'command', 'command': 'existing-hook'}]}]}}
settings.write_text(json.dumps(original))
with patch.object(hook.urllib.request, 'build_opener') as network:
result = hook.install_claude(root, "project'; echo bad", 'http://127.0.0.1:9749')
again = hook.install_claude(root, "project'; echo bad", 'http://127.0.0.1:9749')
network.assert_not_called()
self.assertTrue(result['changed'])
self.assertFalse(again['changed'])
installed = json.loads(settings.read_text())
self.assertEqual(installed['permissions'], original['permissions'])
self.assertEqual(installed['hooks']['PostToolUse'][0], original['hooks']['PostToolUse'][0])
self.assertEqual(len(installed['hooks']['PostToolUse']), 2)
command = installed['hooks']['PostToolUse'][1]['hooks'][0]['command']
self.assertEqual(hook.shlex.split(command)[0], "ATLAS_PROJECT=project'; echo bad")
self.assertEqual(hook.shlex.split(command)[-1], str(root.resolve() / '.claude/hooks/cbm-atlas-trace.py'))
before = settings.read_bytes()
with self.assertRaises(ValueError):
hook.install_claude(root, 'another-project', 'http://127.0.0.1:9749')
self.assertEqual(settings.read_bytes(), before)
def test_setup_refuses_symlinks_malformed_settings_and_external_daemons_before_writes(self):
root = Path(self.directory.name) / 'repo'
root.mkdir()
with self.assertRaises(ValueError):
hook.install_claude(root, 'fixture', 'https://example.com')
self.assertFalse((root / '.claude').exists())
(root / '.claude').mkdir()
settings = root / '.claude/settings.local.json'
settings.write_text('malformed user data')
with self.assertRaises(ValueError):
hook.install_claude(root, 'fixture', 'http://127.0.0.1:9749')
self.assertEqual(settings.read_text(), 'malformed user data')
settings.unlink()
target = root / 'external-settings.json'
target.write_text('{}')
settings.symlink_to(target)
with self.assertRaises(ValueError):
hook.install_claude(root, 'fixture', 'http://127.0.0.1:9749')
self.assertEqual(target.read_text(), '{}')
self.assertFalse((root / '.claude/hooks').exists())
def test_setup_refuses_a_second_execution_with_a_different_matcher_or_timeout(self):
root = Path(self.directory.name) / 'TEST fixture'
root.mkdir()
hook.install_claude(root, 'fixture', 'http://127.0.0.1:9749')
settings = root / '.claude/settings.local.json'
pristine = json.loads(settings.read_text())
for change in ('matcher', 'timeout', 'relative-command'):
config = json.loads(json.dumps(pristine))
entry = config['hooks']['PostToolUse'][0]
if change == 'matcher':
entry['matcher'] = 'Read'
elif change == 'timeout':
entry['hooks'][0]['timeout'] = 5
else:
entry['hooks'][0]['command'] = 'python3 .claude/hooks/cbm-atlas-trace.py'
settings.write_text(json.dumps(config))
before = settings.read_bytes()
with self.assertRaisesRegex(ValueError, 'existing Atlas hook differs'):
hook.install_claude(root, 'fixture', 'http://127.0.0.1:9749')
self.assertEqual(settings.read_bytes(), before)
self.assertEqual(len(json.loads(settings.read_text())['hooks']['PostToolUse']), 1)
def test_post_edit_range_is_unknown_instead_of_an_unmodified_duplicate_occurrence(self):
source = Path(self.directory.name) / 'edited.txt'
source.write_text('new value\nold value\n')
with patch('builtins.open', side_effect=AssertionError('Metadata hook must not read source')):
self.assertIsNone(hook.line_span(str(source), {'old_string': 'old value', 'new_string': 'new value'}))
self.assertEqual(hook.line_span(str(source), {'offset': 8, 'limit': 3}), [8, 10])
self.assertIsNone(hook.line_span(str(source), {'offset': True}))
def test_acknowledged_duplicate_retry_removes_only_acknowledged_rows(self):
self.record(1)
self.record(2)
class Response:
status = 200
def __enter__(self): return self
def __exit__(self, *args): return None
def read(self): return b'{"accepted":0,"duplicates":2}'
class Opener:
def open(self, request, timeout):
self_body = json.loads(request.data)
assert request.full_url == 'http://127.0.0.1:9749/api/agent-events'
assert self_body['project'] == 'fixture'
assert len(self_body['events']) == 2
return Response()
with patch.object(hook.urllib.request, 'build_opener', return_value=Opener()), \
hook.open_outbox() as db:
hook.flush(db)
self.assertEqual(db.execute('SELECT count(*) FROM pending').fetchone()[0], 0)
if __name__ == '__main__':
unittest.main()
+183 -226
View File
@@ -119,6 +119,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import AtlasChrome, { CLOSE_COMMAND_SEARCH_EVENT, OPEN_COMMAND_SEARCH_EVENT } from './app/AtlasChrome';
import ArchitecturePanel from './architecture/ArchitecturePanel';
import SelectionContextPanel from './why/SelectionContext';
import ChangeAnalysisWorkspace from './impact/ChangeAnalysisWorkspace';
import type { SelectionImpactTarget } from './impact/selection-impact';
import SourceEvidenceDrawer, { fileQualifiedName, type SourceEvidenceTarget } from './architecture/SourceEvidenceDrawer';
import { setUiLogProject } from './app/ui-log-install';
import DaemonAlerts from './diagnostics/DaemonAlerts';
import DiagnosticsPanel from './diagnostics/DiagnosticsPanel';
import { useDaemonLogFeed } from './diagnostics/useDaemonLogFeed';
import { resolveRepositorySelection, useRepositorySnapshot } from './architecture/repository-source';
import ActivityPanel from './agents/ActivityPanel';
import WelcomePanel from './app/WelcomePanel';
import BrowserChatDock from './browser-ai/BrowserChatDock';
@@ -192,12 +201,11 @@ import {
searchByMeaning,
} from './search/find-by-meaning';
import type { RankedHit } from './search/semantic-search';
import { rankHits } from './search/semantic-search';
import {
commandExamplesFor,
exampleSymbolOf,
} from './search/command-examples';
import { EMPTY_LOCAL_INDEX, localCandidates, settledSearchHits } from './search/local-suggestions';
import { EMPTY_LOCAL_INDEX, localCandidates, rankLocalCandidates, settledSearchHits } from './search/local-suggestions';
import type { LocalIndex } from './search/local-suggestions';
import {
isSearchable,
@@ -259,12 +267,7 @@ 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 ImpactPanel from './impact/ImpactPanel';
import type { ImpactMode, ImpactStatus } from './impact/ImpactPanel';
import { readImpact } from './impact/impact-source';
import { refRejection } from './impact/impact-model';
import type { ImpactModel, ImpactTarget } from './impact/impact-model';
import { IMPACT_MENU_LABEL, impactRefRejected } from './impact/impact-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';
@@ -744,12 +747,17 @@ declare global {
export default function App(): JSX.Element {
const [workspace, setWorkspace] = useState<Workspace>(() => {
try {
const requested = new URLSearchParams(window.location.search).get('workspace');
if (requested === 'explore' || requested === 'galaxy' || requested === 'architecture' || requested === 'agents' || requested === 'system') return requested;
const saved = localStorage.getItem('cbm.workspace');
return saved === 'explore' || saved === 'galaxy' || saved === 'agents' || saved === 'system' ? saved : 'architecture';
} catch { return 'architecture'; }
});
const changeWorkspace = (value: Workspace): void => {
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. */ }
};
const [welcomeOpen, setWelcomeOpen] = useState(() => {
@@ -778,6 +786,7 @@ export default function App(): JSX.Element {
};
const client = useMemo(() => new RpcIntelligenceClient({}), []);
const api = useMemo(() => new AtlasApi({}), []);
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
/*
* What the projects panel asks the server. The list comes over /rpc like
@@ -807,6 +816,9 @@ export default function App(): JSX.Element {
}, []);
const [project, setProject] = useState('');
const [logScopeChoice, setLogScopeChoice] = useState<{ project: string; scope: 'project' | 'daemon' }>();
const logScope = logScopeChoice?.project === project ? logScopeChoice.scope : 'project';
const daemonLogFeed = useDaemonLogFeed(api, Boolean(project), 3000, logScope === 'project' ? project : undefined);
const [projectDetail, setProjectDetail] = useState('resolving project ...');
const [serverOk, setServerOk] = useState<boolean | undefined>(undefined);
const [counts, setCounts] = useState<{ nodes?: number; edges?: number }>({});
@@ -951,8 +963,8 @@ export default function App(): JSX.Element {
* Der Live-Modus der Agenten (W11a).
*
* AUS per Vorgabe, und er wird auch nicht gespeichert. Dieselbe Regel wie
* beim lokalen Modell: solange er aus ist, geht keine einzige Anfrage an die
* Bruecke, und eine Seite, die nach dem naechsten Laden von selbst wieder zu
* beim lokalen Modell: solange er aus ist, geht keine einzige Agentenabfrage an den
* Daemon, und eine Seite, die nach dem naechsten Laden von selbst wieder zu
* reden anfaengt, waere ein Schalter, ueber den der Leser nur einmal
* entschieden hat. Er steht hier und nicht im Graph-Panel, weil ihn zwei
* Wege umlegen: der Menuepunkt und die Kommandozeile.
@@ -967,19 +979,12 @@ export default function App(): JSX.Element {
* das ganze Fenster fuellt.
*/
const [fullscreenToggle, setFullscreenToggle] = useState(0);
/*
* Der Port der Bruecke, aus der Adresszeile.
*
* `?agents=<port>` und sonst 4142. Aus der Adresse und nicht aus einer
* Einstellung, weil es keine Wahl des Lesers ist, sondern die Lage seiner
* Maschine: wer eine zweite Bruecke auf einem anderen Port faehrt, sagt es
* beim Aufrufen, und ein Beweislauf tut dasselbe.
*/
// Compatibility prop for existing HUD consumers; the stream uses this daemon origin.
const bridgePort = useMemo(
() => bridgePortFromSearch(typeof location === 'undefined' ? '' : location.search),
[],
);
const agentStream = useAgentStream({ on: liveAgentsOn, port: bridgePort });
const agentStream = useAgentStream({ on: liveAgentsOn, project });
const agents = useMemo(
() => ({ ...agentStream, port: bridgePort, on: liveAgentsOn }),
[agentStream, bridgePort, liveAgentsOn],
@@ -1108,6 +1113,8 @@ 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);
const [searchStatus, setSearchStatus] = useState<SearchOverlayStatus>('ready');
@@ -1358,6 +1365,7 @@ export default function App(): JSX.Element {
const [overviewLoading, setOverviewLoading] = useState(false);
const [overviewError, setOverviewError] = useState<string | undefined>();
const [overviewRevision, setOverviewRevision] = useState(0);
const repositoryReading = useRepositorySnapshot(project, overviewRevision);
const [entryQuery, setEntryQuery] = useState('');
const [entryHits, setEntryHits] = useState<RankedHit[]>(noHits);
const [entryStatus, setEntryStatus] = useState<EntrySearchStatus>('idle');
@@ -1384,24 +1392,6 @@ export default function App(): JSX.Element {
const bugTicket = useRef(0);
const impactOpen = explainOpen && explainTab === 'change';
const [impactMode, setImpactMode] = useState<ImpactMode>('worktree');
const [impactModel, setImpactModel] = useState<ImpactModel | undefined>(undefined);
const [impactStatus, setImpactStatus] = useState<ImpactStatus>('idle');
const [impactMessage, setImpactMessage] = useState('');
const [impactRouteNote, setImpactRouteNote] = useState('');
const [refDraft, setRefDraft] = useState('');
const [refError, setRefError] = useState('');
/**
* Der Vergleichspunkt, gegen den die angezeigte Lesung wirklich gefahren
* wurde.
*
* Getrennt vom Feldinhalt, und das ist der Punkt: wer tippt, aendert nicht
* die Frage, die auf dem Bildschirm beantwortet ist. Erst der Knopf setzt
* ihn, und nur ein Ref, dessen Form geprueft wurde, kommt hier an.
*/
const [appliedRef, setAppliedRef] = useState<string | undefined>(undefined);
const impactTicket = useRef(0);
// ------------------------------------------ Das lokale Modell (W5a) ----
/*
@@ -1518,7 +1508,11 @@ export default function App(): JSX.Element {
const readerFocusRange = useMemo(() => readerSelection?.path === activePath
? { startLine: readerSelection.startLine, endLine: readerSelection.endLine > readerSelection.startLine && readerSelection.endColumn === 1 ? readerSelection.endLine - 1 : readerSelection.endLine }
: caretLine !== undefined && caretLine > 0 ? { startLine: caretLine, endLine: caretLine } : undefined, [readerSelection, activePath, caretLine]);
const activeGalaxySelection = galaxySelectionProject === project ? galaxySelection : undefined;
const activeGalaxySelection = useMemo(() => resolveRepositorySelection(
galaxySelectionProject === project ? galaxySelection : undefined, repositoryReading.snapshot,
), [galaxySelectionProject, project, galaxySelection, repositoryReading.snapshot]);
const selectionUnavailable = Boolean(galaxySelectionProject === project && galaxySelection && repositoryReading.snapshot && !activeGalaxySelection);
const selectionUnavailableText = 'The previous selection is absent from this bounded index snapshot. Choose a source element again to inspect its current evidence.';
const selectedGraphSymbol = useMemo(() => hierarchySymbolOf(activeGalaxySelection, project), [activeGalaxySelection, project]);
const readerFocusSymbol = (twinSymbol?.projectName === undefined || twinSymbol.projectName === project) && symbolMatchesReader(twinSymbol, activePath, readerFocusRange)
? twinSymbol : symbolMatchesReader(selectedGraphSymbol, activePath, readerFocusRange) ? selectedGraphSymbol : undefined;
@@ -1532,6 +1526,7 @@ export default function App(): JSX.Element {
useEffect(() => {
const fromUrl = new URLSearchParams(window.location.search).get('project') ?? '';
if (fromUrl.length > 0) {
setUiLogProject(fromUrl);
setProject(fromUrl);
setProjectDetail('');
return;
@@ -1548,7 +1543,8 @@ export default function App(): JSX.Element {
setProjectDetail(messages.statusbar.noProjectIndexed);
return;
}
setProject(first.name);
setUiLogProject(first.name);
setProject(first.name);
setProjectDetail('');
})
.catch((error: unknown) => {
@@ -1594,7 +1590,7 @@ export default function App(): JSX.Element {
return () => {
cancelled = true;
};
}, [client, project]);
}, [client, project, overviewRevision]);
// ------------------------------------------------------- Baum ----------
@@ -1640,7 +1636,7 @@ export default function App(): JSX.Element {
return () => {
cancelled = true;
};
}, [api, project]);
}, [api, project, overviewRevision]);
/*
* Die zweite Baumquelle: der Coverage-Store.
@@ -1683,7 +1679,7 @@ export default function App(): JSX.Element {
return () => {
cancelled = true;
};
}, [client, project]);
}, [client, project, overviewRevision]);
/**
* Die Vereinigung, aus der der Explorer zeichnet.
@@ -2305,7 +2301,7 @@ export default function App(): JSX.Element {
const onLayout = useCallback((loaded: GraphData) => {
setLayout(loaded);
}, []);
}, [project]);
/**
* Ein Klick in die Galaxie.
@@ -2429,7 +2425,7 @@ export default function App(): JSX.Element {
: localPool;
return pool.length === 0
? noHits
: rankHits(pool, query, fanInOf).slice(0, MAX_SEARCH_ROWS);
: rankLocalCandidates(pool, query, fanInOf, MAX_SEARCH_ROWS);
},
[fanInOf, localPool],
);
@@ -2471,9 +2467,12 @@ export default function App(): JSX.Element {
*/
useEffect(() => {
const query = command.trim();
// Retire the previous response before the next debounce window begins.
searchTicket.current += 1;
const ticket = searchTicket.current;
searchAbort.current?.abort();
searchAbort.current = undefined;
if (project.length === 0 || !isSearchable(query, SEARCH_MIN_QUERY)) {
searchAbort.current?.abort();
searchAbort.current = undefined;
setHits(noHits);
setAnsweredQuery('');
setHitSource('index');
@@ -2499,8 +2498,6 @@ export default function App(): JSX.Element {
searchAbort.current?.abort();
const controller = new AbortController();
searchAbort.current = controller;
searchTicket.current += 1;
const ticket = searchTicket.current;
searchStats.current.serverRequests += 1;
const started = performance.now();
searchByMeaning(
@@ -2561,11 +2558,16 @@ export default function App(): JSX.Element {
);
});
}, SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timer);
return () => {
window.clearTimeout(timer);
searchTicket.current += 1;
searchAbort.current?.abort();
};
}, [command, project, provider, fanInOf]);
const closeSearch = useCallback(() => {
window.dispatchEvent(new Event(CLOSE_COMMAND_SEARCH_EVENT));
searchTicket.current += 1;
searchAbort.current?.abort();
searchAbort.current = undefined;
setCommand('');
@@ -2763,7 +2765,7 @@ export default function App(): JSX.Element {
setWhyAnswer(recordWhyAnswer(store, project, intent));
setWhyReopened(false);
if (intent === 'understand') {
void startProjectTour();
changeWorkspace('architecture');
} else if (intent === 'entry') {
setEntryOpen(true);
} else if (intent === 'bug') {
@@ -2772,7 +2774,7 @@ export default function App(): JSX.Element {
openExplain('change');
}
},
[openExplain, project, startProjectTour, store],
[openExplain, project, changeWorkspace, store],
);
const declineWhy = useCallback(() => {
@@ -3044,95 +3046,6 @@ export default function App(): JSX.Element {
[project, provider],
);
// ----------------------------- Die Aenderungsansicht: Modus, Ref, Lesung -
const chooseImpactMode = useCallback((mode: ImpactMode) => {
setImpactMode(mode);
setRefError('');
if (mode === 'worktree') {
setAppliedRef(undefined);
}
}, []);
/**
* Der Knopf neben dem Ref-Feld.
*
* Die Form wird hier geprueft und der Aufruf unterbleibt, wenn sie nicht
* stimmt. Warum nicht die Engine gefragt wird, steht bei `refRejection`: sie
* nimmt ein unbekanntes `since` kommentarlos an und antwortet ueber den
* Arbeitsbaum, also waere ein Tippfehler eine plausible Antwort auf eine
* andere Frage.
*/
const applyRef = useCallback(() => {
const value = refDraft.trim();
const rejection = refRejection(value);
if (rejection !== undefined) {
setRefError(impactRefRejected(value, rejection));
return;
}
setRefError('');
setAppliedRef(value);
}, [refDraft]);
useEffect(() => {
if (!impactOpen || project.length === 0) {
return;
}
impactTicket.current += 1;
const ticket = impactTicket.current;
setImpactStatus('loading');
setImpactMessage('');
readImpact(provider, ATLAS_WORKSPACE_ROOT, {
projectName: project,
generation: 1,
...(appliedRef === undefined ? {} : { sinceRef: appliedRef }),
// Der Routen-Scan liest den Quelltext ueber denselben Weg wie der
// Reader. Einen zweiten Weg zu einer Datei gaebe es hier nicht, und
// ein zweiter waere eine zweite Stelle, an der eine Kappung
// interpretiert wird.
readSource: async (filePath) => {
const loaded = await loadFileDocument(client, project, filePath).catch(() => undefined);
return loaded === undefined
? undefined
: { source: loaded.source, truncated: loaded.truncated };
},
})
.then((reading) => {
if (ticket !== impactTicket.current) {
return;
}
setImpactModel(reading.model);
setImpactRouteNote(reading.routeNote);
setImpactStatus('ready');
})
.catch((error: unknown) => {
if (ticket !== impactTicket.current) {
return;
}
setImpactModel(undefined);
setImpactStatus('failed');
setImpactMessage(error instanceof Error ? error.message : String(error));
});
}, [appliedRef, client, impactOpen, project, provider]);
/** Eine Zeile der Aenderungsansicht oeffnen: derselbe Weg wie jeder Klick. */
const openImpactRow = useCallback(
(target: ImpactTarget) => {
const ref = twinTargetOf({
name: target.name,
qualifiedName: target.qualifiedName,
kind: 'unknown',
filePath: target.filePath,
startLine: target.line,
});
if (ref === undefined) {
return;
}
followRef.current(ref);
},
[],
);
// ------------------------------------------ Das lokale Modell (W5a) ----
/*
@@ -4570,9 +4483,109 @@ export default function App(): JSX.Element {
const walkStepQualifiedName =
walkStep !== undefined && walkStep.kind === 'symbol' ? walkStep.qualifiedName : undefined;
const liveIr = matchingInspectorIr(twinIr, readerFocusSymbol, project, activePath);
const liveOutline = useMemo(() => liveIr ? buildPseudocode(
{ kind: 'symbol', label: liveIr.symbol.name },
{ irs: [liveIr], ...(layout === undefined ? {} : { graph: layout }) },
) : undefined, [liveIr, layout]);
const liveSelection = readerSelection?.path === activePath && document?.path === activePath
? readerSelection : undefined;
const symbolList = fileSymbols.project === project && fileSymbols.path === activePath ? fileSymbols : undefined;
const liveCode: SelectedCodeSnapshot = {
project, filePath: activePath, symbol: readerFocusSymbol, ir: liveIr,
selection: liveSelection, document: document?.path === activePath ? document : undefined,
imports: activePath ? imports : undefined,
pseudocode: liveOutline,
status: liveIr ? 'ready' : readerStatus === 'loading' ? 'loading'
: twinStatus === 'failed' ? 'failed' : twinStatus === 'not-indexed' ? 'not-indexed'
: readerFocusSymbol ? 'loading' : 'empty',
message: twinHint || twinMessage,
coverageNote: coverageNote?.text,
fileSymbols: symbolList?.symbols,
fileSymbolsStatus: symbolList?.status ?? (activePath ? 'loading' : 'ready'),
fileSymbolsMessage: symbolList?.message,
};
const inspector = pinnedCode?.project === project ? pinnedCode : liveCode;
const evidenceGraph = repositoryReading.snapshot;
const inspectorNode = evidenceGraph?.nodes.find(node => inspector.symbol?.qualifiedName
? node.qualified_name === inspector.symbol.qualifiedName
: 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' });
if (target) followTarget(target);
};
const navigateEvidence = (filePath: string, line?: number, name?: string): void => {
setSourceEvidence({ project, target: { filePath, line, name } });
};
const readMapSource = useCallback(async (path: string, startLine = 1, maxLines = 65) => {
const qualifiedName = fileQualifiedName(repositoryReading.snapshot, path);
if (!qualifiedName) throw new Error(workspaceText.sourceOutsideSnapshot);
return client.getCodeSnippet(project, qualifiedName, { startLine, maxLines });
}, [client, project, repositoryReading.snapshot]);
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 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={agents.state} onNavigate={navigateEvidence}
onImpact={() => openSelectionImpact(mapImpactTarget)} />
</>;
const followInspectorTarget = (target: SymbolRef): void => {
setPinnedCode(undefined);
followTarget(target);
};
const askInspector = (): void => {
if (!inspector.filePath) return;
setChatAttachment(inspector.selection ? { ...inspector.selection, project: inspector.project, id: crypto.randomUUID() } : undefined);
setChatGraphSelection(inspectorChatContext(inspector, crypto.randomUUID()));
setBrowserAiOpen(true);
};
const showInspectorGraph = (): void => {
const node = layout?.nodes.find(entry => inspector.symbol?.qualifiedName
? entry.qualified_name === inspector.symbol.qualifiedName
: entry.file_path === inspector.filePath && (entry.label === 'File' || entry.label === 'Module'));
if (node) {
setGalaxySelection(node);
setGalaxySelectionProject(project);
changeWorkspace('galaxy');
} else {
setGalaxyOn(true);
if (inspector.symbol) followTarget(inspector.symbol);
else if (inspector.filePath) openFile(inspector.filePath);
}
};
const selectedCode = <SelectedCodePanel {...inspector}
selectionContext={<SelectionContextPanel graph={evidenceGraph}
selected={inspector.symbol ? inspectorNode : undefined} path={inspector.filePath}
agents={agents.state} onNavigate={navigateEvidence} />}
impact={<button className="atlas-analysis-action" disabled={!inspector.filePath} onClick={() => openSelectionImpact(inspectorImpactTarget)}>{workspaceText.selectionImpact} →</button>}
pinned={pinnedCode?.project === project}
onTogglePin={() => setPinnedCode(current => current?.project === project ? undefined : { ...liveCode,
selection: liveCode.selection ? { ...liveCode.selection } : undefined })}
onAsk={askInspector} onShowGraph={showInspectorGraph} onFollow={followInspectorTarget}
onPointRow={inspector.filePath === activePath ? pointRow : undefined}
onOpenFlow={inspector.symbol && inspector.symbol.qualifiedName === twinSymbol?.qualifiedName ? toggleFlow : undefined}
callOutline={inspector.pseudocode && inspector.symbol ? <PseudocodeView document={inspector.pseudocode}
symbolName={inspector.symbol.name} imports={inspector.imports} onOpenLine={openLine} /> : undefined} />;
const galaxy = (
<GalaxyPanel
project={project}
selectionPanel={workspace === 'galaxy' ? mapSelectionPanel : undefined}
visible={workspace === 'galaxy' || (workspace === 'explore' && galaxyOn)}
workspaceExpanded={workspace === 'galaxy'}
focusQualifiedName={workspace === 'galaxy' ? activeGalaxySelection?.qualified_name : readerFocusSymbol?.qualifiedName}
@@ -4585,7 +4598,6 @@ export default function App(): JSX.Element {
setGalaxySelection(node);
setGalaxySelectionProject(project);
setChatGraphSelection(selectedGraphContext(layout, node, project, crypto.randomUUID()));
setBrowserAiOpen(true);
} : undefined}
onSelectShadowNode={(node) => {
setGalaxySelection(undefined);
@@ -4623,63 +4635,6 @@ export default function App(): JSX.Element {
/>
);
const liveIr = matchingInspectorIr(twinIr, readerFocusSymbol, project, activePath);
const liveOutline = useMemo(() => liveIr ? buildPseudocode(
{ kind: 'symbol', label: liveIr.symbol.name },
{ irs: [liveIr], ...(layout === undefined ? {} : { graph: layout }) },
) : undefined, [liveIr, layout]);
const liveSelection = readerSelection?.path === activePath && document?.path === activePath
? readerSelection : undefined;
const symbolList = fileSymbols.project === project && fileSymbols.path === activePath ? fileSymbols : undefined;
const liveCode: SelectedCodeSnapshot = {
project, filePath: activePath, symbol: readerFocusSymbol, ir: liveIr,
selection: liveSelection, document: document?.path === activePath ? document : undefined,
imports: activePath ? imports : undefined,
pseudocode: liveOutline,
status: liveIr ? 'ready' : readerStatus === 'loading' ? 'loading'
: twinStatus === 'failed' ? 'failed' : twinStatus === 'not-indexed' ? 'not-indexed'
: readerFocusSymbol ? 'loading' : 'empty',
message: twinHint || twinMessage,
coverageNote: coverageNote?.text,
fileSymbols: symbolList?.symbols,
fileSymbolsStatus: symbolList?.status ?? (activePath ? 'loading' : 'ready'),
fileSymbolsMessage: symbolList?.message,
};
const inspector = pinnedCode?.project === project ? pinnedCode : liveCode;
const followInspectorTarget = (target: SymbolRef): void => {
setPinnedCode(undefined);
followTarget(target);
};
const askInspector = (): void => {
if (!inspector.filePath) return;
setChatAttachment(inspector.selection ? { ...inspector.selection, project: inspector.project, id: crypto.randomUUID() } : undefined);
setChatGraphSelection(inspectorChatContext(inspector, crypto.randomUUID()));
setBrowserAiOpen(true);
};
const showInspectorGraph = (): void => {
const node = layout?.nodes.find(entry => inspector.symbol?.qualifiedName
? entry.qualified_name === inspector.symbol.qualifiedName
: entry.file_path === inspector.filePath && (entry.label === 'File' || entry.label === 'Module'));
if (node) {
setGalaxySelection(node);
setGalaxySelectionProject(project);
changeWorkspace('galaxy');
} else {
setGalaxyOn(true);
if (inspector.symbol) followTarget(inspector.symbol);
else if (inspector.filePath) openFile(inspector.filePath);
}
};
const selectedCode = <SelectedCodePanel {...inspector}
pinned={pinnedCode?.project === project}
onTogglePin={() => setPinnedCode(current => current?.project === project ? undefined : { ...liveCode,
selection: liveCode.selection ? { ...liveCode.selection } : undefined })}
onAsk={askInspector} onShowGraph={showInspectorGraph} onFollow={followInspectorTarget}
onPointRow={inspector.filePath === activePath ? pointRow : undefined}
onOpenFlow={inspector.symbol && inspector.symbol.qualifiedName === twinSymbol?.qualifiedName ? toggleFlow : undefined}
callOutline={inspector.pseudocode && inspector.symbol ? <PseudocodeView document={inspector.pseudocode}
symbolName={inspector.symbol.name} imports={inspector.imports} onOpenLine={openLine} /> : undefined} />;
/*
* Die fuenf Reiter des Erklaeren-Bereichs, und was gerade dahinter liegt.
*
@@ -4794,25 +4749,6 @@ export default function App(): JSX.Element {
}}
onClose={collapseExplain}
/>
) : explainTab === 'change' && project.length > 0 ? (
<ImpactPanel
project={project}
mode={impactMode}
onMode={chooseImpactMode}
refDraft={refDraft}
onRefDraft={(value) => {
setRefDraft(value);
setRefError('');
}}
onGo={applyRef}
refError={refError}
model={impactModel}
status={impactStatus}
message={impactMessage}
routeNote={impactRouteNote}
onOpen={openImpactRow}
onClose={collapseExplain}
/>
) : undefined;
/** Ein Griff, mit seinen Grenzen aus dem Modell. */
@@ -4858,6 +4794,8 @@ export default function App(): JSX.Element {
return (
<AtlasChrome
workspaceStatus={<DaemonAlerts key={`${project}:${logScope}`} project={project} scope={logScope}
onScopeChange={scope => setLogScopeChoice({ project, scope })} reading={daemonLogFeed} coverage={coverage} coverageKnown={coverageAsked} coverageError={coverageError} onDiagnose={() => setDiagnosticsOpen(true)} />}
workspace={workspace}
projectSwitcher={<ProjectSwitcher currentProject={project} listProjects={projectsSource.listProjects}
onSelectProject={openProject} onManageProjects={() => setProjectsOpen(true)} />}
@@ -4874,7 +4812,23 @@ export default function App(): JSX.Element {
<button type="button" disabled={!liveSelection} aria-keyshortcuts="Control+Shift+L Meta+Shift+L"
onClick={() => { if (liveSelection) attachSelection(liveSelection); }}>{workspaceText.askSelection}</button>
</>}
globalOverlay={<>{welcomeOpen && <WelcomePanel workspace={workspace} guidance={guidance}
globalOverlay={<><DiagnosticsPanel project={project} client={client} coverage={coverageAsked ? coverage : undefined}
coverageError={coverageError} active={diagnosticsOpen} onClose={() => setDiagnosticsOpen(false)}
path={(workspace === 'architecture' || workspace === 'galaxy') ? activeGalaxySelection?.file_path : activePath || undefined}
onNavigate={path => { setDiagnosticsOpen(false); navigateEvidence(path); }}
onCoverage={reading => { setCoverage(reading.index); setCoverageMeta({ ...reading.answer.metadata }); setCoverageAsked(true); setCoverageError(''); setOverviewRevision(value => value + 1); }} />
{(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={() => setDiagnosticsOpen(true)} 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} />}
{welcomeOpen && <WelcomePanel workspace={workspace} guidance={guidance}
onWorkspace={changeWorkspace} onGuidance={changeGuidance} onContinue={finishSetup}
onLocalAi={() => { finishSetup(); setBrowserAiOpen(true); }} />}
{projectsOpen && <ProjectsPanel project={project} source={projectsSource}
@@ -4895,21 +4849,24 @@ export default function App(): JSX.Element {
workspacePanel={<>
<div hidden={workspace !== 'architecture'}>
<ArchitecturePanel projectName={project} overview={overview}
readSource={readMapSource}
graph={repositoryReading.snapshot} graphGeneration={repositoryReading.snapshot?.generation} selection={activeGalaxySelection} selectionPanel={mapSelectionPanel}
graphNote={repositoryReading.error ?? (repositoryReading.snapshot
? `Index: ${repositoryReading.snapshot.indexedAt || 'timestamp unknown'} · snapshot ${repositoryReading.snapshot.generation}. ${repositoryReading.snapshot.nodesTruncated || repositoryReading.snapshot.edgesTruncated ? 'The architecture query reached a bound; some evidence is omitted.' : 'All matching source symbols and dependency edges in this index snapshot were read.'}${selectionUnavailable ? ` ${selectionUnavailableText}` : ''}`
: undefined)}
onSelect={selectMapNode}
onProjectWalk={() => { changeWorkspace('explore'); void startProjectTour(); }}
loading={overviewLoading} error={overviewError}
onRefresh={() => setOverviewRevision((value) => value + 1)}
onNavigate={(filePath, line, name) => {
setWorkspace('explore');
const target = twinTargetOf({ filePath, startLine: line, name: name ?? filePath, kind: 'unknown' });
if (target !== undefined) followTarget(target);
}} />
onNavigate={navigateEvidence} />
</div>
<div hidden={workspace !== 'agents'}>
<ActivityPanel state={agents.state} status={agents.status} on={liveAgentsOn} port={bridgePort}
graph={layout} onToggle={() => setLiveAgentsOn((value) => !value)}
<ActivityPanel project={project} api={api} state={agents.state} status={agents.status} on={liveAgentsOn} port={bridgePort}
graph={evidenceGraph} onToggle={() => setLiveAgentsOn((value) => !value)}
onOpenNode={(node) => { setWorkspace('explore'); openGalaxyNode(node); }} />
</div>
<div hidden={workspace !== 'system'}>
<SystemWorkspace api={api} active={workspace === 'system'} version={ATLAS_VERSION}
<SystemWorkspace api={api} project={project} active={workspace === 'system'} version={ATLAS_VERSION}
onOpenProjects={() => setProjectsOpen(true)} />
</div>
</>}
+5
View File
@@ -10,9 +10,12 @@ import { activityRows } from './activity-model';
import type { ActivityFilter } from './activity-model';
import { activityStrings as s } from './activity-strings';
import './activity.css';
import AgentSetup from './AgentSetup';
import type { AtlasApi } from '../app/atlas-api';
interface Props {
state: AgentsState; status: AgentSourceStatus; on: boolean; port: number;
project?: string; api?: Pick<AtlasApi, 'repoInfo'>;
graph?: GraphData; onToggle: () => void; onOpenNode: (node: GraphNode) => void;
}
const emptyFilter: ActivityFilter = { agent: '', run: '', kind: '', query: '' };
@@ -36,6 +39,8 @@ export default function ActivityPanel(props: Props): JSX.Element {
<div className="cbm-activity-connection"><span data-state={props.status.state}>{props.status.state}</span>
<button type="button" onClick={props.onToggle}>{props.on ? s.disconnect : s.connect}</button></div>
</header>
<AgentSetup api={props.api} project={props.project} port={props.port} />
<p className="cbm-activity-source-status">{props.on ? props.status.state === 'connected' ? 'History polling connected' : 'Waiting for daemon history' : 'History polling off'} · {all.length ? <>Latest recorded tool call: <time dateTime={new Date(Math.max(...all.map(row => row.ts))).toISOString()}>{new Date(Math.max(...all.map(row => row.ts))).toLocaleString()}</time>. This does not mean an agent is currently working.</> : 'No recorded tool call has been received for this project.'}</p>
<div className="cbm-activity-metrics">
{[[s.events, props.state.actors.filter((actor) => !actor.you).reduce((sum, actor) => sum + actor.count, 0)], [s.actors, props.state.actors.filter((actor) => !actor.you).length],
[s.gaps, props.state.missed], [s.dropped, props.status.drops]].map(([label, value]) =>
+34
View File
@@ -0,0 +1,34 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import AgentSetup, { agentInstallCommand } from './AgentSetup';
let host: HTMLDivElement; let root: Root;
beforeEach(() => { (globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true; host = document.createElement('div'); document.body.append(host); root = createRoot(host); });
afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.restoreAllMocks(); });
const open = async () => { await act(async () => { const details = host.querySelector('details')!; details.open = true; details.dispatchEvent(new Event('toggle')); }); };
it('exposes actionable setup without querying or changing anything until opened', async () => {
const api = { repoInfo: vi.fn().mockResolvedValue({ rootPath: '/repo with spaces', branch: 'main', remoteUrl: '' }) };
await act(async () => root.render(<AgentSetup api={api} project="project-a" port={9749} />));
expect(host.querySelector('summary')?.textContent).toBe('Connect your agent');
expect(api.repoInfo).not.toHaveBeenCalled();
await open();
expect(api.repoInfo).toHaveBeenCalledWith('project-a');
expect(host.querySelector('pre')?.textContent).toContain("--root '/repo with spaces' --project 'project-a'");
expect(host.textContent).toContain('no file contents');
expect(host.textContent).toContain('does not configure your coding client');
expect(host.textContent).toContain('Other coding clients need their own event adapter');
});
it('never presents the previous repository command when a new root fails to load', async () => {
const api = { repoInfo: vi.fn().mockResolvedValueOnce({ rootPath: '/repo-a' }).mockRejectedValueOnce(new Error('repository unavailable')) };
await act(async () => root.render(<AgentSetup api={api} project="a" port={9749} />)); await open();
expect(host.querySelector('pre')?.textContent).toContain('/repo-a');
await act(async () => root.render(<AgentSetup api={api} project="b" port={9749} />));
expect(host.querySelector('pre')).toBeNull();
expect(host.querySelector('[role="alert"]')?.textContent).toContain('repository unavailable');
});
it('quotes project and root values as literal shell words', () => {
const command = agentInstallCommand("/repo'; touch /tmp/unwanted", '$(env)', 9749);
expect(command).toContain("--root '/repo'\\''; touch /tmp/unwanted'");
expect(command).toContain("--project '$(env)'");
});
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import type { AtlasApi } from '../app/atlas-api';
import hookSource from '../../agents/hooks/atlas-trace.py?raw';
/** POSIX shell word quoting; paths and indexed names are data, never commands. */
export const shellWord = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
export const agentInstallCommand = (root: string, project: string, port: number): string =>
`python3 ~/Downloads/cbm-atlas-trace.py --install-claude --root ${shellWord(root)} --project ${shellWord(project)} --daemon-url ${shellWord(`http://127.0.0.1:${port}`)}`;
export default function AgentSetup({ api, project, port }: { api?: Pick<AtlasApi, 'repoInfo'>; project?: string; port: number }) {
const [open, setOpen] = useState(false);
const [reading, setReading] = useState<{ project: string; root: string; error: string }>();
const [copied, setCopied] = useState('');
useEffect(() => {
if (!open || !api || !project) return;
let disposed = false;
setReading(undefined); setCopied('');
void api.repoInfo(project).then(info => {
if (!disposed) setReading({ project, root: info.rootPath, error: info.rootPath ? '' : 'The daemon did not report this repository root.' });
}).catch(error => {
if (!disposed) setReading({ project, root: '', error: error instanceof Error ? error.message : String(error) });
});
return () => { disposed = true; };
}, [open, api, project]);
const current = reading?.project === project ? reading : undefined;
const command = current?.root && project ? agentInstallCommand(current.root, project, port) : '';
const download = () => {
const url = URL.createObjectURL(new Blob([hookSource], { type: 'text/x-python;charset=utf-8' }));
const anchor = document.createElement('a'); anchor.href = url; anchor.download = 'cbm-atlas-trace.py';
anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
};
return <details className="cbm-agent-setup" onToggle={event => setOpen(event.currentTarget.open)}>
<summary>Connect your agent</summary>
<p>Claude Code on macOS/Linux · Python 3 required. This installs a project-local PostToolUse hook. Connecting the browser only reads history; it does not configure your coding client.</p>
{!project || !api ? <p>Select an indexed project to generate its setup command.</p> : <>
<ol>
<li><button type="button" onClick={download}>Download hook</button> Save <code>cbm-atlas-trace.py</code> in Downloads, or adjust its path in the command below.</li>
<li>Review and run this command in your terminal. It copies the hook into <code>.claude/hooks/</code> and adds it to <code>.claude/settings.local.json</code>, preserving unrelated settings. Conflicting existing hooks are refused.
{current?.error ? <p role="alert">Setup unavailable: {current.error}</p> : command ? <>
<pre>{command}</pre><button type="button" onClick={() => { void navigator.clipboard.writeText(command).then(() => setCopied('Command copied')).catch(() => setCopied('Clipboard unavailable; select and copy the command.')); }}>Copy setup command</button> <span role="status">{copied}</span>
</> : <p>Reading the indexed repository root…</p>}
</li>
<li>Start a new Claude Code session in that repository and perform a tool call. Then choose <strong>Load recorded activity</strong> here. A new recorded event is the evidence that the hook reached this daemon.</li>
</ol>
<p>Destination: <code>http://127.0.0.1:{port}/api/agent-events</code> · Project: <code>{project}</code>. No bridge or extra listener. Pending events retry on the next tool call.</p>
</>}
<p>The hook records tool names, paths, timestamps, optional line spans and up to 180 characters of a command or search pattern. It records no file contents or tool results. Metadata stays in the local daemon; commands and paths can still contain sensitive information.</p>
<p>To remove it, delete only the entry referencing <code>cbm-atlas-trace.py</code> from this project's <code>PostToolUse</code> hooks. Other coding clients need their own event adapter; their activity is not inferred.</p>
</details>;
}
+3 -3
View File
@@ -255,14 +255,14 @@ describe('die Quelle', () => {
expect(one('atlas-agents-source')?.getAttribute('data-state')).toBe('off');
});
it('nennt ohne Bruecke den Befehl, der sie startet', async () => {
it('nennt bei fehlender Verbindung den vorhandenen Daemon', async () => {
await render({
status: status({ state: 'no-source', hello: undefined, error: 'refused' }),
port: 4711,
});
expect(textOf('atlas-agents-reading')).toContain('no bridge is answering');
expect(textOf('atlas-agents-reading')).toContain('no daemon is answering');
expect(one('atlas-agents-command')?.getAttribute('data-command'))
.toBe('node tools/agent-bridge.mjs --port 4711');
.toBe('codebase-memory-mcp --ui');
});
it('gibt eine Wiedergabe als Wiedergabe aus und nicht als Gegenwart', async () => {
+4 -4
View File
@@ -1,16 +1,16 @@
export const activityStrings = {
title: 'Agent activity', subtitle: 'Follow recorded work and inspect the code it touches.',
connect: 'Connect stream', disconnect: 'Disconnect', agent: 'Agent', run: 'Run', kind: 'Activity',
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',
gaps: 'Missing sequence numbers', dropped: 'Connection drops', unmapped: 'Unmapped',
noSource: 'Connect the local agent bridge to start observing activity.',
noEvents: 'No recorded activity yet.', noMatch: 'No activity matches these filters.',
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.',
clear: 'Clear filters', evidence: 'Event evidence', open: 'Open source', select: 'Select an event to inspect its evidence.',
retained: 'Showing retained history: up to 80 events per agent. Counts are observed totals, not task progress.',
outcome: 'An end event records the end of a tool call. Its result is not reported by this stream.',
mapping: 'Mapping', timestamp: 'Recorded time', source: 'Source', phase: 'Phase', details: 'Details',
replay: 'Replay', live: 'Live', unknown: 'Not reported', reset: 'Reset',
replay: 'Replay', live: 'Recorded', unknown: 'Not reported', reset: 'Reset',
disconnected: 'Stream is off. Retained events remain available.',
limited: 'Graph placement uses the loaded map. Unmapped events remain visible.',
noGraphMatch: 'No matching location in the loaded graph. The index may contain more nodes.',
+9
View File
@@ -37,3 +37,12 @@
.cbm-activity-inspector pre { white-space: pre-wrap; font: 11px var(--atlas-mono); }
.cbm-activity-inspector > button { margin-top: 12px; }
@media(max-width: 850px) { .cbm-activity { padding: 16px; } .cbm-activity-body { grid-template-columns: 1fr; } .cbm-activity-inspector { border-left: 0; border-top: 1px solid var(--atlas-line); } .cbm-activity-metrics { grid-template-columns: repeat(2, 1fr); } }
.cbm-agent-setup { border: 1px solid #365548; border-radius: 7px; padding: 9px 12px; background: #101b16; margin: 12px 0; }
.cbm-agent-setup > summary { cursor: pointer; color: #99e7be; font-weight: 600; }
.cbm-agent-setup p, .cbm-agent-setup li { color: #bbcec3; font-size: 13px; line-height: 1.5; }
.cbm-agent-setup li { margin: 12px 0; }.cbm-agent-setup code { overflow-wrap: anywhere; }
.cbm-agent-setup pre { white-space: pre-wrap; overflow-wrap: anywhere; padding: 12px; background: #07100b; border-radius: 5px; }
.cbm-agent-setup button { border: 1px solid #496b59; background: #182d21; color: #dbf3e4; padding: 5px 9px; border-radius: 4px; cursor: pointer; }
.cbm-agent-setup :focus-visible { outline: 2px solid #69ddbe; outline-offset: 3px; }
.cbm-activity-source-status { color: #a7bfb0; font-size: 12px; line-height: 1.5; }
+4 -3
View File
@@ -1,8 +1,9 @@
/**
* Was ein Ereignis ist, und welche Art von Arbeit es zeigt.
*
* Die Ereignisse kommen von der Bruecke (tools/agent-bridge.mjs) und damit aus
* einer Datei, die ein Hook geschrieben hat. Dieses Modul ist die einzige
* Die Ereignisse kommen aus dem SQLite-Journal desselben Daemons; lokale
* Werkzeug-Hooks senden ihre Metadaten an /api/agent-events. Historische
* Fixture-Bruecken bleiben separate Testwerkzeuge. Dieses Modul ist die einzige
* Stelle, die aus einer fremden JSON-Zeile ein Ereignis dieses Produkts macht,
* und es hat dabei genau zwei Aufgaben.
*
@@ -60,7 +61,7 @@ export interface AgentEvent {
intent?: string;
/** Woher das Ereignis kommt, wenn die Quelle es sagt (etwa `fs`). */
source: string;
/** Ob die Bruecke dieses Ereignis als Wiedergabe gekennzeichnet hat. */
/** Ob die Quelle dieses Ereignis als Wiedergabe gekennzeichnet hat. */
replay: boolean;
/** Die aufgezeichnete Zeit, wenn die Wiedergabe sie verschoben hat. */
recordedTs?: number;
+98
View File
@@ -0,0 +1,98 @@
// @vitest-environment jsdom
import { act, useLayoutEffect } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { POLL_MS, RECONNECT_MS, useAgentStream, type AgentStream } from './agent-source';
let root: Root;
let container: HTMLDivElement;
let stream: AgentStream;
const event = (seq: number) => ({ ts: seq * 1000, agent: 'fixture', run: 'run', seq,
phase: 'end', tool: 'Read', path: `file${seq}.ts` });
const page = (events: unknown[], cursor: number, generation = 'first') => ({
ok: true, json: async () => ({ events, cursor, generation, reset: false,
retained: events.length, has_more: false, truncated: false }),
});
beforeEach(() => {
vi.useFakeTimers();
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
function Consumer(props: { on: boolean; project?: string; fetch: typeof globalThis.fetch }): null {
stream = useAgentStream(props);
return null;
}
it('does not connect when off and always polls the current daemon, never a bridge', async () => {
const fetch = vi.fn().mockResolvedValue(page([], 0));
await act(async () => root.render(<Consumer on={false} project="repo / test" fetch={fetch} />));
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
expect(fetch).not.toHaveBeenCalled();
await act(async () => root.render(<Consumer on project="repo / test" fetch={fetch} />));
expect(fetch.mock.calls[0]?.[0]).toBe('/api/agent-events?project=repo%20%2F%20test&after=0&limit=200');
expect(stream.status.state).toBe('connected');
await act(async () => root.render(<Consumer on={false} project="repo / test" fetch={fetch} />));
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
expect(fetch).toHaveBeenCalledTimes(1);
});
it('resumes after a connection failure and accepts late events without duplicate counts', async () => {
const fetch = vi.fn().mockResolvedValueOnce(page([event(2)], 1))
.mockRejectedValueOnce(new Error('connection lost'))
.mockResolvedValueOnce(page([event(1), event(2)], 2));
await act(async () => root.render(<Consumer on project="demo" fetch={fetch} />));
await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS); });
expect(stream.status.state).toBe('no-source');
await act(async () => { await vi.advanceTimersByTimeAsync(RECONNECT_MS); });
expect(fetch.mock.calls[1]?.[0]).toContain('after=1');
expect(fetch.mock.calls[2]?.[0]).toContain('after=1');
expect(stream.state.events).toBe(2);
expect(stream.state.actors[0]?.last.seq).toBe(2);
expect(stream.status.drops).toBe(1);
});
it('clears another project or replaced database before accepting its history', async () => {
const fetch = vi.fn().mockResolvedValueOnce(page([event(2)], 5))
.mockResolvedValueOnce(page([event(1)], 1, 'replacement'))
.mockResolvedValueOnce(page([event(1)], 1, 'replacement'))
.mockResolvedValueOnce(page([], 0, 'replacement'));
await act(async () => root.render(<Consumer on project="one" fetch={fetch} />));
await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS + 1); });
expect(fetch.mock.calls[2]?.[0]).toContain('after=0');
expect(stream.state.events).toBe(1);
expect(stream.state.actors[0]?.last.seq).toBe(1);
await act(async () => root.render(<Consumer on project="two" fetch={fetch} />));
expect(fetch.mock.calls[3]?.[0]).toContain('project=two&after=0');
expect(stream.state.events).toBe(0);
});
it('hides prior project activity and provenance before passive effects on project change', async () => {
const fetch = vi.fn().mockResolvedValueOnce(page([event(1)], 1))
.mockImplementation(() => new Promise(() => undefined));
const observed: { project: string; events: number; hello: boolean; status: string }[] = [];
function Observe({ project }: { project: string }): null {
const current = useAgentStream({ on: true, project, fetch });
useLayoutEffect(() => {
observed.push({ project, events: current.state.events,
hello: current.status.hello !== undefined, status: current.status.state });
});
return null;
}
await act(async () => root.render(<Observe project="one" />));
expect(observed.at(-1)).toMatchObject({ project: 'one', events: 1, hello: true, status: 'connected' });
await act(async () => root.render(<Observe project="two" />));
const switched = observed.filter(value => value.project === 'two');
expect(switched.length).toBeGreaterThan(0);
expect(switched.every(value => value.events === 0 && !value.hello && value.status === 'connecting')).toBe(true);
expect(fetch.mock.calls[1]?.[0]).toContain('project=two&after=0');
});
+97 -147
View File
@@ -1,58 +1,20 @@
/**
* Die Verbindung zur Bruecke, und der Zustand, wenn es keine gibt.
*
* ## Aus heisst aus
*
* Solange der Live-Modus aus ist, geht von hier KEINE Anfrage hinaus. Nicht
* eine Probe, nicht ein `HEAD`, nicht ein Versuch herauszufinden, ob eine
* Bruecke laeuft. Dasselbe Versprechen wie beim lokalen Modell (src/llm), und
* derselbe Grund: ein Schalter, der im Hintergrund weiter fragt, ist kein
* Schalter, sondern eine Farbe. Der Zaehler {@link AgentSourceStatus.requests}
* steht im Testgriff, damit die Null gemessen werden kann und nicht geglaubt
* werden muss.
*
* ## Und ohne Bruecke heisst ohne Bruecke
*
* Ist der Modus an und antwortet niemand, sagt der Zustand `no-source`, und das
* Instrument zeigt den Befehl, der die Bruecke startet. Ein leerer Graph waere
* an dieser Stelle die Behauptung, es arbeite gerade niemand, und das ist eine
* Aussage, die dieses Fenster nicht treffen kann.
*
* ## Warum `fetch` und nicht `EventSource`
*
* Der Draht ist Server-Sent-Events, das Lesen ist ein `fetch` mit einem
* Stroem-Leser. Drei Gruende, in dieser Reihenfolge:
*
* 1. **Wiederaufnahme.** Die Bruecke nimmt die zuletzt gesehene Nummer je Lauf
* als Abfrageparameter. `EventSource` schickt beim Wiederverbinden seine
* eigene `Last-Event-ID` und sonst nichts; die Nummern MEHRERER Laeufe
* passen dort nicht hinein.
* 2. **Abbrechen.** Ein `AbortController` beendet die Verbindung in dem
* Augenblick, in dem der Schalter faellt. `EventSource.close()` tut das auch,
* verbindet aber vorher von selbst neu, und "von selbst" ist genau das, was
* ein ausgeschalteter Modus nicht tun darf.
* 3. **Messbarkeit.** Ein `fetch` ist eine Anfrage, die man zaehlen kann, und
* die Zusicherung dieses Moduls ist eine Zahl.
*/
/** Same-origin daemon polling over the existing sequential HTTP transport.
* Off means zero requests. Cursors are committed only after a validated page;
* reconnects retain state, and a replaced SQLite database resets its generation. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { AgentEvent } from './agent-event';
import { readAgentEvent, sinceParameter } from './agent-event';
import { readAgentEvent } from './agent-event';
import { emptyAgentsState, withEvent } from './agent-store';
import type { AgentsState } from './agent-store';
/** Der Vorgabeport der Bruecke. Derselbe wie in tools/agent-bridge.mjs. */
export const DEFAULT_BRIDGE_PORT = 4142;
/** Wie lange nach einem Abriss gewartet wird, bevor neu verbunden wird. */
/** Kept as a source compatibility name; the current daemon port is used. */
export const DEFAULT_BRIDGE_PORT = 9749;
export const RECONNECT_MS = 3000;
/** Was der Zustand `no-source` als Weg nach vorn nennt. */
export function bridgeCommand(port: number): string {
return port === DEFAULT_BRIDGE_PORT
? 'node tools/agent-bridge.mjs'
: `node tools/agent-bridge.mjs --port ${port}`;
export const POLL_MS = 1000;
export function bridgeCommand(_port: number): string {
return 'codebase-memory-mcp --ui';
}
/** Die Lage der Quelle. */
@@ -61,20 +23,20 @@ export type SourceState =
| 'off'
/** Eine Verbindung laeuft gerade an. */
| 'connecting'
/** Die Bruecke antwortet und schickt Ereignisse. */
/** Der Daemon antwortet mit gespeicherten Ereignissen. */
| 'connected'
/** Der Modus ist an und niemand antwortet. */
| 'no-source';
/** Was die Bruecke ueber sich gesagt hat. */
/** Quelleninformation des Daemons (Feldnamen kompatibel zum bisherigen HUD). */
export interface BridgeHello {
/** `live` verfolgt eine Datei, `replay` spielt eine Aufzeichnung ab. */
/** `live` liest die lokale SQLite-Historie, `replay` bezeichnet Testaufzeichnungen. */
mode: string;
/** Die Datei, die sie liest. */
/** Lesbare Bezeichnung der Datenquelle. */
file: string;
/** Wie viele Zeilen sie gelesen hat. */
/** Wie viele Ereignisse der Daemon aktuell aufbewahrt. */
events: number;
/** Wie viele Zeilen kein JSON waren. */
/** Wie viele empfangene Ereignisse nicht lesbar waren. */
unreadable: number;
}
@@ -87,7 +49,7 @@ export interface AgentSourceStatus {
requests: number;
/** Wie oft die Verbindung abgerissen ist. */
drops: number;
/** Was die Bruecke ueber sich gesagt hat, wenn sie es gesagt hat. */
/** Metadaten der zuletzt gelesenen Daemon-Antwort. */
hello: BridgeHello | undefined;
/** Der letzte Fehler, woertlich. Leer, wenn keiner. */
error: string;
@@ -124,22 +86,17 @@ export function parseSseFrame(raw: string): SseFrame | undefined {
return data.length === 0 ? undefined : { event: name, data: data.join('\n') };
}
/** Der Port der Bruecke, wie die Adresszeile ihn nennt. */
export function bridgePortFromSearch(search: string): number {
try {
const raw = new URLSearchParams(search).get('agents');
const port = Number(raw);
return Number.isFinite(port) && port > 0 && port < 65536 ? port : DEFAULT_BRIDGE_PORT;
} catch {
return DEFAULT_BRIDGE_PORT;
}
/** Der aktuelle Daemon-Port; ein alter agents-Parameter hat keine Wirkung. */
export function bridgePortFromSearch(_search: string): number {
return typeof location === 'undefined' ? DEFAULT_BRIDGE_PORT : Number(location.port) || 80;
}
export interface AgentStreamOptions {
/** Ob der Live-Modus an ist. Aus heisst: keine Anfrage. */
on: boolean;
/** Der Port der Bruecke. */
/** Kompatibilitaetsfeld fuer alte Aufrufer; der Transport verwendet same-origin. */
port?: number;
project?: string;
/** Ersetzbares fetch, damit Tests ohne Netz laufen. */
fetch?: typeof globalThis.fetch | undefined;
}
@@ -148,13 +105,13 @@ export interface AgentStreamOptions {
export interface AgentStream {
state: AgentsState;
status: AgentSourceStatus;
/** Ein eigenes Ereignis dazulegen, ohne die Bruecke zu fragen. */
/** Ein eigenes Browsereignis lokal dazulegen. */
push: (event: AgentEvent, you: boolean) => void;
}
/**
* Der Strom, wie ihn die Panels bekommen: samt Port, damit das Instrument den
* Befehl nennen kann, der die Bruecke startet.
* vorhandenen Daemon-Port anzeigen kann.
*/
export interface AgentsRuntime extends AgentStream {
port: number;
@@ -170,24 +127,29 @@ export interface AgentsRuntime extends AgentStream {
* zwanzig Zustandsketten aufmachen, aus denen jede die vorige ueberschreibt.
*/
export function useAgentStream(options: AgentStreamOptions): AgentStream {
const port = options.port ?? DEFAULT_BRIDGE_PORT;
const project = options.project ?? '';
const fetchImpl = options.fetch;
const on = options.on;
const stateRef = useRef<AgentsState>(emptyAgentsState());
const [state, setState] = useState<AgentsState>(stateRef.current);
const [reading, setReading] = useState({ project, value: stateRef.current });
const requests = useRef(0);
const drops = useRef(0);
const [status, setStatus] = useState<AgentSourceStatus>({
const cursor = useRef(0);
const incomplete = useRef(false);
const generation = useRef<string | undefined>(undefined);
const previousProject = useRef(project);
const [source, setSource] = useState<{ project: string; value: AgentSourceStatus }>({ project, value: {
state: 'off',
origin: '',
requests: 0,
drops: 0,
hello: undefined,
error: '',
});
} });
const push = useCallback((event: AgentEvent, you: boolean) => {
if (previousProject.current !== project) return;
const next = withEvent(stateRef.current, event);
stateRef.current = you
? {
@@ -196,35 +158,43 @@ export function useAgentStream(options: AgentStreamOptions): AgentStream {
(actor.id === event.agent ? { ...actor, you: true } : actor)),
}
: next;
setState(stateRef.current);
}, []);
setReading({ project, value: stateRef.current });
}, [project]);
useEffect(() => {
if (previousProject.current !== project) {
previousProject.current = project;
cursor.current = 0;
generation.current = undefined;
incomplete.current = false;
stateRef.current = emptyAgentsState();
setReading({ project, value: stateRef.current });
}
if (!on) {
setStatus({
setSource({ project, value: {
state: 'off',
origin: '',
requests: requests.current,
drops: drops.current,
hello: undefined,
error: '',
});
} });
return;
}
const origin = `http://127.0.0.1:${port}`;
const origin = typeof location === 'undefined' ? '' : location.origin;
const doFetch = fetchImpl ?? globalThis.fetch;
let stopped = false;
let controller: AbortController | null = null;
let timer: ReturnType<typeof setTimeout> | undefined;
const announce = (patch: Partial<AgentSourceStatus>): void => {
setStatus((current) => ({
...current,
setSource((current) => ({ project, value: {
...(current.project === project ? current.value : { state: 'connecting', hello: undefined, error: '' }),
origin,
requests: requests.current,
drops: drops.current,
...patch,
}));
} }));
};
const connect = async (): Promise<void> => {
@@ -232,78 +202,49 @@ export function useAgentStream(options: AgentStreamOptions): AgentStream {
return;
}
controller = new AbortController();
const since = sinceParameter(stateRef.current.seen);
const url = `${origin}/events${since.length > 0 ? `?since=${encodeURIComponent(since)}` : ''}`;
const url = `/api/agent-events?project=${encodeURIComponent(project)}&after=${cursor.current}&limit=200`;
requests.current += 1;
announce({ state: 'connecting', error: '' });
if (cursor.current === 0) announce({ state: 'connecting', error: '' });
try {
const response = await doFetch(url, {
headers: { Accept: 'text/event-stream' },
signal: controller.signal,
});
if (!response.ok || response.body === null) {
throw new Error(`the bridge answered with HTTP ${response.status}`);
const response = await doFetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`daemon activity: HTTP ${response.status}`);
const page = await response.json() as {
events: unknown[]; cursor: number; generation: string; reset: boolean;
has_more: boolean; truncated: boolean; retained: number;
};
if (!Array.isArray(page.events) || !Number.isSafeInteger(page.cursor)
|| page.cursor < 0 || typeof page.generation !== 'string') {
throw new Error('Invalid daemon activity response');
}
announce({ state: 'connected', error: '' });
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const chunk = await reader.read();
if (chunk.done) {
break;
}
buffer += decoder.decode(chunk.value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop() ?? '';
let changed = false;
for (const part of parts) {
const frame = parseSseFrame(part);
if (frame === undefined) {
continue;
}
if (frame.event === 'hello') {
try {
const hello = JSON.parse(frame.data) as BridgeHello;
announce({ state: 'connected', hello });
stateRef.current = {
...stateRef.current,
unreadable: Number(hello.unreadable) || 0,
};
changed = true;
} catch {
/* Ein unlesbarer Gruss aendert nichts an den Ereignissen. */
}
continue;
}
if (frame.event !== 'trace') {
continue;
}
try {
const event = readAgentEvent(JSON.parse(frame.data));
if (event !== undefined) {
stateRef.current = withEvent(stateRef.current, event);
changed = true;
}
} catch {
/* Eine unlesbare Zeile ist eine Zeile weniger, kein Abbruch. */
}
}
if (changed && !stopped) {
setState(stateRef.current);
}
}
if (!stopped) {
drops.current += 1;
announce({ state: 'no-source', error: 'the bridge closed the stream' });
timer = setTimeout(() => void connect(), RECONNECT_MS);
}
} catch (failure) {
if (stopped) {
if (stopped) return;
if (page.reset || (generation.current !== undefined && generation.current !== page.generation)) {
stateRef.current = emptyAgentsState();
cursor.current = 0;
incomplete.current = false;
generation.current = page.generation;
setReading({ project, value: stateRef.current });
timer = setTimeout(() => void connect(), 0);
return;
}
const message = failure instanceof Error ? failure.message : String(failure);
announce({ state: 'no-source', error: message });
generation.current = page.generation;
let next = stateRef.current;
for (const raw of page.events) {
const event = readAgentEvent(raw);
if (event !== undefined) next = withEvent(next, event);
else next = { ...next, unreadable: next.unreadable + 1 };
}
stateRef.current = next;
cursor.current = page.cursor;
incomplete.current ||= page.truncated;
setReading({ project, value: next });
announce({ state: 'connected', error: incomplete.current
? 'Older activity expired from local retention; this history is incomplete.' : '',
hello: { mode: 'live', file: 'daemon SQLite activity', events: page.retained, unreadable: next.unreadable } });
timer = setTimeout(() => void connect(), page.has_more ? 0 : POLL_MS);
} catch (failure) {
if (stopped) return;
drops.current += 1;
announce({ state: 'no-source', error: failure instanceof Error ? failure.message : String(failure) });
timer = setTimeout(() => void connect(), RECONNECT_MS);
}
};
@@ -316,7 +257,16 @@ export function useAgentStream(options: AgentStreamOptions): AgentStream {
}
controller?.abort();
};
}, [on, port, fetchImpl]);
}, [on, project, fetchImpl]);
return useMemo(() => ({ state, status, push }), [state, status, push]);
// A passive effect resets the cursor, but the first render of another
// project must already hide the previous project's events and provenance.
return useMemo(() => ({
state: reading.project === project ? reading.value : emptyAgentsState(),
status: source.project === project ? source.value : {
state: on ? 'connecting' as const : 'off' as const, origin: '',
requests: requests.current, drops: drops.current, hello: undefined, error: '',
},
push,
}), [reading, source, project, on, push]);
}
+8
View File
@@ -54,6 +54,14 @@ describe('der Zustand des Stroms', () => {
expect(state.actors[0]?.missed).toBe(2);
});
it('reconciles a late missing sequence and ignores duplicate deliveries', () => {
const state = fold([event({ seq: 1 }), event({ seq: 4 }), event({ seq: 2 }),
event({ seq: 2 }), event({ seq: 3 })]);
expect(state.events).toBe(4);
expect(state.missed).toBe(0);
expect(state.actors[0]?.missed).toBe(0);
});
it('haelt das erste Ereignis eines Laufs NIE fuer eine Luecke', () => {
// Die Oberflaeche ist bei Nummer 40 eingestiegen. Das sind keine
// verlorenen Nachrichten, das ist ein spaeter Zuhoerer.
+6 -1
View File
@@ -119,7 +119,8 @@ function withRun(runs: readonly RunState[], event: AgentEvent): {
missed: 0,
};
}
const gap = event.seq > found.lastSeq + 1 ? event.seq - found.lastSeq - 1 : 0;
const gap = event.seq > found.lastSeq + 1 ? event.seq - found.lastSeq - 1
: event.seq > found.joinedAt && event.seq < found.lastSeq && found.missed > 0 ? -1 : 0;
return {
runs: runs.map((entry) =>
(entry.run === event.run
@@ -131,6 +132,10 @@ function withRun(runs: readonly RunState[], event: AgentEvent): {
/** Ein Ereignis in den Zustand aufnehmen. Rein, damit ein Test ihn nachbauen kann. */
export function withEvent(state: AgentsState, event: AgentEvent): AgentsState {
// A retry must not increment counts or append another timeline row. Lower
// sequence numbers are still accepted when they arrive late.
if (state.actors.some((actor) => actor.events.some((seenEvent) =>
seenEvent.run === event.run && seenEvent.seq === event.seq))) return state;
const id = event.agent;
const existing = state.actors.find((actor) => actor.id === id);
const seen = new Map(state.seen);
+5 -6
View File
@@ -70,14 +70,13 @@ export const agentStrings = {
sourceOff: 'live mode is off, and nothing is asked',
sourceOffDetail:
'not one request goes to the bridge while this is off. Turn it on in the atlas menu with '
'not one request goes to the daemon while this is off. Turn it on in the atlas menu with '
+ '[g] live agents, or type "live agents" in the command line.',
sourceConnecting: 'connecting to the bridge',
sourceNone: 'live mode is on and no bridge is answering',
sourceConnecting: 'connecting to the daemon',
sourceNone: 'live mode is on and no daemon is answering',
sourceNoneDetail:
'this window has no backend and cannot start one. The bridge reads the event file and hands '
+ 'it on; without it there is no source, and an empty graph here would be the claim that '
+ 'nobody is working:',
'check that the local daemon is running. It stores tool events in SQLite and serves them '
+ 'on the same port as this map. Recorded history is retained locally while the connection retries.',
sourceConnected: (file: string): string => `reading ${file}`,
sourceReplay: (file: string): string => `replaying ${file}`,
replayNote:
+3 -1
View File
@@ -151,6 +151,7 @@ export interface AtlasChromeProps {
workspace?: Workspace;
onWorkspaceChange?: (workspace: Workspace) => void;
workspacePanel?: ReactNode;
workspaceStatus?: ReactNode;
guidance?: Guidance;
onGuidanceChange?: (guidance: Guidance) => void;
/** Versions-Chip, zur Buildzeit injiziert. Nur die Fassung, ohne Zusatz. */
@@ -642,7 +643,7 @@ export default function AtlasChrome(props: AtlasChromeProps): JSX.Element {
};
return (
<div className="atlas-shell" data-workspace={props.workspace ?? 'explore'} data-guidance={props.guidance ?? 'brief'} data-chat-open={props.chatOpen === true}>
<div className="atlas-shell" data-workspace={props.workspace ?? 'explore'} data-guidance={props.guidance ?? 'brief'} data-chat-open={props.chatOpen === true} data-has-status={props.workspaceStatus !== undefined}>
<header className="atlas-header" data-testid="atlas-header">
<h1 className="atlas-brand">{messages.app.brand}</h1>
<span className="atlas-version" data-testid="atlas-version">
@@ -789,6 +790,7 @@ export default function AtlasChrome(props: AtlasChromeProps): JSX.Element {
{props.projectSwitcher}
</header>
{props.workspaceStatus}
<div className="atlas-workspace-content" style={{ '--atlas-chat-width': `${chatWidth}px` } as CSSProperties}>
<div className="atlas-workspace-stage">
<div className="atlas-exploration-workspace" data-testid="atlas-exploration-workspace" hidden={props.workspace !== undefined && props.workspace !== 'explore' && props.workspace !== 'galaxy'}>
+11
View File
@@ -34,6 +34,17 @@ function api(reply: { ok?: boolean; status?: number; body: string }): { api: Atl
}
describe('AtlasApi, the projects routes', () => {
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' });
const url = new URL(calls[0]!.url);
expect(url.searchParams.get('scope')).toBe('unattributed');
expect(url.searchParams.get('min_level')).toBe('error');
expect(url.searchParams.get('q')).toBe('path&level=info');
expect(url.searchParams.has('level')).toBe(false);
expect(result.query).toBe('path&level=info');
});
it('starts an index job with a JSON body over POST', async () => {
const { api: client, calls } = api({ status: 202, body: '{"status":"indexing","slot":1,"path":"/repo"}' });
const started = await client.startIndex('/repo', 'repo');
+8 -2
View File
@@ -84,6 +84,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') ?? '',
level: refused ? 'warn' : 'error',
message: error.message,
detail,
@@ -225,8 +226,13 @@ export class AtlasApi {
}
/** The last `lines` lines of the server log. */
async logs(lines: number): Promise<LogTail> {
return readLogs(await this.getJson(`/api/logs?${new URLSearchParams({ lines: String(lines) }).toString()}`));
async logs(lines: number, minLevel?: 'debug' | 'info' | 'warn' | 'error', project?: string, filters?: { scope?: 'unattributed'; query?: string }): Promise<LogTail> {
const query = new URLSearchParams({ lines: String(lines) });
if (minLevel) query.set('min_level', minLevel);
if (project !== undefined) query.set('project', project);
if (filters?.scope) query.set('scope', filters.scope);
if (filters?.query?.trim()) query.set('q', filters.query.trim());
return readLogs(await this.getJson(`/api/logs?${query.toString()}`));
}
/** The codebase-memory-mcp processes on this machine, as the server sees them. */
+28
View File
@@ -77,6 +77,34 @@ function fakeClient(pages: unknown[], status: unknown = STATUS) {
describe('loadCoverage', () => {
it('follows the real envelope cursor and resolves abbreviated status lists after all pages arrive', async () => {
const summary = { ...STATUS, indexed_at: 'g', parse_partial: { files: [], count: 1, truncated: true }, not_indexed: { dirs: [], dirs_count: 1, files: [], files_count: 1, truncated: true } };
const entries = [{ path: 'partial.c', kind: 'parse_partial' }, { path: 'build', kind: 'not_indexed_dir' }, { path: 'image.png', kind: 'not_indexed_file' }];
const envelope = (start: number, end: number) => ({
project: 'p', metadata: METADATA, has_more: end < entries.length,
...(end < entries.length ? { next_offset: end } : {}),
scopes: [{ scope: '.', status: 'known_gaps', total: 3, returned: end - start, truncated: true, entries: entries.slice(start, end) }],
});
const { client, calls } = fakeClient([envelope(0, 2), envelope(2, 3)], summary);
const reading = await loadCoverage(client, 'p');
expect(calls.filter((call) => call.tool === 'check_index_coverage').map((call) => call.scopeOffset)).toEqual([0, 2]);
expect(reading.index.records.size).toBe(3);
expect(reading.index.records.get('partial.c')?.state).toBe('partial');
expect(reading.index.listingComplete).toBe(true);
expect(reading.index.truncations).toEqual([]);
});
it('rejects an index generation change across coverage pages or after the summary', async () => {
const first = page([{ path: 'a.c', kind: 'parse_partial' }], true, 1);
const second = { ...page([{ path: 'b.c', kind: 'parse_partial' }], false), metadata: { ...METADATA, generation: 'other' } };
await expect(loadCoverage(fakeClient([first, second]).client, 'p')).rejects.toThrow('Index generation changed');
await expect(loadCoverage(fakeClient([first], { ...STATUS, indexed_at: 'old' }).client, 'p')).rejects.toThrow('Index generation changed');
});
it('does not turn a missing root scope into a successful empty reading', async () => {
await expect(loadCoverage(fakeClient([{ scopes: [], metadata: METADATA }]).client, 'p')).rejects.toThrow('no root coverage scope');
});
it('fragt beide Quellen und legt sie zusammen', async () => {
const { client, calls } = fakeClient([page([{ path: 'src/b.ts', kind: 'discovery' }], false)]);
const reading = await loadCoverage(client, 'p');
+7 -1
View File
@@ -62,6 +62,7 @@ export async function loadCoverage(
const scopes: CoverageScope[] = [];
let answer = readCoverageAnswer(undefined);
let generation: string | undefined;
let offset = 0;
for (let page = 0; page < COVERAGE_MAX_PAGES; page += 1) {
answer = readCoverageAnswer(
@@ -71,9 +72,14 @@ export async function loadCoverage(
scopeOffset: offset,
}),
);
if ((status.generation && answer.metadata.generation && status.generation !== answer.metadata.generation)
|| (generation !== undefined && generation !== answer.metadata.generation)) {
throw new Error('Index generation changed while reading coverage. Run the local diagnosis again.');
}
generation = answer.metadata.generation;
const scope = answer.scopes[0];
if (scope === undefined) {
break;
throw new Error('The daemon returned no root coverage scope. Coverage completeness is unknown.');
}
scopes.push(scope);
if (!scope.hasMore) {
+32
View File
@@ -353,6 +353,18 @@ describe('readCoverageAnswer', () => {
const answer = readCoverageAnswer(SCOPE_PAYLOAD);
it('preserves structured parser ranges from the real coverage RPC in the shared evidence', () => {
const parsed = readCoverageAnswer({ scopes: [{ scope: '.', entries: [
{ path: 'partial.c', kind: 'parse_partial', ranges: [{ start: 2, end: 2 }, { start: 5, end: 8 }, { start: 0, end: 4 }, { start: 7, end: 3 }, { start: '1', end: 2 }] },
{ path: 'explicit.c', kind: 'parse_partial', detail: 'original detail', ranges: [{ start: 1, end: 2 }] },
{ path: 'unknown.c', kind: 'parse_partial', ranges: [{ start: -1, end: 2 }] },
] }] });
const index = buildCoverageIndex({ scopes: parsed.scopes });
expect(index.records.get('partial.c')?.reason).toBe('Source lines: 2-2, 5-8');
expect(index.records.get('explicit.c')?.reason).toBe('original detail');
expect(index.records.get('unknown.c')?.reason).toBe('');
});
it('liest den Scope samt Seitenansage', () => {
expect(answer.scopes[0]?.scope).toBe('.');
expect(answer.scopes[0]?.total).toBe(3);
@@ -390,6 +402,26 @@ describe('readCoverageAnswer', () => {
describe('buildCoverageIndex', () => {
it('keeps unresolved status omissions when pages repeat rows, change totals, or cover only a subtree', () => {
const status = readIndexStatusCoverage({ parse_partial: { files: [], count: 2, truncated: true } });
const page = { requestedScope: '.', scope: '.', status: 'known_gaps', total: 2, hasMore: true, entries: [{ path: 'partial.c', kind: 'parse_partial', detail: '1-2' }] };
const repeated = buildCoverageIndex({ status, scopes: [page, { ...page, hasMore: false }] });
expect(repeated.listingComplete).toBe(false);
expect(repeated.truncations.some((line) => line.includes('cut the parse_partial'))).toBe(true);
expect(repeated.truncations.some((line) => line.includes('1 distinct rows loaded'))).toBe(true);
const changed = buildCoverageIndex({ scopes: [page, { ...page, total: 1, hasMore: false }] });
expect(changed.listingComplete).toBe(false);
const subtree = buildCoverageIndex({ status, scopes: [{ ...page, scope: 'src', total: 1, hasMore: false }] });
expect(subtree.listingComplete).toBe(false);
expect(subtree.truncations.some((line) => line.includes('cut the parse_partial'))).toBe(true);
});
it('does not call unavailable coverage complete even when its empty count agrees', () => {
const index = buildCoverageIndex({ scopes: [{ requestedScope: '.', scope: '.', status: 'coverage_unavailable', total: 0, hasMore: false, entries: [] }] });
expect(index.listingComplete).toBe(false);
expect(index.truncations).toHaveLength(1);
});
const index = buildCoverageIndex({
status: readIndexStatusCoverage(STATUS_PAYLOAD),
scopes: readCoverageAnswer(SCOPE_PAYLOAD).scopes,
+53 -9
View File
@@ -418,6 +418,8 @@ export interface NotIndexedFileEntry {
/** Die Coverage-Beilage von `index_status`, so wie der Server sie schreibt. */
export interface IndexStatusCoverage {
/** The index generation whose summary counts were read, when reported. */
generation?: string;
parsePartial: PartialFileEntry[];
parsePartialCount: number;
parsePartialTruncated: boolean;
@@ -501,6 +503,7 @@ export function readIndexStatusCoverage(payload: unknown): IndexStatusCoverage {
}
return {
...(coverageString(raw['indexed_at']) ? { generation: coverageString(raw['indexed_at']) } : {}),
parsePartial: partialFiles,
parsePartialCount: asNumber(partial['count']),
parsePartialTruncated: partial['truncated'] === true,
@@ -590,6 +593,19 @@ function readCoverageRows(value: unknown): CoverageRow[] {
kind: coverageString(entry['kind']),
detail: coverageString(entry['detail']),
};
// The current MCP encoder replaces parse_partial's stored detail with
// structured ranges. Preserve that source evidence in the shared join.
if (row.kind === 'parse_partial' && !row.detail) {
const ranges = coverageArray(entry['ranges']).flatMap((range) => {
if (!isRecord(range)) return [];
const start = range['start'];
const end = range['end'];
return typeof start === 'number' && typeof end === 'number'
&& Number.isSafeInteger(start) && Number.isSafeInteger(end)
&& start > 0 && end >= start ? [`${start}-${end}`] : [];
});
if (ranges.length > 0) row.detail = `Source lines: ${ranges.join(', ')}`;
}
const match = coverageString(entry['match']);
if (match.length > 0) {
row.match = match;
@@ -620,7 +636,8 @@ export function readCoverageAnswer(payload: unknown): CoverageAnswer {
}
const scopes: CoverageScope[] = [];
for (const entry of coverageArray(raw['scopes'])) {
const rawScopes = coverageArray(raw['scopes']);
for (const entry of rawScopes) {
if (!isRecord(entry)) {
continue;
}
@@ -629,11 +646,17 @@ export function readCoverageAnswer(payload: unknown): CoverageAnswer {
scope: coverageString(entry['scope']),
status: coverageString(entry['status']),
total: asNumber(entry['total']),
hasMore: entry['has_more'] === true,
// Production reports continuation on the response envelope. A
// scope's `truncated` also remains true on its final partial page,
// so that flag cannot determine whether another page is needed.
hasMore: typeof entry['has_more'] === 'boolean' ? entry['has_more']
: rawScopes.length === 1 && raw['has_more'] === true,
entries: readCoverageRows(entry['entries']),
};
if (entry['next_offset'] !== undefined) {
scope.nextOffset = asNumber(entry['next_offset']);
} else if (rawScopes.length === 1 && raw['next_offset'] !== undefined) {
scope.nextOffset = asNumber(raw['next_offset']);
}
scopes.push(scope);
}
@@ -676,6 +699,8 @@ export interface CoverageIndex {
records: ReadonlyMap<string, CoverageRecord>;
/** Ehrliche Zeilen ueber Listen, die der Server gekappt hat. */
truncations: string[];
/** All recorded root-scope rows were returned, not all repository files indexed. */
listingComplete?: boolean;
counts: {
partial: number;
skipped: number;
@@ -743,6 +768,22 @@ export function buildCoverageIndex(input: {
const records = new Map<string, CoverageRecord>();
const truncations: string[] = [];
const counts = { partial: 0, skipped: 0, notIndexedDirs: 0, notIndexedFiles: 0, scopeEntries: 0 };
const scopeReadings = new Map<string, { last: CoverageScope; total: number; consistent: boolean; rows: Map<string, CoverageRow> }>();
for (const scope of input.scopes ?? []) {
const key = scope.scope || '.';
const reading = scopeReadings.get(key) ?? { last: scope, total: scope.total, consistent: true, rows: new Map<string, CoverageRow>() };
reading.consistent &&= scope.total === reading.total
&& ['known_gaps', 'no_recorded_issue', 'complete'].includes(scope.status);
reading.last = scope;
for (const row of scope.entries) reading.rows.set(`${row.path}\0${row.kind}`, row);
scopeReadings.set(key, reading);
}
const complete = (reading: NonNullable<ReturnType<typeof scopeReadings.get>>) =>
reading.consistent && !reading.last.hasMore && reading.rows.size === reading.total;
const rootReading = scopeReadings.get('.');
const listingComplete = rootReading !== undefined && complete(rootReading);
const rootRows = listingComplete ? [...rootReading!.rows.values()] : [];
const listedKind = (kind: string) => rootRows.filter((row) => row.kind === kind).length;
const status = input.status;
if (status !== undefined) {
@@ -787,17 +828,18 @@ export function buildCoverageIndex(input: {
counts.notIndexedDirs = status.notIndexedDirsCount;
counts.notIndexedFiles = status.notIndexedFilesCount;
if (status.parsePartialTruncated) {
if (status.parsePartialTruncated && (!listingComplete || listedKind('parse_partial') < status.parsePartialCount)) {
truncations.push(
`the server cut the parse_partial list: ${status.parsePartialCount} recorded, fewer listed`,
);
}
if (status.skippedTruncated) {
const listedSkipped = rootRows.filter((row) => coverageStateForKind(row.kind) === 'skipped').length;
if (status.skippedTruncated && (!listingComplete || listedSkipped < status.skippedCount)) {
truncations.push(
`the server cut the skipped list: ${status.skippedCount} recorded, fewer listed`,
);
}
if (status.notIndexedTruncated) {
if (status.notIndexedTruncated && (!listingComplete || listedKind('not_indexed_dir') < status.notIndexedDirsCount || listedKind('not_indexed_file') < status.notIndexedFilesCount)) {
truncations.push(
'the server cut the not_indexed list: '
+ `${status.notIndexedDirsCount} folders and ${status.notIndexedFilesCount} files recorded, fewer listed`,
@@ -817,15 +859,17 @@ export function buildCoverageIndex(input: {
});
counts.scopeEntries += 1;
}
if (scope.hasMore) {
}
for (const [scope, reading] of scopeReadings) {
if (!complete(reading)) {
truncations.push(
`the coverage store has more entries under "${scope.scope.length > 0 ? scope.scope : '.'}" `
+ `than were fetched: ${scope.total} recorded`,
`the coverage reading under "${scope}" is incomplete or inconsistent: `
+ `${reading.total} recorded, ${reading.rows.size} distinct rows loaded`,
);
}
}
return { records, truncations, counts };
return { records, truncations, counts, listingComplete };
}
/**
+17 -2
View File
@@ -22,7 +22,7 @@ afterEach(() => {
handle = undefined;
});
function install() {
function install(getProject?: () => string) {
const posts: { payload: UiLogPayload; final: boolean }[] = [];
const buffer = new UiLogBuffer({
page: '/?project=demo',
@@ -44,7 +44,7 @@ function install() {
warn: (...args: unknown[]) => printed.push(['warn', ...args]),
error: (...args: unknown[]) => printed.push(['error', ...args]),
} as unknown as Console;
handle = installUiLog({ buffer, console: fakeConsole, session: 'test' });
handle = installUiLog({ buffer, console: fakeConsole, session: 'test', getProject });
return { buffer, posts, printed, fakeConsole };
}
@@ -54,6 +54,21 @@ async function entries(buffer: UiLogBuffer, posts: { payload: UiLogPayload }[])
}
describe('installUiLog', () => {
it('captures console ownership before flushing and retains the failed request project', async () => {
let project = 'A';
const { buffer, posts, fakeConsole } = install(() => project);
fakeConsole.warn('A warning');
project = 'B';
fakeConsole.error('B error');
reportError({ project: 'A', source: 'rpc', level: 'error', message: 'late A request' });
reportError({ project: '', source: 'api', level: 'error', message: 'daemon request' });
const all = await entries(buffer, posts);
expect(all.map((entry) => [entry.message, entry.project])).toEqual([
['session test started on /', 'A'], ['A warning', 'A'], ['B error', 'B'],
['late A request', 'A'], ['daemon request', ''],
]);
});
it('starts the session with a line that names page, build and browser', async () => {
const { buffer, posts } = install();
const all = await entries(buffer, posts);
+20 -5
View File
@@ -37,6 +37,14 @@ export interface InstallUiLogOptions {
transport?: UiLogTransport;
buffer?: UiLogBuffer;
session?: string;
getProject?: () => string;
}
let activeProject: string | undefined;
/** The application also resolves projects without changing the page URL. */
export function setUiLogProject(project: string): void {
activeProject = project;
}
export interface UiLogHandle {
@@ -129,8 +137,10 @@ export function installUiLog(options: InstallUiLogOptions = {}): UiLogHandle {
const con = options.console ?? console;
const session = options.session ?? newSessionId();
const page = `${target.location.pathname}${target.location.search}`;
const getProject = options.getProject ?? (() => activeProject
?? new URLSearchParams(target.location.search).get('project') ?? '');
const buffer = options.buffer
?? new UiLogBuffer({ page, session, transport: options.transport ?? httpUiLogTransport() });
?? new UiLogBuffer({ page, session, getProject, transport: options.transport ?? httpUiLogTransport() });
let recording = false;
const guarded = (fn: () => void): void => {
@@ -155,8 +165,9 @@ export function installUiLog(options: InstallUiLogOptions = {}): UiLogHandle {
original.apply(con, args);
guarded(() => {
const described = describeArgs(args);
buffer.record(level, 'console', described.message,
described.stack === undefined ? {} : { stack: described.stack });
buffer.record(level, 'console', described.message, {
project: getProject(), stack: described.stack,
});
});
};
}
@@ -165,6 +176,7 @@ export function installUiLog(options: InstallUiLogOptions = {}): UiLogHandle {
guarded(() => {
const described = describeReason(event.error);
buffer.record('error', 'window', event.message.length > 0 ? event.message : described.message, {
project: getProject(),
stack: described.stack,
url: event.filename,
line: event.lineno,
@@ -175,8 +187,9 @@ export function installUiLog(options: InstallUiLogOptions = {}): UiLogHandle {
const onRejection = (event: PromiseRejectionEvent): void => {
guarded(() => {
const described = describeReason(event.reason);
buffer.record('error', 'promise', described.message,
described.stack === undefined ? {} : { stack: described.stack });
buffer.record('error', 'promise', described.message, {
project: getProject(), stack: described.stack,
});
});
};
const onHide = (): void => {
@@ -190,6 +203,7 @@ export function installUiLog(options: InstallUiLogOptions = {}): UiLogHandle {
const stopObserving = observeErrors((report) => {
guarded(() => {
buffer.record(report.level, report.source, report.message, {
project: report.project ?? getProject(),
detail: report.detail,
stack: report.stack,
});
@@ -203,6 +217,7 @@ export function installUiLog(options: InstallUiLogOptions = {}): UiLogHandle {
const userAgent = (target.navigator as Navigator | undefined)?.userAgent ?? '';
buffer.record('info', 'ui-log', `session ${session} started on ${page}`, {
project: getProject(),
detail: `build ${ATLAS_VERSION}${ATLAS_BUILD_SUFFIX.length > 0 ? `-${ATLAS_BUILD_SUFFIX}` : ''}; ${userAgent}`,
url: target.location.href,
});
+23
View File
@@ -57,6 +57,29 @@ function harness(answers: () => boolean = () => true) {
}
describe('UiLogBuffer', () => {
it('preserves the recorded project across project changes and failed batch retries', async () => {
let project = 'project-A';
let accepted = false;
const posts: UiLogPayload[] = [];
const buffer = new UiLogBuffer({
page: '/', session: 'projects', getProject: () => project,
transport: { send: async (payload) => { posts.push(payload); return accepted; } },
schedule: () => 0, cancel: () => undefined,
});
buffer.record('error', 'console', 'A failed');
await buffer.flush();
project = 'project-B';
buffer.record('warn', 'console', 'B warning');
buffer.record('error', 'rpc', 'late A request failed', { project: 'project-A' });
buffer.record('error', 'api', 'daemon request failed', { project: '' });
accepted = true;
await buffer.flush();
expect(posts[1]?.entries.map((entry) => [entry.message, entry.project])).toEqual([
['A failed', 'project-A'], ['B warning', 'project-B'],
['late A request failed', 'project-A'], ['daemon request failed', ''],
]);
});
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' });
+14 -1
View File
@@ -8,7 +8,8 @@
* into the failure had nothing to read. This buffer is the second copy: every
* console call, every uncaught error, every rejected promise and every failed
* /rpc or /api request is queued here and posted to POST /api/ui-log, which
* appends it to <cache_dir>/logs/ui.log on the server (src/ui/http_server.c).
* persists it in the local SQLite journal and a bounded compatibility JSONL
* export on the same daemon (src/ui/http_server.c).
* The file is what a bug report attaches and what `tail -f` reads.
*
* The rules of the buffer, and why each is there:
@@ -36,6 +37,8 @@
export type UiLogLevel = 'debug' | 'log' | 'info' | 'warn' | 'error';
export interface UiLogEntry {
/** Project at recording time; an empty string explicitly means daemon-wide. */
project?: string;
/** When the entry was recorded on the page, ISO 8601. */
ts: string;
/** Position in this page's sequence, from 1. Gaps mean dropped entries. */
@@ -66,6 +69,7 @@ export interface UiLogTransport {
}
export interface UiLogExtra {
project?: string;
detail?: string;
stack?: string;
url?: string;
@@ -93,6 +97,8 @@ export interface UiLogOptions {
page: string;
session: string;
transport: UiLogTransport;
/** Sampled when recording, never when sending a delayed or retried batch. */
getProject?: () => string;
now?: () => Date;
schedule?: (fn: () => void, ms: number) => unknown;
cancel?: (handle: unknown) => void;
@@ -153,6 +159,10 @@ export class UiLogBuffer {
source: source.slice(0, 64),
message: capField(message),
};
const project = extra.project ?? this.options.getProject?.();
if (project !== undefined) {
entry.project = project;
}
if (extra.detail !== undefined && extra.detail.length > 0) {
entry.detail = capField(extra.detail);
}
@@ -202,6 +212,9 @@ export class UiLogBuffer {
const lost = this.droppedSinceFlush;
this.droppedSinceFlush = 0;
batch.unshift({
// Loss can span several projects. Do not attribute the aggregate
// to whichever project happens to be open when delivery resumes.
project: '',
ts: this.now().toISOString(),
seq: ++this.seq,
level: 'warn',
+4
View File
@@ -46,6 +46,10 @@ export const workspaceStrings = {
daemonNavigation: (state: string) => `Open System: daemon ${state}`,
chatWidth: 'Width of local chat',
askSelection: 'Ask about selection',
selectionImpact: 'Assess change impact',
changeAnalysis: 'Change analysis',
sourceOutsideSnapshot: 'This file is outside the loaded index snapshot.',
selectionEvidence: 'Selection evidence',
graphContext: 'Graph context',
coverageShadowSelection: (name: string) => `Coverage shadow: ${name}`,
start: 'Start exploring',
@@ -8,13 +8,18 @@ import {
import type { ArchitectureView, ConfigStorage } from './architecture-model';
import { architectureText as text } from './strings';
import './architecture.css';
import RepositoryMapView, { RelationshipEvidence } from './RepositoryMap';
import type { RepositoryMapProps } from './RepositoryMap';
import { repositoryMap } from './repository-map';
export interface ArchitecturePanelProps {
export interface ArchitecturePanelProps extends Pick<RepositoryMapProps, 'graph' | 'selection' | 'selectionPanel' | 'onSelect' | 'graphNote' | 'readSource'> {
projectName: string;
graphGeneration?: string;
overview?: ArchitectureOverviewDto;
loading?: boolean;
error?: string;
onRefresh?: () => void;
onProjectWalk?: () => void;
/** The declaration line is 1-based, as returned by the provider. */
onNavigate: (filePath: string, line?: number, name?: string) => void;
}
@@ -161,8 +166,11 @@ function Overview({ data, filtered, filter, onGroup, onNavigate }: {
</>;
}
function Dependencies({ data, empty, onGroup }: { data: ArchitectureOverviewDto; empty: string; onGroup: (group: string) => void }): JSX.Element {
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} />
@@ -170,14 +178,18 @@ function Dependencies({ data, empty, onGroup }: { data: ArchitectureOverviewDto;
<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.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>}
</>;
}
@@ -224,7 +236,7 @@ function Findings({ view, data, empty, onNavigate }: {
</section>;
}
function ArchitectureWorkspace({ projectName, overview, loading = false, error, onRefresh, onNavigate }: ArchitecturePanelProps): JSX.Element {
function ArchitectureWorkspace({ projectName, overview, loading = false, error, onRefresh, onProjectWalk, onNavigate, graph, selection, selectionPanel, onSelect, graphNote, graphGeneration, readSource }: ArchitecturePanelProps): JSX.Element {
const storage = useMemo(browserStorage, []);
const [config, setConfig] = useState(() => readArchitectureConfig(storage, projectName));
const [saved, setSaved] = useState(true);
@@ -238,7 +250,8 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error,
return <section className="atlas-architecture" data-testid="atlas-architecture" aria-label={text.title} aria-busy={loading}>
<header className="atlas-arch-heading"><div><span className="atlas-arch-project">{projectName}</span>
<h1>{text.title}</h1><p>{text.subtitle}</p></div>
{onRefresh && <button className="atlas-arch-action" onClick={onRefresh} disabled={loading || !projectName}>{text.refresh}</button>}
<div className="repo-map-directions">{onProjectWalk && <button onClick={onProjectWalk} disabled={!projectName}>{text.importWalk}</button>}
{onRefresh && <button className="atlas-arch-action" onClick={onRefresh} disabled={loading || !projectName}>{text.refresh}</button>}</div>
</header>
<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} data-view={view}
@@ -254,9 +267,10 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error,
: 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}
{ready && data && filtered && <div className="atlas-arch-content" key={`${config.view}:${config.filter}`} data-testid="atlas-architecture-content">
{config.view === 'overview' ? <Overview data={data} filtered={filtered} filter={config.filter} onGroup={onGroup} onNavigate={onNavigate} />
: config.view === 'dependencies' ? <Dependencies data={filtered} empty={empty} onGroup={onGroup} />
{ready && data && filtered && <div className="atlas-arch-content" key={`${config.view}:${config.filter}:${graphGeneration ?? ""}`} data-testid="atlas-architecture-content">
{config.view === 'overview' ? <><RepositoryMapView graph={graph} graphNote={graphNote} overview={data} filter={config.filter} onNavigate={onNavigate} onSelect={onSelect} selection={selection} selectionPanel={selectionPanel} readSource={readSource} />
<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} />}
</div>}
{ready && <details className="atlas-arch-evidence"><summary>{text.evidenceSummary}</summary><p>{text.evidenceDetail}</p></details>}
@@ -0,0 +1,31 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { expect, it, vi } from 'vitest';
import RepositoryMapView from './RepositoryMap';
import type { GraphData, GraphNode } from '../galaxy/types';
import type { ArchitectureOverviewDto } from '../core/intelligence-provider';
it('walks repository to an area, relationship evidence, file, symbol and source', async () => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
const container = document.createElement('div'); document.body.append(container);
const root = createRoot(container); const onNavigate = vi.fn(); const onSelect = vi.fn();
const makeNode = (id: number, name: string, path: string): GraphNode => ({ id, name, file_path: path,
start_line: 12, x: 0, y: 0, z: 0, label: 'Function', size: 1, color: '#fff' });
const graph: GraphData = { total_nodes: 2, nodes: [makeNode(1, 'serve', 'src/api/server.ts'), makeNode(2, 'save', 'src/store/db.ts')], edges: [{ source: 1, target: 2, type: 'CALLS' }] };
const overview: ArchitectureOverviewDto = { totalSymbols: 2, totalRelations: 1, symbolKinds: [], relationKinds: [], languages: [], groups: [], entryPoints: [], routes: [], clusters: [], layers: [], boundaries: [], hotspots: [], files: [] };
const click = async (text: string) => { const buttons = [...container.querySelectorAll('button')]; const button = buttons.find(button => button.textContent === text) ?? buttons.find(button => button.textContent?.includes(text)); expect(button, text).toBeTruthy(); await act(async () => button!.click()); };
try {
await act(async () => root.render(<RepositoryMapView graph={graph} overview={overview} filter="" onNavigate={onNavigate} onSelect={onSelect} />));
await click('src/api');
expect(container.textContent).toContain('How this area connects');
await click('1 edges · inspect');
expect(container.querySelector('[aria-label="Relationship evidence"]')?.textContent).toContain('CALLS');
await click('save'); expect(onSelect).toHaveBeenCalledWith(graph.nodes[1]);
await click('src/api/server.ts');
await click('Read file'); expect(onNavigate).toHaveBeenCalledWith('src/api/server.ts');
await click('serve'); expect(onSelect).toHaveBeenCalledWith(graph.nodes[0]);
await click('Repository');
expect(container.textContent).toContain('Components and responsibilities');
} finally { await act(async () => root.unmount()); container.remove(); }
});
+160
View File
@@ -0,0 +1,160 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import type { ArchitectureOverviewDto } from '../core/intelligence-provider';
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';
import { useRepositoryGuide } from './useRepositoryGuide';
export interface RepositoryMapProps {
graph?: GraphData;
overview: ArchitectureOverviewDto;
filter: string;
onNavigate: (path: string, line?: number, name?: string) => void;
onSelect?: (node: GraphNode) => void;
selection?: GraphNode;
selectionPanel?: ReactNode;
graphNote?: string;
readSource?: SourceReader;
}
function Nodes({ nodes, onSelect, onNavigate }: Pick<RepositoryMapProps, 'onSelect' | 'onNavigate'> & { nodes: GraphNode[] }) {
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>
</button>
</li>)}</ul>{nodes.length > limit && <button className="atlas-arch-action" onClick={() => setLimit(limit + 24)}>More symbols ({limit} of {nodes.length})</button>}</>;
}
export function RelationshipEvidence({ edges, onSelect, onNavigate }: Pick<RepositoryMapProps, 'onSelect' | 'onNavigate'> & { edges: MapEvidence[] }) {
const [limit, setLimit] = useState(12);
return <div className="repo-map-evidence" aria-label="Relationship evidence">
<p className="repo-map-note">Static index edges can include heuristic name resolution. Open the declarations to verify the relationship; a call edge does not prove runtime execution.</p>
{edges.slice(0, limit).map((edge, index) => <div className="repo-map-edge" key={`${edge.source.id}:${edge.type}:${edge.target.id}:${index}`}>
<Nodes nodes={[edge.source]} onSelect={onSelect} onNavigate={onNavigate} />
<span className="repo-map-relation">{edge.type} →{edge.id !== undefined && <small>Edge #{edge.id}</small>}
{edge.line !== undefined && edge.source.file_path && <button onClick={() => onNavigate(edge.source.file_path!, edge.line, edge.source.name)}>Site :{edge.line}</button>}
{edge.unverifiedLine !== undefined && <small title="The reported line could not be verified against the source declaration range. Inspect the declarations instead.">Site unverified<br />Reported :{edge.unverifiedLine}</small>}
<details className="repo-map-resolution"><summary>Resolution</summary><small>{edge.strategy ? `Index method: ${edge.strategy}` : 'Resolution method not recorded.'}</small>
{edge.confidence !== undefined && <small>Index confidence: {edge.confidence}. This is a resolver score, not a defect probability.</small>}</details></span>
<Nodes nodes={[edge.target]} onSelect={onSelect} onNavigate={onNavigate} />
</div>)}
<p>{Math.min(limit, edges.length)} of {edges.length} retained edges</p>
{limit < edges.length && <button className="atlas-arch-action" onClick={() => setLimit(limit + 24)}>More evidence</button>}
</div>;
}
function Quote({ quote, onNavigate }: { quote: SourceQuote; onNavigate: RepositoryMapProps['onNavigate'] }) {
return <div className="repo-map-quote"><span className="repo-map-eyebrow">{quote.kind === 'readme' ? 'Project README' : 'Source documentation'} · author description</span>
<blockquote>{quote.text}</blockquote><button onClick={() => onNavigate(quote.path, quote.line)}>Source: {quote.path}:{quote.line}</button></div>;
}
export default function RepositoryMapView({ graph, overview, filter, onNavigate, onSelect, selection, selectionPanel, graphNote, readSource }: RepositoryMapProps) {
const map = useMemo(() => graph ? repositoryMap(graph) : undefined, [graph]);
const [areaPath, setAreaPath] = useState<string>();
const [file, setFile] = useState<string>();
const [showAll, setShowAll] = useState(false);
const [direction, setDirection] = useState<'incoming' | 'outgoing'>('outgoing');
const [evidence, setEvidence] = useState<{ title: string; edges: MapEvidence[] }>();
const [entryId, setEntryId] = useState<number>();
const selectionRef = useRef<HTMLElement>(null);
const evidenceRef = useRef<HTMLElement>(null);
useEffect(() => { selectionRef.current?.scrollIntoView?.({ block: 'start', behavior: 'smooth' }); }, [selection?.id]);
useEffect(() => { if (evidence) { evidenceRef.current?.scrollIntoView?.({ block: 'nearest' }); evidenceRef.current?.focus({ preventScroll: true }); } }, [evidence]);
const area = map?.areas.find(row => row.path === areaPath);
const openArea = (path?: string) => { setAreaPath(path); setFile(undefined); setEvidence(undefined); setShowAll(false); };
const query = filter.trim().toLowerCase();
const entries = useMemo(() => entryCandidates(graph?.nodes ?? []), [graph]);
const entry = entries.find(node => node.id === entryId) ?? entries[0];
const routes = useMemo(() => entry && map ? entryRoutes(entry, map.evidence) : [], [entry, map]);
const primaryAreas = useMemo(() => [...(map?.areas ?? [])].sort((a, b) => {
const support = (path: string) => /(^|\/)(tests?|tools|scripts|docs|pkg|\.github|test-infrastructure)(\/|$)/.test(path);
return Number(support(a.path)) - Number(support(b.path));
}), [map]);
const summaryEntries = overview.entryPoints.filter(entry => entry.filePath);
const readme = overview.files.find(path => /^readme\.(md|rst|txt)$/i.test(path));
const sourcePaths = useMemo(() => [...new Set([...(readme ? [readme] : []), ...(area ? sourceCandidates(area) : []),
...primaryAreas.slice(0, 12).flatMap(sourceCandidates)])], [readme, area, primaryAreas]);
const guide = useRepositoryGuide(sourcePaths, readSource);
const quoteFor = (files: string[]) => sourcePaths.filter(path => files.includes(path)).map(path => guide.quotes[path]).find(Boolean);
const areas = primaryAreas.filter(row => !query || row.path.toLowerCase().includes(query)
|| row.nodes.some(node => node.name.toLowerCase().includes(query)) || quoteFor(row.files)?.text.toLowerCase().includes(query));
const scopedNodes = area?.nodes.filter(node => node.file_path === file && !['File', 'Module'].includes(node.label)) ?? [];
const connections = area ? areaConnections(area, direction) : [];
const layers = area ? overview.layers.filter(layer => layer.group && (area.path === layer.group || area.path.endsWith(`/${layer.group}`))) : [];
return <div className="repo-map" data-testid="repository-map">
<div className="repo-map-intro">
<div><p>Repository map · source responsibilities and indexed connections</p></div>
<details><summary>Evidence legend</summary><p>CALLS: indexed invocations. IMPORTS: static imports. USAGE: references. INHERITS / IMPLEMENTS: type contracts. DATA_FLOWS appears only for recorded data-flow edges. Directory areas and layer roles are navigation heuristics, not confirmed domain boundaries. Agent activity is shown separately.</p></details>
</div>
{!map && graphNote && <p role="status" className="repo-map-note">{graphNote}</p>}
{!map ? <p role="status">Repository graph not available yet. The summary tabs remain available.</p> : <>
<details className="repo-map-snapshot"><summary>{map.fileCount.toLocaleString()} indexed files · {map.evidence.length.toLocaleString()} retained edges · snapshot and limits</summary>
{graphNote && <p className="repo-map-note">{graphNote}</p>}
<p className="repo-map-note">Bounded index snapshot; missing edges do not prove independence.{map.unresolvedEdges > 0 && ` ${map.unresolvedEdges} edges have an endpoint outside these source areas.`}</p>
</details>
<nav className="repo-map-breadcrumb" aria-label="Repository map location">
<button onClick={() => openArea()}>Repository</button>
{area && <><span>›</span><button onClick={() => { setFile(undefined); setEvidence(undefined); }}>{area.path}</button></>}
{file && <><span>›</span><span>{file.split('/').at(-1)}</span></>}
</nav>
{selection && <section className="repo-map-selection" ref={selectionRef}><div className="repo-map-selection-heading"><div><span className="repo-map-eyebrow">Selected symbol · {areaOf(selection.file_path ?? '')}</span><h2>{selection.name}</h2></div>
<button className="atlas-arch-action" onClick={() => onNavigate(selection.file_path!, selection.start_line, selection.name)}>Open source · {selection.file_path}:{selection.start_line ?? '?'}</button></div>
{selection.documentation && <details><summary>Indexed documentation</summary><p>{selection.documentation}</p></details>}
{selectionPanel}
</section>}
{!area ? <>
<div className="repo-map-onboarding">
<section className="repo-map-purpose"><h3>What this repository does</h3>
{readme && guide.quotes[readme] ? <Quote quote={guide.quotes[readme]} onNavigate={onNavigate} />
: <p>{readSource && !guide.finished ? 'Reading the project introduction…' : 'No project introduction was available in the inspected source. Explore the component evidence below.'}</p>}
{readme && <button className="atlas-arch-action" onClick={() => onNavigate(readme)}>Read project README</button>}
</section>
<section className="repo-map-start"><h3>Where to start</h3><p>Index entry candidates, with roles inferred from file type and location. Select one to follow its static calls.</p>
{entries.length ? <div className="repo-map-entry-grid">{entries.slice(0, 4).map(node => <button key={node.id} aria-pressed={node.id === entry?.id} onClick={() => { setEntryId(node.id); setEvidence(undefined); }}>
<span className="repo-map-eyebrow">{entryRole(node)} · entry candidate</span><strong>{node.name}</strong><span>{node.file_path}:{node.start_line ?? '?'}</span></button>)}</div>
: summaryEntries.length ? <ul className="repo-map-nodes">{summaryEntries.slice(0, 4).map(entry => <li key={`${entry.filePath}:${entry.name}`}><button onClick={() => onNavigate(entry.filePath!, entry.line, entry.name)}><strong>{entry.name}</strong><span>{entry.filePath}</span></button></li>)}</ul>
: <p>No entry point was identified. Start with a connected area below and inspect its exported interfaces.</p>}
{entry && <details className="repo-map-flow" open={entryId !== undefined}><summary>Follow static call paths from {entry.name} · {routes.length} paths across files</summary><div className="repo-map-section-title"><h3>Static paths from {entry.name}</h3><button onClick={() => onNavigate(entry.file_path!, entry.start_line, entry.name)}>Read entry source</button></div>
<p>Each step is a recorded CALLS edge. This shows possible reachability, not an observed runtime sequence or data flow.</p>
{routes.length ? routes.map((route, index) => <div key={index} className="repo-map-route"><div className="repo-map-route-steps">{[route.edges[0].source, ...route.edges.map(edge => edge.target)].map((node, step) => <span key={node.id}>
{step > 0 && <span aria-label="calls" className="repo-map-route-arrow">→</span>}<button onClick={() => onNavigate(node.file_path!, node.start_line, node.name)}><small>{areaOf(node.file_path!)}</small><strong>{node.name}</strong></button></span>)}</div>
<button className="atlas-arch-action" onClick={() => setEvidence({ title: `Static call path from ${entry.name}`, edges: route.edges })}>Inspect {route.edges.length} call edges</button></div>)
: <p>No call path across files was found within 5 calls and 500 symbols. Open the entry source or choose another candidate.</p>}
{evidence && <section ref={evidenceRef} tabIndex={-1} className="repo-map-focused-evidence"><div className="repo-map-section-title"><h3>{evidence.title}</h3><button onClick={() => setEvidence(undefined)}>Close evidence</button></div><RelationshipEvidence edges={evidence.edges} onSelect={onSelect} onNavigate={onNavigate} /></section>}
</details>}
</section>
</div>
<section><h3>Components and responsibilities</h3><p className="repo-map-note">Boundaries follow source locations and are inferred. Descriptions quote source documentation; relationships come from indexed edges. Application areas appear before tests and tools.</p>
<div className="repo-map-areas">{areas.slice(0, showAll ? undefined : 12).map(row => { const quote = quoteFor(row.files); return <article key={row.path} className="repo-map-component">
<button className="repo-map-area" onClick={() => openArea(row.path)}><span className="repo-map-eyebrow">Source area · inferred boundary</span><strong>{row.path}</strong>
<span>{row.files.length} files · {row.entryPoints.length} entry candidates · {row.interfaces.length} exported symbols</span>
<span>Used by {new Set(row.incoming.map(edge => areaOf(edge.source.file_path!))).size} areas · Depends on {new Set(row.outgoing.map(edge => areaOf(edge.target.file_path!))).size} areas</span></button>
{quote ? <Quote quote={quote} onNavigate={onNavigate} /> : <p className="repo-map-no-description">{readSource && !guide.finished ? 'Checking source documentation…' : 'No responsibility description in the inspected file headers. Inspect interfaces and callers to establish its role.'}</p>}
<button className="repo-map-component-action" onClick={() => openArea(row.path)}>Explore connections →</button>
</article>; })}</div>
{!areas.length && <p>No source areas match. Clear the filter or inspect index diagnostics.</p>}
{areas.length > 12 && !showAll && <button className="atlas-arch-action" onClick={() => setShowAll(true)}>All {areas.length} areas, including tests and tools</button>}
</section>
</> : <>
<section className="repo-map-area-heading"><h2>{area.path}</h2><p>{area.files.length} files · {area.nodes.length} indexed nodes</p>
{quoteFor(area.files) && <Quote quote={quoteFor(area.files)!} onNavigate={onNavigate} />}
{layers.map(layer => <p key={layer.layer}>Inferred role: {layer.layer}. {layer.reason || 'No further reason supplied by the index.'}</p>)}
{!file && <><h3>Entry points and interfaces</h3><Nodes nodes={[...area.entryPoints, ...area.interfaces.filter(node => !area.entryPoints.includes(node))].slice(0, 8)} onSelect={onSelect} onNavigate={onNavigate} />
{!area.entryPoints.length && !area.interfaces.length && <p>No entry or export classification was supplied. Inspect a file below.</p>}</>}
</section>
{evidence && <section ref={evidenceRef} tabIndex={-1} key={evidence.title} className="repo-map-focused-evidence"><div className="repo-map-section-title"><h3>{evidence.title}</h3><button onClick={() => setEvidence(undefined)}>Close evidence</button></div><RelationshipEvidence edges={evidence.edges} onSelect={onSelect} onNavigate={onNavigate} /></section>}
{!file ? <div className="repo-map-columns"><section><h3>How this area connects</h3>
<div className="repo-map-directions"><button aria-pressed={direction === 'outgoing'} onClick={() => { setDirection('outgoing'); setEvidence(undefined); }}>Depends on →</button><button aria-pressed={direction === 'incoming'} onClick={() => { setDirection('incoming'); setEvidence(undefined); }}>Used by ←</button></div>
<p className="repo-map-note">Cross-area edges grouped by relation kind. Counts are retained graph edges, not call frequency.</p>
<ul className="repo-map-connections">{connections.map(row => <li key={`${row.area}:${row.type}`}>
<button onClick={() => openArea(row.area)}>{row.area}</button><span>{row.type}</span><button onClick={() => setEvidence({ title: `${area.path} ${direction === 'incoming' ? '←' : '→'} ${row.area}`, edges: row.evidence })}>{row.evidence.length} edges · inspect</button>
</li>)}</ul>{!connections.length && <p>No cross-area edges found in this snapshot. This is not evidence of independence.</p>}
</section><section><h3>Files</h3><div className="repo-map-files">{area.files.map(path => <button key={path} onClick={() => { setFile(path); setEvidence(undefined); }}>{path}</button>)}</div></section></div>
: <section><h3>Symbols in {file}</h3><button className="atlas-arch-action" onClick={() => onNavigate(file)}>Read file</button><Nodes nodes={scopedNodes} onSelect={onSelect} onNavigate={onNavigate} />
<button className="atlas-arch-action" onClick={() => setEvidence({ title: file, edges: map.evidence.filter(edge => edge.source.file_path === file || edge.target.file_path === file) })}>Inspect file relationships</button>
</section>}
</>}
</>}
</div>;
}
@@ -0,0 +1,181 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import SourceEvidenceDrawer, { fileQualifiedName, type SourceEvidenceTarget } from './SourceEvidenceDrawer';
import type { GraphData, GraphNode } from '../galaxy/types';
import type { CodeSnippetResult } from '../provider/rpc-schemas';
import { COLUMNS, fileNodeForPath, moduleForFile } from '../provider/cypher';
import projectLockEof from './fixtures/project-lock-eof.json';
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
const path = 'src/daemon/application.c';
const moduleName = 'cbm-pr2068.src.daemon.application';
const makeNode = (id: number, label: string, qualified_name: string): GraphNode => ({
id, label, qualified_name, name: 'application.c', file_path: path, x: 0, y: 0, z: 0, size: 1, color: '#fff',
});
const graph: GraphData = { total_nodes: 2, edges: [], nodes: [
makeNode(1, 'File', 'cbm-pr2068.src.daemon.application.c.__file__'), makeNode(2, 'Module', moduleName),
] };
// Synthetic text with the exact paging shape observed on real Module responses.
function page(first = 2901, overrides: Partial<CodeSnippetResult> = {}): CodeSnippetResult {
return { qualified_name: moduleName, file_path: `/workspace/${path}`, source_mode: 'full',
start_line: first, end_line: first + 159, source: Array.from({ length: 160 }, (_, i) => `fixture source line ${first + i}`).join('\n') + '\n',
source_truncated: true, source_clipped: true, next_start_line: first + 160, original_end_line: 3571, ...overrides };
}
const cleanups: (() => Promise<void>)[] = [];
afterEach(async () => { for (const clean of cleanups.splice(0)) await clean(); vi.restoreAllMocks(); });
async function mount(getCodeSnippet = vi.fn(async (_project: string, _qn: string, window?: { startLine?: number; maxLines?: number }) => page(window?.startLine)),
queryRows = vi.fn(async (_project: string, _query: string): Promise<Record<string, string>[]> => []), snapshot: GraphData | null = graph) {
const container = document.createElement('div'); document.body.append(container);
const root = createRoot(container), onClose = vi.fn(), onExplore = vi.fn();
let mounted = true;
const unmount = async () => { if (mounted) { mounted = false; await act(async () => root.unmount()); } container.remove(); };
cleanups.push(unmount);
const render = async (target: SourceEvidenceTarget = { filePath: path, line: 2909, kind: 'callsite' }, project = 'cbm-pr2068') => {
await act(async () => root.render(<SourceEvidenceDrawer project={project} target={target} graph={snapshot ?? undefined}
client={{ getCodeSnippet, queryRows }} onClose={onClose} onExplore={onExplore} />));
};
await render();
const click = async (name: string) => {
const button = [...container.querySelectorAll('button')].find(row => row.textContent === name);
expect(button, name).toBeTruthy(); await act(async () => button!.click());
};
return { container, render, click, onClose, onExplore, unmount, getCodeSnippet, queryRows };
}
it('prefers the full-range Module over the File fragment and highlights the actual call-site line', async () => {
expect(fileQualifiedName(graph, path)).toBe(moduleName);
const ui = await mount();
expect(ui.getCodeSnippet).toHaveBeenCalledWith('cbm-pr2068', moduleName, { startLine: 2901, maxLines: 160 });
expect(ui.queryRows).not.toHaveBeenCalled();
expect(ui.container.textContent).toContain('Indexed call site');
expect(ui.container.querySelector('[data-selected=true]')?.textContent).toContain('2909');
expect(ui.container.querySelectorAll('code > span')).toHaveLength(160); // No phantom trailing line.
expect(ui.container.querySelectorAll('a')).toHaveLength(0); // Line labels are not 160 empty tab stops.
expect(ui.container.querySelector('pre')?.tabIndex).toBe(0);
});
it('resolves source directly by project and path when Explore has no architecture snapshot', async () => {
const queryRows = vi.fn(async () => [{ [COLUMNS.moduleForFile[0]]: moduleName }]);
const ui = await mount(undefined, queryRows, null);
expect(queryRows).toHaveBeenCalledExactlyOnceWith('cbm-pr2068', moduleForFile(path));
expect(ui.getCodeSnippet).toHaveBeenCalledWith('cbm-pr2068', moduleName, { startLine: 2901, maxLines: 160 });
expect(ui.container.querySelector('[data-selected=true]')?.textContent).toContain('2909');
});
it('uses the verified File fallback and reports an unindexed path without guessing a name', async () => {
const fileName = moduleName + '.c.__file__';
const queryRows = vi.fn(async (_project: string, query: string) => query === fileNodeForPath(path)
? [{ [COLUMNS.fileNode[0]]: fileName }] : []);
const snippet = vi.fn(async () => page(2901, { qualified_name: fileName }));
const ui = await mount(snippet, queryRows, null);
expect(queryRows.mock.calls.map(call => call[1])).toEqual([moduleForFile(path), fileNodeForPath(path)]);
expect(snippet).toHaveBeenCalledWith('cbm-pr2068', fileName, { startLine: 2901, maxLines: 160 });
await ui.render({ filePath: 'missing.c' });
expect(ui.container.querySelector('[role=alert]')?.textContent).toContain('No indexed Module or File declaration');
expect(snippet).toHaveBeenCalledTimes(1);
});
it('does not continue a stale lookup after changing projects', async () => {
const pending: ((rows: Record<string, string>[]) => void)[] = [];
const queryRows = vi.fn(() => new Promise<Record<string, string>[]>(resolve => pending.push(resolve)));
const ui = await mount(undefined, queryRows, null);
await ui.render({ filePath: path, line: 50 }, 'other-project');
await act(async () => pending[0]([{ [COLUMNS.moduleForFile[0]]: 'old-project.module' }]));
expect(ui.getCodeSnippet).not.toHaveBeenCalled();
await act(async () => pending[1]([{ [COLUMNS.moduleForFile[0]]: moduleName }]));
expect(ui.getCodeSnippet).toHaveBeenCalledExactlyOnceWith('other-project', moduleName, { startLine: 42, maxLines: 160 });
});
it('paginates by the returned continuation, goes back, and resets a changed target location', async () => {
const ui = await mount();
await ui.click('Next lines');
expect(ui.getCodeSnippet).toHaveBeenLastCalledWith('cbm-pr2068', moduleName, { startLine: 3061, maxLines: 160 });
expect(ui.container.querySelector('[data-selected=true]')).toBeNull();
await ui.click('Previous lines');
expect(ui.container.querySelector('[data-selected=true]')?.textContent).toContain('2909');
await ui.render({ filePath: path, line: 50 });
expect(ui.getCodeSnippet).toHaveBeenLastCalledWith('cbm-pr2068', moduleName, { startLine: 42, maxLines: 160 });
expect(ui.container.querySelector('[data-selected=true]')?.textContent).toContain('50');
});
it('does not paint a previous project reply under a new project heading', async () => {
const pending: ((value: CodeSnippetResult) => void)[] = [];
const getCodeSnippet = vi.fn(() => new Promise<CodeSnippetResult>(resolve => pending.push(resolve)));
const ui = await mount(getCodeSnippet);
await ui.render({ filePath: path, line: 10 }, 'other-project');
await act(async () => pending[0](page(2901, { source: 'old project secret' })));
expect(ui.container.textContent).not.toContain('old project secret');
expect(ui.container.textContent).toContain('Reading local source');
await act(async () => pending[1](page(2)));
expect(ui.container.querySelector('[data-selected=true]')?.textContent).toContain('10');
});
it('labels an ignored source window honestly instead of relabeling the returned lines', async () => {
// The actual File-node reply ignored start_line=2901 and returned 1..51.
const ui = await mount(vi.fn(async () => page(1, { end_line: 51, source: Array(51).fill('fixture').join('\n'),
source_truncated: undefined, source_clipped: undefined, next_start_line: undefined })));
expect(ui.container.textContent).toContain('Requested line 2909 is outside this returned window');
expect(ui.container.querySelector('[data-selected=true]')).toBeNull();
expect(ui.container.querySelector('.source-evidence-line-number')?.textContent).toBe('1');
});
it.each([undefined, 2901, 9999])('does not follow a missing, repeating or skipping continuation (%s)', async next => {
const ui = await mount(vi.fn(async () => page(2901, { next_start_line: next })));
const nextButton = [...ui.container.querySelectorAll('button')].find(button => button.textContent === 'Next lines');
expect(nextButton?.disabled).toBe(true);
expect(ui.container.textContent).toContain('no valid next page');
});
it('disables continuation at the reported end of source', async () => {
const ui = await mount(vi.fn(async () => page(2901, { source_truncated: false, source_clipped: false, next_start_line: undefined })));
expect([...ui.container.querySelectorAll('button')].find(button => button.textContent === 'Next lines')?.disabled).toBe(true);
expect(ui.container.textContent).not.toContain('no valid next page');
});
it('rejects source identity and line-range errors', async () => {
const ui = await mount(vi.fn(async () => page(2901, { qualified_name: 'other-project.secret' })));
expect(ui.container.querySelector('[role=alert]')?.textContent).toContain('different indexed file');
expect(ui.container.querySelector('pre')).toBeNull();
});
it('does not invent line evidence for a malformed source body', async () => {
const ui = await mount(vi.fn(async () => page(2901, { source: 'one line' })));
expect(ui.container.querySelector('[role=alert]')?.textContent).toContain('does not match');
expect(ui.container.querySelector('[data-selected=true]')).toBeNull();
expect(ui.container.querySelector('.source-evidence-line-number')?.textContent).toBe('');
});
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.
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.querySelector('[data-selected=true]')?.textContent).toContain('cbm_private_lock_directory_close(directory)');
expect(ui.container.textContent).toContain('Lines 53 to 190');
expect([...ui.container.querySelectorAll('button')].find(button => button.textContent === 'Next lines')?.disabled).toBe(true);
});
it('treats source markup as text and never requests external assets', async () => {
const fetch = vi.spyOn(globalThis, 'fetch');
const ui = await mount(vi.fn(async () => page(2901, { source: '<img src="https://example.invalid/private">', end_line: 2901 })));
expect(ui.container.querySelector('img')).toBeNull();
expect(ui.container.textContent).toContain('<img src=');
expect(fetch).not.toHaveBeenCalled();
});
it('focuses close, lets Escape close the modeless drawer, then restores its original opener', async () => {
const opener = document.createElement('button'); document.body.append(opener); opener.focus();
const ui = await mount();
expect(document.activeElement?.getAttribute('aria-label')).toBe('Close source evidence');
expect(ui.container.querySelector('[role=dialog]')?.getAttribute('aria-modal')).toBe('false');
await act(async () => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })));
expect(ui.onClose).toHaveBeenCalledTimes(1);
await ui.unmount();
expect(document.activeElement).toBe(opener); opener.remove();
});
@@ -0,0 +1,152 @@
import { useEffect, useRef, useState } from 'react';
import type { RpcIntelligenceClient } from '../provider/rpc-client';
import type { CodeSnippetResult } from '../provider/rpc-schemas';
import type { GraphData } from '../galaxy/types';
import { COLUMNS, fileNodeForPath, moduleForFile } from '../provider/cypher';
import './source-evidence.css';
export interface SourceEvidenceTarget { filePath: string; line?: number; name?: string; kind?: 'callsite' | 'declaration' | 'file' }
const PAGE_LINES = 160;
const validLine = (value: number | undefined): value is number => value !== undefined && Number.isSafeInteger(value) && value > 0;
export function fileQualifiedName(graph: GraphData | undefined, path: string): string | undefined {
// File nodes may carry 0..0 and only return the first source fragment.
// Module nodes own the whole-file range used by the source paging API.
return graph?.nodes.find(node => node.file_path === path && node.label === 'Module' && node.qualified_name)?.qualified_name
?? graph?.nodes.find(node => node.file_path === path && node.label === 'File' && node.qualified_name)?.qualified_name;
}
/** Source stays next to its referring map/analysis. No external resources are loaded. */
export default function SourceEvidenceDrawer({ project, target, graph, client, onClose, onExplore }: {
project: string; target: SourceEvidenceTarget; graph?: GraphData;
client: Pick<RpcIntelligenceClient, 'getCodeSnippet' | 'queryRows'>; onClose: () => void;
onExplore: (target: SourceEvidenceTarget) => void;
}) {
const qualifiedName = fileQualifiedName(graph, target.filePath);
const requestedLine = validLine(target.line) ? target.line : undefined;
const initialStart = Math.max(1, (requestedLine ?? 1) - 8);
const targetKey = JSON.stringify([project, target.filePath, requestedLine, qualifiedName]);
const [pageWindow, setPageWindow] = useState<{ key: string; start: number }>();
const start = pageWindow?.key === targetKey ? pageWindow.start : initialStart;
const [refresh, setRefresh] = useState(0);
const readKey = JSON.stringify([targetKey, start, refresh]);
const activeKey = useRef(readKey); activeKey.current = readKey;
const [reading, setReading] = useState<{ key: string; source?: CodeSnippetResult; error?: string }>();
// A new target can render before its effect. Never relabel old file contents.
const current = reading?.key === readKey ? reading : undefined;
const [copiedKey, setCopiedKey] = useState('');
const [copyError, setCopyError] = useState<{ key: string; message: string }>();
const closeRef = useRef<HTMLButtonElement>(null);
const selectedRef = useRef<HTMLSpanElement>(null);
const sourceRef = useRef<HTMLPreElement>(null);
const restoreFocus = useRef(true);
useEffect(() => {
const previous = document.activeElement;
closeRef.current?.focus();
return () => {
if (restoreFocus.current && previous instanceof HTMLElement && previous.isConnected
&& !previous.closest('[hidden], [inert]')) previous.focus({ preventScroll: true });
};
}, []);
useEffect(() => {
let alive = true;
const fail = (error: unknown) => {
if (alive) setReading({ key: readKey, error: error instanceof Error ? error.message : String(error) });
};
if (!project || !target.filePath || /[\x00-\x1f\x7f]/.test(target.filePath)) {
fail('Select a project and a valid repository file to inspect source evidence.');
return;
}
void (async () => {
let resolvedName = qualifiedName;
if (!resolvedName) {
// Evidence can originate in Explore or a bounded impact result,
// before the user has ever loaded the architecture snapshot.
const modules = await client.queryRows(project, moduleForFile(target.filePath));
if (!alive) return;
resolvedName = modules[0]?.[COLUMNS.moduleForFile[0]] || undefined;
if (!resolvedName) {
const files = await client.queryRows(project, fileNodeForPath(target.filePath));
if (!alive) return;
resolvedName = files[0]?.[COLUMNS.fileNode[0]] || undefined;
}
}
if (!resolvedName) throw new Error('No indexed Module or File declaration was found for this project and path. Check index diagnostics or choose another source location.');
const source = await client.getCodeSnippet(project, resolvedName, { startLine: start, maxLines: PAGE_LINES });
if (!alive) return;
if (source.qualified_name && source.qualified_name !== resolvedName) throw new Error('The source reply identifies a different indexed file. Refresh the map before following this evidence.');
const returnedPath = source.file_path?.replace(/\\/g, '/');
if (returnedPath && returnedPath !== target.filePath && !returnedPath.endsWith(`/${target.filePath}`))
throw new Error('The source reply identifies a different file path.');
if (!validLine(source.start_line) || !validLine(source.end_line) || source.end_line < source.start_line)
throw new Error('The source reply has no usable line range. No source location can be verified.');
if (source.source_mode && source.source_mode !== 'full') throw new Error('The daemon returned an outline instead of source. Open Explore to inspect the available file information.');
if (source.source.trim() === '(source not available)') throw new Error('The daemon could not read the current local source.');
setReading({ key: readKey, source });
})().catch(fail);
return () => { alive = false; };
}, [project, qualifiedName, start, client, readKey]);
const source = current?.source;
const lines = source ? source.source.split('\n') : [];
// A complete Module range may include its empty EOF line. A clipped page
// instead ends in a separator after its last reported line. The explicit
// source range distinguishes these cases; never accept other count gaps.
const reportedLines = source && validLine(source.start_line) && validLine(source.end_line)
? source.end_line - source.start_line + 1 : undefined;
if (lines.at(-1) === '' && lines.length - 1 === reportedLines) lines.pop();
const firstLine = source?.start_line;
const lastLine = validLine(firstLine) ? firstLine + lines.length - 1 : undefined;
const rangeMatches = source && lastLine === source.end_line;
const more = source?.source_truncated === true || source?.source_clipped === true;
const next = source?.next_start_line;
const canNext = Boolean(more && validLine(next) && validLine(lastLine) && next === lastLine + 1 && rangeMatches);
const locationVisible = requestedLine !== undefined && validLine(firstLine) && validLine(lastLine)
&& requestedLine >= firstLine && requestedLine <= lastLine && rangeMatches;
useEffect(() => {
const panel = sourceRef.current, selected = selectedRef.current;
if (!panel) return;
panel.scrollTop = 0;
if (selected) {
// Scroll only this code pane; preserve the referring map's viewport.
panel.scrollTop = Math.max(0, selected.getBoundingClientRect().top - panel.getBoundingClientRect().top - panel.clientHeight / 2);
}
}, [source]);
const moveTo = (line: number) => setPageWindow({ key: targetKey, start: line });
const copy = async () => {
try {
await navigator.clipboard.writeText(`${target.filePath}:${requestedLine ?? firstLine ?? 1}`);
if (activeKey.current === readKey) { setCopiedKey(readKey); setCopyError(undefined); }
} catch {
if (activeKey.current === readKey) setCopyError({ key: readKey, message: 'Copy unavailable. Select the location above to copy it manually.' });
}
};
return <aside className="source-evidence-drawer" role="dialog" aria-modal="false" aria-label="Source evidence" onKeyDown={event => {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onClose(); }
}}>
<header><div><span>Source evidence</span><h2>{target.name ?? target.filePath.split('/').at(-1)}</h2></div>
<button ref={closeRef} onClick={onClose} aria-label="Close source evidence">Close</button></header>
<p className="source-evidence-location">{target.filePath}{requestedLine ? `:${requestedLine}` : ''}</p>
<p className="source-evidence-note">{target.kind === 'callsite' ? 'Indexed call site' : target.kind === 'declaration' ? 'Indexed declaration' : 'Requested source location'}.
{' '}Current local source may have changed since indexing. The map stays open.</p>
<div className="source-evidence-actions"><button onClick={() => void copy()}>{copiedKey === readKey ? 'Copied location' : 'Copy location'}</button>
<button onClick={() => setRefresh(value => value + 1)}>Refresh source</button>
<button onClick={() => { restoreFocus.current = false; onExplore(target); }}>Open in Explore</button></div>
{copyError?.key === readKey && <p role="status">{copyError.message}</p>}
{current?.error ? <p role="alert">{current.error}</p> : !source ? <p role="status">Reading local source…</p> : <>
{!rangeMatches && <p role="alert">Source text does not match the reported line range. Line numbers and highlighting are unavailable.</p>}
{requestedLine && !locationVisible && rangeMatches && <p role="status" className="source-evidence-notice">Requested line {requestedLine} is outside this returned window.
{start === initialStart ? ' The index or requested source window may be stale.' : <button onClick={() => moveTo(initialStart)}>Return to requested location</button>}</p>}
<pre ref={sourceRef} tabIndex={0} aria-label="Source lines" className="source-evidence-lines"><code>{lines.map((line, index) => {
const number = (firstLine ?? 1) + index;
const selected = rangeMatches && number === requestedLine;
return <span key={number} data-selected={selected} ref={selected ? selectedRef : undefined}>
<span className="source-evidence-line-number" aria-hidden="true">{rangeMatches ? number : ''}</span>{line || ' '}<br /></span>;
})}</code></pre>
{lines.length === 0 && <p role="status">No source text was returned.</p>}
{more && !canNext && <p role="status">The source is truncated, but no valid next page was supplied. Refresh or open Explore to inspect the available source.</p>}
<nav aria-label="Source pages"><button disabled={!rangeMatches || !firstLine || firstLine <= 1} onClick={() => moveTo(Math.max(1, firstLine! - PAGE_LINES))}>Previous lines</button>
<span>{rangeMatches ? `Lines ${firstLine} to ${lastLine}` : 'Unverified line range'}{source.original_end_line ? ` · reported end ${source.original_end_line}` : ''}</span>
<button disabled={!canNext} onClick={() => moveTo(next!)}>Next lines</button></nav>
</>}
</aside>;
}
@@ -128,3 +128,101 @@
.atlas-arch-module-grid { grid-template-columns: minmax(0, 1fr); }
.atlas-arch-map svg { height: 250px; }
}
/* Repository map: overview, area, file, symbol. Edges retain their evidence. */
.repo-map { display: grid; gap: 16px; }
.repo-map-snapshot { font-size: 12px; color: var(--atlas-dim); }
.repo-map-snapshot summary { cursor: pointer; }
.repo-map h2 { font-size: 20px; }
.repo-map h3 { margin-bottom: 8px; }
.repo-map-intro { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; }
.repo-map-intro details { max-width: 470px; font-size: 12px; }
.repo-map-intro summary { cursor: pointer; color: var(--atlas-cyan); }
.repo-map-intro p { margin-top: 6px; }
.repo-map-note { font-size: 12px; color: var(--atlas-dim); line-height: 1.6; }
.repo-map-breadcrumb { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
.repo-map-breadcrumb button, .repo-map-files button, .repo-map-connections button {
border: 0; background: transparent; color: var(--atlas-cyan); padding: 4px 0; text-align: left; overflow-wrap: anywhere;
}
.repo-map-start { background: var(--atlas-panel); padding: 18px; border: 1px solid var(--atlas-line); border-left: 3px solid var(--atlas-cyan); }
.repo-map-start > .repo-map-nodes { grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }
.repo-map-areas { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 12px; margin: 14px 0; }
.repo-map-area { display: grid; gap: 8px; background: var(--atlas-panel); border: 1px solid var(--atlas-line); color: var(--atlas-fg); text-align: left; padding: 18px; min-height: 156px; }
.repo-map-area:hover { border-color: var(--atlas-cyan); }
.repo-map-area strong { font-size: 19px; color: var(--atlas-phosphor); }
.repo-map-area > span { font-size: 11px; color: var(--atlas-dim); overflow-wrap: anywhere; }
.repo-map-eyebrow { text-transform: uppercase; letter-spacing: .06em; font-size: 10px; color: var(--atlas-dim); }
.repo-map-columns { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 24px; }
.repo-map-nodes { display: grid; list-style: none; padding: 0; margin: 8px 0; gap: 6px; min-width: 0; }
.repo-map-nodes button { display: grid; gap: 3px; text-align: left; color: var(--atlas-fg); background: var(--atlas-panel); border: 1px solid var(--atlas-line); padding: 10px; width: 100%; overflow-wrap: anywhere; }
.repo-map-nodes button:hover { border-color: var(--atlas-cyan); }
.repo-map-nodes span { color: var(--atlas-dim); font: 10px/1.5 var(--atlas-font); }
.repo-map-directions { display: flex; gap: 6px; margin: 12px 0; }
.repo-map-directions button { padding: 7px 10px; border: 1px solid var(--atlas-line); background: var(--atlas-panel); color: var(--atlas-fg); }
.repo-map-directions button[aria-pressed="true"] { border-color: var(--atlas-cyan); color: var(--atlas-cyan); }
.repo-map-connections { list-style: none; padding: 0; }
.repo-map-connections li { display: grid; grid-template-columns: 1fr 90px 120px; gap: 8px; border-bottom: 1px solid var(--atlas-line); padding: 7px 0; align-items: center; }
.repo-map-connections span { font-size: 10px; color: var(--atlas-dim); }
.repo-map-files { display: grid; gap: 5px; max-height: 380px; overflow: auto; }
.repo-map-files button { font: 11px/1.5 var(--atlas-font); }
.repo-map-edge { display: grid; grid-template-columns: minmax(0, 1fr) 95px minmax(0, 1fr); gap: 7px; align-items: center; }
.repo-map-relation { text-align: center; font: 10px var(--atlas-font); color: var(--atlas-cyan); }
.repo-map-evidence { border: 1px solid var(--atlas-line); padding: 12px; margin-top: 10px; min-width: 0; }
.repo-map-evidence > p { font-size: 11px; }
.repo-map-selection { border-top: 2px solid var(--atlas-cyan); padding-top: 20px; scroll-margin: 20px; }
.repo-map-selection-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.repo-map-selection-heading button { white-space: normal; }
@media (max-width: 850px) { .repo-map-columns { grid-template-columns: 1fr; } .repo-map-intro, .repo-map-selection-heading { flex-direction: column; } .repo-map-edge { grid-template-columns: 1fr; } }
/* The overview has one reading order; evidence opens next to its context. */
.repo-map-purpose { padding: 16px 20px; border-radius: 9px; border: 1px solid var(--atlas-line); background: var(--atlas-panel); }
.repo-map-quote { padding: 12px 0; }
.repo-map-quote blockquote { margin: 6px 0 9px; font-size: 13px; color: var(--atlas-fg); line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.repo-map-quote button, .repo-map-section-title button, .repo-map-component-action { border: none; background: none; color: var(--atlas-cyan); text-align: left; padding: 3px 0; overflow-wrap: anywhere; }
.repo-map-quote button { font-size: 11px; }
.repo-map-purpose .repo-map-quote { padding: 0 0 10px; }
.repo-map-entry-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-top: 12px; }
.repo-map-entry-grid > button { display: grid; gap: 6px; text-align: left; border: 1px solid var(--atlas-line); border-radius: 7px; color: var(--atlas-fg); padding: 12px; background: var(--atlas-bg); min-width: 0; }
.repo-map-entry-grid > button[aria-pressed=true] { border-color: var(--atlas-cyan); }
.repo-map-entry-grid > button > span:last-child { font: 10px/1.5 var(--atlas-font); color: var(--atlas-dim); overflow-wrap: anywhere; }
.repo-map-start { border-radius: 9px; }
.repo-map-flow { margin-top: 16px; }
.repo-map-flow > summary { color: var(--atlas-cyan); cursor: pointer; }
.repo-map-section-title { display: flex; gap: 16px; align-items: center; justify-content: space-between; margin: 12px 0; }
.repo-map-section-title h3 { margin: 0; }
.repo-map-route { border-top: 1px solid var(--atlas-line); padding: 12px 0; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.repo-map-route-steps { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; flex: 1; }
.repo-map-route-steps > span { display: flex; gap: 8px; align-items: center; }
.repo-map-route-steps button { display: grid; text-align: left; gap: 5px; padding: 10px; border-radius: 6px; border: 1px solid var(--atlas-line); background: var(--atlas-bg); color: var(--atlas-fg); max-width: 230px; }
.repo-map-route-steps strong { font-size: 11px; overflow-wrap: anywhere; }
.repo-map-route-steps small { color: var(--atlas-dim); font-size: 10px; }
.repo-map-route-arrow { color: var(--atlas-cyan); }
.repo-map-component { display: flex; flex-direction: column; padding: 16px; border: 1px solid var(--atlas-line); border-radius: 9px; background: var(--atlas-panel); min-width: 0; }
.repo-map-component:focus-within, .repo-map-component:hover { border-color: var(--atlas-cyan); }
.repo-map-component .repo-map-area { border: none; padding: 0; min-height: 0; background: transparent; }
.repo-map-component .repo-map-area strong { font-size: 17px; }
.repo-map-component .repo-map-quote { flex: 1; }
.repo-map-component-action { align-self: flex-start; margin-top: auto; }
.repo-map-no-description { font-size: 12px; padding: 12px 0; }
.repo-map-focused-evidence { border: 1px solid var(--atlas-cyan); border-radius: 9px; padding: 14px; background: var(--atlas-panel); scroll-margin: 12px; max-height: min(560px, 65vh); overflow: auto; }
.repo-map-focused-evidence .repo-map-evidence { border: none; padding: 0; }
.repo-map-area-heading .repo-map-quote { max-width: 780px; }
.repo-map-relation small { display: block; margin-top: 5px; color: var(--atlas-dim); }
.repo-map-relation button { display: block; margin: 6px auto; padding: 5px 8px; background: var(--atlas-panel-raised); border: 1px solid var(--atlas-line); color: var(--atlas-cyan); border-radius: 4px; }
@media (max-width: 900px) { .repo-map-entry-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .repo-map-entry-grid { grid-template-columns: 1fr; } }
.atlas-architecture .atlas-arch-heading { margin-bottom: 10px; }
.atlas-architecture .atlas-arch-heading p { display: none; }
.atlas-architecture .atlas-arch-toolbar { margin-bottom: 12px; }
.repo-map { gap: 12px; }
.repo-map-intro { font-size: 11px; }
.repo-map-onboarding { display: grid; grid-template-columns: minmax(0, .8fr) minmax(0, 1.2fr); gap: 14px; align-items: start; }
.repo-map-onboarding .repo-map-entry-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.repo-map-onboarding .repo-map-purpose .repo-map-quote blockquote { -webkit-line-clamp: 5; }
.repo-map-onboarding:has(.repo-map-flow[open]) { grid-template-columns: minmax(0, 1fr); }
.repo-map-onboarding:has(.repo-map-flow[open]) .repo-map-entry-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@media (max-width: 900px) { .repo-map-onboarding { grid-template-columns: minmax(0, 1fr); }.repo-map-onboarding:has(.repo-map-flow[open]) .repo-map-entry-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.repo-map-resolution { font-size: 10px; margin-top: 8px; }
.repo-map-resolution summary { cursor: pointer; }
.repo-map-resolution[open] { text-align: left; padding: 6px; border: 1px solid var(--atlas-line); }
@@ -0,0 +1,6 @@
{
"source": " }\n cbm_project_lock_manager_t *manager = calloc(1, sizeof(*manager));\n if (manager) {\n manager->directory = directory;\n manager->registry = cbm_lock_registry_new(directory);\n }\n if (!manager || !manager->registry) {\n free(manager);\n cbm_private_lock_directory_close(directory);\n return NULL;\n }\n return manager;\n}\n\ncbm_private_file_lock_status_t cbm_project_lock_lease_release(cbm_project_lock_lease_t **lease_io) {\n if (!lease_io || !*lease_io) {\n return CBM_PRIVATE_FILE_LOCK_IO;\n }\n cbm_project_lock_lease_t *lease = *lease_io;\n cbm_private_file_lock_status_t result = CBM_PRIVATE_FILE_LOCK_OK;\n if (lease->project) {\n result = cbm_lock_lease_release(&lease->project);\n if (lease->project) {\n return CBM_PRIVATE_FILE_LOCK_IO;\n }\n }\n if (lease->project_set) {\n cbm_private_file_lock_status_t set_status = cbm_lock_lease_release(&lease->project_set);\n if (set_status != CBM_PRIVATE_FILE_LOCK_OK) {\n result = set_status;\n }\n if (lease->project_set) {\n return CBM_PRIVATE_FILE_LOCK_IO;\n }\n }\n free(lease);\n *lease_io = NULL;\n return result;\n}\n\nstatic cbm_private_file_lock_status_t project_lock_failed_acquire(\n cbm_project_lock_lease_t *lease, cbm_private_file_lock_status_t status,\n cbm_project_lock_lease_t **lease_out) {\n if (!lease->project && !lease->project_set) {\n free(lease);\n return status;\n }\n cbm_project_lock_lease_t *cleanup = lease;\n cbm_private_file_lock_status_t cleanup_status = cbm_project_lock_lease_release(&cleanup);\n if (cleanup) {\n *lease_out = cleanup;\n return CBM_PRIVATE_FILE_LOCK_IO;\n }\n return cleanup_status == CBM_PRIVATE_FILE_LOCK_OK ? status : CBM_PRIVATE_FILE_LOCK_IO;\n}\n\nstatic cbm_private_file_lock_status_t project_lock_acquire_internal(\n cbm_project_lock_manager_t *manager, const char *project, uint64_t deadline_ms,\n const cbm_lock_cancel_token_t *cancel_token, bool try_once,\n cbm_project_lock_lease_t **lease_out) {\n if (lease_out) {\n *lease_out = NULL;\n }\n if (!manager || !manager->registry || !project || !project[0] || !lease_out) {\n return CBM_PRIVATE_FILE_LOCK_UNSAFE;\n }\n bool wildcard = strcmp(project, \"*\") == 0;\n char project_key[PROJECT_LOCK_KEY_CAP];\n if (!wildcard && !project_lock_key(project, project_key)) {\n return CBM_PRIVATE_FILE_LOCK_UNSAFE;\n }\n cbm_project_lock_lease_t *lease = calloc(1, sizeof(*lease));\n if (!lease) {\n return CBM_PRIVATE_FILE_LOCK_IO;\n }\n cbm_private_file_lock_status_t status =\n try_once ? cbm_lock_registry_try_acquire(manager->registry, PROJECT_SET_KEY,\n wildcard ? CBM_PRIVATE_FILE_LOCK_EX\n : CBM_PRIVATE_FILE_LOCK_SH,\n &lease->project_set)\n : cbm_lock_registry_acquire(manager->registry, PROJECT_SET_KEY,\n wildcard ? CBM_PRIVATE_FILE_LOCK_EX\n : CBM_PRIVATE_FILE_LOCK_SH,\n deadline_ms, cancel_token, &lease->project_set);\n if (status != CBM_PRIVATE_FILE_LOCK_OK) {\n return project_lock_failed_acquire(lease, status, lease_out);\n }\n if (!wildcard) {\n status = try_once ? cbm_lock_registry_try_acquire(manager->registry, project_key,\n CBM_PRIVATE_FILE_LOCK_EX, &lease->project)\n : cbm_lock_registry_acquire(manager->registry, project_key,\n CBM_PRIVATE_FILE_LOCK_EX, deadline_ms,\n cancel_token, &lease->project);\n if (status != CBM_PRIVATE_FILE_LOCK_OK) {\n return project_lock_failed_acquire(lease, status, lease_out);\n }\n }\n *lease_out = lease;\n return CBM_PRIVATE_FILE_LOCK_OK;\n}\n\ncbm_private_file_lock_status_t cbm_project_lock_acquire(cbm_project_lock_manager_t *manager,\n const char *project, uint64_t deadline_ms,\n const cbm_lock_cancel_token_t *cancel_token,\n cbm_project_lock_lease_t **lease_out) {\n return project_lock_acquire_internal(manager, project, deadline_ms, cancel_token, false,\n lease_out);\n}\n\ncbm_private_file_lock_status_t cbm_project_lock_try_acquire(cbm_project_lock_manager_t *manager,\n const char *project,\n Line truncated
"start_line": 53,
"end_line": 190,
"source_mode": "full"
}
@@ -0,0 +1,66 @@
import { expect, it } from 'vitest';
import { entryCandidates, entryRole, entryRoutes, sourceCandidates, sourceQuote } from './repository-guide';
import { repositoryMap } from './repository-map';
import type { GraphData, GraphNode } from '../galaxy/types';
const node = (id: number, file_path: string, name: string, status: GraphNode['status'] = 'normal'): GraphNode => ({
id, file_path, name, status, label: 'Function', x: 0, y: 0, z: 0, size: 1, color: '',
});
const graph: GraphData = { total_nodes: 3, nodes: [node(1, 'src/api/main.ts', 'main', 'entry'),
node(2, 'src/service/users.ts', 'users'), node(3, 'src/store/save.ts', 'save')],
edges: [{ source: 1, target: 2, type: 'CALLS' }, { source: 2, target: 3, type: 'IMPORTS' }, { source: 2, target: 3, type: 'CALLS' }] };
it('separates application entries from test and tool entry candidates', () => {
const test = node(10, 'tests/server.test.ts', 'main', 'entry');
const tool = node(11, 'graph-ui/tools/smoke.mjs', 'main', 'entry');
const app = node(12, 'src/main.c', 'main', 'entry');
expect(entryRole(test)).toBe('Tests'); expect(entryRole(tool)).toBe('Tools');
expect(entryRole(node(13, 'pkg/npm/install.js', 'main', 'entry'))).toBe('Tools');
expect(entryRole(node(14, 'setup.py', 'main', 'entry'))).toBe('Tools');
expect(entryRole(node(15, '.git/hooks/pre-commit', 'main', 'entry'))).toBe('Tools');
expect(entryRole(node(16, 'pkg/pypi/src/codebase_memory_mcp/_cli.py', 'main', 'entry'))).toBe('Application');
expect(entryCandidates([test, tool, app])[0]).toBe(app);
});
it('quotes a real README paragraph while skipping badges, HTML and code', () => {
expect(sourceQuote('README.md', '# Project\n[![CI](badge.png)](https://example.com)\n\nA local repository index exposes symbols and relationships to coding tools.\n'))
.toEqual({ path: 'README.md', line: 4, text: 'A local repository index exposes symbols and relationships to coding tools.', kind: 'readme' });
expect(sourceQuote('README.md', '# Project\n```\nThis is an example string with enough words to look like a description.\n```')).toBeUndefined();
});
it('retains header location, ignores code and does not turn a license into a responsibility', () => {
expect(sourceQuote('src/store/store.h', '/*\n * Opaque SQLite graph store for repository symbols.\n * One handle per thread.\n */\nvoid store();'))
.toMatchObject({ line: 2, text: 'Opaque SQLite graph store for repository symbols. One handle per thread.' });
expect(sourceQuote('src/a.ts', '// Copyright 2026 Example\n// Permission is hereby granted')).toBeUndefined();
expect(sourceQuote('src/a.ts', 'export function veryImportantRepositoryModule() {}')).toBeUndefined();
});
it('quotes only comments and never includes adjacent code or C preprocessor directives', () => {
expect(sourceQuote('src/a.h', '#ifndef HEADER_H\n#define HEADER_H\n// A helpful explanation appears after declarations.\nstruct Component {};')).toBeUndefined();
expect(sourceQuote('src/a.c', '// Coordinates clients and resource subscriptions.\nint private_state = 99;'))
.toMatchObject({ text: 'Coordinates clients and resource subscriptions.', line: 1 });
expect(sourceQuote('src/a.c', '/* Coordinates clients and resource subscriptions. */ int private_state = 99;'))
.toMatchObject({ text: 'Coordinates clients and resource subscriptions.', line: 1 });
expect(sourceQuote('src/a.py', '#!/usr/bin/env python\n"""Coordinates clients and resource subscriptions."""\nprivate_state = 99'))
.toMatchObject({ text: 'Coordinates clients and resource subscriptions.', line: 2 });
expect(sourceQuote('src/a.py', '# Coordinates clients and resource subscriptions.\nprivate_state = 99'))
.toMatchObject({ text: 'Coordinates clients and resource subscriptions.', line: 1 });
expect(sourceQuote('src/a.c', '/* Coordinates clients and resource subscriptions. */', 0)).toBeUndefined();
});
it('ranks companion headers using recorded implementation connections, without module-name rules', () => {
const nodes = [node(1, 'src/widget/alphabet.h', 'config'), node(2, 'src/widget/service.h', 'api'),
node(3, 'src/widget/service.c', 'handle'), node(4, 'src/client/use.c', 'call')];
const map = repositoryMap({ total_nodes: nodes.length, nodes,
edges: [{ source: 4, target: 3, type: 'CALLS' }] });
expect(sourceCandidates(map.areas.find(area => area.path === 'src/widget')!)[0]).toBe('src/widget/service.h');
});
it('follows only actual call edges across components and retains their evidence', () => {
const map = repositoryMap(graph);
const paths = entryRoutes(graph.nodes[0], map.evidence);
expect(paths[0].edges.map(edge => edge.target.name)).toEqual(['users', 'save']);
expect(paths[0].edges.every(edge => edge.type === 'CALLS')).toBe(true);
expect(paths[0].edges[0]).toBe(map.evidence[0]);
expect(entryRoutes(graph.nodes[2], map.evidence)).toEqual([]);
});
@@ -0,0 +1,121 @@
import type { GraphNode } from '../galaxy/types';
import { areaOf, type RepositoryArea, type MapEvidence } from './repository-map';
export interface SourceQuote { text: string; path: string; line: number; kind: 'readme' | 'header' }
export type SourceReader = (path: string, startLine?: number, maxLines?: number) => Promise<{ source: string; start_line?: number }>;
export type EntryRole = 'Application' | 'Frontend' | 'Tests' | 'Tools';
/** Labels are navigation heuristics; they do not promote a name into a verified entry point. */
export function entryRole(node: GraphNode): EntryRole {
const path = node.file_path ?? '';
if (node.status === 'test' || /(^|\/)(tests?|fixtures?|verification|eval)(\/|\.)|\.(test|spec)\./i.test(path)) return 'Tests';
if (/(^|\/)(scripts?|tools?|hooks?|examples?|benchmarks?)(\/|\.)/i.test(path)
|| /(^|\/)(install|uninstall|setup)\.[^/]+$/i.test(path)) return 'Tools';
if (/\.(tsx|jsx|vue|svelte)$/.test(path)) return 'Frontend';
return 'Application';
}
export function entryCandidates(nodes: GraphNode[]): GraphNode[] {
return nodes.filter(node => node.file_path && node.status === 'entry')
.sort((a, b) => (Number(entryRole(a) === 'Tests' || entryRole(a) === 'Tools') - Number(entryRole(b) === 'Tests' || entryRole(b) === 'Tools'))
|| Number(/^(main|App|bootstrap|start)$/i.test(b.name)) - Number(/^(main|App|bootstrap|start)$/i.test(a.name))
|| (b.out_calls ?? 0) - (a.out_calls ?? 0) || a.name.localeCompare(b.name));
}
export function sourceCandidates(area: RepositoryArea): string[] {
const part = area.path.split('/').at(-1);
const degree = new Map<string, number>();
for (const edge of [...area.incoming, ...area.outgoing]) for (const node of [edge.source, edge.target]) {
if (node.file_path && areaOf(node.file_path) === area.path) degree.set(node.file_path, (degree.get(node.file_path) ?? 0) + 1);
}
// C declarations often live in a header, while recorded calls belong to its
// implementation. Let those observed connections rank companion headers;
// do not special-case a repository's module names.
const connectedness = (path: string) => /\.(h|hpp)$/.test(path)
? Math.max(degree.get(path) ?? 0, ...['c', 'cc', 'cpp', 'cxx'].map(extension => degree.get(path.replace(/\.(h|hpp)$/, `.${extension}`)) ?? 0))
: degree.get(path) ?? 0;
const score = (path: string) => (/\/README\.md$/i.test(path) ? 30000 : 0) + (/\.(h|hpp)$/.test(path) ? 10000 : 0) + (path.split('/').at(-1)?.split('.')[0] === part ? 20000 : 0)
+ Math.min(5000, connectedness(path)) - path.split('/').length;
return area.files.filter(path => /\.(h|hpp|c|cc|cpp|cxx|ts|tsx|js|jsx|py|rs|go|java|cs|md)$/.test(path))
.sort((a, b) => score(b) - score(a) || a.localeCompare(b)).slice(0, 2);
}
function plainMarkdown(text: string): string {
return text.replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/[*_`]/g, '').replace(/<[^>]*>/g, '').trim();
}
/** Quote only repository-authored prose, never infer a responsibility from an identifier. */
export function sourceQuote(path: string, source: string, start = 1): SourceQuote | undefined {
if (!Number.isSafeInteger(start) || start < 1) return undefined;
const lines = source.split('\n');
if (/(^|\/)readme\.(md|txt|rst)$/i.test(path)) {
let fenced = false;
for (let index = 0; index < lines.length; index++) {
const raw = lines[index].trim();
if (/^```|^~~~/.test(raw)) { fenced = !fenced; continue; }
if (fenced || !raw || /^(#|[!\[<|>]|[-=]{3})/.test(raw)) continue;
const text = plainMarkdown(raw);
if (text.length >= 45 && /[a-z]{3}\s+[a-z]{3}/i.test(text)) return { text: text.slice(0, 500), path, line: start + index, kind: 'readme' };
}
return undefined;
}
const first = lines.findIndex(line => line.trim() && !/^#!/.test(line.trim()));
if (first < 0) return undefined;
const firstText = lines[first].trim();
const hashComments = /\.(py|rb|sh|bash|zsh|yaml|yml|toml|r|pl|pm|ps1)$/i.test(path);
const block = firstText.startsWith('/*') ? ['/*', '*/']
: firstText.startsWith('"""') ? ['"""', '"""']
: firstText.startsWith("'''") ? ["'''", "'''"] : undefined;
const prefix = firstText.startsWith('//') ? '//'
: hashComments && firstText.startsWith('#') ? '#' : undefined;
if (!block && !prefix) return undefined;
const prose: { text: string; line: number }[] = [];
for (let index = first; index < Math.min(lines.length, first + 40); index++) {
let raw = lines[index].trim(), closed = false;
if (block) {
if (index === first) raw = raw.slice(block[0].length);
const end = raw.indexOf(block[1]);
if (end >= 0) { raw = raw.slice(0, end); closed = true; }
if (block[0] === '/*') raw = raw.replace(/^\*+\s?/, '');
} else {
if (!raw.startsWith(prefix!)) break;
raw = raw.slice(prefix!.length);
}
const text = raw.trim();
if (/copyright|SPDX|permission is hereby|licensed under|all rights reserved/i.test(text)) return undefined;
if (text) prose.push({ text, line: start + index });
if (closed) break;
}
if (!prose.length) return undefined;
const text = prose.map(row => row.text).join(' ');
return text.length >= 30 && /[a-z]{3}\s+[a-z]{3}/i.test(text) ? { text: text.slice(0, 550), path, line: prose[0].line, kind: 'header' } : undefined;
}
export interface StaticRoute { edges: MapEvidence[]; limited: boolean }
/** A bounded actual CALLS walk. Imports and co-change never become execution steps. */
export function entryRoutes(entry: GraphNode, evidence: MapEvidence[]): StaticRoute[] {
const outgoing = new Map<number, MapEvidence[]>();
for (const edge of evidence) if (edge.type === 'CALLS' && entryRole(edge.target) !== 'Tests' && entryRole(edge.target) !== 'Tools') {
const rows = outgoing.get(edge.source.id) ?? []; rows.push(edge); outgoing.set(edge.source.id, rows);
}
const queue: { node: GraphNode; edges: MapEvidence[] }[] = [{ node: entry, edges: [] }];
const visited = new Set<number>([entry.id]); const paths: MapEvidence[][] = [];
let cursor = 0;
while (cursor < queue.length && visited.size < 500) {
const row = queue[cursor++];
if (row.edges.length >= 5) continue;
for (const edge of outgoing.get(row.node.id) ?? []) {
if (visited.has(edge.target.id)) continue;
visited.add(edge.target.id);
const edges = [...row.edges, edge];
if (edge.source.file_path !== edge.target.file_path) paths.push(edges);
queue.push({ node: edge.target, edges });
if (visited.size >= 500) break;
}
}
const areas = (edges: MapEvidence[]) => new Set([areaOf(entry.file_path!), ...edges.map(edge => areaOf(edge.target.file_path!))]).size;
const seen = new Set<string>();
return paths.sort((a, b) => areas(b) - areas(a) || a.length - b.length).filter(path => {
const target = path.at(-1)!.target.file_path!; if (seen.has(target)) return false; seen.add(target); return true;
}).slice(0, 3).map(edges => ({ edges, limited: cursor < queue.length }));
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { areaConnections, areaOf, repositoryMap } from './repository-map';
import type { GraphData, GraphNode } from '../galaxy/types';
export const node = (id: number, file_path: string, name = `symbol${id}`, status: GraphNode['status'] = 'normal'): GraphNode => ({
id, file_path, name, status, label: 'Function', qualified_name: `fixture.${name}`, start_line: id + 1,
x: 0, y: 0, z: 0, size: 1, color: '#fff',
});
export const graph: GraphData = { total_nodes: 4, nodes: [node(1, 'src/api/server.ts', 'start', 'entry'),
node(2, 'src/service/users.ts', 'users'), node(3, 'src/store/db.ts', 'save'), node(4, 'test/users.test.ts', 'testUsers', 'test')],
edges: [{ source: 1, target: 2, type: 'CALLS' }, { source: 2, target: 3, type: 'CALLS' },
{ source: 2, target: 3, type: 'IMPORTS' }, { source: 4, target: 2, type: 'CALLS' }] };
describe('repository map evidence', () => {
it('retains exact directional edges and their source declarations across areas', () => {
const map = repositoryMap(graph);
const service = map.areas.find(area => area.path === 'src/service')!;
expect(service.incoming.map(edge => edge.source.name)).toEqual(['start', 'testUsers']);
expect(areaConnections(service, 'outgoing').map(row => [row.area, row.type, row.evidence.length]))
.toEqual([['src/store', 'CALLS', 1], ['src/store', 'IMPORTS', 1]]);
expect(service.outgoing[0].target).toBe(graph.nodes[2]);
expect(service.outgoing[0].source.start_line).toBe(3);
expect(map.areas.find(area => area.path === 'src/api')?.entryPoints[0].name).toBe('start');
});
it('does not invent edges, double-count retries, or turn containment into dependencies', () => {
const map = repositoryMap({ ...graph, edges: [...graph.edges, graph.edges[0],
{ source: 1, target: 77, type: 'CALLS' }, { source: 1, target: 3, type: 'CONTAINS' }] });
expect(map.evidence).toHaveLength(4);
expect(map.unresolvedEdges).toBe(1);
});
it('keeps segment boundaries and source areas meaningful without merging similarly named folders', () => {
expect(areaOf('src/api/a.ts')).toBe('src/api');
expect(areaOf('src/api2/b.ts')).toBe('src/api2');
expect(areaOf('README.md')).toBe('(root)');
expect(areaOf('packages/client/src/a.ts')).toBe('packages/client');
expect(areaOf('tests/unit/users.ts')).toBe('tests');
});
it('keeps impossible index coordinates as unverified metadata, not source links', () => {
const source = { ...graph.nodes[0], start_line: 20, end_line: 80 };
const map = repositoryMap({ ...graph, nodes: [source, ...graph.nodes.slice(1)], edges: [
{ id: 7, source: 1, target: 2, type: 'CALLS', line: 6989 },
{ id: 8, source: 1, target: 3, type: 'CALLS', line: 61 },
] });
expect(map.evidence[0]).toMatchObject({ id: 7, line: undefined, unverifiedLine: 6989 });
expect(map.evidence[1]).toMatchObject({ id: 8, line: 61, unverifiedLine: undefined });
});
});
+101
View File
@@ -0,0 +1,101 @@
import type { GraphData, GraphNode } from '../galaxy/types';
export const MAP_RELATIONS = ['CALLS', 'IMPORTS', 'USAGE', 'INHERITS', 'IMPLEMENTS', 'DATA_FLOWS'] as const;
export type MapRelation = typeof MAP_RELATIONS[number];
export interface MapEvidence { source: GraphNode; target: GraphNode; type: MapRelation; id?: number; line?: number; unverifiedLine?: number; strategy?: string; confidence?: number }
export interface RepositoryArea {
path: string;
files: string[];
nodes: GraphNode[];
entryPoints: GraphNode[];
interfaces: GraphNode[];
incoming: MapEvidence[];
outgoing: MapEvidence[];
}
export interface RepositoryMap {
areas: RepositoryArea[];
evidence: MapEvidence[];
fileCount: number;
representedNodes: number;
totalNodes: number;
unresolvedEdges: number;
}
/** A navigational partition, never a claim about domain ownership. */
export function areaOf(path: string): string {
const parts = path.split('/').filter(Boolean);
if (parts.length < 2) return '(root)';
// Source containers have a useful second level; all other directories remain
// visible intact instead of guessing a programming-language-specific layout.
if (['src', 'lib', 'internal', 'packages', 'apps', 'services'].includes(parts[0]) && parts.length > 2) {
return parts.slice(0, 2).join('/');
}
return parts[0];
}
const isSource = (node: GraphNode) => Boolean(node.file_path && node.file_path !== '{}'
&& !['Project', 'Folder', 'Package', 'Branch'].includes(node.label));
const priority = (a: GraphNode, b: GraphNode) => (b.in_calls ?? 0) - (a.in_calls ?? 0)
|| (b.out_calls ?? 0) - (a.out_calls ?? 0) || a.name.localeCompare(b.name);
/** Linear in the already loaded graph. Every aggregate retains its real edges. */
export function repositoryMap(graph: GraphData): RepositoryMap {
const areas = new Map<string, RepositoryArea>();
const nodes = new Map(graph.nodes.filter(isSource).map(node => [node.id, node]));
const files = new Map<string, Set<string>>();
for (const node of nodes.values()) {
const path = areaOf(node.file_path!);
let area = areas.get(path);
if (!area) {
area = { path, files: [], nodes: [], entryPoints: [], interfaces: [], incoming: [], outgoing: [] };
areas.set(path, area); files.set(path, new Set());
}
files.get(path)!.add(node.file_path!);
area.nodes.push(node);
if (node.status === 'entry') area.entryPoints.push(node);
if (node.status === 'exported') area.interfaces.push(node);
}
const evidence: MapEvidence[] = [];
const seen = new Set<string>();
let unresolvedEdges = 0;
for (const edge of graph.edges) {
if (!MAP_RELATIONS.includes(edge.type as MapRelation)) continue;
const key = `${edge.source}:${edge.type}:${edge.target}`;
if (seen.has(key)) continue;
seen.add(key);
const source = nodes.get(edge.source); const target = nodes.get(edge.target);
if (!source || !target) { unresolvedEdges++; continue; }
// Some parser edges retain preprocessed coordinates. An out-of-range
// number is evidence metadata, never a verified navigable call site.
const validSite = edge.line !== undefined && source.start_line !== undefined && source.end_line !== undefined
&& edge.line >= source.start_line && edge.line <= source.end_line;
const finding: MapEvidence = { source, target, type: edge.type as MapRelation, id: edge.id,
line: validSite ? edge.line : undefined, unverifiedLine: validSite ? undefined : edge.line,
strategy: edge.strategy, confidence: edge.confidence };
evidence.push(finding);
const from = areas.get(areaOf(source.file_path!))!;
const to = areas.get(areaOf(target.file_path!))!;
if (from !== to) { from.outgoing.push(finding); to.incoming.push(finding); }
}
for (const area of areas.values()) {
area.files = [...files.get(area.path)!].sort();
area.entryPoints.sort(priority); area.interfaces.sort(priority); area.nodes.sort(priority);
}
return {
areas: [...areas.values()].sort((a, b) => b.incoming.length + b.outgoing.length
- a.incoming.length - a.outgoing.length || a.path.localeCompare(b.path)),
evidence, fileCount: new Set([...files.values()].flatMap(set => [...set])).size,
representedNodes: nodes.size, totalNodes: graph.total_nodes, unresolvedEdges,
};
}
export function areaConnections(area: RepositoryArea, direction: 'incoming' | 'outgoing') {
const rows = new Map<string, { area: string; type: MapRelation; evidence: MapEvidence[] }>();
for (const edge of area[direction]) {
const other = areaOf((direction === 'incoming' ? edge.source : edge.target).file_path!);
const key = `${other}:${edge.type}`;
const row = rows.get(key) ?? { area: other, type: edge.type, evidence: [] };
row.evidence.push(edge); rows.set(key, row);
}
return [...rows.values()].sort((a, b) => b.evidence.length - a.evidence.length || a.area.localeCompare(b.area));
}
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from 'vitest';
import { fetchRepositorySnapshot, readRepositorySnapshot, resolveRepositorySelection } from './repository-source';
describe('logical repository snapshot', () => {
it('retains source-free route identities without inventing a source file and derives call degree only from calls', () => {
const graph = readRepositorySnapshot({ generation: 'fixture-g1', indexed_at: '2026-09-09', total_nodes: 3,
nodes_truncated: false, edges_truncated: false,
nodes: [{ id: 1, label: 'Route', name: '/api/test' },
{ id: 2, label: 'Function', name: 'serve', file_path: 'src/api.ts', is_entry: true, start_line: 12, package_name: 'api' },
{ id: 3, label: 'Function', name: 'test', file_path: 'tests/api.ts', is_test: true }],
edges: [{ id: 8, source: 3, target: 2, type: 'CALLS', line: 30 }, { id: 9, source: 3, target: 2, type: 'IMPORTS' }] });
expect(graph.nodes[0].file_path).toBeUndefined();
expect(graph.nodes[1]).toMatchObject({ status: 'entry', in_calls: 1, package_name: 'api' });
expect(graph.nodes[2]).toMatchObject({ status: 'test', out_calls: 1 });
expect(graph.edges[0]).toMatchObject({ id: 8, source: 3, target: 2, type: 'CALLS', line: 30 });
expect(graph.nodesTruncated).toBe(false);
});
it('moves source navigation and documentation together with a stable symbol after reindexing', () => {
const snapshot = readRepositorySnapshot({ generation: 'new', nodes: [{ id: 12, name: 'serve', qualified_name: 'p.serve', file_path: 'server.ts', start_line: 40, docstring: 'Current docs' }], edges: [] });
const old = { ...snapshot.nodes[0], id: 3, start_line: 10, documentation: 'Old docs' };
expect(resolveRepositorySelection(old, snapshot)).toMatchObject({ id: 12, start_line: 40, documentation: 'Current docs' });
expect(resolveRepositorySelection({ ...old, qualified_name: 'p.removed' }, snapshot)).toBeUndefined();
});
it('refuses missing snapshot metadata instead of labeling an empty graph as complete', () => {
expect(() => readRepositorySnapshot({ nodes: [], edges: [] })).toThrow('snapshot metadata');
expect(readRepositorySnapshot({ nodes: [], edges: [], generation: 'fixture' }).nodesTruncated).toBe(true);
});
});
it('recovers from an interrupted response and respects cancellation before retrying', async () => {
vi.useFakeTimers();
const fetcher = vi.fn().mockRejectedValueOnce(new TypeError('Failed to fetch')).mockResolvedValue({ ok: true, json: async () => ({ generation: 'reconnected', nodes: [], edges: [] }) });
vi.stubGlobal('fetch', fetcher);
try {
const ready = fetchRepositorySnapshot('actual-project', new AbortController().signal);
await vi.runAllTimersAsync();
expect((await ready).generation).toBe('reconnected'); expect(fetcher).toHaveBeenCalledTimes(2);
fetcher.mockReset().mockRejectedValue(new TypeError('Failed to fetch'));
const controller = new AbortController();
const cancelled = fetchRepositorySnapshot('previous-project', controller.signal);
const assertion = expect(cancelled).rejects.toThrow('Aborted');
await Promise.resolve(); controller.abort(); await assertion;
await vi.runAllTimersAsync(); expect(fetcher).toHaveBeenCalledTimes(1);
} finally { vi.unstubAllGlobals(); vi.useRealTimers(); }
});
it('does not retry a malformed graph or a permanent server response', async () => {
const fetcher = vi.fn().mockResolvedValueOnce({ ok: true, json: async () => ({ nodes: [], edges: [] }) })
.mockResolvedValueOnce({ ok: false, status: 404 });
vi.stubGlobal('fetch', fetcher);
try {
await expect(fetchRepositorySnapshot('p', new AbortController().signal)).rejects.toThrow('snapshot metadata');
await expect(fetchRepositorySnapshot('p', new AbortController().signal)).rejects.toThrow('HTTP 404');
expect(fetcher).toHaveBeenCalledTimes(2);
} finally { vi.unstubAllGlobals(); }
});
@@ -0,0 +1,103 @@
import { useEffect, useState } from 'react';
import type { GraphData, GraphNode, GraphEdge } from '../galaxy/types';
export interface RepositorySnapshot extends GraphData {
generation: string;
indexedAt: string;
nodesTruncated: boolean;
edgesTruncated: boolean;
}
const object = (value: unknown): Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
const string = (value: unknown): string => typeof value === 'string' ? value : '';
const number = (value: unknown): number => typeof value === 'number' && Number.isFinite(value) ? value : 0;
/** Reuse symbol identity with the 3D graph. Coordinates are deliberately unused
* in this logical map; loading more architecture evidence never expands WebGL. */
export function readRepositorySnapshot(value: unknown): RepositorySnapshot {
const raw = object(value);
if (!Array.isArray(raw.nodes) || !Array.isArray(raw.edges) || typeof raw.generation !== 'string') {
throw new Error('Repository map response has no graph snapshot metadata.');
}
const nodes: GraphNode[] = raw.nodes.map(value => {
const row = object(value);
if (!Number.isSafeInteger(row.id)) throw new Error('Repository map contains an invalid source identity.');
return { id: number(row.id), name: string(row.name), label: string(row.label),
file_path: string(row.file_path) || undefined, qualified_name: string(row.qualified_name),
start_line: number(row.start_line) || undefined, end_line: number(row.end_line) || undefined,
status: row.is_entry === true ? 'entry' : row.is_test === true ? 'test'
: row.is_exported === true ? 'exported' : undefined,
in_calls: 0, out_calls: 0,
documentation: string(row.docstring), x: 0, y: 0, z: 0, size: 1, color: '',
package_name: string(row.package_name),
};
});
const edges: GraphEdge[] = raw.edges.map(value => {
const row = object(value);
if (!Number.isSafeInteger(row.source) || !Number.isSafeInteger(row.target) || typeof row.type !== 'string') throw new Error('Repository map contains an invalid edge.');
return { source: number(row.source), target: number(row.target), type: row.type,
id: number(row.id) || undefined, line: number(row.line) || undefined,
strategy: string(row.strategy) || undefined,
confidence: typeof row.confidence === 'number' && Number.isFinite(row.confidence) ? row.confidence : undefined };
});
const byId = new Map(nodes.map(node => [node.id, node]));
for (const edge of edges) {
if (edge.type !== 'CALLS') continue;
const source = byId.get(edge.source); const target = byId.get(edge.target);
if (source) source.out_calls = (source.out_calls ?? 0) + 1;
if (target) target.in_calls = (target.in_calls ?? 0) + 1;
}
return { nodes, edges, total_nodes: number(raw.total_nodes), generation: raw.generation,
indexedAt: string(raw.indexed_at), nodesTruncated: raw.nodes_truncated !== false,
edgesTruncated: raw.edges_truncated !== false };
}
/** Numeric IDs and source locations may change on reindexing. */
export function resolveRepositorySelection(selected: GraphNode | undefined, snapshot?: GraphData): GraphNode | undefined {
if (!selected || !snapshot) return selected;
return snapshot.nodes.find(candidate => selected.qualified_name
? candidate.qualified_name === selected.qualified_name && candidate.file_path === selected.file_path
: candidate.id === selected.id && candidate.name === selected.name && candidate.file_path === selected.file_path);
}
/** A cold daemon or interrupted large response can recover without a page reload. */
export async function fetchRepositorySnapshot(project: string, signal: AbortSignal, onRetry?: (attempt: number) => void): Promise<RepositorySnapshot> {
for (let attempt = 0; ; attempt++) {
let raw: unknown;
try {
const response = await fetch(`/api/repository-map?${new URLSearchParams({ project })}`, { signal });
if (!response.ok) {
const error = new Error(`Repository map returned HTTP ${response.status}.`);
if (![408, 429, 502, 503, 504].includes(response.status)) throw Object.assign(error, { permanent: true });
throw error;
}
raw = await response.json();
} catch (error) {
if (signal.aborted || attempt >= 2 || (error instanceof Error && 'permanent' in error)) throw error;
onRetry?.(attempt + 1);
await new Promise<void>((resolve, reject) => {
const abort = () => { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')); };
const timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve(); }, 750 * (attempt + 1));
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) abort();
});
continue;
}
// Invalid graph identities are a data error; retries cannot establish their meaning.
return readRepositorySnapshot(raw);
}
}
export function useRepositorySnapshot(project: string, revision: number) {
const [reading, setReading] = useState<{ project: string; snapshot?: RepositorySnapshot; error?: string; loading: boolean }>({ project: '', loading: false });
useEffect(() => {
if (!project) return;
const controller = new AbortController();
setReading({ project, loading: true });
void fetchRepositorySnapshot(project, controller.signal, attempt => {
if (!controller.signal.aborted) setReading({ project, loading: true, error: `Repository connection interrupted. Retrying (${attempt}/2)…` });
}).then(snapshot => { if (!controller.signal.aborted) setReading({ project, snapshot, loading: false }); })
.catch((error: unknown) => { if (!controller.signal.aborted) setReading({ project, loading: false, error: error instanceof Error ? error.message : String(error) }); });
return () => controller.abort();
}, [project, revision]);
return reading.project === project ? reading : { project, loading: Boolean(project) };
}
@@ -0,0 +1,22 @@
.source-evidence-drawer { position: fixed; z-index: 160; top: 72px; bottom: 34px; right: 16px; width: min(680px, 52vw); display: flex; flex-direction: column; gap: 10px; padding: 20px; color: var(--atlas-fg); background: var(--atlas-panel); border: 1px solid var(--atlas-cyan); border-radius: 12px; box-shadow: 0 12px 40px #0008; font: 13px/1.5 var(--atlas-ui-font); box-sizing: border-box; }
.source-evidence-drawer header, .source-evidence-drawer nav, .source-evidence-actions { display: flex; justify-content: space-between; gap: 10px; align-items: center; flex-shrink: 0; }
.source-evidence-actions { flex-wrap: wrap; }
.source-evidence-drawer header > div { min-width: 0; }
.source-evidence-drawer header > button { flex-shrink: 0; }
.source-evidence-drawer h2, .source-evidence-drawer p { margin: 0; }
.source-evidence-drawer h2 { font-size: 18px; overflow-wrap: anywhere; }
.source-evidence-drawer header span, .source-evidence-note { color: var(--atlas-dim); font-size: 11px; }
.source-evidence-drawer button { color: var(--atlas-fg); background: var(--atlas-panel-raised); border: 1px solid var(--atlas-line); border-radius: 6px; padding: 6px 10px; cursor: pointer; font: inherit; }
.source-evidence-drawer button:disabled { opacity: .45; cursor: default; }
.source-evidence-drawer :focus-visible { outline: 2px solid var(--atlas-cyan); outline-offset: 2px; }
.source-evidence-location { color: var(--atlas-cyan); overflow-wrap: anywhere; font-family: var(--atlas-font); }
.source-evidence-lines { flex: 1; min-height: 0; overflow: auto; margin: 0; padding: 12px 0; border: 1px solid var(--atlas-line); border-radius: 6px; background: var(--atlas-bg); font: 12px/1.7 var(--atlas-font); tab-size: 4; }
.source-evidence-lines code > span { display: block; min-width: max-content; padding-right: 12px; }
.source-evidence-lines br { display: none; }
.source-evidence-line-number { display: inline-block; width: 46px; padding-right: 12px; color: var(--atlas-dim); text-align: right; user-select: none; }
.source-evidence-drawer [role=alert] { border-left: 3px solid var(--atlas-red, #f87171); padding: 8px; background: var(--atlas-panel-raised); }
.source-evidence-notice { color: var(--atlas-amber, #f4c76b); }
.source-evidence-notice button { margin-left: 8px; }
.source-evidence-lines [data-selected=true] { background: var(--atlas-panel-raised); box-shadow: inset 3px 0 var(--atlas-cyan); }
@media (max-width: 900px) { .source-evidence-drawer { width: calc(100vw - 24px); right: 12px; top: 100px; } }
@media (max-height: 600px) { .source-evidence-drawer { top: 12px; bottom: 12px; padding: 12px; gap: 6px; } }
+5
View File
@@ -2,6 +2,11 @@
export const architectureText = {
title: 'Architecture',
subtitle: 'Understand the structure. Follow the evidence.',
summaryDetails: 'Index summary, layers and communities',
importWalk: 'Read by import order',
inspectEdges: 'Inspect edges',
retainedEdges: (count: number, reported: number) => `${count} retained edges from a boundary reporting ${reported} calls. Package membership uses the same index rule as the summary. Counts may differ across bounds or index updates.`,
edgeEvidenceUnavailable: 'No constituent edge is available in the loaded map snapshot. The summary count alone cannot establish a path or source location; refresh or inspect the source area.',
views: { overview: 'Overview', dependencies: 'Dependencies', entryPoints: 'Entry points', routes: 'Routes', hotspots: 'Hotspots' },
navigation: 'Architecture views',
refresh: 'Refresh',
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import { useRepositoryGuide } from './useRepositoryGuide';
import type { SourceReader } from './repository-guide';
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
const cleanups: (() => Promise<void>)[] = [];
afterEach(async () => { for (const cleanup of cleanups.splice(0)) await cleanup(); });
async function mount(paths: string[], readSource?: SourceReader) {
const container = document.createElement('div'); document.body.append(container);
const root = createRoot(container);
let result: ReturnType<typeof useRepositoryGuide>;
function Harness({ paths, reader }: { paths: string[]; reader?: SourceReader }) {
result = useRepositoryGuide(paths, reader); return null;
}
const render = async (nextPaths = paths, reader = readSource) => {
await act(async () => root.render(<Harness paths={nextPaths} reader={reader} />));
};
cleanups.push(async () => { await act(async () => root.unmount()); container.remove(); });
await render();
return { render, result: () => result! };
}
it('finishes an empty inspection without leaving a permanent loading state', async () => {
const read = vi.fn(async () => ({ source: '' }));
const ui = await mount([], read);
expect(ui.result().finished).toBe(true);
expect(read).not.toHaveBeenCalled();
});
it('does not transfer stale descriptions across a path or reader switch', async () => {
const resolve: ((value: { source: string }) => void)[] = [];
const reader = vi.fn(() => new Promise<{ source: string }>(done => resolve.push(done)));
const ui = await mount(['README.md'], reader);
await ui.render(['other/README.md']);
await act(async () => resolve[0]({ source: 'A previous project description must never appear in the selected repository.' }));
expect(ui.result().quotes).toEqual({});
await act(async () => resolve[1]({ source: 'A current project description explains the selected repository to its readers.' }));
expect(Object.keys(ui.result().quotes)).toEqual(['other/README.md']);
const replacement = vi.fn(async () => { throw new Error('source unavailable'); });
await ui.render(['other/README.md'], replacement);
expect(ui.result()).toMatchObject({ quotes: {}, finished: true, failures: 1 });
});
@@ -0,0 +1,28 @@
import { useEffect, useState } from 'react';
import { sourceQuote, type SourceQuote, type SourceReader } from './repository-guide';
/** Small, sequential batches keep source reads bounded and owned by this project snapshot. */
export function useRepositoryGuide(paths: string[], readSource?: SourceReader) {
const [reading, setReading] = useState<{ key?: string; reader?: SourceReader; quotes: Record<string, SourceQuote>; finished: boolean; failures: number }>({ quotes: {}, finished: false, failures: 0 });
const key = paths.join('\n');
useEffect(() => {
let active = true;
const list = key.split('\n').filter(Boolean).slice(0, 29);
setReading({ key, reader: readSource, quotes: {}, finished: !readSource || list.length === 0, failures: 0 });
if (!readSource || list.length === 0) return;
void (async () => {
const quotes: Record<string, SourceQuote> = {}; let failures = 0;
for (let index = 0; index < list.length && active; index += 3) {
const batch = list.slice(index, index + 3);
const results = await Promise.allSettled(batch.map(path => readSource(path, 1, 65)));
results.forEach((result, i) => {
if (result.status === 'fulfilled') { const quote = sourceQuote(batch[i], result.value.source, result.value.start_line); if (quote) quotes[batch[i]] = quote; }
else failures++;
});
if (active) setReading({ key, reader: readSource, quotes: { ...quotes }, finished: index + 3 >= list.length, failures });
}
})();
return () => { active = false; };
}, [key, readSource]);
return reading.reader === readSource && reading.key === key ? reading : { quotes: {}, finished: false, failures: 0 };
}
@@ -0,0 +1,150 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AtlasApi } from '../app/atlas-api';
import { readLogs } from '../projects/projects-model';
import { buildCoverageIndex } from '../app/tree-model';
import DaemonAlerts from './DaemonAlerts';
import { useDaemonLogFeed } from './useDaemonLogFeed';
let root: Root; let container: HTMLDivElement;
beforeEach(() => {
vi.useFakeTimers();
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible');
container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container);
});
afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.restoreAllMocks(); vi.useRealTimers(); });
const snapshot = (generation = 'db-a') => ({ lines: ['WARN routine', 'ERROR fixture parse failed'], total: 2, persistent: true, generation, retention_limit: 5000, records: [
{ id: 2, ts: '2026-09-09T12:00:01Z', level: 'warn', source: 'index.start', message: 'Routine warning' },
{ id: 1, ts: '2026-09-09T12:00:00Z', level: 'error', source: 'index.file', message: 'Fixture parse failure: src/broken.ts' },
] });
function Harness({ api }: { api: Pick<AtlasApi, 'logs'> }) { return <DaemonAlerts reading={useDaemonLogFeed(api, true, 100)} />; }
describe('Shared persistent daemon event feed', () => {
it('reads same-origin warn/error snapshots, prioritizes errors and recovers without duplicates', async () => {
let turn = 0; const calls: string[] = [];
const fetch = vi.fn(async (url: string | URL | Request) => {
calls.push(String(url)); turn += 1;
if (turn === 2) throw new Error('connection interrupted');
return { ok: true, status: 200, text: async () => JSON.stringify(snapshot(turn > 3 ? 'db-after-restart' : 'db-a')) } as Response;
});
const api = new AtlasApi({ fetch: fetch as typeof globalThis.fetch });
await act(async () => root.render(<Harness api={api} />));
expect(calls).toEqual(['/api/logs?lines=200&min_level=warn']);
expect(container.querySelector('li')).toBeNull();
expect(container.querySelector('[data-testid="daemon-alerts"]')?.className).not.toContain('daemon-alerts-has-errors');
await act(async () => [...container.querySelectorAll('button')].find(button => button.textContent === 'History (2 retained)')!.click());
expect(container.querySelector('li')?.className).toBe('daemon-alert-error');
expect(container.querySelector('li')?.textContent).toContain('! ERROR');
expect(container.querySelector('li')?.textContent).toContain('src/broken.ts');
expect(container.querySelector('time')?.dateTime).toBe('2026-09-09T12:00:00Z');
await act(async () => vi.advanceTimersByTimeAsync(100));
expect(container.querySelector('[role="alert"]')?.textContent).toContain('connection interrupted');
expect(container.querySelectorAll('li')).toHaveLength(2);
await act(async () => vi.advanceTimersByTimeAsync(200));
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.querySelectorAll('li')).toHaveLength(2);
expect(container.textContent).toContain('shared history limit 5000');
expect(calls.every((url) => url === '/api/logs?lines=200&min_level=warn')).toBe(true);
});
it('keeps historical explanation and secondary controls inside the expanded history', async () => {
const data = readLogs(snapshot());
const refresh = vi.fn(), onScopeChange = vi.fn();
await act(async () => root.render(<DaemonAlerts project="fixture" reading={{ data, loading: false, updatedAt: 1, error: null, refresh }} onScopeChange={onScopeChange} />));
expect(container.querySelectorAll('.diagnostics-caveat')).toHaveLength(0);
expect([...container.querySelectorAll('button')].map(button => button.textContent)).toEqual(['History (2 retained)']);
await act(async () => container.querySelector('button')!.click());
expect(container.textContent).toContain('Recorded history, not a count of active failures');
expect(container.textContent).toContain('All daemon history');
expect(container.textContent).toContain('Refresh events');
});
it('retains legacy tails but marks unavailable persistence and avoids a clean bill of health', async () => {
const data = readLogs({ lines: ['ERROR legacy failure'], total: 1, persistent: false });
await act(async () => root.render(<DaemonAlerts reading={{ data, loading: false, updatedAt: null, error: null }} />));
expect(container.textContent).toContain('Persistent event history unavailable');
expect(container.querySelector('.daemon-alert-error')).toBeNull();
await act(async () => [...container.querySelectorAll('button')].find(button => button.textContent === 'History (1 retained)')!.click());
expect(container.querySelector('.daemon-alert-error')).not.toBeNull();
await act(async () => root.render(<DaemonAlerts reading={{ data: readLogs({ lines: [], total: 0, persistent: true, records: [] }), loading: false, updatedAt: null, error: null }} />));
expect(container.textContent).toContain('Unrecorded events and indexing gaps may still exist');
});
it('validates and deduplicates durable row IDs', () => {
const input = snapshot(); input.records.push(input.records[0]!);
const parsed = readLogs({ ...input, records: [...input.records, { id: -1, message: 'bad' }] });
expect(parsed.records?.map((row) => row.id)).toEqual([1, 2]);
});
it('shows shared index gaps immediately without a percentage or triggering diagnosis', async () => {
const onDiagnose = vi.fn();
const coverage = buildCoverageIndex({ scopes: [{ requestedScope: '.', scope: '.', total: 2, status: 'complete', hasMore: false, entries: [{ path: 'broken.ts', kind: 'parse_partial', detail: '3-5' }, { path: 'vendor/', kind: 'not_indexed_dir', detail: 'gitignore' }] }] });
await act(async () => root.render(<DaemonAlerts reading={{ data: readLogs({ lines: [], records: [], total: 0, persistent: true }), updatedAt: null, loading: false, error: null }} coverage={coverage} coverageKnown onDiagnose={onDiagnose} />));
expect(container.querySelector('.daemon-coverage-summary')?.textContent).toContain('1 partially parsed paths · 0 skipped · 1 intentionally excluded');
expect(container.textContent).not.toMatch(/\d+%/); expect(onDiagnose).not.toHaveBeenCalled();
});
it('shows the actual JSON message ahead of receipt metadata and retains raw evidence', async () => {
const raw = JSON.stringify({ received: '2026-09-09T12:00:00Z', session: 'session-id', message: 'Parser failed in src/broken.ts' });
const data = readLogs({ lines: [raw], records: [{ id: 20, ts: '2026-09-09T12:00:00Z', level: 'error', source: 'parser', message: raw }], total: 1, persistent: true });
await act(async () => root.render(<DaemonAlerts reading={{ data, loading: false, updatedAt: null, error: null }} />));
await act(async () => [...container.querySelectorAll('button')].find(button => button.textContent === 'History (1 retained)')!.click());
expect(container.querySelector('li > pre')?.textContent).toBe('Parser failed in src/broken.ts');
expect(container.querySelector('details pre')?.textContent).toBe(raw);
expect(container.textContent).toContain('Recorded history, not a count of active failures');
});
it('announces a genuinely new error and acknowledges it without deleting history', async () => {
const first = readLogs(snapshot());
const render = async (data: typeof first) => act(async () => root.render(<DaemonAlerts project="real-project" reading={{ data, loading: false, updatedAt: 1, error: null }} />));
await render(first);
expect(container.querySelector('.daemon-alert-error')).toBeNull();
const next = readLogs({ ...snapshot(), total: 3, records: [...snapshot().records, { id: 3, ts: '2026-09-09T12:01:00Z', level: 'error', source: 'index.worker', message: 'New actual failure', project: 'real-project' }] });
await render(next);
expect(container.querySelector('.daemon-alert-error')?.textContent).toContain('New actual failure');
expect(container.textContent).toContain('1 new recorded errors · real-project');
await act(async () => [...container.querySelectorAll('button')].find(button => button.textContent === 'Acknowledge new events')!.click());
expect(container.querySelector('.daemon-alert-error')).toBeNull();
await act(async () => [...container.querySelectorAll('button')].find(button => button.textContent === 'History (3 retained)')!.click());
expect(container.querySelectorAll('li')).toHaveLength(3);
expect(container.textContent).toContain('New actual failure');
});
it('requires server project attribution and never shows a previous project while a new request fails', async () => {
const calls: string[] = [];
const fetch = vi.fn(async (url: string | URL | Request) => {
calls.push(String(url));
if (String(url).includes('project=B')) throw new Error('B unavailable');
return { ok: true, status: 200, text: async () => JSON.stringify({ ...snapshot(), scope: 'project', project: 'A' }) } as Response;
});
const api = new AtlasApi({ fetch: fetch as typeof globalThis.fetch });
function Scoped({ project }: { project: string }) {
const feed = useDaemonLogFeed(api, true, 100, project);
return <div>{feed.data?.project}:{feed.error}</div>;
}
await act(async () => root.render(<Scoped project="A" />));
expect(container.textContent).toBe('A:');
await act(async () => root.render(<Scoped project="B" />));
expect(container.textContent).toContain('B unavailable');
expect(container.textContent).not.toContain('A:');
expect(calls).toEqual(['/api/logs?lines=200&min_level=warn&project=A', '/api/logs?lines=200&min_level=warn&project=B']);
});
it('retains B during a failed refresh even when an older A request completes late', async () => {
let completeA!: (value: ReturnType<typeof readLogs>) => void;
let bReads = 0;
const api = { logs: vi.fn((_lines: number, _level?: string, project?: string) => {
if (project === 'A') return new Promise<ReturnType<typeof readLogs>>(resolve => { completeA = resolve; });
bReads += 1;
if (bReads > 1) return Promise.reject(new Error('B refresh interrupted'));
return Promise.resolve(readLogs({ ...snapshot(), scope: 'project', project: 'B' }));
}) };
function Scoped({ project }: { project: string }) {
const feed = useDaemonLogFeed(api, true, 100, project);
return <div>{feed.data?.project}:{feed.error}</div>;
}
await act(async () => root.render(<Scoped project="A" />));
await act(async () => root.render(<Scoped project="B" />));
expect(container.textContent).toBe('B:');
await act(async () => completeA(readLogs({ ...snapshot(), scope: 'project', project: 'A' })));
expect(container.textContent).toBe('B:');
await act(async () => vi.advanceTimersByTimeAsync(100));
expect(container.textContent).toBe('B:B refresh interrupted');
});
});
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useMemo, useState } from 'react';
import type { LogTail } from '../projects/projects-model';
import { logLevel } from '../system/system-model';
import type { SystemReading } from '../system/useSystemPoll';
import type { CoverageIndex } from '../app/tree-model';
import { diagnosticCategory, diagnosticRecords } from './diagnostics-model';
import './diagnostics.css';
function readableMessage(raw: string): string {
try {
const record: unknown = JSON.parse(raw);
if (record && typeof record === 'object' && !Array.isArray(record) && 'message' in record && typeof record.message === 'string') return record.message;
} catch { /* Plain daemon log lines already are the display message. */ }
return raw;
}
export interface DaemonAlertsProps {
project?: string;
scope?: 'project' | 'daemon';
onScopeChange?: (scope: 'project' | 'daemon') => void;
reading: SystemReading<LogTail> & { refresh?: () => void };
onDiagnose?: () => void;
coverage?: CoverageIndex | null;
coverageKnown?: boolean;
coverageError?: string | null;
}
/** Priority feed independent of graph selection; these are daemon events, not graph facts. */
export default function DaemonAlerts({ reading, onDiagnose, coverage, coverageKnown, coverageError, project, scope = 'project', onScopeChange }: DaemonAlertsProps) {
const [expanded, setExpanded] = useState(scope === 'daemon');
const [seen, setSeen] = useState<{ generation: string; id: number }>();
const generation = reading.data?.generation ?? 'legacy';
const cursor = Math.max(0, ...(reading.data?.records ?? []).map(row => row.id));
useEffect(() => {
if (reading.data && seen?.generation !== generation) setSeen({ generation, id: cursor });
}, [reading.data, seen?.generation, generation, cursor]);
const records = useMemo(() => {
const rows = reading.data?.records?.map((record) => ({
...record, key: `${reading.data?.generation ?? 'daemon'}:${record.id}`,
severity: logLevel(`level=${record.level}`),
})) ?? (reading.data?.lines ?? []).map((line, index) => ({
key: `legacy:${index}`, id: index, ts: '', level: '', source: 'Daemon log', message: line, severity: logLevel(line),
}));
return rows.filter((row) => row.severity === 'error' || row.severity === 'warn')
.sort((left, right) => (left.severity === 'error' ? 0 : 1) - (right.severity === 'error' ? 0 : 1) || right.id - left.id);
}, [reading.data]);
const errors = records.filter((record) => record.severity === 'error').length;
const fresh = seen?.generation === generation ? records.filter(row => row.id > seen.id) : [];
const newErrors = scope === 'project' ? fresh.filter(row => row.severity === 'error') : [];
const visible = expanded ? records : newErrors.slice(0, 1);
const acknowledge = () => { setSeen({ generation, id: cursor }); setExpanded(false); };
const coverageSummary = useMemo(() => {
if (coverageError) return 'Index coverage unavailable; the map may be incomplete.';
if (!coverage || coverageKnown === false) return 'Index coverage unknown.';
const rows = diagnosticRecords(coverage);
if (!rows.length) return 'No coverage gaps recorded; complete indexing is not guaranteed.';
const partial = rows.filter((row) => row.state === 'partial').length;
const excluded = rows.filter((row) => diagnosticCategory(row) === 'excluded').length;
const skipped = rows.filter((row) => row.state === 'skipped').length;
return `Index coverage: ${partial} partially parsed paths · ${skipped} skipped · ${excluded} intentionally excluded${coverage.truncations.length ? ' · detail listing incomplete' : ''}.`;
}, [coverage, coverageKnown, coverageError]);
return <section className={`daemon-alerts${newErrors.length || reading.error ? ' daemon-alerts-has-errors' : ''}${expanded ? ' daemon-alerts-expanded' : ' daemon-alerts-collapsed'}`} aria-label="Daemon errors and warnings" data-testid="daemon-alerts">
<div className="daemon-alerts-heading">
<strong>{scope === 'daemon' ? 'Daemon history · all projects' : newErrors.length ? `! ${newErrors.length} new recorded errors · ${project ?? 'this project'}` : `Project events${project ? ` · ${project}` : ''}`}</strong>
<span>{reading.error ? 'Connection interrupted · last reading' : reading.loading && !reading.data ? 'Reading events…' : reading.updatedAt ? `Last checked ${new Date(reading.updatedAt).toLocaleTimeString()}` : 'No event reading yet'}</span>
<button type="button" onClick={() => setExpanded(!expanded)}>{expanded ? 'Close history' : `History (${reading.data?.total ?? records.length} retained)`}</button>
{newErrors.length > 0 && <button type="button" onClick={acknowledge}>Acknowledge new events</button>}
{expanded && onScopeChange && <button type="button" onClick={() => onScopeChange(scope === 'project' ? 'daemon' : 'project')}>{scope === 'project' ? 'All daemon history' : 'This project only'}</button>}
{expanded && reading.refresh && <button type="button" onClick={reading.refresh} disabled={reading.loading}>Refresh events</button>}
{onDiagnose && <button type="button" onClick={onDiagnose}>Inspect index coverage</button>}
</div>
{(coverageKnown !== undefined || coverage !== undefined || coverageError) && <p className="daemon-coverage-summary" title={coverageSummary}>{coverageSummary}</p>}
{reading.error && <p role="alert" className="diagnostics-error">Event updates failed: {reading.error}. A missing update does not mean the daemon recovered.</p>}
{reading.data?.persistent !== true && reading.data && <p className="diagnostics-caveat">Persistent event history unavailable. This log tail may not survive a restart.</p>}
{expanded && <p className="diagnostics-caveat">Recorded history, not a count of active failures. {scope === 'project' ? 'Only events attributed to this project; older unattributed records remain in daemon history.' : 'Includes other projects and older events without project attribution.'} Acknowledging events does not resolve their causes or delete them.</p>}
{visible.length > 0 && <ol className="daemon-alert-list">{visible.map((record) => {
const message = readableMessage(record.message);
return <li key={record.key} className={`daemon-alert-${record.severity}`}>
<div><strong>{record.severity === 'error' ? '! ERROR' : '△ WARNING'}</strong> <span>{record.source || 'Source not reported'}</span> {record.ts && <time dateTime={record.ts}>{record.ts}</time>} {reading.data?.records && <span>Event #{record.id}</span>}{scope === 'daemon' && <span>Project: {'project' in record && typeof record.project === 'string' ? record.project : 'not recorded'}</span>}</div>
<pre>{message.length > 400 ? `${message.slice(0, 400)}…` : message}</pre>
<details><summary>Full recorded event</summary><pre>{record.message}</pre></details>
</li>})}</ol>}
{expanded && records.length === 0 && reading.data && <p>No attributed warning/error events in this retained reading. Unrecorded events and indexing gaps may still exist.</p>}
{reading.data && expanded && <p className="diagnostics-caveat">{records.length} of {reading.data.total} matching retained events shown · {errors} errors in this page{reading.data.retentionLimit !== undefined ? ` · shared history limit ${reading.data.retentionLimit}` : ''}. Older events may have expired. Project and file details appear only when recorded.</p>}
</section>;
}
@@ -0,0 +1,102 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { RpcIntelligenceClient } from '../provider/rpc-client';
import { buildCoverageIndex } from '../app/tree-model';
import DiagnosticsPanel, { type DiagnosticsPanelProps } from './DiagnosticsPanel';
let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container);
});
afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
async function click(text: string) {
const button = [...container.querySelectorAll('button')].find((item) => item.textContent === text);
expect(button).toBeDefined(); await act(async () => button!.click());
}
function clientFixture() {
const calls: { url: string; tool: string; args: Record<string, unknown> }[] = [];
const fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
const body = JSON.parse(String(init?.body));
calls.push({ url: String(url), tool: body.params.name, args: body.params.arguments });
if (String(url) !== '/rpc' || init?.method !== 'POST') throw new Error('Unexpected external request');
let payload: unknown;
if (body.params.name === 'index_status') payload = { parse_partial: { count: 1, files: [{ path: 'src/broken.ts', error_ranges: '3-5' }] } };
else if (body.params.name === 'check_index_coverage') payload = body.params.arguments.paths
? { paths: [{ path: 'src/broken.ts', status: 'partial', freshness: 'metadata_changed', recommended_action: 'read_source_and_reindex', coverage: [{ path: 'src/broken.ts', kind: 'parse_partial', detail: '3-5' }] }] }
: { metadata: { generation: 'fixture-generation', generation_matches: true, recording_status: 'complete' }, scopes: [{ scope: '.', total: 1, has_more: false, entries: [{ path: 'src/broken.ts', kind: 'parse_partial', detail: '3-5' }] }] };
else throw new Error('Unexpected tool/agent action');
return { ok: true, status: 200, text: async () => JSON.stringify({ jsonrpc: '2.0', id: body.id, result: { content: [{ type: 'text', text: JSON.stringify(payload) }] } }) } as Response;
});
return { client: new RpcIntelligenceClient({ fetch: fetch as typeof globalThis.fetch }), calls, fetch };
}
describe('Local diagnosis user boundary', () => {
it('does no read until requested, uses existing tools, and only copies the edited report', async () => {
const fixture = clientFixture(); const outside = vi.fn(); vi.stubGlobal('fetch', outside);
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
const onNavigate = vi.fn(); const onCoverage = vi.fn();
await act(async () => root.render(<DiagnosticsPanel project="fixture" client={fixture.client} active onClose={vi.fn()} onNavigate={onNavigate} path="src/broken.ts" onCoverage={onCoverage} />));
expect(fixture.calls).toHaveLength(0); expect(container.querySelector('textarea')).toBeNull();
await click('Run local diagnosis');
expect(fixture.calls.map((call) => call.tool)).toEqual(['index_status', 'check_index_coverage', 'check_index_coverage']);
expect(fixture.calls.every((call) => call.url === '/rpc')).toBe(true);
expect(container.textContent).toContain('File size or modification time changed');
expect(container.textContent).toContain('fixture-generation'); expect(onCoverage).toHaveBeenCalledOnce();
const area = container.querySelector('textarea')!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(area, 'Reviewed, redacted draft');
area.dispatchEvent(new Event('input', { bubbles: true }));
});
await click('Copy edited report'); expect(writeText).toHaveBeenCalledWith('Reviewed, redacted draft');
const createObjectURL = vi.fn().mockReturnValue('blob:local-diagnostic-report');
const revokeObjectURL = vi.fn();
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: createObjectURL });
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: revokeObjectURL });
const linkClick = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
expect(this.href).toBe('blob:local-diagnostic-report'); expect(this.download).toBe('local-index-diagnosis.txt');
});
await click('Download edited report'); expect(createObjectURL).toHaveBeenCalledOnce(); expect(linkClick).toHaveBeenCalledOnce(); expect(revokeObjectURL).toHaveBeenCalledWith('blob:local-diagnostic-report');
await click('src/broken.ts'); expect(onNavigate).toHaveBeenCalledWith('src/broken.ts');
expect(fixture.calls).toHaveLength(3); expect(outside).not.toHaveBeenCalled();
expect(container.querySelector('a[href^="http"]')).toBeNull();
expect([...container.querySelectorAll('button')].some((button) => /upload|publish|create issue/i.test(button.textContent ?? ''))).toBe(false);
});
it('never presents a failed read as complete coverage', async () => {
const fixture = clientFixture(); fixture.fetch.mockRejectedValue(new Error('SQLite busy'));
await act(async () => root.render(<DiagnosticsPanel project="fixture" client={fixture.client} active onClose={vi.fn()} onNavigate={vi.fn()} />));
await click('Run local diagnosis');
expect(container.querySelector('[role="alert"]')?.textContent).toContain('SQLite busy');
expect(container.textContent).toContain('Completeness is unknown');
expect(container.querySelector('textarea')).toBeNull();
});
it('renders shared coverage reasons and an honest empty state without refetching', async () => {
const fixture = clientFixture(); const props: DiagnosticsPanelProps = { project: 'fixture', client: fixture.client, active: true, onClose: vi.fn(), onNavigate: vi.fn(), coverage: buildCoverageIndex({}) };
await act(async () => root.render(<DiagnosticsPanel {...props} />));
expect(container.textContent).toContain('does not prove complete indexing');
expect(fixture.calls).toHaveLength(0);
await act(async () => root.render(<DiagnosticsPanel {...props} coverage={buildCoverageIndex({ scopes: [{ scope: '.', requestedScope: '.', status: 'complete', total: 1, hasMore: false, entries: [{ path: 'vendor/', kind: 'not_indexed_dir', detail: 'gitignore' }] }] })} />));
expect(container.textContent).toContain('Excluded by index rules'); expect(container.textContent).toContain('gitignore');
});
it('discards a diagnosis from a previous project and does not continue its path query', async () => {
let resolve!: (data: unknown) => void;
const fixture = clientFixture();
vi.spyOn(fixture.client, 'indexStatusPayload').mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
const onCoverage = vi.fn();
const props = { project: 'old-project', client: fixture.client, active: true, path: 'old.ts', onClose: vi.fn(), onNavigate: vi.fn(), onCoverage };
await act(async () => root.render(<DiagnosticsPanel {...props} />));
await click('Run local diagnosis');
await act(async () => root.render(<DiagnosticsPanel {...props} project="new-project" path="new.ts" />));
await act(async () => resolve({}));
expect(container.textContent).toContain('new-project'); expect(container.querySelector('textarea')).toBeNull();
expect(onCoverage).not.toHaveBeenCalled();
// loadCoverage owns its bounded scope lookup; the obsolete selected
// path must never be requested or committed afterward.
expect(fixture.calls.some((call) => call.args['paths'] !== undefined)).toBe(false);
});
});
@@ -0,0 +1,97 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { loadCoverage, loadPathCoverage, type CoverageReading } from '../app/coverage-source';
import type { CoverageIndex } from '../app/tree-model';
import type { RpcIntelligenceClient } from '../provider/rpc-client';
import { DIAGNOSTIC_LABELS, diagnosticAction, diagnosticCategory, diagnosticRecords, freshnessText, localDiagnosticReport, type LocalDiagnosis } from './diagnostics-model';
import './diagnostics.css';
export interface DiagnosticsPanelProps {
project: string;
client: RpcIntelligenceClient;
coverage?: CoverageIndex | null;
coverageError?: string | null;
active: boolean;
onClose: () => void;
path?: string;
onNavigate: (path: string) => void;
/** App can update its shared coverage reading from the same diagnosis. */
onCoverage?: (reading: CoverageReading) => void;
}
export default function DiagnosticsPanel({ project, client, coverage, coverageError, active, onClose, path, onNavigate, onCoverage }: DiagnosticsPanelProps) {
const [diagnosis, setDiagnosis] = useState<LocalDiagnosis | null>(null);
const [report, setReport] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState('');
const [query, setQuery] = useState('');
const request = useRef(0);
useEffect(() => {
request.current += 1;
setDiagnosis(null); setReport(''); setBusy(false); setError(null); setStatus(''); setQuery('');
return () => { request.current += 1; };
}, [project, path, active]);
const index = diagnosis?.reading.index ?? coverage;
const records = useMemo(() => diagnosticRecords(index), [index]);
const filtered = useMemo(() => records.filter((record) => `${record.path} ${record.reason}`.toLowerCase().includes(query.toLowerCase())), [records, query]);
const run = async () => {
const ticket = ++request.current;
setBusy(true); setError(null); setStatus('');
try {
const reading = await loadCoverage(client, project);
if (ticket !== request.current) return;
const pathAnswer = path ? await loadPathCoverage(client, project, path) : undefined;
if (ticket !== request.current) return;
const result: LocalDiagnosis = { project, requestedPath: path, checkedAt: new Date().toISOString(), reading, pathAnswer };
setDiagnosis(result); setReport(localDiagnosticReport(result)); onCoverage?.(reading);
} catch (reason) {
if (ticket === request.current) setError(reason instanceof Error ? reason.message : String(reason));
} finally {
if (ticket === request.current) setBusy(false);
}
};
const copy = async () => {
try {
if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable; select the draft and copy it manually.');
await navigator.clipboard.writeText(report); setStatus('Edited report copied locally. Nothing was sent.');
} catch (reason) { setStatus(reason instanceof Error ? reason.message : String(reason)); }
};
const download = () => {
const url = URL.createObjectURL(new Blob([report], { type: 'text/plain;charset=utf-8' }));
const anchor = document.createElement('a'); anchor.href = url; anchor.download = 'local-index-diagnosis.txt';
anchor.click(); URL.revokeObjectURL(url); setStatus('Edited report downloaded locally. Nothing was sent.');
};
if (!active) return null;
return <section className="diagnostics-panel" role="dialog" aria-label="Index coverage diagnosis" data-testid="diagnostics-panel">
<header><div><p className="diagnostics-eyebrow">LOCAL EVIDENCE</p><h2>Index coverage diagnosis</h2><p><code>{project}</code>{path && <> · Selected <code>{path}</code></>}</p></div><button type="button" onClick={onClose}>Close diagnosis</button></header>
<p>The map can omit excluded files, unsupported inputs and failed or partial parses. Inspect the recorded reasons before relying on missing graph relationships.</p>
{(error || coverageError) && <p role="alert" className="diagnostics-error">Coverage reading failed: {error || coverageError}. {index ? 'Previous records remain visible; this is not a fresh reading.' : 'Completeness is unknown.'}</p>}
<div className="diagnostics-actions"><button type="button" disabled={busy || !project} onClick={() => { void run(); }}>{busy ? 'Reading local diagnosis…' : 'Run local diagnosis'}</button><span>Reads existing coverage metadata through this daemon. No agent, upload or issue creation.</span></div>
{!index && <p>Coverage evidence has not been loaded. Run a local diagnosis to inspect the index records.</p>}
{index && <>
<div className="diagnostics-counts">{(['partial', 'excluded', 'unsupported', 'skipped', 'unknown'] as const).map((category) => <span key={category}>{DIAGNOSTIC_LABELS[category]}: <strong>{records.filter((record) => diagnosticCategory(record) === category).length}</strong></span>)}</div>
<p className="diagnostics-caveat">Counts describe returned path records, including directories. They do not measure all repository files. Unsupported inputs and files never discovered may be absent from these records.</p>
<p className="diagnostics-caveat">Partial parses mark source ranges the index parser could not interpret reliably. Grammar or preprocessor limitations and malformed source can cause this; a partial parse alone does not establish a build or runtime failure. Exclusions follow indexing rules.</p>
{index.listingComplete && <p className="diagnostics-caveat">All recorded coverage entries have been loaded. The parser limitations and exclusions listed below still apply.</p>}
{index.truncations.map((note) => <p key={note} className="diagnostics-caveat">Incomplete list: {note}</p>)}
</>}
{diagnosis && <div className="diagnostics-reading"><p>Checked {diagnosis.checkedAt} · Index generation <code>{diagnosis.reading.answer.metadata.generation || 'unavailable'}</code> · Recording {diagnosis.reading.answer.metadata.recordingStatus || 'unavailable'}</p>
{!diagnosis.reading.answer.metadata.generationMatches && <p className="diagnostics-error">Coverage generation does not confirm the current index. Treat the map as potentially stale.</p>}
{path && <p>{diagnosis.pathAnswer ? freshnessText(diagnosis.pathAnswer.freshness) : 'No path-specific evidence returned. Freshness is unknown.'}</p>}
{diagnosis.pathAnswer?.coverage.map((row, i) => <p key={`${row.path}:${i}`}><code>{row.path}</code> · {row.kind} · {row.detail || 'No detail recorded'}</p>)}
{diagnosis.reading.answer.caveat && <p className="diagnostics-caveat">{diagnosis.reading.answer.caveat}</p>}
</div>}
{records.length > 0 && <><label className="diagnostics-filter">Filter recorded paths or reasons<input type="search" value={query} onChange={(event) => setQuery(event.currentTarget.value)} /></label>
<ol className="diagnostics-records">{filtered.slice(0, 100).map((record) => <li key={record.path} className={`diagnostics-record-${diagnosticCategory(record)}`}>
<div><strong>{DIAGNOSTIC_LABELS[diagnosticCategory(record)]}</strong> · {record.kind} <button type="button" onClick={() => onNavigate(record.path)}><code>{record.path}</code></button></div>
<p><strong>Recorded reason:</strong> {record.reason || 'No reason recorded'}</p>
<p><strong>Evidence:</strong> {record.sources.join(', ') || 'Source not reported'}</p>
<p>{diagnosticAction(record)}</p>
</li>)}</ol><p className="diagnostics-caveat">Showing {Math.min(100, filtered.length)} of {filtered.length} matching records. Filter to locate another path.</p></>}
{index && records.length === 0 && <p>No gap records returned. This does not prove complete indexing; undiscovered or unsupported files may be unrecorded.</p>}
{diagnosis && <section aria-label="Local diagnostic report"><h3>Review the local report</h3><p>This draft contains paths and recorded reasons. Edit it before sharing. Copy and download stay local; publication would require a separate, explicit action outside this panel.</p>
<textarea aria-label="Editable local diagnostic report" value={report} onChange={(event) => setReport(event.currentTarget.value)} spellCheck={false} />
<div className="diagnostics-actions"><button type="button" onClick={() => { void copy(); }}>Copy edited report</button><button type="button" onClick={download}>Download edited report</button><span role="status">{status}</span></div>
</section>}
</section>;
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { buildCoverageIndex, readCoverageAnswer } from '../app/tree-model';
import type { CoverageRecord } from '../app/tree-model';
import { diagnosticCategory, freshnessText, localDiagnosticReport } from './diagnostics-model';
describe('Existing index evidence in diagnostics', () => {
const row = (state: CoverageRecord['state'], reason: string): CoverageRecord => ({ path: 'src/a.unknown', kind: 'file', state, reason, sources: ['check_index_coverage'] });
it('does not infer unsupported languages from paths or arbitrary skip messages', () => {
expect(diagnosticCategory(row('skipped', 'read failed / read'))).toBe('skipped');
expect(diagnosticCategory(row('skipped', 'unsupported language / extract'))).toBe('unsupported');
expect(diagnosticCategory(row('partial', '2-4'))).toBe('partial');
expect(diagnosticCategory(row('not-indexed', 'gitignore'))).toBe('excluded');
expect(diagnosticCategory(row('skipped', 'unknown'))).toBe('skipped');
});
it('does not claim metadata matches prove equal content or untracked means pending', () => {
expect(freshnessText('metadata_match')).toContain('not a content-hash guarantee');
expect(freshnessText('not_tracked')).toContain('pending work is not confirmed');
expect(freshnessText('metadata_changed')).toContain('changed since indexing');
expect(freshnessText('')).toContain('not a clean result');
});
it('bounds a reviewable local report and retains source and uncertainty', () => {
const entries = Array.from({ length: 205 }, (_, i) => ({ path: `src/f${i}.ts`, kind: 'parse_partial', detail: '2-3' }));
const scopes = [{ requestedScope: '.', scope: '.', status: 'complete', total: 205, hasMore: false, entries }];
const index = buildCoverageIndex({ scopes });
const report = localDiagnosticReport({ project: 'fixture', checkedAt: '2026-09-09T12:00:00Z', reading: { index, scopes, answer: readCoverageAnswer({ metadata: { generation: 'g1', generation_matches: false }, caveat: 'best effort' }) } });
expect(report).toContain('Index generation: g1');
expect(report).toContain('not confirmed');
expect(report).toContain('Report limited to 200 of 205 recorded paths');
expect(report).toContain('Evidence source: check_index_coverage');
expect(report).toContain('No source text or daemon log is attached');
expect(report).not.toMatch(/\d+%/);
});
});
@@ -0,0 +1,88 @@
import type { CoverageReading } from '../app/coverage-source';
import type { CoverageIndex, CoveragePathAnswer, CoverageRecord } from '../app/tree-model';
export type DiagnosticCategory = 'partial' | 'excluded' | 'unsupported' | 'skipped' | 'unknown';
export const DIAGNOSTIC_LABELS: Record<DiagnosticCategory, string> = {
partial: 'Partial parse', excluded: 'Excluded by index rules', unsupported: 'Unsupported language',
skipped: 'Indexing skipped', unknown: 'Unclassified coverage record',
};
/** Display the existing coverage join; only a specific recorded reason identifies unsupported input. */
export function diagnosticCategory(record: CoverageRecord): DiagnosticCategory {
if (record.state === 'partial') return 'partial';
if (record.state === 'ignored' || record.state === 'not-indexed') return 'excluded';
// cbm.c emits this exact error; don't guess support from a file extension.
if (record.state === 'skipped' && /^unsupported language(?:\s*\/|$)/.test(record.reason)) return 'unsupported';
return record.state === 'skipped' ? 'skipped' : 'unknown';
}
export function diagnosticAction(record: CoverageRecord): string {
const category = diagnosticCategory(record);
if (category === 'partial') return 'Read the reported source ranges; verify missing symbols before relying on graph paths.';
if (category === 'excluded') return 'Review the recorded exclusion rule; open the source directly or adjust that rule before reindexing.';
if (category === 'unsupported') return 'Read this file directly; check parser support before requesting another index run.';
if (category === 'skipped') return 'Inspect the recorded reason and source; correct the cause before reindexing.';
return 'Inspect the existing coverage evidence; completeness cannot be inferred from this record.';
}
export function diagnosticRecords(index: CoverageIndex | null | undefined): CoverageRecord[] {
const priority: Record<DiagnosticCategory, number> = { partial: 0, skipped: 1, unsupported: 2, unknown: 3, excluded: 4 };
return [...(index?.records.values() ?? [])].sort((a, b) => priority[diagnosticCategory(a)] - priority[diagnosticCategory(b)] || a.path.localeCompare(b.path));
}
export function freshnessText(freshness: string): string {
if (freshness === 'metadata_match') return 'File size and modification time match the index record. This is not a content-hash guarantee.';
if (freshness === 'metadata_changed') return 'File size or modification time changed since indexing. Read the current source and reindex.';
if (freshness === 'not_tracked') return 'No indexed file metadata exists for this path. It may be unindexed; pending work is not confirmed.';
if (freshness === 'missing') return 'The requested file is missing on disk.';
if (freshness === 'outside_project') return 'This path is outside the selected project.';
return 'Freshness is unavailable. Missing evidence is not a clean result.';
}
export interface LocalDiagnosis {
project: string;
requestedPath?: string;
checkedAt: string;
reading: CoverageReading;
pathAnswer?: CoveragePathAnswer;
}
/** A bounded, deterministic draft produced only after a user's local diagnosis action. */
export function localDiagnosticReport(diagnosis: LocalDiagnosis): string {
const { reading, pathAnswer } = diagnosis;
const records = diagnosticRecords(reading.index);
const metadata = reading.answer.metadata;
const lines = [
'# Local index diagnosis: review before sharing', '',
`Project: ${diagnosis.project}`, `Checked locally: ${diagnosis.checkedAt}`,
`Index generation: ${metadata.generation || 'unavailable'}`,
`Coverage recorded at: ${metadata.recordedAt || 'unavailable'}`,
`Recording status: ${metadata.recordingStatus || 'unavailable'}`,
`Coverage generation matches index: ${metadata.generationMatches ? 'yes' : 'not confirmed'}`,
'Evidence: existing index_status + check_index_coverage. No source text or daemon log is attached.',
'This is a best-effort record, not a repository-wide coverage percentage.',
'Paths and reasons below may be sensitive. Edit this draft before sharing it.', '',
];
if (diagnosis.requestedPath) {
lines.push(`Selected path: ${diagnosis.requestedPath}`);
lines.push(pathAnswer ? `Path status: ${pathAnswer.status || 'unavailable'}; freshness: ${pathAnswer.freshness || 'unavailable'}` : 'The daemon returned no evidence for the selected path.');
if (pathAnswer) {
lines.push(freshnessText(pathAnswer.freshness), `Reported next action: ${pathAnswer.recommendedAction || 'unavailable'}`);
for (const row of pathAnswer.coverage) lines.push(`Path evidence: ${row.path} | ${row.kind} | ${row.detail || 'No detail recorded'}`);
}
lines.push('');
}
lines.push(`Recorded paths: ${records.length} (files and directories; not a file-coverage denominator).`);
lines.push(`Ignored file records stored: ${metadata.ignoredFilesStored}; total reported: ${metadata.ignoredFilesTotal}.`);
for (const note of reading.index.truncations) lines.push(`Incomplete list: ${note}`);
if (reading.answer.caveat) lines.push(`Daemon caveat: ${reading.answer.caveat}`);
if (!records.length) lines.push('No gap records returned. Discovery omissions and unsupported files may be unrecorded.');
for (const record of records.slice(0, 200)) {
lines.push('', `- ${record.path} (${record.kind}): ${DIAGNOSTIC_LABELS[diagnosticCategory(record)]}`,
` Recorded reason: ${record.reason || 'No reason recorded'}`,
` Evidence source: ${record.sources.join(', ') || 'Not reported'}`,
` Next check: ${diagnosticAction(record)}`);
}
if (records.length > 200) lines.push('', `Report limited to 200 of ${records.length} recorded paths.`);
return lines.join('\n');
}
+37
View File
@@ -0,0 +1,37 @@
.daemon-alerts, .diagnostics-panel { box-sizing: border-box; color: #d7e6e0; background: #101a16; border: 1px solid #355346; font: 13px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.daemon-alerts { position: relative; z-index: 5; padding: 6px 10px; max-height: 34vh; overflow: auto; border-radius: 8px; }
.daemon-alerts-has-errors { border-color: #dc6074; border-left-width: 4px; }
.daemon-alerts-heading, .diagnostics-panel header, .diagnostics-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.daemon-alerts-heading > strong { margin-right: auto; }.daemon-alerts-heading > span { color: #a5b8ae; font-size: 11px; }
.daemon-coverage-summary { margin: 5px 0; color: #e5c88b; font-size: 12px; }
.daemon-alerts button, .diagnostics-panel button { color: #dcf2e5; background: #142a20; border: 1px solid #466758; border-radius: 5px; cursor: pointer; padding: 5px 9px; font: inherit; }
.daemon-alerts button:hover, .diagnostics-panel button:hover { border-color: #66e8af; }.daemon-alerts button:disabled, .diagnostics-panel button:disabled { opacity: .5; cursor: default; }
.daemon-alerts :focus-visible, .diagnostics-panel :focus-visible { outline: 2px solid #62dfef; outline-offset: 3px; }
.daemon-alert-list { list-style: none; padding: 0; margin: 8px 0; }.daemon-alert-list li { margin: 7px 0; padding: 8px 10px; border-left: 3px solid; }
.daemon-alert-error, .diagnostics-error { color: #ffc0cb; background: #3d1722; border-color: #ff637f; }.daemon-alert-warn { color: #f0d397; background: #2e2616; border-color: #dfa745; }
.daemon-alert-list pre { font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; margin: 4px 0 0; }.daemon-alert-list time, .daemon-alert-list span { font-size: 11px; margin-left: 8px; }
.diagnostics-caveat { color: #b5c3ba; font-size: 11px; margin: 6px 0; }.diagnostics-error { padding: 9px 12px; border-left: 3px solid #ff637f; }
.diagnostics-panel { position: fixed; z-index: 180; inset: 7vh max(18px, calc((100vw - 1100px) / 2)); padding: 24px; border-radius: 12px; overflow: auto; box-shadow: 0 12px 80px #000b; }
.diagnostics-panel header { justify-content: space-between; align-items: start; }.diagnostics-panel h2 { margin: 3px 0 9px; font-size: 23px; }.diagnostics-panel h3 { font-size: 16px; }.diagnostics-eyebrow { font: 11px ui-monospace, monospace; color: #6df0b8; letter-spacing: .14em; margin: 0; }
.diagnostics-panel code { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }.diagnostics-actions { margin: 14px 0; }.diagnostics-actions > span { color: #b5c3ba; font-size: 12px; }
.diagnostics-counts { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0 6px; }.diagnostics-counts span { padding: 7px 10px; background: #1b2b23; border: 1px solid #375044; border-radius: 5px; }
.diagnostics-reading { padding: 10px 14px; background: #192921; margin: 14px 0; }.diagnostics-filter { display: grid; gap: 5px; }
.diagnostics-panel input, .diagnostics-panel textarea { color: #e0eee5; background: #08110c; border: 1px solid #466758; border-radius: 5px; padding: 10px; font: inherit; box-sizing: border-box; }
.diagnostics-panel textarea { width: 100%; min-height: 260px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
.diagnostics-records { list-style: none; padding: 0; max-height: 42vh; overflow: auto; }.diagnostics-records li { padding: 12px; margin: 8px 0; border: 1px solid #375044; border-left-width: 3px; border-radius: 5px; }.diagnostics-records p { margin: 5px 0; overflow-wrap: anywhere; }
.diagnostics-records .diagnostics-record-partial, .diagnostics-records .diagnostics-record-skipped { border-left-color: #ed7d91; }.diagnostics-records .diagnostics-record-excluded { border-left-color: #9eabb0; }
@media (max-width: 600px) { .diagnostics-panel { inset: 12px; padding: 16px; }.daemon-alerts { max-height: 28vh; } }
.daemon-alerts-collapsed .daemon-alerts-heading { gap: 6px; }
.daemon-alerts-collapsed .daemon-alerts-heading button { padding: 3px 8px; font-size: 12px; }
.daemon-alerts-collapsed .daemon-coverage-summary { margin: 2px 0; }
.daemon-alerts-collapsed .daemon-alert-list { margin: 4px 0 0; }
.daemon-alerts-collapsed .daemon-alert-list > li { margin: 0; padding: 4px 8px; }
.daemon-alerts-collapsed .daemon-alert-list > li > pre { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.daemon-alerts-collapsed .daemon-alert-list summary { font-size: 11px; }
.daemon-alerts-collapsed .daemon-alerts-heading { flex-wrap: nowrap; min-width: 0; }
.daemon-alerts-collapsed .daemon-alerts-heading > strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.daemon-alerts-collapsed .daemon-alerts-heading > button { flex-shrink: 0; }
.daemon-alerts-collapsed .daemon-coverage-summary { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@media (max-width: 800px) { .daemon-alerts-collapsed .daemon-alerts-heading > span { display: none; } }
@@ -0,0 +1,33 @@
import { useCallback, useRef } from 'react';
import type { AtlasApi } from '../app/atlas-api';
import type { LogTail } from '../projects/projects-model';
import { useSystemPoll } from '../system/useSystemPoll';
/** Mount once; server-side filtering precedes limits. Each scope owns its results. */
export function useDaemonLogFeed(api: Pick<AtlasApi, 'logs'>, active = true, pollMs = 3000, project?: string) {
const key = project === undefined ? 'daemon' : `project:${project}`;
const currentKey = useRef(key);
currentKey.current = key;
const last = useRef<{ key: string; data: LogTail; updatedAt: number }>(undefined);
const read = useCallback(async () => {
try {
const data = await api.logs(200, 'warn', project);
if (project !== undefined && (data.scope !== 'project' || data.project !== project)) {
throw new Error('Project log attribution is unavailable. Open daemon history for older, unattributed events.');
}
const result = { key, data, updatedAt: Date.now() };
if (currentKey.current === key) last.current = result;
return { ...result, error: null };
} catch (cause) {
const retained = last.current?.key === key ? last.current : undefined;
return { key, data: retained?.data ?? null, updatedAt: retained?.updatedAt ?? null,
error: cause instanceof Error ? cause.message : String(cause) };
}
}, [api, key, project]);
const reading = useSystemPoll(read, active, false, pollMs);
const owned = reading.data?.key === key ? reading.data : undefined;
// A previous scope can remain in the generic poller during the next request,
// including an unsuccessful request. Never relabel that data or error.
return { ...reading, data: owned?.data ?? null, error: owned?.error ?? null,
loading: reading.loading || !owned, updatedAt: owned?.updatedAt ?? null };
}
+2
View File
@@ -720,6 +720,7 @@ export interface GalaxyPanelProps {
fullscreenToggle?: number;
/** Der Speicher fuer die Lage des Instruments. Ersetzbar fuer Tests. */
agentStore?: Storage | undefined;
selectionPanel?: import('react').ReactNode;
}
/** Wie lange die Zeitangaben im Instrument stehen, bis sie neu gerechnet werden. */
@@ -2402,6 +2403,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
</div>
)}
</div>
{props.selectionPanel && <aside className="galaxy-selection-evidence" aria-label="Selection evidence">{props.selectionPanel}</aside>}
{note.length > 0 && (
<p className="atlas-galaxy-note" data-testid="atlas-galaxy-note">
{props.workspaceExpanded && note === GALAXY_NO_FOCUS_NOTE
+9
View File
@@ -35,6 +35,9 @@ export interface GraphNode {
status?: NodeStatus;
in_calls?: number;
out_calls?: number;
/** Indexed documentation when supplied by the logical repository map. */
documentation?: string;
package_name?: string;
}
export type NodeStatus =
@@ -47,6 +50,12 @@ export type NodeStatus =
| 'structural';
export interface GraphEdge {
id?: number;
/** Recorded representative callsite, when present; not the declaration. */
line?: number;
/** Resolution provenance supplied by the index, when available. */
strategy?: string;
confidence?: number;
source: number;
target: number;
type: string;
+7 -7
View File
@@ -291,7 +291,7 @@ export const messages = {
'mnemonic:a': 'show or hide the galaxy',
'mnemonic:w': 'ask again where to start, and walk from there',
'mnemonic:b': 'open the BUG hunt on the symbol in the twin',
'mnemonic:c': 'open the change scope',
'mnemonic:c': 'open change impact',
'mnemonic:l': 'turn the local model on or off',
'mnemonic:r': 'put every zone back to the width and height it starts with',
'mnemonic:s': 'open or close the settings: the model, and what the drawing costs',
@@ -584,7 +584,7 @@ export const messages = {
walk: 'walk',
chat: 'chat',
bug: 'bug hunt',
change: 'change scope',
change: 'change impact',
},
tabTitle: {
flow: 'the recorded calls of the symbol in the twin, as a sequence and as steps',
@@ -598,7 +598,7 @@ export const messages = {
'No symbol in the twin yet, so there is no sequence to draw. Open a file and put '
+ 'the caret in a function; the twin follows it, and this tab follows the twin.',
walk:
'No walk is running. Start one from the atlas menu ("why am I here") and its steps '
'No walk is running. Start one from the atlas menu ("Choose a starting point") and its steps '
+ 'appear here.',
chat:
'Nothing was asked in this session yet. Type a question in the command line and '
@@ -619,7 +619,7 @@ export const messages = {
turns === 1 ? '1 question in this session' : `${turns} questions in this session`,
chatIdle: 'nothing asked yet',
bug: 'bug hunt',
change: 'change scope',
change: 'change impact',
},
/**
* Die vier Griffe.
@@ -1092,10 +1092,10 @@ export const messages = {
uiLogTitle: 'Frontend log',
uiLogSource: (shown: number, total: number, path: string): string =>
`read from GET /api/ui-log: ${shown} of ${total} lines of ${path}`,
uiLogPrevious: (path: string): string => `rotated once; the lines before these are in ${path}`,
uiLogPrevious: (path: string): string => `Local compatibility export archive: ${path}; retained history is read from SQLite.`,
uiLogAbout:
'This page sends its console output, uncaught errors and failed requests to that file as '
+ 'they happen. Attach the file to a bug report; an agent can read it with the same route.',
'This page records console output, uncaught errors and failed requests in the daemon’s local SQLite journal. '
+ 'The JSONL file remains a bounded compatibility export. Review any content before sharing it; nothing is published automatically.',
uiLogEmpty: 'nothing recorded yet',
uiLogError: (detail: string): string => `the frontend log did not arrive: ${detail}`,
reload: 'reload',
@@ -0,0 +1,164 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent } from 'react';
import SelectionImpactWidget from './SelectionImpactWidget';
import type { SelectionImpactTarget, fetchSelectionImpact } from './selection-impact';
import { changedAreas, loadChangeSet, loadFileImpactSymbols, localDiffCommand, validChangeRef } from './change-set';
import type { ChangeSetReading, ChangeSymbolSource } from './change-set';
import LocalEvidenceCommand from './LocalEvidenceCommand';
import './change-analysis.css';
export type ChangeAnalysisMode = 'selection' | 'worktree' | 'since-ref';
export interface ChangeAnalysisWorkspaceProps {
project: string;
selectedTarget?: SelectionImpactTarget;
expectedGeneration?: string;
client?: ChangeSymbolSource;
onOpenSource: (target: SelectionImpactTarget) => void;
onDiagnose?: () => void;
onClose?: () => void;
initialMode?: ChangeAnalysisMode;
/** Tests can supply deferred readings without a daemon. */
loadChanges?: typeof loadChangeSet;
loadSymbols?: typeof loadFileImpactSymbols;
loadImpact?: typeof fetchSelectionImpact;
}
/** One shared, full-size flow for a selected declaration and an actual Git change set. */
export default function ChangeAnalysisWorkspace({ project, selectedTarget, expectedGeneration, client,
onOpenSource, onDiagnose, onClose, initialMode, loadChanges = loadChangeSet, loadSymbols = loadFileImpactSymbols, loadImpact,
}: ChangeAnalysisWorkspaceProps) {
const [mode, setMode] = useState<ChangeAnalysisMode>(initialMode ?? (selectedTarget?.filePath ? 'selection' : 'worktree'));
const [ref, setRef] = useState('HEAD');
const [appliedRef, setAppliedRef] = useState('HEAD');
const [refresh, setRefresh] = useState(0);
const [filter, setFilter] = useState('');
const [chosen, setChosen] = useState<{ owner: string; target: SelectionImpactTarget }>();
const [reading, setReading] = useState<{ key: string; data?: ChangeSetReading; error?: string; loading: boolean }>();
const [symbols, setSymbols] = useState<{ key: string; rows: SelectionImpactTarget[]; error?: string }>();
const workspace = useRef<HTMLElement>(null);
const selectionIdentity = JSON.stringify([project, selectedTarget?.filePath, selectedTarget?.qualifiedName, selectedTarget?.id]);
const base = mode === 'worktree' ? 'HEAD' : appliedRef;
const readingKey = JSON.stringify([project, mode, base, refresh]);
const ownedReading = reading?.key === readingKey ? reading : undefined;
const target = chosen?.owner === selectionIdentity ? chosen.target : selectedTarget;
const symbolKey = JSON.stringify([project, target?.filePath, expectedGeneration]);
const ownedSymbols = symbols?.key === symbolKey ? symbols : undefined;
useLayoutEffect(() => {
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : undefined;
(workspace.current?.querySelector<HTMLElement>('button') ?? workspace.current)?.focus();
return () => { if (previous?.isConnected) previous.focus(); };
}, []);
const keyboard = (event: KeyboardEvent<HTMLElement>) => {
if (event.key === 'Escape' && onClose) { event.preventDefault(); event.stopPropagation(); onClose(); return; }
if (event.key !== 'Tab' || !workspace.current) return;
const controls = [...workspace.current.querySelectorAll<HTMLElement>('button:not(:disabled), input, select, summary, [tabindex="0"]')]
.filter(element => element.getClientRects().length > 0 && !element.closest('[hidden]'));
const first = controls[0], last = controls.at(-1);
if (event.shiftKey && document.activeElement === first && last) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last && first) { event.preventDefault(); first.focus(); }
};
useEffect(() => {
setChosen(selectedTarget?.filePath ? { owner: selectionIdentity, target: selectedTarget } : undefined);
setFilter('');
}, [selectionIdentity]); // Stable identity, not a new callback-created prop object.
useEffect(() => {
if (mode === 'selection' || !project) return;
const controller = new AbortController();
setReading({ key: readingKey, loading: true });
void loadChanges(project, base, controller.signal).then(data => {
if (!controller.signal.aborted) setReading({ key: readingKey, data, loading: false });
}, error => {
if (!controller.signal.aborted) setReading({ key: readingKey, loading: false,
error: error instanceof Error ? error.message : String(error) });
});
return () => controller.abort();
}, [project, mode, base, refresh, loadChanges, readingKey]);
useEffect(() => {
if (!client || !target?.filePath) return;
let current = true;
const file = target.filePath;
void loadSymbols(client, project, file).then(rows => {
if (current) setSymbols({ key: symbolKey, rows });
}, error => {
if (current) setSymbols({ key: symbolKey, rows: [], error: error instanceof Error ? error.message : String(error) });
});
return () => { current = false; };
}, [client, project, target?.filePath, expectedGeneration, symbolKey, loadSymbols]);
const areas = useMemo(() => changedAreas((ownedReading?.data?.files ?? []).filter(file => file.toLowerCase().includes(filter.toLowerCase())),
selectedTarget?.filePath), [ownedReading?.data, filter, selectedTarget?.filePath]);
const select = (next: SelectionImpactTarget) => setChosen({ owner: selectionIdentity, target: next });
const selectedChanged = ownedReading?.data?.files.includes(target?.filePath ?? '');
return <section ref={workspace} tabIndex={-1} onKeyDown={keyboard} className="change-analysis" data-testid="change-analysis-workspace" aria-label="Change impact workspace">
<header className="change-analysis-header"><h2>Change impact</h2>
{onClose && <button type="button" onClick={onClose}>Close change impact</button>}</header>
<div className="change-analysis-toolbar">
<div role="tablist" aria-label="Change analysis scope">{([
['selection', 'Current selection'], ['worktree', 'Working tree'], ['since-ref', 'Since ref'],
] as const).map(([value, label]) => <button role="tab" type="button" key={value} aria-selected={mode === value}
onClick={() => setMode(value)}>{label}</button>)}</div>
{mode === 'since-ref' && <form onSubmit={event => { event.preventDefault(); if (validChangeRef(ref)) { setAppliedRef(ref); setRefresh(value => value + 1); } }}>
<label>Baseline revision <input value={ref} onChange={event => setRef(event.currentTarget.value)} placeholder="HEAD~1 or branch name" /></label>
<button type="submit" disabled={!validChangeRef(ref)}>Read changes</button></form>}
{mode !== 'selection' && <button type="button" onClick={() => setRefresh(value => value + 1)} disabled={ownedReading?.loading}>Refresh changes</button>}
<span>{project || 'No project selected'}</span>
</div>
<div className="change-analysis-columns" data-has-changes={mode !== 'selection'}>
{mode !== 'selection' && <aside className="change-analysis-files" aria-label="Changed files">
<h3>{mode === 'worktree' ? 'Uncommitted changes' : `Changes since ${appliedRef}`}</h3>
<p>{mode === 'worktree' ? 'Compared explicitly with HEAD; staged, unstaged and untracked paths are included.'
: 'The daemon compares the merge base with HEAD and includes current worktree changes.'}</p>
{(!ownedReading || ownedReading.loading) && <p role="status">Reading the local Git change set…</p>}
{ownedReading?.error && <p role="alert">Change set unavailable: {ownedReading.error} No low-risk result was inferred.</p>}
{ownedReading?.data && <>
<p><strong>{ownedReading.data.files.length} of {ownedReading.data.totalFiles} changed paths loaded</strong></p>
<details><summary>Change-set revision and limits</summary><p>Baseline {ownedReading.data.base} · merge base <code>{ownedReading.data.mergeBase}</code><br />Read {ownedReading.data.checkedAt}</p>
<p>Changes may continue while this view is open. Refresh before relying on the scope. File-level selection analyzes all indexed declarations, not only edited lines.</p>
{ownedReading.data.limitations.map(note => <p key={note}>{note}</p>)}</details>
{!ownedReading.data.complete && <p role="alert">The changed-file listing is incomplete. Missing files are not evidence of safety.</p>}
{ownedReading.data.totalFiles === 0 && <p>No changed paths were reported for this revision. Select a file from the repository to inspect its dependencies.</p>}
<label className="change-analysis-filter">Filter changed paths<input type="search" value={filter} onChange={event => setFilter(event.currentTarget.value)} /></label>
<p className="change-analysis-note">Areas are grouped by path and ordered by changed-file count, with the current selection first. These counts measure scope, not risk.</p>
{areas.map(area => <details className="change-analysis-area" key={area.path} open={areas.length <= 3 || area.files.includes(selectedTarget?.filePath ?? '')}>
<summary>{area.path} · {area.files.length} changed paths</summary>
{area.files.map(file => <button type="button" key={file} aria-pressed={target?.filePath === file}
onClick={() => select({ filePath: file, name: file.split('/').at(-1) })}>{file}</button>)}
</details>)}
{areas.length === 0 && ownedReading.data.totalFiles > 0 && <p>No changed path matches this filter.</p>}
</>}
</aside>}
<main className="change-analysis-evidence">
<div className="change-analysis-selection">
{selectedTarget?.filePath && <button type="button" onClick={() => select(selectedTarget)}>Use current editor selection</button>}
{target?.filePath ? <>
<strong>{target.filePath}</strong>
<label>Analysis scope <select aria-label="Analysis scope" value={target.qualifiedName ?? ''} onChange={event => {
const next = ownedSymbols?.rows.find(row => row.qualifiedName === event.currentTarget.value);
select(next ?? { filePath: target.filePath, name: target.filePath.split('/').at(-1) });
}}><option value="">Entire indexed file</option>
{target.qualifiedName && !ownedSymbols?.rows.some(row => row.qualifiedName === target.qualifiedName)
&& <option value={target.qualifiedName}>{target.name ?? target.qualifiedName}</option>}
{ownedSymbols?.rows.map(row => <option key={row.qualifiedName} value={row.qualifiedName}>{row.name} · line {row.line ?? '?'}</option>)}
</select></label>
<button type="button" onClick={() => onOpenSource(target)}>Open source evidence</button>
{client && !ownedSymbols && <small role="status">Reading indexed function and method choices…</small>}
{mode !== 'selection' && ownedReading?.data && <>
<span>{selectedChanged ? 'This file is in the detected change set.' : 'This selection is outside the loaded changed-file list.'}</span>
<small>{target.qualifiedName ? 'This symbol was selected manually; edited lines are not automatically mapped to declarations.'
: 'Whole-file impact includes unchanged declarations. Select a function or method to refine the graph scope.'}</small>
<details className="change-analysis-diff"><summary>Inspect this file’s local diff</summary>
<LocalEvidenceCommand command={localDiffCommand(ownedReading.data.mergeBase, target.filePath)} label="Copy local diff command" />
<small>Git diff shows tracked changes. For an untracked path, inspect its source and Git status.</small></details>
</>}
{ownedSymbols?.error && <p role="alert">Symbol choices unavailable: {ownedSymbols.error}. Whole-file analysis remains available.</p>}
{ownedSymbols && ownedSymbols.rows.length >= 200 && <small>Symbol choices are bounded to the first 200 functions and 200 methods.</small>}
</> : <p>Select a changed file or open a repository file to start the analysis.</p>}
</div>
<SelectionImpactWidget project={project} target={target} expectedGeneration={expectedGeneration}
onOpen={onOpenSource} onDiagnose={onDiagnose} load={loadImpact} compact />
</main>
</div>
</section>;
}
+7
View File
@@ -160,6 +160,13 @@ describe('ImpactPanel', () => {
}
});
it('does not display low risk when the rule inputs were not measured', async () => {
await render({ model: { ...MODEL, risk: 'low', completeness: { measured: 0, unmeasured: 2, total: 2 } } });
expect(at('atlas-impact-badge')?.textContent).toBe('RISK UNRESOLVED');
expect(at('atlas-impact-badge')?.getAttribute('data-level')).toBe('unresolved');
expect(container.textContent).toContain('Risk inputs are missing for 2 of 2 symbols');
});
it('draws the four lists and names the endpoints it reached', async () => {
await render();
expect(all('atlas-impact-direct-row')).toHaveLength(1);
+19 -8
View File
@@ -63,6 +63,10 @@ import {
IMPACT_TESTS_EMPTY,
IMPACT_TESTS_TITLE,
IMPACT_TITLE,
IMPACT_RISK_UNRESOLVED,
IMPACT_NO_ELEVATED_SIGNAL,
IMPACT_UNRESOLVED_TOOLTIP,
impactUnresolvedInputs,
RISK_LABELS,
impactEvidenceValue,
impactRiskTooltip,
@@ -162,6 +166,8 @@ export default function ImpactPanel(props: ImpactPanelProps): JSX.Element {
const tiles = model?.summaryTiles;
const rules = model === undefined ? [] : badgeRules(model);
const testCount = (model?.tests.covering.length ?? 0) + (model?.tests.missing.length ?? 0);
const unresolved = model?.risk === 'low'
&& (model.completeness.total === 0 || model.completeness.unmeasured > 0);
return (
<div
@@ -171,7 +177,7 @@ export default function ImpactPanel(props: ImpactPanelProps): JSX.Element {
aria-label={IMPACT_TITLE}
data-mode={props.mode}
data-status={props.status}
data-badge={model?.risk ?? ''}
data-badge={unresolved ? 'unresolved' : model?.risk ?? ''}
data-tiles={tiles === undefined ? 0 : 5 + tiles.indirect.length}
data-direct={model?.direct.length ?? 0}
data-downstream={model?.downstream.reduce((sum, group) => sum + group.symbols.length, 0) ?? 0}
@@ -292,17 +298,22 @@ export default function ImpactPanel(props: ImpactPanelProps): JSX.Element {
</div>
<div className="atlas-impact-badge-row">
<Hint name="impact-badge" text={IMPACT_BADGE_TOOLTIP}>
<Hint name="impact-badge" text={unresolved
? IMPACT_UNRESOLVED_TOOLTIP
: IMPACT_BADGE_TOOLTIP}>
<span
className="atlas-impact-badge"
data-testid="atlas-impact-badge"
data-level={model.risk}
data-level={unresolved ? 'unresolved' : model.risk}
>
{RISK_LABELS[model.risk]}
{unresolved ? IMPACT_RISK_UNRESOLVED : model.risk === 'low' ? IMPACT_NO_ELEVATED_SIGNAL : RISK_LABELS[model.risk]}
</span>
</Hint>
<div className="atlas-impact-badge-why">
<span className="atlas-impact-eyebrow">{IMPACT_BADGE_WHY_TITLE}</span>
{unresolved && <p className="atlas-impact-rule" role="status">
{impactUnresolvedInputs(model.completeness.unmeasured, model.completeness.total)}
</p>}
{rules.length === 0 ? (
<p className="atlas-impact-rule" data-testid="atlas-impact-badge-rule">
{IMPACT_BADGE_WHY_NONE}
@@ -361,8 +372,8 @@ export default function ImpactPanel(props: ImpactPanelProps): JSX.Element {
</RowButton>
<span className="atlas-impact-where">{row.filePath ?? ''}</span>
<Hint name="impact-risk" text={impactRiskTooltip(row.risk, row.reasons)}>
<span className="atlas-impact-chip" data-level={row.risk}>
{RISK_LABELS[row.risk]}
<span className="atlas-impact-chip" data-level={row.risk === 'low' && unresolved ? 'unresolved' : row.risk}>
{row.risk === 'low' ? IMPACT_NO_ELEVATED_SIGNAL : RISK_LABELS[row.risk]}
</span>
</Hint>
</li>
@@ -403,9 +414,9 @@ export default function ImpactPanel(props: ImpactPanelProps): JSX.Element {
>
<span
className="atlas-impact-chip"
data-level={row.risk}
data-level={row.risk === 'low' && unresolved ? 'unresolved' : row.risk}
>
{RISK_LABELS[row.risk]}
{row.risk === 'low' ? IMPACT_NO_ELEVATED_SIGNAL : RISK_LABELS[row.risk]}
</span>
</Hint>
</li>
@@ -0,0 +1,20 @@
import { useState } from 'react';
/** Clipboard writes occur only on the explicit button click. Nothing is executed or sent. */
export default function LocalEvidenceCommand({ command, label = 'Copy local command' }: { command: string; label?: string }) {
const [result, setResult] = useState<{ command: string; message: string }>();
const copy = async () => {
try {
if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable. Select the command below and copy it manually.');
await navigator.clipboard.writeText(command);
setResult({ command, message: 'Command copied locally. Nothing was executed or sent.' });
} catch (error) {
setResult({ command, message: error instanceof Error ? error.message : 'Could not copy. Select the command and copy it manually.' });
}
};
return <div className="local-evidence-command">
<button type="button" disabled={!command} onClick={() => { void copy(); }}>{label}</button>
<code tabIndex={0}>{command}</code>
{result?.command === command && <span role="status">{result.message}</span>}
</div>;
}
@@ -0,0 +1,181 @@
import { useEffect, useId, useState } from 'react';
import type { JSX } from 'react';
import { evidenceTarget, fetchSelectionImpact, localCommitCommand, selectionRisk } from './selection-impact';
import type { SelectionImpact, SelectionImpactTarget } from './selection-impact';
import LocalEvidenceCommand from './LocalEvidenceCommand';
import './selection-impact.css';
export interface SelectionImpactWidgetProps {
project: string;
target?: SelectionImpactTarget;
onOpen: (target: SelectionImpactTarget) => void;
onDiagnose?: () => void;
/** Shared repository-map snapshot, when available. */
expectedGeneration?: string;
/** The shared workspace already displays file and symbol controls. */
compact?: boolean;
/** Test seam; production always reads the same daemon origin. */
load?: typeof fetchSelectionImpact;
}
function stamp(value: number): string {
return new Date(value * 1000).toLocaleString();
}
export default function SelectionImpactWidget({ project, target, onOpen, onDiagnose,
expectedGeneration, compact = false, load = fetchSelectionImpact }: SelectionImpactWidgetProps): JSX.Element {
const [reading, setReading] = useState<{ key: string; data: SelectionImpact }>();
const [error, setError] = useState('');
const [state, setState] = useState('idle');
const [refresh, setRefresh] = useState(0);
const [findingLimit, setFindingLimit] = useState(8);
const [historyLimit, setHistoryLimit] = useState(5);
const [tab, setTab] = useState('dependencies');
const panelId = useId();
const file = target?.filePath, qn = target?.qualifiedName, id = target?.id;
const selectionKey = JSON.stringify([project, file, qn, id, expectedGeneration]);
// A prop change can paint before its effect runs: never label an older
// result with the new selection, including two symbols in the same file.
const data = reading?.key === selectionKey ? reading.data : undefined;
useEffect(() => {
setReading(undefined); setError(''); setState('idle');
setFindingLimit(8); setHistoryLimit(5); setTab('dependencies');
if (!project || !file) return;
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
let attempts = 0;
const selection = { filePath: file, qualifiedName: qn, id };
const read = async (): Promise<void> => {
try {
setState('loading');
const reply = await load(project, selection, controller.signal, refresh > 0 && attempts === 0);
if (controller.signal.aborted) return;
attempts++;
if (reply.status === 'ready') { setReading({ key: selectionKey, data: reply }); setState('ready'); }
else if (reply.status === 'failed') { setError(reply.error); setState('failed'); }
else if (attempts < 80) {
setState(reply.status);
timer = setTimeout(() => { void read(); }, 500);
} else { setError('Analysis is taking longer than expected. Refresh to retry.'); setState('failed'); }
} catch (cause) {
if (!controller.signal.aborted) {
setError(cause instanceof Error ? cause.message : String(cause)); setState('failed');
}
}
};
void read();
return () => { controller.abort(); if (timer !== undefined) clearTimeout(timer); };
}, [project, file, qn, id, refresh, load, expectedGeneration, selectionKey]);
const risk = data ? selectionRisk(data) : undefined;
return <section className="selection-impact" data-testid="selection-impact" data-status={state}
aria-label="Selection impact analysis">
{!compact && <header><div><h3>Selection evidence</h3></div>
{file && <button type="button" onClick={() => setRefresh(value => value + 1)}>Refresh analysis</button>}
</header>}
{compact && file && !data && <div><button type="button" onClick={() => setRefresh(value => value + 1)}>Refresh analysis</button></div>}
{!file && <p>Select an indexed file or symbol to inspect its dependency paths and local Git evidence.</p>}
{file && !compact && <p className="selection-impact-scope"><strong>{target?.name ?? file}</strong><br />{file}
{(qn || id !== undefined) ? ' · selected symbol' : ' · whole file'}</p>}
{(state === 'loading' || state === 'pending' || state === 'busy')
&& <p role="status">{state === 'busy' ? 'Waiting for the current local analysis…' : 'Reading the graph snapshot and bounded local Git history…'}</p>}
{error && <p role="alert">Impact evidence unavailable: {error} Risk remains unresolved.</p>}
{data && risk && <>
{expectedGeneration && expectedGeneration !== data.snapshot.generation
&& <p role="alert">The repository map and this impact analysis use different index generations.
Refresh the map and analysis before comparing their findings.</p>}
<div className="selection-impact-risk" data-level={risk.level}>
<div className="selection-impact-risk-heading"><strong>{risk.label}</strong>
{compact ? <button type="button" onClick={() => setRefresh(value => value + 1)}>Refresh analysis</button>
: <span>Heuristic, no defect probability</span>}</div>
{compact && <small>Transparent heuristic, no defect probability</small>}
<ul>{risk.reasons.map(reason => <li key={reason}>{reason}</li>)}</ul>
<button type="button" className="selection-impact-quality-link" onClick={() => setTab('quality')}>{risk.uncertainties.length} data limitations · inspect before relying on absence of findings</button>
</div>
<nav className="selection-impact-tabs" aria-label="Impact evidence" role="tablist">
{([['dependencies', `Dependencies · ${data.structural.reachable}`],
['tests', `Tests to review · ${data.structural.test_candidates}`],
['history', `Git history · ${data.history.cochanges.length}`],
['quality', `Data quality · ${risk.uncertainties.length}`]] as const).map(([value, label]) =>
<button type="button" role="tab" key={value} id={`${panelId}-${value}-tab`}
aria-controls={`${panelId}-${value}`} aria-selected={tab === value} onClick={() => setTab(value)}>{label}</button>)}
</nav>
<div className="selection-impact-panels">
<section className="selection-impact-quality" role="tabpanel" id={`${panelId}-quality`}
aria-labelledby={`${panelId}-quality-tab`} hidden={tab !== 'quality'}>
<h4>Data quality and revision · {risk.uncertainties.length} limitations</h4>
<p>Computed {stamp(data.computed_at)} · cached for up to {data.cache_seconds}s.<br />
Index: {data.snapshot.indexed_at || 'unknown timestamp'} · generation <code>{data.snapshot.generation || 'unknown'}</code><br />
Git HEAD: <code>{data.history.head ?? 'unavailable'}</code><br />
Scope: {data.scope === 'symbol' ? 'selected symbol; Git evidence is file-level' : 'selected file, bounded indexed declarations'}.<br />
Graph scope: {data.structural.seed_count} starting nodes · at most {data.structural.visit_cap} visited nodes · {data.structural.result_cap} returned findings.</p>
<ul>{risk.uncertainties.map(reason => <li key={reason}>{reason}</li>)}</ul>
{onDiagnose && <button type="button" onClick={onDiagnose}>Inspect local indexing diagnostics</button>}
</section>
<section role="tabpanel" id={`${panelId}-dependencies`} aria-labelledby={`${panelId}-dependencies-tab`} hidden={tab !== 'dependencies'}><h4>Structural impact · indexed dependencies</h4>
<p>{data.structural.direct} direct · {data.structural.reachable} total dependents within {data.structural.max_depth} hops.
{' '}CALLS means a static call relation; IMPORTS means an import dependency. Neither proves runtime execution or data flow.</p>
{data.structural.findings.length === 0 && <p>No dependency path was established for this selection in the current bounded index. This is not evidence of safety.</p>}
<p>Showing {Math.min(findingLimit, data.structural.findings.length)} of {data.structural.findings.length} returned evidence paths.</p>
<ol className="selection-impact-findings">{data.structural.findings.slice(0, findingLimit).map(finding => <li key={finding.id}>
<button type="button" onClick={() => onOpen(evidenceTarget(finding))}>{finding.name}</button>
<span>{finding.distance === 1 ? 'direct dependency' : `${finding.distance} hops`}{finding.test_candidate ? ' · test candidate (path heuristic)' : ''}</span>
<small>{finding.file_path}:{finding.line}</small>
<details><summary>Show indexed path ({finding.path.length} edge{finding.path.length === 1 ? '' : 's'})</summary>
<ol>{finding.path.map(edge => <li key={edge.edge_id}>
<button type="button" onClick={() => onOpen(evidenceTarget(edge.from))}>{edge.from.name}</button>
{' '}<code>{edge.type}</code>{' '}
<button type="button" onClick={() => onOpen(evidenceTarget(edge.to))}>{edge.to.name}</button>
<small>Graph edge #{edge.edge_id} · source {edge.from.file_path}:{edge.from.line}</small>
</li>)}</ol>
</details>
</li>)}</ol>
{findingLimit < data.structural.findings.length && <button type="button"
onClick={() => setFindingLimit(value => value + 8)}>
Show more affected symbols ({findingLimit} of {data.structural.findings.length})
</button>}
</section>
<section role="tabpanel" id={`${panelId}-history`} aria-labelledby={`${panelId}-history-tab`} hidden={tab !== 'history'}><h4>Historical evidence · files changed together</h4>
<p>{data.history.selection_commits} ordinary commits touched this file in a sample of {data.history.commits_scanned} commits,
up to {data.history.window_days} days. Excluded: {data.history.merges_excluded} merges and {data.history.mass_changes_excluded} changes
touching more than {data.history.mass_change_threshold} files. Rename history is not followed.</p>
<p>Co-changes are historical association, not functional dependency or evidence of a later defect.</p>
{data.history.cochanges.length === 0 && <p>No co-change evidence is available in this sample.</p>}
{data.history.cochanges.length > 0 && <p>Showing {Math.min(historyLimit, data.history.cochanges.length)} of {data.history.cochanges.length} returned files.
{data.history.cochanges_omitted > 0 && ` ${data.history.cochanges_omitted} further co-change files were omitted by the server limit.`}</p>}
<ul className="selection-impact-history">{data.history.cochanges.slice(0, historyLimit).map(row => <li key={row.file_path}>
<button type="button" onClick={() => onOpen({ filePath: row.file_path, name: row.file_path })}>{row.file_path}</button>
{' '}· {row.shared_commits} shared commit{row.shared_commits === 1 ? '' : 's'}
<details><summary>Inspect local Git evidence</summary>
{row.commit_refs.map(hash => {
const commit = data.history.commits.find(commit => commit.hash === hash);
return <div className="selection-impact-commit" key={hash}><p><code>{hash}</code>{commit && <> · {stamp(commit.time)}<br />{commit.subject} · {commit.files} changed files</>}</p>
<LocalEvidenceCommand command={localCommitCommand(hash, data.file_path, row.file_path)} label="Copy local Git evidence command" /></div>;
})}
</details>
</li>)}</ul>
{historyLimit < data.history.cochanges.length && <button type="button"
onClick={() => setHistoryLimit(value => value + 5)}>
Show more historical associations ({historyLimit} of {data.history.cochanges.length})
</button>}
</section>
<section role="tabpanel" id={`${panelId}-tests`} aria-labelledby={`${panelId}-tests-tab`} hidden={tab !== 'tests'}>
<h4>Tests to review</h4>
<p>{data.structural.test_candidates} test candidates reach the selection in the bounded graph walk. File-path classification is heuristic; a test relationship does not prove assertion coverage.</p>
{data.structural.test_candidates === 0 && <p>No test dependency was established. Search the test directories and add a focused regression check; missing evidence is not a passing test.</p>}
<ul className="selection-impact-tests">{data.structural.findings.filter(finding => finding.test_candidate).map(finding => <li key={finding.id}>
<button type="button" onClick={() => onOpen(evidenceTarget(finding))}>Open test source: {finding.name}</button>
<small>{finding.file_path}:{finding.line} · {finding.distance} indexed hop{finding.distance === 1 ? '' : 's'}</small>
<details><summary>Inspect test dependency path</summary><ol>{finding.path.map(edge => <li key={edge.edge_id}>
<button type="button" onClick={() => onOpen(evidenceTarget(edge.from))}>{edge.from.name}</button>{' '}
<code>{edge.type}</code>{' '}<button type="button" onClick={() => onOpen(evidenceTarget(edge.to))}>{edge.to.name}</button>
<small>Graph edge #{edge.edge_id} · source {edge.from.file_path}:{edge.from.line}</small>
</li>)}</ol></details>
</li>)}</ul>
{data.structural.test_candidates > data.structural.findings.filter(finding => finding.test_candidate).length
&& <p role="alert">Some test candidates are outside the returned evidence limit. Refine the selection to inspect their paths.</p>}
<h4>Next checks</h4><ol>{risk.next.map(next => <li key={next}>{next}</li>)}</ol>
</section>
</div>
</>}
</section>;
}
+30
View File
@@ -0,0 +1,30 @@
.change-analysis { display: flex; flex-direction: column; width: 100%; height: 100%; min-height: 0; color: var(--atlas-fg); background: var(--atlas-panel); font: 13px/1.5 var(--atlas-ui-font); }
.change-analysis-header { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 12px 20px; border-bottom: 1px solid var(--atlas-line); }
.change-analysis h2 { margin: 0; font-size: 22px; }.change-analysis h3 { margin: 0; font-size: 16px; }.change-analysis p { margin: 5px 0; }
.change-analysis button, .change-analysis input, .change-analysis select { font: inherit; color: inherit; border: 1px solid var(--atlas-line); border-radius: 4px; padding: 6px 9px; background: var(--atlas-panel-raised); }
.change-analysis button { cursor: pointer; }.change-analysis button:hover { background: var(--atlas-select); }.change-analysis button:focus-visible, .change-analysis select:focus-visible, .change-analysis input:focus-visible { outline: 2px solid var(--atlas-cyan); outline-offset: 2px; }
.change-analysis button[aria-selected=true], .change-analysis button[aria-pressed=true] { border-color: var(--atlas-phosphor); background: var(--atlas-select); }.change-analysis button:disabled { opacity: .5; cursor: default; }
.change-analysis-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 10px 20px; border-bottom: 1px solid var(--atlas-line); }.change-analysis-toolbar [role=tablist], .change-analysis-toolbar form { display: flex; gap: 6px; align-items: center; }
.change-analysis-toolbar label { display: flex; gap: 8px; align-items: center; }.change-analysis-toolbar input { max-width: 220px; }
.change-analysis-columns { display: grid; grid-template-columns: minmax(245px, 30%) minmax(0, 1fr); flex: 1; min-height: 0; overflow: hidden; }
.change-analysis-columns[data-has-changes=false] { grid-template-columns: minmax(0, 1fr); }
.change-analysis-files { padding: 16px; overflow: auto; min-height: 0; border-right: 1px solid var(--atlas-line); overflow-wrap: anywhere; }.change-analysis-files details { margin-top: 10px; }.change-analysis summary { cursor: pointer; padding: 5px 0; }
.change-analysis-filter { display: grid; margin: 14px 0 8px; gap: 4px; }.change-analysis-filter input { min-width: 0; width: 100%; box-sizing: border-box; }
.change-analysis-note, .change-analysis small { color: var(--atlas-dim); font-size: 11px; }
.change-analysis-area { border-top: 1px solid var(--atlas-line); }.change-analysis-area summary { font-weight: 600; }.change-analysis-area > button { display: block; width: 100%; text-align: left; margin: 4px 0; overflow-wrap: anywhere; }
.change-analysis-evidence { min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
.change-analysis-selection { padding: 12px 18px; border-bottom: 1px solid var(--atlas-line); display: flex; flex-wrap: wrap; align-items: center; gap: 7px 12px; }
.change-analysis-selection > strong { flex-basis: 100%; overflow-wrap: anywhere; }.change-analysis-selection label { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; }.change-analysis-selection select { max-width: min(100%, 520px); }
.change-analysis-evidence > .selection-impact { flex: 1; min-height: 0; overflow: hidden; }
.change-analysis [role=alert] { color: var(--atlas-alarm); padding: 8px; border-left: 3px solid var(--atlas-alarm); }
@media (max-width: 820px) { .change-analysis-columns { grid-template-columns: minmax(180px, 34%) minmax(0, 1fr); }.change-analysis-header, .change-analysis-toolbar { padding: 10px; }.change-analysis-selection { padding: 8px; }.change-analysis-toolbar form { flex-wrap: wrap; } }
@media (max-width: 600px) { .change-analysis-columns { display: flex; flex-direction: column; }.change-analysis-files { flex: 0 0 25%; max-height: 25%; border-right: 0; border-bottom: 1px solid var(--atlas-line); }.change-analysis-evidence { flex: 1; }.change-analysis-toolbar input { max-width: 150px; }.change-analysis-selection select { max-width: 260px; } }
.change-analysis-diff { flex-basis: 100%; }.change-analysis:focus { outline: none; }
/* Short and phone-sized windows scroll the evidence column as one unit;
fixed controls must never squeeze the actual findings to zero height. */
@media (max-width: 600px), (max-height: 650px) {
.change-analysis-evidence { overflow: auto; display: block; }
.change-analysis-evidence > .selection-impact { min-height: 320px; overflow: visible; }
.change-analysis .selection-impact-panels { overflow: visible; flex: none; }
}
@@ -0,0 +1,97 @@
// @vitest-environment jsdom
import { act, useLayoutEffect } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ChangeAnalysisWorkspace from './ChangeAnalysisWorkspace';
import LocalEvidenceCommand from './LocalEvidenceCommand';
import type { ChangeSetReading, loadChangeSet } from './change-set';
import type { SelectionImpactReply, fetchSelectionImpact } from './selection-impact';
let container: HTMLDivElement, root: Root;
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container);
});
afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.restoreAllMocks(); });
const pendingImpact = async (): Promise<SelectionImpactReply> => new Promise(() => {});
const reading = (project = 'fixture'): ChangeSetReading => ({ project, base: 'HEAD', mergeBase: 'a'.repeat(40),
checkedAt: '2026-09-09T10:00:00Z', files: ['src/fixture.c'], totalFiles: 1, complete: true, pages: 1, limitations: [] });
const click = async (label: string) => { await act(async () => [...container.querySelectorAll('button')].find(button => button.textContent === label)!.click()); };
describe('shared change impact workspace', () => {
it('focuses the analysis on entry and restores its initiating control on close', async () => {
const trigger = document.createElement('button'); document.body.appendChild(trigger); trigger.focus();
const close = vi.fn();
await act(async () => root.render(<ChangeAnalysisWorkspace project="fixture" selectedTarget={{ filePath: 'fixture.c' }}
onOpenSource={vi.fn()} onClose={close} loadImpact={pendingImpact} />));
expect(document.activeElement?.textContent).toBe('Close change impact');
await act(async () => document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })));
expect(close).toHaveBeenCalledOnce();
await act(async () => root.render(null));
expect(document.activeElement).toBe(trigger);
trigger.remove();
});
it('starts with actual changes when no editor selection exists, then opens source without leaving the analysis', async () => {
const loadChanges = vi.fn<typeof loadChangeSet>(async () => reading()), open = vi.fn();
const loadImpact = vi.fn<typeof fetchSelectionImpact>(pendingImpact);
await act(async () => root.render(<ChangeAnalysisWorkspace project="fixture" onOpenSource={open} loadChanges={loadChanges} loadImpact={loadImpact} />));
expect(loadChanges.mock.calls[0]?.[0]).toBe('fixture');
expect(container.textContent).toContain('1 of 1 changed paths loaded');
expect(loadImpact).not.toHaveBeenCalled();
await click('src/fixture.c');
expect(loadImpact.mock.calls[0]?.[1]).toMatchObject({ filePath: 'src/fixture.c' });
await click('Open source evidence');
expect(open).toHaveBeenCalledWith({ filePath: 'src/fixture.c', name: 'fixture.c' });
expect(container.querySelector('[data-testid="change-analysis-workspace"]')).not.toBeNull();
expect(container.textContent).toContain('This file is in the detected change set.');
expect(container.textContent).toContain('not only edited lines');
});
it('keeps incomplete change-set errors visible instead of making a low-risk claim', async () => {
const data = { ...reading(), totalFiles: 6, complete: false, limitations: ['Cursor unavailable'] };
await act(async () => root.render(<ChangeAnalysisWorkspace project="fixture" onOpenSource={vi.fn()}
loadChanges={async () => data} loadImpact={pendingImpact} />));
expect(container.textContent).toContain('1 of 6 changed paths loaded');
expect(container.querySelector('[role="alert"]')?.textContent).toContain('incomplete');
});
it('does not paint a previous selection under changed props, even before passive effects', async () => {
const captures: string[] = [];
function Observe() { useLayoutEffect(() => { captures.push(container.textContent ?? ''); }); return null; }
const props = { project: 'fixture', onOpenSource: vi.fn(), loadImpact: pendingImpact };
await act(async () => root.render(<><ChangeAnalysisWorkspace {...props} selectedTarget={{ filePath: 'old.c' }} /><Observe /></>));
captures.length = 0;
await act(async () => root.render(<><ChangeAnalysisWorkspace {...props} selectedTarget={{ filePath: 'new.c' }} /><Observe /></>));
expect(captures[0]).toContain('new.c');
expect(captures[0]).not.toContain('old.c');
});
it('does not show a late change-set response from another project', async () => {
let resolveOld!: (value: ChangeSetReading) => void;
const old = new Promise<ChangeSetReading>(resolve => { resolveOld = resolve; });
const loadChanges = vi.fn().mockReturnValueOnce(old).mockImplementationOnce(() => new Promise(() => {}));
const props = { onOpenSource: vi.fn(), loadChanges, loadImpact: pendingImpact };
await act(async () => root.render(<ChangeAnalysisWorkspace {...props} project="old-project" />));
await act(async () => root.render(<ChangeAnalysisWorkspace {...props} project="new-project" />));
await act(async () => resolveOld(reading('old-project')));
expect(container.textContent).not.toContain('src/fixture.c');
expect(loadChanges.mock.calls[0]?.[2].aborted).toBe(true);
});
});
describe('local Git evidence action', () => {
it('copies only after a deliberate click and displays the exact local command', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
await act(async () => root.render(<LocalEvidenceCommand command="git show aaaa -- 'fixture.c'" />));
expect(writeText).not.toHaveBeenCalled();
await click('Copy local command');
expect(writeText).toHaveBeenCalledWith("git show aaaa -- 'fixture.c'");
expect(container.textContent).toContain('Nothing was executed or sent');
});
it('reports clipboard rejection without claiming success and keeps a manual copy fallback', async () => {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn().mockRejectedValue(new Error('Clipboard denied')) } });
await act(async () => root.render(<LocalEvidenceCommand command="git diff HEAD -- 'fixture.c'" />));
await click('Copy local command');
expect(container.textContent).toContain('Clipboard denied');
expect(container.textContent).not.toContain('Command copied');
expect(container.querySelector('code')?.textContent).toBe("git diff HEAD -- 'fixture.c'");
});
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from 'vitest';
import { changedAreas, loadChangeSet, loadFileImpactSymbols, localDiffCommand, validChangeRef } from './change-set';
const page = (files: string[], total = files.length, extra = {}) => ({ base: 'HEAD', merge_base: 'a'.repeat(40),
changed_files: files, changed_total: total, changed_has_more: false, ...extra });
describe('bounded local change-set reading', () => {
it('uses an explicit HEAD baseline and files-only scope instead of the absent default main branch', async () => {
const read = vi.fn().mockResolvedValue(page(['src/fixture.c']));
expect(await loadChangeSet('fixture', 'HEAD', undefined, read)).toMatchObject({ files: ['src/fixture.c'], complete: true });
expect(read.mock.calls[0]?.[0]).toMatchObject({ project: 'fixture', since: 'HEAD', scope: 'files', module_limit: 0, format: 'json' });
});
it('follows snapshot cursors and accounts for every changed path', async () => {
const read = vi.fn().mockResolvedValueOnce(page(['a.c'], 2, { changed_has_more: true, changed_next_cursor: 'snapshot-page2' }))
.mockResolvedValueOnce(page(['b.c'], 2));
const data = await loadChangeSet('fixture', 'HEAD', undefined, read);
expect(data).toMatchObject({ files: ['a.c', 'b.c'], totalFiles: 2, pages: 2, complete: true });
expect(read.mock.calls[1]?.[0].changed_cursor).toBe('snapshot-page2');
expect(read.mock.calls[1]?.[0].changed_offset).toBeUndefined();
});
it('reports stalled or unavailable continuation honestly and does not infer an empty change set', async () => {
const read = vi.fn().mockResolvedValue(page([], 20, { changed_has_more: true, changed_continuation_requires_higher_budget: true }));
const data = await loadChangeSet('fixture', 'HEAD', undefined, read);
expect(read).toHaveBeenCalledTimes(2);
expect(data).toMatchObject({ complete: false, files: [], totalFiles: 20 });
expect(data.limitations.join(' ')).toContain('progressing snapshot cursor');
});
it('rejects a moved snapshot, malformed response and unsafe baseline before presenting findings', async () => {
const read = vi.fn().mockResolvedValueOnce(page(['a.c'], 2, { changed_has_more: true, changed_next_cursor: 'page2' }))
.mockResolvedValueOnce(page(['b.c'], 3));
await expect(loadChangeSet('fixture', 'HEAD', undefined, read)).rejects.toThrow('moved');
await expect(loadChangeSet('fixture', 'HEAD', undefined, async () => ({ changed_files: [] }))).rejects.toThrow('incomplete');
const unused = vi.fn();
await expect(loadChangeSet('fixture', '--exec=anything', undefined, unused)).rejects.toThrow('valid local Git revision');
expect(unused).not.toHaveBeenCalled();
});
it('rejects absolute and traversal paths and accounts for a mismatching final total', async () => {
await expect(loadChangeSet('fixture', 'HEAD', undefined, async () => page(['../private']))).rejects.toThrow('repository-relative');
const data = await loadChangeSet('fixture', 'HEAD', undefined, async () => page(['a.c'], 2));
expect(data.complete).toBe(false);
expect(data.limitations.join(' ')).toContain('reported total');
});
it('keeps path-area navigation separate from any risk score and prefers the selected file', () => {
const areas = changedAreas(['src/ui/a.c', 'src/ui/b.c', 'src/daemon/a.c', 'README.md'], 'src/daemon/a.c');
expect(areas.map(area => area.path)).toEqual(['src/daemon', 'src/ui', '(root)']);
expect(areas[1]?.files).toHaveLength(2);
});
it('reads actual indexed declarations and never re-labels them as changed lines', async () => {
const client = { queryRows: vi.fn().mockResolvedValueOnce([
{ 'n.qualified_name': 'fixture.a', 'n.name': 'a', 'n.file_path': 'a.c', 'n.start_line': '10' },
{ 'n.qualified_name': 'unrelated', 'n.file_path': 'b.c' },
]).mockResolvedValueOnce([]) };
expect(await loadFileImpactSymbols(client, 'fixture', 'a.c')).toEqual([{ qualifiedName: 'fixture.a', name: 'a', filePath: 'a.c', line: 10 }]);
expect(client.queryRows.mock.calls[0]?.[1]).toContain('LIMIT 200');
});
it('provides safely quoted local commands without executing them', () => {
expect(validChangeRef('HEAD~3')).toBe(true);
expect(validChangeRef('feature/fix')).toBe(true);
expect(validChangeRef('HEAD;curl secret')).toBe(false);
expect(localDiffCommand('HEAD', "it's $(unsafe).c")).toBe("git diff 'HEAD' -- 'it'\\''s $(unsafe).c'");
});
});
+113
View File
@@ -0,0 +1,113 @@
import { callToolJson } from '../provider/rpc-transport';
import type { SelectionImpactTarget } from './selection-impact';
export interface ChangeSetReading {
project: string;
base: string;
mergeBase: string;
checkedAt: string;
files: string[];
totalFiles: number;
complete: boolean;
pages: number;
limitations: string[];
}
export type ChangeSetRequest = (args: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown>;
const request: ChangeSetRequest = (args, signal) => callToolJson('detect_changes', args, { signal });
const object = (value: unknown): Record<string, unknown> => value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown> : {};
export function validChangeRef(value: string): boolean {
// A local revision, never shell syntax or a Git option. The daemon also validates it.
return value.length > 0 && value.length <= 200 && /^[A-Za-z0-9_][A-Za-z0-9_./~^@{}+-]*$/.test(value);
}
/** Read file pages separately from graph impact so one cannot consume the other's budget. */
export async function loadChangeSet(project: string, base: string, signal?: AbortSignal,
read: ChangeSetRequest = request): Promise<ChangeSetReading> {
if (!project || !validChangeRef(base)) throw new Error('Choose a project and a valid local Git revision.');
const files = new Set<string>();
let cursor: string | undefined;
const cursors = new Set<string>();
let mergeBase = '', total = -1, complete = false, pages = 0;
let budget = 12000;
const limitations: string[] = [];
while (pages < 8 && files.size < 4000) {
const payload = object(await read({ project, since: base, scope: 'files', direction: 'inbound',
changed_limit: 500, limit: 1, module_limit: 0, max_output_tokens: budget, format: 'json',
...(cursor ? { changed_cursor: cursor } : {}) }, signal));
if (signal?.aborted) throw signal.reason;
if (payload.base !== base || typeof payload.merge_base !== 'string' || !Array.isArray(payload.changed_files)
|| !Number.isSafeInteger(payload.changed_total) || Number(payload.changed_total) < 0
|| typeof payload.changed_has_more !== 'boolean') {
throw new Error('The daemon returned an incomplete change-set response. No empty or safe result was inferred.');
}
if (total >= 0 && (total !== payload.changed_total || mergeBase !== payload.merge_base)) {
throw new Error('The Git change set moved while its pages were loading. Refresh to read one snapshot.');
}
total = Number(payload.changed_total); mergeBase = payload.merge_base;
const entries = payload.changed_files;
if (entries.some(file => typeof file !== 'string' || !file || file.startsWith('/') || file.split('/').includes('..'))) {
throw new Error('The daemon returned an invalid repository-relative changed path.');
}
if (entries.length === 0 && payload.changed_has_more && payload.changed_continuation_requires_higher_budget && budget < 24000) {
budget = 24000; continue;
}
pages += 1;
const previousSize = files.size;
for (const file of entries as string[]) files.add(file);
if (!payload.changed_has_more) {
complete = files.size === total;
if (!complete) limitations.push('The returned file pages do not account for the reported total.');
break;
}
const next = payload.changed_next_cursor;
if (typeof next !== 'string' || !next || cursors.has(next) || files.size === previousSize) {
limitations.push('The daemon could not provide a progressing snapshot cursor for the remaining changed files.');
break;
}
cursors.add(next); cursor = next;
}
if (!complete && limitations.length === 0) limitations.push('Changed-file reading reached its limit of 8 pages / 4,000 paths.');
return { project, base, mergeBase, checkedAt: new Date().toISOString(), files: [...files], totalFiles: total,
complete, pages, limitations };
}
export interface ChangedArea { path: string; files: string[] }
/** Navigation groups from recorded paths; change volume is not a risk score. */
export function changedAreas(files: readonly string[], preferredFile?: string): ChangedArea[] {
const areas = new Map<string, string[]>();
for (const file of files) {
const parts = file.split('/');
const area = parts.length > 2 ? parts.slice(0, 2).join('/') : parts.length > 1 ? parts[0] : '(root)';
const group = areas.get(area) ?? []; group.push(file); areas.set(area, group);
}
return [...areas].map(([path, entries]) => ({ path, files: entries.sort((a, b) =>
Number(b === preferredFile) - Number(a === preferredFile) || a.localeCompare(b)) }))
.sort((a, b) => Number(b.files.includes(preferredFile ?? '')) - Number(a.files.includes(preferredFile ?? ''))
|| b.files.length - a.files.length || a.path.localeCompare(b.path));
}
export interface ChangeSymbolSource {
queryRows(project: string, query: string): Promise<Record<string, string>[]>;
}
export async function loadFileImpactSymbols(client: ChangeSymbolSource, project: string, filePath: string): Promise<SelectionImpactTarget[]> {
const results = await Promise.all(['Function', 'Method'].map(label => client.queryRows(project,
`MATCH (n:${label}) WHERE n.file_path = ${JSON.stringify(filePath)} RETURN n.name, n.qualified_name, n.file_path, n.start_line LIMIT 200`)));
const unique = new Map<string, SelectionImpactTarget>();
for (const row of results.flat()) {
const qualifiedName = row['n.qualified_name'];
if (!qualifiedName || row['n.file_path'] !== filePath) continue;
unique.set(qualifiedName, { name: row['n.name'], qualifiedName, filePath,
...(Number(row['n.start_line']) > 0 ? { line: Number(row['n.start_line']) } : {}) });
}
return [...unique.values()].sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
}
export function localDiffCommand(base: string, filePath: string): string {
if (!validChangeRef(base)) return '';
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
return `git diff ${quote(base)} -- ${quote(filePath)}`;
}
+8
View File
@@ -171,6 +171,14 @@ export function impactEvidenceValue(
/** Die Ueberschrift der Flaeche. */
export const IMPACT_TITLE = 'What would this change reach?';
export const IMPACT_RISK_UNRESOLVED = 'RISK UNRESOLVED';
export const IMPACT_NO_ELEVATED_SIGNAL = 'NO ELEVATED SIGNAL';
export const IMPACT_UNRESOLVED_TOOLTIP =
'Some risk inputs are missing. No elevated rule fired; this does not establish low risk.';
export function impactUnresolvedInputs(unmeasured: number, total: number): string {
return `Risk inputs are missing for ${unmeasured} of ${total} symbols. `
+ 'No elevated signal is not evidence of safety. Open an affected file to inspect its revision, dependency paths and local Git evidence.';
}
/**
* Wie das [a]tlas-Menue den Weg hierher nennt, mit seinem Buchstaben
+33
View File
@@ -0,0 +1,33 @@
.selection-impact { padding: 12px 18px; display: flex; flex-direction: column; gap: 8px; color: var(--atlas-fg); font-size: 13px; line-height: 1.45; overflow-wrap: anywhere; min-height: 0; }
.selection-impact header { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
.selection-impact h3 { font-size: 19px; margin: 0; }
.selection-impact h4 { font-size: 14px; margin: 0 0 8px; }
.selection-impact p { margin: 7px 0; }
.selection-impact button { cursor: pointer; color: inherit; border: 1px solid var(--atlas-line); background: var(--atlas-panel-raised); border-radius: 5px; padding: 4px 7px; font: inherit; text-align: left; }
.selection-impact button:hover { background: var(--atlas-select); }
.selection-impact button:focus-visible { outline: 2px solid var(--atlas-cyan); outline-offset: 2px; }
.selection-impact-panels { overflow: auto; min-height: 0; flex: 1; padding-right: 8px; }
.selection-impact-panels > section { padding-top: 8px; }
.selection-impact [hidden] { display: none; }
.selection-impact-tabs { display: flex; flex-wrap: wrap; gap: 6px; border-bottom: 1px solid var(--atlas-line); padding-bottom: 8px; }
.selection-impact-tabs [aria-selected=true] { border-color: var(--atlas-phosphor); background: var(--atlas-select); }
.selection-impact-risk { border-left: 3px solid var(--atlas-cyan); background: var(--atlas-select); padding: 10px 12px; }
.selection-impact-risk[data-level="high"] { border-color: var(--atlas-alarm); }
.selection-impact-risk[data-level="unresolved"] { border-color: var(--atlas-dim); }
.selection-impact-risk-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px; }
.selection-impact-risk-heading > span { color: var(--atlas-dim); font-size: 11px; }
.selection-impact-risk li { margin: 3px 0; }
.selection-impact-risk .selection-impact-quality-link { border: 0; background: transparent; color: var(--atlas-cyan); padding: 3px 0; font-size: 12px; text-decoration: underline; }
.selection-impact-scope { font-size: 12px; }
.selection-impact summary { cursor: pointer; font-weight: 600; padding: 5px 0; }
.selection-impact li { margin: 7px 0; }
.selection-impact ul, .selection-impact ol { padding-left: 20px; margin: 5px 0; }
.selection-impact-findings > li { padding-bottom: 10px; border-bottom: 1px solid var(--atlas-line); }
.selection-impact-findings span { margin-left: 9px; }
.selection-impact small { display: block; color: var(--atlas-dim); }
.selection-impact code { font-size: 11px; white-space: normal; }
.selection-impact [role="alert"] { color: var(--atlas-alarm); border-left: 3px solid var(--atlas-alarm); padding-left: 10px; }
.local-evidence-command { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.local-evidence-command code { overflow-wrap: anywhere; user-select: all; font-size: 11px; }
.local-evidence-command [role=status] { width: 100%; }
@media (max-width: 600px), (max-height: 650px) { .selection-impact { padding: 8px; gap: 5px; }.selection-impact-risk { padding: 6px 8px; }.selection-impact-risk > ul { max-height: 64px; overflow: auto; }.selection-impact-scope { margin: 0; }.selection-impact h3 { font-size: 15px; } }
@@ -0,0 +1,211 @@
// @vitest-environment jsdom
import { act, useLayoutEffect } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import SelectionImpactWidget from './SelectionImpactWidget';
import { fetchSelectionImpact, localCommitCommand, readSelectionImpact, selectionRisk } from './selection-impact';
import type { SelectionImpact, SelectionImpactReply } from './selection-impact';
/** Explicit test data; no claim about the production repository. */
function fixture(file = 'core.ts'): SelectionImpact {
const origin = { id: 1, name: 'core', qualified_name: 'fixture.core', file_path: file, line: 3 };
const caller = { id: 2, name: 'caller', qualified_name: 'fixture.caller', file_path: 'caller.ts', line: 8 };
return {
status: 'ready', project: 'fixture', file_path: file, scope: 'file', computed_at: 1700000000, cache_seconds: 30,
snapshot: { indexed_at: '2026-09-09T10:00:00Z', generation: 'legacy', index_revision: 'unknown',
freshness: 'Index equivalence to HEAD is unverified.', coverage_recording: 'unavailable', coverage: [] },
structural: { available: true, basis: 'CALLS and IMPORTS', max_depth: 4, visit_cap: 600, result_cap: 60,
seed_count: 1, reachable: 1, direct: 1, test_candidates: 0, truncated: false,
findings: [{ ...caller, distance: 1, test_candidate: false,
path: [{ edge_id: 7, type: 'CALLS', from: caller, to: origin }] }] },
history: { available: true, head: 'a'.repeat(40), shallow: false, worktree_status_known: true,
selection_uncommitted: true, truncated: false, commit_cap: 200, window_days: 548,
mass_change_threshold: 30, commits_scanned: 5, commits_considered: 3, selection_commits: 2,
merges_excluded: 1, mass_changes_excluded: 1, read_errors: 0, cochanges_omitted: 0,
commits: [{ hash: 'b'.repeat(40), subject: 'Explicit fixture commit', time: 1700000000, files: 2 }],
cochanges: [{ file_path: 'peer.ts', shared_commits: 2, commit_refs: ['b'.repeat(40)] }] },
};
}
describe('selection impact evidence', () => {
it('does not turn missing graph, coverage and history into low risk', () => {
const data = fixture();
data.structural = { ...data.structural, available: false, reachable: 0, direct: 0, findings: [] };
data.history = { ...data.history, available: false, cochanges: [], selection_commits: 0, shallow: true };
const risk = selectionRisk(data);
expect(risk.level).toBe('unresolved');
expect(risk.uncertainties.join(' ')).toContain('shallow');
expect(risk.uncertainties.join(' ')).toContain('uncommitted');
expect(risk.uncertainties.join(' ')).toContain('coverage recording');
expect(risk.reasons.join(' ')).toContain('does not establish low risk');
});
it('keeps a high structural signal separate from uncertainty and historical association', () => {
const data = fixture();
data.structural.direct = 10;
data.structural.reachable = 12;
data.history.cochanges[0]!.shared_commits = 3;
const risk = selectionRisk(data);
expect(risk.level).toBe('high');
expect(risk.reasons[0]).toContain('10 direct or 25 total');
expect(risk.reasons.join(' ')).toContain('historical associations separately');
expect(risk.uncertainties).toContain('Index equivalence to HEAD is unverified.');
});
it('rejects incomplete or disconnected graph evidence instead of inventing a count', () => {
const data = fixture();
expect(readSelectionImpact(data)).toEqual(data);
data.structural.findings[0]!.path[0]!.from.id = 999;
expect(() => readSelectionImpact(data)).toThrow('Disconnected');
expect(() => readSelectionImpact({ status: 'ready' })).toThrow();
});
it('validates same-origin response identity and sends the selected file and qualified name', async () => {
const request = vi.fn<typeof fetch>(async () => new Response(JSON.stringify(fixture())));
const response = await fetchSelectionImpact('fixture', { filePath: 'core.ts', qualifiedName: 'fixture.core' }, undefined, false, request);
expect(response.status).toBe('ready');
expect(request.mock.calls[0]?.[0]).toBe('/api/impact-analysis?project=fixture&file=core.ts&node=fixture.core');
await expect(fetchSelectionImpact('another', { filePath: 'core.ts' }, undefined, false, request))
.rejects.toThrow('different selection');
});
it('quotes displayed Git commands without enabling shell substitutions in file names', () => {
expect(localCommitCommand('a'.repeat(40), '$(touch unexpected).ts', "it's.ts"))
.toBe(`git show ${'a'.repeat(40)} -- '$(touch unexpected).ts' 'it'\\''s.ts'`);
expect(localCommitCommand('--exec=bad', 'core.ts')).toBe('Commit hash unavailable');
});
});
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement('div'); document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount()); container.remove(); vi.useRealTimers();
});
describe('SelectionImpactWidget', () => {
it('starts from an honest empty state and performs no analysis without a selection', async () => {
const load = vi.fn();
await act(async () => root.render(<SelectionImpactWidget project="fixture" onOpen={vi.fn()} load={load} />));
expect(container.textContent).toContain('Select an indexed file or symbol');
expect(load).not.toHaveBeenCalled();
});
it('shows edge evidence, separate historical evidence, revision and user-initiated diagnostics', async () => {
const open = vi.fn(), diagnose = vi.fn();
const load = vi.fn(async () => fixture());
await act(async () => root.render(<SelectionImpactWidget project="fixture" target={{ filePath: 'core.ts' }}
expectedGeneration="newer-snapshot" onOpen={open} onDiagnose={diagnose} load={load} />));
expect(container.textContent).toContain('Graph edge #7');
expect(container.textContent).toContain('Historical evidence');
expect(container.textContent).toContain('not functional dependency');
expect(container.textContent).toContain('uncommitted changes');
expect(container.textContent).toContain('Git HEAD:');
expect(container.textContent).toContain('different index generations');
expect(container.textContent).toContain('Explicit fixture commit');
expect(diagnose).not.toHaveBeenCalled();
const buttons = [...container.querySelectorAll('button')];
await act(async () => buttons.find(button => button.textContent === 'caller')!.click());
expect(open).toHaveBeenCalledWith({ filePath: 'caller.ts', name: 'caller', qualifiedName: 'fixture.caller', id: 2, line: 8 });
await act(async () => buttons.find(button => button.textContent === 'Inspect local indexing diagnostics')!.click());
expect(diagnose).toHaveBeenCalledOnce();
});
it('polls pending work and cancels responses for an obsolete selection', async () => {
vi.useFakeTimers();
const load = vi.fn(async (): Promise<SelectionImpactReply> => ({ status: 'pending' }));
load.mockResolvedValueOnce({ status: 'pending' }).mockResolvedValueOnce(fixture());
await act(async () => root.render(<SelectionImpactWidget project="fixture" target={{ filePath: 'core.ts' }} onOpen={vi.fn()} load={load} />));
expect(container.textContent).toContain('Reading the graph snapshot');
await act(async () => vi.advanceTimersByTimeAsync(500));
expect(container.querySelector('[data-status="ready"]')).not.toBeNull();
let resolveOld: (value: SelectionImpactReply) => void = () => {};
const first = new Promise<SelectionImpactReply>(resolve => { resolveOld = resolve; });
const newer = vi.fn().mockReturnValueOnce(first).mockResolvedValueOnce(fixture('new.ts'));
await act(async () => root.render(<SelectionImpactWidget project="fixture" target={{ filePath: 'old.ts' }} onOpen={vi.fn()} load={newer} />));
await act(async () => root.render(<SelectionImpactWidget project="fixture" target={{ filePath: 'new.ts' }} onOpen={vi.fn()} load={newer} />));
await act(async () => resolveOld(fixture('old.ts')));
expect(container.textContent).toContain('new.ts');
expect(container.textContent).not.toContain('old.ts');
expect(newer.mock.calls[0]?.[2].aborted).toBe(true);
});
it('reports connection failure without a reassuring risk badge', async () => {
const load = vi.fn(async (): Promise<SelectionImpactReply> => { throw new Error('disconnected'); });
await act(async () => root.render(<SelectionImpactWidget project="fixture" target={{ filePath: 'core.ts' }} onOpen={vi.fn()} load={load} />));
expect(container.querySelector('[role="alert"]')?.textContent).toContain('Risk remains unresolved');
expect(container.querySelector('.selection-impact-risk')).toBeNull();
});
it('keeps a compact overview and makes test and Git evidence directly navigable through separate tabs', async () => {
const data = fixture();
data.structural.test_candidates = 1;
data.structural.findings[0]!.test_candidate = true;
const open = vi.fn(), load = vi.fn(async () => data);
await act(async () => root.render(<SelectionImpactWidget project="fixture" target={{ filePath: 'core.ts' }} onOpen={open} load={load} />));
const panels = [...container.querySelectorAll<HTMLElement>('[role="tabpanel"]')];
expect(panels.filter(panel => !panel.hidden)).toHaveLength(1);
expect(panels.find(panel => !panel.hidden)?.textContent).toContain('Structural impact');
const buttons = [...container.querySelectorAll<HTMLButtonElement>('button')];
await act(async () => buttons.find(button => button.textContent === 'Tests to review · 1')!.click());
expect(panels.find(panel => !panel.hidden)?.textContent).toContain('Test');
await act(async () => buttons.find(button => button.textContent === 'Open test source: caller')!.click());
expect(open).toHaveBeenCalledWith(expect.objectContaining({ filePath: 'caller.ts', line: 8 }));
await act(async () => buttons.find(button => button.textContent === 'Git history · 1')!.click());
expect(panels.find(panel => !panel.hidden)?.textContent).toContain('Explicit fixture commit');
expect(panels.find(panel => !panel.hidden)?.textContent).toContain('Copy local Git evidence command');
expect(container.querySelector('.selection-impact-risk')?.textContent).toContain('data limitations');
});
it('keeps refresh and risk available when the shared workspace already owns the selection header', async () => {
const load = vi.fn<typeof fetchSelectionImpact>(async () => fixture());
await act(async () => root.render(<SelectionImpactWidget compact project="fixture" target={{ filePath: 'core.ts' }} onOpen={vi.fn()} load={load} />));
expect(container.querySelector('.selection-impact-scope')).toBeNull();
expect(container.querySelector('.selection-impact-risk')?.textContent).toContain('Transparent heuristic');
const refresh = [...container.querySelectorAll('button')].filter(button => button.textContent === 'Refresh analysis');
expect(refresh).toHaveLength(1);
await act(async () => refresh[0]!.click());
expect(load).toHaveBeenCalledTimes(2);
expect(load.mock.calls[1]?.[3]).toBe(true);
});
it('never paints old symbol findings beneath a new symbol label before effects clear state', async () => {
const captures: string[] = [];
function BeforeEffects(): null {
useLayoutEffect(() => { captures.push(container.textContent ?? ''); });
return null;
}
const load = vi.fn().mockResolvedValueOnce(fixture())
.mockImplementationOnce(() => new Promise<SelectionImpactReply>(() => {}));
await act(async () => root.render(<><SelectionImpactWidget project="fixture"
target={{ filePath: 'core.ts', qualifiedName: 'fixture.first', name: 'first symbol' }}
onOpen={vi.fn()} load={load} /><BeforeEffects /></>));
expect(container.textContent).toContain('Historical evidence');
captures.length = 0;
await act(async () => root.render(<><SelectionImpactWidget project="fixture"
target={{ filePath: 'core.ts', qualifiedName: 'fixture.second', name: 'second symbol' }}
onOpen={vi.fn()} load={load} /><BeforeEffects /></>));
expect(captures[0]).toContain('second symbol');
expect(captures[0]).not.toContain('Historical evidence');
});
it('progressively reveals returned evidence while keeping the actual counts visible', async () => {
const data = fixture();
const original = data.structural.findings[0]!;
data.structural.findings = Array.from({ length: 20 }, (_, index) => ({ ...original, id: index + 10 }));
data.structural.reachable = 40;
data.structural.truncated = true;
data.history.cochanges = Array.from({ length: 12 }, (_, index) => ({ ...data.history.cochanges[0]!, file_path: `peer-${index}.ts` }));
data.history.cochanges_omitted = 3;
const load = vi.fn(async () => data);
await act(async () => root.render(<SelectionImpactWidget project="fixture" target={{ filePath: 'core.ts' }} onOpen={vi.fn()} load={load} />));
expect(container.querySelectorAll('.selection-impact-findings > li')).toHaveLength(8);
expect(container.querySelectorAll('.selection-impact-history > li')).toHaveLength(5);
expect(container.textContent).toContain('40 total dependents');
expect(container.textContent).toContain('3 further co-change files were omitted');
const more = [...container.querySelectorAll('button')].find(button => button.textContent?.includes('Show more affected symbols'))!;
expect(more.textContent).toContain('(8 of 20)');
await act(async () => more.click());
expect(container.querySelectorAll('.selection-impact-findings > li')).toHaveLength(16);
const history = [...container.querySelectorAll('button')].find(button => button.textContent?.includes('Show more historical associations'))!;
await act(async () => history.click());
expect(container.querySelectorAll('.selection-impact-history > li')).toHaveLength(10);
});
});
+254
View File
@@ -0,0 +1,254 @@
/** Selection impact deliberately keeps graph evidence and Git association apart. */
export interface SelectionImpactTarget {
filePath: string;
name?: string;
id?: number;
qualifiedName?: string;
line?: number;
}
export interface ImpactEvidenceNode {
id: number;
name: string;
qualified_name: string;
file_path: string;
line: number;
}
export interface ImpactEvidenceEdge {
edge_id: number;
type: 'CALLS' | 'IMPORTS';
from: ImpactEvidenceNode;
to: ImpactEvidenceNode;
}
export interface SelectionImpactFinding extends ImpactEvidenceNode {
distance: number;
test_candidate: boolean;
path: ImpactEvidenceEdge[];
}
export interface SelectionImpact {
status: 'ready';
project: string;
file_path: string;
scope: 'file' | 'symbol';
computed_at: number;
cache_seconds: number;
snapshot: {
indexed_at: string;
generation: string;
index_revision: string;
freshness: string;
coverage_recording: string;
coverage: { kind: string; count: number }[];
};
structural: {
available: boolean;
basis: string;
max_depth: number;
visit_cap: number;
result_cap: number;
seed_count: number;
reachable: number;
direct: number;
test_candidates: number;
truncated: boolean;
error?: string;
findings: SelectionImpactFinding[];
};
history: {
available: boolean;
head?: string;
shallow?: boolean;
worktree_status_known?: boolean;
selection_uncommitted?: boolean;
truncated: boolean;
error?: string;
commit_cap: number;
window_days: number;
mass_change_threshold: number;
commits_scanned: number;
commits_considered: number;
selection_commits: number;
merges_excluded: number;
mass_changes_excluded: number;
read_errors: number;
cochanges_omitted: number;
commits: { hash: string; subject: string; time: number; files: number }[];
cochanges: { file_path: string; shared_commits: number; commit_refs: string[] }[];
};
}
export type SelectionImpactReply = SelectionImpact | { status: 'pending' | 'busy' }
| { status: 'failed'; error: string };
function object(value: unknown): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value))
throw new Error('Invalid impact response');
return value as Record<string, unknown>;
}
function text(value: unknown): string {
if (typeof value !== 'string') throw new Error('Invalid impact text');
return value;
}
function count(value: unknown): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
throw new Error('Invalid impact count');
return value;
}
function flag(value: unknown): boolean {
if (typeof value !== 'boolean') throw new Error('Invalid impact flag');
return value;
}
function list(value: unknown): unknown[] {
if (!Array.isArray(value)) throw new Error('Invalid impact evidence list');
return value;
}
function node(value: unknown): ImpactEvidenceNode {
const row = object(value);
return { id: count(row.id), name: text(row.name), qualified_name: text(row.qualified_name),
file_path: text(row.file_path), line: count(row.line) };
}
/** Fail closed: an API error or malformed evidence never becomes a reassuring zero. */
export function readSelectionImpact(value: unknown): SelectionImpactReply {
const row = object(value);
if (row.status === 'pending' || row.status === 'busy') return { status: row.status };
if (row.status === 'failed') return { status: 'failed', error: text(row.error) };
if (row.status !== 'ready' || (row.scope !== 'symbol' && row.scope !== 'file'))
throw new Error('Invalid impact snapshot');
const snapshot = object(row.snapshot), structural = object(row.structural), history = object(row.history);
const result: SelectionImpact = {
status: 'ready', project: text(row.project), file_path: text(row.file_path), scope: row.scope,
computed_at: count(row.computed_at), cache_seconds: count(row.cache_seconds),
snapshot: {
indexed_at: text(snapshot.indexed_at), generation: text(snapshot.generation),
index_revision: text(snapshot.index_revision), freshness: text(snapshot.freshness),
coverage_recording: text(snapshot.coverage_recording),
coverage: list(snapshot.coverage).map(value => {
const item = object(value); return { kind: text(item.kind), count: count(item.count) };
}),
},
structural: {
available: flag(structural.available), basis: text(structural.basis),
max_depth: count(structural.max_depth), visit_cap: count(structural.visit_cap),
result_cap: count(structural.result_cap), seed_count: count(structural.seed_count),
reachable: count(structural.reachable), direct: count(structural.direct),
test_candidates: count(structural.test_candidates), truncated: flag(structural.truncated),
...(structural.error === undefined ? {} : { error: text(structural.error) }),
findings: list(structural.findings).map(value => {
const item = object(value);
const path = list(item.path).map(value => {
const edge = object(value);
if (edge.type !== 'CALLS' && edge.type !== 'IMPORTS') throw new Error('Unknown impact relation');
return { edge_id: count(edge.edge_id), type: edge.type,
from: node(edge.from), to: node(edge.to) } as ImpactEvidenceEdge;
});
const finding = { ...node(item), distance: count(item.distance),
test_candidate: flag(item.test_candidate), path };
if (path.length === 0 || path.length !== finding.distance || path[0]?.from.id !== finding.id
|| path.some((edge, i) => i > 0 && path[i - 1]?.to.id !== edge.from.id))
throw new Error('Disconnected impact evidence path');
return finding;
}),
},
history: {
available: flag(history.available), truncated: flag(history.truncated),
commit_cap: count(history.commit_cap), window_days: count(history.window_days),
mass_change_threshold: count(history.mass_change_threshold),
commits_scanned: count(history.commits_scanned), commits_considered: count(history.commits_considered),
selection_commits: count(history.selection_commits), merges_excluded: count(history.merges_excluded),
mass_changes_excluded: count(history.mass_changes_excluded), read_errors: count(history.read_errors),
cochanges_omitted: count(history.cochanges_omitted),
...(history.head === undefined ? {} : { head: text(history.head) }),
...(history.error === undefined ? {} : { error: text(history.error) }),
...(history.shallow === undefined ? {} : { shallow: flag(history.shallow) }),
...(history.worktree_status_known === undefined ? {} : { worktree_status_known: flag(history.worktree_status_known) }),
...(history.selection_uncommitted === undefined ? {} : { selection_uncommitted: flag(history.selection_uncommitted) }),
commits: list(history.commits).map(value => {
const item = object(value); return { hash: text(item.hash), subject: text(item.subject),
time: count(item.time), files: count(item.files) };
}),
cochanges: list(history.cochanges).map(value => {
const item = object(value); return { file_path: text(item.file_path),
shared_commits: count(item.shared_commits), commit_refs: list(item.commit_refs).map(text) };
}),
},
};
return result;
}
export interface SelectionRisk {
level: 'high' | 'elevated' | 'unresolved';
label: string;
reasons: string[];
uncertainties: string[];
next: string[];
}
/** An explicit review-priority heuristic, never a calibrated defect prediction. */
export function selectionRisk(data: SelectionImpact): SelectionRisk {
const graph = data.structural, history = data.history;
const reasons: string[] = [], uncertainties: string[] = [];
let level: SelectionRisk['level'] = 'unresolved';
if (graph.available && (graph.direct >= 10 || graph.reachable >= 25)) {
level = 'high';
reasons.push(`${graph.direct} direct and ${graph.reachable} total indexed dependents meet the broad-impact rule (10 direct or 25 total).`);
} else if (graph.available && graph.reachable > 0) {
level = 'elevated';
reasons.push(`${graph.reachable} indexed dependent${graph.reachable === 1 ? '' : 's'} could be affected; inspect direct paths first.`);
}
if (history.available && history.selection_commits >= 10) {
reasons.push(`This file changed in ${history.selection_commits} sampled ordinary commits (frequent-change threshold: 10).`);
if (level === 'unresolved') level = 'elevated';
}
const repeated = history.cochanges.filter(row => row.shared_commits >= 3);
if (history.available && repeated.length > 0)
reasons.push(`${repeated.length} file${repeated.length === 1 ? '' : 's'} changed together with this selection in at least 3 sampled commits; review these historical associations separately.`);
if (reasons.length === 0) reasons.push('No elevated signal was established in the available sample. This does not establish low risk.');
if (!graph.available) uncertainties.push(graph.error ?? 'The selection has no usable indexed dependency evidence.');
if (graph.truncated) uncertainties.push('The graph walk or evidence list reached a bound; further dependents may exist.');
if (data.snapshot.coverage_recording !== 'complete') uncertainties.push('Index coverage recording is incomplete or unavailable.');
const gaps = data.snapshot.coverage.reduce((sum, row) => sum + row.count, 0);
if (gaps > 0) uncertainties.push(`${gaps} persisted index coverage records identify excluded, unsupported, or partially indexed paths; inspect Diagnostics.`);
if (data.snapshot.index_revision === 'unknown') uncertainties.push(data.snapshot.freshness);
if (!history.available) uncertainties.push(history.error ?? 'Historical evidence is unavailable or contains read failures.');
if (history.shallow) uncertainties.push('This is a shallow checkout; earlier co-changes are missing.');
if (history.truncated) uncertainties.push('The history scan reached a count, time, or file bound.');
if (history.available && history.selection_commits < 3) uncertainties.push('Fewer than 3 sampled ordinary commits touch this file; historical evidence is sparse.');
if (history.selection_uncommitted) uncertainties.push('The selected file has uncommitted changes; Git history describes committed versions, and graph freshness is unverified.');
if (history.worktree_status_known !== true) uncertainties.push('Working-tree status of the selection could not be established.');
const next = [
'Inspect the nearest dependent paths and check the interfaces used by their callers or importers.',
graph.test_candidates > 0
? `Review and run the ${graph.test_candidates} indexed test candidate${graph.test_candidates === 1 ? '' : 's'} that reach the selection. Test-file classification is heuristic.`
: 'No test dependency was found within this walk. Search for integration tests and add a focused regression check; this is not a coverage measurement.',
'Compare the local change with the indexed source and reindex before relying on absence of downstream findings.',
];
if (history.cochanges.length > 0) next.push('Inspect the linked local commit evidence for repeated co-changes; shared commits alone do not establish a dependency.');
return { level, label: level === 'high' ? 'Broad impact · high review priority'
: level === 'elevated' ? 'Review needed · elevated change risk' : 'Risk unresolved', reasons, uncertainties, next };
}
export function evidenceTarget(node: ImpactEvidenceNode): SelectionImpactTarget {
return { filePath: node.file_path, name: node.name, id: node.id,
qualifiedName: node.qualified_name, line: node.line };
}
/** Display-only local command; quote even shell metacharacters in Git filenames. */
export function localCommitCommand(hash: string, ...paths: string[]): string {
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(hash)) return 'Commit hash unavailable';
const quote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
return `git show ${hash} -- ${paths.map(quote).join(' ')}`;
}
export async function fetchSelectionImpact(project: string, target: SelectionImpactTarget,
signal?: AbortSignal, refresh = false, doFetch: typeof fetch = globalThis.fetch): Promise<SelectionImpactReply> {
const query = new URLSearchParams({ project, file: target.filePath });
if (target.qualifiedName) query.set('node', target.qualifiedName);
else if (target.id !== undefined) query.set('node', `#${target.id}`);
if (refresh) query.set('refresh', '1');
const response = await doFetch(`/api/impact-analysis?${query}`, { signal });
if (!response.ok) throw new Error(`Impact analysis returned HTTP ${response.status}`);
const reply = readSelectionImpact(await response.json());
if (reply.status === 'ready' && (reply.project !== project || reply.file_path !== target.filePath))
throw new Error('Impact response belongs to a different selection');
return reply;
}
+48 -5
View File
@@ -174,14 +174,57 @@ export function readAdr(raw: unknown): AdrRecord {
// ------------------------------------------------------------------ logs --
export interface LogTail {
query?: string;
scope?: 'project' | 'daemon' | 'unattributed';
project?: string | null;
lines: string[];
total: number;
/** Absent on older daemons and when persistent storage is unavailable. */
records?: DaemonLogRecord[];
persistent?: boolean;
generation?: string;
retentionLimit?: number;
oldestCursor?: number;
cursor?: number;
hasMore?: boolean;
}
export interface DaemonLogRecord {
project?: string | null;
id: number;
ts: string;
level: string;
source: string;
message: string;
}
export function readLogs(raw: unknown): LogTail {
const record = isRecord(raw) ? raw : {};
const lines = strings(record['lines']);
return { lines, total: optionalNumber(record['total']) ?? lines.length };
const result: LogTail = { lines, total: optionalNumber(record['total']) ?? lines.length };
if (typeof record['query'] === 'string') result.query = record['query'];
if (Array.isArray(record['records'])) {
// A snapshot replaces the previous snapshot. Stable IDs also collapse a
// repeated row within one response; never append replayed records.
const rows = new Map<number, DaemonLogRecord>();
for (const entry of record['records']) {
if (!isRecord(entry)) continue;
const id = optionalNumber(entry['id']);
if (id === undefined || !Number.isSafeInteger(id) || id < 0 || typeof entry['message'] !== 'string') continue;
rows.set(id, { id, ts: text(entry['ts']), level: text(entry['level']), source: text(entry['source']), message: entry['message'], ...(typeof entry['project'] === 'string' || entry['project'] === null ? { project: entry['project'] } : {}) });
}
result.records = [...rows.values()].sort((left, right) => left.id - right.id);
}
if (['project', 'daemon', 'unattributed'].includes(String(record['scope']))) result.scope = record['scope'] as LogTail['scope'];
if (typeof record['project'] === 'string' || record['project'] === null) result.project = record['project'];
if (typeof record['persistent'] === 'boolean') result.persistent = record['persistent'];
if (typeof record['generation'] === 'string') result.generation = record['generation'];
if (typeof record['has_more'] === 'boolean') result.hasMore = record['has_more'];
for (const [input, output] of [['retention_limit', 'retentionLimit'], ['oldest_cursor', 'oldestCursor'], ['cursor', 'cursor']] as const) {
const value = optionalNumber(record[input]);
if (value !== undefined) result[output] = value;
}
return result;
}
// ------------------------------------------------------------- processes --
@@ -200,14 +243,14 @@ export interface ServerProcess {
export type ProcessCpuUnit = 'percent' | 'seconds' | 'unknown';
export type ProcessMemoryKind = 'resident' | 'working_set' | 'peak_resident' | 'unknown';
/** The tail of the frontend log file the server keeps (POST /api/ui-log). */
/** Original frontend JSON lines from the daemon journal (POST /api/ui-log). */
export interface UiLogTail {
/** The file on the server; what a bug report attaches. */
/** The authoritative local storage path reported by this daemon. */
path: string;
/** The rotated file before it, when there is one. Empty otherwise. */
/** A legacy local export archive, when there is one. Empty otherwise. */
previousPath: string;
sizeBytes: number;
/** True when the lines do not start at the first line of the file. */
/** True when the displayed window or legacy import is partial. */
partial: boolean;
/** One JSON object per line, as written by the server. */
lines: string[];
+10
View File
@@ -1647,6 +1647,16 @@ export class CbmRpcProvider implements IntelligenceProvider {
* ein Tastendruck ueberholt, bevor er beantwortet ist. Wer sie nicht
* mitgibt, bekommt das Verhalten von vorher.
*/
async searchFile(root: string, path: string, opts?: ProviderQueryOptions & { signal?: AbortSignal }): Promise<SymbolSearchHit | undefined> {
if (opts?.signal?.aborted) return undefined;
const project = await this.projectFor(root, opts);
if (!project || opts?.signal?.aborted) return undefined;
const rows = await this.client.queryRows(project, fileExists(path));
if (opts?.signal?.aborted) return undefined;
const row = rows.find(row => row[COLUMNS.fileExists[1]] === path);
return row ? { name: path.split('/').at(-1) ?? path, kind: 'unknown', filePath: path } : undefined;
}
async searchSymbols(
root: string,
pattern: string,
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { parseCompactRows, parseSearchResults } from './compact-rows';
import { RpcIntelligenceClient } from './rpc-client';
import { FakeRpc, queryContains, rowsText } from '../test-support/rpc-recordings';
import { loadFileSymbols } from '../twin/selected-code-context';
import functions from './fixtures/application-function.compact.txt?raw';
import classes from './fixtures/application-class.compact.txt?raw';
import functionPage2 from './fixtures/application-function-page2.compact.txt?raw';
// Unmodified responses captured 2026-09-09 from the local PR daemon:
// query_graph project=cbm-pr2068, application.c, Function / Class, LIMIT 200.
const prefix = 'rows_refs: 2 (cols: id prefix)\n 0 project.src.\n 1 src/\nrows_ref_rule: @N+suffix=prefix+suffix\n';
const table = 'rows: 1 (cols: n.qualified_name n.file_path)\n @0+main @1+main.c\ntotal: 1\n';
describe('compact prefix directories', () => {
it('decodes the actual 127 function and 8 class replies without losing paths or declaration lines', () => {
const f = parseCompactRows(functions), c = parseCompactRows(classes);
expect(f.rows).toHaveLength(127);
expect(c.rows).toHaveLength(8);
expect(c.rows[0]).toEqual(['cbm_daemon_application', 'cbm-pr2068.src.daemon.application.cbm_daemon_application',
'src/daemon/application.c', '171', '200', 'false']);
expect(f.rows.find(row => row[0] === 'cbm_daemon_application_new')?.slice(1, 4)).toEqual([
'cbm-pr2068.src.daemon.application.cbm_daemon_application_new', 'src/daemon/application.c', '2909']);
expect(f).toMatchObject({ returned: 127, total: 144, totalRelation: 'eq', hasMore: true, truncated: true,
truncationReason: 'output_budget', nextOffset: 127 });
expect(parseCompactRows(functionPage2)).toMatchObject({ returned: 17, total: 144, offset: 127, hasMore: false, truncated: false });
});
it('populates the real inspector reader through the shared client, with no Function/Class query failure', async () => {
const rpc = new FakeRpc([
{ tool: 'query_graph', when: args => typeof args.cursor === 'string', text: functionPage2 },
{ tool: 'query_graph', when: queryContains('(n:Function)'), text: functions },
{ tool: 'query_graph', when: queryContains('(n:Class)'), text: classes },
{ tool: 'query_graph', text: rowsText(['n.name'], []) },
]);
const result = await loadFileSymbols(new RpcIntelligenceClient({ fetch: rpc.fetch }), 'cbm-pr2068', 'src/daemon/application.c');
expect(result.symbols).toHaveLength(152);
expect(result.message).toBe('');
expect(result.symbols.find(symbol => symbol.name === 'cbm_daemon_application_new')?.uri).toBe('file:///workspace/src/daemon/application.c');
});
it('expands empty suffixes and leaves quoted ref-looking literals unchanged', () => {
const response = prefix + 'rows: 1 (cols: literal expanded)\n "@0+unchanged" @1+\ntotal: 1\n';
expect(parseCompactRows(response).rows).toEqual([['@0+unchanged', 'src/']]);
});
it('does not recursively expand dictionary declarations', () => {
const response = 'rows_refs: 1 (cols: id prefix)\n 4 "@99+literal/"\nrows_ref_rule: @N+suffix=prefix+suffix\n'
+ 'rows: 1 (cols: path)\n @4+file.c\ntotal: 1\n';
expect(parseCompactRows(response).rows).toEqual([['@99+literal/file.c']]);
});
it('keeps unquoted ref-looking values literal when no directory is declared', () => {
expect(parseCompactRows('rows: 1 (cols: name)\n @0+literal\ntotal: 1\n').rows).toEqual([['@0+literal']]);
});
it.each([
prefix.replace(' 1 src/', ' 0 src/'),
prefix.replace('refs: 2', 'refs: 3'),
prefix.replace('prefix+suffix', 'suffix+prefix'),
prefix.replace(' 1 src/', ' -1 src/'),
])('rejects a malformed directory instead of manufacturing source identities', bad => {
expect(() => parseCompactRows(bad + table)).toThrow();
});
it('rejects unresolved references', () => {
expect(() => parseCompactRows(prefix + table.replace('@0+main', '@7+main'))).toThrow('unknown reference @7');
});
it('uses the same literal/reference distinction for compressed search results', () => {
const response = prefix.replaceAll('rows_', 'results_')
+ 'results: 1 (cols: qn label file lines rank)\n @0+main Function @1+main.c 1-9 -4.2\n'
+ 'returned: 1\ntotal: 3\ntotal_relation: gte\nsearch_mode: bm25\nhas_more: true\ntruncated: true\n';
expect(parseSearchResults(response)).toMatchObject({
rows: [['project.src.main', 'Function', 'src/main.c', '1-9', '-4.2']], hasMore: true, truncated: true,
});
});
it('retains current cursor and truncation metadata', () => {
const response = prefix + table.replace('total: 1', 'returned: 1\ntotal: 4\ntotal_relation: eq\nhas_more: true\ntruncated: true\ntruncation_reason: output_budget\nnext_cursor: q1.example\nnext_offset: 1\nwarning: "bounded query"');
expect(parseCompactRows(response)).toMatchObject({ nextCursor: 'q1.example', nextOffset: 1,
truncationReason: 'output_budget', warning: 'bounded query', truncated: true, total: 4 });
});
it.each(['returned: 2\n', 'total_relation: approximate\n'])('rejects contradictory result metadata', footer => {
expect(() => parseCompactRows(prefix + table + footer)).toThrow();
});
});
+83 -8
View File
@@ -40,6 +40,11 @@ export interface CompactRows {
totalRelation?: string;
hasMore?: boolean;
truncated?: boolean;
offset?: number;
nextOffset?: number;
nextCursor?: string;
truncationReason?: string;
warning?: string;
}
/** Eine geparste search_graph-Antwort. */
@@ -73,7 +78,12 @@ const DATA_LINE = /^ {2}\S/;
/** Zerlegt eine Datenzeile in Zellen und nimmt Anfuehrungszeichen weg. */
export function splitCells(line: string): string[] {
const cells: string[] = [];
return cellTokens(line).map(cell => cell.value);
}
/** Quoted @N+ values are literals; retain that distinction until expansion. */
function cellTokens(line: string): { value: string; quoted: boolean }[] {
const cells: { value: string; quoted: boolean }[] = [];
let i = 0;
while (i < line.length) {
while (i < line.length && (line[i] === ' ' || line[i] === '\t')) {
@@ -106,14 +116,14 @@ export function splitCells(line: string): string[] {
`kompakte Zeile hat ein nicht geschlossenes Anfuehrungszeichen: ${line.trim()}`,
);
}
cells.push(buf);
cells.push({ value: buf, quoted: true });
continue;
}
const start = i;
while (i < line.length && line[i] !== ' ' && line[i] !== '\t') {
i += 1;
}
cells.push(line.slice(start, i));
cells.push({ value: line.slice(start, i), quoted: false });
}
return cells;
}
@@ -139,11 +149,19 @@ function collectRows(
from: number,
columns: string[],
label: string,
refs?: ReadonlyMap<number, string>,
): { rows: string[][]; next: number } {
const rows: string[][] = [];
let i = from;
while (i < lines.length && DATA_LINE.test(lines[i])) {
const cells = splitCells(lines[i].slice(2));
const cells = cellTokens(lines[i].slice(2)).map(cell => {
const ref = !cell.quoted && refs ? /^@(\d+)\+(.*)$/.exec(cell.value) : null;
if (!ref) return cell.value;
const prefix = refs!.get(Number(ref[1]));
if (prefix === undefined) throw new Error(`${label}: unknown reference @${ref[1]}`);
// Prefix entries are declarations, never recursively expanded.
return prefix + ref[2];
});
if (cells.length !== columns.length) {
throw new Error(
`${label}: Zeile ${i + 1} hat ${cells.length} Zellen, der Kopf nennt ` +
@@ -156,6 +174,38 @@ function collectRows(
return { rows, next: i };
}
function readReferences(lines: string[], from: number, key: 'rows' | 'results'):
{ refs?: ReadonlyMap<number, string>; next: number } {
const head = new RegExp(`^${key}_refs:\\s*(\\d+)\\s*\\(cols:\\s*id\\s+prefix\\)\\s*$`).exec(lines[from]);
if (!head) return { next: from };
const count = Number(head[1]);
const collected = collectRows(lines, from + 1, ['id', 'prefix'], `${key}_refs`);
if (!Number.isSafeInteger(count) || collected.rows.length !== count)
throw new Error(`${key}_refs: declared count does not match reference rows`);
const refs = new Map<number, string>();
for (const [id, prefix] of collected.rows) {
const number = Number(id);
if (!/^\d+$/.test(id) || !Number.isSafeInteger(number) || refs.has(number))
throw new Error(`${key}_refs: invalid or duplicate reference id ${id}`);
refs.set(number, prefix);
}
if (lines[collected.next]?.trim() !== `${key}_ref_rule: @N+suffix=prefix+suffix`)
throw new Error(`${key}_refs: missing or unsupported reference rule`);
return { refs, next: collected.next + 1 };
}
function validateCounts(parsed: CompactRows | SearchResults): void {
for (const value of [parsed.total, parsed.returned, 'offset' in parsed ? parsed.offset : undefined,
'nextOffset' in parsed ? parsed.nextOffset : undefined]) {
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0))
throw new Error('Invalid compact response count');
}
if (parsed.total < parsed.rows.length || (parsed.returned !== undefined && parsed.returned !== parsed.rows.length))
throw new Error('Compact response counts contradict returned rows');
if (parsed.totalRelation !== undefined && parsed.totalRelation !== 'eq' && parsed.totalRelation !== 'gte')
throw new Error('Unknown total_relation');
}
/**
* Parst die Zeilenform (`rows:` / Datenzeilen / `total:` / optional `hint:`).
*
@@ -172,7 +222,9 @@ export function parseCompactRows(text: string): CompactRows {
throw new Error('kompakte Antwort war leer');
}
const head = ROWS_HEAD.exec(lines[i]);
const directory = readReferences(lines, i, 'rows');
i = directory.next;
const head = ROWS_HEAD.exec(lines[i] ?? '');
if (head === null) {
throw new Error(
`unbekannter Kopf einer kompakten Antwort, erwartet "rows: N (cols: ...)": ` +
@@ -183,7 +235,7 @@ export function parseCompactRows(text: string): CompactRows {
const columns = readColumns(head[2]);
i += 1;
const collected = collectRows(lines, i, columns, 'kompakte Antwort');
const collected = collectRows(lines, i, columns, 'kompakte Antwort', directory.refs);
const rows = collected.rows;
i = collected.next;
@@ -199,6 +251,7 @@ export function parseCompactRows(text: string): CompactRows {
let totalRelation: string | undefined;
let hasMore: boolean | undefined;
let truncated: boolean | undefined;
const metadata: Pick<CompactRows, 'offset' | 'nextOffset' | 'nextCursor' | 'truncationReason' | 'warning'> = {};
for (; i < lines.length; i += 1) {
const line = lines[i];
if (line.trim().length === 0) {
@@ -234,6 +287,17 @@ export function parseCompactRows(text: string): CompactRows {
truncated = truncatedMatch[1] === 'true';
continue;
}
const extra = /^(offset|next_offset|next_cursor|truncation_reason|warning):\s*(.*)$/.exec(line);
if (extra) {
const value = unquote(extra[2]);
if (extra[1] === 'offset' || extra[1] === 'next_offset') {
if (!/^\d+$/.test(value)) throw new Error(`Invalid ${extra[1]}`);
metadata[extra[1] === 'offset' ? 'offset' : 'nextOffset'] = Number(value);
} else if (extra[1] === 'next_cursor') metadata.nextCursor = value;
else if (extra[1] === 'truncation_reason') metadata.truncationReason = value;
else metadata.warning = value;
continue;
}
throw new Error(`unbekannte Fusszeile einer kompakten Antwort: ${line.trim()}`);
}
@@ -241,7 +305,7 @@ export function parseCompactRows(text: string): CompactRows {
throw new Error('kompakte Antwort ohne total-Zeile');
}
const out: CompactRows = { columns, rows, total };
const out: CompactRows = { columns, rows, total, ...metadata };
if (hint !== undefined) {
out.hint = hint;
}
@@ -257,6 +321,7 @@ export function parseCompactRows(text: string): CompactRows {
if (truncated !== undefined) {
out.truncated = truncated;
}
validateCounts(out);
return out;
}
@@ -275,6 +340,7 @@ export function parseSearchResults(text: string): SearchResults {
let returned: number | undefined;
let totalRelation: string | undefined;
let truncated: boolean | undefined;
let refs: ReadonlyMap<number, string> | undefined;
let i = 0;
while (i < lines.length) {
@@ -283,6 +349,14 @@ export function parseSearchResults(text: string): SearchResults {
i += 1;
continue;
}
if (line.startsWith('results_refs:')) {
if (refs || rows) throw new Error('Duplicate or misplaced results_refs');
const directory = readReferences(lines, i, 'results');
if (!directory.refs) throw new Error('Malformed results_refs');
refs = directory.refs;
i = directory.next;
continue;
}
const totalMatch = TOTAL_LINE.exec(line);
if (totalMatch !== null) {
@@ -324,7 +398,7 @@ export function parseSearchResults(text: string): SearchResults {
if (head !== null) {
declared = Number.parseInt(head[1], 10);
columns = readColumns(head[2]);
const collected = collectRows(lines, i + 1, columns, 'Suchantwort');
const collected = collectRows(lines, i + 1, columns, 'Suchantwort', refs);
rows = collected.rows;
i = collected.next;
continue;
@@ -362,6 +436,7 @@ export function parseSearchResults(text: string): SearchResults {
if (truncated !== undefined) {
out.truncated = truncated;
}
validateCounts(out);
return out;
}
@@ -50,6 +50,16 @@ describe('reportError', () => {
});
describe('the /rpc client announces', () => {
it('retains request ownership for transport and payload failures and marks project-free calls global', async () => {
const reports = listen();
await expect(callToolText('query_graph', { project: 'A' }, { fetch: replying(500, 'oom') })).rejects.toThrow();
await expect(callToolJson('index_status', { project: 'B' }, {
fetch: replying(200, '{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"not json"}]}}'),
})).rejects.toThrow();
await expect(callToolText('list_projects', {}, { fetch: replying(500, 'down') })).rejects.toThrow();
expect(reports.map((report) => report.project)).toEqual(['A', 'B', '']);
});
it('a refused tool as info, with the tool name in the message', async () => {
const reports = listen();
await expect(callToolText('index_repository', {}, {
+2
View File
@@ -19,6 +19,8 @@ export type ReportSource = 'rpc' | 'api' | 'reader' | 'ui';
export type ReportLevel = 'info' | 'warn' | 'error';
export interface ErrorReport {
/** Request ownership, retained even if a different project opens before failure. */
project?: string;
source: ReportSource;
level: ReportLevel;
message: string;
@@ -0,0 +1,18 @@
rows_refs: 2 (cols: id prefix)
0 cbm-pr2068.src.daemon.application.
1 src/daemon/application.c
rows_ref_rule: @N+suffix=prefix+suffix
rows: 8 (cols: n.name n.qualified_name n.file_path n.start_line n.end_line n.is_test)
cbm_daemon_application @0+cbm_daemon_application @1+ "171" "200" "false"
cbm_daemon_application_watch @0+cbm_daemon_application_watch @1+ "98" "103" "false"
cbm_daemon_application_session @0+cbm_daemon_application_session @1+ "105" "131" "false"
cbm_daemon_application_job @0+cbm_daemon_application_job @1+ "133" "151" "false"
cbm_daemon_application_mutation @0+cbm_daemon_application_mutation @1+ "164" "169" "false"
cbm_daemon_application_watch_job_subscription @0+cbm_daemon_application_watch_job_subscription @1+ "158" "162" "false"
cbm_config @0+cbm_daemon_application.cbm_config @1+ "174" "174" "false"
cbm_watcher @0+cbm_daemon_application.cbm_watcher @1+ "173" "173" "false"
returned: 8
total: 8
total_relation: eq
has_more: false
truncated: false
@@ -0,0 +1,28 @@
rows_refs: 2 (cols: id prefix)
0 cbm-pr2068.src.daemon.application.
1 src/daemon/application.c
rows_ref_rule: @N+suffix=prefix+suffix
rows: 17 (cols: n.name n.qualified_name n.file_path n.start_line n.end_line n.is_test)
cbm_daemon_application_client_set_context @0+cbm_daemon_application_client_set_context @1+ "3200" "3254" "false"
cbm_daemon_application_client_set_ui_config @0+cbm_daemon_application_client_set_ui_config @1+ "3256" "3288" "false"
cbm_daemon_application_client_ui_readiness_proof @0+cbm_daemon_application_client_ui_readiness_proof @1+ "3290" "3320" "false"
application_client_text_request_tagged @0+application_client_text_request_tagged @1+ "3322" "3342" "false"
application_client_text_request @0+application_client_text_request @1+ "3344" "3350" "false"
cbm_daemon_application_client_mcp @0+cbm_daemon_application_client_mcp @1+ "3352" "3357" "false"
cbm_daemon_application_client_mcp_tagged @0+cbm_daemon_application_client_mcp_tagged @1+ "3359" "3366" "false"
cbm_daemon_application_client_tool @0+cbm_daemon_application_client_tool @1+ "3368" "3390" "false"
cbm_daemon_application_client_hook_augment @0+cbm_daemon_application_client_hook_augment @1+ "3392" "3398" "false"
application_background_index @0+application_background_index @1+ "3400" "3506" "false"
cbm_daemon_application_index @0+cbm_daemon_application_index @1+ "3508" "3511" "false"
cbm_daemon_application_watcher_index @0+cbm_daemon_application_watcher_index @1+ "3513" "3516" "false"
cbm_daemon_application_active_jobs @0+cbm_daemon_application_active_jobs @1+ "3518" "3531" "false"
cbm_daemon_application_job_subscribers @0+cbm_daemon_application_job_subscribers @1+ "3533" "3543" "false"
cbm_daemon_application_physical_job_limit @0+cbm_daemon_application_physical_job_limit @1+ "3545" "3553" "false"
cbm_daemon_application_worker_memory_budget_bytes @0+cbm_daemon_application_worker_memory_budget_bytes @1+ "3555" "3563" "false"
cbm_daemon_application_session_retains_store_for_test @0+cbm_daemon_application_session_retains_store_for_test @1+ "3565" "3570" "false"
returned: 17
total: 144
total_relation: eq
offset: 127
has_more: false
truncated: false
@@ -0,0 +1,140 @@
rows_refs: 2 (cols: id prefix)
0 cbm-pr2068.src.daemon.application.
1 src/daemon/application.c
rows_ref_rule: @N+suffix=prefix+suffix
rows: 127 (cols: n.name n.qualified_name n.file_path n.start_line n.end_line n.is_test)
cbm_daemon_application_fail_next_job_thread_start_for_test @0+cbm_daemon_application_fail_next_job_thread_start_for_test @1+ "218" "221" "false"
cbm_daemon_application_background_initializes_for_test @0+cbm_daemon_application_background_initializes_for_test @1+ "232" "235" "false"
cbm_daemon_application_busy_queue_waits_for_test @0+cbm_daemon_application_busy_queue_waits_for_test @1+ "243" "245" "false"
cbm_daemon_application_hold_job_before_start_for_test @0+cbm_daemon_application_hold_job_before_start_for_test @1+ "247" "250" "false"
application_job_thread_create @0+application_job_thread_create @1+ "252" "258" "false"
application_request_cancelled_locked @0+application_request_cancelled_locked @1+ "260" "266" "false"
application_deadline_after @0+application_deadline_after @1+ "268" "271" "false"
application_cleanup_force_terminate @0+application_cleanup_force_terminate @1+ "273" "286" "false"
application_project_lock_release_fully @0+application_project_lock_release_fully @1+ "288" "300" "false"
application_worker_diagnostic @0+application_worker_diagnostic @1+ "304" "316" "false"
application_worker_start_default @0+application_worker_start_default @1+ "318" "329" "false"
application_worker_poll_default @0+application_worker_poll_default @1+ "331" "336" "false"
application_worker_cancel_default @0+application_worker_cancel_default @1+ "338" "342" "false"
application_worker_log_path_default @0+application_worker_log_path_default @1+ "344" "348" "false"
application_worker_destroy_default @0+application_worker_destroy_default @1+ "350" "354" "false"
application_get_u32 @0+application_get_u32 @1+ "356" "359" "false"
application_put_u32 @0+application_put_u32 @1+ "361" "366" "false"
application_text_copy @0+application_text_copy @1+ "368" "378" "false"
application_regular_db_exists @0+application_regular_db_exists @1+ "380" "392" "false"
application_canonical_directory_exists @0+application_canonical_directory_exists @1+ "394" "397" "false"
application_find_watch_locked @0+application_find_watch_locked @1+ "399" "407" "false"
application_remove_watch_entry_locked @0+application_remove_watch_entry_locked @1+ "409" "433" "false"
application_remove_watch_locked @0+application_remove_watch_locked @1+ "435" "438" "false"
application_release_session_watch_locked @0+application_release_session_watch_locked @1+ "440" "453" "false"
application_session_workspace_allowed @0+application_session_workspace_allowed @1+ "455" "469" "false"
application_refresh_watch_locked @0+application_refresh_watch_locked @1+ "472" "553" "false"
application_refresh_watch @0+application_refresh_watch @1+ "555" "562" "false"
application_job_free @0+application_job_free @1+ "564" "573" "false"
application_jobs_reap_completed @0+application_jobs_reap_completed @1+ "578" "602" "false"
application_tmp_lock @0+application_tmp_lock @1+ "618" "622" "false"
application_tmp_unlock @0+application_tmp_unlock @1+ "624" "626" "false"
application_cache_dir @0+application_cache_dir @1+ "628" "651" "false"
application_unique_recovery_file @0+application_unique_recovery_file @1+ "653" "682" "false"
application_recovery_files_create @0+application_recovery_files_create @1+ "684" "695" "false"
application_recovery_files_remove @0+application_recovery_files_remove @1+ "697" "705" "false"
application_truncate_file @0+application_truncate_file @1+ "707" "710" "false"
application_read_suspects @0+application_read_suspects @1+ "714" "804" "false"
application_free_suspects @0+application_free_suspects @1+ "806" "814" "false"
application_suspect_contains @0+application_suspect_contains @1+ "816" "823" "false"
application_append_quarantine @0+application_append_quarantine @1+ "825" "836" "false"
application_max_restarts @0+application_max_restarts @1+ "838" "848" "false"
application_attempt_init @0+application_attempt_init @1+ "850" "854" "false"
application_attempt_free @0+application_attempt_free @1+ "856" "859" "false"
application_job_cancel_requested @0+application_job_cancel_requested @1+ "861" "867" "false"
application_project_keys_conflict @0+application_project_keys_conflict @1+ "869" "872" "false"
application_mutation_conflicts_locked @0+application_mutation_conflicts_locked @1+ "874" "883" "false"
application_job_reserves_project_locked @0+application_job_reserves_project_locked @1+ "885" "893" "false"
application_mutation_begin_internal @0+application_mutation_begin_internal @1+ "895" "986" "false"
cbm_daemon_application_project_mutation_try_begin @0+cbm_daemon_application_project_mutation_try_begin @1+ "988" "991" "false"
cbm_daemon_application_project_mutation_end @0+cbm_daemon_application_project_mutation_end @1+ "993" "1029" "false"
application_watcher_mutation_begin @0+application_watcher_mutation_begin @1+ "1031" "1033" "false"
application_watcher_mutation_end @0+application_watcher_mutation_end @1+ "1035" "1037" "false"
application_watcher_project_pruned @0+application_watcher_project_pruned @1+ "1039" "1052" "false"
application_session_mutation_begin @0+application_session_mutation_begin @1+ "1054" "1058" "false"
application_session_mutation_try_begin @0+application_session_mutation_try_begin @1+ "1060" "1064" "false"
application_session_mutation_end @0+application_session_mutation_end @1+ "1066" "1071" "false"
application_job_wait_for_mutations @0+application_job_wait_for_mutations @1+ "1073" "1088" "false"
application_cancelled_result @0+application_cancelled_result @1+ "1090" "1096" "false"
application_job_run_attempt @0+application_job_run_attempt @1+ "1098" "1174" "false"
application_job_failure_response @0+application_job_failure_response @1+ "1176" "1195" "false"
application_attempt_disposition @0+application_attempt_disposition @1+ "1197" "1208" "false"
application_record_attempt @0+application_record_attempt @1+ "1210" "1221" "false"
application_record_cancelled @0+application_record_cancelled @1+ "1223" "1229" "false"
application_result_is_attributable_failure @0+application_result_is_attributable_failure @1+ "1231" "1235" "false"
application_job_execution_init @0+application_job_execution_init @1+ "1253" "1256" "false"
application_job_execution_cancel @0+application_job_execution_cancel @1+ "1258" "1262" "false"
application_index_response_succeeded @0+application_index_response_succeeded @1+ "1269" "1277" "false"
application_consume_attempt @0+application_consume_attempt @1+ "1279" "1308" "false"
application_recovery_record_suspects @0+application_recovery_record_suspects @1+ "1310" "1355" "false"
application_job_try_partial @0+application_job_try_partial @1+ "1357" "1382" "false"
application_job_recover @0+application_job_recover @1+ "1384" "1433" "false"
application_auto_index_retry_pending_locked @0+application_auto_index_retry_pending_locked @1+ "1439" "1485" "false"
application_job_publish @0+application_job_publish @1+ "1487" "1518" "false"
application_job_thread @0+application_job_thread @1+ "1520" "1557" "false"
application_find_job_locked @0+application_find_job_locked @1+ "1559" "1567" "false"
application_find_active_job_locked @0+application_find_active_job_locked @1+ "1572" "1580" "false"
application_index_project_key @0+application_index_project_key @1+ "1582" "1587" "false"
application_active_job_count_locked @0+application_active_job_count_locked @1+ "1589" "1597" "false"
application_index_args_normalize_defaults @0+application_index_args_normalize_defaults @1+ "1604" "1621" "false"
application_index_args_equal @0+application_index_args_equal @1+ "1623" "1641" "false"
application_job_subscribe_locked @0+application_job_subscribe_locked @1+ "1645" "1706" "false"
application_job_subscribe @0+application_job_subscribe @1+ "1708" "1717" "false"
application_job_unsubscribe_locked @0+application_job_unsubscribe_locked @1+ "1719" "1727" "false"
application_auto_index_release_locked @0+application_auto_index_release_locked @1+ "1732" "1746" "false"
application_auto_index_cancel_join @0+application_auto_index_cancel_join @1+ "1748" "1770" "false"
application_update_cancel_locked @0+application_update_cancel_locked @1+ "1772" "1781" "false"
application_update_owner_release_locked @0+application_update_owner_release_locked @1+ "1783" "1797" "false"
application_update_version_valid @0+application_update_version_valid @1+ "1799" "1810" "false"
application_update_publish_terminal_locked @0+application_update_publish_terminal_locked @1+ "1812" "1838" "false"
application_update_thread @0+application_update_thread @1+ "1840" "1889" "false"
application_update_subscribe_locked @0+application_update_subscribe_locked @1+ "1891" "1929" "false"
application_update_reap @0+application_update_reap @1+ "1931" "1964" "false"
application_auto_index_args @0+application_auto_index_args @1+ "1966" "1979" "false"
application_background_initialize_impl @0+application_background_initialize_impl @1+ "1981" "2062" "false"
application_background_initialize @0+application_background_initialize @1+ "2064" "2068" "false"
application_jsonrpc_success @0+application_jsonrpc_success @1+ "2070" "2077" "false"
application_update_notice_inject @0+application_update_notice_inject @1+ "2079" "2090" "false"
application_watch_job_subscription_exists_locked @0+application_watch_job_subscription_exists_locked @1+ "2092" "2103" "false"
application_watch_job_subscribe_late_session_locked @0+application_watch_job_subscribe_late_session_locked @1+ "2109" "2132" "false"
application_watch_job_subscribe_sessions_locked @0+application_watch_job_subscribe_sessions_locked @1+ "2137" "2181" "false"
application_watch_job_unsubscribe_session_locked @0+application_watch_job_unsubscribe_session_locked @1+ "2183" "2200" "false"
application_watch_job_unsubscribe_job_locked @0+application_watch_job_unsubscribe_job_locked @1+ "2202" "2216" "false"
application_job_wait_for_session @0+application_job_wait_for_session @1+ "2218" "2240" "false"
application_index_execute @0+application_index_execute @1+ "2242" "2303" "false"
application_session_open @0+application_session_open @1+ "2305" "2345" "false"
application_set_context @0+application_set_context @1+ "2347" "2411" "false"
application_oversized_response_error @0+application_oversized_response_error @1+ "2422" "2455" "false"
application_framable_response @0+application_framable_response @1+ "2463" "2470" "false"
cbm_daemon_application_framable_response_for_test @0+cbm_daemon_application_framable_response_for_test @1+ "2472" "2475" "false"
application_mcp_request @0+application_mcp_request @1+ "2477" "2535" "false"
application_tool_request @0+application_tool_request @1+ "2537" "2571" "false"
application_set_ui_config @0+application_set_ui_config @1+ "2573" "2609" "false"
application_ui_readiness_proof @0+application_ui_readiness_proof @1+ "2611" "2627" "false"
application_request_dispatch @0+application_request_dispatch @1+ "2629" "2672" "false"
application_request @0+application_request @1+ "2674" "2760" "false"
application_cancel_jobs_locked @0+application_cancel_jobs_locked @1+ "2762" "2768" "false"
application_request_cancel @0+application_request_cancel @1+ "2770" "2798" "false"
application_session_cancel @0+application_session_cancel @1+ "2800" "2856" "false"
application_session_close @0+application_session_close @1+ "2858" "2898" "false"
cbm_daemon_application_set_permanent @0+cbm_daemon_application_set_permanent @1+ "2900" "2907" "false"
cbm_daemon_application_new @0+cbm_daemon_application_new @1+ "2909" "2997" "false"
cbm_daemon_application_shutdown @0+cbm_daemon_application_shutdown @1+ "2999" "3055" "false"
cbm_daemon_application_free_with_timeout @0+cbm_daemon_application_free_with_timeout @1+ "3057" "3126" "false"
cbm_daemon_application_free @0+cbm_daemon_application_free @1+ "3128" "3130" "false"
cbm_daemon_application_runtime_callbacks @0+cbm_daemon_application_runtime_callbacks @1+ "3132" "3146" "false"
application_client_exchange_tagged @0+application_client_exchange_tagged @1+ "3148" "3190" "false"
application_client_exchange @0+application_client_exchange @1+ "3192" "3198" "false"
returned: 127
total: 144
total_relation: eq
has_more: true
truncated: true
truncation_reason: output_budget
next_cursor: q1.u0911bd5153455afeg2.6b1590743e407166.631dcb67d00cdf81484e3ece670ffbf4.127.64768524b792413a
next_offset: 127
+39
View File
@@ -0,0 +1,39 @@
import { expect, it } from 'vitest';
import { RpcIntelligenceClient } from './rpc-client';
import { FakeRpc } from '../test-support/rpc-recordings';
const first = 'rows: 1 (cols: n.name)\n first\nreturned: 1\ntotal: 2\ntotal_relation: eq\nhas_more: true\ntruncated: true\ntruncation_reason: output_budget\nnext_cursor: q1.next\nnext_offset: 1\n';
const last = 'rows: 1 (cols: n.name)\n last\nreturned: 1\ntotal: 2\noffset: 1\ntotal_relation: eq\nhas_more: false\ntruncated: false\n';
function client(end = last) {
const rpc = new FakeRpc([
{ tool: 'query_graph', when: args => args.cursor === 'q1.next', text: end },
{ tool: 'query_graph', text: first },
]);
return { rpc, client: new RpcIntelligenceClient({ fetch: rpc.fetch }) };
}
it('reads cursor pages without losing the project, query or row order', async () => {
const { client: c, rpc } = client();
expect(await c.queryRows('project-a', 'MATCH query')).toEqual([{ 'n.name': 'first' }, { 'n.name': 'last' }]);
expect(rpc.calls[1].args).toEqual({ project: 'project-a', query: 'MATCH query', cursor: 'q1.next' });
});
it('keeps truncation metadata on the single-page API', async () => {
expect(await client().client.queryGraph('p', 'q')).toMatchObject({ total: 2, returned: 1, truncated: true, nextCursor: 'q1.next' });
});
it.each([last.replace('offset: 1', 'offset: 0'), last.replace('total: 2', 'total: 3'), last.replace('n.name', 'n.file_path')])(
'does not merge incompatible pages into a plausible complete answer', async end => {
await expect(client(end).client.queryRows('p', 'q')).rejects.toThrow('result snapshot');
},
);
it('does not turn a bounded engine result without continuation into complete rows', async () => {
const rpc = new FakeRpc([{ tool: 'query_graph', text: first.replace('next_cursor: q1.next\n', '') }]);
await expect(new RpcIntelligenceClient({ fetch: rpc.fetch }).queryRows('p', 'q')).rejects.toThrow('Incomplete query results');
});
it('rejects a repeated cursor', async () => {
await expect(client(last.replace('has_more: false', 'has_more: true') + 'next_cursor: q1.next\n').client.queryRows('p', 'q'))
.rejects.toThrow('Incomplete query results');
});
+6
View File
@@ -281,6 +281,12 @@ describe('die Werkzeuge, die erst auf Bitte JSON liefern', () => {
expect(rpc.calls[0].args['since']).toBe('HEAD~3');
expect(rpc.calls[0].args['depth']).toBe(2);
});
it('vergleicht den Working tree explizit mit HEAD, auch ohne lokalen main-Branch', async () => {
const { client: c, rpc } = client([{ tool: 'detect_changes', json: { changed_files: [] } }]);
await c.detectChanges(RECORDED_PROJECT);
expect(rpc.calls[0].args['since']).toBe('HEAD');
});
});
describe('Suche', () => {
+25 -6
View File
@@ -210,8 +210,8 @@ export class RpcIntelligenceClient {
* schreibt an derselben Stelle `""`, und ohne die Uebersetzung wuerde ein
* Bindestrich als Dateiname durch das Produkt wandern.
*/
async queryGraph(project: string, query: string): Promise<QueryGraphResult> {
const text = await this.text('query_graph', { project, query });
async queryGraph(project: string, query: string, cursor?: string): Promise<QueryGraphResult> {
const text = await this.text('query_graph', { project, query, ...(cursor ? { cursor } : {}) });
let parsed: CompactRows;
try {
parsed = parseCompactRows(text);
@@ -219,16 +219,33 @@ export class RpcIntelligenceClient {
throw asParseFailure('query_graph', error);
}
return {
columns: parsed.columns,
...parsed,
rows: parsed.rows.map((cells) => cells.map(normalizeCell)),
total: parsed.total,
};
}
/** Tabellarische Abfrage, schon als ein Objekt je Zeile. */
async queryRows(project: string, query: string): Promise<Record<string, string>[]> {
const result = await this.queryGraph(project, query);
return rowsToObjects(result.columns, result.rows);
const rows = [...result.rows];
let page = result;
const cursors = new Set<string>();
while (page.hasMore || page.truncated || page.nextCursor) {
if (!page.nextCursor || cursors.has(page.nextCursor) || cursors.size >= 20 || rows.length >= 10000) {
throw new EngineError('query_graph', `Incomplete query results: ${page.truncationReason ?? 'retrieval bound reached'} (${rows.length} rows read).`);
}
if (page.nextOffset !== undefined && page.nextOffset !== rows.length)
throw new EngineError('query_graph', 'Query continuation does not follow the returned rows.');
cursors.add(page.nextCursor);
page = await this.queryGraph(project, query, page.nextCursor);
if (page.offset !== rows.length || page.total !== result.total
|| JSON.stringify(page.columns) !== JSON.stringify(result.columns) || page.rows.length === 0)
throw new EngineError('query_graph', 'Query pages disagree about their result snapshot.');
rows.push(...page.rows);
}
if (page.totalRelation === 'gte' || (result.total !== undefined && result.total > rows.length))
throw new EngineError('query_graph', `Incomplete query results (${rows.length} rows read).`);
return rowsToObjects(result.columns, rows);
}
async listProjects(): Promise<ListProjectsResult> {
@@ -386,7 +403,9 @@ export class RpcIntelligenceClient {
): Promise<DetectChangesResult> {
return readDetectChanges(await this.json('detect_changes', {
project,
...(sinceRef ? { since: sinceRef } : {}),
// The UI's default is the working tree. Omitting the ref asks the
// daemon to compare with main, which may not exist in this clone.
since: sinceRef || 'HEAD',
...(args.depth !== undefined ? { depth: args.depth } : {}),
}));
}
+9
View File
@@ -157,6 +157,15 @@ export interface QueryGraphResult {
columns: string[];
rows: string[][];
total?: number;
returned?: number;
totalRelation?: string;
hasMore?: boolean;
truncated?: boolean;
offset?: number;
nextOffset?: number;
nextCursor?: string;
truncationReason?: string;
warning?: string;
}
/** Ein Kandidat, wenn ein blosser Name auf mehr als ein Symbol passte. */
+7 -4
View File
@@ -160,7 +160,7 @@ export async function callTool(
try {
return await callToolUnobserved(name, args, opts);
} catch (err) {
announceFailure(name, err);
announceFailure(name, err, args);
throw err;
}
}
@@ -173,16 +173,19 @@ export async function callTool(
* everything else is a failure (error). An aborted call is the caller's own
* doing and is not announced.
*/
function announceFailure(name: string, err: unknown): void {
function announceFailure(name: string, err: unknown, args: Record<string, unknown>): void {
if (!hasErrorObservers()) {
return;
}
if (err instanceof Error && err.name === 'AbortError') {
return;
}
const project = typeof args['project'] === 'string' ? args['project']
: typeof args['project_name'] === 'string' ? args['project_name'] : '';
if (err instanceof RpcError) {
const level = err.notAllowed ? 'info' : err.kind === 'tool' ? 'warn' : 'error';
reportError({
project,
source: 'rpc',
level,
message: err.message,
@@ -192,7 +195,7 @@ function announceFailure(name: string, err: unknown): void {
return;
}
const message = err instanceof Error ? err.message : String(err);
reportError({ source: 'rpc', level: 'error', message: `/rpc ${name}: ${message}`, stack: stackOf(err) });
reportError({ project, source: 'rpc', level: 'error', message: `/rpc ${name}: ${message}`, stack: stackOf(err) });
}
async function callToolUnobserved(
@@ -333,7 +336,7 @@ export async function callToolJson<T = unknown>(
`/rpc ${name}: Antworttext war kein JSON (${(err as Error).message})`,
{ bodyText: truncate(text) },
);
announceFailure(name, shape);
announceFailure(name, shape, args);
throw shape;
}
}
+14
View File
@@ -31,6 +31,7 @@
import type { SymbolSearchHit } from '../core/intelligence-provider';
import type { RankedHit } from './semantic-search';
import { queryTerms, rankHits } from './semantic-search';
import { fileQueryPath } from './path-query';
/**
* Wie viele Kandidaten ein Wort beitragen darf.
@@ -66,6 +67,8 @@ export const SEARCH_MIN_QUERY = 2;
* Server, ohne Transport und ohne dass ein Test wissen muss, wie /rpc redet.
*/
export interface SymbolSearcher {
/** Exact File-node lookup; optional for providers that only offer symbol search. */
searchFile?(root: string, path: string, opts?: MeaningOptions): Promise<SymbolSearchHit | undefined>;
searchSymbols(
root: string,
pattern: string,
@@ -147,6 +150,17 @@ export async function searchByMeaning(
opts: MeaningOptions = {},
fanInOf: (hit: SymbolSearchHit) => number = () => 0,
): Promise<MeaningAnswer> {
const path = fileQueryPath(query);
if (path && searcher.searchFile) {
if (opts.signal?.aborted) return { hits: [], candidates: [], complete: false, aborted: true };
try {
const file = await searcher.searchFile(root, path, opts);
if (opts.signal?.aborted) return { hits: [], candidates: [], complete: false, aborted: true };
if (file?.filePath === path) {
return { hits: rankHits([file], query, fanInOf), candidates: [file], complete: false, aborted: false };
}
} catch { /* Keep available token matches; never invent the requested file. */ }
}
const terms = queryTerms(query);
if (terms.length === 0) {
return { hits: [], candidates: [], complete: true, aborted: false };
+20 -1
View File
@@ -46,6 +46,7 @@ import type { CodeAtlasSymbolKind } from '../core/focus-protocol';
import type { SymbolSearchHit } from '../core/intelligence-provider';
import type { RankedHit } from './semantic-search';
import { rankHits } from './semantic-search';
import { fileQueryPath } from './path-query';
/** Wie viele Sofort-Vorschlaege hoechstens gerechnet werden. */
export const MAX_LOCAL_SUGGESTIONS = 10;
@@ -66,6 +67,9 @@ export function settledSearchHits(indexed: RankedHit[], loaded: RankedHit[]): {
hits: RankedHit[];
source: 'index' | 'loaded';
} {
if (loaded[0]?.pathMatch === 'file' && indexed[0]?.pathMatch !== 'file') {
return { hits: loaded, source: 'loaded' };
}
if (indexed.length === 0 && loaded.length > 0) {
return { hits: loaded, source: 'loaded' };
}
@@ -116,6 +120,21 @@ export function localCandidates(index: LocalIndex): SymbolSearchHit[] {
return [...index.symbols, ...fileCandidates(index.files)];
}
/** Rank the immediate local/cache pool without substituting a different file.
* A full path may be typed before the corresponding tree branch has loaded.
* Empty provisional results let the UI show its pending index lookup, so Enter
* cannot open an unrelated token match while that exact request is in flight.
*/
export function rankLocalCandidates(
candidates: readonly SymbolSearchHit[], query: string,
fanInOf: (hit: SymbolSearchHit) => number = () => 0,
limit: number = MAX_LOCAL_SUGGESTIONS,
): RankedHit[] {
const path = fileQueryPath(query);
const eligible = path ? candidates.filter(hit => hit.filePath === path) : candidates;
return rankHits(eligible, query, fanInOf).slice(0, limit);
}
/**
* Die Sofort-Vorschlaege zu einer Anfrage, ohne einen einzigen Serverweg.
*
@@ -131,5 +150,5 @@ export function localSuggestions(
if (index.symbols.length === 0 && index.files.length === 0) {
return [];
}
return rankHits(localCandidates(index), query, fanInOf).slice(0, limit);
return rankLocalCandidates(localCandidates(index), query, fanInOf, limit);
}
+7
View File
@@ -0,0 +1,7 @@
/** A repository-relative path request, kept intact instead of split into words. */
export function fileQueryPath(query: string): string | undefined {
const path = query.trim().replace(/\\/g, '/').replace(/^(?:\.\/)+/, '');
if (!path || path.startsWith('/') || path.endsWith('/') || /^[A-Za-z]:/.test(path)
|| /[\x00-\x1f]/.test(path) || path.split('/').some(part => !part || part === '..' || part === '.')) return undefined;
return path.includes('/') || /\.[^\s.]+$/.test(path) ? path : undefined;
}
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from 'vitest';
import type { SymbolSearchHit } from '../core/intelligence-provider';
import { CbmRpcProvider } from '../provider/cbm-rpc-provider';
import { RpcIntelligenceClient } from '../provider/rpc-client';
import { FakeRpc } from '../test-support/rpc-recordings';
import { searchByMeaning } from './find-by-meaning';
import { fileCandidates, localSuggestions, rankLocalCandidates, settledSearchHits } from './local-suggestions';
import { fileQueryPath } from './path-query';
import { rankHits } from './semantic-search';
const path = 'src/daemon/application.c';
const wrong: SymbolSearchHit = { name: 'CBM_DAEMON_APPLICATION_INTERNAL_H', kind: 'unknown',
filePath: 'src/daemon/application_internal.h', qualifiedName: 'p.internal.header', line: 5 };
const symbol: SymbolSearchHit = { name: 'cbm_daemon_application_new', kind: 'function',
filePath: path, qualifiedName: 'p.application.new', line: 2909 };
describe('explicit file path search', () => {
it('puts the exact loaded file before popular symbols and similarly named files', () => {
const hits = localSuggestions({ symbols: [wrong, symbol], files: [wrong.filePath!, path] }, path, () => 1000000);
expect(hits[0].pathMatch).toBe('file');
expect(hits[0].hit.filePath).toBe(path);
expect(hits[0].hit.line).toBeUndefined();
expect(hits[0].matched).toEqual(['exact file path']);
expect(hits[1].hit).toEqual(symbol);
});
it('keeps an exact path lookup pending when the loaded tree and cached symbols contain only other files', () => {
expect(localSuggestions({ symbols: [wrong], files: [] }, path)).toEqual([]);
// App combines a previous prefix reply with the current loaded pool.
// Neither source may offer the similarly named header as an Enter target.
const cached = { ...wrong, name: 'applicationCached', filePath: 'tests/test_application.c' };
expect(rankLocalCandidates([cached, wrong], path, () => 1000000)).toEqual([]);
expect(rankLocalCandidates([cached, wrong], './src/daemon/application.c')).toEqual([]);
expect(rankLocalCandidates([cached, wrong], 'src\\daemon\\application.c')).toEqual([]);
});
it('permits only exact-file symbols provisionally and preserves ordinary word suggestions', () => {
const hits = rankLocalCandidates([wrong, symbol], path);
expect(hits.map(row => row.hit)).toEqual([symbol]);
expect(hits[0].pathMatch).toBe('symbol');
expect(rankLocalCandidates([wrong], 'application')[0].hit).toEqual(wrong);
});
it('keeps an exact loaded file when a nonempty token reply only finds other symbols', () => {
const loaded = rankHits(fileCandidates([path]), path);
const indexed = rankHits([wrong], path);
expect(settledSearchHits(indexed, loaded)).toEqual({ hits: loaded, source: 'loaded' });
});
it('prefers a newly verified exact index file over the loaded candidate', () => {
const loaded = rankHits(fileCandidates([path]), path);
const indexed = rankHits([{ name: 'application.c', kind: 'unknown', filePath: path }], path);
expect(settledSearchHits(indexed, loaded)).toEqual({ hits: indexed, source: 'index' });
});
it('looks up the complete literal path before token queries, including without a loaded tree', async () => {
const rpc = new FakeRpc([{ tool: 'query_graph', text: 'rows: 1 (cols: n.name n.file_path)\n application.c src/daemon/application.c\nreturned: 1\ntotal: 1\ntotal_relation: eq\nhas_more: false\ntruncated: false\n' }]);
const provider = new CbmRpcProvider(new RpcIntelligenceClient({ fetch: rpc.fetch }));
const answer = await searchByMeaning(provider, '/workspace', path, { projectName: 'cbm-pr2068' });
expect(answer.hits).toHaveLength(1);
expect(answer.hits[0].hit).toEqual({ name: 'application.c', kind: 'unknown', filePath: path });
expect(rpc.calls).toHaveLength(1);
expect(rpc.calls[0].args.query).toContain('n.file_path = "src/daemon/application.c"');
expect(answer.complete).toBe(false); // An exact lookup is not a complete prefix cache.
});
it('falls back without inventing a file when no exact graph file exists', async () => {
const searchFile = vi.fn(async () => undefined), searchSymbols = vi.fn(async () => [wrong]);
const answer = await searchByMeaning({ searchFile, searchSymbols }, '/', path);
expect(searchFile).toHaveBeenCalledWith('/', path, {});
expect(answer.hits.every(hit => hit.hit.filePath !== path)).toBe(true);
expect(searchSymbols).toHaveBeenCalled();
});
it('does not attribute a mismatched provider file to the requested path', async () => {
const answer = await searchByMeaning({ searchFile: async () => wrong, searchSymbols: async () => [] }, '/', path);
expect(answer.hits).toEqual([]);
});
it('discards an exact lookup result after cancellation', async () => {
const controller = new AbortController();
const searchSymbols = vi.fn(async () => []);
const answer = await searchByMeaning({ searchFile: async () => {
controller.abort(); return { name: 'application.c', kind: 'unknown', filePath: path };
}, searchSymbols }, '/', path, { signal: controller.signal });
expect(answer.aborted).toBe(true);
expect(answer.hits).toEqual([]);
expect(searchSymbols).not.toHaveBeenCalled();
});
it('normalizes relative path notation without fuzzy case folding or directory traversal', () => {
expect(fileQueryPath('./src/daemon/application.c')).toBe(path);
expect(fileQueryPath('src\\daemon\\application.c')).toBe(path);
expect(fileQueryPath('README.md')).toBe('README.md');
for (const value of ['../private.c', '/tmp/file.c', 'C:\\private.c', 'src/../private.c', 'src/', 'find callers'])
expect(fileQueryPath(value)).toBeUndefined();
expect(rankHits(fileCandidates(['README.md']), 'readme.md')[0]?.pathMatch).toBeUndefined();
});
it('finds a short exact filename even when its tokens are too short for word search', () => {
const hit = rankHits(fileCandidates(['a.c']), 'a.c')[0];
expect(hit.hit.filePath).toBe('a.c');
expect(hit.pathMatch).toBe('file');
});
});
+20 -6
View File
@@ -7,8 +7,9 @@
* Bedeutungen von "passt am besten" zu haben, und die Abweichung faende
* niemand, weil beide Listen plausibel aussehen.
*
* Aenderungen gegenueber dem Original: nur der Importpfad von
* SymbolSearchHit. Was diese Datei an Signalen NICHT bekommt, entscheidet der
* Aenderungen gegenueber dem Original: Importpfad von SymbolSearchHit und
* explizite Dateipfad-Anfragen vor der Wort-Rangfolge (PR 2068).
* Was diese Datei an Signalen NICHT bekommt, entscheidet der
* Aufrufer: an diesem Backend bleiben `isTest` und `isExported` undefiniert,
* weil die flache search_graph-Form sie nicht traegt (UPSTREAM-ASKS.md,
* Ask 5). Undefiniert heisst hier weder wahr noch falsch: der Bonus und der
@@ -69,6 +70,7 @@
*/
import type { SymbolSearchHit } from '../core/intelligence-provider';
import { fileQueryPath } from './path-query';
/**
* How many words one query may carry.
@@ -125,6 +127,8 @@ export interface RankedHit {
matched: string[];
/** How many symbols reach it, when the index measured that. */
fanIn: number;
/** Exact file requests outrank token matches, independently of popularity. */
pathMatch?: 'file' | 'symbol';
}
/**
@@ -284,21 +288,30 @@ export function rankHits(
fanInOf: (hit: SymbolSearchHit) => number = () => 0
): RankedHit[] {
const terms = queryTerms(query);
if (terms.length === 0) {
const exactPath = fileQueryPath(query);
if (terms.length === 0 && !exactPath) {
return [];
}
const ranked: RankedHit[] = [];
const seen = new Set<string>();
for (const hit of hits) {
if (!isNavigable(hit)) {
const pathMatches = exactPath !== undefined && hit.filePath === exactPath;
const fileMatch = pathMatches && (hit.kind === 'module'
|| (hit.kind === 'unknown' && !hit.qualifiedName && hit.line === undefined)
|| hit.name === exactPath || hit.name === exactPath.split('/').at(-1));
if (!fileMatch && !isNavigable(hit)) {
continue;
}
const key = hit.qualifiedName ?? `${hit.filePath ?? ''}#${hit.name}`;
const key = fileMatch ? `file:${hit.filePath}` : hit.qualifiedName ?? `${hit.filePath ?? ''}#${hit.name}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
const scored = scoreHit(hit, terms, fanInOf(hit));
if (pathMatches) {
scored.pathMatch = fileMatch ? 'file' : 'symbol';
scored.matched = [fileMatch ? 'exact file path' : 'symbol in requested file'];
}
if (scored.matched.length === 0) {
continue;
}
@@ -315,7 +328,8 @@ export function rankHits(
* whatever the index happened to return, which is not a promise it makes.
*/
export function compareRanked(a: RankedHit, b: RankedHit): number {
return b.score - a.score
const priority = (entry: RankedHit) => entry.pathMatch === 'file' ? 2 : entry.pathMatch === 'symbol' ? 1 : 0;
return priority(b) - priority(a) || b.score - a.score
|| b.fanIn - a.fanIn
|| (a.hit.qualifiedName ?? a.hit.name).localeCompare(b.hit.qualifiedName ?? b.hit.name);
}
+16 -2
View File
@@ -1,4 +1,5 @@
.atlas-shell { grid-template-rows: auto minmax(0, 1fr) auto; }
.atlas-shell[data-has-status=true] { grid-template-rows: auto auto minmax(0, 1fr) auto; }
.atlas-header { gap: 12px; padding: 12px 18px; overflow: visible; position: relative; z-index: 20; border-bottom-color: var(--atlas-phosphor-mute); box-shadow: 0 2px 18px var(--atlas-glow); }
.atlas-brand { font-size: 15px; font-weight: 700; letter-spacing: .22em; color: var(--atlas-phosphor); text-shadow: 0 0 16px var(--atlas-glow); }
.atlas-version, .atlas-version-suffix { font-size: 10px; color: var(--atlas-dim); }
@@ -175,7 +176,7 @@
.atlas-chat-column[data-graph=true] > .cbm-chat-dock { flex: 1 0 440px; min-height: 440px; }
.atlas-chat-column[data-graph=true] > .atlas-galaxy-host { flex: 0 0 var(--atlas-chat-graph-height); min-height: 320px; border-left: 1px solid var(--atlas-line); }
.atlas-chat-column > .atlas-splitter { flex: 0 0 8px; }
.atlas-selection-host { flex: 0 0 auto; min-width: 0; min-height: 0; max-height: min(300px, 34vh); overflow: auto; border-bottom: 1px solid var(--atlas-line); }
.atlas-selection-host { flex: 1 1 55%; min-width: 0; min-height: 240px; max-height: none; overflow: auto; border-bottom: 1px solid var(--atlas-line); }
.atlas-selection-host[hidden] { display: none; }
.atlas-selection-mount { min-width: 0; }
.atlas-chat-column[data-selection-inspector=true] { overflow-y: auto; }
@@ -190,7 +191,7 @@
/* Keep the canvas usable in both Explore hosts, including short windows. */
.atlas-shell[data-workspace=explore] .atlas-side { overflow-y: auto; }
.atlas-shell[data-workspace=explore] .atlas-side .atlas-twin { min-height: 180px; max-height: max(180px, calc(100% - 340px)); }
.atlas-shell[data-workspace=explore] .atlas-galaxy-host:has(.atlas-galaxy[data-visible=true]) { min-height: 320px; }
.atlas-shell[data-workspace=explore] .atlas-galaxy-host:has(.atlas-galaxy[data-visible=true]) { min-height: 220px; flex: 1 1 35%; }
.atlas-shell[data-workspace=explore] .atlas-galaxy { position: relative; }
.atlas-shell[data-workspace=explore] .atlas-galaxy-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 8px; padding: 8px; overflow: visible; }
.atlas-shell[data-workspace=explore] .atlas-galaxy-head-row { grid-column: 2; grid-row: 1; gap: 4px; align-items: center; }
@@ -222,3 +223,16 @@
.atlas-command-palette .atlas-command-examples { position: static; border: none; background: transparent; padding: 0; }
.atlas-search-options:empty { display: none; }
@media (max-width: 1200px) { .atlas-search-action kbd, .atlas-header .atlas-chips, .atlas-version-suffix { display: none; } .atlas-workspace-tabs { margin-left: 0; } }
.atlas-analysis-overlay { position: fixed; z-index: 150; inset: 68px 22px 36px; background: var(--atlas-bg); border: 1px solid var(--atlas-cyan); border-radius: 12px; overflow: hidden; box-shadow: 0 12px 48px #0009; }
.atlas-analysis-action { display: block; width: 100%; text-align: left; color: var(--atlas-cyan); background: var(--atlas-panel); border: 1px solid var(--atlas-line); border-radius: 6px; padding: 10px; font: inherit; cursor: pointer; }
.atlas-analysis-action:disabled { opacity: .5; cursor: default; }
.atlas-analysis-overlay:focus-visible, .atlas-analysis-action:focus-visible { outline: 2px solid var(--atlas-cyan); outline-offset: 3px; }
@media (max-width: 760px) { .atlas-analysis-overlay { inset: 96px 8px 28px; } }
/* Project identity takes priority over aggregate graph counts in the header. */
.atlas-header:has(.atlas-project-switcher) .atlas-chips { display: none; }
.atlas-header:has(.atlas-project-switcher) { gap: 8px; }
.atlas-header .atlas-project-switcher { flex-basis: 180px; min-width: 130px; }
@media (min-width: 1280px) { .atlas-header .atlas-workspace-tabs { margin-left: 6px; }.atlas-header .atlas-project-switcher-label { display: none; } }
@media (max-width: 1100px) { .atlas-header:has(.atlas-project-switcher) .atlas-version { display: none; }.atlas-header .atlas-workspace-tabs button { padding-inline: 8px; } }
+57 -10
View File
@@ -3,7 +3,7 @@ import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import SystemWorkspace, { type SystemWorkspaceProps } from './SystemWorkspace';
import { readProcesses } from '../projects/projects-model';
import { readLogs, readProcesses } from '../projects/projects-model';
let container: HTMLDivElement;
let root: Root;
@@ -18,8 +18,8 @@ const click = async (label: string) => {
expect(button).toBeDefined();
await act(async () => { button?.click(); });
};
const render = async (api = source(), onOpenProjects = vi.fn()) => {
await act(async () => { root.render(<SystemWorkspace api={api} onOpenProjects={onOpenProjects} pollMs={100} />); });
const render = async (api = source(), onOpenProjects = vi.fn(), project = 'fixture-a') => {
await act(async () => { root.render(<SystemWorkspace api={api} onOpenProjects={onOpenProjects} pollMs={100} project={project} />); });
return api;
};
@@ -78,19 +78,56 @@ describe('System workspace', () => {
await act(async () => { await vi.advanceTimersByTimeAsync(500); });
expect(api.indexJobs).toHaveBeenCalledTimes(1);
});
it('filters and copies exactly the visible log tail', async () => {
it('fetches errors from retained history rather than filtering the latest 200 info lines', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
const api = await render();
await click('Logs');
expect(api.logs).toHaveBeenCalledWith(200);
const select = container.querySelector('select')!;
const api = source();
api.logs = vi.fn().mockResolvedValueOnce({ lines: Array.from({ length: 200 }, (_, i) => `info ${i}`), total: 249, persistent: true })
.mockResolvedValue({ lines: ['older retained error'], total: 49, persistent: true });
await render(api); await click('Logs');
expect(api.logs).toHaveBeenLastCalledWith(200, undefined, undefined, {});
const select = container.querySelector<HTMLSelectElement>('[aria-label="Log severity"]')!;
await act(async () => { select.value = 'error'; select.dispatchEvent(new Event('change', { bubbles: true })); });
expect(container.querySelector('.system-log')?.textContent).toBe('level=ERROR msg=Index_failed');
expect(api.logs).toHaveBeenLastCalledWith(200, 'error', undefined, {});
expect(container.querySelector('.system-log')?.textContent).toBe('older retained error');
expect(container.textContent).toContain('49 matching retained events');
await click('Copy visible');
expect(writeText).toHaveBeenCalledWith('level=ERROR msg=Index_failed');
expect(writeText).toHaveBeenCalledWith('older retained error');
expect(container.querySelector('[role="status"]')?.textContent).toBe('Visible lines copied');
});
it('passes scopes to the daemon and refuses ignored scope filters', async () => {
const api = source();
api.logs = vi.fn().mockResolvedValueOnce({ lines: ['all history'], total: 10, persistent: true })
.mockResolvedValueOnce({ lines: ['project A'], total: 1, persistent: true, scope: 'project', project: 'fixture-a' })
.mockResolvedValue({ lines: ['wrong daemon-wide result'], total: 10, persistent: true, scope: 'daemon' });
await render(api); await click('Logs');
const select = container.querySelector<HTMLSelectElement>('[aria-label="Log scope"]')!;
await act(async () => { select.value = 'project'; select.dispatchEvent(new Event('change', { bubbles: true })); });
expect(api.logs).toHaveBeenLastCalledWith(200, undefined, 'fixture-a', {});
expect(container.querySelector('.system-log')?.textContent).toContain('project A');
await act(async () => { select.value = 'unattributed'; select.dispatchEvent(new Event('change', { bubbles: true })); });
expect(api.logs).toHaveBeenLastCalledWith(200, undefined, undefined, { scope: 'unattributed' });
expect(container.querySelector('[role="alert"]')?.textContent).toContain('did not confirm unattributed scope');
expect(container.querySelector('.system-log')?.textContent).not.toContain('project A');
expect(container.querySelector('.system-log')?.textContent).not.toContain('wrong daemon-wide');
});
it('debounces text search and requires a confirmed full-history query', async () => {
const api = source();
api.logs = vi.fn().mockImplementation(async (_limit, _level, _project, filters) => ({
lines: filters?.query ? ['archived src/broken.ts failure'] : ['latest info'], total: 1,
persistent: true, query: filters?.query ?? '',
}));
await render(api); await click('Logs');
const input = container.querySelector<HTMLInputElement>('input[type="search"]')!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'src/broken.ts');
input.dispatchEvent(new Event('input', { bubbles: true }));
});
expect(api.logs).toHaveBeenLastCalledWith(200, undefined, undefined, {});
await act(async () => { await vi.advanceTimersByTimeAsync(250); });
expect(api.logs).toHaveBeenLastCalledWith(200, undefined, undefined, { query: 'src/broken.ts' });
expect(container.querySelector('.system-log')?.textContent).toContain('archived src/broken.ts failure');
});
it('retains the last successful reading and shows refresh failure', async () => {
const api = source();
api.processes = vi.fn().mockResolvedValueOnce(processes()).mockRejectedValue(new Error('Connection lost'));
@@ -107,4 +144,14 @@ describe('System workspace', () => {
expect(container.querySelector('#system-tab-indexes')?.getAttribute('aria-selected')).toBe('true');
expect(document.activeElement?.id).toBe('system-tab-indexes');
});
it('shows persisted error severity, source, receipt time and path in the real log view', async () => {
const api = source();
api.logs = vi.fn().mockResolvedValue(readLogs({ lines: ['fixture failure'], total: 1, persistent: true, retention_limit: 5000, generation: 'fixture', records: [{ id: 17, ts: '2026-09-09T12:00:00Z', level: 'error', source: 'ui.index.done', message: 'project=fixture path=src/broken.ts rc=err' }] }));
await render(api); await click('Logs');
const record = container.querySelector('.system-log-record.system-log-error');
expect(record?.textContent).toContain('ERROR'); expect(record?.textContent).toContain('ui.index.done');
expect(record?.textContent).toContain('src/broken.ts'); expect(record?.textContent).toContain('Event #17');
expect(record?.querySelector('time')?.dateTime).toBe('2026-09-09T12:00:00Z');
expect(container.textContent).toContain('SQLite history');
});
});
+41 -14
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react';
import type { AtlasApi } from '../app/atlas-api';
import { cpuText, filterLogs, logLevel, memoryLabel, memoryText, type LogLevel } from './system-model';
import { cpuText, logLevel, memoryLabel, memoryText } from './system-model';
import { useSystemPoll, type SystemReading } from './useSystemPoll';
import './system.css';
@@ -10,6 +10,7 @@ export interface SystemWorkspaceProps {
active?: boolean;
version?: string;
pollMs?: number;
project?: string;
}
type SystemTab = 'overview' | 'indexes' | 'logs';
@@ -26,34 +27,50 @@ function ReadStatus({ reading, paused }: { reading: SystemReading<unknown>; paus
}
/** Read-only daemon dashboard; project mutations stay in the existing dialog. */
export default function SystemWorkspace({ api, onOpenProjects, active = true, version, pollMs = 3000 }: SystemWorkspaceProps) {
export default function SystemWorkspace({ api, onOpenProjects, active = true, version, pollMs = 3000, project }: SystemWorkspaceProps) {
const [tab, setTab] = useState<SystemTab>('overview');
const [paused, setPaused] = useState(false);
const [query, setQuery] = useState('');
const [level, setLevel] = useState<LogLevel | 'all'>('all');
const [level, setLevel] = useState<'all' | 'error' | 'warn' | 'info'>('all');
const [scope, setScope] = useState<'daemon' | 'project' | 'unattributed'>('daemon');
const [search, setSearch] = useState('');
useEffect(() => { const timer = setTimeout(() => setSearch(query.trim()), 250); return () => clearTimeout(timer); }, [query]);
const [copyStatus, setCopyStatus] = useState('');
const [followTail, setFollowTail] = useState(true);
const logRef = useRef<HTMLDivElement>(null);
const readProcesses = useCallback(() => api.processes(), [api]);
const readLogs = useCallback(() => api.logs(200), [api]);
const selectedProject = scope === 'project' ? project : undefined;
const readLogs = useCallback(async () => {
if (scope === 'project' && !selectedProject) throw new Error('Select a project to read its attributed events.');
const data = await api.logs(200, level === 'all' ? undefined : level, selectedProject, {
...(scope === 'unattributed' ? { scope: 'unattributed' as const } : {}),
...(search ? { query: search } : {}),
});
if ((scope !== 'daemon' || level !== 'all' || search) && data.persistent !== true) throw new Error('Persistent history is unavailable. Filters require the daemon journal.');
if (scope === 'project' && (data.scope !== 'project' || data.project !== selectedProject)) throw new Error('The daemon did not confirm this project scope.');
if (scope === 'unattributed' && data.scope !== 'unattributed') throw new Error('The daemon did not confirm unattributed scope.');
if (search && data.query !== search) throw new Error('This daemon does not confirm full-history search. Refresh the daemon before using this filter.');
return data;
}, [api, level, selectedProject, scope, search]);
const readJobs = useCallback(() => api.indexJobs(), [api]);
const processes = useSystemPoll(readProcesses, active, paused, pollMs);
const logs = useSystemPoll(readLogs, active && tab === 'logs', paused, pollMs);
const jobs = useSystemPoll(readJobs, active && tab === 'indexes', paused, pollMs);
const report = processes.data;
const self = report?.processes.find((process) => process.isSelf);
const visibleLines = useMemo(() => filterLogs(logs.data?.lines ?? [], query, level), [logs.data, query, level]);
const visibleLines = logs.data?.lines ?? [];
const visibleRecords = logs.data?.records;
const selectedReading = tab === 'logs' ? logs : tab === 'indexes' ? jobs : processes;
useEffect(() => {
if (followTail && logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
}, [visibleLines, followTail, tab]);
}, [logs.data, followTail, tab]);
useEffect(() => { setCopyStatus(''); }, [query, level, logs.data]);
const copyLogs = async () => {
try {
if (!navigator.clipboard?.writeText) throw new Error('Clipboard access is unavailable. Select and copy the visible text.');
await navigator.clipboard.writeText(visibleLines.join('\n'));
await navigator.clipboard.writeText(visibleRecords ? visibleRecords.map((record) => `${record.ts} ${record.level.toUpperCase()} ${record.source} #${record.id}\n${record.message}`).join('\n') : visibleLines.join('\n'));
setCopyStatus('Visible lines copied');
} catch (error) {
setCopyStatus(error instanceof Error ? error.message : 'Could not copy the log.');
@@ -118,19 +135,29 @@ export default function SystemWorkspace({ api, onOpenProjects, active = true, ve
<div id="system-panel-logs" role="tabpanel" aria-labelledby="system-tab-logs" hidden={tab !== 'logs'}>
<section className="system-section">
<div className="system-section-heading"><div><h2>Daemon log</h2><p>{report && report.selfPid > 0 ? `Serving process ${report.selfPid}` : 'Serving daemon'} · Latest 200 lines</p></div><button type="button" disabled={visibleLines.length === 0} onClick={() => { void copyLogs(); }}>Copy visible</button></div>
<div className="system-section-heading"><div><h2>Daemon log</h2><p>{report && report.selfPid > 0 ? `Serving process ${report.selfPid}` : 'Serving daemon'} · Latest 200 matching events</p></div><button type="button" disabled={(visibleRecords?.length ?? visibleLines.length) === 0} onClick={() => { void copyLogs(); }}>Copy visible</button></div>
{logs.data && <p className="system-muted">{logs.data.persistent === true ? `SQLite history · ${logs.data.retentionLimit ?? 'bounded'} event retention limit · survives daemon restarts` : 'Persistent history unavailable; this tail may not survive a restart.'} Events identify affected paths or jobs only when the daemon recorded them.</p>}
<div className="system-log-controls">
<label><span>Filter</span><input type="search" value={query} onChange={(event) => setQuery(event.currentTarget.value)} placeholder="Find in this tail…" /></label>
<label><span>Level</span><select value={level} onChange={(event) => setLevel(event.currentTarget.value as LogLevel | 'all')}>{(['all', 'error', 'warn', 'info', 'debug', 'trace', 'other'] as const).map((value) => <option value={value} key={value}>{value === 'all' ? 'All levels' : value === 'other' ? 'Unclassified' : value.toUpperCase()}</option>)}</select></label>
<label><span>Scope</span><select aria-label="Log scope" value={scope} onChange={(event) => setScope(event.currentTarget.value as typeof scope)}>
<option value="daemon">All daemon projects</option>
<option value="project" disabled={!project}>Current project{project ? ` · ${project}` : ' · none selected'}</option>
<option value="unattributed">Unattributed events</option>
</select></label>
<label><span>Search retained history</span><input type="search" maxLength={256} value={query} onChange={(event) => setQuery(event.currentTarget.value)} placeholder="Find a source, path or message…" /></label>
<label><span>Severity</span><select aria-label="Log severity" value={level} onChange={(event) => setLevel(event.currentTarget.value as typeof level)}>
<option value="all">All levels</option><option value="info">Info and above</option>
<option value="warn">Warnings and errors</option><option value="error">Errors</option>
</select></label>
<label className="system-tail-toggle"><input type="checkbox" checked={followTail} onChange={(event) => setFollowTail(event.currentTarget.checked)} /> Follow tail</label>
</div>
<div className="system-log-meta"><span>{visibleLines.length} shown · {logs.data?.total ?? 'Unknown'} lines reported</span><span role="status">{copyStatus}</span></div>
<p className="system-muted">Scope, severity and search filter the retained SQLite history before the 200-event display limit. {scope === 'project' ? `Only explicitly attributed events for ${project ?? 'the selected project'}.` : scope === 'unattributed' ? 'Project ownership was not recorded; no project is inferred from a path.' : 'Includes all projects and events with unknown ownership.'}</p>
<div className="system-log-meta"><span>{visibleRecords?.length ?? visibleLines.length} shown · {logs.data?.total ?? 'Unknown'} matching retained events</span><span role="status">{copyStatus}</span></div>
<div className="system-log" aria-label="Daemon log lines" tabIndex={0} ref={logRef} onScroll={() => {
const element = logRef.current;
if (element && element.scrollHeight - element.scrollTop - element.clientHeight > 32) setFollowTail(false);
}}>
{visibleLines.map((line, index) => <div className={`system-log-line system-log-${logLevel(line)}`} key={index}>{line || '\u00a0'}</div>)}
{visibleLines.length === 0 && <p>{logs.loading ? 'Reading log…' : !logs.data ? 'No log reading available.' : logs.data.lines.length === 0 ? 'The daemon returned an empty log tail.' : 'No lines match these filters.'}</p>}
{visibleRecords ? visibleRecords.map((record) => <div className={`system-log-line system-log-record system-log-${logLevel(`level=${record.level}`)}`} key={`${logs.data?.generation ?? ''}:${record.id}`}><strong>{record.level.toUpperCase() || 'UNCLASSIFIED'}</strong> · <time dateTime={record.ts}>{record.ts || 'Time unavailable'}</time> · {record.source || 'Source unavailable'} · Event #{record.id} · {record.project ? `Project: ${record.project}` : 'Project not recorded'}<pre>{record.message}</pre></div>) : visibleLines.map((line, index) => <div className={`system-log-line system-log-${logLevel(line)}`} key={index}>{line || '\u00a0'}</div>)}
{(visibleRecords?.length ?? visibleLines.length) === 0 && <p>{logs.loading ? 'Reading log…' : !logs.data ? 'No log reading available.' : 'No retained events match these filters.'}</p>}
</div>
</section>
</div>
+2 -1
View File
@@ -39,12 +39,13 @@
.system-job-indexing { color: #69dcea; }.system-job-error { color: #f09eac; }.system-job-done { color: #76e7ae; }
.system-log-controls { display: flex; align-items: end; gap: 14px; flex-wrap: wrap; }
.system-log-controls label { display: grid; gap: 6px; font-size: 12px; color: #acc0b6; }
.system-log-controls label:first-child { flex: 1; min-width: 160px; }
.system-log-controls label:has(input[type='search']) { flex: 1; min-width: 220px; }
.system-log-controls input[type='search'], .system-log-controls select { width: 100%; padding: 9px 10px; background: #08110c; color: #dbe8e1; border: 1px solid #355346; border-radius: 5px; }
.system-log-controls .system-tail-toggle { display: flex; align-items: center; padding-bottom: 9px; }
.system-tail-toggle input { accent-color: #65edb0; }
.system-log-meta { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; font-size: 11px; color: #a2b7ad; margin: 15px 0 8px; }
.system-log { background: #070d09; border: 1px solid #263e32; border-radius: 5px; max-height: 52vh; min-height: 220px; overflow: auto; padding: 14px; font: 12px/1.75 ui-monospace, SFMono-Regular, Menlo, monospace; }
.system-log-line { white-space: pre-wrap; overflow-wrap: anywhere; color: #bccdc2; }.system-log-error { color: #f3a9b6; }.system-log-warn { color: #edcc97; }.system-log-debug, .system-log-trace { color: #96b4a4; }
.system-log-record { padding: 8px 10px; border-left: 3px solid transparent; margin: 5px 0; }.system-log-record pre { font: inherit; white-space: pre-wrap; overflow-wrap: anywhere; margin: 4px 0; }.system-log-record.system-log-error { border-color: #ff637f; background: #35151e; color: #ffc0cb; }.system-log-record.system-log-warn { border-color: #dfa745; background: #2e2616; }
@media (max-width: 1000px) { .system-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 550px) { .system-metrics { grid-template-columns: 1fr; }.system-section { padding: 14px; }.system-heading-actions { gap: 8px; } }
+18 -1
View File
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { act } from 'react';
import { act, useLayoutEffect } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useSystemPoll } from './useSystemPoll';
@@ -101,6 +101,23 @@ describe('System polling lifecycle', () => {
await visible('visible');
expect(state().data).toBe(5);
});
it('never exposes the previous scope before passive effects or while paused', async () => {
const seen: Array<number | null> = [];
function Observer({ read, paused }: { read: () => Promise<number>; paused: boolean }) {
const reading = useSystemPoll(read, true, paused, 100);
useLayoutEffect(() => { seen.push(reading.data); });
return <output>{JSON.stringify(reading)}</output>;
}
const readA = vi.fn().mockResolvedValue(42), readB = vi.fn().mockResolvedValue(99);
await act(async () => { root.render(<Observer read={readA} paused={false} />); });
expect(state().data).toBe(42);
seen.length = 0;
await act(async () => { root.render(<Observer read={readB} paused />); });
expect(seen.every(value => value === null)).toBe(true);
expect(readB).not.toHaveBeenCalled();
await act(async () => { root.render(<Observer read={readB} paused={false} />); });
expect(state().data).toBe(99);
});
it('cleans up timers and visibility listeners on unmount', async () => {
const read = vi.fn().mockResolvedValue(1);
await render(read);
+9 -2
View File
@@ -9,10 +9,17 @@ export interface SystemReading<T> {
/** Read-only polling. Each lifecycle owns its responses; hidden views never commit them. */
export function useSystemPoll<T>(read: () => Promise<T>, active: boolean, paused: boolean, intervalMs: number) {
const [reading, setReading] = useState<SystemReading<T>>({ data: null, updatedAt: null, error: null, loading: false });
const empty: SystemReading<T> = { data: null, updatedAt: null, error: null, loading: false };
const [owned, setOwned] = useState({ owner: read, reading: empty });
const refreshRef = useRef<() => void>(() => {});
useEffect(() => {
const setReading = (update: SystemReading<T> | ((previous: SystemReading<T>) => SystemReading<T>)) => {
setOwned(previous => {
const current = previous.owner === read ? previous.reading : { data: null, updatedAt: null, error: null, loading: false };
return { owner: read, reading: typeof update === 'function' ? update(current) : update };
});
};
let disposed = false;
let generation = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
@@ -62,5 +69,5 @@ export function useSystemPoll<T>(read: () => Promise<T>, active: boolean, paused
};
}, [read, active, paused, intervalMs]);
return { ...reading, refresh: useCallback(() => refreshRef.current(), []) };
return { ...(owned.owner === read ? owned.reading : empty), refresh: useCallback(() => refreshRef.current(), []) };
}
+4
View File
@@ -39,6 +39,8 @@ export interface SelectedCodePanelProps {
onOpenFlow?: () => void;
callOutline?: ReactNode;
imports?: ImportsGroup;
selectionContext?: ReactNode;
impact?: ReactNode;
}
const SECTION_TITLES: Readonly<Record<string, string>> = {
@@ -163,6 +165,8 @@ export default function SelectedCodePanel(props: SelectedCodePanelProps) {
<button type="button" className="selected-code-ask" onClick={props.onAsk}>Ask about this</button>
<button type="button" onClick={props.onShowGraph}>Show in graph</button>
</div>
{props.selectionContext}
{props.impact}
{noSymbolMessage && <p className="selected-code-message" role="status">{noSymbolMessage}</p>}
{!symbol && (
<div className="selected-code-file" data-testid="selected-code-file">
+48
View File
@@ -0,0 +1,48 @@
import { useMemo } from 'react';
import type { GraphData, GraphNode } from '../galaxy/types';
import type { AgentsState } from '../agents/agent-store';
import { selectionContext } from './selection-context';
import { RelationshipEvidence } from '../architecture/RepositoryMap';
import './selection-context.css';
export default function SelectionContextPanel({ graph, selected, path, agents, onNavigate, onImpact }: {
graph?: GraphData; selected?: GraphNode; path: string; agents?: AgentsState;
onNavigate: (path: string, line?: number, name?: string) => void; onImpact?: () => void;
}) {
const context = useMemo(() => selectionContext(graph, selected, path, agents), [graph, selected, path, agents]);
const callers = context.incoming.filter(edge => edge.type === 'CALLS');
const callerFiles = new Set(callers.map(edge => edge.source.file_path).filter(Boolean));
return <section className="selection-context" aria-label="Selection context" data-testid="selection-context">
<h3>Selection context</h3>
{!path ? <p>Select a file or symbol to inspect its indexed connections and recorded activity.</p> : <>
<p className="selection-context-subject">{selected?.name ?? path}</p>
<h4>Graph evidence</h4>
{!graph ? <p>The repository graph is not available. Relevance cannot be established yet.</p> : <>
{context.selected?.status === 'entry' && <p>The index classifies this declaration as an entry candidate. Open its source and follow outgoing calls to verify its role.</p>}
{callers.length > 0 && <p>{callers.length} indexed caller edges from {callerFiles.size} files reach this selection. Check these callers when changing its contract.</p>}
{context.entryPath.length > 0 && <details><summary>Reachable from {context.entryPath[0].source.name} · inspect {context.entryPath.length} calls</summary>
<p>This selection is reachable from an indexed entry candidate. This is static reachability, not an observed execution or data flow.</p>
<RelationshipEvidence edges={context.entryPath} onNavigate={onNavigate} />
</details>}
{!context.incoming.length && !context.outgoing.length && <p>{/\.(md|rst|txt)$/i.test(path)
? 'This documentation file has no code dependencies in the loaded graph. Read its source for project context.'
: 'No supported dependency edge for this selection is present in the loaded graph. Inspect the source and index coverage before assessing its impact.'}</p>}
{context.incoming.length > 0 && <details><summary>Incoming relationships · {context.incoming.length}</summary><RelationshipEvidence edges={context.incoming} onNavigate={onNavigate} /></details>}
{context.outgoing.length > 0 && <details><summary>Outgoing relationships · {context.outgoing.length}</summary><RelationshipEvidence edges={context.outgoing} onNavigate={onNavigate} /></details>}
{context.pathSearchLimited && !context.entryPath.length && <p>No entry path found within 4 calls / 500 visited symbols. Longer paths were not evaluated.</p>}
</>}
<details><summary>Observed agent activity · {context.activity.length} retained events for this file</summary>
{context.activity.length === 0 ? <p>No observed tool event for this file is available in the loaded agent activity. The Agents view shows the connection and event source.</p>
: <ul>{context.activity.map(({ agent, event }) => <li key={`${agent}:${event.run}:${event.seq}:${event.phase}`}>
<details><summary>{agent} · {event.tool} · {event.phase} · <time dateTime={new Date(event.ts).toISOString()}>{new Date(event.ts).toLocaleTimeString()}</time></summary>
<p>Observed tool event for this file. A tool name does not reveal intention or prove a successful change.</p>
<pre>{JSON.stringify(event, null, 2)}</pre>
<button onClick={() => onNavigate(path, event.lines?.[0])}>Open event location</button>
</details>
</li>)}</ul>}
</details>
<div className="selection-context-actions"><button onClick={() => onNavigate(path, context.selected?.start_line, context.selected?.name)}>Read source evidence</button>
{onImpact && <button onClick={onImpact}>Assess change impact</button>}</div>
</>}
</section>;
}
+14
View File
@@ -0,0 +1,14 @@
.selection-context { border: 1px solid var(--atlas-line); padding: 14px; margin: 12px 0; color: var(--atlas-fg); font: 12px/1.55 var(--atlas-ui-font, system-ui); min-width: 0; }
.selection-context h3 { font-size: 15px; margin: 0 0 4px; }
.selection-context h4 { font-size: 11px; color: var(--atlas-cyan); margin: 14px 0 5px; text-transform: uppercase; letter-spacing: .06em; }
.selection-context p { margin: 5px 0; color: var(--atlas-dim); }
.selection-context-subject { overflow-wrap: anywhere; }
.selection-context details { margin: 8px 0; }
.selection-context summary { cursor: pointer; }
.selection-context pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 240px; overflow: auto; }
.selection-context button { font: inherit; color: var(--atlas-cyan); background: var(--atlas-panel); border: 1px solid var(--atlas-line); padding: 6px 8px; cursor: pointer; }
.selection-context ul { list-style: none; padding: 0; }
.selection-context-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.selection-context button:focus-visible, .selection-context summary:focus-visible { outline: 2px solid var(--atlas-cyan); outline-offset: 2px; }
.galaxy-selection-evidence { position: absolute; top: 150px; right: 12px; bottom: 35px; width: min(460px, 45%); overflow: auto; background: var(--atlas-bg); border: 1px solid var(--atlas-line); padding: 12px; z-index: 4; }
.galaxy-selection-evidence .repo-map-edge { grid-template-columns: 1fr; }
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { selectionContext } from './selection-context';
import type { GraphData, GraphNode } from '../galaxy/types';
const node = (id: number, status: GraphNode['status'] = 'normal'): GraphNode => ({
id, file_path: `src/${id}.ts`, name: `s${id}`, status, label: 'Function',
x: 0, y: 0, z: 0, size: 1, color: '#fff',
});
const graph: GraphData = { total_nodes: 4, nodes: [node(1, 'entry'), node(2), node(3), node(4, 'entry')],
edges: [{ source: 1, target: 2, type: 'CALLS' }, { source: 2, target: 3, type: 'CALLS' },
{ source: 3, target: 2, type: 'CALLS' }, { source: 4, target: 3, type: 'IMPORTS' }] };
describe('selection relevance', () => {
it('explains a bounded, reproducible entry CALLS path in invocation order', () => {
const result = selectionContext(graph, graph.nodes[2], 'src/3.ts');
expect(result.entryPath.map(edge => [edge.source.id, edge.target.id])).toEqual([[1, 2], [2, 3]]);
expect(result.entryPath.every(edge => edge.type === 'CALLS')).toBe(true);
expect(result.incoming.map(edge => edge.type)).toEqual(['CALLS', 'IMPORTS']);
});
it('never treats imports alone as a call or data-flow path', () => {
const result = selectionContext({ ...graph, edges: graph.edges.filter(edge => edge.type === 'IMPORTS') }, graph.nodes[2], 'src/3.ts');
expect(result.entryPath).toEqual([]);
expect(result.incoming[0].type).toBe('IMPORTS');
});
it('names an incomplete path search instead of saying no entry path exists', () => {
const long: GraphData = { total_nodes: 7, nodes: Array.from({ length: 7 }, (_, index) => node(index, index === 0 ? 'entry' : 'normal')),
edges: Array.from({ length: 6 }, (_, index) => ({ source: index, target: index + 1, type: 'CALLS' })) };
const result = selectionContext(long, long.nodes[6], 'src/6.ts');
expect(result.entryPath).toEqual([]);
expect(result.pathSearchLimited).toBe(true);
});
it('resolves stable source identity after reindexing and rejects a recycled numeric ID', () => {
const selected = { ...graph.nodes[2], qualified_name: 'project.target', id: 98 };
const current = { ...graph, nodes: graph.nodes.map(n => n.id === 3 ? { ...n, qualified_name: 'project.target' } : n) };
expect(selectionContext(current, selected, 'src/3.ts').entryPath).toHaveLength(2);
expect(selectionContext(graph, { ...graph.nodes[2], name: 'removed' }, 'src/3.ts').incoming).toEqual([]);
});
it('returns an honest empty context for unavailable data and unknown files', () => {
expect(selectionContext(undefined, undefined, '').incoming).toEqual([]);
expect(selectionContext(graph, undefined, 'not-indexed.ts').incoming).toEqual([]);
});
});
+75
View File
@@ -0,0 +1,75 @@
import type { GraphData, GraphNode } from '../galaxy/types';
import type { AgentEvent } from '../agents/agent-event';
import type { AgentsState } from '../agents/agent-store';
import { MAP_RELATIONS, type MapEvidence, type MapRelation } from '../architecture/repository-map';
export interface SelectionContext {
selected?: GraphNode;
incoming: MapEvidence[];
outgoing: MapEvidence[];
entryPath: MapEvidence[];
pathSearchLimited: boolean;
activity: { agent: string; event: AgentEvent }[];
}
/** All sentences are derived from edges or observed events. No intent inference. */
export function selectionContext(graph: GraphData | undefined, selected: GraphNode | undefined,
path: string, agents?: AgentsState): SelectionContext {
const result: SelectionContext = { selected, incoming: [], outgoing: [], entryPath: [], pathSearchLimited: false, activity: [] };
if (!path) return result;
for (const actor of agents?.actors ?? []) {
if (actor.you) continue;
for (const event of actor.events) {
if (event.path === path) result.activity.push({ agent: actor.name, event });
}
}
result.activity.sort((a, b) => b.event.ts - a.event.ts || b.event.seq - a.event.seq);
result.activity = result.activity.slice(0, 8);
if (!graph) return result;
const nodes = new Map(graph.nodes.map(node => [node.id, node]));
// Reindexing may recycle numeric IDs. Resolve the stable source identity
// against this snapshot before attributing any relationship to a selection.
if (selected) {
selected = graph.nodes.find(candidate => selected!.qualified_name
? candidate.qualified_name === selected!.qualified_name && candidate.file_path === path
: candidate.id === selected!.id && candidate.name === selected!.name && candidate.file_path === path);
result.selected = selected;
if (!selected) return result;
}
const incoming = new Map<number, MapEvidence[]>();
const seen = new Set<string>();
for (const edge of graph.edges) {
if (!MAP_RELATIONS.includes(edge.type as MapRelation)) continue;
const source = nodes.get(edge.source); const target = nodes.get(edge.target);
if (!source || !target) continue;
const key = `${source.id}:${edge.type}:${target.id}`;
if (seen.has(key)) continue;
seen.add(key);
const row = { source, target, type: edge.type as MapRelation, id: edge.id, line: edge.line };
if (selected ? target.id === selected.id : target.file_path === path && source.file_path !== path) result.incoming.push(row);
if (selected ? source.id === selected.id : source.file_path === path && target.file_path !== path) result.outgoing.push(row);
if (edge.type === 'CALLS') {
const rows = incoming.get(target.id) ?? [];
rows.push(row); incoming.set(target.id, rows);
}
}
// A shortest static caller path within a stated bound. No import edge may
// appear in a call path, and no topological ordering is called execution.
if (selected && selected.status !== 'entry') {
const queue: { id: number; edges: MapEvidence[] }[] = [{ id: selected.id, edges: [] }];
const visited = new Set([selected.id]);
for (let i = 0; i < queue.length && i < 500; i++) {
const current = queue[i];
if (current.edges.length >= 4) { if (incoming.get(current.id)?.length) result.pathSearchLimited = true; continue; }
for (const edge of incoming.get(current.id) ?? []) {
if (visited.has(edge.source.id)) continue;
visited.add(edge.source.id);
const edges = [edge, ...current.edges];
if (edge.source.status === 'entry') { result.entryPath = edges; return result; }
if (queue.length < 500) queue.push({ id: edge.source.id, edges });
else result.pathSearchLimited = true;
}
}
}
return result;
}
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff. Show more