mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 22:02:49 +08:00
* fix(explore): find files by exact Chinese filenames Co-Authored-By: GPT-5 <noreply@openai.com> * fix(explore): retrieve exact Han filenames (#1372) ASCII-only query preparation discarded Han filename queries before retrieval. Add exact basename/stem file lookup and prioritize matches before the entry-point cap while respecting node-kind filters. Preserve shared tokenization and retrieval budgets; cover Lua, Svelte, and TypeScript retrieval. Co-authored-by: 小为 <1053122090@qq.com> Co-authored-by: GPT-5 <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 小为 <1053122090@qq.com> Co-authored-by: GPT-5 <noreply@openai.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Chinese filename retrieval through codegraph_explore (#1372).
|
||||
*
|
||||
* Exact Chinese filenames are already indexed and searchable through
|
||||
* `codegraph query`, but explore drops a Han-only filename before it can reach
|
||||
* the indexed file.
|
||||
*
|
||||
* The locked invariant: an exact Chinese filename reaches explore without
|
||||
* turning partial Chinese terms into broader filename search or overriding
|
||||
* explicit node-kind filters.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import CodeGraph from '../src/index';
|
||||
import { ToolHandler } from '../src/mcp/tools';
|
||||
|
||||
describe('Chinese filename retrieval (#1372)', () => {
|
||||
let testDir: string;
|
||||
let cg: CodeGraph;
|
||||
let handler: ToolHandler;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1372-'));
|
||||
fs.writeFileSync(
|
||||
path.join(testDir, '示例模块.lua'),
|
||||
'local M = {}\nreturn M\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(testDir, '示例模块扩展.lua'),
|
||||
'local Extension = {}\nreturn Extension\n'
|
||||
);
|
||||
for (const fileName of ['用户Service.lua', '用户模块2.lua', '用户-登录.lua']) {
|
||||
fs.writeFileSync(
|
||||
path.join(testDir, fileName),
|
||||
'local MixedName = {}\nreturn MixedName\n'
|
||||
);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(testDir, '用户.v2.lua'),
|
||||
'local DottedStem = {}\nreturn DottedStem\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(testDir, '深度模块.svelte'),
|
||||
'<script>const exactLongName = {};</script>\n'
|
||||
);
|
||||
for (let i = 0; i < 24; i++) {
|
||||
fs.writeFileSync(
|
||||
path.join(testDir, `深度模块扩展${i}.c`),
|
||||
`int prefix_noise_${i}(void) { return ${i}; }\n`
|
||||
);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(testDir, '共享模块.ts'),
|
||||
'export function sharedEntry() { return 42; }\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(testDir, 'Service.ts'),
|
||||
'export class Service {}\n'
|
||||
);
|
||||
|
||||
cg = CodeGraph.initSync(testDir, {
|
||||
config: { include: ['**/*.lua', '**/*.c', '**/*.svelte', '**/*.ts'], exclude: [] },
|
||||
});
|
||||
await cg.indexAll();
|
||||
handler = new ToolHandler(cg);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (cg) cg.destroy();
|
||||
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns a file whose exact Chinese name is the explore query', async () => {
|
||||
const result = await handler.execute('codegraph_explore', { query: '示例模块' });
|
||||
const text = result.content[0]!.text as string;
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(text).toContain('示例模块.lua');
|
||||
expect(text).not.toContain('示例模块扩展.lua');
|
||||
expect(text).not.toContain('No relevant code found');
|
||||
});
|
||||
|
||||
it('does not treat a partial Chinese name as an exact filename', async () => {
|
||||
const result = await handler.execute('codegraph_explore', { query: '示例' });
|
||||
const text = result.content[0]!.text as string;
|
||||
|
||||
expect(text).not.toContain('示例模块.lua');
|
||||
expect(text).not.toContain('示例模块扩展.lua');
|
||||
});
|
||||
|
||||
it('supports exact Chinese filenames mixed with ASCII, digits, and separators', async () => {
|
||||
for (const query of ['用户Service', '用户模块2', '用户-登录']) {
|
||||
const result = await handler.execute('codegraph_explore', { query });
|
||||
const text = result.content[0]!.text as string;
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(text).toContain(`${query}.lua`);
|
||||
expect(text).not.toContain('No relevant code found');
|
||||
}
|
||||
});
|
||||
|
||||
it('matches a dotted stem with or without the final extension', async () => {
|
||||
for (const query of ['用户.v2', '用户.v2.lua']) {
|
||||
const result = await handler.execute('codegraph_explore', { query });
|
||||
const text = result.content[0]!.text as string;
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(text).toContain('用户.v2.lua');
|
||||
expect(text).not.toContain('No relevant code found');
|
||||
}
|
||||
});
|
||||
|
||||
it('does not lose an exact stem behind a large prefix candidate set', async () => {
|
||||
const result = await handler.execute('codegraph_explore', { query: '深度模块' });
|
||||
const text = result.content[0]!.text as string;
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(text).toContain('深度模块.svelte');
|
||||
expect(text).not.toContain('No relevant code found');
|
||||
});
|
||||
|
||||
it('does not override an explicit node-kind filter', async () => {
|
||||
const context = await cg.findRelevantContext('示例模块', {
|
||||
nodeKinds: ['function'],
|
||||
});
|
||||
|
||||
expect([...context.nodes.values()].some((node) => node.kind === 'file')).toBe(false);
|
||||
});
|
||||
|
||||
it('retrieves a TypeScript file and its source through the same MCP path', async () => {
|
||||
const result = await handler.execute('codegraph_explore', { query: '共享模块' });
|
||||
const text = result.content[0]!.text as string;
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(text).toContain('共享模块.ts');
|
||||
expect(text).toContain('return 42');
|
||||
});
|
||||
|
||||
it('keeps the exact mixed filename ahead of symbol matches under the entry-point cap', async () => {
|
||||
const context = await cg.findRelevantContext('用户Service', { searchLimit: 1 });
|
||||
|
||||
expect(context.roots).toHaveLength(1);
|
||||
expect(context.nodes.get(context.roots[0]!)?.name).toBe('用户Service.lua');
|
||||
});
|
||||
|
||||
it('admits exact file matches when file nodes are explicitly allowed', async () => {
|
||||
const context = await cg.findRelevantContext('示例模块', { nodeKinds: ['file'] });
|
||||
|
||||
expect(context.roots.map((id) => context.nodes.get(id)?.name)).toContain('示例模块.lua');
|
||||
});
|
||||
});
|
||||
@@ -590,6 +590,35 @@ export class ContextBuilder {
|
||||
exactMatches = exactMatches.slice(0, Math.ceil(opts.searchLimit * 3));
|
||||
}
|
||||
|
||||
// Step 2c: Match an exact Han filename (with or without its extension).
|
||||
// Keep this separate from natural-language terms so partial Chinese words
|
||||
// do not become broad FTS queries.
|
||||
const filenameQuery = query.trim();
|
||||
const hasHanInFilename = Array.from(filenameQuery).some(
|
||||
(char) => /\p{Script_Extensions=Han}/u.test(char)
|
||||
);
|
||||
const allowsFileNodes = options.nodeKinds === undefined
|
||||
|| options.nodeKinds.length === 0
|
||||
|| options.nodeKinds.includes('file');
|
||||
const exactFileMatches: SearchResult[] = [];
|
||||
|
||||
if (hasHanInFilename && allowsFileNodes) {
|
||||
const candidates = [
|
||||
...this.queries.getNodesByName(filenameQuery)
|
||||
.filter((node) => node.kind === 'file'),
|
||||
...this.queries.getFileNodesByNamePrefix(`${filenameQuery}.`),
|
||||
];
|
||||
for (const node of candidates) {
|
||||
const fileName = path.basename(node.filePath);
|
||||
const fileExtension = path.extname(fileName);
|
||||
const fileStem = fileExtension
|
||||
? fileName.slice(0, -fileExtension.length)
|
||||
: fileName;
|
||||
const isExactMatch = fileName === filenameQuery || fileStem === filenameQuery;
|
||||
if (isExactMatch) exactFileMatches.push({ node, score: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Run text search for natural language term matching
|
||||
// This catches file-name and node-name matches that semantic search may miss,
|
||||
// which is critical for template-heavy codebases (e.g., Liquid/Shopify themes)
|
||||
@@ -987,6 +1016,16 @@ export class ContextBuilder {
|
||||
// they want the TerminalPanel class, not the import statement
|
||||
filteredResults = this.resolveImportsToDefinitions(filteredResults);
|
||||
|
||||
// An exact filename is the query's requested entry point. Keep it ahead of
|
||||
// broader text matches before applying the entry-point cap.
|
||||
if (exactFileMatches.length > 0) {
|
||||
const exactFileIds = new Set(exactFileMatches.map((result) => result.node.id));
|
||||
filteredResults = [
|
||||
...exactFileMatches,
|
||||
...filteredResults.filter((result) => !exactFileIds.has(result.node.id)),
|
||||
];
|
||||
}
|
||||
|
||||
// Cap entry points so traversal budget isn't spread too thin.
|
||||
// With 36 entry points and maxNodes=120, each gets only 3 nodes — useless.
|
||||
// Cap to searchLimit so each entry point gets a meaningful traversal budget.
|
||||
|
||||
@@ -278,6 +278,7 @@ export class QueryBuilder {
|
||||
getUnresolvedByName?: SqliteStatement;
|
||||
getNodesByName?: SqliteStatement;
|
||||
getNodesByNamePrefix?: SqliteStatement;
|
||||
getFileNodesByNamePrefix?: SqliteStatement;
|
||||
getNodesByQualifiedNameExact?: SqliteStatement;
|
||||
getNodesByLowerName?: SqliteStatement;
|
||||
getUnresolvedCount?: SqliteStatement;
|
||||
@@ -1257,6 +1258,20 @@ export class QueryBuilder {
|
||||
return rows.map(rowToNode);
|
||||
}
|
||||
|
||||
/** File nodes whose basename starts with `prefix`, without a result cap. */
|
||||
getFileNodesByNamePrefix(prefix: string): Node[] {
|
||||
if (!this.stmts.getFileNodesByNamePrefix) {
|
||||
this.stmts.getFileNodesByNamePrefix = this.db.prepare(
|
||||
"SELECT * FROM nodes WHERE kind = 'file' AND name >= ? AND name < ? ORDER BY name"
|
||||
);
|
||||
}
|
||||
const rows = this.stmts.getFileNodesByNamePrefix.all(
|
||||
prefix,
|
||||
prefix + ''
|
||||
) as NodeRow[];
|
||||
return rows.map(rowToNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nodes by exact qualified name match (uses idx_nodes_qualified_name index)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user