mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
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:
1 parent
80f4f41f9b
commit
4372d9fada
130 files changed
+9744
-906
No files matched your search
+7
-4
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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>
|
||||
</>}
|
||||
|
||||
@@ -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]) =>
|
||||
|
||||
@@ -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)'");
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'}>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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[](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 });
|
||||
});
|
||||
});
|
||||
@@ -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; } }
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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', {}, {
|
||||
|
||||
@@ -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
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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; } }
|
||||
@@ -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,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(), []) };
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user