Skip to content
Closed
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### New Features

- `codegraph_explore` finds code containing storage keys, command-line flags, and event names; rebuild existing indexes to enable these matches.

- **Codex and Astra read project guidance from `AGENTS.md`.** The canonical agent guide now lives in `AGENTS.md` (with a nested `docs/AGENTS.md` for long validation notes); `CLAUDE.md` is a thin `@AGENTS.md` wrapper for Claude Code. Codex/Astra no longer miss the old CLAUDE-only instructions.

- **Next.js pages and their navigation are in the graph.** App Router pages (`app/(group)/blog/[slug]/page.tsx` → `/blog/:slug`) and Pages Router pages are routes bound to the component they export, and `<Link href>`, an internal `<a href>`, `router.push` / `router.replace` (`next/navigation` and `next/router`), `redirect()` / `permanentRedirect()` and the middleware's `NextResponse.redirect(…)` are `navigates` edges between them. `app/api/**/route.ts` exports (`GET`, `POST`, …) are endpoints bound to their functions, and `pages/api/*` handlers are `ANY /api/…`. Re-index after upgrading.
Expand Down Expand Up @@ -221,6 +223,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Dart extension-type getters remain searchable when using the WebAssembly parser.

- Calling a built-in method on an awaited value no longer records a call into an unrelated class that happens to declare a method of the same name, and a variable bound to an awaited call now resolves methods on the type that call returns. Thanks @maxmilian. (#1840)
- Questions about selectors and cache invalidation now retain matching declarations alongside named source bodies within the existing response limit.

- Questions naming source files, types or tests now preserve more of the requested definitions and assertions, and Vue layout questions include matching template and style code.
- Broad code questions now retain matching readers and writers, complete test assertions, and more of the requested Vue column and cell source.
- Spring mappings now include every declared path combination and resolve constants declared in the same file, while unresolved paths no longer appear as false root routes. (#1461)
- `codegraph node` now accepts a file reference that carries a line number — `src/app.ts:42`, `src/app.ts:42-80`, `src/app.ts#L42`, `src/app.ts#L42-L80` — instead of reporting the file as not indexed; the line range becomes the window that is read, and an `--offset`/`--limit` you pass yourself still wins. A path that really is named that way is still looked up as written. (#1831)
- `codegraph callers`, `codegraph callees` and `codegraph impact` now resolve qualified names, group results and JSON edges by definition, and accept `--file` to narrow ambiguous names; thanks @ferrine. (#1512, #1656)
Expand Down
23 changes: 23 additions & 0 deletions __tests__/explore-pinned-allocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,29 @@ const cand = (
const budget = getExploreOutputBudget(1000);

describe('allocateExploreBudget — pinned files', () => {
it('funds a named body and its supporting source without overspending the pool', () => {
const { allowances, pool } = allocateExploreBudget([
cand('pipeline.ts', 100, { pinned: true, minChars: 6500 }),
...Array.from({ length: 5 }, (_, i) => cand(`caller${i}.ts`, 100)),
], budget, 8);
expect(allowances.get('pipeline.ts')!).toBeGreaterThanOrEqual(6500);
expect([...allowances.values()].reduce((sum, n) => sum + n, 0)).toBeLessThanOrEqual(pool);
for (const [file, bytes] of allowances) {
if (file !== 'pipeline.ts') expect(bytes).toBeGreaterThanOrEqual(EXPLORE_ALLOCATION.MIN_CHARS);
}
});

it('shares unaffordable source minimums and ignores nonfinite demands', () => {
const { allowances, pool } = allocateExploreBudget([
cand('a.ts', 100, { minChars: 100000 }),
cand('b.ts', 100, { minChars: 100000 }),
cand('c.ts', 100, { minChars: NaN }),
], budget, 8);
expect(allowances.get('a.ts')).toBe(allowances.get('b.ts'));
expect(allowances.get('c.ts')!).toBeGreaterThanOrEqual(EXPLORE_ALLOCATION.MIN_CHARS);
expect([...allowances.values()].reduce((sum, n) => sum + n, 0)).toBeLessThanOrEqual(pool);
});

it('never cliffs a pinned file, however low it scores', () => {
const { allowances, cliffed } = allocateExploreBudget(
[
Expand Down
200 changes: 200 additions & 0 deletions __tests__/explore-requested-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';

let dir: string;
let cg: CodeGraph;

beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-requested-source-'));
fs.mkdirSync(path.join(dir, 'test'));
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"requested-source-fixture"}');
fs.writeFileSync(path.join(dir, 'snapshot.ts'), [
...Array.from({ length: 150 }, (_, i) => `export function helper${i}() { return ${i}; }`),
'export function recommendedPickSnapshot(picks: unknown[]) {',
' return picks.map(player => ({ player, marker: "snapshot-body-end" }));',
'}',
].join('\n'));
fs.writeFileSync(path.join(dir, 'test', 'snapshot.test.ts'), [
'import { recommendedPickSnapshot } from "../snapshot";',
...Array.from({ length: 35 }, (_, i) => [
`it("unrelated sorting ${i}", () => {`,
' const rows = recommendedPickSnapshot([]);',
...Array.from({ length: 8 }, () => ' expect(rows).toEqual([]);'),
'});',
].join('\n')),
'it("recommendedPickSnapshot preserves recentPicks identity", () => {',
' const recentPicks = recommendedPickSnapshot([{ id: "4430871" }]);',
' expect(recentPicks).toMatchObject([',
' {',
' player: {',
' id: "4430871",',
' },',
' marker: "snapshot-body-end",',
' },',
' ]);',
' expect(recentPicks[0].player.id).toBe("4430871");',
'});',
].join('\n'));
fs.writeFileSync(path.join(dir, 'LayoutBoard.vue'), [
'<template>',
' <table>',
' <colgroup><col class="col-w-survival" /></colgroup>',
...Array.from({ length: 310 }, () => ' <tr><td class="spacer">filler</td></tr>'),
' <tr><td>{{ formatSurvival(player.survival) }}</td></tr>',
' </table>',
'</template>',
'<script setup lang="ts">',
'const player = { survival: 0.9 };',
'function formatSurvival(value: number) { return String(value); }',
'</script>',
'<style scoped>',
'.col-w-survival {',
' width: 176px;',
' min-width: 176px;',
'}',
'</style>',
].join('\n'));
fs.writeFileSync(path.join(dir, 'models.ts'), [
'export interface ConsensusPlayer {',
...Array.from({ length: 120 }, (_, i) => ` providerField${i}?: number | null;`),
' rawProviderEvidence: number | null;',
'}',
'export function calculateConsensusPlayer() {',
...Array.from({ length: 90 }, (_, i) => ` const decoy${i} = "${'incidental calculation '.repeat(8)}";`),
' return "incidental-name-infix";',
'}',
...Array.from({ length: 250 }, (_, i) => `export function observe${i}() { return ${i}; }`),
].join('\n'));
fs.writeFileSync(path.join(dir, 'pipeline.ts'), [
'const state = { isMockActive: false, draftedPlayerNames: [] as string[] };',
'export const pipeline = {',
...Array.from({ length: 160 }, (_, i) => ` helper${i}() { return "${'padding '.repeat(20)}"; },`),
' consensusCalcKey() {',
' return [state.isMockActive, state.draftedPlayerNames.length].join("|");',
' },',
' evaluateConsensus() { return this.consensusCalcKey(); },',
'};',
].join('\n'));
fs.writeFileSync(path.join(dir, 'receiver.ts'), [
...Array.from({ length: 100 }, (_, i) => `function filler${i}() { return "${'unrelated padding '.repeat(10)}"; }`),
'function persistQueuePriority(value: number) {',
...Array.from({ length: 12 }, (_, i) => ` const check${i} = "${'validate the input '.repeat(4)}";`),
' return value;',
'}',
'function receiveQueueSnapshot(value: number) {',
...Array.from({ length: 12 }, (_, i) => ` const context${i} = "${'validate the context '.repeat(4)}";`),
' return persistQueuePriority(value);',
'}',
'export function listenForQueueMessage(value: number) {',
...Array.from({ length: 12 }, (_, i) => ` const channel${i} = "${'validate the sender '.repeat(4)}";`),
' return receiveQueueSnapshot(value);',
'}',
].join('\n'));
fs.writeFileSync(path.join(dir, 'identity.ts'), [
'import { unrelatedHub } from "./hub";',
'export function playerIdentity(id: number) { unrelatedHub(); return id; }',
].join('\n'));
fs.writeFileSync(path.join(dir, 'hub.ts'), [
...Array.from({ length: 30 }, (_, i) => `function hubStep${i}() { return ${i}; }`),
`export function unrelatedHub() { ${Array.from({ length: 30 }, (_, i) => `hubStep${i}();`).join(' ')} }`,
].join('\n'));
fs.writeFileSync(path.join(dir, 'receipt.ts'), [
'import { playerIdentity } from "./identity";',
'export function receipt0() { return playerIdentity(0); }',
'export function receipt1() { return playerIdentity(1); }',
'export function receipt2() { return playerIdentity(2); }',
].join('\n'));
cg = CodeGraph.initSync(dir);
await cg.indexAll();
}, 60_000);

afterAll(() => {
cg?.destroy();
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});

async function explore(query: string): Promise<string> {
const result = await new ToolHandler(cg).execute('codegraph_explore', { query });
expect(result.isError).not.toBe(true);
return result.content?.[0]?.text ?? '';
}

function sourceIn(text: string, file: string): string {
const start = text.indexOf('**`' + file + '`**');
expect(start, file).toBeGreaterThan(-1);
const block = text.slice(start).match(/```[^\n]*\n([\s\S]*?)```/);
expect(block, file).not.toBeNull();
return block![1]!;
}

describe('requested evidence in large selected files', () => {
it('prioritizes direct callers over a dense callee for a single named function', async () => {
const result = await new ToolHandler(cg).execute('codegraph_explore', { query: 'playerIdentity', maxFiles: 2 });
const out = result.content?.[0]?.text ?? '';
expect(sourceIn(out, 'identity.ts')).toContain('function playerIdentity');
expect(sourceIn(out, 'receipt.ts')).toContain('return playerIdentity(0)');
});
it('keeps a compound-concept writer and its local receiver chain complete', async () => {
const out = await explore('How does queue_priority reach storage?');
const source = sourceIn(out, 'receiver.ts');
expect(source).toContain('return value;');
expect(source).toContain('return persistQueuePriority(value);');
expect(source).toContain('return receiveQueueSnapshot(value);');
expect(source).toContain('const context11 =');
expect(source).toContain('const channel11 =');
expect(out.length).toBeLessThanOrEqual(25_000);
});
it('returns the full named test when the query does not name its assertion', async () => {
const out = await explore('test/snapshot.test.ts recommendedPickSnapshot');
expect(sourceIn(out, 'test/snapshot.test.ts')).toContain('expect(recentPicks[0].player.id).toBe("4430871")');
});

it('retains bounded vocabulary seeds when other names rank above them', async () => {
const graph = await cg.findRelevantContext('snapshot_helper storage', {
searchLimit: 1, traversalDepth: 0, seedNames: ['helper149', 'recommendedPickSnapshot'],
});
expect(graph.roots.map(id => graph.nodes.get(id)?.name)).toEqual(
expect.arrayContaining(['helper149', 'recommendedPickSnapshot']),
);
});
it('keeps the declaration using the requested invalidation inputs', async () => {
const out = await explore('evaluateConsensus in pipeline.ts: how do isMockActive and draftedPlayerNames invalidate cached players?');
expect(sourceIn(out, 'pipeline.ts')).toContain('return [state.isMockActive, state.draftedPlayerNames.length].join("|")');
});
it('returns the complete identity assertion alongside the named implementation', async () => {
const out = await explore('recommendedPickSnapshot in snapshot.ts: locate recentPicks identity assertions in test/snapshot.test.ts:');
const test = sourceIn(out, 'test/snapshot.test.ts');
expect(test).toContain('expect(recentPicks[0].player.id).toBe("4430871")');
expect(test).toContain('marker: "snapshot-body-end"');
expect(sourceIn(out, 'snapshot.ts')).toContain('return picks.map(player => ({ player, marker: "snapshot-body-end" }))');
});

it.each([
'LayoutBoard.vue "col-w-survival"',
'How does LayoutBoard format survival and set column widths?',
])('returns template and CSS evidence for %s', async query => {
const out = await explore(query);
const source = sourceIn(out, 'LayoutBoard.vue');
expect(source).toContain('<colgroup><col class="col-w-survival" /></colgroup>');
expect(source).toContain('.col-w-survival {');
expect(source).toContain('min-width: 176px;');
expect(out.length).toBeLessThanOrEqual(25_000);
});

it('returns a named interface body instead of unrelated declarations', async () => {
const out = await explore('ConsensusPlayer fields in models.ts: locate raw provider evidence');
const source = sourceIn(out, 'models.ts');
expect(source).toContain('providerField0?: number | null;');
expect(source).toContain('providerField60?: number | null;');
expect(source).toContain('rawProviderEvidence: number | null;');
});

it('recognizes punctuation after a requested symbol', async () => {
const out = await explore('recommendedPickSnapshot: preserve the returned snapshot');
expect(sourceIn(out, 'snapshot.ts')).toContain('return picks.map(player => ({ player, marker: "snapshot-body-end" }))');
});
});
3 changes: 2 additions & 1 deletion __tests__/kernel-retry-materialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe.skipIf(!kernelBuilt)('kernel buffer-transport storage (#1541)', () => {
it('storeExtractionResult persists the decoded nodes of a raw kernel result', async () => {
const source =
'def target_fn(root, mission_path):\n' +
' return (root, mission_path)\n' +
' return (root, mission_path, "adapter.mission")\n' +
'\n' +
'class Adapter:\n' +
' def adapt(self):\n' +
Expand Down Expand Up @@ -93,6 +93,7 @@ describe.skipIf(!kernelBuilt)('kernel buffer-transport storage (#1541)', () => {
expect(nodes.length).toBe(raw!.counts.nodes);
expect(nodes.map((n) => n.name)).toContain('target_fn');
expect(nodes.map((n) => n.name)).toContain('Adapter');
expect(cg.findLiteralSeedIds('"adapter.mission"').map(id => cg.getNode(id)?.name)).toEqual(['target_fn']);
});
});

Expand Down
78 changes: 78 additions & 0 deletions __tests__/literal-capture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import type { Node } from '../src/types';
import { captureLiterals, isSeedLiteral, seedLiteralsInQuery } from '../src/extraction/literal-capture';

function node(id: string, kind: Node['kind'], startLine: number, endLine: number): Node {
return {
id, kind, name: id, qualifiedName: id, filePath: 'src/a.ts', language: 'typescript',
startLine, endLine, startColumn: 0, endColumn: Number.MAX_SAFE_INTEGER, updatedAt: 0,
};
}

describe('isSeedLiteral', () => {
it('keeps storage keys, flags, dotted names and paths', () => {
for (const v of ['bompus_custom_ds_players', '--start', '-v', 'draft.pick', 'api/v1/users', 'ns:event'])
expect(isSeedLiteral(v), v).toBe(v !== '-v');
});
it('drops plain words, prose, and values that start with a separator', () => {
for (const v of ['ready', 'Error', 'not found', './utils', '../x', '', 'a_b'])
expect(isSeedLiteral(v), v).toBe(false);
});
});

describe('seedLiteralsInQuery', () => {
it('finds quoted spans and bare runs, stripping surrounding punctuation', () => {
expect(seedLiteralsInQuery('who writes "bompus_custom_ds_players" (via --start)?'))
.toEqual(['bompus_custom_ds_players', '--start']);
});
it('returns nothing for a symbol-anchored question', () => {
expect(seedLiteralsInQuery('callers of espnPlayerKey in shared')).toEqual([]);
});
});

describe('captureLiterals', () => {
const source = [
`import { x } from './utils/helpers';`, // 1: path starts with '.', never qualifies
`const KEY = 'bompus_custom_ds_players';`, // 2: top level → file node
`export function save() {`, // 3
` storage.set('bompus_custom_ds_players', 1);`, // 4
` log('ready');`, // 5: plain word
` emit(\`draft.pick\`); emit(\`draft.\${n}\`);`, // 6: second one interpolates
`}`, // 7
`export class Boot { start() { run('--start'); } }`,// 8
].join('\n');

it('attributes each literal to the innermost enclosing symbol, else the file', () => {
const file = node('file:src/a.ts', 'file', 1, 8);
const save = node('save', 'function', 3, 7);
const boot = node('Boot', 'class', 8, 8);
const start = node('start', 'method', 8, 8);
const imp = node('./utils/helpers', 'import', 1, 1);
const nodes = [file, imp, save, boot, start];
captureLiterals(source, nodes);
expect(file.literals).toEqual(['bompus_custom_ds_players']);
expect(save.literals).toEqual(['bompus_custom_ds_players', 'draft.pick']);
expect(start.literals).toEqual(['--start']);
expect(boot.literals).toBeUndefined();
expect(imp.literals).toBeUndefined();
});

it('dedupes per node and caps at 32', () => {
const many = Array.from({ length: 40 }, (_, i) => `k('key_${i}'); k('key_${i}');`).join('\n');
const fn = node('f', 'function', 1, 40);
captureLiterals(many, [node('file:src/a.ts', 'file', 1, 40), fn]);
expect(fn.literals).toHaveLength(32);
expect(new Set(fn.literals).size).toBe(32);
});

it('uses UTF-16 columns to distinguish same-line siblings after non-ASCII source', () => {
const prefix = '/* café 😀 */ ';
const first = "function writer(){return 'cache.write';}";
const second = "function reader(){return 'cache.read';}";
const writer = { ...node('writer', 'function', 1, 1), startColumn: prefix.length, endColumn: (prefix + first).length };
const reader = { ...node('reader', 'function', 1, 1), startColumn: (prefix + first).length, endColumn: (prefix + first + second).length };
captureLiterals(prefix + first + second, [writer, reader]);
expect(writer.literals).toEqual(['cache.write']);
expect(reader.literals).toEqual(['cache.read']);
});
});
Loading