feat(mcp): codegraph_node reads files like the Read tool — offset/limit, byte-parity (#738)
Makes codegraph_node a drop-in faster Read for indexed source files (file-read mode: <n>\t<line> like Read, offset/limit, + blast-radius header; symbolsOnly for the map). Fixes the old file-view dropping imports/line-numbers. #383/#527 preserved. Validated by A/B: explore/node already return source + line numbers, so Read=0 when used. Includes the A/B eval harness scripts. Full suite green (1270).
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* codegraph_node FILE-VIEW mode: a bare `file` (no `symbol`) returns that file's
|
||||
* symbol map + graph role (dependents), and verbatim bodies with includeCode —
|
||||
* a Read replacement for a source file that also surfaces the blast radius.
|
||||
* codegraph_node FILE READ mode: a `file` with no `symbol` reads that file like
|
||||
* the Read tool — current source with `<n>\t<line>` numbering (byte-for-byte
|
||||
* Read's shape), narrowable with offset/limit — plus a one-line blast-radius
|
||||
* header. `symbolsOnly` returns the structural map instead. Config/data files
|
||||
* are summarized by key, never dumped (#383).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
@@ -24,9 +26,23 @@ describe('codegraph_node file-view (Read replacement)', () => {
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'b.ts'),
|
||||
"import { helper } from './a';\nexport function useHelper() { return helper(2); }\n",
|
||||
"import { helper } from './a';\n\n// a comment between symbols\nconst SETTING = 7;\nexport function useHelper() { return helper(2) + SETTING; }\n",
|
||||
);
|
||||
cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } });
|
||||
// A config/data file (#383): its values may be secrets and must never be
|
||||
// dumped verbatim by the file-view.
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'application.properties'),
|
||||
'spring.datasource.password=SUPERSECRET123\nserver.port=8080\n',
|
||||
);
|
||||
// A large file: exceeds the file-view line budget, so it must be windowed
|
||||
// honestly (not silently truncated).
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'big.ts'),
|
||||
'export function big() {\n' +
|
||||
Array.from({ length: 2000 }, (_, i) => ` const v${i} = ${i};`).join('\n') +
|
||||
'\n return 0;\n}\n',
|
||||
);
|
||||
cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts', '**/*.properties'], exclude: [] } });
|
||||
await cg.indexAll();
|
||||
h = new ToolHandler(cg);
|
||||
});
|
||||
@@ -39,21 +55,54 @@ describe('codegraph_node file-view (Read replacement)', () => {
|
||||
const text = async (args: Record<string, unknown>): Promise<string> =>
|
||||
(await h.execute('codegraph_node', args)).content.map((c) => c.text).join('\n');
|
||||
|
||||
it("a bare file (no symbol) returns the file's symbols + dependents", async () => {
|
||||
const out = await text({ file: 'a.ts' });
|
||||
expect(out).toContain('src/a.ts');
|
||||
expect(out).toContain('helper');
|
||||
expect(out).toContain('Widget');
|
||||
expect(out).toMatch(/depended on by 1 file/i);
|
||||
expect(out).toContain('src/b.ts'); // the dependent file (blast radius)
|
||||
it('reads a whole file like Read by default — `<n>\\t<line>` lines (no pad), imports + gaps included', async () => {
|
||||
const out = await text({ file: 'b.ts' }); // no includeCode needed — content is the default
|
||||
// Byte-for-byte Read shape: line 1 is "1<TAB>import …", NOT space-padded.
|
||||
expect(out).toMatch(/^1\timport \{ helper \} from '\.\/a';$/m);
|
||||
expect(out).toContain('// a comment between symbols'); // inter-symbol gap (Read has it; old reconstruction dropped it)
|
||||
expect(out).toContain('const SETTING = 7'); // top-level statement
|
||||
expect(out).toContain('useHelper'); // the symbol body too
|
||||
expect(out).not.toContain('```'); // Read has no code fence; neither do we
|
||||
});
|
||||
|
||||
it('resolves by basename and returns verbatim bodies with includeCode', async () => {
|
||||
const out = await text({ file: 'a.ts', includeCode: true });
|
||||
expect(out).toContain('return x + 1'); // helper body
|
||||
expect(out).toContain('class Widget'); // class body, verbatim
|
||||
// It must NOT steer the agent back to Read — it is the Read replacement.
|
||||
expect(out.toLowerCase()).not.toContain('read `src/a.ts`');
|
||||
it('leads with a one-line blast-radius header (the value-add over Read)', async () => {
|
||||
const out = await text({ file: 'a.ts' });
|
||||
expect(out).toMatch(/used by 1 file: src\/b\.ts/); // a.ts is imported by b.ts
|
||||
expect(out).toContain('return x + 1'); // still returns the source
|
||||
});
|
||||
|
||||
it('offset/limit narrow the window exactly like Read', async () => {
|
||||
const out = await text({ file: 'big.ts', offset: 1000, limit: 3 });
|
||||
// Window starts at the requested line, numbered exactly: "1000<TAB> const v998 = 998;"
|
||||
expect(out).toMatch(/^1000\t {2}const v998 = 998;$/m);
|
||||
expect(out).not.toMatch(/^1\t/m); // line 1 is NOT shown
|
||||
expect(out).toMatch(/lines 1000[–-]1002 of \d+/); // honest pagination note
|
||||
});
|
||||
|
||||
it('an offset past EOF is reported, not a crash', async () => {
|
||||
const out = await text({ file: 'a.ts', offset: 9999 });
|
||||
expect(out).toMatch(/past the end/i);
|
||||
});
|
||||
|
||||
it('paginates a large file honestly by default — "lines 1–N of TOTAL", never a silent truncate', async () => {
|
||||
const out = await text({ file: 'big.ts' });
|
||||
expect(out).toMatch(/lines 1[–-]\d+ of \d+/); // explicit window note
|
||||
expect(out).not.toContain('(output truncated)'); // not the generic 15k chop
|
||||
expect(out).toMatch(/^1\texport function big/m); // the head of the window is real source
|
||||
});
|
||||
|
||||
it('does NOT dump a config/data file (yaml/properties) — #383 secret safety', async () => {
|
||||
const out = await text({ file: 'application.properties' });
|
||||
expect(out).not.toContain('SUPERSECRET123'); // the value never reaches the agent
|
||||
expect(out.toLowerCase()).toMatch(/config|values withheld/);
|
||||
});
|
||||
|
||||
it('symbolsOnly returns the structural map, not the source', async () => {
|
||||
const out = await text({ file: 'a.ts', symbolsOnly: true });
|
||||
expect(out).toContain('### Symbols');
|
||||
expect(out).toContain('helper');
|
||||
expect(out).toContain('Widget');
|
||||
expect(out).not.toContain('return x + 1'); // bodies are NOT included in the map
|
||||
});
|
||||
|
||||
it('still works as a normal symbol lookup (no regression)', async () => {
|
||||
|
||||
Reference in New Issue
Block a user