+ {selectionContent &&
Selection details
- {props.selectionPanel}
+ {selectionContent}
}
{note.length > 0 && !scope.loading && (
diff --git a/graph-ui/src/why/SelectionContext.test.tsx b/graph-ui/src/why/SelectionContext.test.tsx
new file mode 100644
index 000000000..4a9ba8cea
--- /dev/null
+++ b/graph-ui/src/why/SelectionContext.test.tsx
@@ -0,0 +1,58 @@
+// @vitest-environment jsdom
+/*
+ * Handtest K13: in Galaxy nennt "Selection details" die Beziehungen aus dem
+ * geladenen Ausschnitt und sagt, wenn er unvollstaendig ist. Bis dahin las das
+ * Panel den gekappten Repository-Schnappschuss und zeigte bei JSONBAgg nur
+ * "Outgoing relationships · 2".
+ */
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, expect, it, vi } from 'vitest';
+import SelectionContextPanel from './SelectionContext';
+import type { GraphData, GraphNode } from '../galaxy/types';
+
+let host: HTMLDivElement, root: Root;
+beforeEach(() => {
+ (globalThis as unknown as Record).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(); });
+
+const node = (id: number, extra: Partial = {}): GraphNode => ({ id, name: `s${id}`, qualified_name: `p.s${id}`, file_path: `src/${id}.py`,
+ label: 'Function', x: 0, y: 0, z: 0, size: 1, color: '#fff', ...extra });
+const selected = node(1, { name: 'JSONBAgg', qualified_name: 'p.JSONBAgg', file_path: 'general.py', label: 'Class' });
+const tests = Array.from({ length: 11 }, (_, at) => node(10 + at));
+const scopeGraph: GraphData = { total_nodes: 15, nodes: [selected, ...tests, node(30, { label: 'Module', file_path: 'general.py' }), node(40), node(41)], edges: [
+ ...tests.flatMap(test => [{ source: test.id, target: 1, type: 'CALLS', line: 5 }, { source: test.id, target: 1, type: 'TESTS' }]),
+ { source: 30, target: 1, type: 'DEFINES' }, { source: 1, target: 40, type: 'INHERITS' }, { source: 1, target: 41, type: 'INHERITS' }] };
+// The capped repository snapshot knows the class and its bases, but not the tests behind the 20,000-node cap.
+const snapshot: GraphData = { total_nodes: 3, nodes: [selected, node(40), node(41)], edges: scopeGraph.edges.filter(edge => edge.type === 'INHERITS') };
+const summaries = () => [...host.querySelectorAll('summary')].map(entry => entry.textContent);
+
+it('K13: lists incoming and outgoing relationships of the loaded scope by type', async () => {
+ await act(async () => root.render());
+ expect(summaries()).toContain('Incoming relationships · 23 (CALLS 11 · TESTS 11 · DEFINES 1)');
+ expect(summaries()).toContain('Outgoing relationships · 2 (INHERITS 2)');
+ expect(host.textContent).toContain('From the loaded Galaxy scope');
+});
+
+it('K13: says when the scope is partial, still loading or traced in one direction or for some types only', async () => {
+ const render = (scope: Parameters[0]['scope']) => act(async () => root.render(
+ ));
+ await render({ graph: scopeGraph, complete: false, direction: 'both' });
+ expect(host.textContent).toContain('The scope is still loading');
+ await render({ graph: scopeGraph, complete: true, direction: 'both', partial: { layer: 3, nodes: 9139, edges: 43010, limit: 'nodes' } });
+ expect(host.textContent).toContain('Layer 3 of this scope stopped at the render limit');
+ await render({ graph: scopeGraph, complete: true, direction: 'outbound' });
+ expect(host.textContent).toContain('Incoming relationships are not traced in this scope');
+ await render({ graph: scopeGraph, complete: true, direction: 'both', edgeTypes: ['CALLS'] });
+ expect(host.textContent).toContain('Only these relationship types are traced: CALLS');
+});
+
+it('K13: without a scope that contains the selection, it keeps the repository snapshot and says so', async () => {
+ await act(async () => root.render());
+ expect(summaries()).toContain('Outgoing relationships · 2 (INHERITS 2)');
+ expect(host.textContent).toContain('From the repository map snapshot');
+});
diff --git a/graph-ui/src/why/SelectionContext.tsx b/graph-ui/src/why/SelectionContext.tsx
index 9b3b02888..0463767b6 100644
--- a/graph-ui/src/why/SelectionContext.tsx
+++ b/graph-ui/src/why/SelectionContext.tsx
@@ -2,14 +2,36 @@ import { useMemo } from 'react';
import type { GraphData, GraphNode } from '../galaxy/types';
import type { AgentsState } from '../agents/agent-store';
import { selectionContext } from './selection-context';
+import { selectionScopeText } from './selection-strings';
import { RelationshipEvidence } from '../architecture/RepositoryMap';
+import type { MapEvidence } from '../architecture/repository-map';
+import type { ScopePartial, TraceDirection } from '../galaxy/graph-scope';
import './selection-context.css';
-export default function SelectionContextPanel({ graph, selected, path, agents, onNavigate, onImpact }: {
+/** The scope that Galaxy has loaded around the selection (hand test K13). */
+export interface SelectionScope {
+ graph: GraphData;
+ complete: boolean;
+ direction: TraceDirection;
+ edgeTypes?: readonly string[];
+ partial?: ScopePartial;
+ depth?: number;
+}
+
+export default function SelectionContextPanel({ graph, selected, path, agents, onNavigate, onImpact, scope }: {
graph?: GraphData; selected?: GraphNode; path: string; agents?: AgentsState;
onNavigate: (path: string, line?: number, name?: string) => void; onImpact?: () => void;
+ /**
+ * In Galaxy: the loaded scope. Its relationships replace the repository
+ * snapshot, which is capped (20,000 nodes by file path) and missed, for
+ * JSONBAgg, all 23 incoming relationships from the tests behind the cap.
+ */
+ scope?: SelectionScope;
}) {
- const context = useMemo(() => selectionContext(graph, selected, path, agents), [graph, selected, path, agents]);
+ const inScope = Boolean(scope && selected && scope.graph.nodes.some(node => selected.qualified_name
+ ? node.qualified_name === selected.qualified_name && node.file_path === path : node.id === selected.id));
+ const source = inScope ? scope!.graph : graph;
+ const context = useMemo(() => selectionContext(source, selected, path, agents, { allRelations: inScope }), [source, selected, path, agents, inScope]);
const callers = context.incoming.filter(edge => edge.type === 'CALLS');
const callerFiles = new Set(callers.map(edge => edge.source.file_path).filter(Boolean));
return
@@ -17,7 +39,12 @@ export default function SelectionContextPanel({ graph, selected, path, agents, o
{!path ? Select a file or symbol to inspect its indexed connections.
: <>
{selected?.name ?? path}
Graph evidence
- {!graph ? The repository graph is not available. Relevance cannot be established yet.
: <>
+ {!source ? The repository graph is not available. Relevance cannot be established yet.
: <>
+ {scope && {inScope ? selectionScopeText.fromScope(scope.depth) : selectionScopeText.fromSnapshot}
}
+ {inScope && !scope!.complete && {selectionScopeText.loading}
}
+ {inScope && scope!.partial && {selectionScopeText.partial(scope!.partial.layer, scope!.partial.limit)}
}
+ {inScope && scope!.direction !== 'both' && {selectionScopeText.notTraced(scope!.direction)}
}
+ {inScope && scope!.edgeTypes && {selectionScopeText.typesOnly(scope!.edgeTypes)}
}
{context.selected?.status === 'entry' && The index classifies this declaration as an entry candidate. Open its source and follow outgoing calls to verify its role.
}
{callers.length > 0 && {callers.length} indexed caller edges from {callerFiles.size} files reach this selection. Check these callers when changing its contract.
}
{context.entryPath.length > 0 && Reachable from {context.entryPath[0].source.name} · inspect {context.entryPath.length} calls
@@ -27,8 +54,11 @@ export default function SelectionContextPanel({ graph, selected, path, agents, o
{!context.incoming.length && !context.outgoing.length && {/\.(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.'}
}
- {context.incoming.length > 0 && Incoming relationships · {context.incoming.length}
}
- {context.outgoing.length > 0 && Outgoing relationships · {context.outgoing.length}
}
+ {/* RelationshipEvidence prints the type as text, so a scope type outside the map relations reads the same. */}
+ {context.incoming.length > 0 && {selectionScopeText.incoming(context.incoming.length, context.incomingByType)}
+ }
+ {context.outgoing.length > 0 && {selectionScopeText.outgoing(context.outgoing.length, context.outgoingByType)}
+ }
{context.pathSearchLimited && !context.entryPath.length && No entry path found within 4 calls / 500 visited symbols. Longer paths were not evaluated.
}
>}
{agents && Observed agent activity · {context.activity.length} retained events for this file
diff --git a/graph-ui/src/why/selection-context.test.ts b/graph-ui/src/why/selection-context.test.ts
index 887c80ec5..16c33460b 100644
--- a/graph-ui/src/why/selection-context.test.ts
+++ b/graph-ui/src/why/selection-context.test.ts
@@ -40,3 +40,28 @@ describe('selection relevance', () => {
expect(selectionContext(graph, undefined, 'not-indexed.ts').incoming).toEqual([]);
});
});
+
+describe('hand test K13: relationships from the loaded Galaxy scope', () => {
+ // JSONBAgg in django-demo: 11 tests CALL and TEST it, its module DEFINES it, and it INHERITS twice.
+ const root = { ...node(1), name: 'JSONBAgg', qualified_name: 'p.JSONBAgg', file_path: 'general.py' };
+ const tests = Array.from({ length: 11 }, (_, at) => ({ ...node(10 + at), file_path: 'tests/test_aggregates.py' }));
+ const module = { ...node(30), label: 'Module', file_path: 'general.py' };
+ const bases = [node(40), node(41)];
+ const scope: GraphData = { total_nodes: 15, nodes: [root, ...tests, module, ...bases], edges: [
+ ...tests.flatMap(test => [{ source: test.id, target: 1, type: 'CALLS', line: 5 }, { source: test.id, target: 1, type: 'TESTS' }]),
+ { source: 30, target: 1, type: 'DEFINES' }, { source: 1, target: 40, type: 'INHERITS' }, { source: 1, target: 41, type: 'INHERITS' }] };
+
+ it('keeps every relationship type of the scope, not only the repository-map relations', () => {
+ const result = selectionContext(scope, root, 'general.py', undefined, { allRelations: true });
+ expect(result.incoming).toHaveLength(23);
+ expect(result.outgoing).toHaveLength(2);
+ expect(result.incomingByType).toEqual([['CALLS', 11], ['TESTS', 11], ['DEFINES', 1]]);
+ expect(result.outgoingByType).toEqual([['INHERITS', 2]]);
+ });
+
+ it('still limits the repository map snapshot to its map relations', () => {
+ const result = selectionContext(scope, root, 'general.py');
+ expect(result.incoming).toHaveLength(11);
+ expect(result.incomingByType).toEqual([['CALLS', 11]]);
+ });
+});
diff --git a/graph-ui/src/why/selection-context.ts b/graph-ui/src/why/selection-context.ts
index 609ab7d45..fbd08caaa 100644
--- a/graph-ui/src/why/selection-context.ts
+++ b/graph-ui/src/why/selection-context.ts
@@ -3,10 +3,16 @@ 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';
+/** A relationship of the selection. In a Galaxy scope every indexed type counts (K13), not only the map relations. */
+export type SelectionEvidence = Omit & { type: MapRelation | string };
+
export interface SelectionContext {
selected?: GraphNode;
- incoming: MapEvidence[];
- outgoing: MapEvidence[];
+ incoming: SelectionEvidence[];
+ outgoing: SelectionEvidence[];
+ /** The same relationships counted by type, most frequent first. */
+ incomingByType: [string, number][];
+ outgoingByType: [string, number][];
entryPath: MapEvidence[];
pathSearchLimited: boolean;
activity: { agent: string; event: AgentEvent }[];
@@ -14,8 +20,8 @@ export interface SelectionContext {
/** 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: [] };
+ path: string, agents?: AgentsState, options: { allRelations?: boolean } = {}): SelectionContext {
+ const result: SelectionContext = { selected, incoming: [], outgoing: [], incomingByType: [], outgoingByType: [], entryPath: [], pathSearchLimited: false, activity: [] };
if (!path) return result;
for (const actor of agents?.actors ?? []) {
if (actor.you) continue;
@@ -39,20 +45,22 @@ export function selectionContext(graph: GraphData | undefined, selected: GraphNo
const incoming = new Map();
const seen = new Set();
for (const edge of graph.edges) {
- if (!MAP_RELATIONS.includes(edge.type as MapRelation)) continue;
+ if (!options.allRelations && !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 };
+ const row = { source, target, type: edge.type, 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);
+ rows.push({ ...row, type: 'CALLS' }); incoming.set(target.id, rows);
}
}
+ result.incomingByType = countByType(result.incoming);
+ result.outgoingByType = countByType(result.outgoing);
// 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') {
@@ -73,3 +81,9 @@ export function selectionContext(graph: GraphData | undefined, selected: GraphNo
}
return result;
}
+
+function countByType(edges: readonly SelectionEvidence[]): [string, number][] {
+ const counts = new Map();
+ for (const edge of edges) counts.set(edge.type, (counts.get(edge.type) ?? 0) + 1);
+ return [...counts].sort(([a, left], [b, right]) => right - left || a.localeCompare(b));
+}
diff --git a/graph-ui/src/why/selection-strings.ts b/graph-ui/src/why/selection-strings.ts
new file mode 100644
index 000000000..7e4b784d0
--- /dev/null
+++ b/graph-ui/src/why/selection-strings.ts
@@ -0,0 +1,21 @@
+/**
+ * The words of the selection context where it reads a Galaxy scope (hand test
+ * K13). Every sentence names where the relationships come from and what the
+ * scope leaves out; nothing here claims completeness the scope does not have.
+ */
+const byType = (types: readonly [string, number][]) => types.map(([type, count]) => `${type} ${count.toLocaleString()}`).join(' · ');
+
+export const selectionScopeText = {
+ incoming: (count: number, types: readonly [string, number][]) =>
+ `Incoming relationships · ${count.toLocaleString()}${types.length ? ` (${byType(types)})` : ''}`,
+ outgoing: (count: number, types: readonly [string, number][]) =>
+ `Outgoing relationships · ${count.toLocaleString()}${types.length ? ` (${byType(types)})` : ''}`,
+ fromScope: (depth?: number) => `From the loaded Galaxy scope${depth ? ` (${depth === 1 ? '1 layer' : `${depth} layers`})` : ''}, every indexed relationship type.`,
+ fromSnapshot: 'From the repository map snapshot. It is capped and can miss relationships that the Galaxy scope shows; select the symbol in Galaxy to load them.',
+ loading: 'The scope is still loading: relationships may be missing until it is complete.',
+ partial: (layer: number, limit: 'nodes' | 'edges') => `Layer ${layer} of this scope stopped at the render limit (${limit}). `
+ + (layer > 1 ? 'The direct relationships of the root are complete; deeper ones may be missing.' : 'The direct relationships of the root are incomplete.'),
+ notTraced: (direction: 'inbound' | 'outbound') =>
+ `${direction === 'outbound' ? 'Incoming' : 'Outgoing'} relationships are not traced in this scope (${direction === 'outbound' ? 'outgoing' : 'incoming'} only).`,
+ typesOnly: (types: readonly string[]) => `Only these relationship types are traced: ${types.length ? types.join(', ') : 'none'}.`,
+};
diff --git a/graph-ui/tools/handtest-fixes-galaxy.mjs b/graph-ui/tools/handtest-fixes-galaxy.mjs
index 89d6cd164..3c814e44c 100644
--- a/graph-ui/tools/handtest-fixes-galaxy.mjs
+++ b/graph-ui/tools/handtest-fixes-galaxy.mjs
@@ -424,8 +424,65 @@ async function checkK3(page) {
}
/* ------------------------------------------------------------------ */
+/* K13: Selection details aus dem geladenen Ausschnitt */
-const CHECKS = { K9: checkK9, K2: checkK2, K8: checkK8, K3: checkK3 };
+const typeList = (counts) => Object.entries(counts).sort(([a, x], [b, y]) => y - x || a.localeCompare(b)).map(([type, n]) => `${type} ${n}`).join(' · ');
+
+async function checkK13(page) {
+ await section(page, 'K13', 'Selection details lists the relationships of the loaded scope; "Next lines" at the end of a file says so');
+ const truth = await relationCounts(ROOT);
+ const total = (counts) => Object.values(counts).reduce((sum, n) => sum + n, 0);
+ await open(page);
+ await select(page, ROOT);
+ await settled(page);
+ const details = page.locator('.atlas-galaxy-selection-details').first();
+ await details.locator('> summary').click();
+ await wait(700);
+ const summaries = await details.locator('summary').allInnerTexts();
+ const source = await details.locator('.selection-context-source').innerText().catch(() => '');
+ await shot(page, 'K13', 'details-root', `Selection details for ${ROOT}: ${summaries.filter((text) => /relationships/.test(text)).join(' | ')}`);
+ await details.screenshot({ path: join(OUT, 'K13', 'details-root-panel.png') }).catch(() => {});
+ const wantIn = `Incoming relationships · ${total(truth.incoming)} (${typeList(truth.incoming)})`;
+ const wantOut = `Outgoing relationships · ${total(truth.outgoing)} (${typeList(truth.outgoing)})`;
+ await check('K13', `Selection details of ${ROOT} match the index: incoming and outgoing by type, from the loaded scope`,
+ summaries.includes(wantIn) && summaries.includes(wantOut) && /loaded Galaxy scope/.test(source), { truth, wantIn, wantOut, summaries, source });
+
+ // Eine Auswahl hinter dem Deckel des Schnappschusses (tests/ liegt hinter 20.000 Knoten).
+ const child = await qualifiedName('test_jsonb_agg_jsonfield_order_by');
+ const childTruth = await relationCounts('test_jsonb_agg_jsonfield_order_by');
+ await page.evaluate((qn) => globalThis.__atlasGalaxy?.clickNode(qn), child);
+ await settled(page);
+ await wait(800);
+ const childText = await details.innerText().catch(() => '');
+ const childSummaries = await details.locator('summary').allInnerTexts();
+ await shot(page, 'K13', 'details-test-behind-cap', `Selection details for a test behind the snapshot cap: ${childSummaries.filter((text) => /relationships/.test(text)).join(' | ')}`);
+ const childOut = `Outgoing relationships · ${total(childTruth.outgoing)} (${typeList(childTruth.outgoing)})`;
+ await check('K13', 'A selected test outside the capped snapshot still shows its relationships from the scope', childSummaries.includes(childOut)
+ && !/absent from this bounded index snapshot/.test(childText), { childTruth, childOut, childSummaries });
+
+ // "Read source evidence": general.py endet in Zeile 65 (dazu die leere Zeile 66).
+ await page.getByRole('button', { name: 'Back', exact: true }).click().catch(() => {});
+ await settled(page);
+ await wait(800);
+ await details.getByRole('button', { name: 'Read source evidence' }).click();
+ await page.waitForSelector('nav[aria-label="Source pages"]', { timeout: 20000 }).catch(() => {});
+ await wait(500);
+ const pager = page.locator('nav[aria-label="Source pages"]').first();
+ const pagerText = await pager.innerText().catch(() => '');
+ const next = pager.getByRole('button', { name: 'Next lines' });
+ const nextState = { disabled: await next.isDisabled().catch(() => null), title: await next.getAttribute('title').catch(() => null) };
+ const lines = await page.locator('.source-evidence-lines code > span').count();
+ await page.locator('.source-evidence-lines').evaluate((el) => { el.scrollTop = el.scrollHeight; }).catch(() => {});
+ await wait(300);
+ await shot(page, 'K13', 'source-evidence-end', `Source evidence for general.py scrolled to the end: "${pagerText.replace(/\s+/g, ' ')}"`);
+ await check('K13', '"Next lines" is disabled at the real end of general.py and says so', /end of file/.test(pagerText) && nextState.disabled === true
+ && /end of this file/.test(nextState.title ?? ''), { pagerText, nextState, renderedLines: lines });
+ await page.keyboard.press('Escape');
+}
+
+/* ------------------------------------------------------------------ */
+
+const CHECKS = { K9: checkK9, K2: checkK2, K8: checkK8, K3: checkK3, K13: checkK13 };
await mkdir(OUT, { recursive: true });
const context = await chromium.launchPersistentContext(PROFILE, {