mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-03 22:57:40 +08:00
`codegraph node "src/app.ts:42-80"` answered `No indexed file matches` for a file that IS indexed, worded byte-for-byte like a genuine miss, so there was no way to tell "wrong path" from "right path, wrong spelling". The file-view normalizer treated the `:42-80` as part of the filename. explore has stripped exactly these shapes all along (src/search/query-paths.ts) — file-view now does too, and the range it strips becomes the read window. - The literal spelling is resolved FIRST; only when that finds nothing is the suffix stripped, so a file genuinely named `foo:12` still wins. - Supports `:<a>`, `:<a>-<b>`, `#L<n>`, `#L<a>-L<b>` (and `#L<a>-<b>`). - A stripped range maps to `offset=a, limit=b-a+1`; a bare `:<a>` is a start-here pointer (Read given only an offset). An explicit offset/limit from the caller always wins over the suffix. - `#L<n>` was not mentioned in the issue but was broken the same way. Tests: 8 new cases across __tests__/node-file-view.test.ts and __tests__/cli-node-command.test.ts, including a vacuity guard (the same path without the suffix resolves) and a genuine-miss case. Red-armed: with the fix reverted, 6 of them fail with the exact `No indexed file matches` string. Claude-Session: https://claude.ai/code/session_01XDR1wm73oH8J8cRWnKyv9m Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
181 lines
8.2 KiB
TypeScript
181 lines
8.2 KiB
TypeScript
/**
|
||
* 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';
|
||
import * as path from 'path';
|
||
import * as os from 'os';
|
||
import CodeGraph from '../src/index';
|
||
import { ToolHandler } from '../src/mcp/tools';
|
||
|
||
describe('codegraph_node file-view (Read replacement)', () => {
|
||
let dir: string;
|
||
let cg: CodeGraph;
|
||
let h: ToolHandler;
|
||
|
||
beforeEach(async () => {
|
||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fileview-'));
|
||
fs.mkdirSync(path.join(dir, 'src'));
|
||
fs.writeFileSync(
|
||
path.join(dir, 'src', 'a.ts'),
|
||
'export function helper(x: number) {\n return x + 1;\n}\nexport class Widget {\n build() { return helper(1); }\n}\n',
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(dir, 'src', 'b.ts'),
|
||
"import { helper } from './a';\n\n// a comment between symbols\nconst SETTING = 7;\nexport function useHelper() { return helper(2) + SETTING; }\n",
|
||
);
|
||
// 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);
|
||
});
|
||
|
||
afterEach(() => {
|
||
if (cg) cg.close();
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
const text = async (args: Record<string, unknown>): Promise<string> =>
|
||
(await h.execute('codegraph_node', args)).content.map((c) => c.text).join('\n');
|
||
|
||
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('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 () => {
|
||
const out = await text({ symbol: 'helper', includeCode: true });
|
||
expect(out).toContain('helper');
|
||
expect(out).toContain('return x + 1');
|
||
});
|
||
|
||
it('a miss returns a helpful message, not a crash', async () => {
|
||
const out = await text({ file: 'does-not-exist.ts' });
|
||
expect(out).toMatch(/no indexed file matches/i);
|
||
});
|
||
|
||
// #1831: a path pasted with a line suffix (`a.ts:12`, `a.ts:12-40`, `a.ts#L88`)
|
||
// used to be treated as part of the FILENAME, so an indexed file came back as
|
||
// `No indexed file matches` — byte-identical to a genuine miss. explore has
|
||
// stripped these shapes all along (src/search/query-paths.ts); file-view now
|
||
// does too, and the range becomes the read window.
|
||
describe('line-suffixed paths (#1831)', () => {
|
||
it('VACUITY GUARD: the plain path resolves, so a suffixed miss can only come from the suffix', async () => {
|
||
const out = await text({ file: 'big.ts' });
|
||
expect(out).not.toMatch(/no indexed file matches/i);
|
||
expect(out).toMatch(/^1\texport function big/m);
|
||
});
|
||
|
||
it('`file.ts:<a>-<b>` reads that range, not a miss', async () => {
|
||
const out = await text({ file: 'big.ts:1000-1002' });
|
||
expect(out).not.toMatch(/no indexed file matches/i);
|
||
expect(out).toMatch(/^1000\t {2}const v998 = 998;$/m);
|
||
expect(out).toMatch(/^1002\t {2}const v1000 = 1000;$/m);
|
||
expect(out).not.toMatch(/^1003\t/m); // limit = b - a + 1, no more
|
||
expect(out).not.toMatch(/^1\t/m);
|
||
});
|
||
|
||
it('`file.ts:<a>` starts the window at that line (Read given only an offset)', async () => {
|
||
const out = await text({ file: 'big.ts:1000' });
|
||
expect(out).toMatch(/^1000\t {2}const v998 = 998;$/m);
|
||
expect(out).not.toMatch(/^999\t/m);
|
||
});
|
||
|
||
it('`file.ts#L<n>` and `file.ts#L<a>-L<b>` work the same way', async () => {
|
||
const single = await text({ file: 'big.ts#L1000' });
|
||
expect(single).toMatch(/^1000\t {2}const v998 = 998;$/m);
|
||
expect(single).not.toMatch(/^999\t/m);
|
||
|
||
const range = await text({ file: 'big.ts#L1000-L1002' });
|
||
expect(range).toMatch(/^1000\t {2}const v998 = 998;$/m);
|
||
expect(range).toMatch(/^1002\t {2}const v1000 = 1000;$/m);
|
||
expect(range).not.toMatch(/^1003\t/m);
|
||
|
||
// The bare `#L1000-1002` spelling (no second L) too.
|
||
const bare = await text({ file: 'big.ts#L1000-1002' });
|
||
expect(bare).toMatch(/^1000\t {2}const v998 = 998;$/m);
|
||
expect(bare).not.toMatch(/^1003\t/m);
|
||
});
|
||
|
||
it('a full repo-relative path carries its suffix too', async () => {
|
||
const out = await text({ file: 'src/big.ts:1000-1001' });
|
||
expect(out).toMatch(/^1000\t {2}const v998 = 998;$/m);
|
||
expect(out).not.toMatch(/^1002\t/m);
|
||
});
|
||
|
||
it('an explicit offset/limit from the caller WINS over the suffix (no silent override)', async () => {
|
||
const out = await text({ file: 'big.ts:1000-1002', offset: 5, limit: 2 });
|
||
expect(out).toMatch(/^5\t {2}const v3 = 3;$/m);
|
||
expect(out).toMatch(/^6\t {2}const v4 = 4;$/m);
|
||
expect(out).not.toMatch(/^1000\t/m);
|
||
});
|
||
|
||
it('a genuine miss still reports a miss, suffix or not', async () => {
|
||
const out = await text({ file: 'does-not-exist.ts:10-20' });
|
||
expect(out).toMatch(/no indexed file matches/i);
|
||
});
|
||
});
|
||
});
|