mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +08:00
66aebd17e2
The pre-release agent A/B (main vs v1.6.0, 7 README repos, 3 runs each) had main returning 15% less source on vscode for the same queries, with Reads in 2 of 3 runs where v1.6.0 had none. Replaying every query both builds' agents issued traced it to three causes: 1. Named gaps (#1711) displaced source. Every cluster fit measured the gap names, which on a long path run to hundreds of chars, so a class shrunk into its file's room overran it by the names alone and was dropped whole: rpcProtocol.ts emitted 222 of 5,496 funded chars (v1.6.0: 5,194), and the test fixture came back as an empty fence. Fits now measure bare gaps; names are added at assembly from what the file's budget has left. 2. Interface members (#1638) corroborated English words. The NL-stopword guard lets a bare word seed a definition when another query word names a symbol in the same file; `readonly host` in an options interface made "main" seed `main()` in agentHostServerMain.ts, which took the named-first tier from the answer files. Members declared inside an interface no longer count as corroboration. 3. The interface-dispatch note announced dispatch through any common base, declared or not: "`extension` -> runtime dispatch to 2706 types implementing Disposable" opened 9 of 31 vscode answers. A supertype now has to declare the member (it, an ancestor, or a same-named Swift extension). When absence can't be judged, because a protocol's requirements aren't indexed, the note stays as before. Replayed queries, source lines vs v1.6.0 (main -> fixed): vscode 10,744 -> 12,577 (12,617), excalidraw 13,065 -> 13,698 (13,764), django 8,420 -> 8,803 (8,954), tokio 13,618 -> 14,151 (14,600), okhttp 6,511 -> 6,883 (7,090), alamofire 7,019 -> 7,249 (7,120); no query below 60% of v1.6.0. Real dispatch notes (Alamofire adapt/retry/asURLRequest, django, okhttp) are unchanged. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
177 lines
7.4 KiB
TypeScript
177 lines
7.4 KiB
TypeScript
/**
|
|
* codegraph_explore — NL-stopword collision guard (named-symbol seeding).
|
|
*
|
|
* handleExplore's named-symbol seeding treats every identifier-shaped query
|
|
* token as "a symbol the agent named" and grants its definition the
|
|
* named-FIRST sort tier. But explore also takes natural-language questions,
|
|
* whose ordinary English words collide with real callables: on this repo the
|
|
* query "…check the latest version…" exact-matched the lone `check()` method
|
|
* of an unrelated WAL-valve class, which then outranked (and, within the
|
|
* per-repo file budget, fully displaced) the upgrade module the corroboration
|
|
* ranking had correctly scored — so the agent fell back to Read/Grep.
|
|
*
|
|
* The guard: a shape-precise token (camelCase, PascalCase, snake_case,
|
|
* qualified) seeds unconditionally — it is an unambiguous symbol reference.
|
|
* A BARE lowercase word seeds only definitions whose file another query token
|
|
* co-names (that other token is itself a symbol defined in the same file, the
|
|
* "check drain fire" sibling-bag shape) — which an incidental English-word
|
|
* collision never is.
|
|
*/
|
|
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';
|
|
|
|
/** Paths explore rendered as full-body ``**`<path>`** —`` source sections, in order. */
|
|
function sourcedFiles(text: string): string[] {
|
|
const out: string[] = [];
|
|
for (const line of text.split('\n')) {
|
|
const m = line.match(/^\*\*`(.+?)`\*\* —/);
|
|
if (m) out.push(m[1].trim());
|
|
}
|
|
return out;
|
|
}
|
|
|
|
describe('codegraph_explore — NL-stopword collision guard', () => {
|
|
let testDir: string;
|
|
let cg: CodeGraph;
|
|
let handler: ToolHandler;
|
|
|
|
beforeEach(async () => {
|
|
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-stopword-'));
|
|
|
|
// --- The collision file: an unrelated class whose methods are ordinary
|
|
// English words ("check", "drain", "fire" — the only defs of those names).
|
|
// Substantive bodies + an internal call mesh so it isn't skipped as a stub.
|
|
const dbDir = path.join(testDir, 'src', 'db');
|
|
fs.mkdirSync(dbDir, { recursive: true });
|
|
fs.writeFileSync(path.join(dbDir, 'valve.ts'),
|
|
`export class Valve {\n` +
|
|
` private open = false;\n` +
|
|
` check(): void {\n` +
|
|
` if (this.open) {\n` +
|
|
` this.fire();\n` +
|
|
` }\n` +
|
|
` }\n` +
|
|
` fire(): void {\n` +
|
|
` this.drain();\n` +
|
|
` this.open = false;\n` +
|
|
` }\n` +
|
|
` drain(): void {\n` +
|
|
` this.open = true;\n` +
|
|
` }\n` +
|
|
`}\n`);
|
|
|
|
// --- The answer file: the upgrade module the query is actually about.
|
|
// Its path and symbol names match the query's topic terms (upgrade,
|
|
// latest, version), so the corroboration ranking scores it — the bug was
|
|
// the collision file's named tier sorting ABOVE it anyway.
|
|
const upDir = path.join(testDir, 'src', 'upgrade');
|
|
fs.mkdirSync(upDir, { recursive: true });
|
|
fs.writeFileSync(path.join(upDir, 'updater.ts'),
|
|
`export function normalizeVersion(v: string): string {\n` +
|
|
` return v.startsWith('v') ? v : 'v' + v;\n` +
|
|
`}\n` +
|
|
`export function resolveLatestVersion(): string {\n` +
|
|
` return normalizeVersion('9.9.9');\n` +
|
|
`}\n` +
|
|
`export function runUpgrade(): string {\n` +
|
|
` const latest = resolveLatestVersion();\n` +
|
|
` return latest;\n` +
|
|
`}\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 });
|
|
});
|
|
|
|
async function explore(query: string): Promise<string> {
|
|
const res = await handler.execute('codegraph_explore', { query });
|
|
expect(res.isError).toBeFalsy();
|
|
return res.content[0]!.text;
|
|
}
|
|
|
|
it('a bare English word ("check") does not tier its namesake above the corroborated answer file', async () => {
|
|
const text = await explore('how does the upgrade flow check the latest version');
|
|
const files = sourcedFiles(text);
|
|
const updater = files.findIndex((f) => f.endsWith('updater.ts'));
|
|
const valve = files.findIndex((f) => f.endsWith('valve.ts'));
|
|
// The upgrade module must render, and must rank above the collision file
|
|
// (pre-guard, valve.ts held the named-FIRST tier and sorted on top).
|
|
expect(updater).toBeGreaterThanOrEqual(0);
|
|
if (valve !== -1) expect(updater).toBeLessThan(valve);
|
|
});
|
|
|
|
it('a sibling bag of bare words ("check drain fire") still tiers their shared file first', async () => {
|
|
const text = await explore('check drain fire');
|
|
const files = sourcedFiles(text);
|
|
// Every token is co-named by the others in valve.ts — genuine bare-name
|
|
// symbol bags must keep the named tier.
|
|
expect(files[0]).toMatch(/valve\.ts$/);
|
|
});
|
|
|
|
it('a shape-precise token (camelCase) seeds unconditionally', async () => {
|
|
const text = await explore('runUpgrade');
|
|
const files = sourcedFiles(text);
|
|
expect(files[0]).toMatch(/updater\.ts$/);
|
|
});
|
|
});
|
|
|
|
describe('codegraph_explore — interface members never corroborate a bare word', () => {
|
|
let testDir: string;
|
|
let cg: CodeGraph;
|
|
|
|
afterEach(() => {
|
|
cg?.destroy();
|
|
if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('an options interface\'s `host` does not seed the same file\'s `main()` above the answer', async () => {
|
|
// vscode's agentHostServerMain.ts: `interface IServerOptions { readonly host… }`
|
|
// beside `function main()`. Once interface members were indexed (#1638),
|
|
// `host` in "extension host … main process" counted as a second query
|
|
// token named in that file, so the English word "main" seeded `main()` into
|
|
// the named-first tier and the file outranked the ones about the extension
|
|
// host itself.
|
|
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-iface-corroboration-'));
|
|
const src = path.join(testDir, 'src');
|
|
fs.mkdirSync(src, { recursive: true });
|
|
fs.writeFileSync(path.join(src, 'serverMain.ts'),
|
|
'interface IServerOptions {\n' +
|
|
' readonly port: number;\n' +
|
|
' readonly host: string | undefined;\n' +
|
|
'}\n\n' +
|
|
'export function main(options: IServerOptions): number {\n' +
|
|
' return options.port;\n' +
|
|
'}\n');
|
|
fs.writeFileSync(path.join(src, 'extensionHostProcess.ts'),
|
|
'export class ExtensionHostConnection {\n' +
|
|
' constructor(private readonly send: (msg: string) => void) {}\n' +
|
|
' connectToMainProcess(): void {\n' +
|
|
" this.send('hello');\n" +
|
|
' }\n' +
|
|
'}\n\n' +
|
|
'export function startExtensionHostProcess(): ExtensionHostConnection {\n' +
|
|
' const connection = new ExtensionHostConnection((msg) => console.log(msg));\n' +
|
|
' connection.connectToMainProcess();\n' +
|
|
' return connection;\n' +
|
|
'}\n');
|
|
for (let i = 1; i <= 12; i++) fs.writeFileSync(path.join(src, `noise${i}.ts`), `export const n${i} = ${i};\n`);
|
|
cg = CodeGraph.initSync(testDir);
|
|
await cg.indexAll();
|
|
|
|
const res = await new ToolHandler(cg).execute('codegraph_explore', {
|
|
query: 'how does the extension host talk to the main process',
|
|
});
|
|
const files = sourcedFiles(res.content[0].text as string);
|
|
expect(files[0]).toBe('src/extensionHostProcess.ts');
|
|
});
|
|
});
|