Files
codegraph/__tests__/node-file-view.test.ts
Max HsuandClaude Opus 5 31391b9751 fix(node): accept a line-numbered file reference in file-view (#1831) (#1836)
`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>
2026-09-27 11:57:16 +00:00

181 lines
8.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});
});
});