Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 209 additions & 3 deletions packages/api/src/__tests__/graph-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,15 @@ async function errorFor(path: string): Promise<{ status: number; error: string }
describe('graph route numeric boundaries', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedFullGraph.mockResolvedValue({ nodes: [], edges: [] });
mockedFullGraph.mockResolvedValue({
nodes: [],
edges: [],
totalNodes: 0,
totalEdges: 0,
windowOrder: 'degree-desc,id-asc',
degreeScope: 'global',
truncated: false,
});
mockedReferences.mockResolvedValue({ references: [], referencingFiles: [], truncated: false });
mockedDependencies.mockResolvedValue({ nodes: [], edges: [] });
});
Expand Down Expand Up @@ -101,6 +109,84 @@ describe('graph route numeric boundaries', () => {
expect(mockedFullGraph).toHaveBeenCalledWith(1000, undefined);
});

it('preserves full graph totals, ordering metadata, and truncation caveat', async () => {
mockedFullGraph.mockResolvedValue({
nodes: [],
edges: [],
totalNodes: 25,
totalEdges: 40,
windowOrder: 'degree-desc,id-asc',
degreeScope: 'global',
truncated: true,
});

const response = await graphRoutes.request('/api/graph/full?limit=10');

expect(response.status).toBe(200);
expect(await response.json()).toEqual({
nodes: [],
edges: [],
totalNodes: 25,
totalEdges: 40,
windowOrder: 'degree-desc,id-asc',
degreeScope: 'global',
truncated: true,
});
});

it('projects full graph edges to the fields consumed by the dashboard', async () => {
mockedFullGraph.mockResolvedValue({
nodes: [],
edges: [{
id: '["CALLS","source","target"]',
source: 'source',
target: 'target',
label: 'CALLS',
data: {
type: 'CALLS',
from: 'source',
to: 'target',
bodySnippet: 'large relationship payload',
},
} as never],
totalNodes: 2,
totalEdges: 1,
windowOrder: 'degree-desc,id-asc',
degreeScope: 'global',
truncated: false,
});

const response = await graphRoutes.request('/api/graph/full?limit=10');

expect(response.status).toBe(200);
expect((await response.json()).edges).toEqual([{
source: 'source',
target: 'target',
label: 'CALLS',
}]);
});

it('resolves projectId to a boundary-safe graph scope', async () => {
const roQuery = vi.fn().mockResolvedValue({ data: [{ rootPath: '/workspace/app' }], metadata: [] });
mockedGetGraphClient.mockResolvedValue({ roQuery } as never);

const response = await graphRoutes.request('/api/graph/full?projectId=project-app');

expect(response.status).toBe(200);
expect(mockedFullGraph).toHaveBeenCalledWith(100, '/workspace/app');
});

it('does not fall back to the global graph for an unknown projectId', async () => {
const roQuery = vi.fn().mockResolvedValue({ data: [], metadata: [] });
mockedGetGraphClient.mockResolvedValue({ roQuery } as never);

const response = await graphRoutes.request('/api/graph/full?projectId=missing');

expect(response.status).toBe(404);
expect(await response.json()).toEqual({ error: 'Project not found' });
expect(mockedFullGraph).not.toHaveBeenCalled();
});

it.each(['NaN', 'Infinity', '0', '-1', '1.5', '11'])(
'rejects dependency depth=%s before touching the graph',
async (depth) => {
Expand Down Expand Up @@ -167,18 +253,138 @@ describe('GET /api/graph/full unavailable storage', () => {
expect(await response.json()).toEqual({
nodes: [],
edges: [],
totalNodes: 0,
totalEdges: 0,
windowOrder: 'degree-desc,id-asc',
degreeScope: 'global',
truncated: false,
storage: blockedSetupStatus.storage,
});
});
});

describe('GET /api/graph/files', () => {
const getFileGraph = vi.fn();
const fileGraphResult = {
nodes: [{
id: 'File:/x/main.ts',
displayName: 'main.ts',
filePath: '/x/main.ts',
symbolCount: 3,
label: 'File' as const,
}],
edges: [],
totalNodes: 4,
totalEdges: 5,
windowOrder: 'degree-desc,id-asc' as const,
truncated: true,
};

beforeEach(() => {
vi.clearAllMocks();
mockedGetGraphClient.mockResolvedValue({
roQuery: vi.fn().mockResolvedValue({ data: [{ rootPath: '/x' }], metadata: [] }),
} as never);
getFileGraph.mockResolvedValue(fileGraphResult);
mockedCreateQueries.mockReturnValue({ getFileGraph } as never);
});

it.each(['NaN', 'Infinity', '0', '-1', '1.5', '1001'])(
'rejects limit=%s before touching the graph',
async (limit) => {
const result = await errorFor(`/api/graph/files?limit=${limit}`);

expect(result.status).toBe(400);
expect(result.error).toBe('limit must be a positive integer between 1 and 1000');
expect(mockedGetGraphClient).not.toHaveBeenCalled();
},
);

it('returns the frozen file graph shape scoped through project root resolution', async () => {
const response = await graphRoutes.request('/api/graph/files?projectId=project-x&limit=50');

expect(response.status).toBe(200);
expect(await response.json()).toEqual(fileGraphResult);
expect(getFileGraph).toHaveBeenCalledWith(50, '/x');
});

it('does not return the global file graph for an unknown projectId', async () => {
mockedGetGraphClient.mockResolvedValueOnce({
roQuery: vi.fn().mockResolvedValue({ data: [], metadata: [] }),
} as never);

const response = await graphRoutes.request('/api/graph/files?projectId=missing');

expect(response.status).toBe(404);
expect(await response.json()).toEqual({ error: 'Project not found' });
expect(getFileGraph).not.toHaveBeenCalled();
});
});

describe('GET /api/graph/neighbors', () => {
const getNodeNeighbors = vi.fn();
const neighborResult = {
centerId: 'File:/x/main.ts',
nodes: [{ id: 'File:/x/main.ts', label: 'File', displayName: 'main.ts', filePath: '/x/main.ts', data: {} }],
edges: [],
incomingTruncated: false,
outgoingTruncated: true,
limit: 25,
};

beforeEach(() => {
vi.clearAllMocks();
mockedGetGraphClient.mockResolvedValue({} as never);
getNodeNeighbors.mockResolvedValue(neighborResult);
mockedCreateQueries.mockReturnValue({ getNodeNeighbors } as never);
});

it('requires a persisted id', async () => {
const result = await errorFor('/api/graph/neighbors');

expect(result).toEqual({ status: 400, error: 'id parameter is required' });
expect(mockedGetGraphClient).not.toHaveBeenCalled();
});

it.each(['NaN', 'Infinity', '0', '-1', '1.5', '1001'])(
'rejects limit=%s before touching the graph',
async (limit) => {
const result = await errorFor(`/api/graph/neighbors?id=File%3A%2Fx%2Fmain.ts&limit=${limit}`);

expect(result.status).toBe(400);
expect(result.error).toBe('limit must be a positive integer between 1 and 1000');
expect(mockedGetGraphClient).not.toHaveBeenCalled();
},
);

it('returns the frozen neighbor shape for a persisted File id', async () => {
const response = await graphRoutes.request('/api/graph/neighbors?id=File%3A%2Fx%2Fmain.ts&limit=25');

expect(response.status).toBe(200);
expect(await response.json()).toEqual(neighborResult);
expect(getNodeNeighbors).toHaveBeenCalledWith('File:/x/main.ts', 25);
});

it('returns 404 when the persisted id does not exist', async () => {
getNodeNeighbors.mockResolvedValueOnce(undefined);

const response = await graphRoutes.request('/api/graph/neighbors?id=File%3A%2Fx%2Fmissing.ts');

expect(response.status).toBe(404);
expect(await response.json()).toEqual({ error: 'Graph node not found' });
});
});

describe('GET /api/graph/file-relationships', () => {
const relationshipResult = {
filePath: '/x/main.ts',
containedSymbols: [{ id: 'Function:/x/main.ts:run:5', label: 'Function', displayName: 'run', filePath: '/x/main.ts', data: {} }],
imports: [{ id: 'File:/x/dep.ts', label: 'File', displayName: 'dep.ts', filePath: '/x/dep.ts', data: {} }],
importers: [{ id: 'File:/x/importer.ts', label: 'File', displayName: 'importer.ts', filePath: '/x/importer.ts', data: {} }],
knowledgeEntities: [{ id: 'Entity:Decision:Main entry point', label: 'Entity', displayName: 'Main entry point', data: { text: 'Main entry point', type: 'Decision' } }],
totals: { containedSymbols: 600, imports: 1, importers: 1, knowledgeEntities: 1 },
truncated: { containedSymbols: true, imports: false, importers: false, knowledgeEntities: false },
limit: 500,
};
const getFileRelationships = vi.fn();

Expand All @@ -196,13 +402,13 @@ describe('GET /api/graph/file-relationships', () => {
expect(mockedGetGraphClient).not.toHaveBeenCalled();
});

it.each(['NaN', 'Infinity', '0', '-1', '1.5', '501'])(
it.each(['NaN', 'Infinity', '0', '-1', '1.5', '1001'])(
'rejects limit=%s before touching the graph',
async (limit) => {
const result = await errorFor(`/api/graph/file-relationships?path=/x/main.ts&limit=${limit}`);

expect(result.status).toBe(400);
expect(result.error).toBe('limit must be a positive integer between 1 and 500');
expect(result.error).toBe('limit must be a positive integer between 1 and 1000');
expect(mockedGetGraphClient).not.toHaveBeenCalled();
},
);
Expand Down
104 changes: 84 additions & 20 deletions packages/api/src/routes/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { readBlockedSetupStatus } from '../storage-state.js';
export const graphRoutes = new Hono();

const FULL_GRAPH_LIMIT_MAX = 1000;
const FILE_RELATIONSHIP_LIMIT_MAX = 500;
const FILE_GRAPH_LIMIT_MAX = 1000;
const NEIGHBOR_LIMIT_MAX = 1000;
const FILE_RELATIONSHIP_LIMIT_MAX = 1000;
const REFERENCE_LIMIT_MAX = 1000;
const DEPENDENCY_DEPTH_MAX = 10;
const SYMBOL_ID_PATTERN = /^sym:v1:[a-f0-9]{64}$/;
Expand All @@ -29,50 +31,112 @@ function boundedPositiveInteger(
return { valid: true, value };
}

/** GET /api/graph/full?limit=N&projectId=X — returns { nodes, edges } optionally filtered by project */
async function resolveProjectRootPath(projectId: string): Promise<string | null> {
const client = await getGraphClient();
const projectResult = await client.roQuery<{ rootPath: string | null }>(
'MATCH (p:Project {id: $id}) RETURN p.rootPath AS rootPath',
{ params: { id: projectId } },
);
return projectResult.data[0]?.rootPath ?? null;
}

function projectFullGraphResponse<T extends {
edges: Array<{ source: string; target: string; label: string }>;
}>(data: T): Omit<T, 'edges'> & {
edges: Array<{ source: string; target: string; label: string }>;
} {
return {
...data,
edges: data.edges.map(({ source, target, label }) => ({ source, target, label })),
};
}

/** GET /api/graph/full?limit=N&projectId=X returns a degree-ordered window with scoped totals. */
graphRoutes.get('/api/graph/full', async (c) => {
try {
const parsedLimit = boundedPositiveInteger(c.req.query('limit'), 'limit', FULL_GRAPH_LIMIT_MAX);
if (!parsedLimit.valid) return c.json({ error: parsedLimit.error }, 400);
const limit = parsedLimit.value ?? 100;
const projectId = c.req.query('projectId');

// If projectId given, resolve rootPath and filter
if (projectId) {
const client = await getGraphClient();

// Get project rootPath
const projectResult = await client.roQuery<{ rootPath: string | null }>(
`MATCH (p:Project {id: $id}) RETURN p.rootPath AS rootPath`,
{ params: { id: projectId } },
);
const rootPath = projectResult.data[0]?.rootPath;

if (rootPath) {
// Fetch only nodes belonging to this project (by file path prefix)
const data = await codeGraphService.getFullGraph(limit, rootPath);
return c.json({ nodes: data.nodes, edges: data.edges });
}
const rootPath = await resolveProjectRootPath(projectId);
if (!rootPath) return c.json({ error: 'Project not found' }, 404);
const data = await codeGraphService.getFullGraph(limit, rootPath);
return c.json(projectFullGraphResponse(data));
}

// No project filter — return all
const rootPath = c.req.query('rootPath') ?? undefined;
const data = await codeGraphService.getFullGraph(limit, rootPath);
return c.json({ nodes: data.nodes, edges: data.edges });
return c.json(projectFullGraphResponse(data));
} catch (error) {
const setup = await readBlockedSetupStatus();
if (setup !== null) {
return c.json({ nodes: [], edges: [], storage: setup.storage });
return c.json({
nodes: [],
edges: [],
totalNodes: 0,
totalEdges: 0,
windowOrder: 'degree-desc,id-asc',
degreeScope: 'global',
truncated: false,
storage: setup.storage,
});
}
return c.json({ error: safeErrorMessage('GET /api/graph/full', error, 'Failed to fetch graph.') }, 500);
}
});

/** GET /api/graph/files?projectId=X&limit=N - bounded File-to-File IMPORTS graph. */
graphRoutes.get('/api/graph/files', async (c) => {
try {
const parsedLimit = boundedPositiveInteger(c.req.query('limit'), 'limit', FILE_GRAPH_LIMIT_MAX);
if (!parsedLimit.valid) return c.json({ error: parsedLimit.error }, 400);

const projectId = c.req.query('projectId');
let rootPath: string | undefined;
if (projectId) {
const resolvedRootPath = await resolveProjectRootPath(projectId);
if (!resolvedRootPath) return c.json({ error: 'Project not found' }, 404);
rootPath = resolvedRootPath;
}

const client = await getGraphClient();
const data = await createQueries(client).getFileGraph(parsedLimit.value ?? 100, rootPath);
return c.json(data);
} catch (error) {
return c.json({
error: safeErrorMessage('GET /api/graph/files', error, 'Failed to fetch file graph.'),
}, 500);
}
});

/** GET /api/graph/neighbors?id=X&limit=N - direct neighbors and their induced graph. */
graphRoutes.get('/api/graph/neighbors', async (c) => {
try {
const parsedLimit = boundedPositiveInteger(c.req.query('limit'), 'limit', NEIGHBOR_LIMIT_MAX);
if (!parsedLimit.valid) return c.json({ error: parsedLimit.error }, 400);

const id = c.req.query('id');
if (!id) return c.json({ error: 'id parameter is required' }, 400);

const client = await getGraphClient();
const data = await createQueries(client).getNodeNeighbors(id, parsedLimit.value ?? 100);
if (!data) return c.json({ error: 'Graph node not found' }, 404);
return c.json(data);
} catch (error) {
return c.json({
error: safeErrorMessage('GET /api/graph/neighbors', error, 'Failed to fetch node neighbors.'),
}, 500);
}
});

/**
* GET /api/graph/file-relationships?path=X&limit=N
*
* Returns the four relationship collections consumed by the File detail panel.
* Each collection is independently bounded to 1..500 items; the default is 100.
* Each collection is independently bounded to 1..1000 items; the default is 100.
*/
graphRoutes.get('/api/graph/file-relationships', async (c) => {
try {
Expand Down
Loading
Loading