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

### Fixes

- Explore retains explicitly pinned code in mixed documentation/code requests, returns parameterized test callbacks, and preserves quoted source terms that match the filename stem.

- Python closures no longer link calls or method values to a captured receiver type when later replacement makes that type uncertain.
- Python receiver calls no longer link to a stale type after same-line replacement when statement order cannot establish a reliable type.

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,11 @@ Compared with upstream `main` at `aeb8f955` (after v1.6.2). Each item here and i
| Feature | Upstream | Fork | What it does |
|---|:-:|:-:|---|
| Session search (`codegraph sessions`, `codegraph_sessions`) | — | ✓ | Searches this project's earlier Claude Code, Codex, Cursor/T3, OpenCode, AGY, Devin and Grok transcripts and its git commit messages, so an agent can find what a past session decided. `codegraph_explore` also names the sessions that mentioned the symbols it returns. `"sessions": false` in `codegraph.json` turns it off. |
| Markdown indexing | — | ✓ | Headings, sections, tables and links become graph nodes; a documentation question gets the matching section. |
| Markdown indexing | — | ✓ | Headings, sections, tables and links become graph nodes; a documentation question gets the matching section. Explicit code-file pins remain eligible in mixed documentation/code requests. |
| Missing names reported | — | ✓ | After a complete scan of indexed source without skipped files, `codegraph_explore` lists requested names it cannot find, helping an agent catch a guessed name. |
| Local callee source with file pins | — | ✓ | When a query names functions in pinned files, `codegraph_explore` prioritizes local callee bodies through two call or callback hops, within the file and output budgets. |
| Quoted prose source | — | ✓ | Quoted spans of three or more words find script strings and template text, ignoring case and punctuation, through a capped source scan. No re-index needed. |
| Requested test source | — | ✓ | Pinned JavaScript and TypeScript test files return matching `it`/`test` callbacks. Questions about tests include source from the nearest test callers of named symbols, within the file and output budgets. |
| Requested test source | — | ✓ | Pinned JavaScript and TypeScript test files return matching `it`/`test` callbacks, including `.each` parameterized tests. Questions about tests include source from the nearest test callers of named symbols, within the file and output budgets. |
| Near-duplicate functions | — | ✓ | `codegraph_explore` and `codegraph_node` name the near-identical copies of a function, so a fix made in one copy is not forgotten in the others. |
| External HTTP endpoints | — | ✓ | JavaScript and TypeScript calls through `fetch`, axios, ky, got and similar clients appear as endpoint nodes such as `GET https://api.github.com/…`. |
| Change questions | — | ✓ | "What did my changes touch?" or `main..HEAD` is answered from the diff: the changed functions, their callers and their tests. |
Expand Down
106 changes: 106 additions & 0 deletions __tests__/explore-requested-source-regressions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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;

const padding = Array.from({ length: 160 }, (_, i) =>
`function fixture${i}() { return "${'irrelevant padding '.repeat(12)}"; }`).join('\n');
const testBuilders = [
['normal', 'it'],
['table', 'it.each([1, 2])'],
['matrix', 'test.each([1, 2])'],
['parallel', 'test.concurrent.each([1, 2])'],
['tagged', 'it.each`value\n${1}\n${2}`'],
['spaced', 'it .each([1, 2])'],
['commented', 'test /* builder */ .concurrent\n .each([1, 2])'],
] as const;

beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-requested-regressions-'));
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"requested-source-fixture"}');
fs.writeFileSync(path.join(dir, 'README.md'), '# Worker guide\n\n## Usage\n\nRun the worker.\n');
fs.writeFileSync(path.join(dir, 'worker.ts'),
'export function runWorker() { return "WORKER_BODY_MARKER"; }\n');
for (const [name, builder] of testBuilders) {
fs.writeFileSync(path.join(dir, `${name}.test.ts`),
`${padding}\n${builder}("restores the cache", (value) => {\n` +
' expect(value).toBe(1); // ASSERTION_MARKER\n});\n');
}
const component = '<template><div class="cache_key" /></template>\n<script>\n' + padding +
'\n</script>\n<style>\n.cache_key { width: 173px; /* STYLE_BODY_MARKER */ }\n</style>\n';
fs.writeFileSync(path.join(dir, 'cache_key.vue'), component);
fs.writeFileSync(path.join(dir, 'Board.vue'), component);
fs.writeFileSync(path.join(dir, 'cache-key.vue'), component.replaceAll('cache_key', 'cache-key'));
fs.writeFileSync(path.join(dir, 'cache-key.ts'),
'export function siblingWorker() { return "SIBLING_BODY_MARKER"; }\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, maxFiles?: number): Promise<string> {
const result = await new ToolHandler(cg).execute('codegraph_explore', { query, maxFiles });
expect(result.isError).not.toBe(true);
return result.content?.[0]?.text ?? '';
}

function sourceIn(output: string, file: string): string {
const start = output.indexOf('**`' + file + '`');
expect(start, file).toBeGreaterThanOrEqual(0);
const section = output.slice(start).split('\n**`')[0]!;
return [...section.matchAll(/```[^\n]*\n([\s\S]*?)```/g)].map(match => match[1]).join('\n');
}

describe('explicit source survives unrelated query context', () => {
it.each(['README.md worker.ts', 'worker.ts README.md'])('returns both pinned files for %s', async query => {
const output = await explore(query, 2);
expect(sourceIn(output, 'README.md')).toContain('Worker guide');
expect(sourceIn(output, 'worker.ts')).toContain('WORKER_BODY_MARKER');
});

it('keeps code-only and symbol-named mixed requests working', async () => {
expect(sourceIn(await explore('worker.ts', 2), 'worker.ts')).toContain('WORKER_BODY_MARKER');
expect(sourceIn(await explore('README.md worker.ts runWorker', 2), 'worker.ts')).toContain('WORKER_BODY_MARKER');
});

it('keeps an unrelated code file out of a documentation-only answer', async () => {
const output = await explore('README.md');
expect(sourceIn(output, 'README.md')).toContain('Worker guide');
expect(output).not.toContain('**`worker.ts`');
});

it.each(testBuilders)('returns the requested callback for %s', async (name) => {
const file = `${name}.test.ts`;
const output = await explore(file);
expect(sourceIn(output, file)).toContain('ASSERTION_MARKER');
expect(sourceIn(output, file)).toContain('expect(value).toBe(1)');
});

it('keeps a quoted identifier when it equals the pinned filename stem', async () => {
expect(sourceIn(await explore('cache_key.vue "cache_key"'), 'cache_key.vue')).toContain('STYLE_BODY_MARKER');
});

it('does not treat an unquoted filename stem as a requested CSS identifier', async () => {
expect(sourceIn(await explore('cache_key.vue cache_key'), 'cache_key.vue')).not.toContain('STYLE_BODY_MARKER');
});

it.each(['"', "'", '`'])('keeps a quoted kebab identifier wrapped in %s', async quote => {
const output = await explore(`cache-key.vue ${quote}cache-key${quote}`);
expect(sourceIn(output, 'cache-key.vue')).toContain('STYLE_BODY_MARKER');
expect(sourceIn(output, 'cache-key.ts')).toContain('SIBLING_BODY_MARKER');
});

it('keeps alternate-filename and alternate-term source controls working', async () => {
expect(sourceIn(await explore('Board.vue "cache_key"'), 'Board.vue')).toContain('STYLE_BODY_MARKER');
expect(sourceIn(await explore('cache_key.vue "173px"'), 'cache_key.vue')).toContain('STYLE_BODY_MARKER');
});
});
4 changes: 3 additions & 1 deletion site/src/content/docs/reference/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ By default the server exposes `codegraph_explore` for code and `codegraph_sessio

Exposing one strong code tool is deliberate. Measured agent behavior showed that one well-aimed tool steers agents to a direct answer better than a menu of narrower ones — fewer mis-picks — and agents reach for it both when answering questions and while editing code.

Explicit code-file pins remain eligible when the query also names documentation. Quoted terms used to select template or style source remain meaningful even when they match the filename stem. File and output budgets still apply.

When a query names functions in pinned files, `codegraph_explore` prioritizes their local callee bodies through two call or callback hops. It selects at most sixteen local helpers per file, each at most 200 lines. File and output budgets still apply.

Quoted spans of three or more words also find current indexed code by word sequence, ignoring case and punctuation. Matches return an enclosing symbol or source around the matching lines, including script strings and unquoted template text. No re-index is needed.

The scan checks its 300 ms budget between files and stops at sixteen matches or 64 MiB read. Files over 1 MiB are skipped. Up to four spans of at most 300 characters each are considered. File and output budgets still apply. Edits since the last sync can leave indexed symbol extents behind the matching source.

When you ask about tests, `codegraph_explore` includes source from the nearest test callers within three caller hops. These links describe static callers, not measured runtime coverage. Pinned JavaScript and TypeScript test files prioritize matching `it`/`test` callbacks. File and output budgets still apply.
When you ask about tests, `codegraph_explore` includes source from the nearest test callers within three caller hops. These links describe static callers, not measured runtime coverage. Pinned JavaScript and TypeScript test files prioritize matching `it`/`test` callbacks, including `.each` parameterized tests. File and output budgets still apply.

## Daemon lifecycle

Expand Down
22 changes: 18 additions & 4 deletions src/mcp/explore-source-ranges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Language, Node } from '../types';
import { parseSourceTree, type TreeNode as SyntaxNode } from '../extraction/parse-tree';
import { seedLiteralsInQuery } from '../extraction/literal-capture';
import { extractSearchTerms, isTestPath } from '../search/query-utils';
import { QUOTED_SPAN } from './source-scan';

export interface RequestedSourceRange {
start: number;
Expand All @@ -23,7 +24,10 @@ export async function requestedSourceRanges(
// or "board". Match the remaining question against its template and styles.
const basename = filePath.split('/').pop()!.replace(/\.[^.]+$/, '');
const escapedBasename = basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const question = query.replace(new RegExp(`\\b${escapedBasename}\\b`, 'gi'), '');
const question = query.replace(
new RegExp(`${QUOTED_SPAN.source}|\\b${escapedBasename}\\b`, 'gi'),
match => /^["'`]/.test(match) ? match : '',
);
const terms = extractSearchTerms(question).filter(term => !test
|| !['test', 'tests', 'testing', 'spec', 'specs', 'verify', 'verifies', 'verifi'].includes(term));
const literals = seedLiteralsInQuery(question);
Expand Down Expand Up @@ -82,10 +86,20 @@ export async function requestedSourceRanges(
try {
const visit = (node: SyntaxNode): void => {
if (node.type === 'call_expression') {
const callee = node.childForFieldName('function')?.text ?? '';
const callee = node.childForFieldName('function');
const parameterized = callee?.type === 'call_expression';
let target = parameterized ? callee.childForFieldName('function') : callee;
const members: string[] = [];
while (target?.type === 'member_expression') {
members.push(target.childForFieldName('property')?.text ?? '');
target = target.childForFieldName('object');
}
const each = parameterized && members.shift() === 'each';
const testCall = (!parameterized || each) && target?.type === 'identifier'
&& /^(?:it|test)$/.test(target.text)
&& members.every(member => /^(?:only|skip|concurrent|serial|failing)$/.test(member));
const args = node.childForFieldName('arguments')?.namedChildren ?? [];
if (/^(?:it|test)(?:\.(?:only|skip|concurrent|serial|failing))*$/.test(callee)
&& args.some(a => ['arrow_function', 'function_expression'].includes(a.type))) {
if (testCall && args.some(a => ['arrow_function', 'function_expression'].includes(a.type))) {
const callHit = callLines.some(line => line >= node.startPosition.row + 1 && line <= node.endPosition.row + 1);
const hit = score(node.text) + score(args[0]?.text ?? '') + (callHit ? scoreCeiling : 0);
if (hit > 0) {
Expand Down
3 changes: 2 additions & 1 deletion src/mcp/server-instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,11 @@ against dozens of greps and reads.

## Reading results

- Explicit code-file pins remain eligible when the query also names documentation, within the file and output budgets. Quoted terms used to select template or style source remain meaningful even when they match the filename stem.
- A named COBOL copybook prioritizes its indexed source and lists COPY and EXEC SQL INCLUDE sites. If no source is indexed, the result says so.
- When a query names functions in pinned files, local callee bodies are prioritized through two call or callback hops. At most sixteen local helpers per file are selected, each at most 200 lines; file and output budgets still apply.
- Quoted spans of three or more words also match current indexed code by word sequence, ignoring case and punctuation. This includes script strings and unquoted template text. The scan checks its 300 ms budget between files and stops at sixteen matches or 64 MiB read. It skips files over 1 MiB and considers up to four spans of at most 300 characters each. File and output budgets still apply; no re-index is needed.
- Questions about tests include source from the nearest test callers of named symbols, within three caller hops and the file/output budgets. These are static caller links, not measured runtime coverage. Naming a JavaScript or TypeScript test file prioritizes matching \`it\`/\`test\` callbacks.
- Questions about tests include source from the nearest test callers of named symbols, within three caller hops and the file/output budgets. These are static caller links, not measured runtime coverage. Naming a JavaScript or TypeScript test file prioritizes matching \`it\`/\`test\` callbacks, including \`.each\` parameterized tests.

- **The source codegraph returns is the file's current text** (files that changed since the last sync are flagged), so re-checking it with grep costs time and context without adding accuracy. Call edges from the parse are reliable; a hop marked as a name match (see Limitations) is the one to check.
- **Read/Grep are for what the index lacks**: a detail a codegraph answer didn't cover, or files codegraph doesn't index (such as configs). Markdown is indexed — every \`.md\` file's headings, sections, tables and links — so a documentation question (a rule, a runbook, a plan row, a research finding) goes to \`codegraph_explore\` too; it returns the section body, not just its heading.
Expand Down
7 changes: 4 additions & 3 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7187,9 +7187,10 @@ export class ToolHandler {
}
continue;
}
// A doc answer keeps its budget: a code file joins it only when the query
// named a symbol the file defines.
if (docTierFiles.size > 0 && !proseFiles.has(filePath) && !group.nodes.some((n) => codeNamedIds.has(n.id))) continue;
// A doc answer keeps unrelated code out. Explicit file pins, named
// symbols and prose matches still request code within the same budgets.
if (docTierFiles.size > 0 && !pinnedSet.has(filePath) && !proseFiles.has(filePath)
&& !group.nodes.some((n) => codeNamedIds.has(n.id))) continue;

// Adaptive sizing (CODEGRAPH_ADAPTIVE_EXPLORE, default on): collapse a file
// to a per-symbol view when it's a redundant member of a polymorphic family.
Expand Down
8 changes: 6 additions & 2 deletions src/search/query-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,13 +466,17 @@ export function extractQueryPaths(
const stemMatches = basenameStems.get(stripped.toLowerCase());
const matches = stemMatches ? pinnableMatches(stripped, stemMatches) : null;
if (!matches) continue;
consumed.add(i);
// A quoted duplicate stem asks for source evidence inside an already
// pinned file. Keep it in the matching query rather than consuming it again.
const keepQuotedStem = !lines && /^["'`]/.test(tokens[i]!)
&& matches.some(m => pinnedSeen.has(m));
if (!keepQuotedStem) consumed.add(i);
for (const m of matches) {
if (pinnedSeen.has(m) || pinned.length >= maxPins) continue;
pinnedSeen.add(m);
pinned.push(m);
}
if (matches.length === 1 && pinnedSeen.has(matches[0]!)) {
if (!keepQuotedStem && matches.length === 1 && pinnedSeen.has(matches[0]!)) {
singleFileAt.set(i, matches[0]!);
if (lines) anchors.push({ file: matches[0]!, ...lines });
}
Expand Down