mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +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>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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'),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user