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';