mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-12 09:47:20 +08:00
fix(ui): show the loaded scope's relationships in Galaxy selection details
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 <bj@techport.io>
This commit is contained in:
+16
-2
@@ -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 {
|
||||
<SelectionContextPanel graph={evidenceGraph} selected={activeGalaxySelection}
|
||||
path={activeGalaxySelection.file_path ?? ''} agents={experimentalAgentsEnabled ? agents.state : undefined} onNavigate={navigateEvidence} />
|
||||
</>;
|
||||
/*
|
||||
* Galaxy reads its selection from the loaded scope (hand test K13). The
|
||||
* repository snapshot is capped at 20,000 nodes by file path; a symbol in
|
||||
* the scope but behind that cap is still a valid selection here.
|
||||
*/
|
||||
const galaxySelectionPanel = (scope: SelectionScope | undefined) => {
|
||||
const raw = galaxySelectionProject === project ? galaxySelection : undefined;
|
||||
const inScope = raw && scope?.graph.nodes.some(node => raw.qualified_name
|
||||
? node.qualified_name === raw.qualified_name && node.file_path === raw.file_path : node.id === raw.id);
|
||||
const selected = inScope ? raw : activeGalaxySelection;
|
||||
if (!selected) return selectionUnavailable ? <p role="status" className="repo-map-note">{selectionUnavailableText}</p> : undefined;
|
||||
return <SelectionContextPanel graph={evidenceGraph} selected={selected} scope={scope}
|
||||
path={selected.file_path ?? ''} agents={experimentalAgentsEnabled ? agents.state : undefined} onNavigate={navigateEvidence} />;
|
||||
};
|
||||
const followInspectorTarget = (target: SymbolRef): void => {
|
||||
setPinnedCode(undefined);
|
||||
followTarget(target);
|
||||
@@ -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}
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
|
||||
@@ -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 && <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>
|
||||
<span>{rangeMatches ? `Lines ${firstLine} to ${lastLine}` : 'Unverified line range'}{source.original_end_line ? ` · reported end ${source.original_end_line}` : ''}{atEnd ? END_OF_FILE : ''}</span>
|
||||
<button disabled={!canNext} title={atEnd ? endOfFileTitle(lastLine!) : undefined} onClick={() => moveTo(next!)}>Next lines</button></nav>
|
||||
</>}
|
||||
</aside>;
|
||||
}
|
||||
|
||||
@@ -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<typeof import('./GraphScene')>(),
|
||||
GraphScene: () => <output data-testid="scene" />,
|
||||
}));
|
||||
|
||||
let host: HTMLDivElement, 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(); 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 <p>selection evidence</p>; };
|
||||
await act(async () => root.render(<GalaxyPanel project="sample" visible workspaceExpanded onOpenNode={vi.fn()} selectionPanel={panel}
|
||||
fetch={scopeFetch({ nodes, edges }).fetch} />));
|
||||
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');
|
||||
});
|
||||
@@ -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 {
|
||||
</details>}
|
||||
</span> : null;
|
||||
|
||||
/* Der geladene Ausschnitt fuer "Selection details" (K13). */
|
||||
const selectionScope = useMemo<SelectionScope | undefined>(() => 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 {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{props.selectionPanel && <details className="galaxy-selection-evidence atlas-galaxy-selection-details" aria-label="Selection evidence">
|
||||
{selectionContent && <details className="galaxy-selection-evidence atlas-galaxy-selection-details" aria-label="Selection evidence">
|
||||
<summary>Selection details</summary>
|
||||
<div className="atlas-galaxy-selection-details-body">{props.selectionPanel}</div>
|
||||
<div className="atlas-galaxy-selection-details-body">{selectionContent}</div>
|
||||
</details>}
|
||||
{note.length > 0 && !scope.loading && (
|
||||
<p className="atlas-galaxy-note" data-testid="atlas-galaxy-note">
|
||||
|
||||
@@ -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<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(); });
|
||||
|
||||
const node = (id: number, extra: Partial<GraphNode> = {}): 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(<SelectionContextPanel graph={snapshot} selected={selected} path="general.py" onNavigate={vi.fn()}
|
||||
scope={{ graph: scopeGraph, complete: true, direction: 'both' }} />));
|
||||
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<typeof SelectionContextPanel>[0]['scope']) => act(async () => root.render(
|
||||
<SelectionContextPanel graph={snapshot} selected={selected} path="general.py" onNavigate={vi.fn()} scope={scope} />));
|
||||
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(<SelectionContextPanel graph={snapshot} selected={selected} path="general.py" onNavigate={vi.fn()}
|
||||
scope={{ graph: { total_nodes: 1, nodes: [node(99)], edges: [] }, complete: true, direction: 'both' }} />));
|
||||
expect(summaries()).toContain('Outgoing relationships · 2 (INHERITS 2)');
|
||||
expect(host.textContent).toContain('From the repository map snapshot');
|
||||
});
|
||||
@@ -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 <section className="selection-context" aria-label="Selection context" data-testid="selection-context">
|
||||
@@ -17,7 +39,12 @@ export default function SelectionContextPanel({ graph, selected, path, agents, o
|
||||
{!path ? <p>Select a file or symbol to inspect its indexed connections.</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> : <>
|
||||
{!source ? <p>The repository graph is not available. Relevance cannot be established yet.</p> : <>
|
||||
{scope && <p className="selection-context-source" data-source={inScope ? 'scope' : 'snapshot'}>{inScope ? selectionScopeText.fromScope(scope.depth) : selectionScopeText.fromSnapshot}</p>}
|
||||
{inScope && !scope!.complete && <p role="status">{selectionScopeText.loading}</p>}
|
||||
{inScope && scope!.partial && <p role="status">{selectionScopeText.partial(scope!.partial.layer, scope!.partial.limit)}</p>}
|
||||
{inScope && scope!.direction !== 'both' && <p>{selectionScopeText.notTraced(scope!.direction)}</p>}
|
||||
{inScope && scope!.edgeTypes && <p>{selectionScopeText.typesOnly(scope!.edgeTypes)}</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>
|
||||
@@ -27,8 +54,11 @@ export default function SelectionContextPanel({ graph, selected, path, agents, o
|
||||
{!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>}
|
||||
{/* RelationshipEvidence prints the type as text, so a scope type outside the map relations reads the same. */}
|
||||
{context.incoming.length > 0 && <details><summary>{selectionScopeText.incoming(context.incoming.length, context.incomingByType)}</summary>
|
||||
<RelationshipEvidence edges={context.incoming as MapEvidence[]} onNavigate={onNavigate} /></details>}
|
||||
{context.outgoing.length > 0 && <details><summary>{selectionScopeText.outgoing(context.outgoing.length, context.outgoingByType)}</summary>
|
||||
<RelationshipEvidence edges={context.outgoing as MapEvidence[]} 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>}
|
||||
</>}
|
||||
{agents && <details><summary>Observed agent activity · {context.activity.length} retained events for this file</summary>
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<MapEvidence, 'type'> & { 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<number, MapEvidence[]>();
|
||||
const seen = new Set<string>();
|
||||
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<string, number>();
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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'}.`,
|
||||
};
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user