mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-02 01:37:32 +08:00
test(explore): guard named symbols in a pinned file past the node cap
A file named by path is pinned: its first 300 symbols by start line enter the gather. Past that cap the file splits into a head cluster of pinned filler and, one cluster down, the named symbols the cap cut off, which used to render nothing (`protocol.ts serializeAck`: 0 of 3 lines; the same query unpinned: all of them). #2062's cross-cluster hold-back fixes this shape without knowing about pinning; its own fixture is an unpinned file. This fixture pins the pinned entry path to it: red on tools.ts before #2062 (2 of 6), green on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
47840a6ed4
commit
a800de8892
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* A symbol the query names survives a pinned file's node cap.
|
||||
*
|
||||
* A file named by path is PINNED: its symbols enter the gather unconditionally,
|
||||
* the first `PINNED_FILE_NODE_CAP` (300) of them by start line. Under the cap a
|
||||
* dense file merges into one cluster, and the cluster shrinks by member in
|
||||
* importance order, so what the query named is kept first. Past the cap the file
|
||||
* splits in two: the head (one named method merged with ~300 pinned neighbours)
|
||||
* and, one cluster down, the named symbols the cap cut off. Those were gathered
|
||||
* and ranked importance 10 — the named-def injection was never the problem —
|
||||
* but the head cluster ranked first on density and shrank against its OWN
|
||||
* members only, so pin filler spent the file's budget and the named cluster was
|
||||
* left under MIN_CHARS and dropped. `protocol.ts receiveOne serializeAck`
|
||||
* rendered `serializeAck` at 0 of 3 lines; `receiveOne serializeAck`, without
|
||||
* the path, rendered it whole.
|
||||
*
|
||||
* Fixed by the cross-cluster hold-back of #2062 (a cluster's incidental members
|
||||
* wait until lower-ranked clusters' named members are paid), which reaches this
|
||||
* shape without knowing about pinning. #2062's own fixture is an unpinned file,
|
||||
* so this one guards the pinned entry path: the pin is what floods the head
|
||||
* cluster with filler, and a hold-back re-scoped away from it would reopen the
|
||||
* bug here and nowhere else.
|
||||
*
|
||||
* The fixture is that shape, generated so the sizes that decide it are visible
|
||||
* here: `receiveOne` → `dispatchRequest` / `MessageCodec.serializeAck` at the
|
||||
* top, 300 unrelated `trackMetric*` methods, then the `MessageKind` enum and a
|
||||
* `MessageCodec` whose five serializers are followed by 150 `encodeField*`
|
||||
* members.
|
||||
*/
|
||||
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 } from '../src/mcp/tools';
|
||||
import type { ExploreDiagnosticReport } from '../src/mcp/explore-diagnostics';
|
||||
|
||||
const FILE = 'src/rpc/protocol.ts';
|
||||
/** `PINNED_FILE_NODE_CAP` in `handleExplore`. */
|
||||
const PIN_CAP = 300;
|
||||
|
||||
function protocolSource(): string {
|
||||
const L: string[] = [
|
||||
'export class Protocol {',
|
||||
' private handlers = new Map<string, (x: Uint8Array) => unknown>();',
|
||||
' private outbox: Uint8Array[] = [];',
|
||||
'',
|
||||
' receiveOne(raw: Uint8Array): void {',
|
||||
' const kind = raw[0] as MessageKind;',
|
||||
' if (kind === MessageKind.Request) {',
|
||||
' this.dispatchRequest(raw);',
|
||||
' this.outbox.push(MessageCodec.serializeAck(raw[1] ?? 0));',
|
||||
' } else if (kind === MessageKind.Cancel) {',
|
||||
' this.outbox.push(MessageCodec.serializeCancel(raw[1] ?? 0));',
|
||||
' }',
|
||||
' }',
|
||||
'',
|
||||
' private dispatchRequest(raw: Uint8Array): void {',
|
||||
' const id = raw[1] ?? 0;',
|
||||
" const method = String(raw[2] ?? '');",
|
||||
' const handler = this.handlers.get(method);',
|
||||
' if (!handler) {',
|
||||
' this.outbox.push(MessageCodec.serializeReplyErr(id, new Error(`no handler: ${method}`)));',
|
||||
' return;',
|
||||
' }',
|
||||
' const started = Date.now();',
|
||||
' let result: unknown;',
|
||||
' try {',
|
||||
' result = handler(raw.subarray(3));',
|
||||
' } catch (err) {',
|
||||
' this.outbox.push(MessageCodec.serializeReplyErr(id, err as Error));',
|
||||
' return;',
|
||||
' }',
|
||||
' if (Date.now() - started > 1000) {',
|
||||
' this.outbox.push(MessageCodec.serializeAck(id));',
|
||||
' }',
|
||||
' if (this.outbox.length > 64) {',
|
||||
' this.outbox.splice(0, this.outbox.length - 64);',
|
||||
' }',
|
||||
' this.outbox.push(MessageCodec.serializeReply(id, result ?? null));',
|
||||
' }',
|
||||
];
|
||||
for (let i = 0; i < 300; i++) {
|
||||
L.push('', ` trackMetric${i}(value: number): number {`, ` return value * ${i + 1} + this.outbox.length;`, ' }');
|
||||
}
|
||||
L.push(
|
||||
'}',
|
||||
'',
|
||||
'export const enum MessageKind {',
|
||||
' Request = 1,',
|
||||
' Reply = 2,',
|
||||
' ReplyErr = 3,',
|
||||
' Cancel = 4,',
|
||||
' Ack = 5,',
|
||||
'}',
|
||||
'',
|
||||
'export class MessageCodec {',
|
||||
' static serializeRequest(id: number, method: string): Uint8Array {',
|
||||
' const body = new TextEncoder().encode(method);',
|
||||
' const out = new Uint8Array(body.length + 2);',
|
||||
' out[0] = MessageKind.Request;',
|
||||
' out[1] = id & 0xff;',
|
||||
' out.set(body, 2);',
|
||||
' return out;',
|
||||
' }',
|
||||
'',
|
||||
' static serializeAck(id: number): Uint8Array {',
|
||||
' return Uint8Array.of(MessageKind.Ack, id & 0xff);',
|
||||
' }',
|
||||
'',
|
||||
' static serializeCancel(id: number): Uint8Array {',
|
||||
' return Uint8Array.of(MessageKind.Cancel, id & 0xff);',
|
||||
' }',
|
||||
'',
|
||||
' static serializeReply(id: number, value: unknown): Uint8Array {',
|
||||
' const body = new TextEncoder().encode(JSON.stringify(value ?? null));',
|
||||
' const out = new Uint8Array(body.length + 2);',
|
||||
' out[0] = MessageKind.Reply;',
|
||||
' out[1] = id & 0xff;',
|
||||
' out.set(body, 2);',
|
||||
' return out;',
|
||||
' }',
|
||||
'',
|
||||
' static serializeReplyErr(id: number, err: Error): Uint8Array {',
|
||||
' const body = new TextEncoder().encode(err.message);',
|
||||
' const out = new Uint8Array(body.length + 2);',
|
||||
' out[0] = MessageKind.ReplyErr;',
|
||||
' out[1] = id & 0xff;',
|
||||
' out.set(body, 2);',
|
||||
' return out;',
|
||||
' }',
|
||||
);
|
||||
for (let i = 0; i < 150; i++) {
|
||||
L.push('', ` static encodeField${i}(id: number): Uint8Array {`, ` return Uint8Array.of(${i % 250}, id & 0xff, ${(i * 7) % 250});`, ' }');
|
||||
}
|
||||
L.push('}');
|
||||
return L.join('\n') + '\n';
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
let cg: CodeGraph;
|
||||
|
||||
/** One explore call plus the CG-4 diagnostic for it. */
|
||||
async function explore(query: string): Promise<{ text: string; report: ExploreDiagnosticReport }> {
|
||||
const sidecar = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-pin-cap-diag-')), 'r.jsonl');
|
||||
const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
|
||||
process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
|
||||
let text = '';
|
||||
try {
|
||||
const res = await new ToolHandler(cg).execute('codegraph_explore', { query });
|
||||
text = res.content?.[0]?.text ?? '';
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
|
||||
else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
|
||||
}
|
||||
const lines = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean);
|
||||
fs.rmSync(path.dirname(sidecar), { recursive: true, force: true });
|
||||
return { text, report: JSON.parse(lines[lines.length - 1]!) as ExploreDiagnosticReport };
|
||||
}
|
||||
|
||||
/** Every `<n>\t<text>` line number the response sent for `file`. */
|
||||
function renderedLines(response: string, file: string): Set<number> {
|
||||
const out = new Set<number>();
|
||||
let current: string | null = null;
|
||||
let inFence = false;
|
||||
for (const line of response.split('\n')) {
|
||||
const header = !inFence ? line.match(/^\*\*`([^`]+)`\*\*/) : null;
|
||||
if (header) { current = header[1]!; continue; }
|
||||
if (line.startsWith('```')) { inFence = !inFence; continue; }
|
||||
const m = inFence && current === file ? line.match(/^(\d+)\t/) : null;
|
||||
if (m) out.add(Number(m[1]));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The pinned file's indexable symbols in the order the pin gathers them. */
|
||||
function pinOrder() {
|
||||
return cg.getNodesInFile(FILE)
|
||||
.filter((n) => n.kind !== 'file' && n.kind !== 'import' && n.kind !== 'export')
|
||||
.sort((a, b) => a.startLine - b.startLine);
|
||||
}
|
||||
|
||||
/** Names whose whole definition did NOT reach the response. */
|
||||
function incompleteBodies(response: string, names: string[]): string[] {
|
||||
const sent = renderedLines(response, FILE);
|
||||
return names.filter((name) => {
|
||||
const node = cg.getNodesInFile(FILE).find((n) => n.name === name && n.kind !== 'import');
|
||||
expect(node, `${name} is not indexed in ${FILE}`).toBeDefined();
|
||||
for (let l = node!.startLine; l <= node!.endLine; l++) if (!sent.has(l)) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-pin-cap-'));
|
||||
fs.mkdirSync(path.join(dir, path.dirname(FILE)), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, FILE), protocolSource());
|
||||
cg = CodeGraph.initSync(dir);
|
||||
await cg.indexAll();
|
||||
}, 180_000);
|
||||
|
||||
afterAll(() => {
|
||||
cg?.destroy();
|
||||
if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('fixture shape — if this rots, the tests below mean nothing', () => {
|
||||
it('holds more symbols than a pinned file contributes to the gather', () => {
|
||||
expect(pinOrder().length).toBeGreaterThan(PIN_CAP);
|
||||
});
|
||||
|
||||
it('puts the named serializers past the cap and receiveOne inside it', () => {
|
||||
const order = pinOrder().map((n) => n.name);
|
||||
expect(order.indexOf('receiveOne')).toBeLessThan(PIN_CAP);
|
||||
for (const name of ['serializeAck', 'serializeCancel', 'serializeReply']) {
|
||||
expect(order.indexOf(name), name).toBeGreaterThanOrEqual(PIN_CAP);
|
||||
}
|
||||
});
|
||||
|
||||
it('renders the named symbols whole when the file is NOT pinned', async () => {
|
||||
// The parity the pinned query is held to below.
|
||||
const { text } = await explore('receiveOne serializeAck');
|
||||
expect(incompleteBodies(text, ['receiveOne', 'serializeAck'])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a pinned file past the node cap', () => {
|
||||
it('returns a named symbol past the cap, and the named one before it', async () => {
|
||||
// Pre-fix: 0 of serializeAck's 3 lines — the head cluster's filler took the budget.
|
||||
const { text, report } = await explore('protocol.ts receiveOne serializeAck');
|
||||
expect(report.files.find((f) => f.path === FILE)?.pinned).toBe(true);
|
||||
expect(incompleteBodies(text, ['receiveOne', 'serializeAck'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns a lone named symbol past the cap', async () => {
|
||||
// No flow at all (one token), so no named-def injection either: the symbol
|
||||
// is gathered as a search entry, and the render alone decided its fate.
|
||||
const { text } = await explore('protocol.ts serializeAck');
|
||||
expect(incompleteBodies(text, ['serializeAck'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the file inside the room it had — the fix redistributes, never raises', async () => {
|
||||
// Before the fix the head cluster alone fit inside the reservation (8,700 of
|
||||
// 9,100), so paying the named symbols must come out of that room, not on
|
||||
// top of it. Letting the file's clusters share the head cluster's CEILING
|
||||
// instead was measured at ~3K more filler per pinned response.
|
||||
const { report } = await explore('protocol.ts receiveOne serializeAck');
|
||||
const rec = report.files.find((f) => f.path === FILE)!;
|
||||
expect(rec.render).toBe('clusters');
|
||||
expect(rec.emittedChars).toBeLessThanOrEqual(rec.allowance);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user