Files
codegraph/__tests__/explore-elided-symbol-names.test.ts
T
d983f73484 fix(explore): name symbols elided by a file trim (#1711) (#1765)
When codegraph_explore trimmed a rendered file, holes printed as bare
`... (gap) ...` and the header's `+N more` hid the dropped names — while the
footer asked the model to re-explore with exact names it was never given.

Name elided defs in gap markers as `name (file:line)` (using the full file
index, not just the relevance gather), bias the per-file header toward
symbols the trim cut, and point the trimmed-footer at those names.

Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
2026-09-08 07:31:04 -05:00

154 lines
6.1 KiB
TypeScript

/**
* Regression for #1711 — when codegraph_explore trims a file, elided symbols
* must be named (gap markers + header bias), not left as a bare `... (gap) ...`
* while the footer asks for "exact names" the model was never given.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
import {
ToolHandler,
formatGapMarker,
symbolsBetweenRanges,
biasHeaderSymbols,
joinPartsWithNamedGaps,
} from '../src/mcp/tools';
describe('#1711 helpers — name what a trim dropped', () => {
it('formatGapMarker stays bare when the hole has no symbols', () => {
expect(formatGapMarker('a.ts', [])).toBe('\n\n... (gap) ...\n\n');
});
it('formatGapMarker lists name (file:line) for elided symbols', () => {
const marker = formatGapMarker('src/obs.ts', [
{ name: 'syncStateNow', kind: 'method', startLine: 1913 },
{ name: 'performHeavyDraftSync', kind: 'method', startLine: 1867 },
]);
expect(marker).toContain('syncStateNow (src/obs.ts:1913)');
expect(marker).toContain('performHeavyDraftSync (src/obs.ts:1867)');
expect(marker).toMatch(/\.\.\. \(gap: .+\) \.\.\./);
});
it('symbolsBetweenRanges only returns defs that start in the hole', () => {
const nodes = [
{ name: 'keep', kind: 'method', startLine: 10, endLine: 20 },
{ name: 'elided', kind: 'method', startLine: 30, endLine: 40 },
{ name: 'also', kind: 'method', startLine: 45, endLine: 50 },
{ name: 'later', kind: 'method', startLine: 60, endLine: 70 },
{ name: 'imp', kind: 'import', startLine: 35, endLine: 35 },
];
const hit = symbolsBetweenRanges(nodes, 20, 60);
expect(hit.map((h) => h.name)).toEqual(['elided', 'also']);
});
it('biasHeaderSymbols prefers elided labels over frequency alone', () => {
const { shown } = biasHeaderSymbols(
[
'imports0(method)', 'imports0(method)', 'imports0(method)',
'imports1(method)', 'imports1(method)',
'noise(method)',
],
[{ name: 'syncStateNow', kind: 'method', startLine: 100 }],
3,
);
expect(shown[0]).toBe('syncStateNow(method)');
expect(shown).toContain('imports0(method)');
});
it('joinPartsWithNamedGaps annotates the hole between parts', () => {
const text = joinPartsWithNamedGaps(
'f.ts',
[
{ range: { start: 1, end: 5 }, text: 'ONE' },
{ range: { start: 40, end: 45 }, text: 'TWO' },
],
[{ name: 'mid', kind: 'function', startLine: 20, endLine: 25 }],
);
expect(text).toContain('ONE');
expect(text).toContain('TWO');
expect(text).toContain('mid (f.ts:20)');
});
});
describe('#1711 explore — trimmed file names its elisions', () => {
let dir: string;
let cg: CodeGraph;
let response: string;
beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1711-'));
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"cg1711","version":"1.0.0"}\n');
const srcDir = path.join(dir, 'src');
fs.mkdirSync(srcDir);
// One large observer + noise files so the budget trims rather than shipping whole.
const lines: string[] = ['export class EspnDraftObserver {'];
for (let i = 0; i < 40; i++) {
lines.push(` imports${i}() { return ${i}; }`, '');
}
const big = (name: string, next: string | null, n: number) => {
lines.push(` ${name}() {`);
lines.push(` const marker = "${name}_MARKER";`);
for (let j = 0; j < n; j++) lines.push(` const x${j} = ${j} + marker.length;`);
lines.push(next ? ` return this.${next}();` : ' return marker;');
lines.push(' }', '');
};
big('persistDraftState', null, 60);
big('performHeavyDraftSync', 'persistDraftState', 60);
big('syncStateNow', 'performHeavyDraftSync', 60);
big('scrapeFullDraftState', 'syncStateNow', 60);
for (let i = 0; i < 30; i++) {
lines.push(` calls${i}() { return ${i}; }`, '');
}
lines.push('}', '');
fs.writeFileSync(path.join(srcDir, 'espn-draft-observer.ts'), lines.join('\n'));
for (let i = 1; i <= 20; i++) {
fs.writeFileSync(path.join(srcDir, `noise${i}.ts`), `export const n${i} = ${i};\n`);
}
cg = CodeGraph.initSync(dir);
await cg.indexAll();
const result = await new ToolHandler(cg).execute('codegraph_explore', {
query:
'In this repos ESPN draft observer (espn-draft-observer.ts), name in order the chain of methods from scrapeFullDraftState to the method that calls storage.saveDraftState. One line.',
});
response = result.content?.[0]?.text ?? '';
}, 120_000);
afterAll(() => {
cg?.destroy();
if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
});
it('still renders the observer file (ranker chooses the right file)', () => {
expect(response).toContain('espn-draft-observer.ts');
});
it('names elided symbols inside gap markers as name (file:line)', () => {
// Match only in-fence gap markers that list at least one path:line ref.
const namedGaps = response.match(/\.\.\. \(gap: [^\n]*?\([^\n]+?:\d+\)[^\n]*?\) \.\.\./g) ?? [];
expect(namedGaps.length, `response head:\n${response.slice(0, 2000)}`).toBeGreaterThan(0);
for (const g of namedGaps) {
expect(g).toMatch(/\w+ \([^\s)]+:\d+\)/);
}
});
it('footer points at named gaps / header instead of asking for unknown names', () => {
if (!response.includes('trimmed for size')) return;
expect(response).toMatch(/preferred in the file header|named inside gap markers/);
});
it('biases the file header away from filler-only when symbols were elided', () => {
const header = response.split('\n').find((l) => l.includes('**`src/espn-draft-observer.ts`**'));
expect(header).toBeDefined();
// Either the header names a chain method, or a named gap does — never
// neither while the footer asks for exact names.
const namesAnswer = /syncStateNow|performHeavyDraftSync|persistDraftState|scrapeFullDraftState/;
const namedSomewhere =
namesAnswer.test(header!) ||
namesAnswer.test(response);
expect(namedSomewhere).toBe(true);
});
});