Files
codegraph/__tests__/explore-exact-target.test.ts
Colby Mchenry 47840a6ed4 fix(explore): return a qualified or line-anchored method's body instead of its signature (#2063)
`SQLCompiler.as_sql pre_sql_setup get_select` on django returned get_select and
pre_sql_setup in full and SQLCompiler.as_sql -- qualified, step 1 of the Flow,
226 lines -- as one signature line. compiler.py renders as a per-symbol
focused view, which picked bodies in source order within a tier, so the
unnamed bridge get_qualify_sql and get_select (both above as_sql in the file)
took the cap. The follow-ups agents issue (`compiler.py:776`,
`compiler.py lines 900-1003`) pinned the file and dropped the line numbers.
Every agent run ended in a Read of compiler.py.

- query-paths: keep `:776` / `:12-40` / `#L88-L120` suffixes as line anchors
  and bind prose ranges (`lines 900-1003`, `L900-L1003`, `lines 900 to 1003`)
  to the nearest single-file path.
- EXACT targets: a qualified name resolving to <=3 callables, or the callable
  enclosing a single-line anchor. Multi-line anchors render as that span.
- Focused view: tiers exact > named spine step > unnamed bridge > unique named >
  family co-named, spine in call order. A tier 0-1 body that does not fit is
  windowed (head + calls into the question's other symbols) with the explore
  range that returns each hole, never reduced to a signature. Priced in
  rendered chars net of the signature lines, so the section keeps its promise.
- Cluster path: exact clusters rank first with the spine's bounded overshoot,
  shrink by exact rendered size, and hold back what lower-ranked exact
  clusters still owe; files holding one are funded like spine files.
- Blast radius leads with exact targets.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:20:54 +00:00

307 lines
12 KiB
TypeScript

/**
* EXACT targets in `codegraph_explore`: a qualified name (`SQLCompiler.as_sql`)
* or a line anchor (`compiler.py:776`, `compiler.py lines 900-1003`).
*
* The originating gap (django, measured 2026-09-27 on main and on every branch
* since): `SQLCompiler.as_sql pre_sql_setup get_select` returned `get_select`
* and `pre_sql_setup` in full and `SQLCompiler.as_sql` — the qualified name,
* step 1 of the rendered Flow, 226 lines — as ONE signature line. compiler.py
* is a base+subclasses "family" file, so it renders as a per-symbol focused
* view, and that view chose bodies in SOURCE order within a tier: the unnamed
* bridge `get_qualify_sql` and `get_select` sit above `as_sql` in the file, took
* the cap, and the method the agent asked for collapsed. Its follow-ups
* (`SQLCompiler.as_sql full body compiler.py:776`, `compiler.py lines 900-1003`)
* pinned the file but dropped the line numbers, so they returned other methods'
* clusters — and every agent run ended in a Read of compiler.py.
*
* The fixture is that file in miniature (Python, like django): `SQLCompiler`
* with `get_select` and `get_qualify_sql` above a larger `as_sql`, three
* subclasses overriding `as_sql`, and a second family file with a base method
* too big for any budget.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ToolHandler } from '../src/mcp/tools';
import CodeGraph from '../src/index';
/** Line numbers rendered for `file` in an explore response. */
function renderedLines(text: string, file: string): Set<number> {
const out = new Set<number>();
let current: string | null = null;
let inFence = false;
for (const line of text.split('\n')) {
const header = /^\*\*`([^`]+)`\*\*/.exec(line);
if (header && !inFence) { current = header[1]!; continue; }
if (line.startsWith('```')) { inFence = !inFence; continue; }
if (inFence && current === file) {
const m = /^(\d+)\t/.exec(line);
if (m) out.add(Number(m[1]));
}
}
return out;
}
/** The `**`<file>`**` section, header through the line before the next file header. */
function sectionFor(text: string, file: string): string {
const lines = text.split('\n');
const start = lines.findIndex((l) => l.startsWith(`**\`${file}\`**`));
if (start < 0) return '';
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (lines[i]!.startsWith('**`')) { end = i; break; }
}
return lines.slice(start, end).join('\n');
}
/** `n` filler statements, each a distinct line so nothing dedups or folds. */
function filler(tag: string, n: number, indent = ' '): string {
return Array.from({ length: n }, (_, i) =>
`${indent}${tag}_${i} = self.query.alias_refcount.get("${tag}_${i}", 0) + len(self.query.select)`,
).join('\n');
}
/**
* Unrelated methods, so compiler.py is a real family file's size (django's is
* 2,291 lines): small enough to ship WHOLE, the file would answer every
* question by accident and hide the gap.
*/
function helpers(n: number): string {
return Array.from({ length: n }, (_, i) => `
def helper_${i}(self, value):
"""Unrelated helper ${i}."""
first = self.query.alias_map.get(value)
second = self.query.alias_refcount.get(value, 0)
third = self.query.external_aliases.get(value, False)
return first, second, third`).join('\n');
}
const COMPILER = 'compiler.py';
const PLANNER = 'planner.py';
describe('codegraph_explore — exact targets (qualified names, line anchors)', () => {
let testDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
const lineOf: Record<string, number> = {};
beforeAll(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-explore-exact-'));
// compiler.py — get_select and get_qualify_sql ABOVE as_sql, as in django.
const compiler = `
class SQLCompiler:
def pre_sql_setup(self, with_col_aliases=False):
# PRE_SQL_SETUP_BODY
self.setup_query(with_col_aliases=with_col_aliases)
order_by = self.get_order_by()
return order_by
def setup_query(self, with_col_aliases=False):
self.select = self.get_select(with_col_aliases=with_col_aliases)
return self.select
def get_order_by(self):
return []
def get_select(self, with_col_aliases=False):
# GET_SELECT_BODY
${filler('sel', 34)}
return []
def get_qualify_sql(self):
# GET_QUALIFY_BODY
${filler('qual', 34)}
inner = self.get_select()
return inner
def as_sql(self, with_limits=True, with_col_aliases=False):
# AS_SQL_HEAD
order_by = self.pre_sql_setup(with_col_aliases=with_col_aliases)
${filler('head', 30)}
result = self.get_qualify_sql()
${filler('tail', 30)}
return result # AS_SQL_TAIL_MARKER
def execute_sql(self):
sql = self.as_sql()
return sql
${helpers(40)}
def render_sql(compiler):
return SQLCompiler.as_sql(compiler)
class SQLInsertCompiler(SQLCompiler):
def as_sql(self):
# INSERT_AS_SQL_BODY
return super().as_sql()
def execute_sql(self):
sql = self.as_sql()
return sql
class SQLUpdateCompiler(SQLCompiler):
def as_sql(self):
# UPDATE_AS_SQL_BODY
return super().as_sql()
def pre_sql_setup(self):
# UPDATE_PRE_SQL_SETUP_BODY
return super().pre_sql_setup()
class SQLDeleteCompiler(SQLCompiler):
def as_sql(self):
# DELETE_AS_SQL_BODY
return super().as_sql()
`.trimStart();
fs.writeFileSync(path.join(testDir, COMPILER), compiler);
const compilerLines = compiler.split('\n');
const find = (lines: string[], needle: string) => lines.findIndex((l) => l.includes(needle)) + 1;
lineOf.asSqlDef = find(compilerLines, 'def as_sql(self, with_limits');
lineOf.asSqlTail = find(compilerLines, 'AS_SQL_TAIL_MARKER');
lineOf.asSqlTailStart = find(compilerLines, 'result = self.get_qualify_sql()') + 1;
// planner.py — a family whose base method is too big for ANY budget. It
// calls `stage()` (the spine's next hop) near its END, so a head-only cut
// would lose the very line the flow runs through.
const planner = `
class Planner:
def plan(self, query):
# PLAN_HEAD
${filler('plan', 420)}
staged = self.stage(query) # PLAN_CALLS_STAGE
${filler('post', 6)}
return staged
def stage(self, query):
return self.finalize(query)
def finalize(self, query):
# FINALIZE_BODY
return query
def explain_plan(self, query):
# EXPLAIN_PLAN_BODY
return str(query)
class HashPlanner(Planner):
def plan(self, query):
return super().plan(query)
class MergePlanner(Planner):
def plan(self, query):
return super().plan(query)
class NestedPlanner(Planner):
def plan(self, query):
return super().plan(query)
`.trimStart();
fs.writeFileSync(path.join(testDir, PLANNER), planner);
lineOf.planCallsStage = find(planner.split('\n'), 'PLAN_CALLS_STAGE');
// Other `as_sql`s, so the bare name is a family (as django's 110 are).
fs.writeFileSync(path.join(testDir, 'lookups.py'), `
class Exact:
def as_sql(self, compiler, connection):
return "%s = %s"
class IExact:
def as_sql(self, compiler, connection):
return "UPPER(%s) = UPPER(%s)"
`.trimStart());
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.py'], exclude: [] } });
await cg.indexAll();
handler = new ToolHandler(cg);
});
afterAll(() => {
if (cg) cg.destroy();
if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
const explore = async (query: string): Promise<string> => {
const result = await handler.execute('codegraph_explore', { query });
return result.content?.[0]?.text ?? '';
};
it('fixture sanity: the flow runs as_sql → get_qualify_sql → get_select and compiler.py goes focused', async () => {
const text = await explore('SQLCompiler.as_sql pre_sql_setup get_select');
expect(text).toContain('**Flow (call path among the symbols you queried)');
expect(text).toMatch(/1\. as_sql \(compiler\.py:\d+\)[\s\S]*2\. get_qualify_sql[\s\S]*3\. get_select/);
expect(sectionFor(text, COMPILER), 'the family file renders as a per-symbol view').toContain('· focused');
});
it('returns the qualified method\'s WHOLE body, not its signature line', async () => {
const text = await explore('SQLCompiler.as_sql pre_sql_setup get_select');
const section = sectionFor(text, COMPILER);
const lines = renderedLines(text, COMPILER);
for (let ln = lineOf.asSqlDef!; ln <= lineOf.asSqlTail!; ln++) {
expect(lines.has(ln), `as_sql line ${ln} rendered`).toBe(true);
}
expect(section).toContain('AS_SQL_TAIL_MARKER');
// The other named step still gets its body too.
expect(section).toContain('GET_SELECT_BODY');
});
it('keeps the family skeleton for the subclasses: their as_sql overrides stay signatures', async () => {
const text = await explore('SQLCompiler.as_sql pre_sql_setup get_select');
const section = sectionFor(text, COMPILER);
expect(section).not.toContain('INSERT_AS_SQL_BODY');
expect(section).not.toContain('DELETE_AS_SQL_BODY');
});
it('leads the blast radius with the qualified method, not a same-named override', async () => {
const text = await explore('SQLCompiler.as_sql pre_sql_setup get_select');
const blast = text.slice(text.indexOf('**Blast radius'), text.indexOf('**Relationships**'));
const first = blast.split('\n').find((l) => l.startsWith('- `'));
expect(first).toContain(`\`as_sql\` (${COMPILER}:${lineOf.asSqlDef})`);
});
it('a `file:line` anchor returns the method enclosing that line, whole', async () => {
// No symbol named — the line alone has to say which method.
const text = await explore(`${COMPILER}:${lineOf.asSqlDef! + 40} full body`);
const lines = renderedLines(text, COMPILER);
for (let ln = lineOf.asSqlDef!; ln <= lineOf.asSqlTail!; ln++) {
expect(lines.has(ln), `as_sql line ${ln} rendered`).toBe(true);
}
});
it('a `lines A-B` range returns exactly that span of the pinned file', async () => {
// The tail of `as_sql` — the span a windowed render elides and the agent
// then asks for by number (django's `lines 900-1003 as_sql tail`).
const a = lineOf.asSqlTailStart!;
const b = lineOf.asSqlTail!;
const text = await explore(`${COMPILER} lines ${a}-${b} tail`);
const lines = renderedLines(text, COMPILER);
for (let ln = a; ln <= b; ln++) expect(lines.has(ln), `line ${ln} rendered`).toBe(true);
});
it('windows an exact body too big for the budget — head AND the call the flow runs through — and names the rest by range', async () => {
const text = await explore('Planner.plan finalize explain_plan');
expect(text).toContain('**Flow (call path among the symbols you queried)');
const section = sectionFor(text, PLANNER);
const lines = renderedLines(text, PLANNER);
expect(section).toContain('PLAN_HEAD');
// The spine's next-hop call sits ~420 lines into the body: a head-only cut
// would drop it.
expect(lines.has(lineOf.planCallsStage!), 'the call into stage() is rendered').toBe(true);
// Windowed, not dumped: most of the 430-line body is elided...
expect(lines.size).toBeLessThan(300);
// ...and each hole is named with the explore query that returns it.
expect(section).toMatch(/lines \d+-\d+ of `plan` elided — codegraph_explore `planner\.py:\d+-\d+` returns them/);
// Steered to explore, never to Read (the tag's own "do NOT Read" aside).
expect(section.replace(/do NOT Read/g, '')).not.toMatch(/\bRead\b/);
});
});