Skip to content

fix(explore): report extension-less paths the index doesn't hold (#1830) - #1837

Draft
maxmilian wants to merge 1 commit into
colbymchenry:mainfrom
maxmilian:fix/1830-dotless-path-caveat
Draft

fix(explore): report extension-less paths the index doesn't hold (#1830)#1837
maxmilian wants to merge 1 commit into
colbymchenry:mainfrom
maxmilian:fix/1830-dotless-path-caveat

Conversation

@maxmilian

Copy link
Copy Markdown
Contributor

Fixes #1830.

The bug

codegraph_explore treats a path-shaped span with no extension on its last segmentscripts/deploy, bin/build, scripts/pre-commit in a repo that doesn't index it — as free text. The agent gets back a pile of unrelated source with no indication at all that the file it named was never consulted.

Root cause

extractQueryPaths (src/search/query-paths.ts) sends a span to unresolvedPathSpans — the "say the miss out loud" channel — under:

} else if (ambiguous || isClearlyPathShaped(normalized)) {

and isClearlyPathShaped requires a dot-extension on the last segment:

const slash = normalized.lastIndexOf('/');
if (slash <= 0) return false;
return DOTTED_BASENAME.test(normalized.slice(slash + 1));

scripts/deploy has a slash, so it becomes a candidate; resolveSpan returns nothing (the file is not indexed); and then both arms are false — so the token is not consumed, never enters unresolvedPathSpans, and its fragments (scripts, deploy) go on to feed FTS.

Both downstream caveat sinks already exist and work — the empty-subgraph note and the summary line ("No indexed file uniquely matches …"). They were simply never handed the span.

Why fs-existence and not a looser shape

The obvious "fix" — treat any slash-bearing span as a path — is wrong, and this repo already has a test that says so. __tests__/query-paths.test.ts's leaves slash-bearing non-paths alone asserts that

does gen_server:call/2 block and/or timeout

comes back completely untouched. Loosening the shape test turns that red, strips and/or, input/output, client/server out of queries repo-wide, and mints a false No indexed file uniquely matches `and/or` caveat in the response. Shape genuinely cannot separate the two cases — scripts/deploy and and/or are the same shape. The file's existence on disk is the only discriminator available.

Why the predicate is injected

query-paths.ts's docstring states the module is "Pure string work — no DB, no fs — so it is trivially testable and safe inside the query-pool workers." That is load-bearing, so no fs was added to the module. Instead extractQueryPaths takes an optional existsOnDisk?: (relPath: string) => boolean, and the single call site in src/mcp/tools.ts — which owns projectRoot — supplies it:

function pathIsProjectFile(projectRoot: string, relPath: string): boolean {
  try {
    const abs = validatePathWithinRoot(projectRoot, relPath);
    return abs !== null && statSync(abs).isFile();
  } catch { return false; }
}
  • Containment goes through validatePathWithinRoot, so a ../-bearing span in an agent query cannot probe outside the project.
  • Only a regular file counts. A directory span (src/search) is not a file reference and keeps flowing to the normal matching pipeline.
  • Every fs error answers false; the whole extraction is already wrapped in catch { /* path pinning must never fail an explore call */ }.
  • The predicate is optional, so callers that cannot touch the disk (query-pool workers) simply get today's behavior.

Net change in query-paths.ts is two lines plus doc.

The issue also suggests the check might be shareable with the node tool, since node "already gets this right". I looked and I don't think it is: node matches a whole string against the index file list, while explore is classifying tokens inside free text. The inputs have different shapes and the correct answers differ. I left node alone.

Retrieval effect — AGENTS.md "do not regress"

This change makes a token get consumed and removed from matchQuery, so it moves retrieval and falls under AGENTS.md's Retrieval performance (do not regress). I ran a deterministic A/B on the built dist (scripts/agent-eval/probe-explore.mjs) over an indexed repro where scripts/deploy exists on disk but is not indexed. Baseline arm = this branch's merge-base build; fixed arm = this branch.

I deliberately did not use ab-new-vs-baseline.sh here: it drives a real agent on an implementation task, and no such task reliably emits a query naming a dotless unindexed path — both arms would have missed the changed code entirely and the numbers would have been noise.

query baseline this PR
query is only the dotless path 998 chars of unrelated source, no caveat No relevant code found for "…" (no indexed file uniquely matches `scripts/deploy`)
dotless path inside a real symbol query 1010 chars 1061 chars — byte-identical except the added caveat sentence; same 4 symbols across the same 2 files
query with no path span byte-identical between arms

The middle row is the important one: when the query carries real content, nothing about what gets retrieved changes — only the caveat is added. The first row is the change the issue asks for, and worth an explicit maintainer call: unresolvedPathSpans in this module has always coupled "report the span" with "strip it from the query", and this arm inherits that coupling, so a query that is only the dotless path now answers with the caveat instead of unrelated source. I did not decouple the two.

Tests

__tests__/query-paths.test.ts gains 6 tests (27 → 33):

  • the dotless span is reported when the injected predicate says the file exists, and the predicate is asserted to have actually been consulted (vacuity guard);
  • the same span is left alone when no predicate is injected;
  • and/or prose stays untouched even with a predicate present — and the predicate is asserted to have been asked and refused, not bypassed by shape;
  • input/output (slashed, not a file) stays untouched;
  • a dotted span that matches nothing is still reported (the existing arm is independent of disk);
  • an indexed dotless path (scripts/pre-commit) still pins by resolution and is never asked about.

Red-arm check — deleting only the new || line, tests unchanged: 3 failed / 30 passed, with all 27 pre-existing tests (including the and/or guard) staying green. Restored: 33/33.

npm run build clean. npm test: 14 failed / 4425 passed / 192 skipped — the same 14 failures across the same 8 files (installer-targets, mcp-callers-truncation, nextjs, object-literal-methods, react-native-bridge, ui-steps-api, ui-steps-api-servers, ui-steps-cross-tier) that the unpatched merge-base produces; unrelated to this change.

CHANGELOG.md [Unreleased] → Fixes updated.

Out of scope

The issue also mentions the prompt-hook having the same blind spot, and asks about gating the "Complete source for N files … do NOT re-read them" footer. Both are separate decisions with their own trade-offs; this PR does only the caveat.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XDR1wm73oH8J8cRWnKyv9m

…bymchenry#1830)

A query span like `scripts/deploy` was neither pinned nor reported.
`isClearlyPathShaped` demands a dot-extension on the last segment, so a
dotless slashed span that resolved to no indexed file fell through every
arm: it was not consumed, never entered `unresolvedPathSpans`, 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 read.
Both caveat sinks — the empty-subgraph note and the summary line — were
already in place; they were simply never handed the span.

Shape alone cannot decide this: `and/or`, `input/output` and
`client/server` have exactly the same shape, and loosening the shape test
to "has a slash" strips that prose out of every query and mints a false
"No indexed file uniquely matches `and/or`" caveat (the existing
`leaves slash-bearing non-paths alone` test pins this). The file's
existence on disk is the discriminator instead — and since query-paths.ts
is deliberately pure (no DB, no fs, safe in the query-pool workers), the
check is injected by the caller as an `existsOnDisk` predicate. The call
site in tools.ts owns the project root and runs it through
`validatePathWithinRoot`, so a `../` span cannot probe outside the
project, and only a regular file counts — a directory span keeps flowing
to the normal matching pipeline.

Retrieval effect (AGENTS.md "do not regress"), measured as a deterministic
A/B on the built dist over an indexed repro, baseline = upstream/main:
- query that is only the dotless path — before: 998 chars of unrelated
  source, no caveat; after: `No relevant code found ... (no indexed file
  uniquely matches `scripts/deploy`)`.
- same path inside a real symbol query — output byte-identical except the
  added caveat sentence (+51 chars); the same 4 symbols across 2 files.
- query with no path span — byte-identical between arms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDR1wm73oH8J8cRWnKyv9m
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant