mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
290e03f7bc
A bare basename several directories share (`editorOptions.ts`, `worker.rs`, `compiler.py`) pinned every match, and the matches split the pinned reservation: on vscode `editorOptions.ts clampedInt cursorStyleToString cursorStyleFromString` gave the file holding all three 8,626 chars, against 15,181 without the path, and spent the rest on a workbench helper defining none of them. Path extraction now takes an injected `symbolFiles` lookup (the module stays DB-free, like `existsOnDisk`). When a span matches several files, the matches defining a precise symbol token the query names (camelCase, PascalCase, snake_case, qualified — the seeder's NL-stopword shape test) are pinned and the rest set aside; the summary line names them. When no match defines one, or every match does, the span resolves exactly as before. The same test resolves a basename shared past the ambiguity budget (django's `models.py`) when it narrows into budget, and a line anchor binds once the span narrows to one file. Replay of 196 queries over 8 indexed repos, main vs this branch: every unambiguous pin is byte-identical (38 unique basenames, 13 dir/basename, 8 file:line, 38 earlier-sweep path queries), as are 26 path-free queries, 13 where every match defines a named symbol and 8 naming no symbol. All 52 changed responses are shared basenames — named FULL 207 -> 231 (11 up, 0 down), named lines 6,045 -> 7,577, none over 25K. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
/**
|
|
* A bare basename two directories share pins only the file defining what the
|
|
* query names.
|
|
*
|
|
* vscode has two `editorOptions.ts`: the editor's option registry (which holds
|
|
* `clampedInt`, `cursorStyleToString`, `cursorStyleFromString`) and a small
|
|
* workbench helper holding none of them. `editorOptions.ts clampedInt
|
|
* cursorStyleToString cursorStyleFromString` pinned BOTH, and the two pins split
|
|
* the pinned reservation — 8,626 chars each, against 15,181 for the named file
|
|
* when the same query left the path out. So writing the file's name made the
|
|
* answer worse: the named file got half the room, and the unrelated one spent
|
|
* the rest.
|
|
*
|
|
* Path extraction now asks which of a span's matches define a symbol the query
|
|
* names, and pins those; the rest are set aside and named in the summary line.
|
|
* When no match defines one, or every match does, the symbols pick nothing out
|
|
* and every match pins as before.
|
|
*
|
|
* The fixture is that shape, generated so the sizes that decide it are visible
|
|
* here: the registry is ~60 option classes with the three named functions in
|
|
* the middle, the helper is a handful of unrelated functions.
|
|
*/
|
|
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 REGISTRY = 'src/editor/common/config/editorOptions.ts';
|
|
const HELPER = 'src/workbench/common/editor/editorOptions.ts';
|
|
const NAMED = ['clampedInt', 'cursorStyleToString', 'cursorStyleFromString'];
|
|
|
|
function registrySource(): string {
|
|
const L: string[] = [
|
|
'export interface IConfigurationPropertySchema {',
|
|
' type?: string;',
|
|
' default?: unknown;',
|
|
' minimum?: number;',
|
|
' maximum?: number;',
|
|
'}',
|
|
'',
|
|
'export abstract class BaseEditorOption<K extends number, V> {',
|
|
' constructor(public readonly id: K, public readonly name: string, public readonly defaultValue: V) {}',
|
|
' public abstract validate(input: unknown): V;',
|
|
'}',
|
|
];
|
|
const optionClass = (i: number) => {
|
|
L.push(
|
|
'',
|
|
`export class EditorOption${i} extends BaseEditorOption<number, string> {`,
|
|
` private readonly allowed${i} = ['alpha${i}', 'beta${i}', 'gamma${i}'];`,
|
|
'',
|
|
` constructor() {`,
|
|
` super(${i}, 'option${i}', 'alpha${i}');`,
|
|
' }',
|
|
'',
|
|
' public validate(input: unknown): string {',
|
|
" if (typeof input !== 'string') {",
|
|
' return this.defaultValue;',
|
|
' }',
|
|
` return this.allowed${i}.includes(input) ? input : this.defaultValue;`,
|
|
' }',
|
|
'}',
|
|
);
|
|
};
|
|
for (let i = 0; i < 30; i++) optionClass(i);
|
|
L.push(
|
|
'',
|
|
'export function clampedInt<T>(value: unknown, defaultValue: T, minimum: number, maximum: number): number | T {',
|
|
" if (typeof value === 'undefined') {",
|
|
' return defaultValue;',
|
|
' }',
|
|
' let r = parseInt(String(value), 10);',
|
|
' if (isNaN(r)) {',
|
|
' return defaultValue;',
|
|
' }',
|
|
' r = Math.max(minimum, r);',
|
|
' r = Math.min(maximum, r);',
|
|
' return r | 0;',
|
|
'}',
|
|
'',
|
|
'export class EditorIntOption<K extends number> extends BaseEditorOption<K, number> {',
|
|
' public static clampedInt<T>(value: unknown, defaultValue: T, minimum: number, maximum: number): number | T {',
|
|
' return clampedInt(value, defaultValue, minimum, maximum);',
|
|
' }',
|
|
'',
|
|
' constructor(id: K, name: string, defaultValue: number, public readonly minimum: number, public readonly maximum: number, schema?: IConfigurationPropertySchema) {',
|
|
" if (typeof schema !== 'undefined') {",
|
|
" schema.type = 'integer';",
|
|
' schema.default = defaultValue;',
|
|
' schema.minimum = minimum;',
|
|
' schema.maximum = maximum;',
|
|
' }',
|
|
' super(id, name, defaultValue);',
|
|
' }',
|
|
'',
|
|
' public validate(input: unknown): number {',
|
|
' return EditorIntOption.clampedInt(input, this.defaultValue, this.minimum, this.maximum);',
|
|
' }',
|
|
'}',
|
|
'',
|
|
'export const enum TextEditorCursorStyle {',
|
|
' Line = 1,',
|
|
' Block = 2,',
|
|
' Underline = 3,',
|
|
' LineThin = 4,',
|
|
' BlockOutline = 5,',
|
|
' UnderlineThin = 6,',
|
|
'}',
|
|
'',
|
|
'export function cursorStyleToString(cursorStyle: TextEditorCursorStyle): string {',
|
|
' switch (cursorStyle) {',
|
|
" case TextEditorCursorStyle.Line: return 'line';",
|
|
" case TextEditorCursorStyle.Block: return 'block';",
|
|
" case TextEditorCursorStyle.Underline: return 'underline';",
|
|
" case TextEditorCursorStyle.LineThin: return 'line-thin';",
|
|
" case TextEditorCursorStyle.BlockOutline: return 'block-outline';",
|
|
" case TextEditorCursorStyle.UnderlineThin: return 'underline-thin';",
|
|
' }',
|
|
'}',
|
|
'',
|
|
'export function cursorStyleFromString(cursorStyle: string): TextEditorCursorStyle {',
|
|
' switch (cursorStyle) {',
|
|
" case 'line': return TextEditorCursorStyle.Line;",
|
|
" case 'block': return TextEditorCursorStyle.Block;",
|
|
" case 'underline': return TextEditorCursorStyle.Underline;",
|
|
" case 'line-thin': return TextEditorCursorStyle.LineThin;",
|
|
" case 'block-outline': return TextEditorCursorStyle.BlockOutline;",
|
|
" case 'underline-thin': return TextEditorCursorStyle.UnderlineThin;",
|
|
' }',
|
|
' return TextEditorCursorStyle.Line;',
|
|
'}',
|
|
);
|
|
for (let i = 30; i < 60; i++) optionClass(i);
|
|
return L.join('\n') + '\n';
|
|
}
|
|
|
|
function helperSource(): string {
|
|
const L: string[] = [
|
|
'export interface ITextEditorViewState { scrollTop: number; cursor: number }',
|
|
'',
|
|
'export function applyTextEditorOptions(options: { selection?: number; viewState?: ITextEditorViewState }, editor: { reveal(line: number): void; restore(s: ITextEditorViewState): void }): boolean {',
|
|
' if (options.viewState) {',
|
|
' editor.restore(massageEditorViewState(options.viewState));',
|
|
' return true;',
|
|
' }',
|
|
' if (typeof options.selection === "number") {',
|
|
' editor.reveal(options.selection);',
|
|
' return true;',
|
|
' }',
|
|
' return false;',
|
|
'}',
|
|
'',
|
|
'function massageEditorViewState(state: ITextEditorViewState): ITextEditorViewState {',
|
|
' return { scrollTop: Math.max(0, state.scrollTop), cursor: Math.max(0, state.cursor) };',
|
|
'}',
|
|
];
|
|
for (let i = 0; i < 12; i++) {
|
|
L.push(
|
|
'',
|
|
`export function restoreEditorGroup${i}(state: ITextEditorViewState): number {`,
|
|
` const offset = state.scrollTop * ${i + 2} + state.cursor;`,
|
|
` return offset > ${100 * (i + 1)} ? offset - ${i} : offset + ${i};`,
|
|
'}',
|
|
);
|
|
}
|
|
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-same-basename-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 };
|
|
}
|
|
|
|
const fileRecord = (report: ExploreDiagnosticReport, file: string) =>
|
|
report.files.find((f) => f.path === file);
|
|
|
|
/** The response renders a source section for `file`. */
|
|
const hasSection = (response: string, file: string): boolean =>
|
|
response.includes('**`' + file + '`');
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/** How many of the registry's named definitions the response sent whole. */
|
|
function completeNamedBodies(response: string): number {
|
|
const sent = renderedLines(response, REGISTRY);
|
|
return cg.getNodesInFile(REGISTRY)
|
|
.filter((n) => NAMED.includes(n.name) && n.kind !== 'import')
|
|
.filter((n) => {
|
|
for (let l = n.startLine; l <= n.endLine; l++) if (!sent.has(l)) return false;
|
|
return true;
|
|
}).length;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-same-basename-'));
|
|
for (const [file, source] of [[REGISTRY, registrySource()], [HELPER, helperSource()]] as const) {
|
|
fs.mkdirSync(path.join(dir, path.dirname(file)), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, file), source);
|
|
}
|
|
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 gates below mean nothing', () => {
|
|
it('the named symbols live in the registry and nowhere in the helper', () => {
|
|
const registry = cg.getNodesInFile(REGISTRY).map((n) => n.name);
|
|
const helper = cg.getNodesInFile(HELPER).map((n) => n.name);
|
|
for (const name of NAMED) {
|
|
expect(registry).toContain(name);
|
|
expect(helper).not.toContain(name);
|
|
}
|
|
// Two `clampedInt`s — the function and the static method — so four bodies.
|
|
expect(cg.getNodesInFile(REGISTRY).filter((n) => NAMED.includes(n.name))).toHaveLength(4);
|
|
});
|
|
});
|
|
|
|
describe('a basename two directories share', () => {
|
|
it('pins only the file defining the named symbols, and names the one it set aside', async () => {
|
|
const { text, report } = await explore(`editorOptions.ts ${NAMED.join(' ')}`);
|
|
expect(fileRecord(report, REGISTRY)?.pinned).toBe(true);
|
|
expect(fileRecord(report, HELPER)?.pinned ?? false).toBe(false);
|
|
expect(hasSection(text, HELPER)).toBe(false);
|
|
expect(text).toContain('1 file pinned from the query.');
|
|
expect(text).toContain(`Not pinned: \`${HELPER}\`, which defines none of the named symbols.`);
|
|
});
|
|
|
|
it('gives the named file the room it gets when the query leaves the path out', async () => {
|
|
const pinned = await explore(`editorOptions.ts ${NAMED.join(' ')}`);
|
|
const unpinned = await explore(NAMED.join(' '));
|
|
const pinnedAllowance = fileRecord(pinned.report, REGISTRY)?.allowance ?? 0;
|
|
const unpinnedAllowance = fileRecord(unpinned.report, REGISTRY)?.allowance ?? 0;
|
|
expect(unpinnedAllowance).toBeGreaterThan(0);
|
|
// Pre-fix the two pins split the reservation: half the room, or less.
|
|
expect(pinnedAllowance).toBeGreaterThanOrEqual(unpinnedAllowance);
|
|
expect(completeNamedBodies(pinned.text)).toBeGreaterThanOrEqual(completeNamedBodies(unpinned.text));
|
|
expect(completeNamedBodies(pinned.text)).toBe(4);
|
|
});
|
|
|
|
it('pins both when the query names no symbol to choose between them', async () => {
|
|
const { text, report } = await explore('editorOptions.ts');
|
|
expect(fileRecord(report, REGISTRY)?.pinned).toBe(true);
|
|
expect(fileRecord(report, HELPER)?.pinned).toBe(true);
|
|
expect(text).toContain('2 files pinned from the query.');
|
|
expect(text).not.toContain('Not pinned:');
|
|
});
|
|
|
|
it('a path with its directory pins that file alone, with no set-aside note', async () => {
|
|
const { text, report } = await explore(`${HELPER} ${NAMED.join(' ')}`);
|
|
expect(fileRecord(report, HELPER)?.pinned).toBe(true);
|
|
expect(fileRecord(report, REGISTRY)?.pinned ?? false).toBe(false);
|
|
expect(text).not.toContain('Not pinned:');
|
|
});
|
|
});
|