Skip to content
Open
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 @@ -162,6 +162,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

#### MCP / indexing

- `codegraph_explore` no longer answers "who calls this?" with a list of test names. On a well-covered symbol the relationship list was almost entirely its own test suite, so the production caller you were asking about was cut off under "... and 100 more". Tests are now left out of that list — unless your question is itself about tests, you named a test file by path, or the tests are the only callers there are — and `codegraph explore --tests` / `--no-tests` (or `includeTests` from an agent) asks for them either way. Examples, samples and benchmarks are not affected; only test suites are. The blast radius still names the test files that cover a symbol, as before.

- Daemon startup and cleanup now preserve live legacy PID-only locks while still reclaiming dead or identity-disproved records, preventing two writers from serving the same project.
- Incremental sync now keeps edge rebinding crash-safe: replacing a resolved edge with its recovery reference commits atomically, so an interruption cannot permanently remove the relationship.
- Status now detects committed but unindexed changes and restored edits without scanning every source file; thanks @inth3shadows. (#1829)
Expand Down
174 changes: 174 additions & 0 deletions __tests__/explore-relationship-test-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* codegraph_explore — test edges in the Relationships section.
*
* Test files are already cut from the source section. The edge list was not
* filtered with them, so on a well-covered symbol the per-kind cap filled with
* `TestX -> X` lines and the production caller the agent asked about never
* rendered. Relationships now follows the same rule, with an `includeTests`
* escape hatch in both directions and the same "unless the query is about
* tests" waiver the source-file filter uses.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';

describe('codegraph_explore — test edges in Relationships', () => {
let testDir: string;
let cg: CodeGraph;
let handler: ToolHandler;

beforeEach(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-reltests-'));
const src = path.join(testDir, 'src');
fs.mkdirSync(src, { recursive: true });

// One production caller of `reserveSlot`, buried under many test callers —
// the real shape: a covered symbol's edge list is mostly its suite.
fs.writeFileSync(
path.join(src, 'store.ts'),
`export function reserveSlot() { return 1; }\n`,
);
fs.writeFileSync(
path.join(src, 'warm.ts'),
`import { reserveSlot } from './store';\n` +
`export function warmIfIdle() { return reserveSlot(); }\n`,
);
const testCallers = Array.from({ length: 8 }, (_, i) =>
`export function checkReserveSlot${i}() { return reserveSlot(); }\n`,
).join('');
fs.writeFileSync(
path.join(src, 'store.test.ts'),
`import { reserveSlot } from './store';\n` + testCallers,
);

// An example calls it too. Examples are not production code, but they are
// not a test suite either — they render in the source section, so their
// edges have to survive.
fs.mkdirSync(path.join(testDir, 'examples'), { recursive: true });
fs.writeFileSync(
path.join(testDir, 'examples', 'quickstart.ts'),
`import { reserveSlot } from '../src/store';\n` +
`export function demoReserve() { return reserveSlot(); }\n`,
);

// `orphanHelper` is called ONLY from a test — filtering it leaves nothing.
fs.writeFileSync(
path.join(src, 'orphan.ts'),
`export function orphanHelper() { return 7; }\n`,
);
fs.writeFileSync(
path.join(src, 'orphan.test.ts'),
`import { orphanHelper } from './orphan';\n` +
`export function checkOrphan() { return orphanHelper(); }\n`,
);

// The Relationships section is gated on repo size (off below 500 files), so
// the fixture has to clear that bar for this filter to render at all.
const filler = path.join(src, 'filler');
fs.mkdirSync(filler, { recursive: true });
for (let i = 0; i < 520; i++) {
fs.writeFileSync(path.join(filler, `mod${i}.ts`), `export function filler${i}() { return ${i}; }\n`);
}

cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();
handler = new ToolHandler(cg);
});

afterEach(() => {
if (cg) cg.destroy();
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});

const relationships = (text: string): string => {
const start = text.indexOf('**Relationships**');
if (start === -1) return '';
const rest = text.slice(start);
const end = rest.indexOf('**Source Code**');
return end === -1 ? rest : rest.slice(0, end);
};

it('keeps the production caller and drops the suite by default', async () => {
const res = await handler.execute('codegraph_explore', { query: 'reserveSlot' });
const rel = relationships(res.content[0].text);

expect(rel).toContain('warmIfIdle');
expect(rel).not.toMatch(/checkReserveSlot/);
});

it('includeTests: true puts the suite back', async () => {
const res = await handler.execute('codegraph_explore', {
query: 'reserveSlot',
includeTests: true,
});
const rel = relationships(res.content[0].text);

expect(rel).toMatch(/checkReserveSlot/);
});

it('shows the suite unasked when the query is itself about tests', async () => {
const res = await handler.execute('codegraph_explore', {
query: 'which tests cover reserveSlot',
});
const rel = relationships(res.content[0].text);

expect(rel).toMatch(/checkReserveSlot/);
});

it('includeTests: false overrides that waiver', async () => {
const res = await handler.execute('codegraph_explore', {
query: 'which tests cover reserveSlot',
includeTests: false,
});
const rel = relationships(res.content[0].text);

expect(rel).not.toMatch(/checkReserveSlot/);
});

it('keeps a test file that the query pinned by path', async () => {
// extractQueryPaths strips the path span out of the query before the waiver
// sees it, so the "test" inside the path cannot speak for itself.
const res = await handler.execute('codegraph_explore', {
query: 'src/store.test.ts reserveSlot',
});
const rel = relationships(res.content[0].text);

expect(rel).toMatch(/checkReserveSlot/);
});

it('does not cut examples, benchmarks or fixtures — only tests', async () => {
const res = await handler.execute('codegraph_explore', { query: 'reserveSlot' });
const rel = relationships(res.content[0].text);

// Rendered in the source section, so its edges have to be there too.
expect(rel).toContain('demoReserve');
});

it('stands down when the tests are the only callers there are', async () => {
const res = await handler.execute('codegraph_explore', { query: 'orphanHelper' });
const rel = relationships(res.content[0].text);

expect(rel).toMatch(/checkOrphan/);
});

it('still empties the section when includeTests: false asked for that', async () => {
const res = await handler.execute('codegraph_explore', {
query: 'orphanHelper',
includeTests: false,
});
const rel = relationships(res.content[0].text);

expect(rel).not.toMatch(/checkOrphan/);
});

it('leaves the blast radius section naming the covering test file', async () => {
const res = await handler.execute('codegraph_explore', { query: 'reserveSlot' });
const text = res.content[0].text;

expect(text).toContain('**Blast radius');
expect(text).toMatch(/tests:.*store\.test\.ts/);
});
});
8 changes: 7 additions & 1 deletion src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1292,7 +1292,9 @@ program
.description('Explore an area: relevant symbols\' source + call paths in one shot (same output as the codegraph_explore MCP tool)')
.option('-p, --path <path>', 'Project path')
.option('--max-files <number>', 'Maximum number of files to include source from')
.action(async (queryParts: string[], options: { path?: string; maxFiles?: string }) => {
.option('--tests', 'Include test/spec symbols in Relationships (default: only when the query is about tests)')
.option('--no-tests', 'Exclude test/spec symbols from Relationships')
.action(async (queryParts: string[], options: { path?: string; maxFiles?: string; tests?: boolean }) => {
const projectPath = resolveProjectPath(options.path);

try {
Expand All @@ -1308,6 +1310,10 @@ program

const args: Record<string, unknown> = { query: queryParts.join(' ') };
if (options.maxFiles) args.maxFiles = parseInt(options.maxFiles, 10);
// Left undefined unless a flag was actually passed — that is the auto case,
// and commander leaves `tests` undefined when both --tests and --no-tests
// are declared and neither is given.
if (typeof options.tests === 'boolean') args.includeTests = options.tests;
const result = await handler.execute('codegraph_explore', args);

console.log(result.content[0]?.text ?? '');
Expand Down
56 changes: 53 additions & 3 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
} from '../sync/worktree';
import type { PendingFile } from '../sync';
import type { Node, Edge, SearchResult, Subgraph, NodeKind } from '../types';
import { isTestFile, normalizeNameToken } from '../search/query-utils';
import { isTestFile, isTestPath, normalizeNameToken, queryIsAboutTests } from '../search/query-utils';
import { groupDefinitions, lastQualifierPart, matchesSymbol } from '../graph/symbol-lookup';
import { extractQueryPaths, queryMightContainPaths } from '../search/query-paths';
import {
Expand Down Expand Up @@ -1342,6 +1342,10 @@ export const tools: ToolDefinition[] = [
description: 'Maximum number of files to include source code from (default: 12)',
default: 12,
},
includeTests: {
type: 'boolean',
description: 'Include test/spec symbols in the Relationships section. Omit for auto — they are shown only when the query itself is about tests. Pass true to ask who covers a symbol; false to force them out.',
},
projectPath: projectPathProperty,
},
required: ['query'],
Expand Down Expand Up @@ -3905,7 +3909,7 @@ export class ToolHandler {
// keep-minimum then pulled two test files back in as the "spread".
let candidateFiles = [...fileGroups.entries()];
{
const queryMentionsTests = /\b(test|tests|testing|spec|verify|verifies)\b/i.test(matchQuery);
const queryMentionsTests = queryIsAboutTests(matchQuery);
if (!queryMentionsTests) {
// A pinned file is exempt: naming a test file by path IS asking for it.
const nonLow = candidateFiles.filter(([p]) => !isLowValue(p) || pinnedSet.has(p));
Expand Down Expand Up @@ -4202,9 +4206,55 @@ export class ToolHandler {
if (blastRadius) lines.push(blastRadius);

// Relationship map — show how symbols connect
const significantEdges = subgraph.edges.filter(e =>
//
// Test edges are cut for the same reason test FILES are cut from the source
// section above: on a well-covered symbol they are most of the edge list, and
// the per-kind cap is small. A Go store method with a dozen callers rendered
// as ten `TestReserveSlot_* -> ReserveSlot` lines and "... and 100 more", so
// the one production caller the agent was asking about never made the cut —
// the section named the test suite instead of the call graph.
//
// `includeTests` overrides in both directions. Omitted, it follows the query,
// matching the source-file filter's own rule, so "which tests cover X" still
// answers with tests. The Blast radius section is untouched: its `tests:` line
// NAMES the covering files rather than flooding a cap, which is the half of
// this information that was already working.
//
// Three things are carried over from the source-file filter deliberately,
// because dropping any one of them makes this filter lie:
//
// - The predicate is `isTestPath`, NOT `isTestFile`. The latter is the wide
// reading — examples, samples, benchmarks, fixtures — which nothing here
// intends to cut, and which no phrasing of the query could win back
// (`queryIsAboutTests` doesn't match "which examples call X"). A file
// rendered in the Source Code section while its edges are gone is a
// response that contradicts itself.
// - A PINNED file is exempt. `extractQueryPaths` strips the path span out of
// `matchQuery`, so the "test" inside `src/store.test.ts` is invisible to
// the waiver by the time it runs — naming a test file by path IS asking
// for it, and the source filter says so in the same words.
// - If the filter would empty the section, it stands down. Tests are then
// the only signal for this area, and the blast radius does not cover for
// it: that section is roots-only and capped, so a non-root symbol whose
// callers are all tests would lose the information entirely. An explicit
// `includeTests: false` still means false — that caller asked for the
// empty section and gets it.
const explicitTests = typeof args.includeTests === 'boolean' ? args.includeTests : undefined;
const includeTests = explicitTests ?? queryIsAboutTests(matchQuery);
const isTestEndpoint = (id: string): boolean => {
const n = subgraph.nodes.get(id);
return !!n && isTestPath(n.filePath) && !pinnedSet.has(n.filePath);
};
const nonContainsEdges = subgraph.edges.filter(e =>
e.kind !== 'contains' // skip contains — it's implied by file grouping
);
const productionEdges = nonContainsEdges.filter(e =>
!isTestEndpoint(e.source) && !isTestEndpoint(e.target)
);
const significantEdges =
includeTests || (productionEdges.length === 0 && explicitTests === undefined)
? nonContainsEdges
: productionEdges;

if (budget.includeRelationships && significantEdges.length > 0) {
lines.push('**Relationships**');
Expand Down
12 changes: 12 additions & 0 deletions src/search/query-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,18 @@ export function scorePathRelevance(
return score;
}

/**
* Does the query itself ask about tests?
*
* The waiver behind every test-filtering rule in explore: a filter that helps
* "how does auth work" is exactly wrong for "which tests cover login". Shared
* so the source-file filter and the relationship filter cannot drift into
* disagreeing about what a test-y query is.
*/
export function queryIsAboutTests(query: string): boolean {
return /\b(test|tests|testing|spec|verify|verifies)\b/i.test(query);
}

/**
* Check if a file path looks like a test file
*
Expand Down