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>
This commit is contained in:
Max Hsu
2026-09-27 19:57:16 +08:00
committed by GitHub
parent ba3c21e50d
commit 31391b9751
4 changed files with 135 additions and 10 deletions
+1
View File
@@ -157,6 +157,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Calling a built-in method on an awaited value no longer records a call into an unrelated class that happens to declare a method of the same name, and a variable bound to an awaited call now resolves methods on the type that call returns. Thanks @maxmilian. (#1840)
- 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)
+22
View File
@@ -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);
+62
View File
@@ -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);
});
});
});
+50 -10
View File
@@ -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'),
);
}