fix(mcp): match complete paths in stale banners (#1968) (#1997)

* fix(mcp): match pending paths as whole paths in the stale-file banner (#1968)

withStalenessNotice counted a pending file as referenced whenever its path
was a substring of the response, so a pending src/app.ts was named in the
banner for a response that only showed src/app.tsx, and a pending root
app.tsx for one that showed src/app.tsx. A match now has to stand as a
whole path.

* fix(mcp): match complete paths in stale banners (#1968)

Substring matching incorrectly classified pending files as referenced inside longer paths.
Build on PR #1971 with response delimiters that reject Unicode and punctuation continuations while preserving exact paths and line references.
Add real-index regression coverage for truthful warnings and the elsewhere footer.

Co-authored-by: Eric Minish <eric.minish@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Eric Minish <eric.minish@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-27 12:16:23 +00:00
committed by GitHub
co-authored by Eric Minish Claude Opus 5.5
parent 94ae04d8d3
commit 87924a978c
2 changed files with 134 additions and 4 deletions
+110
View File
@@ -210,3 +210,113 @@ describe('MCP staleness banner', () => {
expect(text.startsWith('⚠️')).toBe(false);
});
});
describe('MCP staleness banner — matching whole paths (#1968)', () => {
let testDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
beforeEach(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-stale-paths-'));
fs.mkdirSync(path.join(testDir, 'src'));
fs.writeFileSync(path.join(testDir, 'src', 'app.tsx'), 'export function appView() { return 1; }\n');
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts', '**/*.tsx'], exclude: [] } });
await cg.indexAll();
handler = new ToolHandler(cg);
cg.watch({ debounceMs: 4000, inertForTests: true });
await cg.waitUntilWatcherReady();
});
afterEach(() => {
try { cg.unwatch(); } catch { /* ignore */ }
try { cg.close(); } catch { /* ignore */ }
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
async function pend(rel: string): Promise<void> {
fs.writeFileSync(path.join(testDir, rel), 'export const edited = 1;\n');
__emitWatchEventForTests(testDir, rel);
await waitFor(() => cg.getPendingFiles().some((p) => p.path === rel));
}
it('does not name a pending file whose path only starts a path the response shows', async () => {
await pend('src/app.ts'); // the response shows src/app.tsx
const text = (await handler.execute('codegraph_search', { query: 'appView' })).content[0].text;
expect(text).toContain('src/app.tsx');
expect(text.startsWith('⚠️')).toBe(false);
expect(text).toMatch(/elsewhere in this project are pending index sync/);
});
it('does not name a pending file whose path only ends a path the response shows', async () => {
await pend('app.tsx'); // the response shows src/app.tsx
const text = (await handler.execute('codegraph_search', { query: 'appView' })).content[0].text;
expect(text.startsWith('⚠️')).toBe(false);
expect(text).toMatch(/elsewhere in this project are pending index sync/);
});
it.each([
'src/app.ts中文.ts',
'目录src/app.ts',
'src/app.ts@backup.ts',
'src/app.ts+backup.ts',
'src/app.ts%backup.ts',
'src/app.ts,backup.ts',
'src/app.ts#backup.ts',
'src/app.ts.backup.ts',
'src/app.ts/child.ts',
'@src/app.ts',
'src/app.ts🦀.ts',
])('keeps a pending prefix or suffix out of the banner for %s', async (shown) => {
fs.mkdirSync(path.dirname(path.join(testDir, shown)), { recursive: true });
fs.writeFileSync(path.join(testDir, shown), 'export function otherView() { return 2; }\n');
await cg.indexAll();
// A directory can also have a filename-like component; use a root suffix
// for that case so the pending file and directory can coexist on disk.
await pend(shown.includes('/child') ? 'child.ts' : 'src/app.ts');
const text = (await handler.execute('codegraph_search', { query: 'otherView' })).content[0].text;
expect(text).toContain(shown);
expect(text.startsWith('⚠️')).toBe(false);
expect(text).toMatch(/elsewhere in this project are pending index sync/);
});
it.each([
'src/app.ts',
'**`src/app.ts`**',
'**src/app.ts**',
'(src/app.ts:12)',
'src/app.ts:12:3',
'src/app.ts:12-20',
'"src/app.ts"',
'File: src/app.ts\n',
'See src/app.ts.',
'`src/app.tsx` then `src/app.ts`',
'src/app.ts, src/other.ts',
'src/app.ts; src/other.ts',
])('preserves a truthful banner for the rendered reference %s', async (reference) => {
await pend('src/app.ts');
// Exercise renderer variants through the notice wrapper with the real
// index and watcher pending set, without mocking the database.
const result = (handler as any).withStalenessNotice({
content: [{ type: 'text', text: reference }],
});
expect(result.content[0].text.startsWith('⚠️')).toBe(true);
expect(result.content[0].text).not.toContain('elsewhere in this project');
});
it.each(['src/目录.ts', 'src/app@backup.ts', 'src/app+backup.ts'])(
'still warns for an exact Unicode or punctuation path: %s', async (rel) => {
fs.writeFileSync(path.join(testDir, rel), 'export function specialView() { return 3; }\n');
await cg.indexAll();
await pend(rel);
const text = (await handler.execute('codegraph_search', { query: 'specialView' })).content[0].text;
expect(text.startsWith('⚠️')).toBe(true);
expect(text).toContain(rel);
},
);
it('still names a pending file the response shows', async () => {
await pend('src/app.tsx');
const text = (await handler.execute('codegraph_search', { query: 'appView' })).content[0].text;
expect(text.startsWith('⚠️')).toBe(true);
});
});
+24 -4
View File
@@ -1034,6 +1034,26 @@ function pointerLineFor(filePath: string, nodes: readonly Node[]): string {
*/
const EPILOGUE_LOST_NOTE = '> (Trailing pointer list omitted for size. The source above is complete and verbatim — treat it as already Read. For anything this call did not cover, run another codegraph_explore with the specific names rather than reading those files.)';
/**
* Match response delimiters rather than ASCII "path characters": filenames
* can contain Unicode, @, +, and other punctuation. Keep line references and
* a sentence-ending period, but reject prefixes/suffixes of longer paths.
*/
function mentionsPath(text: string, relPath: string): boolean {
const delimiter = /[\s`"'()[\]{}*]/;
for (let at = text.indexOf(relPath); at !== -1; at = text.indexOf(relPath, at + 1)) {
if (at > 0 && !delimiter.test(text[at - 1] ?? '')) continue;
const end = at + relPath.length;
if (end === text.length || delimiter.test(text[end] ?? '')) return true;
const suffix = text.slice(end);
if (/^:\d+(?::\d+|[-–]\d+)?(?=$|[\s`"'()[\]{}*])/.test(suffix)) return true;
if (/^:(?=$|\s)/.test(suffix)) return true; // file-list label
if (/^\.(?=$|\s)/.test(suffix)) return true; // prose punctuation
if (/^[,;](?=$|\s)/.test(suffix)) return true; // list separator
}
return false;
}
/**
* Per-file staleness banner emitted at the top of a tool response when the
* file watcher has pending events for files referenced by the response.
@@ -2089,10 +2109,10 @@ export class ToolHandler {
const inResponse: PendingFile[] = [];
const elsewhere: PendingFile[] = [];
for (const p of pending) {
// Substring match against the project-relative POSIX path — that's
// exactly the format both the watcher and every codegraph response
// emit, so a plain includes() is sufficient and avoids regex pitfalls.
if (text.includes(p.path)) inResponse.push(p);
// Project-relative POSIX path — the format both the watcher and every
// codegraph response emit — matched as a whole path, so a pending
// `src/app.ts` isn't "referenced" by a response that shows `src/app.tsx`.
if (mentionsPath(text, p.path)) inResponse.push(p);
else elsewhere.push(p);
}