From bbb8adee6a1a3808e134dbe183448439e2f20724 Mon Sep 17 00:00:00 2001 From: "LINKJOIN\\johnnytsai" Date: Tue, 23 Jun 2026 10:22:50 +0800 Subject: [PATCH] feat(mcp): surface template-view callers in explore blast radius MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blast-radius section listed caller files under a single flat cap (FILE_CAP), so template views (.cshtml/.razor/.vue/.svelte/.astro) — which are cross-layer callers a JS/TS change ripples into — were easily drowned out by the more numerous same-language code callers and vanished into "+N more". A JS helper used by 4 .js files and 22 views would show 0 views, hiding the answer to "which views depend on this?". Split view files into their own slot so they always surface alongside (not competing with) the code callers, each capped independently. Tested in explore-blast-radius.test.ts: a helper with >FILE_CAP code callers plus a .vue view now shows the view in its own dedicated slot. Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/explore-blast-radius.test.ts | 45 ++++++++++++++++++++++++++ src/mcp/tools.ts | 20 ++++++++++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/__tests__/explore-blast-radius.test.ts b/__tests__/explore-blast-radius.test.ts index e85b0738e..1cf02263a 100644 --- a/__tests__/explore-blast-radius.test.ts +++ b/__tests__/explore-blast-radius.test.ts @@ -71,3 +71,48 @@ describe('codegraph_explore — blast radius', () => { expect(text).not.toMatch(/Blast radius[\s\S]*`lonelyLeaf`/); }); }); + +describe('codegraph_explore — template-view callers in blast radius', () => { + let testDir: string; + let cg: CodeGraph; + let handler: ToolHandler; + + beforeEach(async () => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-blast-view-')); + const src = path.join(testDir, 'src'); + fs.mkdirSync(src, { recursive: true }); + + // A shared helper depended on by MANY same-language code callers (> FILE_CAP) + // plus a template view. Under a single flat cap the lone view would be + // pushed into "+N more"; it must instead surface in its own dedicated slot. + fs.writeFileSync(path.join(src, 'helper.ts'), `export function fmt(x: number) { return x + 1; }\n`); + for (const n of ['a', 'b', 'c', 'd', 'e']) { + fs.writeFileSync( + path.join(src, `${n}.ts`), + `import { fmt } from './helper';\nexport function use_${n}() { return fmt(1); }\n`, + ); + } + fs.writeFileSync( + path.join(src, 'Widget.vue'), + `\n\n`, + ); + + cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts', '**/*.vue'], 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('surfaces template-view callers in their own slot, not buried under the code "+N more" cap', async () => { + const res = await handler.execute('codegraph_explore', { query: 'fmt' }); + const text = res.content[0].text; + expect(text).toContain('`fmt`'); + // The .vue view appears in a dedicated "view(s):" slot rather than being + // hidden behind the >FILE_CAP code-caller "+N more". + expect(text).toMatch(/views?:[^\n]*Widget\.vue/); + }); +}); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index b33abbdd4..6bd5de780 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -2193,9 +2193,23 @@ export class ToolHandler { const testFiles = callerFiles.filter((f) => isTestFile(f)); const nonTest = callerFiles.filter((f) => !isTestFile(f)); - const shown = nonTest.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', '); - const more = nonTest.length > FILE_CAP ? ` +${nonTest.length - FILE_CAP} more` : ''; - const where = nonTest.length > 0 ? ` in ${shown}${more}` : ''; + // Template views (.cshtml/.razor/.vue/.svelte/.astro) are cross-layer + // callers — a JS/TS change ripples into the view. Under a single flat cap + // they're easily drowned out by the far more numerous same-language code + // callers and vanish into "+N more" (a JS helper used by 4 .js files and + // 22 views would show 0 views). Surface them in their own slot so the + // "which views depend on this?" answer never gets hidden. + const isView = (f: string) => /\.(cshtml|razor|vue|svelte|astro)$/i.test(f); + const viewFiles = nonTest.filter(isView); + const codeFiles = nonTest.filter((f) => !isView(f)); + + const shownCode = codeFiles.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', '); + const moreCode = codeFiles.length > FILE_CAP ? ` +${codeFiles.length - FILE_CAP} more` : ''; + const codePart = codeFiles.length > 0 ? ` in ${shownCode}${moreCode}` : ''; + const viewPart = viewFiles.length > 0 + ? `; ${viewFiles.length} view${viewFiles.length === 1 ? '' : 's'}: ${viewFiles.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ')}${viewFiles.length > FILE_CAP ? ` +${viewFiles.length - FILE_CAP}` : ''}` + : ''; + const where = codePart + viewPart; const tests = testFiles.length > 0 ? `; tests: ${testFiles.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ')}${testFiles.length > FILE_CAP ? ` +${testFiles.length - FILE_CAP}` : ''}` : '; ⚠️ no covering tests found';