mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-02 09:45:39 +08:00
fix(explore): explain empty results with bounded lexical diagnostics (#1904)
Empty explore results returned no explanation or retry guidance. Check FTS and live name segments with two bounded SQL queries to distinguish matched and unmatched words and suggest indexed names. Preserve success-shaped responses, unresolved-path notes, ranking, and non-empty output; cap added diagnostics below 1.5K characters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bd993b12fa
commit
7794034047
@@ -0,0 +1,134 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import CodeGraph from '../src/index';
|
||||
import { ToolHandler } from '../src/mcp/tools';
|
||||
|
||||
let dir: string;
|
||||
let cg: CodeGraph;
|
||||
|
||||
async function index(files: Record<string, string> = {}) {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-empty-explore-'));
|
||||
for (const [name, source] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(dir, name)), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, name), source);
|
||||
}
|
||||
cg = CodeGraph.initSync(dir);
|
||||
await cg.indexAll();
|
||||
}
|
||||
|
||||
async function explore(query: string) {
|
||||
const result = await new ToolHandler(cg).execute('codegraph_explore', { query });
|
||||
expect(result.isError).toBeFalsy();
|
||||
return result.content[0]!.text;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cg?.destroy();
|
||||
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const registration = {
|
||||
'src/users.py': 'class RegistrationService:\n def create_account(self):\n return 1\n\nclass RateLimiter:\n def check_limit(self):\n return False\n',
|
||||
};
|
||||
|
||||
describe('empty explore diagnostics (#1904)', () => {
|
||||
it('explains a synonym miss without claiming the concept or all query words are absent', async () => {
|
||||
await index(registration);
|
||||
const text = await explore('how do we stop users signing up too fast');
|
||||
expect(text).toContain('No relevant code found');
|
||||
expect(text).toContain('lexically, not by meaning');
|
||||
expect(text).toMatch(/No lexical matches[^\n]*`signing`/);
|
||||
// The file name is indexed, even though no relevant subgraph was returned.
|
||||
expect(text).toMatch(/Matched indexed words[^\n]*`users`/);
|
||||
expect(text).toContain('codegraph_explore');
|
||||
expect(text).not.toMatch(/use (?:Read|grep)/i);
|
||||
});
|
||||
|
||||
it('leaves the literal control on the normal source-rendering path', async () => {
|
||||
await index(registration);
|
||||
const text = await explore('throttle signup limit');
|
||||
expect(text).toContain('check_limit');
|
||||
expect(text).not.toContain('No lexical matches');
|
||||
expect(text).not.toContain('lexically, not by meaning');
|
||||
});
|
||||
|
||||
it('reports an empty index explicitly', async () => {
|
||||
await index();
|
||||
expect(await explore('signup throttle')).toContain('This project has nothing indexed');
|
||||
});
|
||||
|
||||
it('keeps unresolved paths separate from word diagnostics', async () => {
|
||||
await index(registration);
|
||||
const text = await explore('missing/phantom.py signing');
|
||||
expect(text).toContain('no indexed file uniquely matches `missing/phantom.py`');
|
||||
expect(text).toMatch(/No lexical matches[^\n]*`signing`/);
|
||||
expect(text).not.toMatch(/No lexical matches[^\n]*`phantom`/);
|
||||
});
|
||||
|
||||
it.each(['py', 'ts'])('offers real sub-word candidates for filtered words (%s)', async (language) => {
|
||||
await index({
|
||||
[`logic.${language}`]: language === 'py'
|
||||
? 'def explainHowThingsWork():\n return 1\n'
|
||||
: 'export function explainHowThingsWork() { return 1; }\n',
|
||||
});
|
||||
const text = await explore('how');
|
||||
expect(text).toContain('No relevant code found');
|
||||
expect(text).toMatch(/Matched indexed words[^\n]*`how`/);
|
||||
expect(text).toContain('Candidates to retry with codegraph_explore');
|
||||
expect(text).toContain('`explainHowThingsWork`');
|
||||
expect(await explore('explainHowThingsWork')).toContain('return 1');
|
||||
});
|
||||
|
||||
it('recognizes docstring-only matches without inventing name candidates', async () => {
|
||||
await index({ 'logic.ts': '/** with */\nexport function frobnicate() { return 1; }\n' });
|
||||
expect(cg.getNodesByName('frobnicate')[0]?.docstring).toContain('with');
|
||||
const text = await explore('with');
|
||||
expect(text).toContain('No relevant code found');
|
||||
expect(text).toMatch(/Matched indexed words[^\n]*`with`/);
|
||||
expect(text).not.toContain('`frobnicate`');
|
||||
});
|
||||
|
||||
it('offers FTS name-prefix candidates even without an exact segment match', async () => {
|
||||
await index({ 'logic.ts': 'export function withholdValue() { return 1; }\n' });
|
||||
const text = await explore('with');
|
||||
expect(text).toContain('No relevant code found');
|
||||
expect(text).toMatch(/Matched indexed words[^\n]*`with`/);
|
||||
expect(text).toContain('`withholdValue`');
|
||||
});
|
||||
|
||||
it('does not offer deleted symbols left in the segment vocabulary', async () => {
|
||||
await index({ ...registration, 'logic.py': 'def explainHowThingsWork():\n return 1\n' });
|
||||
fs.unlinkSync(path.join(dir, 'logic.py'));
|
||||
await cg.sync();
|
||||
const text = await explore('how');
|
||||
expect(text).toMatch(/No lexical matches[^\n]*`how`/);
|
||||
expect(text).not.toContain('explainHowThingsWork');
|
||||
});
|
||||
|
||||
it('handles punctuation and bounded Unicode words without an error', async () => {
|
||||
await index(registration);
|
||||
const text = await explore('"???"');
|
||||
expect(text).toContain('No relevant code found');
|
||||
expect(text).toContain('codegraph_explore');
|
||||
const miss = cg.getExploreMissDiagnostics('未知词 " OR * ' + 'z'.repeat(80));
|
||||
expect(miss.unmatched).toContain('未知词');
|
||||
expect(miss.limited).toBe(true);
|
||||
});
|
||||
|
||||
it('caps diagnostics and candidate names', async () => {
|
||||
await index({
|
||||
'logic.py': Array.from({ length: 24 }, (_, i) =>
|
||||
`def explainHowVariant${i}():\n return ${i}\n`).join('\n'),
|
||||
});
|
||||
const query = 'how ' + Array.from({ length: 40 }, (_, i) => `zzmissing${i}`).join(' ');
|
||||
const text = await explore(query);
|
||||
expect(text).toContain('No relevant code found');
|
||||
expect(text.length - `No relevant code found for "${query}"`.length).toBeLessThanOrEqual(1500);
|
||||
const candidates = text.match(/`explainHowVariant\d+`/g) ?? [];
|
||||
expect(candidates.length).toBeGreaterThan(0);
|
||||
expect(candidates.length).toBeLessThanOrEqual(12);
|
||||
expect(new Set(candidates).size).toBe(candidates.length);
|
||||
});
|
||||
});
|
||||
@@ -1537,6 +1537,51 @@ export class QueryBuilder {
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Bounded miss diagnostics, independent of relevance filters and ranking. */
|
||||
getExploreMissDiagnostics(query: string): {
|
||||
matched: string[]; unmatched: string[]; candidates: string[]; limited: boolean;
|
||||
} {
|
||||
const words = [...new Set((query.match(/[\p{L}\p{N}]+/gu) ?? []).map(w => w.toLowerCase()))];
|
||||
const checked = words.filter(w => w.length <= 64).slice(0, 16);
|
||||
const limited = checked.length !== words.length;
|
||||
if (checked.length === 0) return { matched: [], unmatched: [], candidates: [], limited };
|
||||
|
||||
// EXISTS uses FTS postings and the segment primary key, never source scans.
|
||||
// Vocab rows can outlive deleted definitions, so verify them against nodes.
|
||||
const rows = this.db.prepare(`
|
||||
WITH words(word, pattern) AS (VALUES ${checked.map(() => '(?, ?)').join(', ')})
|
||||
SELECT word, (
|
||||
EXISTS (SELECT 1 FROM nodes_fts WHERE nodes_fts MATCH pattern)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM name_segment_vocab v WHERE v.segment = word
|
||||
AND EXISTS (SELECT 1 FROM nodes n WHERE n.name = v.name AND n.kind NOT IN ('file', 'import'))
|
||||
)
|
||||
) AS matched FROM words
|
||||
`).all(...checked.flatMap(w => [w, `{name qualified_name signature docstring} : "${w}"*`])) as
|
||||
Array<{ word: string; matched: number }>;
|
||||
|
||||
const names = this.db.prepare(`
|
||||
SELECT name FROM (
|
||||
SELECT v.name FROM name_segment_vocab v
|
||||
WHERE v.segment IN (${checked.map(() => '?').join(', ')})
|
||||
AND EXISTS (SELECT 1 FROM nodes n WHERE n.name = v.name AND n.kind NOT IN ('file', 'import'))
|
||||
LIMIT 12
|
||||
)
|
||||
UNION ALL
|
||||
SELECT name FROM (
|
||||
SELECT n.name FROM nodes_fts JOIN nodes n ON n.rowid = nodes_fts.rowid
|
||||
WHERE nodes_fts MATCH ? AND n.kind NOT IN ('file', 'import')
|
||||
LIMIT 12
|
||||
)
|
||||
`).all(...checked, `name : (${checked.map(w => `"${w}"*`).join(' OR ')})`) as Array<{ name: string }>;
|
||||
return {
|
||||
matched: rows.filter(r => r.matched).map(r => r.word),
|
||||
unmatched: rows.filter(r => !r.matched).map(r => r.word),
|
||||
candidates: [...new Set(names.map(r => r.name))].slice(0, 12),
|
||||
limited,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* FTS5 search with prefix matching
|
||||
*/
|
||||
|
||||
@@ -1634,6 +1634,11 @@ export class CodeGraph {
|
||||
return this.queries.searchNodes(query, options);
|
||||
}
|
||||
|
||||
/** Lexical evidence for an empty explore result; does not alter retrieval. */
|
||||
getExploreMissDiagnostics(query: string) {
|
||||
return this.queries.getExploreMissDiagnostics(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Graph-derived prompt matching for the front-load hook's MEDIUM tier:
|
||||
* which indexed symbols do these prose words name? "state machine des
|
||||
|
||||
@@ -57,6 +57,7 @@ calls; a grep/read exploration is dozens.
|
||||
- **Need more?** Call \`codegraph_explore\` again with more specific names — treat the source it returns as already Read. Suggested call counts are advisory only, NOT a quota; extra calls are never rejected or rate-limited.
|
||||
- Qualified symbol names accept dots, \`::\`, or slashes, including containers whose names contain dots (for example, \`AppWeb.Format.group\`).
|
||||
- Named-symbol call paths require exact matches; partial or mistyped names are never silently substituted as flow endpoints. If a graph query reports a missing symbol with did-you-mean suggestions, query the suggested name explicitly.
|
||||
- Explore matches names and indexed code words lexically, not by meaning; an empty result reports word matches and may suggest indexed candidate names to retry with \`codegraph_explore\`.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
|
||||
+31
-2
@@ -3333,8 +3333,11 @@ export class ToolHandler {
|
||||
// pre-#185 behavior for callers that hit the rare stats failure.
|
||||
let budget: ExploreOutputBudget;
|
||||
let indexedFileCount = -1;
|
||||
let indexedNodeCount = -1;
|
||||
try {
|
||||
indexedFileCount = cg.getStats().fileCount;
|
||||
const stats = cg.getStats();
|
||||
indexedFileCount = stats.fileCount;
|
||||
indexedNodeCount = stats.nodeCount;
|
||||
budget = getExploreOutputBudget(indexedFileCount);
|
||||
} catch {
|
||||
budget = getExploreOutputBudget(Infinity);
|
||||
@@ -3452,7 +3455,33 @@ export class ToolHandler {
|
||||
const missNote = unresolvedPathSpans.length > 0
|
||||
? ` (no indexed file uniquely matches ${unresolvedPathSpans.map((s) => `\`${s}\``).join(', ')})`
|
||||
: '';
|
||||
const empty = `No relevant code found for "${query}"${missNote}`;
|
||||
let explanation = '\n\nExplore matches symbol/file names and indexed code words lexically, not by meaning.';
|
||||
if (indexedNodeCount === 0) {
|
||||
explanation += '\nThis project has nothing indexed.';
|
||||
} else {
|
||||
const miss = cg.getExploreMissDiagnostics(matchQuery);
|
||||
const list = (words: string[]) => words.map(w => `\`${w}\``).join(', ');
|
||||
// Separate caps preserve the retry instruction and complete candidate
|
||||
// names even with long queries or generated identifiers.
|
||||
const cappedList = (words: string[], cap: number) => {
|
||||
const kept: string[] = [];
|
||||
for (const word of words) {
|
||||
if (list([...kept, word]).length > cap) break;
|
||||
kept.push(word);
|
||||
}
|
||||
return list(kept) + (kept.length < words.length ? ' …' : '');
|
||||
};
|
||||
explanation += '\nChecked indexed names, signatures, docstrings (FTS prefixes) and live name segments; not all source text.';
|
||||
if (miss.limited) explanation += '\nWord check limited to 16 words of at most 64 characters.';
|
||||
explanation += `\nNo lexical matches for checked words: ${cappedList(miss.unmatched, 250) || '(none)'}.`;
|
||||
if (miss.matched.length > 0) {
|
||||
explanation += `\nMatched indexed words: ${cappedList(miss.matched, 200)}; these did not yield a relevant result after filtering/scoring.`;
|
||||
}
|
||||
explanation += miss.candidates.length > 0
|
||||
? `\nCandidates to retry with codegraph_explore (shared words, not confirmed answers): ${cappedList(miss.candidates, 350)}`
|
||||
: '\nNo shared-word symbol candidates found; retry codegraph_explore with literal symbol/file names or code terms.';
|
||||
}
|
||||
const empty = `No relevant code found for "${query}"${missNote}${explanation}`;
|
||||
// Still an explore call, so it is still recorded: an empty answer spends a
|
||||
// call against the tier budget even though it emits no source.
|
||||
return this.exploreResult(empty, {
|
||||
|
||||
Reference in New Issue
Block a user