From 495e27a38d94fe85dbcd37d2d244751b4790693c Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sat, 3 Oct 2026 19:17:50 +0200 Subject: [PATCH] fix(ui): show the loaded scope's relationships in Galaxy selection details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand test K13. For JSONBAgg the panel showed only "Outgoing relationships · 2" while the toolbar and the chat counted 23 incoming ones. The panel read the repository map snapshot, which is capped at 20,000 nodes by file path, so the tests under tests/ were missing, and it kept only the map relations, so TESTS and DEFINES never counted. In Galaxy the panel now receives the loaded scope and counts every indexed relationship type, by direction and type ("Incoming relationships · 23 (CALLS 11 · TESTS 11 · DEFINES 1)"). It says where the relationships come from and when the scope is still loading, partial, traced in one direction or for some types only. A symbol in the scope but behind the snapshot cap is a valid selection there. "Next lines" disabled at line 66 of general.py was correct: the file has 65 lines and the Module range ends at 66. The pager now says "end of file" and the disabled button names the last line. Signed-off-by: Bernhard Jackiewicz --- graph-ui/src/App.tsx | 18 +++++- .../SourceEvidenceDrawer.test.tsx | 13 ++++ .../src/architecture/SourceEvidenceDrawer.tsx | 8 ++- .../src/galaxy/GalaxyPanel.selection.test.tsx | 47 +++++++++++++++ graph-ui/src/galaxy/GalaxyPanel.tsx | 19 +++++- graph-ui/src/why/SelectionContext.test.tsx | 58 ++++++++++++++++++ graph-ui/src/why/SelectionContext.tsx | 40 +++++++++++-- graph-ui/src/why/selection-context.test.ts | 25 ++++++++ graph-ui/src/why/selection-context.ts | 28 ++++++--- graph-ui/src/why/selection-strings.ts | 21 +++++++ graph-ui/tools/handtest-fixes-galaxy.mjs | 59 ++++++++++++++++++- 11 files changed, 316 insertions(+), 20 deletions(-) create mode 100644 graph-ui/src/galaxy/GalaxyPanel.selection.test.tsx create mode 100644 graph-ui/src/why/SelectionContext.test.tsx create mode 100644 graph-ui/src/why/selection-strings.ts diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index 127afc6dc..b0e7ebf6f 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -121,7 +121,7 @@ import AtlasChrome, { CLOSE_COMMAND_SEARCH_EVENT, OPEN_COMMAND_SEARCH_EVENT } fr import { AtlasTreeIndexDetails } from './app/AtlasTree'; import ArchitecturePanel from './architecture/ArchitecturePanel'; import AdrWorkspace from './adr/AdrWorkspace'; -import SelectionContextPanel from './why/SelectionContext'; +import SelectionContextPanel, { type SelectionScope } from './why/SelectionContext'; import FileImpactSummary from './impact/FileImpactSummary'; import SourceEvidenceDrawer, { fileQualifiedName, type SourceEvidenceTarget } from './architecture/SourceEvidenceDrawer'; import { setUiLogProject } from './app/ui-log-install'; @@ -4515,6 +4515,20 @@ export default function App(): JSX.Element { ; + /* + * Galaxy reads its selection from the loaded scope (hand test K13). The + * repository snapshot is capped at 20,000 nodes by file path; a symbol in + * the scope but behind that cap is still a valid selection here. + */ + const galaxySelectionPanel = (scope: SelectionScope | undefined) => { + const raw = galaxySelectionProject === project ? galaxySelection : undefined; + const inScope = raw && scope?.graph.nodes.some(node => raw.qualified_name + ? node.qualified_name === raw.qualified_name && node.file_path === raw.file_path : node.id === raw.id); + const selected = inScope ? raw : activeGalaxySelection; + if (!selected) return selectionUnavailable ?

{selectionUnavailableText}

: undefined; + return ; + }; const followInspectorTarget = (target: SymbolRef): void => { setPinnedCode(undefined); followTarget(target); @@ -4558,7 +4572,7 @@ export default function App(): JSX.Element { project={project} onClearSelection={clearGraphSelection} onSelectionEvidence={workspace === 'galaxy' ? onSelectionEvidence : undefined} - selectionPanel={workspace === 'galaxy' ? mapSelectionPanel : undefined} + selectionPanel={workspace === 'galaxy' ? galaxySelectionPanel : undefined} visible={workspace === 'galaxy' || (workspace === 'explore' && galaxyOn)} workspaceExpanded={workspace === 'galaxy'} focusQualifiedName={workspace === 'galaxy' ? activeGalaxySelection?.qualified_name : markedGraphRange ? readerFocusSymbol?.qualifiedName : undefined} diff --git a/graph-ui/src/architecture/SourceEvidenceDrawer.test.tsx b/graph-ui/src/architecture/SourceEvidenceDrawer.test.tsx index e3cc942d0..6812221c1 100644 --- a/graph-ui/src/architecture/SourceEvidenceDrawer.test.tsx +++ b/graph-ui/src/architecture/SourceEvidenceDrawer.test.tsx @@ -179,3 +179,16 @@ it('focuses close, lets Escape close the modeless drawer, then restores its orig await ui.unmount(); expect(document.activeElement).toBe(opener); opener.remove(); }); + +it('says "end of file" when the last page reaches the end of the Module, so a disabled Next is not a mystery (hand test K13)', async () => { + // django/contrib/postgres/aggregates/general.py: 65 lines plus the empty EOF line, read from line 42. + const last = { start_line: 42, end_line: 66, source: Array.from({ length: 25 }, (_, i) => `line ${42 + i}`).join('\n'), + source_truncated: false, source_clipped: false, next_start_line: undefined, original_end_line: undefined }; + const ui = await mount(vi.fn(async () => page(42, last))); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + const pager = ui.container.querySelector('nav[aria-label="Source pages"]')!; + expect(pager.textContent).toContain('Lines 42 to 66 · end of file'); + const next = [...pager.querySelectorAll('button')].find(button => button.textContent === 'Next lines')!; + expect(next.disabled).toBe(true); + expect(next.title).toBe('Line 66 is the end of this file.'); +}); diff --git a/graph-ui/src/architecture/SourceEvidenceDrawer.tsx b/graph-ui/src/architecture/SourceEvidenceDrawer.tsx index bd9466077..dcf74682e 100644 --- a/graph-ui/src/architecture/SourceEvidenceDrawer.tsx +++ b/graph-ui/src/architecture/SourceEvidenceDrawer.tsx @@ -7,6 +7,9 @@ import './source-evidence.css'; export interface SourceEvidenceTarget { filePath: string; line?: number; name?: string; kind?: 'callsite' | 'declaration' | 'file' } const PAGE_LINES = 160; +/* Hand test K13: a disabled "Next lines" at the last page read like a fault. The page says where the file ends. */ +const END_OF_FILE = ' · end of file'; +const endOfFileTitle = (line: number) => `Line ${line} is the end of this file.`; 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 { @@ -100,6 +103,7 @@ export default function SourceEvidenceDrawer({ project, target, graph, client, o 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 atEnd = Boolean(source && !more && rangeMatches && validLine(lastLine)); const locationVisible = requestedLine !== undefined && validLine(firstLine) && validLine(lastLine) && requestedLine >= firstLine && requestedLine <= lastLine && rangeMatches; useEffect(() => { @@ -145,8 +149,8 @@ export default function SourceEvidenceDrawer({ project, target, graph, client, o {lines.length === 0 &&

No source text was returned.

} {more && !canNext &&

The source is truncated, but no valid next page was supplied. Refresh or open Explore to inspect the available source.

} + {rangeMatches ? `Lines ${firstLine} to ${lastLine}` : 'Unverified line range'}{source.original_end_line ? ` · reported end ${source.original_end_line}` : ''}{atEnd ? END_OF_FILE : ''} + } ; } diff --git a/graph-ui/src/galaxy/GalaxyPanel.selection.test.tsx b/graph-ui/src/galaxy/GalaxyPanel.selection.test.tsx new file mode 100644 index 000000000..a6c3c9c14 --- /dev/null +++ b/graph-ui/src/galaxy/GalaxyPanel.selection.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +/* + * Handtest K13: "Selection details" bekommt in Galaxy den geladenen + * Ausschnitt, nicht nur den gekappten Repository-Schnappschuss. + */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import GalaxyPanel from './GalaxyPanel'; +import type { SelectionScope } from '../why/SelectionContext'; +import { scopeFetch, scopeNode } from './test-scope-fetch'; + +vi.mock('./GraphScene', async importOriginal => ({ + ...await importOriginal(), + GraphScene: () => , +})); + +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(); globalThis.__atlasGalaxy = undefined; }); + +const settle = (check: () => void) => vi.waitFor(async () => { + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + check(); +}); + +it('K13: hands the loaded scope to the selection panel, with its completeness and trace', async () => { + const nodes = [1, 2, 3, 4].map(id => scopeNode(id)); + const edges = [{ id: 1, source: 2, target: 1, type: 'CALLS' }, { id: 2, source: 2, target: 1, type: 'TESTS' }, + { id: 3, source: 3, target: 1, type: 'DEFINES' }, { id: 4, source: 1, target: 4, type: 'INHERITS' }]; + const seen: (SelectionScope | undefined)[] = []; + const panel = (scope: SelectionScope | undefined) => { seen.push(scope); return

selection evidence

; }; + await act(async () => root.render()); + await settle(() => expect(globalThis.__atlasGalaxy?.nodes).toBe(4)); + expect(seen.at(-1)).toBeUndefined(); + await act(async () => { globalThis.__atlasGalaxy!.clickNode('sample.n1'); }); + await settle(() => expect(seen.at(-1)?.complete).toBe(true)); + const scope = seen.at(-1)!; + expect(scope.direction).toBe('both'); + expect(scope.depth).toBe(1); + expect(scope.graph.edges.map(edge => edge.type).sort()).toEqual(['CALLS', 'DEFINES', 'INHERITS', 'TESTS']); + expect(host.querySelector('.atlas-galaxy-selection-details')?.textContent).toContain('selection evidence'); +}); diff --git a/graph-ui/src/galaxy/GalaxyPanel.tsx b/graph-ui/src/galaxy/GalaxyPanel.tsx index eafee75af..226d8d87e 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.tsx @@ -190,6 +190,7 @@ import { readerFocusFrame, readerGraphFocus, type SourceFocusRange } from './rea import { projectReaderHierarchy } from './reader-hierarchy'; import Hint from '../ui/tooltip/Hint'; import type { GraphData, GraphNode } from './types'; +import type { SelectionScope } from '../why/SelectionContext'; import { AgentLayer, agentAngles, @@ -741,7 +742,12 @@ export interface GalaxyPanelProps { fullscreenToggle?: number; /** Der Speicher fuer die Lage des Instruments. Ersetzbar fuer Tests. */ agentStore?: Storage | undefined; - selectionPanel?: import('react').ReactNode; + /** + * "Selection details". As a function it receives the loaded scope (hand + * test K13): the relationships of the selection then come from what this + * panel loaded, not from the capped repository snapshot. + */ + selectionPanel?: ReactNode | ((scope: SelectionScope | undefined) => ReactNode); } /** Wie lange die Zeitangaben im Instrument stehen, bis sie neu gerechnet werden. */ @@ -1987,6 +1993,13 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { } : null; + /* Der geladene Ausschnitt fuer "Selection details" (K13). */ + const selectionScope = useMemo(() => props.workspaceExpanded && scope.scope && scope.result ? { + graph: scope.result.data, complete: scope.complete, direction: scope.direction, depth: scope.result.depth, + ...(traceTypes ? { edgeTypes: traceTypes } : {}), ...(scope.result.partial ? { partial: scope.result.partial } : {}), + } : undefined, [props.workspaceExpanded, scope.scope, scope.result, scope.complete, scope.direction, traceTypes]); + const selectionContent = typeof props.selectionPanel === 'function' ? props.selectionPanel(selectionScope) : props.selectionPanel; + /* Was die Leiste sonst noch braucht (K3): Quelle der Wurzel, Gruppen und was ausserhalb der Limits liegt. */ const openRoot = props.workspaceExpanded && scopedRoot?.file_path ? () => props.onOpenNode(layoutNodeForSelection(layout, scopedRoot) ?? scopedRoot) : undefined; @@ -2885,9 +2898,9 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { )} - {props.selectionPanel &&
+ {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, {