Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes

- Spring mappings now include every declared path combination and resolve constants declared in the same file, while unresolved paths no longer appear as false root routes. (#1461)
- `codegraph node` now accepts a file reference that carries a line number — `src/app.ts:42`, `src/app.ts:42-80`, `src/app.ts#L42`, `src/app.ts#L42-L80` — instead of reporting the file as not indexed; the line range becomes the window that is read, and an `--offset`/`--limit` you pass yourself still wins. A path that really is named that way is still looked up as written. (#1831)
- `codegraph callers`, `codegraph callees` and `codegraph impact` now resolve qualified names, group results and JSON edges by definition, and accept `--file` to narrow ambiguous names; thanks @ferrine. (#1512, #1656)
- `codegraph callers`, `codegraph callees` and `codegraph impact` (CLI and MCP) now report missing names with did-you-mean suggestions instead of another symbol's results, and exact matches with no callers stay empty; thanks @uvmplus. (#1473, #1481)

Expand Down
22 changes: 22 additions & 0 deletions __tests__/cli-node-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,28 @@ describe('codegraph node — argument handling (#1044)', () => {
expect(stdout).toContain('export function util');
});

// #1831: `codegraph node "src/util.ts:1-2"` — the way an agent pastes a file
// reference — answered `No indexed file matches`, worded identically to a
// genuine miss, for a file that IS indexed.
it('a path-like positional with a line suffix reads the file (#1831)', () => {
// Vacuity guard: the same path without the suffix does resolve.
const plain = runNode(tempDir, ['src/util.ts']);
expect(plain.code).toBe(0);
expect(plain.stdout).toContain('export function util');

for (const suffixed of ['src/util.ts:1', 'src/util.ts:1-1', 'src/util.ts#L1', 'src/util.ts#L1-L1']) {
const { stdout, code } = runNode(tempDir, [suffixed]);
expect(code).toBe(0);
expect(stdout).not.toMatch(/No indexed file matches/i);
expect(stdout).toContain('export function util');
}
});

it('a genuinely missing path still reports a miss, suffix or not (#1831)', () => {
const { stdout } = runNode(tempDir, ['src/nope.ts:1-2']);
expect(stdout).toMatch(/No indexed file matches/i);
});

it('a bare symbol positional still routes to symbol mode', () => {
const { stdout, code } = runNode(tempDir, ['util']);
expect(code).toBe(0);
Expand Down
62 changes: 62 additions & 0 deletions __tests__/node-file-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,66 @@ describe('codegraph_node file-view (Read replacement)', () => {
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);
});
});
});
60 changes: 50 additions & 10 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6263,23 +6263,63 @@ export class ToolHandler {
opts: { offset?: number; limit?: number; symbolsOnly?: boolean } = {},
): Promise<ToolResult> {
const normalize = (p: string) => p.replace(/\\/g, '/').replace(/^(?:\.?\/+)+/, '').replace(/\/+$/, '');
const wantLower = normalize(fileArg).toLowerCase();
const allFiles = cg.getFiles();
if (allFiles.length === 0) return this.textResult('No files indexed. Run `codegraph index` first.');

let resolved = allFiles.find((f) => f.path.toLowerCase() === wantLower);
let candidates: typeof allFiles = [];
// Resolve ONE spelling of the path against the index: exact, then
// suffix-of-path, then substring — narrowing to a single file or handing
// back the ambiguous set.
const resolveOne = (want: string): { file?: (typeof allFiles)[number]; candidates: typeof allFiles } => {
const wantLower = normalize(want).toLowerCase();
let file = allFiles.find((f) => f.path.toLowerCase() === wantLower);
let found: typeof allFiles = [];
if (!file) {
found = allFiles.filter((f) => f.path.toLowerCase().endsWith('/' + wantLower));
if (found.length === 1) file = found[0];
}
if (!file && found.length === 0) {
found = allFiles.filter((f) => f.path.toLowerCase().includes(wantLower));
if (found.length === 1) file = found[0];
}
return { file, candidates: found };
};

// Agents and humans paste file references WITH a line suffix — `a.ts:12`,
// `a.ts:12-40`, `a.ts#L88`, `a.ts#L12-L40`. explore already strips exactly
// these shapes (src/search/query-paths.ts); file-view used to treat them as
// part of the filename and report an indexed file as missing (#1831).
// The literal spelling is tried FIRST, so a file genuinely named `foo:12`
// still resolves to itself; only when that finds nothing is the suffix
// stripped, and then the range becomes the read window.
const LINE_SUFFIX = /(?::(\d+)(?:-(\d+))?|#L(\d+)(?:-L?(\d+))?)$/;
let { file: resolved, candidates } = resolveOne(fileArg);
let shownArg = fileArg;
if (!resolved) {
candidates = allFiles.filter((f) => f.path.toLowerCase().endsWith('/' + wantLower));
if (candidates.length === 1) resolved = candidates[0];
}
if (!resolved && candidates.length === 0) {
candidates = allFiles.filter((f) => f.path.toLowerCase().includes(wantLower));
if (candidates.length === 1) resolved = candidates[0];
const m = LINE_SUFFIX.exec(normalize(fileArg));
const stripped = m ? fileArg.slice(0, fileArg.length - m[0].length) : '';
const retry = stripped ? resolveOne(stripped) : undefined;
if (m && retry && (retry.file || retry.candidates.length > 0)) {
resolved = retry.file;
candidates = retry.candidates;
shownArg = stripped;
const startLine = Number(m[1] ?? m[3]);
const endRaw = m[2] ?? m[4];
const endLine = endRaw === undefined ? undefined : Number(endRaw);
if (Number.isFinite(startLine) && startLine > 0) {
// An explicit offset/limit from the caller always wins over the
// suffix. `:12` alone is a "start here" pointer (Read given only an
// offset); `:12-40` pins both ends.
opts = {
...opts,
offset: opts.offset ?? startLine,
limit: opts.limit ?? (endLine !== undefined && endLine >= startLine ? endLine - startLine + 1 : undefined),
};
}
}
}
if (!resolved && candidates.length > 1) {
return this.textResult(
[`"${fileArg}" matches ${candidates.length} indexed files — pass a longer path:`, '',
[`"${shownArg}" matches ${candidates.length} indexed files — pass a longer path:`, '',
...candidates.slice(0, 25).map((f) => `- ${f.path}`)].join('\n'),
);
}
Expand Down