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
22 changes: 13 additions & 9 deletions packages/api/src/__tests__/graph-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ describe('graph route numeric boundaries', () => {
},
);

it('forwards the persisted symbol id as the references lookup identity', async () => {
const id = 'sym:v1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const response = await graphRoutes.request(`/api/graph/references?id=${encodeURIComponent(id)}`);

expect(response.status).toBe(200);
expect(mockedReferences).toHaveBeenCalledWith({ id, limit: undefined });
});

it('accepts the full graph upper limit', async () => {
const response = await graphRoutes.request('/api/graph/full?limit=1000');

Expand Down Expand Up @@ -78,16 +86,12 @@ describe('graph route numeric boundaries', () => {
},
);

it.each(['NaN', 'Infinity', '0', '-1', '1.5', '10000001'])(
'rejects reference startLine=%s before touching the graph',
async (startLine) => {
const result = await errorFor(`/api/graph/references?name=run&startLine=${startLine}`);
it('rejects the removed name-based references lookup', async () => {
const result = await errorFor('/api/graph/references?name=run');

expect(result.status).toBe(400);
expect(result.error).toBe('startLine must be a positive integer between 1 and 10000000');
expect(mockedReferences).not.toHaveBeenCalled();
},
);
expect(result).toEqual({ status: 400, error: 'id parameter is required' });
expect(mockedReferences).not.toHaveBeenCalled();
});
});

describe('GET /api/graph/file-relationships', () => {
Expand Down
57 changes: 52 additions & 5 deletions packages/api/src/__tests__/search-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ const KNOWN_LABELS = new Set(['Class', 'Interface', 'Function', 'Variable']);
function threeRawHits(): EnrichedV2Result {
return {
hits: [
{ name: 'GraphClient', nodeType: 'Class' },
{ name: 'ClientOptions', nodeType: 'Interface' },
{ name: 'createGraphClient', nodeType: 'Function' },
{ id: 'sym:v1:' + 'a'.repeat(64), name: 'GraphClient', nodeType: 'Class' },
{ id: 'sym:v1:' + 'b'.repeat(64), name: 'ClientOptions', nodeType: 'Interface' },
{ id: 'sym:v1:' + 'c'.repeat(64), name: 'createGraphClient', nodeType: 'Function' },
],
meta: { query: 'graph client', vectorHits: 3, durationMs: 5 },
};
Expand Down Expand Up @@ -148,7 +148,7 @@ describe('GET /api/search: types filter emptying the page', () => {
mockedSearch.mockResolvedValue(threeRawHits());
mockedGetGraphClient.mockResolvedValue(
fakeGraphClient([
{ name: 'clientCache', nodeType: 'Variable', filePath: '/x.ts', startLine: 1, endLine: 1, isExported: true },
{ id: 'sym:v1:' + 'd'.repeat(64), name: 'clientCache', nodeType: 'Variable', filePath: '/x.ts', startLine: 1, endLine: 1, isExported: true },
]) as never,
);

Expand Down Expand Up @@ -190,7 +190,7 @@ describe('GET /api/search: types filter emptying the page', () => {
});
mockedGetGraphClient.mockResolvedValue(
fakeGraphClient([
{ name: 'clientCache', nodeType: 'Variable', filePath: '/x.ts', startLine: 1, endLine: 1, isExported: true },
{ id: 'sym:v1:' + 'e'.repeat(64), name: 'clientCache', nodeType: 'Variable', filePath: '/x.ts', startLine: 1, endLine: 1, isExported: true },
]) as never,
);

Expand All @@ -201,6 +201,53 @@ describe('GET /api/search: types filter emptying the page', () => {
expect(body.total).toBe(1);
expect(body.notice).toBeUndefined();
});

it('returns persisted ids for every row from the text fallback', async () => {
const symbolId = 'sym:v1:' + 'd'.repeat(64);
mockedSearch.mockResolvedValue({
hits: [],
meta: { query: 'shared', vectorHits: 0, durationMs: 2 },
});
const client = fakeGraphClient([
{
id: symbolId,
name: 'shared',
nodeType: 'Function',
filePath: '/repo/src/shared.ts',
startLine: 4,
endLine: 6,
isExported: true,
},
{
id: 'File:/repo/src/shared.ts',
name: 'shared.ts',
nodeType: 'File',
filePath: '/repo/src/shared.ts',
startLine: null,
endLine: null,
isExported: null,
},
]);
mockedGetGraphClient.mockResolvedValue(client as never);

const { status, body } = await searchJson('q=shared&limit=3');

expect(status).toBe(200);
expect(body.fallback).toBe(true);
const results = body.results as Array<Record<string, unknown>>;
expect(results).toEqual([
expect.objectContaining({ id: symbolId, nodeType: 'Function' }),
expect.objectContaining({ id: 'File:/repo/src/shared.ts', nodeType: 'File' }),
]);
for (const row of results) {
expect(row.id).toEqual(expect.stringMatching(/^(?:sym:v1:[a-f0-9]{64}|File:.+)$/));
}
expect(client.roQuery).toHaveBeenCalledWith(
expect.stringContaining('RETURN n.id AS id'),
expect.objectContaining({ params: { q: 'shared', limit: 3 } }),
);
expect(client.roQuery.mock.calls[0]?.[0]).toContain('AND n.id IS NOT NULL');
});
});

describe('GET /api/search: malformed types values (third adversarial-review finding)', () => {
Expand Down
32 changes: 11 additions & 21 deletions packages/api/src/routes/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ export const graphRoutes = new Hono();
const FULL_GRAPH_LIMIT_MAX = 1000;
const FILE_RELATIONSHIP_LIMIT_MAX = 500;
const REFERENCE_LIMIT_MAX = 1000;
const REFERENCE_START_LINE_MAX = 10_000_000;
const DEPENDENCY_DEPTH_MAX = 10;
const SYMBOL_ID_PATTERN = /^sym:v1:[a-f0-9]{64}$/;

type BoundedIntegerResult =
| { valid: true; value?: number }
Expand Down Expand Up @@ -108,34 +108,24 @@ graphRoutes.get('/api/graph/file', async (c) => {
});

/**
* GET /api/graph/references?name=X&path=Y&startLine=N&limit=M
* GET /api/graph/references?id=X&limit=M
*
* Where a symbol is used. Matches every node with this name, not just one, so
* references that land on a type-reference proxy node are included alongside
* ones that land on the declaration itself. `path` and `startLine` are
* optional and disambiguate between distinct declarations that share a name;
* a matched node with no location of its own (a proxy node) is never excluded
* by them.
* Where a symbol is used. The persisted symbol id is the sole declaration
* identity accepted by this endpoint.
*/
graphRoutes.get('/api/graph/references', async (c) => {
try {
const name = c.req.query('name');
if (!name) return c.json({ error: 'name parameter is required' }, 400);

const parsedLine = boundedPositiveInteger(
c.req.query('startLine'),
'startLine',
REFERENCE_START_LINE_MAX,
);
if (!parsedLine.valid) return c.json({ error: parsedLine.error }, 400);

const parsedLimit = boundedPositiveInteger(c.req.query('limit'), 'limit', REFERENCE_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);
if (!SYMBOL_ID_PATTERN.test(id)) {
return c.json({ error: 'id must be a persisted sym:v1 identifier' }, 400);
}

const data = await codeGraphService.getSymbolReferences({
name,
filePath: c.req.query('path'),
startLine: parsedLine.value,
id,
limit: parsedLimit.value,
});
return c.json(data);
Expand Down
6 changes: 5 additions & 1 deletion packages/api/src/routes/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ searchRoutes.get('/api/search', async (c) => {
: 'n:Function OR n:Class OR n:Interface OR n:Component OR n:Type OR n:Variable OR n:File';

const rows = await client.roQuery<{
id: string;
name: string;
nodeType: string;
filePath: string | null;
Expand All @@ -258,8 +259,10 @@ searchRoutes.get('/api/search', async (c) => {
}>(
`MATCH (n)
WHERE (${typeFilter})
AND n.id IS NOT NULL
AND (toLower(n.name) CONTAINS toLower($q) OR toLower(n.filePath) CONTAINS toLower($q))
RETURN n.name AS name,
RETURN n.id AS id,
n.name AS name,
labels(n)[0] AS nodeType,
n.filePath AS filePath,
n.startLine AS startLine,
Expand All @@ -278,6 +281,7 @@ searchRoutes.get('/api/search', async (c) => {

return c.json({
results: rows.data.map(r => ({
id: r.id,
name: r.name,
nodeType: r.nodeType,
filePath: r.filePath,
Expand Down
18 changes: 9 additions & 9 deletions packages/core/src/__tests__/dependency-depth.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describeIfAvailable('dependency depth enrichment', () => {
await client.query(
`UNWIND range(0, $hub - 1) AS i
MATCH (lib:File {filePath: '/x/lib.ts'})
CREATE (f:Function {name: '_parse', filePath: '/x/lib.ts', startLine: i + 1})
CREATE (f:Function {id: 'hub-' + toString(i), name: '_parse', filePath: '/x/lib.ts', startLine: i + 1})
CREATE (lib)-[:CONTAINS]->(f)`,
{ params: { hub: HUB_SIZE } },
);
Expand All @@ -65,7 +65,7 @@ describeIfAvailable('dependency depth enrichment', () => {
// A genuinely reachable symbol, to pin the values the query returns.
await client.query(`
MATCH (other:File {filePath: '/x/other.ts'})
CREATE (r:Function {name: 'reachable', filePath: '/x/other.ts', startLine: 1})
CREATE (r:Function {id: 'id-reachable', name: 'reachable', filePath: '/x/other.ts', startLine: 1})
CREATE (other)-[:CONTAINS]->(r)
`);
}, 60_000);
Expand All @@ -77,31 +77,31 @@ describeIfAvailable('dependency depth enrichment', () => {

it('answers for an unreachable hub symbol well inside the budget', async () => {
const started = Date.now();
const result = await client.roQuery<{ symbolName: string; minDepth: number | null }>(
const result = await client.roQuery<{ symbolId: string; minDepth: number | null }>(
DEPENDENCY_DEPTH_CYPHER,
{ params: { names: ['_parse'] }, timeout: BUDGET_MS },
{ params: { ids: ['hub-0'] }, timeout: BUDGET_MS },
);
expect(Date.now() - started).toBeLessThan(BUDGET_MS);
// Unreachable symbols yield no row, which the caller reads as "depth unknown".
expect(result.data).toHaveLength(0);
});

it('reports the depth of a reachable symbol', async () => {
const result = await client.roQuery<{ symbolName: string; minDepth: number | null }>(
const result = await client.roQuery<{ symbolId: string; minDepth: number | null }>(
DEPENDENCY_DEPTH_CYPHER,
{ params: { names: ['reachable'] }, timeout: BUDGET_MS },
{ params: { ids: ['id-reachable'] }, timeout: BUDGET_MS },
);
expect(result.data).toHaveLength(1);
expect(result.data[0]?.minDepth).toBe(1);
});

it('stays fast when a hub symbol is batched with ordinary ones', async () => {
const started = Date.now();
const result = await client.roQuery<{ symbolName: string; minDepth: number | null }>(
const result = await client.roQuery<{ symbolId: string; minDepth: number | null }>(
DEPENDENCY_DEPTH_CYPHER,
{ params: { names: ['reachable', '_parse', 'missing'] }, timeout: BUDGET_MS },
{ params: { ids: ['id-reachable', 'hub-0', 'id-missing'] }, timeout: BUDGET_MS },
);
expect(Date.now() - started).toBeLessThan(BUDGET_MS);
expect(result.data.map((r) => r.symbolName)).toEqual(['reachable']);
expect(result.data.map((r) => r.symbolId)).toEqual(['id-reachable']);
});
});
89 changes: 89 additions & 0 deletions packages/core/src/__tests__/embed-pass-node-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { createHash } from 'node:crypto';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { GraphOperations } from '@codegraph/graph';
import type { ParsedFileEntities } from '@codegraph/types';
import { buildFunctionEmbeddingText, generateEmbeddings } from '@codegraph/plugin-nlp';
import { embedAllParsedEntities } from '../embed-pass';

vi.mock('@codegraph/plugin-nlp', async (importOriginal) => {
const actual = await importOriginal<typeof import('@codegraph/plugin-nlp')>();
return {
...actual,
isEmbeddingAvailable: () => true,
generateEmbeddings: vi.fn().mockResolvedValue({ embeddings: [[0.1, 0.2]] }),
};
});

const persistedId = `sym:v1:${'a'.repeat(64)}`;
const functionEntity: ParsedFileEntities['functions'][number] = {
id: persistedId,
scopeKey: '',
disambiguator: '',
name: 'stable',
filePath: '/project/stable.ts',
startLine: 80,
endLine: 84,
isExported: true,
isAsync: false,
isArrow: false,
params: [],
returnType: 'number',
bodySnippet: 'return 1;',
};

const parsed: ParsedFileEntities = {
file: {
path: '/project/stable.ts',
name: 'stable.ts',
extension: 'ts',
loc: 84,
lastModified: new Date(0).toISOString(),
hash: 'hash',
},
functions: [functionEntity],
classes: [],
interfaces: [],
variables: [],
types: [],
components: [],
imports: [],
callEdges: [],
importsEdges: [],
extendsEdges: [],
implementsEdges: [],
rendersEdges: [],
hasMethodEdges: [],
hasPropertyEdges: [],
typeRefs: [],
hasParamEdges: [],
returnsEdges: [],
usesTypeEdges: [],
exportsEdges: [],
importsSymbolEdges: [],
};

describe('embedding cache identity', () => {
beforeEach(() => {
vi.mocked(generateEmbeddings).mockClear();
});

it('does not re-embed unchanged content when the same persisted id moves lines', async () => {
const text = buildFunctionEmbeddingText(functionEntity);
const hash = createHash('sha256').update(text).digest('hex');
const ops = {
getEmbeddingHashesForFiles: vi.fn(),
batchUpdateEmbeddings: vi.fn(),
updateEmbedding: vi.fn(),
} as unknown as GraphOperations;

const result = await embedAllParsedEntities(
[parsed],
ops,
{ provider: 'local' },
new Map([[persistedId, hash]]),
);

expect(result).toMatchObject({ embedded: 0, skipped: 1 });
expect(generateEmbeddings).not.toHaveBeenCalled();
});
});
Loading
Loading