Skip to content
Draft
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

- The prompt hook no longer injects unrelated projects when run from your home directory or a broader directory containing a stray workspace manifest. (#1454)

- `codegraph_explore` now says so when a query names an extension-less file the index doesn't hold. A path like `scripts/deploy` has no extension on its last segment, so it failed the shape test that decides a span is a path beyond doubt — the name was left in the query, shredded into `scripts` and `deploy`, and the answer came back as a pile of unrelated source with no hint that the file you named was never consulted. Such a span is now checked against the project directory: if it is a real file, the answer carries the same `No indexed file uniquely matches ...` note a misspelled `src/foo.ts` already got. Slashed prose — `and/or`, `input/output`, `gen_server:call/2` — has no file behind it and is still left in the query untouched. (#1830)

- Indexing now succeeds when Node.js's SQLite lacks FTS5, with search falling back to name and fuzzy matching; thanks @aniruddhaadak80. (#1532)

- `codegraph_explore` now makes clear that suggested call counts are advisory, so agents keep exploring when an answer is incomplete; thanks @rongbc. (#1504, #1570)
Expand Down
81 changes: 81 additions & 0 deletions __tests__/query-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,84 @@ describe('extractQueryPaths — extension-less kebab basenames', () => {
expect(out.strippedQuery).toBe('background-image-table then');
});
});

/**
* Dotless slashed spans (#1830). `scripts/deploy` names a real file that the
* index does not hold (no recognized extension), but the shape test demands a
* dot-extension on the last segment — so the span was neither pinned NOR
* reported, and its fragments (`scripts`, `deploy`) went on to feed FTS. The
* agent got a pile of unrelated source with no hint that the file it named was
* never consulted. Shape alone cannot decide this (`and/or` is the same shape),
* so the caller injects an `existsOnDisk` predicate and the file's existence
* decides. These tests also pin that the predicate is genuinely CONSULTED —
* a predicate that is never called would make the whole arm vacuous.
*/
describe('extractQueryPaths — dotless slashed spans, decided on disk', () => {
/** Records every span the predicate is asked about. */
const probe = (onDisk: readonly string[]) => {
const asked: string[] = [];
return {
asked,
existsOnDisk: (rel: string) => { asked.push(rel); return onDisk.includes(rel); },
};
};

it('reports a dotless path that exists on disk but is not indexed', () => {
const p = probe(['scripts/deploy']);
const out = extractQueryPaths(
'why does scripts/deploy fail on release', INDEX, { existsOnDisk: p.existsOnDisk },
);
expect(out.pinnedFiles).toEqual([]);
expect(out.unresolvedPathSpans).toEqual(['scripts/deploy']);
expect(out.strippedQuery).toBe('why does fail on release');
// Vacuity guard: the verdict came from the predicate, not from some other arm.
expect(p.asked).toContain('scripts/deploy');
});

it('leaves the same span alone when no predicate is injected', () => {
const q = 'why does scripts/deploy fail on release';
const out = extractQueryPaths(q, INDEX);
expect(out.unresolvedPathSpans).toEqual([]);
expect(out.strippedQuery).toBe(q);
});

it('leaves `and/or` prose alone even though a predicate is injected', () => {
const p = probe(['scripts/deploy']);
const q = 'does gen_server:call/2 block and/or timeout';
const out = extractQueryPaths(q, INDEX, { existsOnDisk: p.existsOnDisk });
expect(out.pinnedFiles).toEqual([]);
expect(out.unresolvedPathSpans).toEqual([]);
expect(out.strippedQuery).toBe(q);
// Consulted and refused — not skipped by shape.
expect(p.asked).toContain('and/or');
});

it('leaves a slashed word pair that is not a file on disk alone', () => {
const p = probe(['scripts/deploy']);
const q = 'trace the input/output buffering path';
const out = extractQueryPaths(q, INDEX, { existsOnDisk: p.existsOnDisk });
expect(out.unresolvedPathSpans).toEqual([]);
expect(out.strippedQuery).toBe(q);
expect(p.asked).toContain('input/output');
});

it('still reports a dotted span that matches nothing and is not on disk', () => {
const p = probe([]);
const out = extractQueryPaths(
'crash in src/routes/gone/missing-page.svelte on load', INDEX,
{ existsOnDisk: p.existsOnDisk },
);
expect(out.unresolvedPathSpans).toEqual(['src/routes/gone/missing-page.svelte']);
expect(out.strippedQuery).toBe('crash in on load');
});

it('pins an indexed dotless path by resolution, never asking about it', () => {
const p = probe(['scripts/pre-commit']);
const out = extractQueryPaths(
'what does scripts/pre-commit run', INDEX, { existsOnDisk: p.existsOnDisk },
);
expect(out.pinnedFiles).toEqual(['scripts/pre-commit']);
expect(out.unresolvedPathSpans).toEqual([]);
expect(p.asked).not.toContain('scripts/pre-commit');
});
});
21 changes: 20 additions & 1 deletion src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,25 @@ export function normalizeQuerySpelling(query: string): string {
);
}

/**
* Does this query-named span point at a real FILE inside the project?
*
* The `existsOnDisk` predicate `extractQueryPaths` takes (that module is pure —
* no DB, no fs — so the fs access lives here, where the project root is known).
* Only a REGULAR FILE counts: a directory span (`src/search`) is not a file
* reference and must keep flowing to the normal matching pipeline. Containment
* is enforced by `validatePathWithinRoot`, so a `../` span in a query cannot
* probe outside the project, and every fs error answers `false`.
*/
function pathIsProjectFile(projectRoot: string, relPath: string): boolean {
try {
const abs = validatePathWithinRoot(projectRoot, relPath);
return abs !== null && statSync(abs).isFile();
} catch {
return false;
}
}

/**
* Calculate the recommended number of codegraph_explore calls based on project size.
* Larger codebases need more exploration calls to cover their surface area,
Expand Down Expand Up @@ -3318,7 +3337,7 @@ export class ToolHandler {
const extraction = extractQueryPaths(
rawQuery,
cg.getFiles().map((f) => f.path),
{ maxPins: maxFiles },
{ maxPins: maxFiles, existsOnDisk: (rel) => pathIsProjectFile(projectRoot, rel) },
);
if (extraction.pinnedFiles.length > 0 || extraction.unresolvedPathSpans.length > 0) {
pinnedFiles = extraction.pinnedFiles;
Expand Down
30 changes: 27 additions & 3 deletions src/search/query-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@
* spans removed.
* Callers treat pinned files as first-class: guaranteed admission, top rank,
* funded first. Pure string work — no DB, no fs — so it is trivially testable
* and safe inside the query-pool workers.
* and safe inside the query-pool workers. The one question string shape cannot
* answer (is this dotless slashed span, `scripts/deploy`, a real file that is
* merely unindexed, or is it prose like `and/or`?) is delegated to an OPTIONAL
* `existsOnDisk` predicate the caller injects — the fs access stays at the call
* site, which owns the project root, and this module stays pure.
*/

export interface QueryPathExtraction {
Expand Down Expand Up @@ -189,7 +193,18 @@ function resolveSpan(
export function extractQueryPaths(
query: string,
indexedPaths: readonly string[],
opts: { maxPins?: number; maxMatchesPerSpan?: number } = {},
opts: {
maxPins?: number;
maxMatchesPerSpan?: number;
/**
* Does this repo-relative span name a real FILE in the project? Optional,
* injected by the caller (see the module docstring): it is the only way to
* tell a dotless path the index simply doesn't hold (`scripts/deploy`)
* from slashed prose (`and/or`), and it must stay out of this module.
* Must not throw — the caller absorbs fs errors and returns false.
*/
existsOnDisk?: (relPath: string) => boolean;
} = {},
): QueryPathExtraction {
const maxPins = Math.max(1, opts.maxPins ?? 8);
const maxMatchesPerSpan = Math.max(1, opts.maxMatchesPerSpan ?? 3);
Expand Down Expand Up @@ -235,10 +250,19 @@ export function extractQueryPaths(
pinnedSeen.add(m);
pinned.push(m);
}
} else if (ambiguous || isClearlyPathShaped(normalized)) {
} else if (
ambiguous
|| isClearlyPathShaped(normalized)
|| (normalized.includes('/') && opts.existsOnDisk?.(normalized) === true)
) {
// A real path that didn't resolve to a usable set. Keeping it in the
// query is strictly worse — its fragments are what minted the junk
// matches this module exists to stop — so strip it and say so.
// The third arm covers the DOTLESS slashed span (`scripts/deploy`,
// `bin/build`): shape alone cannot tell it from `and/or` or
// `input/output`, so the file's existence on disk decides. Loosening the
// SHAPE test instead would strip that prose out of every query and mint a
// false "no indexed file matches `and/or`" caveat.
consumed.add(i);
if (unresolved.length < 4) unresolved.push(normalized);
}
Expand Down