tools.ts 100 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461
  1. /**
  2. * MCP Tool Definitions
  3. *
  4. * Defines the tools exposed by the CodeGraph MCP server.
  5. */
  6. import CodeGraph, { findNearestCodeGraphRoot } from '../index';
  7. import type { Node, Edge, SearchResult, Subgraph, TaskContext, NodeKind } from '../types';
  8. import { createHash } from 'crypto';
  9. import {
  10. constants as fsConstants,
  11. closeSync,
  12. existsSync,
  13. lstatSync,
  14. openSync,
  15. readFileSync,
  16. writeSync,
  17. } from 'fs';
  18. import { clamp, validatePathWithinRoot, validateProjectPath } from '../utils';
  19. import { tmpdir } from 'os';
  20. import { join } from 'path';
  21. /** Maximum output length to prevent context bloat (characters) */
  22. const MAX_OUTPUT_LENGTH = 15000;
  23. /**
  24. * Maximum length for free-form string inputs (query, task, symbol).
  25. * Bounds memory and CPU when a buggy or hostile MCP client sends a
  26. * huge payload — without this an attacker could ship a 100MB string
  27. * and force a full FTS5 scan / OOM the server. 10 000 characters is
  28. * far beyond any realistic legitimate query.
  29. */
  30. const MAX_INPUT_LENGTH = 10_000;
  31. /**
  32. * Maximum length for path-like string inputs (projectPath, path
  33. * filter, glob pattern). Paths beyond a few thousand chars are
  34. * never legitimate and signal abuse or a bug upstream.
  35. */
  36. const MAX_PATH_LENGTH = 4_096;
  37. /**
  38. * Rust path roots that have no file-system equivalent — `crate` is the
  39. * current crate, `super` is the parent module, `self` is the current
  40. * module. Used by `matchesSymbol` to strip these before file-path
  41. * matching so `crate::configurator::stage_apply::run` resolves the
  42. * same as `configurator::stage_apply::run`.
  43. */
  44. const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
  45. /**
  46. * Node kinds that contain other symbols. For these, `codegraph_node` with
  47. * `includeCode=true` returns a structural outline (member names + signatures
  48. * + line numbers) instead of the full body, which for a large class is a
  49. * multi-thousand-character wall of source that bloats the agent's context.
  50. */
  51. const CONTAINER_NODE_KINDS = new Set<NodeKind>([
  52. 'class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module',
  53. ]);
  54. /** Last `::` / `.` / `/`-separated segment of a qualified symbol. */
  55. function lastQualifierPart(symbol: string): string {
  56. const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
  57. return parts[parts.length - 1] ?? symbol;
  58. }
  59. /**
  60. * Calculate the recommended number of codegraph_explore calls based on project size.
  61. * Larger codebases need more exploration calls to cover their surface area,
  62. * but smaller ones should use fewer to avoid unnecessary overhead.
  63. */
  64. export function getExploreBudget(fileCount: number): number {
  65. if (fileCount < 500) return 1;
  66. if (fileCount < 5000) return 2;
  67. if (fileCount < 15000) return 3;
  68. if (fileCount < 25000) return 4;
  69. return 5;
  70. }
  71. /**
  72. * Adaptive output budget for `codegraph_explore`, scaled to project size.
  73. *
  74. * Smaller codebases get a tighter total cap, fewer default files, smaller
  75. * per-file cap, and tighter clustering — so a focused query on a 100-file
  76. * project doesn't dump a whole file's worth of source into the agent's
  77. * context. Larger codebases keep the generous defaults because the
  78. * agent's native discovery cost (grep + find + many Reads) genuinely
  79. * dwarfs a fat explore call at that scale.
  80. *
  81. * Meta-text (relationships map, "additional relevant files" list,
  82. * completeness signal, budget note) is gated off for tiny projects
  83. * where one rich call is the whole story and the extra prose is just
  84. * overhead.
  85. *
  86. * Tier breakpoints mirror `getExploreBudget` so a project sits in the
  87. * same tier across both knobs.
  88. */
  89. export interface ExploreOutputBudget {
  90. /** Hard cap on total output characters. */
  91. maxOutputChars: number;
  92. /** Default `maxFiles` when the caller didn't specify one. */
  93. defaultMaxFiles: number;
  94. /** Cap on contiguous source returned per file (across all its clusters). */
  95. maxCharsPerFile: number;
  96. /** Cluster gap threshold in lines — tighter clustering on small projects. */
  97. gapThreshold: number;
  98. /** Max symbols listed in the per-file header (`#### path — sym(kind), ...`). */
  99. maxSymbolsInFileHeader: number;
  100. /** Max edges shown per relationship kind in the Relationships section. */
  101. maxEdgesPerRelationshipKind: number;
  102. /** Include the "Relationships" section. */
  103. includeRelationships: boolean;
  104. /** Include the "Additional relevant files (not shown)" trailing list. */
  105. includeAdditionalFiles: boolean;
  106. /** Include the "Complete source code is included above…" reminder. */
  107. includeCompletenessSignal: boolean;
  108. /** Include the explore-budget reminder at the end. */
  109. includeBudgetNote: boolean;
  110. }
  111. export function getExploreOutputBudget(fileCount: number): ExploreOutputBudget {
  112. if (fileCount < 500) {
  113. return {
  114. maxOutputChars: 18000,
  115. defaultMaxFiles: 5,
  116. maxCharsPerFile: 3800,
  117. gapThreshold: 8,
  118. maxSymbolsInFileHeader: 6,
  119. maxEdgesPerRelationshipKind: 6,
  120. includeRelationships: true,
  121. includeAdditionalFiles: false,
  122. includeCompletenessSignal: false,
  123. includeBudgetNote: false,
  124. };
  125. }
  126. if (fileCount < 5000) {
  127. return {
  128. // Sized so ONE explore can cover a flow that centers on a god-file (e.g.
  129. // excalidraw's 415 KB App.tsx): the previous 2500/file returned <1% of such
  130. // a file, forcing the agent to Read it anyway. Per-file must also stay ≥ the
  131. // smaller <500 tier (3800) — the old 2500 was non-monotonic. Tokens are
  132. // cheap relative to a 5–10 Read round-trip spiral; favor sufficiency.
  133. maxOutputChars: 28000,
  134. defaultMaxFiles: 10,
  135. maxCharsPerFile: 6500,
  136. gapThreshold: 12,
  137. maxSymbolsInFileHeader: 10,
  138. maxEdgesPerRelationshipKind: 10,
  139. includeRelationships: true,
  140. includeAdditionalFiles: true,
  141. includeCompletenessSignal: true,
  142. includeBudgetNote: true,
  143. };
  144. }
  145. if (fileCount < 15000) {
  146. return {
  147. maxOutputChars: 35000,
  148. defaultMaxFiles: 12,
  149. maxCharsPerFile: 7000,
  150. gapThreshold: 15,
  151. maxSymbolsInFileHeader: 15,
  152. maxEdgesPerRelationshipKind: 15,
  153. includeRelationships: true,
  154. includeAdditionalFiles: true,
  155. includeCompletenessSignal: true,
  156. includeBudgetNote: true,
  157. };
  158. }
  159. return {
  160. maxOutputChars: 38000,
  161. defaultMaxFiles: 14,
  162. maxCharsPerFile: 7000,
  163. gapThreshold: 15,
  164. maxSymbolsInFileHeader: 15,
  165. maxEdgesPerRelationshipKind: 15,
  166. includeRelationships: true,
  167. includeAdditionalFiles: true,
  168. includeCompletenessSignal: true,
  169. includeBudgetNote: true,
  170. };
  171. }
  172. /**
  173. * Whether `codegraph_explore` should prefix source lines with their line
  174. * numbers (cat -n style: `<num>\t<code>`).
  175. *
  176. * Line numbers let the agent cite `file:line` straight from the explore
  177. * payload instead of re-Reading the file just to find a line number — the
  178. * dominant residual cost on precise-tracing questions (#185 follow-up).
  179. *
  180. * Defaults ON. Set `CODEGRAPH_EXPLORE_LINENUMS=0` to disable (used by the
  181. * A/B harness to measure the payload-cost vs. read-savings tradeoff).
  182. */
  183. function exploreLineNumbersEnabled(): boolean {
  184. return process.env.CODEGRAPH_EXPLORE_LINENUMS !== '0';
  185. }
  186. /**
  187. * Prefix each line of a source slice with its 1-based line number, matching
  188. * the Read tool's `cat -n` convention (number + tab) so the agent treats it
  189. * the same way it treats Read output.
  190. *
  191. * @param slice contiguous source text (already extracted from the file)
  192. * @param firstLineNumber the 1-based line number of the slice's first line
  193. */
  194. function numberSourceLines(slice: string, firstLineNumber: number): string {
  195. const out: string[] = [];
  196. const split = slice.split('\n');
  197. for (let i = 0; i < split.length; i++) {
  198. out.push(`${firstLineNumber + i}\t${split[i]}`);
  199. }
  200. return out.join('\n');
  201. }
  202. /**
  203. * Mark a Claude session as having consulted MCP tools.
  204. * This enables Grep/Glob/Bash commands that would otherwise be blocked.
  205. *
  206. * Why the explicit openSync + O_NOFOLLOW dance instead of plain writeFileSync:
  207. * tmpdir() is world-writable on Linux (mode 1777), so on a shared multi-user
  208. * machine any other local user can pre-create `codegraph-consulted-<hash>` as
  209. * a symlink pointing at a file the victim owns. The old `writeFileSync` would
  210. * happily follow that link and overwrite the target's contents with the ISO
  211. * timestamp string (CWE-59). The session-id hash provides the predictability
  212. * gate, but it's defense-in-depth: if a session id ever surfaces in logs,
  213. * argv, or telemetry the attack becomes trivial, and the right fix is to not
  214. * follow links from /tmp paths in the first place.
  215. */
  216. function markSessionConsulted(sessionId: string): void {
  217. try {
  218. const hash = createHash('md5').update(sessionId).digest('hex').slice(0, 16);
  219. const markerPath = join(tmpdir(), `codegraph-consulted-${hash}`);
  220. // Refuse to follow a pre-planted symlink at the marker path (CWE-59).
  221. // O_NOFOLLOW (below) is the atomic, TOCTOU-free guard on POSIX, but it is
  222. // `undefined` on Windows (libuv ignores it), so the bitwise-OR silently
  223. // drops it and openSync would follow the link. This lstat check closes that
  224. // gap cross-platform; ENOENT (path is free) falls through to create it.
  225. try {
  226. if (lstatSync(markerPath).isSymbolicLink()) return;
  227. } catch {
  228. // No existing entry (or stat failed) — nothing to refuse; proceed.
  229. }
  230. // O_NOFOLLOW makes openSync throw ELOOP if markerPath is already a symlink.
  231. // O_CREAT + O_TRUNC keep the original "create-or-overwrite" semantics, and
  232. // mode 0o600 prevents readback by other local users (the marker payload is
  233. // benign, but narrowing the exposure costs nothing).
  234. const flags = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | fsConstants.O_NOFOLLOW;
  235. const fd = openSync(markerPath, flags, 0o600);
  236. try {
  237. writeSync(fd, new Date().toISOString());
  238. } finally {
  239. closeSync(fd);
  240. }
  241. } catch {
  242. // Silently fail - don't break MCP on marker write failure. ELOOP from a
  243. // planted symlink lands here too, which is the intended behavior: refuse
  244. // to write rather than overwrite an attacker-chosen target.
  245. }
  246. }
  247. /**
  248. * MCP Tool definition
  249. */
  250. export interface ToolDefinition {
  251. name: string;
  252. description: string;
  253. inputSchema: {
  254. type: 'object';
  255. properties: Record<string, PropertySchema>;
  256. required?: string[];
  257. };
  258. }
  259. interface PropertySchema {
  260. type: string;
  261. description: string;
  262. enum?: string[];
  263. default?: unknown;
  264. }
  265. /**
  266. * Tool execution result
  267. */
  268. export interface ToolResult {
  269. content: Array<{
  270. type: 'text';
  271. text: string;
  272. }>;
  273. isError?: boolean;
  274. }
  275. /**
  276. * Common projectPath property for cross-project queries
  277. */
  278. const projectPathProperty: PropertySchema = {
  279. type: 'string',
  280. description: 'Path to a different project with .codegraph/ initialized. If omitted, uses current project. Use this to query other codebases.',
  281. };
  282. /**
  283. * All CodeGraph MCP tools
  284. *
  285. * Designed for minimal context usage - use codegraph_context as the primary tool,
  286. * and only use other tools for targeted follow-up queries.
  287. *
  288. * All tools support cross-project queries via the optional `projectPath` parameter.
  289. */
  290. export const tools: ToolDefinition[] = [
  291. {
  292. name: 'codegraph_search',
  293. description: 'Quick symbol search by name. Returns locations only (no code). Use codegraph_context instead for comprehensive task context.',
  294. inputSchema: {
  295. type: 'object',
  296. properties: {
  297. query: {
  298. type: 'string',
  299. description: 'Symbol name or partial name (e.g., "auth", "signIn", "UserService")',
  300. },
  301. kind: {
  302. type: 'string',
  303. description: 'Filter by node kind',
  304. enum: ['function', 'method', 'class', 'interface', 'type', 'variable', 'route', 'component'],
  305. },
  306. limit: {
  307. type: 'number',
  308. description: 'Maximum results (default: 10)',
  309. default: 10,
  310. },
  311. projectPath: projectPathProperty,
  312. },
  313. required: ['query'],
  314. },
  315. },
  316. {
  317. name: 'codegraph_context',
  318. description: 'PRIMARY TOOL — call this FIRST for any "how does X work", architecture, feature, or bug-context question. Composes search + node + callers + callees and returns entry points, related symbols, and key code in ONE call — usually enough to answer with no further search/Read/Grep. Prefer this over chaining codegraph_search + codegraph_node, and over codegraph_explore. NOTE: provides CODE context, not product requirements; for new features still clarify UX/edge cases with the user.',
  319. inputSchema: {
  320. type: 'object',
  321. properties: {
  322. task: {
  323. type: 'string',
  324. description: 'Description of the task, bug, or feature to build context for',
  325. },
  326. maxNodes: {
  327. type: 'number',
  328. description: 'Maximum symbols to include (default: 20)',
  329. default: 20,
  330. },
  331. includeCode: {
  332. type: 'boolean',
  333. description: 'Include code snippets for key symbols (default: true)',
  334. default: true,
  335. },
  336. projectPath: projectPathProperty,
  337. },
  338. required: ['task'],
  339. },
  340. },
  341. {
  342. name: 'codegraph_callers',
  343. description: 'Find all functions/methods that call a specific symbol. Useful for understanding usage patterns and impact of changes.',
  344. inputSchema: {
  345. type: 'object',
  346. properties: {
  347. symbol: {
  348. type: 'string',
  349. description: 'Name of the function, method, or class to find callers for',
  350. },
  351. limit: {
  352. type: 'number',
  353. description: 'Maximum number of callers to return (default: 20)',
  354. default: 20,
  355. },
  356. projectPath: projectPathProperty,
  357. },
  358. required: ['symbol'],
  359. },
  360. },
  361. {
  362. name: 'codegraph_callees',
  363. description: 'Find all functions/methods that a specific symbol calls. Useful for understanding dependencies and code flow.',
  364. inputSchema: {
  365. type: 'object',
  366. properties: {
  367. symbol: {
  368. type: 'string',
  369. description: 'Name of the function, method, or class to find callees for',
  370. },
  371. limit: {
  372. type: 'number',
  373. description: 'Maximum number of callees to return (default: 20)',
  374. default: 20,
  375. },
  376. projectPath: projectPathProperty,
  377. },
  378. required: ['symbol'],
  379. },
  380. },
  381. {
  382. name: 'codegraph_impact',
  383. description: 'Analyze the impact radius of changing a symbol. Shows what code could be affected by modifications.',
  384. inputSchema: {
  385. type: 'object',
  386. properties: {
  387. symbol: {
  388. type: 'string',
  389. description: 'Name of the symbol to analyze impact for',
  390. },
  391. depth: {
  392. type: 'number',
  393. description: 'How many levels of dependencies to traverse (default: 2)',
  394. default: 2,
  395. },
  396. projectPath: projectPathProperty,
  397. },
  398. required: ['symbol'],
  399. },
  400. },
  401. {
  402. name: 'codegraph_node',
  403. description: 'Get ONE symbol\'s details (location, signature, docstring) PLUS its TRAIL — what it calls and what calls it, each with file:line. Pass includeCode=true for source (functions return their body; containers return a member outline). Use this to WALK the call graph hop-by-hop — node a symbol, then node one of its trail entries — the structural, no-Read way to follow "what calls/triggers/handles X" across files. For a broad first overview of many symbols at once use codegraph_explore; use node to drill along a specific path from there. (If a trail is empty on a non-leaf, that hop is likely dynamic dispatch — read just that line.) Source returned with includeCode is the verbatim live file content — identical to Read.',
  404. inputSchema: {
  405. type: 'object',
  406. properties: {
  407. symbol: {
  408. type: 'string',
  409. description: 'Name of the symbol to get details for',
  410. },
  411. includeCode: {
  412. type: 'boolean',
  413. description: 'Include full source code (default: false to minimize context)',
  414. default: false,
  415. },
  416. projectPath: projectPathProperty,
  417. },
  418. required: ['symbol'],
  419. },
  420. },
  421. {
  422. name: 'codegraph_explore',
  423. description: 'Returns source for SEVERAL related symbols grouped by file, plus a relationship map, in ONE capped call. This is the efficient way to inspect many related symbols at once — strongly prefer it over a series of codegraph_node or Read calls (each separate call re-reads the whole context, so 8 node calls cost far more than 1 explore). Use it after codegraph_context when you need to see the actual source of several symbols. Query with specific symbol/file/code terms, NOT natural-language sentences — run codegraph_search first to find names. Bad: "how are agent prompts loaded and passed to the CLI". Good: "renderStaticScene drawElementOnCanvas ShapeCache renderElement.ts". The code it returns is the VERBATIM live file source (byte-for-byte identical to Read), line-numbered — not a summary; treat files it shows as already Read, no need to re-open them.',
  424. inputSchema: {
  425. type: 'object',
  426. properties: {
  427. query: {
  428. type: 'string',
  429. description: 'Symbol names, file names, or short code terms to explore (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts"). Use codegraph_search first to find relevant names.',
  430. },
  431. maxFiles: {
  432. type: 'number',
  433. description: 'Maximum number of files to include source code from (default: 12)',
  434. default: 12,
  435. },
  436. projectPath: projectPathProperty,
  437. },
  438. required: ['query'],
  439. },
  440. },
  441. {
  442. name: 'codegraph_status',
  443. description: 'Get the status of the CodeGraph index, including statistics about indexed files, nodes, and edges.',
  444. inputSchema: {
  445. type: 'object',
  446. properties: {
  447. projectPath: projectPathProperty,
  448. },
  449. },
  450. },
  451. {
  452. name: 'codegraph_files',
  453. description: 'REQUIRED for file/folder exploration. Get the project file structure from the CodeGraph index. Returns a tree view of all indexed files with metadata (language, symbol count). Much faster than Glob/filesystem scanning. Use this FIRST when exploring project structure, finding files, or understanding codebase organization.',
  454. inputSchema: {
  455. type: 'object',
  456. properties: {
  457. path: {
  458. type: 'string',
  459. description: 'Filter to files under this directory path (e.g., "src/components"). Returns all files if not specified.',
  460. },
  461. pattern: {
  462. type: 'string',
  463. description: 'Filter files matching this glob pattern (e.g., "*.tsx", "**/*.test.ts")',
  464. },
  465. format: {
  466. type: 'string',
  467. description: 'Output format: "tree" (hierarchical, default), "flat" (simple list), "grouped" (by language)',
  468. enum: ['tree', 'flat', 'grouped'],
  469. default: 'tree',
  470. },
  471. includeMetadata: {
  472. type: 'boolean',
  473. description: 'Include file metadata like language and symbol count (default: true)',
  474. default: true,
  475. },
  476. maxDepth: {
  477. type: 'number',
  478. description: 'Maximum directory depth to show (default: unlimited)',
  479. },
  480. projectPath: projectPathProperty,
  481. },
  482. },
  483. },
  484. {
  485. name: 'codegraph_trace',
  486. description: 'Trace the CALL PATH between two symbols — "how does <from> reach/become <to>?" Returns the chain of functions from one to the other (each hop with file:line and its body inlined, plus the outgoing calls of the destination itself) in ONE call. This is something grep/Read structurally cannot do: there is no text pattern for "the path from A to B". Ideal for flow questions — how an update triggers a render, how a request reaches a handler, how a QuerySet becomes SQL. If no static path exists the chain likely breaks at dynamic dispatch (callbacks/descriptors/metaclasses); the tool says where and points you to codegraph_node to bridge it.',
  487. inputSchema: {
  488. type: 'object',
  489. properties: {
  490. from: {
  491. type: 'string',
  492. description: 'Symbol the flow starts at (e.g., "QuerySet", "handleRequest", "mutateElement")',
  493. },
  494. to: {
  495. type: 'string',
  496. description: 'Symbol the flow should reach (e.g., "execute_sql", "render", "setState")',
  497. },
  498. projectPath: projectPathProperty,
  499. },
  500. required: ['from', 'to'],
  501. },
  502. },
  503. ];
  504. /**
  505. * Tool handler that executes tools against a CodeGraph instance
  506. *
  507. * Supports cross-project queries via the projectPath parameter.
  508. * Other projects are opened on-demand and cached for performance.
  509. */
  510. export class ToolHandler {
  511. // Cache of opened CodeGraph instances for cross-project queries
  512. private projectCache: Map<string, CodeGraph> = new Map();
  513. // The directory the server last searched for a default project. Surfaced in
  514. // the "not initialized" error so users can see why detection missed.
  515. private defaultProjectHint: string | null = null;
  516. constructor(private cg: CodeGraph | null) {}
  517. /**
  518. * Update the default CodeGraph instance (e.g. after lazy initialization)
  519. */
  520. setDefaultCodeGraph(cg: CodeGraph): void {
  521. this.cg = cg;
  522. }
  523. /**
  524. * Record the directory the server tried to resolve the default project from.
  525. * Used only to make the "no default project" error actionable.
  526. */
  527. setDefaultProjectHint(searchedPath: string): void {
  528. this.defaultProjectHint = searchedPath;
  529. }
  530. /**
  531. * Whether a default CodeGraph instance is available
  532. */
  533. hasDefaultCodeGraph(): boolean {
  534. return this.cg !== null;
  535. }
  536. /**
  537. * Optional allowlist of exposed tools, parsed from the CODEGRAPH_MCP_TOOLS
  538. * env var (comma-separated short names, e.g. "trace,search,node,context").
  539. * Unset/empty → every tool is exposed. Lets an operator (or an A/B harness)
  540. * trim the tool surface without rebuilding the client config; the ablated
  541. * tool is then truly absent from ListTools rather than merely denied on call.
  542. * Matching is on the short form, so "trace" and "codegraph_trace" both work.
  543. */
  544. private toolAllowlist(): Set<string> | null {
  545. const raw = process.env.CODEGRAPH_MCP_TOOLS;
  546. if (!raw || !raw.trim()) return null;
  547. const short = (s: string) => s.trim().replace(/^codegraph_/, '');
  548. const set = new Set(raw.split(',').map(short).filter(Boolean));
  549. return set.size ? set : null;
  550. }
  551. /** Whether a tool name passes the CODEGRAPH_MCP_TOOLS allowlist (if any). */
  552. private isToolAllowed(name: string): boolean {
  553. const allow = this.toolAllowlist();
  554. return !allow || allow.has(name.replace(/^codegraph_/, ''));
  555. }
  556. /**
  557. * Get tool definitions with dynamic descriptions based on project size.
  558. * The codegraph_explore tool description includes a budget recommendation
  559. * scaled to the number of indexed files. Honors the CODEGRAPH_MCP_TOOLS
  560. * allowlist so a trimmed surface is reflected in ListTools.
  561. */
  562. getTools(): ToolDefinition[] {
  563. const allow = this.toolAllowlist();
  564. const visible = allow
  565. ? tools.filter(t => allow.has(t.name.replace(/^codegraph_/, '')))
  566. : tools;
  567. if (!this.cg) return visible;
  568. try {
  569. const stats = this.cg.getStats();
  570. const budget = getExploreBudget(stats.fileCount);
  571. return visible.map(tool => {
  572. if (tool.name === 'codegraph_explore') {
  573. return {
  574. ...tool,
  575. description: `${tool.description} Budget: make at most ${budget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).`,
  576. };
  577. }
  578. return tool;
  579. });
  580. } catch {
  581. return visible;
  582. }
  583. }
  584. /**
  585. * Get CodeGraph instance for a project
  586. *
  587. * If projectPath is provided, opens that project's CodeGraph (cached).
  588. * Otherwise returns the default CodeGraph instance.
  589. *
  590. * Walks up parent directories to find the nearest .codegraph/ folder,
  591. * similar to how git finds .git/ directories.
  592. */
  593. private getCodeGraph(projectPath?: string): CodeGraph {
  594. if (!projectPath) {
  595. if (!this.cg) {
  596. const searched = this.defaultProjectHint ?? process.cwd();
  597. throw new Error(
  598. 'No CodeGraph project is loaded for this session.\n' +
  599. `Searched for a .codegraph/ directory starting from: ${searched}\n` +
  600. 'The index is likely fine — this is a working-directory detection issue: ' +
  601. "the MCP client launched the server outside your project and didn't report the " +
  602. 'workspace root. Fix it either way:\n' +
  603. ' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project"\n' +
  604. ' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]'
  605. );
  606. }
  607. return this.cg;
  608. }
  609. // Check cache first (using original path as key)
  610. if (this.projectCache.has(projectPath)) {
  611. return this.projectCache.get(projectPath)!;
  612. }
  613. // Reject sensitive system directories before opening. Only validate a
  614. // path that actually exists — a nested or not-yet-created sub-path of a
  615. // real project must still be allowed to resolve UP to its .codegraph/
  616. // root below (issue #238), so we don't run the existence-checking
  617. // validator on paths that are meant to walk up.
  618. if (existsSync(projectPath)) {
  619. const pathError = validateProjectPath(projectPath);
  620. if (pathError) {
  621. throw new Error(pathError);
  622. }
  623. }
  624. // Walk up parent directories to find nearest .codegraph/
  625. const resolvedRoot = findNearestCodeGraphRoot(projectPath);
  626. if (!resolvedRoot) {
  627. throw new Error(`CodeGraph not initialized in ${projectPath}. Run 'codegraph init' in that project first.`);
  628. }
  629. // If the path resolves to the default project, reuse the already-open
  630. // default instance rather than opening a SECOND connection to the same DB.
  631. // A duplicate connection serializes reads against the watcher's auto-sync
  632. // writes; on the wasm backend (no WAL) that surfaces as intermittent
  633. // "database is locked" on concurrent tool calls. See issue #238. Deliberately
  634. // not cached under projectPath — the server owns and closes the default
  635. // instance, so routing it through projectCache.closeAll() would double-close it.
  636. if (this.cg && this.cg.getProjectRoot() === resolvedRoot) {
  637. return this.cg;
  638. }
  639. // Check if we already have this resolved root cached (different path, same project)
  640. if (this.projectCache.has(resolvedRoot)) {
  641. const cg = this.projectCache.get(resolvedRoot)!;
  642. // Cache under original path too for faster future lookups
  643. this.projectCache.set(projectPath, cg);
  644. return cg;
  645. }
  646. // Open and cache under both paths
  647. const cg = CodeGraph.openSync(resolvedRoot);
  648. this.projectCache.set(resolvedRoot, cg);
  649. if (projectPath !== resolvedRoot) {
  650. this.projectCache.set(projectPath, cg);
  651. }
  652. return cg;
  653. }
  654. /**
  655. * Close all cached project connections
  656. */
  657. closeAll(): void {
  658. for (const cg of this.projectCache.values()) {
  659. cg.close();
  660. }
  661. this.projectCache.clear();
  662. }
  663. /**
  664. * Validate that a value is a non-empty string within length bounds.
  665. *
  666. * The `maxLength` cap protects against MCP clients that ship huge
  667. * payloads (10MB+ query strings either by accident or maliciously).
  668. * Without this, a single oversized input can pin the FTS5 index or
  669. * exhaust memory before any real work runs.
  670. */
  671. private validateString(
  672. value: unknown,
  673. name: string,
  674. maxLength: number = MAX_INPUT_LENGTH
  675. ): string | ToolResult {
  676. if (typeof value !== 'string' || value.length === 0) {
  677. return this.errorResult(`${name} must be a non-empty string`);
  678. }
  679. if (value.length > maxLength) {
  680. return this.errorResult(
  681. `${name} exceeds maximum length of ${maxLength} characters (got ${value.length})`
  682. );
  683. }
  684. return value;
  685. }
  686. /**
  687. * Validate an optional path-like string input. Returns the value if
  688. * valid (or undefined), or a ToolResult with the error.
  689. */
  690. private validateOptionalPath(
  691. value: unknown,
  692. name: string
  693. ): string | undefined | ToolResult {
  694. if (value === undefined || value === null) return undefined;
  695. if (typeof value !== 'string') {
  696. return this.errorResult(`${name} must be a string`);
  697. }
  698. if (value.length > MAX_PATH_LENGTH) {
  699. return this.errorResult(
  700. `${name} exceeds maximum length of ${MAX_PATH_LENGTH} characters (got ${value.length})`
  701. );
  702. }
  703. return value;
  704. }
  705. /**
  706. * Execute a tool by name
  707. */
  708. async execute(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
  709. try {
  710. // Honor the optional tool allowlist (CODEGRAPH_MCP_TOOLS): a trimmed
  711. // surface rejects ablated tools defensively even if a client cached them.
  712. if (!this.isToolAllowed(toolName)) {
  713. return this.errorResult(`Tool ${toolName} is disabled via CODEGRAPH_MCP_TOOLS`);
  714. }
  715. // Cross-cutting input validation. All tools accept an optional
  716. // `projectPath` and most accept either `query`, `task`, or
  717. // `symbol` — bound their lengths centrally so individual handlers
  718. // can stay focused on tool-specific logic.
  719. const pathCheck = this.validateOptionalPath(args.projectPath, 'projectPath');
  720. if (typeof pathCheck === 'object' && pathCheck !== undefined) {
  721. return pathCheck;
  722. }
  723. // The `path` and `pattern` properties used by codegraph_files are
  724. // also path-shaped — apply the same cap.
  725. if (args.path !== undefined) {
  726. const check = this.validateOptionalPath(args.path, 'path');
  727. if (typeof check === 'object' && check !== undefined) return check;
  728. }
  729. if (args.pattern !== undefined) {
  730. const check = this.validateOptionalPath(args.pattern, 'pattern');
  731. if (typeof check === 'object' && check !== undefined) return check;
  732. }
  733. switch (toolName) {
  734. case 'codegraph_search':
  735. return await this.handleSearch(args);
  736. case 'codegraph_context':
  737. return await this.handleContext(args);
  738. case 'codegraph_callers':
  739. return await this.handleCallers(args);
  740. case 'codegraph_callees':
  741. return await this.handleCallees(args);
  742. case 'codegraph_impact':
  743. return await this.handleImpact(args);
  744. case 'codegraph_explore':
  745. return await this.handleExplore(args);
  746. case 'codegraph_node':
  747. return await this.handleNode(args);
  748. case 'codegraph_status':
  749. return await this.handleStatus(args);
  750. case 'codegraph_files':
  751. return await this.handleFiles(args);
  752. case 'codegraph_trace':
  753. return await this.handleTrace(args);
  754. default:
  755. return this.errorResult(`Unknown tool: ${toolName}`);
  756. }
  757. } catch (err) {
  758. return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}`);
  759. }
  760. }
  761. /**
  762. * Handle codegraph_search
  763. */
  764. private async handleSearch(args: Record<string, unknown>): Promise<ToolResult> {
  765. const query = this.validateString(args.query, 'query');
  766. if (typeof query !== 'string') return query;
  767. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  768. const kind = args.kind as string | undefined;
  769. const rawLimit = Number(args.limit) || 10;
  770. const limit = clamp(rawLimit, 1, 100);
  771. const results = cg.searchNodes(query, {
  772. limit,
  773. kinds: kind ? [kind as NodeKind] : undefined,
  774. });
  775. if (results.length === 0) {
  776. return this.textResult(`No results found for "${query}"`);
  777. }
  778. const formatted = this.formatSearchResults(results);
  779. return this.textResult(this.truncateOutput(formatted));
  780. }
  781. /**
  782. * Handle codegraph_context
  783. */
  784. private async handleContext(args: Record<string, unknown>): Promise<ToolResult> {
  785. const task = this.validateString(args.task, 'task');
  786. if (typeof task !== 'string') return task;
  787. // Mark session as consulted (enables Grep/Glob/Bash)
  788. const sessionId = process.env.CLAUDE_SESSION_ID;
  789. if (sessionId) {
  790. markSessionConsulted(sessionId);
  791. }
  792. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  793. const maxNodes = (args.maxNodes as number) || 20;
  794. const includeCode = args.includeCode !== false;
  795. const context = await cg.buildContext(task, {
  796. maxNodes,
  797. includeCode,
  798. format: 'markdown',
  799. });
  800. // Detect if this looks like a feature request (vs bug fix or exploration)
  801. const isFeatureQuery = this.looksLikeFeatureRequest(task);
  802. const reminder = isFeatureQuery
  803. ? '\n\n⚠️ **Ask user:** UX preferences, edge cases, acceptance criteria'
  804. : '';
  805. // buildContext returns string when format is 'markdown'
  806. if (typeof context === 'string') {
  807. return this.textResult(this.truncateOutput(context + reminder));
  808. }
  809. // If it returns TaskContext, format it
  810. return this.textResult(this.truncateOutput(this.formatTaskContext(context) + reminder));
  811. }
  812. /**
  813. * Heuristic to detect if a query looks like a feature request
  814. */
  815. private looksLikeFeatureRequest(task: string): boolean {
  816. const featureKeywords = [
  817. 'add', 'create', 'implement', 'build', 'enable', 'allow',
  818. 'new feature', 'support for', 'ability to', 'want to',
  819. 'should be able', 'need to add', 'swap', 'edit', 'modify'
  820. ];
  821. const bugKeywords = [
  822. 'fix', 'bug', 'error', 'broken', 'crash', 'issue', 'problem',
  823. 'not working', 'fails', 'undefined', 'null'
  824. ];
  825. const explorationKeywords = [
  826. 'how does', 'where is', 'what is', 'find', 'show me',
  827. 'explain', 'understand', 'explore'
  828. ];
  829. const lowerTask = task.toLowerCase();
  830. // If it's clearly a bug or exploration, not a feature
  831. if (bugKeywords.some(k => lowerTask.includes(k))) return false;
  832. if (explorationKeywords.some(k => lowerTask.includes(k))) return false;
  833. // If it matches feature keywords, it's likely a feature request
  834. return featureKeywords.some(k => lowerTask.includes(k));
  835. }
  836. /**
  837. * Handle codegraph_callers
  838. */
  839. private async handleCallers(args: Record<string, unknown>): Promise<ToolResult> {
  840. const symbol = this.validateString(args.symbol, 'symbol');
  841. if (typeof symbol !== 'string') return symbol;
  842. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  843. const limit = clamp((args.limit as number) || 20, 1, 100);
  844. const allMatches = this.findAllSymbols(cg, symbol);
  845. if (allMatches.nodes.length === 0) {
  846. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  847. }
  848. // Aggregate callers across all matching symbols
  849. const seen = new Set<string>();
  850. const allCallers: Node[] = [];
  851. for (const node of allMatches.nodes) {
  852. for (const c of cg.getCallers(node.id)) {
  853. if (!seen.has(c.node.id)) {
  854. seen.add(c.node.id);
  855. allCallers.push(c.node);
  856. }
  857. }
  858. }
  859. if (allCallers.length === 0) {
  860. return this.textResult(`No callers found for "${symbol}"${allMatches.note}`);
  861. }
  862. const formatted = this.formatNodeList(allCallers.slice(0, limit), `Callers of ${symbol}`) + allMatches.note;
  863. return this.textResult(this.truncateOutput(formatted));
  864. }
  865. /**
  866. * Handle codegraph_callees
  867. */
  868. private async handleCallees(args: Record<string, unknown>): Promise<ToolResult> {
  869. const symbol = this.validateString(args.symbol, 'symbol');
  870. if (typeof symbol !== 'string') return symbol;
  871. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  872. const limit = clamp((args.limit as number) || 20, 1, 100);
  873. const allMatches = this.findAllSymbols(cg, symbol);
  874. if (allMatches.nodes.length === 0) {
  875. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  876. }
  877. // Aggregate callees across all matching symbols
  878. const seen = new Set<string>();
  879. const allCallees: Node[] = [];
  880. for (const node of allMatches.nodes) {
  881. for (const c of cg.getCallees(node.id)) {
  882. if (!seen.has(c.node.id)) {
  883. seen.add(c.node.id);
  884. allCallees.push(c.node);
  885. }
  886. }
  887. }
  888. if (allCallees.length === 0) {
  889. return this.textResult(`No callees found for "${symbol}"${allMatches.note}`);
  890. }
  891. const formatted = this.formatNodeList(allCallees.slice(0, limit), `Callees of ${symbol}`) + allMatches.note;
  892. return this.textResult(this.truncateOutput(formatted));
  893. }
  894. /**
  895. * Handle codegraph_impact
  896. */
  897. private async handleImpact(args: Record<string, unknown>): Promise<ToolResult> {
  898. const symbol = this.validateString(args.symbol, 'symbol');
  899. if (typeof symbol !== 'string') return symbol;
  900. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  901. const depth = clamp((args.depth as number) || 2, 1, 10);
  902. const allMatches = this.findAllSymbols(cg, symbol);
  903. if (allMatches.nodes.length === 0) {
  904. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  905. }
  906. // Aggregate impact across all matching symbols
  907. const mergedNodes = new Map<string, Node>();
  908. const mergedEdges: Edge[] = [];
  909. const seenEdges = new Set<string>();
  910. for (const node of allMatches.nodes) {
  911. const impact = cg.getImpactRadius(node.id, depth);
  912. for (const [id, n] of impact.nodes) {
  913. mergedNodes.set(id, n);
  914. }
  915. for (const e of impact.edges) {
  916. const key = `${e.source}->${e.target}:${e.kind}`;
  917. if (!seenEdges.has(key)) {
  918. seenEdges.add(key);
  919. mergedEdges.push(e);
  920. }
  921. }
  922. }
  923. const mergedImpact = {
  924. nodes: mergedNodes,
  925. edges: mergedEdges,
  926. roots: allMatches.nodes.map(n => n.id),
  927. };
  928. const formatted = this.formatImpact(symbol, mergedImpact) + allMatches.note;
  929. return this.textResult(this.truncateOutput(formatted));
  930. }
  931. /**
  932. * Handle codegraph_trace — shortest CALL PATH between two symbols.
  933. *
  934. * Exposes GraphTraverser.findPath: the chain of functions from `from` to `to`,
  935. * each hop annotated with file:line and the call-site line. This is the
  936. * capability grep/Read structurally cannot provide. When no static path
  937. * exists, the chain has almost certainly broken at dynamic dispatch
  938. * (callbacks, descriptors, metaclasses) — we say so and surface the start
  939. * symbol's outgoing calls so the agent bridges the one missing hop with
  940. * codegraph_node rather than blindly reading.
  941. */
  942. private async handleTrace(args: Record<string, unknown>): Promise<ToolResult> {
  943. const from = this.validateString(args.from, 'from');
  944. if (typeof from !== 'string') return from;
  945. const to = this.validateString(args.to, 'to');
  946. if (typeof to !== 'string') return to;
  947. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  948. const fromMatches = this.findAllSymbols(cg, from);
  949. if (fromMatches.nodes.length === 0) return this.textResult(`Symbol "${from}" not found in the codebase`);
  950. const toMatches = this.findAllSymbols(cg, to);
  951. if (toMatches.nodes.length === 0) return this.textResult(`Symbol "${to}" not found in the codebase`);
  952. // Trace along call edges only — a true call path. Names can map to several
  953. // nodes, so try a few from×to candidate pairs until a usable path turns up.
  954. //
  955. // MAX_HOPS guard: a BFS shortest path longer than this on a dense call graph
  956. // is almost always a spurious wander through unrelated code (django's
  957. // `_fetch_all → … → execute_sql` BFS detours through prefetch/filter), not
  958. // the real execution flow — and a confident-but-wrong 15-hop trace is worse
  959. // than none. Over-cap paths are rejected and reported as "no direct path"
  960. // (which, on real code, means the flow breaks at dynamic dispatch).
  961. const edgeKinds: Edge['kind'][] = ['calls'];
  962. const MAX_HOPS = 7;
  963. const fromTry = fromMatches.nodes.slice(0, 3);
  964. const toTry = toMatches.nodes.slice(0, 3);
  965. let path: Array<{ node: Node; edge: Edge | null }> | null = null;
  966. let overCap: Array<{ node: Node; edge: Edge | null }> | null = null;
  967. for (const f of fromTry) {
  968. for (const t of toTry) {
  969. const p = cg.findPath(f.id, t.id, edgeKinds);
  970. if (!p || p.length <= 1) continue;
  971. if (p.length <= MAX_HOPS) { path = p; break; }
  972. if (!overCap || p.length < overCap.length) overCap = p;
  973. }
  974. if (path) break;
  975. }
  976. if (!path) {
  977. // No static path — almost always a dynamic-dispatch break. Surface the
  978. // start symbol's outgoing calls so the agent can bridge the gap.
  979. const start = fromTry[0]!;
  980. const callees = cg.getCallees(start.id).slice(0, 10)
  981. .map(c => `${c.node.name} (${c.node.filePath}:${c.node.startLine})`);
  982. const lines = [
  983. `No direct call path from "${from}" to "${to}".`,
  984. '',
  985. (overCap
  986. ? `(Only a ${overCap.length}-hop indirect chain connects them — almost certainly a BFS wander through unrelated code, not the real flow.) `
  987. : '') +
  988. 'The direct chain most likely breaks at **dynamic dispatch** (a callback, descriptor, ' +
  989. 'metaclass, or attribute-as-callable) that static parsing cannot resolve into an edge. ' +
  990. `Inspect \`${start.name}\` (${start.filePath}:${start.startLine}) with codegraph_node ` +
  991. '(includeCode=true) — its body usually shows the dynamic call to follow next.',
  992. ];
  993. if (callees.length > 0) {
  994. lines.push('', `**${start.name} statically calls:** ${callees.join(', ')}`);
  995. }
  996. return this.textResult(lines.join('\n') + fromMatches.note + toMatches.note);
  997. }
  998. const lines: string[] = [
  999. `## Trace: ${from} → ${to}`,
  1000. '',
  1001. `Full execution path below — ${path.length} hops, each with its body, plus what the destination calls. This is the complete flow; answer from it.`,
  1002. '',
  1003. `${path.length} hops:`,
  1004. '',
  1005. ];
  1006. // Inline what each hop needs so the agent doesn't Read/Grep to get it: the
  1007. // call-site source line, the registration site for dynamic-dispatch hops, AND
  1008. // the hop's own body (capped per hop so the trace stays path-scoped). Earlier
  1009. // versions inlined only the call-site line, which left agents calling explore
  1010. // or Read for the bodies — the exact follow-up the ablation experiment measured.
  1011. const fileCache = new Map<string, string[]>();
  1012. for (let i = 0; i < path.length; i++) {
  1013. const step = path[i]!;
  1014. if (step.edge) {
  1015. const synth = this.synthEdgeNote(step.edge);
  1016. if (synth) {
  1017. lines.push(` ↓ ${synth.label}`);
  1018. if (synth.registeredAt) {
  1019. const regSrc = this.sourceLineAt(cg, synth.registeredAt, fileCache);
  1020. lines.push(` ↳ registered at ${synth.registeredAt}${regSrc ? ` ${regSrc}` : ''}`);
  1021. }
  1022. } else {
  1023. // The call happens in the PREVIOUS hop's file at edge.line.
  1024. const prev = path[i - 1];
  1025. const ref = prev && step.edge.line ? `${prev.node.filePath}:${step.edge.line}` : undefined;
  1026. const callSrc = this.sourceLineAt(cg, ref, fileCache);
  1027. lines.push(` ↓ ${step.edge.kind}${step.edge.line ? `@${step.edge.line}` : ''}${callSrc ? ` ${callSrc}` : ''}`);
  1028. }
  1029. }
  1030. lines.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine}-${step.node.endLine})`);
  1031. const body = this.sourceRangeAt(cg, step.node.filePath, step.node.startLine, step.node.endLine, fileCache, 60, 1800);
  1032. if (body) lines.push(body);
  1033. }
  1034. // The "last mile": what the destination does next. Agents otherwise explore/Read
  1035. // for exactly this (e.g. renderStaticScene → _renderStaticScene → the canvas draw),
  1036. // so inlining the destination's callees is what actually stops the investigation —
  1037. // sufficiency, not a "don't explore" instruction.
  1038. const dest = path[path.length - 1]!.node;
  1039. const destCallees = cg.getCallees(dest.id)
  1040. .filter(c => !path.some(p => p.node.id === c.node.id))
  1041. .slice(0, 6);
  1042. if (destCallees.length > 0) {
  1043. lines.push('', `### \`${dest.name}\` then calls (the destination's immediate work):`);
  1044. for (const c of destCallees) {
  1045. lines.push('', `- ${c.node.name} (${c.node.filePath}:${c.node.startLine}-${c.node.endLine})`);
  1046. const body = this.sourceRangeAt(cg, c.node.filePath, c.node.startLine, c.node.endLine, fileCache, 16, 600);
  1047. if (body) lines.push(body);
  1048. }
  1049. }
  1050. lines.push('', '> Full path + every hop body + the destination\'s calls are inlined above — the complete flow. Answer from it; a Read is only needed to chase a specific local variable\'s data-flow.');
  1051. return this.textResult(this.truncateOutput(lines.join('\n')));
  1052. }
  1053. /**
  1054. * Describe a synthesized (dynamic-dispatch) edge for human output: how the
  1055. * callback was wired up — the bridge static parsing can't see. Returns null
  1056. * for ordinary static edges. Used by trace + the node trail so a synthesized
  1057. * hop reads as "registered via onUpdate at App.tsx:3148", not a bare arrow.
  1058. */
  1059. private synthEdgeNote(edge: Edge | null): { label: string; compact: string; registeredAt?: string } | null {
  1060. if (!edge || edge.provenance !== 'heuristic') return null;
  1061. const m = edge.metadata as Record<string, unknown> | undefined;
  1062. const registeredAt = typeof m?.registeredAt === 'string' ? m.registeredAt : undefined;
  1063. const at = registeredAt ? ` @${registeredAt}` : '';
  1064. if (m?.synthesizedBy === 'callback') {
  1065. const via = m.via ? `\`${String(m.via)}\`` : 'a registrar';
  1066. const field = m.field ? ` on .${String(m.field)}` : '';
  1067. return {
  1068. label: `callback — registered via ${via}${field} (dynamic dispatch)`,
  1069. compact: `dynamic: callback via ${via}${at}`,
  1070. registeredAt,
  1071. };
  1072. }
  1073. if (m?.synthesizedBy === 'event-emitter') {
  1074. const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
  1075. return {
  1076. label: `event ${ev} — emit → handler (dynamic dispatch)`,
  1077. compact: `dynamic: event ${ev}${at}`,
  1078. registeredAt,
  1079. };
  1080. }
  1081. if (m?.synthesizedBy === 'react-render') {
  1082. return {
  1083. label: `React re-render — \`setState\` re-runs render() (dynamic dispatch)`,
  1084. compact: `dynamic: React re-render via setState${at}`,
  1085. registeredAt,
  1086. };
  1087. }
  1088. if (m?.synthesizedBy === 'jsx-render') {
  1089. const child = m.via ? `<${String(m.via)}>` : 'a child component';
  1090. return {
  1091. label: `renders ${child} (JSX child — dynamic dispatch)`,
  1092. compact: `dynamic: renders ${child}`,
  1093. registeredAt,
  1094. };
  1095. }
  1096. if (m?.synthesizedBy === 'vue-handler') {
  1097. const ev = m.event ? `@${String(m.event)}` : 'a template event';
  1098. return {
  1099. label: `Vue template handler — bound to ${ev} (dynamic dispatch)`,
  1100. compact: `dynamic: Vue ${ev} handler`,
  1101. registeredAt,
  1102. };
  1103. }
  1104. if (m?.synthesizedBy === 'interface-impl') {
  1105. return {
  1106. label: `interface/abstract dispatch — runs the implementation override (dynamic dispatch)`,
  1107. compact: `dynamic: interface → impl${at}`,
  1108. registeredAt,
  1109. };
  1110. }
  1111. return null;
  1112. }
  1113. /**
  1114. * Read one trimmed source line at "relpath:line" (relative to the project
  1115. * root). `cache` holds split file contents so a multi-hop trace reads each
  1116. * file at most once. Returns null if the file/line can't be resolved.
  1117. */
  1118. private sourceLineAt(cg: CodeGraph, ref: string | undefined, cache: Map<string, string[]>): string | null {
  1119. if (!ref) return null;
  1120. const i = ref.lastIndexOf(':');
  1121. if (i < 0) return null;
  1122. const filePath = ref.slice(0, i);
  1123. const line = parseInt(ref.slice(i + 1), 10);
  1124. if (!Number.isFinite(line) || line < 1) return null;
  1125. let fileLines = cache.get(filePath);
  1126. if (!fileLines) {
  1127. const abs = validatePathWithinRoot(cg.getProjectRoot(), filePath);
  1128. if (!abs || !existsSync(abs)) return null;
  1129. try { fileLines = readFileSync(abs, 'utf-8').split('\n'); } catch { return null; }
  1130. cache.set(filePath, fileLines);
  1131. }
  1132. const raw = fileLines[line - 1];
  1133. if (raw == null) return null;
  1134. const t = raw.trim();
  1135. return t.length > 160 ? t.slice(0, 157) + '…' : t;
  1136. }
  1137. /**
  1138. * Read a hop's body — filePath lines [startLine..endLine] — for inlining into
  1139. * a trace, capped (lines + chars) so the whole path stays path-scoped even on
  1140. * a 7-hop chain. Dedents to the body's own indentation and marks truncation.
  1141. * Shares `cache` with sourceLineAt so each file is read at most once per trace.
  1142. */
  1143. private sourceRangeAt(
  1144. cg: CodeGraph,
  1145. filePath: string,
  1146. startLine: number,
  1147. endLine: number,
  1148. cache: Map<string, string[]>,
  1149. maxLines = 28,
  1150. maxChars = 1200
  1151. ): string | null {
  1152. if (!Number.isFinite(startLine) || startLine < 1) return null;
  1153. let fileLines = cache.get(filePath);
  1154. if (!fileLines) {
  1155. const abs = validatePathWithinRoot(cg.getProjectRoot(), filePath);
  1156. if (!abs || !existsSync(abs)) return null;
  1157. try { fileLines = readFileSync(abs, 'utf-8').split('\n'); } catch { return null; }
  1158. cache.set(filePath, fileLines);
  1159. }
  1160. const end = Number.isFinite(endLine) && endLine >= startLine ? endLine : startLine;
  1161. let slice = fileLines.slice(startLine - 1, end);
  1162. if (slice.length === 0) return null;
  1163. let omitted = 0;
  1164. if (slice.length > maxLines) { omitted = slice.length - maxLines; slice = slice.slice(0, maxLines); }
  1165. const nonBlank = slice.filter(l => l.trim().length > 0);
  1166. const dedent = nonBlank.length ? Math.min(...nonBlank.map(l => l.length - l.trimStart().length)) : 0;
  1167. let text = slice.map((l, i) => ` ${startLine + i}\t${l.slice(dedent)}`).join('\n');
  1168. if (text.length > maxChars) {
  1169. text = text.slice(0, maxChars).replace(/\n[^\n]*$/, '');
  1170. omitted = Math.max(omitted, 1);
  1171. }
  1172. if (omitted > 0) text += `\n … (+${omitted} more line${omitted === 1 ? '' : 's'})`;
  1173. return text;
  1174. }
  1175. /**
  1176. * Flow-from-named-symbols: an agent's codegraph_explore query is a bag of
  1177. * symbol names that usually spans the flow it's investigating (e.g.
  1178. * "PmsProductController getList PmsProductService list PmsProductServiceImpl").
  1179. * Surface the longest call chain AMONG those named symbols — scoped to what the
  1180. * agent explicitly named, so (unlike a fuzzy relevance set) there's no
  1181. * wrong-feature wandering. Rides synthesized edges, so controller→service-
  1182. * interface→impl shows up. Returns '' if no chain of >=3 nodes exists.
  1183. *
  1184. * Ambiguous tokens (Java `list` → dozens of nodes) are disambiguated by
  1185. * CO-NAMING: the agent names the class too, so we keep only `list` candidates
  1186. * whose qualifiedName contains another named token (`PmsProductServiceImpl::list`),
  1187. * dropping unrelated `OmsOrderService::list`.
  1188. */
  1189. private buildFlowFromNamedSymbols(cg: CodeGraph, query: string): string {
  1190. try {
  1191. const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
  1192. const tokens = [...new Set(
  1193. query.split(/[\s,]+/)
  1194. .map((t) => t.replace(/\.[A-Za-z0-9]+$/, '').trim()) // strip file ext: Create.cs → Create
  1195. .filter((t) => /^[A-Za-z_$][\w$]{2,}$/.test(t))
  1196. )].slice(0, 16);
  1197. if (tokens.length < 2) return '';
  1198. const lower = tokens.map((t) => t.toLowerCase());
  1199. const named = new Map<string, Node>();
  1200. for (const t of tokens) {
  1201. const cands = this.findAllSymbols(cg, t).nodes.filter((n) => CALLABLE.has(n.kind));
  1202. // Disambiguate by co-naming: for an ambiguous name keep only candidates
  1203. // qualified by another named token; a specific name (<=3 hits) keeps all.
  1204. const pick = cands.length <= 3
  1205. ? cands
  1206. : cands.filter((n) => {
  1207. // Match qualifiedName SEGMENTS (Class::method), not substrings —
  1208. // "list" is a substring of "getList" but not a segment of it.
  1209. const segs = (n.qualifiedName || '').toLowerCase().split(/::|\./).filter(Boolean);
  1210. return lower.some((o) => o !== t.toLowerCase() && segs.includes(o));
  1211. });
  1212. for (const n of pick.slice(0, 6)) named.set(n.id, n);
  1213. if (named.size > 40) break;
  1214. }
  1215. if (named.size < 2) return '';
  1216. const MAX_HOPS = 7;
  1217. let best: Array<{ node: Node; edge: Edge | null }> | null = null;
  1218. // BFS the full call graph (incl. synth edges) from each named seed, but
  1219. // only ACCEPT a sink that is also named — both ends anchored to symbols the
  1220. // agent named, so the chain stays on-topic while bridging intermediates
  1221. // (e.g. the exact interface overload) that the token resolution missed.
  1222. for (const seed of [...named.values()].slice(0, 8)) {
  1223. const parent = new Map<string, { prev: string | null; edge: Edge | null; node: Node }>();
  1224. parent.set(seed.id, { prev: null, edge: null, node: seed });
  1225. const q: Array<{ id: string; depth: number; streak: number }> = [{ id: seed.id, depth: 0, streak: 0 }];
  1226. let deep: string | null = null, deepDepth = 0;
  1227. const MAX_BRIDGE = 1; // ≤1 consecutive UNNAMED hop: bridge one missing intermediate, never wander a god-function's fan-out
  1228. for (let h = 0; h < q.length && parent.size < 1500; h++) {
  1229. const { id, depth, streak } = q[h]!;
  1230. if (id !== seed.id && named.has(id) && depth > deepDepth) { deep = id; deepDepth = depth; }
  1231. if (depth >= MAX_HOPS - 1) continue;
  1232. for (const c of cg.getCallees(id)) {
  1233. if (c.edge.kind !== 'calls' || parent.has(c.node.id)) continue;
  1234. const newStreak = named.has(c.node.id) ? 0 : streak + 1;
  1235. if (newStreak > MAX_BRIDGE) continue;
  1236. parent.set(c.node.id, { prev: id, edge: c.edge, node: c.node });
  1237. q.push({ id: c.node.id, depth: depth + 1, streak: newStreak });
  1238. }
  1239. }
  1240. if (!deep) continue;
  1241. const chain: Array<{ node: Node; edge: Edge | null }> = [];
  1242. let cur: string | null = deep;
  1243. while (cur) { const p = parent.get(cur); if (!p) break; chain.push({ node: p.node, edge: p.edge }); cur = p.prev; }
  1244. chain.reverse();
  1245. if (!best || chain.length > best.length) best = chain;
  1246. }
  1247. if (!best || best.length < 3) return '';
  1248. const out = ['## Flow (call path among the symbols you queried)', ''];
  1249. for (let i = 0; i < best.length; i++) {
  1250. const step = best[i]!;
  1251. if (step.edge) { const sy = this.synthEdgeNote(step.edge); out.push(` ↓ ${sy ? sy.compact : step.edge.kind}`); }
  1252. out.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`);
  1253. }
  1254. out.push('', '> Full source for these symbols is below; codegraph_trace(from,to) for the exact path between two endpoints.', '');
  1255. return out.join('\n');
  1256. } catch {
  1257. return '';
  1258. }
  1259. }
  1260. /**
  1261. * Handle codegraph_explore — deep exploration in a single call
  1262. *
  1263. * Strategy: find relevant symbols via graph traversal, group by file,
  1264. * then read contiguous file sections covering all symbols per file.
  1265. * This replaces multiple codegraph_node + Read calls.
  1266. *
  1267. * Output size is adaptive to project file count via
  1268. * `getExploreOutputBudget` — see #185 for why a fixed 35k cap was a
  1269. * tax on small projects while earning its keep on large ones.
  1270. */
  1271. private async handleExplore(args: Record<string, unknown>): Promise<ToolResult> {
  1272. const query = this.validateString(args.query, 'query');
  1273. if (typeof query !== 'string') return query;
  1274. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1275. const projectRoot = cg.getProjectRoot();
  1276. // Resolve adaptive output budget from project size. Falls back to the
  1277. // largest-tier defaults if stats aren't available, which preserves
  1278. // pre-#185 behavior for callers that hit the rare stats failure.
  1279. let budget: ExploreOutputBudget;
  1280. try {
  1281. budget = getExploreOutputBudget(cg.getStats().fileCount);
  1282. } catch {
  1283. budget = getExploreOutputBudget(Infinity);
  1284. }
  1285. const maxFiles = clamp((args.maxFiles as number) || budget.defaultMaxFiles, 1, 20);
  1286. // Step 1: Find relevant context with generous parameters.
  1287. // Use a large maxNodes budget — explore has its own 35k char output limit
  1288. // that prevents context bloat, so more nodes just means better coverage
  1289. // across entry points (especially for large files like Svelte components).
  1290. const subgraph = await cg.findRelevantContext(query, {
  1291. searchLimit: 8,
  1292. traversalDepth: 3,
  1293. maxNodes: 200,
  1294. minScore: 0.2,
  1295. });
  1296. if (subgraph.nodes.size === 0) {
  1297. return this.textResult(`No relevant code found for "${query}"`);
  1298. }
  1299. // Graph-aware glue: findRelevantContext builds the subgraph from name/text
  1300. // search, so a method that BRIDGES named symbols — e.g. App.tsx's
  1301. // triggerRender, which calls the named triggerUpdate — is never a search hit
  1302. // and gets missed, forcing the agent to Read the file to trace it. Pull in
  1303. // the callers/callees of the entry (root) nodes, but ONLY those that live in
  1304. // files the subgraph already surfaces (where the agent reads to fill gaps),
  1305. // so we add wiring without dragging in unrelated files. These get an
  1306. // importance boost below so they survive the per-file cluster budget.
  1307. const glueNodeIds = new Set<string>();
  1308. const subgraphFiles = new Set<string>();
  1309. for (const n of subgraph.nodes.values()) subgraphFiles.add(n.filePath);
  1310. const GLUE_NODE_CAP = 60;
  1311. for (const rootId of subgraph.roots) {
  1312. if (glueNodeIds.size >= GLUE_NODE_CAP) break;
  1313. let neighbors: Node[] = [];
  1314. try {
  1315. neighbors = [
  1316. ...cg.getCallers(rootId).map(c => c.node),
  1317. ...cg.getCallees(rootId).map(c => c.node),
  1318. ];
  1319. } catch {
  1320. continue;
  1321. }
  1322. for (const nb of neighbors) {
  1323. if (glueNodeIds.size >= GLUE_NODE_CAP) break;
  1324. if (subgraph.nodes.has(nb.id)) continue;
  1325. if (!subgraphFiles.has(nb.filePath)) continue;
  1326. subgraph.nodes.set(nb.id, nb);
  1327. glueNodeIds.add(nb.id);
  1328. }
  1329. }
  1330. // Step 2: Group nodes by file, score by relevance
  1331. const fileGroups = new Map<string, { nodes: Node[]; score: number }>();
  1332. const entryNodeIds = new Set(subgraph.roots);
  1333. // Build a set of nodes directly connected to entry points (depth 1)
  1334. const connectedToEntry = new Set<string>();
  1335. for (const edge of subgraph.edges) {
  1336. if (entryNodeIds.has(edge.source)) connectedToEntry.add(edge.target);
  1337. if (entryNodeIds.has(edge.target)) connectedToEntry.add(edge.source);
  1338. }
  1339. for (const node of subgraph.nodes.values()) {
  1340. // Skip import/export nodes — they add noise without information
  1341. if (node.kind === 'import' || node.kind === 'export') continue;
  1342. const group = fileGroups.get(node.filePath) || { nodes: [], score: 0 };
  1343. group.nodes.push(node);
  1344. // Score: entry point nodes worth 10, directly connected worth 3, others worth 1
  1345. if (entryNodeIds.has(node.id)) {
  1346. group.score += 10;
  1347. } else if (connectedToEntry.has(node.id)) {
  1348. group.score += 3;
  1349. } else {
  1350. group.score += 1;
  1351. }
  1352. fileGroups.set(node.filePath, group);
  1353. }
  1354. // Only include files that have entry points or nodes directly connected to entry points
  1355. const relevantFiles = [...fileGroups.entries()].filter(([, group]) => group.score >= 3);
  1356. // Extract query terms for relevance checking
  1357. const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
  1358. // Sort files: highest relevance first, deprioritize low-value files
  1359. const sortedFiles = relevantFiles.sort((a, b) => {
  1360. const aPath = a[0].toLowerCase();
  1361. const bPath = b[0].toLowerCase();
  1362. // Check if any node name or file path relates to query terms
  1363. const hasQueryRelevance = (filePath: string, nodes: Node[]) => {
  1364. const fp = filePath.toLowerCase();
  1365. if (queryTerms.some(t => fp.includes(t))) return true;
  1366. return nodes.some(n => queryTerms.some(t => n.name.toLowerCase().includes(t)));
  1367. };
  1368. const aRelevant = hasQueryRelevance(aPath, a[1].nodes);
  1369. const bRelevant = hasQueryRelevance(bPath, b[1].nodes);
  1370. if (aRelevant !== bRelevant) return aRelevant ? -1 : 1;
  1371. // Deprioritize test files, icon files, and i18n files
  1372. const isLowValue = (p: string) =>
  1373. /\/(tests?|__tests?__|spec)\//i.test(p) ||
  1374. /\bicons?\b/i.test(p) ||
  1375. /\bi18n\b/i.test(p);
  1376. const aLow = isLowValue(aPath);
  1377. const bLow = isLowValue(bPath);
  1378. if (aLow !== bLow) return aLow ? 1 : -1;
  1379. if (a[1].score !== b[1].score) return b[1].score - a[1].score;
  1380. return b[1].nodes.length - a[1].nodes.length;
  1381. });
  1382. // Step 3: Build relationship map
  1383. const lines: string[] = [
  1384. `## Exploration: ${query}`,
  1385. '',
  1386. `Found ${subgraph.nodes.size} symbols across ${fileGroups.size} files.`,
  1387. '',
  1388. ];
  1389. // Relationship map — show how symbols connect
  1390. const significantEdges = subgraph.edges.filter(e =>
  1391. e.kind !== 'contains' // skip contains — it's implied by file grouping
  1392. );
  1393. if (budget.includeRelationships && significantEdges.length > 0) {
  1394. lines.push('### Relationships');
  1395. lines.push('');
  1396. // Group edges by kind for readability
  1397. const byKind = new Map<string, Array<{ source: string; target: string }>>();
  1398. for (const edge of significantEdges) {
  1399. const sourceNode = subgraph.nodes.get(edge.source);
  1400. const targetNode = subgraph.nodes.get(edge.target);
  1401. if (!sourceNode || !targetNode) continue;
  1402. const group = byKind.get(edge.kind) || [];
  1403. group.push({ source: sourceNode.name, target: targetNode.name });
  1404. byKind.set(edge.kind, group);
  1405. }
  1406. for (const [kind, edges] of byKind) {
  1407. const cap = budget.maxEdgesPerRelationshipKind;
  1408. const shown = edges.slice(0, cap);
  1409. lines.push(`**${kind}:**`);
  1410. for (const e of shown) {
  1411. lines.push(`- ${e.source} → ${e.target}`);
  1412. }
  1413. if (edges.length > cap) {
  1414. lines.push(`- ... and ${edges.length - cap} more`);
  1415. }
  1416. lines.push('');
  1417. }
  1418. }
  1419. // Step 4: Read contiguous file sections
  1420. lines.push('### Source Code');
  1421. lines.push('');
  1422. lines.push('> The code below is the **verbatim, current on-disk source** of these files — re-read from disk on this call and line-numbered, byte-for-byte identical to what the Read tool returns. It is NOT a summary, outline, or stale cache. Treat each block as a Read you have already performed: do not Read a file shown here.');
  1423. lines.push('');
  1424. let totalChars = lines.join('\n').length;
  1425. let filesIncluded = 0;
  1426. let anyFileTrimmed = false;
  1427. for (const [filePath, group] of sortedFiles) {
  1428. if (filesIncluded >= maxFiles) break;
  1429. if (totalChars > budget.maxOutputChars * 0.9) break;
  1430. const absPath = validatePathWithinRoot(projectRoot, filePath);
  1431. if (!absPath || !existsSync(absPath)) continue;
  1432. let fileContent: string;
  1433. try {
  1434. fileContent = readFileSync(absPath, 'utf-8');
  1435. } catch {
  1436. continue;
  1437. }
  1438. const fileLines = fileContent.split('\n');
  1439. const lang = group.nodes[0]?.language || '';
  1440. // Whole-small-file rule: if a relevant file is small enough to afford,
  1441. // return it ENTIRELY instead of clustering. Clustering exists to tame
  1442. // god-files (App.tsx ~13k lines); on a ~134-line component a cluster is a
  1443. // lossy subset of a file the agent will just Read in full anyway — costing
  1444. // a round-trip and a re-read every later turn. Reserve clustering for files
  1445. // too big to ship whole. Still bounded by the total maxOutputChars check.
  1446. const WHOLE_FILE_MAX_LINES = 220;
  1447. const WHOLE_FILE_MAX_CHARS = budget.maxCharsPerFile * 3;
  1448. if (fileLines.length <= WHOLE_FILE_MAX_LINES && fileContent.length <= WHOLE_FILE_MAX_CHARS) {
  1449. const body = fileContent.replace(/\n+$/, '');
  1450. let wholeSection = exploreLineNumbersEnabled() ? numberSourceLines(body, 1) : body;
  1451. const uniqSymbols = [...new Set(
  1452. group.nodes
  1453. .filter(n => n.kind !== 'import' && n.kind !== 'export')
  1454. .map(n => `${n.name}(${n.kind})`)
  1455. )];
  1456. const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
  1457. const omitted = uniqSymbols.length - headerNames.length;
  1458. const wholeHeader = `#### ${filePath} — ${omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')}`;
  1459. if (totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
  1460. const remaining = budget.maxOutputChars - totalChars - 200;
  1461. if (remaining < 500) break;
  1462. wholeSection = wholeSection.slice(0, remaining) + '\n... (trimmed) ...';
  1463. anyFileTrimmed = true;
  1464. }
  1465. lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
  1466. totalChars += wholeSection.length + 200;
  1467. filesIncluded++;
  1468. continue;
  1469. }
  1470. // Cluster nearby symbols to avoid reading huge gaps between distant symbols.
  1471. // Sort by start line, then merge overlapping/adjacent ranges (within the
  1472. // adaptive gap threshold). Include both node ranges AND edge source
  1473. // locations so template sections with component usages/calls are
  1474. // covered (not just script block symbols).
  1475. //
  1476. // Each range carries an `importance` score so we can rank clusters
  1477. // when the per-file budget forces us to drop some: entry-point nodes
  1478. // are worth 10, directly-connected nodes 3, peripheral nodes 1, and
  1479. // bare edge-source lines 2 (less than a connected node but more than
  1480. // a peripheral one — they hint at a reference but aren't a definition).
  1481. // Container kinds whose body can span most/all of a file. When such a
  1482. // node covers most of the file we drop it from the ranges: keeping it
  1483. // would merge every method inside it into one giant cluster spanning
  1484. // the whole file, which then tail-trims down to just the container's
  1485. // opening lines (its header/declarations) and buries the methods the
  1486. // query actually asked about (#185 follow-up — Session.swift in
  1487. // Alamofire is the canonical case: the `Session` class spans ~1,400
  1488. // lines). We want the granular symbols inside, not the envelope.
  1489. const ENVELOPE_KINDS = new Set(['file', 'module', 'class', 'struct', 'interface', 'enum', 'namespace', 'protocol', 'trait', 'component']);
  1490. const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number }> = group.nodes
  1491. .filter(n => n.startLine > 0 && n.endLine > 0)
  1492. // Drop whole-file envelope nodes (containers covering >50% of the file).
  1493. .filter(n => !(ENVELOPE_KINDS.has(n.kind) && (n.endLine - n.startLine + 1) > fileLines.length * 0.5))
  1494. .map(n => {
  1495. let importance = 1;
  1496. if (entryNodeIds.has(n.id)) importance = 10;
  1497. else if (glueNodeIds.has(n.id)) importance = 6; // bridging caller/callee of an entry
  1498. else if (connectedToEntry.has(n.id)) importance = 3;
  1499. return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance };
  1500. });
  1501. // Add edge source locations in this file — captures template references
  1502. // (component usages, event handlers) that aren't nodes themselves.
  1503. // Query edges directly from the DB (not just the subgraph) because BFS
  1504. // traversal may have pruned template reference targets due to node budget.
  1505. const edgeLines = new Set<string>(); // dedup by "line:name"
  1506. for (const node of group.nodes) {
  1507. const outgoing = cg.getOutgoingEdges(node.id);
  1508. for (const edge of outgoing) {
  1509. if (!edge.line || edge.line <= 0 || edge.kind === 'contains') continue;
  1510. const key = `${edge.line}:${edge.target}`;
  1511. if (edgeLines.has(key)) continue;
  1512. edgeLines.add(key);
  1513. // Look up target name from subgraph first, fall back to edge kind
  1514. const targetNode = subgraph.nodes.get(edge.target);
  1515. const targetName = targetNode?.name ?? edge.kind;
  1516. ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2 });
  1517. }
  1518. }
  1519. ranges.sort((a, b) => a.start - b.start);
  1520. if (ranges.length === 0) continue;
  1521. const gapThreshold = budget.gapThreshold;
  1522. const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number }> = [];
  1523. let current = {
  1524. start: ranges[0]!.start,
  1525. end: ranges[0]!.end,
  1526. symbols: [`${ranges[0]!.name}(${ranges[0]!.kind})`],
  1527. score: ranges[0]!.importance,
  1528. maxImportance: ranges[0]!.importance,
  1529. };
  1530. for (let i = 1; i < ranges.length; i++) {
  1531. const r = ranges[i]!;
  1532. if (r.start <= current.end + gapThreshold) {
  1533. current.end = Math.max(current.end, r.end);
  1534. current.symbols.push(`${r.name}(${r.kind})`);
  1535. current.score += r.importance;
  1536. current.maxImportance = Math.max(current.maxImportance, r.importance);
  1537. } else {
  1538. clusters.push(current);
  1539. current = {
  1540. start: r.start,
  1541. end: r.end,
  1542. symbols: [`${r.name}(${r.kind})`],
  1543. score: r.importance,
  1544. maxImportance: r.importance,
  1545. };
  1546. }
  1547. }
  1548. clusters.push(current);
  1549. // Build file section output from clusters, capped by per-file budget.
  1550. // The pathological case (#185): a file like Session.swift where every
  1551. // method is adjacent collapses into one cluster spanning the whole
  1552. // file, and dumping that into the agent's context is most of the
  1553. // token cost on small projects. We pick clusters in priority order
  1554. // until the per-file char cap is hit. Truly enormous single clusters
  1555. // get tail-trimmed with a marker.
  1556. const contextPadding = 3;
  1557. const withLineNumbers = exploreLineNumbersEnabled();
  1558. const buildSection = (c: { start: number; end: number }): string => {
  1559. const startIdx = Math.max(0, c.start - 1 - contextPadding);
  1560. const endIdx = Math.min(fileLines.length, c.end + contextPadding);
  1561. const slice = fileLines.slice(startIdx, endIdx).join('\n');
  1562. // startIdx is 0-based, so the slice's first line is line startIdx + 1.
  1563. return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice;
  1564. };
  1565. // Language-neutral separator (no `//` — not a comment in Python, Ruby,
  1566. // etc.). With line numbers on, the line-number jump also signals the gap.
  1567. const GAP_MARKER = '\n\n... (gap) ...\n\n';
  1568. // Rank clusters for inclusion under the per-file cap. Entry-point
  1569. // clusters come first: a cluster containing a query entry point
  1570. // (importance 10) must outrank a dense block of mere declarations,
  1571. // otherwise on a large file like Session.swift the top-of-file class
  1572. // header + property list (many adjacent low-importance nodes, high
  1573. // density) wins the budget and buries the actual methods the query
  1574. // asked about (perform/didCreateURLRequest/task live deep in the
  1575. // file). Within the same importance tier, prefer density (score per
  1576. // line) so we still favor focused clusters over sprawling ones, then
  1577. // smaller span as a cheap-to-include tiebreak.
  1578. const rankedClusters = clusters
  1579. .map((c, i) => ({ idx: i, span: c.end - c.start + 1, c }))
  1580. .sort((a, b) => {
  1581. if (b.c.maxImportance !== a.c.maxImportance) return b.c.maxImportance - a.c.maxImportance;
  1582. const densityA = a.c.score / a.span;
  1583. const densityB = b.c.score / b.span;
  1584. if (densityB !== densityA) return densityB - densityA;
  1585. if (b.c.score !== a.c.score) return b.c.score - a.c.score;
  1586. return a.span - b.span;
  1587. });
  1588. const chosenIndices = new Set<number>();
  1589. let projectedChars = 0;
  1590. for (const rc of rankedClusters) {
  1591. const sectionLen = buildSection(rc.c).length + (chosenIndices.size > 0 ? GAP_MARKER.length : 0);
  1592. // Always take the top-ranked cluster, even if oversize, so we don't
  1593. // return an empty file section (agent would then re-Read the file,
  1594. // negating the savings).
  1595. if (chosenIndices.size === 0) {
  1596. chosenIndices.add(rc.idx);
  1597. projectedChars += sectionLen;
  1598. continue;
  1599. }
  1600. if (projectedChars + sectionLen > budget.maxCharsPerFile) continue;
  1601. chosenIndices.add(rc.idx);
  1602. projectedChars += sectionLen;
  1603. }
  1604. // Emit chosen clusters in source order so the file reads top-to-bottom.
  1605. let fileSection = '';
  1606. const allSymbols: string[] = [];
  1607. let fileTrimmed = false;
  1608. for (let i = 0; i < clusters.length; i++) {
  1609. if (!chosenIndices.has(i)) continue;
  1610. const cluster = clusters[i]!;
  1611. const section = buildSection(cluster);
  1612. if (fileSection.length > 0) fileSection += GAP_MARKER;
  1613. fileSection += section;
  1614. allSymbols.push(...cluster.symbols);
  1615. }
  1616. // If a single chosen cluster is still oversize (long monolithic
  1617. // function), tail-trim it. Better one trimmed view than nothing.
  1618. if (fileSection.length > budget.maxCharsPerFile) {
  1619. fileSection = fileSection.slice(0, budget.maxCharsPerFile) + '\n... (trimmed) ...';
  1620. fileTrimmed = true;
  1621. }
  1622. if (chosenIndices.size < clusters.length || fileTrimmed) {
  1623. anyFileTrimmed = true;
  1624. }
  1625. // Dedupe + cap the symbols list shown in the per-file header. Some
  1626. // files (Session.swift in Alamofire) produced 3.4KB symbol lists
  1627. // from cluster scoring + edge-source lines, dwarfing the per-file
  1628. // body cap. Show top names by frequency, with a "+N more" tail.
  1629. const symbolCounts = new Map<string, number>();
  1630. for (const s of allSymbols) {
  1631. symbolCounts.set(s, (symbolCounts.get(s) ?? 0) + 1);
  1632. }
  1633. const sortedSymbols = [...symbolCounts.entries()]
  1634. .sort((a, b) => b[1] - a[1])
  1635. .map(([name]) => name);
  1636. const headerCap = budget.maxSymbolsInFileHeader;
  1637. const headerSymbols = sortedSymbols.slice(0, headerCap);
  1638. const omittedCount = sortedSymbols.length - headerSymbols.length;
  1639. const headerSuffix = omittedCount > 0
  1640. ? `${headerSymbols.join(', ')}, +${omittedCount} more`
  1641. : headerSymbols.join(', ');
  1642. const fileHeader = `#### ${filePath} — ${headerSuffix}`;
  1643. // Respect the total output cap on a file-by-file basis.
  1644. if (totalChars + fileSection.length + 200 > budget.maxOutputChars) {
  1645. const remaining = budget.maxOutputChars - totalChars - 200;
  1646. if (remaining < 500) break;
  1647. const trimmed = fileSection.slice(0, remaining) + '\n... (trimmed) ...';
  1648. lines.push(fileHeader);
  1649. lines.push('');
  1650. lines.push('```' + lang);
  1651. lines.push(trimmed);
  1652. lines.push('```');
  1653. lines.push('');
  1654. totalChars += trimmed.length + 200;
  1655. filesIncluded++;
  1656. anyFileTrimmed = true;
  1657. break;
  1658. }
  1659. lines.push(fileHeader);
  1660. lines.push('');
  1661. lines.push('```' + lang);
  1662. lines.push(fileSection);
  1663. lines.push('```');
  1664. lines.push('');
  1665. totalChars += fileSection.length + 200;
  1666. filesIncluded++;
  1667. }
  1668. // Add remaining files as references (from both relevant and peripheral files).
  1669. // Small projects (per budget) skip this — the relevant story already fits
  1670. // in the source section, and a trailing pointer list is pure overhead.
  1671. if (budget.includeAdditionalFiles) {
  1672. const remainingRelevant = sortedFiles.slice(filesIncluded);
  1673. const peripheralFiles = [...fileGroups.entries()]
  1674. .filter(([, group]) => group.score < 3)
  1675. .sort((a, b) => b[1].score - a[1].score);
  1676. const remainingFiles = [...remainingRelevant, ...peripheralFiles];
  1677. if (remainingFiles.length > 0) {
  1678. lines.push('### Not shown above — explore these names for their source');
  1679. lines.push('');
  1680. for (const [filePath, group] of remainingFiles.slice(0, 10)) {
  1681. const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
  1682. lines.push(`- ${filePath}: ${symbols}`);
  1683. }
  1684. if (remainingFiles.length > 10) {
  1685. lines.push(`- ... and ${remainingFiles.length - 10} more files`);
  1686. }
  1687. }
  1688. }
  1689. // Add completeness signal so agents know they don't need to re-read these files.
  1690. // On small projects the budget gates this off — but if we actually had to
  1691. // trim or drop clusters, surface a brief note so the agent knows it can
  1692. // still Read for more detail.
  1693. if (budget.includeCompletenessSignal) {
  1694. lines.push('');
  1695. lines.push('---');
  1696. lines.push(`> **Complete source for ${filesIncluded} files is included above — do NOT re-read them.** If your question also needs files/symbols listed under "Not shown above" (or any area this call didn't cover), make ANOTHER codegraph_explore targeting those names — it returns the same source with line numbers and is cheaper and more complete than reading. Reserve Read for a single specific line range explore can't surface.`);
  1697. } else if (anyFileTrimmed) {
  1698. lines.push('');
  1699. lines.push(`> Some file sections were trimmed for size. For a specific symbol you still need, run another \`codegraph_explore\` (or \`codegraph_node\`) with its exact name — line-numbered source, cheaper and more complete than Read.`);
  1700. }
  1701. // Add explore budget note based on project size
  1702. if (budget.includeBudgetNote) {
  1703. try {
  1704. const stats = cg.getStats();
  1705. const callBudget = getExploreBudget(stats.fileCount);
  1706. lines.push('');
  1707. lines.push(`> **Explore budget: ${callBudget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).** Each call covers ~6 files; if your question spans more, spend your remaining calls on the uncovered area BEFORE falling back to Read — another explore is cheaper and more complete than reading those files. Synthesize once you've used ${callBudget}.`);
  1708. } catch {
  1709. // Stats unavailable — skip budget note
  1710. }
  1711. }
  1712. // Hard-cap to the adaptive budget. The per-file loop bounds the source
  1713. // sections, but the relationship map, additional-files list, and
  1714. // completeness/budget notes can still push the assembled output past
  1715. // maxOutputChars (observed 30k against a 28k tier cap). A fat explore
  1716. // payload persists in the agent's context and is re-read as cache-input
  1717. // on every subsequent turn, so the overrun is paid many times over.
  1718. const output = this.buildFlowFromNamedSymbols(cg, query) + lines.join('\n');
  1719. if (output.length > budget.maxOutputChars) {
  1720. const cut = output.slice(0, budget.maxOutputChars);
  1721. const lastNewline = cut.lastIndexOf('\n');
  1722. const safe = lastNewline > budget.maxOutputChars * 0.8 ? cut.slice(0, lastNewline) : cut;
  1723. return this.textResult(safe + '\n\n... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For any area not covered, run another codegraph_explore with the specific names — do NOT Read these files.)');
  1724. }
  1725. return this.textResult(output);
  1726. }
  1727. /**
  1728. * Handle codegraph_node
  1729. */
  1730. private async handleNode(args: Record<string, unknown>): Promise<ToolResult> {
  1731. const symbol = this.validateString(args.symbol, 'symbol');
  1732. if (typeof symbol !== 'string') return symbol;
  1733. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1734. // Default to false to minimize context usage
  1735. const includeCode = args.includeCode === true;
  1736. const match = this.findSymbol(cg, symbol);
  1737. if (!match) {
  1738. return this.textResult(`Symbol "${symbol}" not found in the codebase`);
  1739. }
  1740. let code: string | null = null;
  1741. let outline: string | null = null;
  1742. if (includeCode) {
  1743. // For container symbols (class/interface/struct/…), the full body is the
  1744. // sum of every method body — a wall of source (e.g. a 10k-char class)
  1745. // that bloats context and is rarely needed in full. Return a structural
  1746. // outline (members + signatures + line numbers) instead; the agent can
  1747. // Read or codegraph_node a specific method for its body. Leaf symbols
  1748. // (function/method/etc.) return their full body as before.
  1749. if (CONTAINER_NODE_KINDS.has(match.node.kind)) {
  1750. outline = this.buildContainerOutline(cg, match.node);
  1751. }
  1752. if (!outline) {
  1753. code = await cg.getCode(match.node.id);
  1754. }
  1755. }
  1756. const trail = this.formatTrail(cg, match.node);
  1757. const formatted = this.formatNodeDetails(match.node, code, outline) + trail + match.note;
  1758. return this.textResult(this.truncateOutput(formatted));
  1759. }
  1760. /**
  1761. * Build the "trail" for a symbol: its direct callees (what it calls) and
  1762. * callers (what calls it), each with file:line — so codegraph_node doubles as
  1763. * the structural Grep→Read→expand primitive: a spot PLUS where to go next.
  1764. * Capped to stay cheap. Walk the graph by calling codegraph_node on a trail
  1765. * entry; no Read needed for covered hops. Empty edges on a non-leaf often mean
  1766. * dynamic dispatch the static graph couldn't resolve — that absence is itself
  1767. * a signal (read that one hop) rather than a dead end.
  1768. */
  1769. private formatTrail(cg: CodeGraph, node: Node): string {
  1770. const TRAIL_CAP = 12;
  1771. const fmt = (e: { node: Node; edge: Edge }) => {
  1772. const base = `${e.node.name} (${e.node.filePath}:${e.node.startLine})`;
  1773. const synth = this.synthEdgeNote(e.edge);
  1774. return synth ? `${base} [${synth.compact}]` : base;
  1775. };
  1776. const collect = (edges: Array<{ node: Node; edge: Edge }>): Array<{ node: Node; edge: Edge }> => {
  1777. const seen = new Set<string>([node.id]);
  1778. const out: Array<{ node: Node; edge: Edge }> = [];
  1779. for (const e of edges) {
  1780. if (seen.has(e.node.id)) continue;
  1781. seen.add(e.node.id);
  1782. out.push(e);
  1783. }
  1784. return out;
  1785. };
  1786. const callees = collect(cg.getCallees(node.id));
  1787. const callers = collect(cg.getCallers(node.id));
  1788. if (callees.length === 0 && callers.length === 0) return '';
  1789. const lines: string[] = ['', '### Trail — codegraph_node any of these to follow it (no Read needed)'];
  1790. if (callees.length > 0) {
  1791. lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`);
  1792. }
  1793. if (callers.length > 0) {
  1794. lines.push(`**Called by ←** ${callers.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callers.length > TRAIL_CAP ? `, +${callers.length - TRAIL_CAP} more` : ''}`);
  1795. }
  1796. return lines.join('\n');
  1797. }
  1798. /**
  1799. * Handle codegraph_status
  1800. */
  1801. private async handleStatus(args: Record<string, unknown>): Promise<ToolResult> {
  1802. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1803. const stats = cg.getStats();
  1804. const lines: string[] = [
  1805. '## CodeGraph Status',
  1806. '',
  1807. `**Files indexed:** ${stats.fileCount}`,
  1808. `**Total nodes:** ${stats.nodeCount}`,
  1809. `**Total edges:** ${stats.edgeCount}`,
  1810. `**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`,
  1811. ];
  1812. // Surface the active SQLite backend (node:sqlite, Node's built-in real
  1813. // SQLite — full WAL + FTS5, no native build).
  1814. lines.push(`**Backend:** node:sqlite (Node built-in) — full WAL + FTS5`);
  1815. // Effective journal mode. 'wal' ⇒ concurrent reads never block on a writer;
  1816. // anything else ⇒ they can ("database is locked"). node:sqlite supports WAL
  1817. // everywhere, so a non-wal mode means the filesystem can't (network/
  1818. // virtualized mounts, WSL2 /mnt). See issue #238.
  1819. const journalMode = cg.getJournalMode();
  1820. if (journalMode === 'wal') {
  1821. lines.push(`**Journal mode:** wal (concurrent reads safe)`);
  1822. } else {
  1823. lines.push(
  1824. `**Journal mode:** ⚠ ${journalMode || 'unknown'} — WAL not active, so reads ` +
  1825. `can block on a concurrent write (WAL appears unsupported on this filesystem)`
  1826. );
  1827. }
  1828. lines.push('', '### Nodes by Kind:');
  1829. for (const [kind, count] of Object.entries(stats.nodesByKind)) {
  1830. if ((count as number) > 0) {
  1831. lines.push(`- ${kind}: ${count}`);
  1832. }
  1833. }
  1834. lines.push('', '### Languages:');
  1835. for (const [lang, count] of Object.entries(stats.filesByLanguage)) {
  1836. if ((count as number) > 0) {
  1837. lines.push(`- ${lang}: ${count}`);
  1838. }
  1839. }
  1840. return this.textResult(lines.join('\n'));
  1841. }
  1842. /**
  1843. * Handle codegraph_files - get project file structure from the index
  1844. */
  1845. private async handleFiles(args: Record<string, unknown>): Promise<ToolResult> {
  1846. const cg = this.getCodeGraph(args.projectPath as string | undefined);
  1847. const pathFilter = args.path as string | undefined;
  1848. const pattern = args.pattern as string | undefined;
  1849. const format = (args.format as 'tree' | 'flat' | 'grouped') || 'tree';
  1850. const includeMetadata = args.includeMetadata !== false;
  1851. const maxDepth = args.maxDepth != null ? clamp(args.maxDepth as number, 1, 20) : undefined;
  1852. // Get all files from the index
  1853. const allFiles = cg.getFiles();
  1854. if (allFiles.length === 0) {
  1855. return this.textResult('No files indexed. Run `codegraph index` first.');
  1856. }
  1857. // Filter by path prefix
  1858. let files = pathFilter
  1859. ? allFiles.filter(f => f.path.startsWith(pathFilter) || f.path.startsWith('./' + pathFilter))
  1860. : allFiles;
  1861. // Filter by glob pattern
  1862. if (pattern) {
  1863. const regex = this.globToRegex(pattern);
  1864. files = files.filter(f => regex.test(f.path));
  1865. }
  1866. if (files.length === 0) {
  1867. return this.textResult(`No files found matching the criteria.`);
  1868. }
  1869. // Format output
  1870. let output: string;
  1871. switch (format) {
  1872. case 'flat':
  1873. output = this.formatFilesFlat(files, includeMetadata);
  1874. break;
  1875. case 'grouped':
  1876. output = this.formatFilesGrouped(files, includeMetadata);
  1877. break;
  1878. case 'tree':
  1879. default:
  1880. output = this.formatFilesTree(files, includeMetadata, maxDepth);
  1881. break;
  1882. }
  1883. return this.textResult(this.truncateOutput(output));
  1884. }
  1885. /**
  1886. * Convert glob pattern to regex
  1887. */
  1888. private globToRegex(pattern: string): RegExp {
  1889. const escaped = pattern
  1890. .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars except * and ?
  1891. .replace(/\*\*/g, '{{GLOBSTAR}}') // Temp placeholder for **
  1892. .replace(/\*/g, '[^/]*') // * matches anything except /
  1893. .replace(/\?/g, '[^/]') // ? matches single char except /
  1894. .replace(/\{\{GLOBSTAR\}\}/g, '.*'); // ** matches anything including /
  1895. return new RegExp(escaped);
  1896. }
  1897. /**
  1898. * Format files as a flat list
  1899. */
  1900. private formatFilesFlat(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
  1901. const lines: string[] = [`## Files (${files.length})`, ''];
  1902. for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
  1903. if (includeMetadata) {
  1904. lines.push(`- ${file.path} (${file.language}, ${file.nodeCount} symbols)`);
  1905. } else {
  1906. lines.push(`- ${file.path}`);
  1907. }
  1908. }
  1909. return lines.join('\n');
  1910. }
  1911. /**
  1912. * Format files grouped by language
  1913. */
  1914. private formatFilesGrouped(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
  1915. const byLang = new Map<string, typeof files>();
  1916. for (const file of files) {
  1917. const existing = byLang.get(file.language) || [];
  1918. existing.push(file);
  1919. byLang.set(file.language, existing);
  1920. }
  1921. const lines: string[] = [`## Files by Language (${files.length} total)`, ''];
  1922. // Sort languages by file count (descending)
  1923. const sortedLangs = [...byLang.entries()].sort((a, b) => b[1].length - a[1].length);
  1924. for (const [lang, langFiles] of sortedLangs) {
  1925. lines.push(`### ${lang} (${langFiles.length})`);
  1926. for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) {
  1927. if (includeMetadata) {
  1928. lines.push(`- ${file.path} (${file.nodeCount} symbols)`);
  1929. } else {
  1930. lines.push(`- ${file.path}`);
  1931. }
  1932. }
  1933. lines.push('');
  1934. }
  1935. return lines.join('\n');
  1936. }
  1937. /**
  1938. * Format files as a tree structure
  1939. */
  1940. private formatFilesTree(
  1941. files: { path: string; language: string; nodeCount: number }[],
  1942. includeMetadata: boolean,
  1943. maxDepth?: number
  1944. ): string {
  1945. // Build tree structure
  1946. interface TreeNode {
  1947. name: string;
  1948. children: Map<string, TreeNode>;
  1949. file?: { language: string; nodeCount: number };
  1950. }
  1951. const root: TreeNode = { name: '', children: new Map() };
  1952. for (const file of files) {
  1953. const parts = file.path.split('/');
  1954. let current = root;
  1955. for (let i = 0; i < parts.length; i++) {
  1956. const part = parts[i];
  1957. if (!part) continue;
  1958. if (!current.children.has(part)) {
  1959. current.children.set(part, { name: part, children: new Map() });
  1960. }
  1961. current = current.children.get(part)!;
  1962. // If this is the last part, it's a file
  1963. if (i === parts.length - 1) {
  1964. current.file = { language: file.language, nodeCount: file.nodeCount };
  1965. }
  1966. }
  1967. }
  1968. // Render tree
  1969. const lines: string[] = [`## Project Structure (${files.length} files)`, ''];
  1970. const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number): void => {
  1971. if (maxDepth !== undefined && depth > maxDepth) return;
  1972. const connector = isLast ? '└── ' : '├── ';
  1973. const childPrefix = isLast ? ' ' : '│ ';
  1974. if (node.name) {
  1975. let line = prefix + connector + node.name;
  1976. if (node.file && includeMetadata) {
  1977. line += ` (${node.file.language}, ${node.file.nodeCount} symbols)`;
  1978. }
  1979. lines.push(line);
  1980. }
  1981. const children = [...node.children.values()];
  1982. // Sort: directories first, then files, both alphabetically
  1983. children.sort((a, b) => {
  1984. const aIsDir = a.children.size > 0 && !a.file;
  1985. const bIsDir = b.children.size > 0 && !b.file;
  1986. if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
  1987. return a.name.localeCompare(b.name);
  1988. });
  1989. for (let i = 0; i < children.length; i++) {
  1990. const child = children[i]!;
  1991. const nextPrefix = node.name ? prefix + childPrefix : prefix;
  1992. renderNode(child, nextPrefix, i === children.length - 1, depth + 1);
  1993. }
  1994. };
  1995. renderNode(root, '', true, 0);
  1996. return lines.join('\n');
  1997. }
  1998. // =========================================================================
  1999. // Symbol resolution helpers
  2000. // =========================================================================
  2001. /**
  2002. * Find a symbol by name, handling disambiguation when multiple matches exist.
  2003. * Returns the best match and a note about alternatives if any.
  2004. */
  2005. /**
  2006. * Check if a node matches a symbol query.
  2007. *
  2008. * Accepts simple names (`run`) and three flavors of qualifier:
  2009. * - dotted `Session.request` (TS/JS/Python)
  2010. * - colon-pair `stage_apply::run` (Rust, C++, Ruby)
  2011. * - slash `configurator/stage_apply` (path-ish)
  2012. *
  2013. * Multi-level qualifiers compose: `crate::configurator::stage_apply::run`
  2014. * works. Rust path prefixes (`crate`, `super`, `self`) are stripped so
  2015. * the canonical `crate::module::symbol` form resolves.
  2016. *
  2017. * Resolution order, last part must always equal `node.name`:
  2018. * 1. Suffix-match against `qualifiedName` (handles class-scoped methods
  2019. * where the extractor builds the qualified name from the AST stack)
  2020. * 2. File-path containment (handles file-derived modules in Rust/
  2021. * Python — `stage_apply::run` matches a `run` in `stage_apply.rs`)
  2022. */
  2023. private matchesSymbol(node: Node, symbol: string): boolean {
  2024. // Simple name match
  2025. if (node.name === symbol) return true;
  2026. // File basename match (e.g., "product-card" matches "product-card.liquid")
  2027. if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true;
  2028. // Qualified-name lookups: split on any supported separator. `\w` keeps
  2029. // identifier chars (incl. `_`) intact; everything else is treated as
  2030. // a separator we tolerate.
  2031. if (!/[.\/]|::/.test(symbol)) return false;
  2032. const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
  2033. if (parts.length < 2) return false;
  2034. const lastPart = parts[parts.length - 1]!;
  2035. if (node.name !== lastPart) return false;
  2036. // Stage 1: qualified-name suffix match. The extractor joins the
  2037. // semantic hierarchy with `::`, so `Session.request` and
  2038. // `Session::request` both become `Session::request` here.
  2039. const colonSuffix = parts.join('::');
  2040. if (node.qualifiedName.includes(colonSuffix)) return true;
  2041. // Stage 2: file-path containment. Rust modules and Python packages
  2042. // are not in `qualifiedName` — they're encoded in the file path. So
  2043. // `stage_apply::run` matches a `run` in any file whose path
  2044. // contains a `stage_apply` segment (with or without an extension).
  2045. //
  2046. // Filter out Rust path prefixes that have no file-system equivalent.
  2047. const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
  2048. if (containerHints.length === 0) return false;
  2049. const segments = node.filePath.split('/').filter((s) => s.length > 0);
  2050. return containerHints.every((hint) =>
  2051. segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint)
  2052. );
  2053. }
  2054. private findSymbol(cg: CodeGraph, symbol: string): { node: Node; note: string } | null {
  2055. // Use higher limit for qualified lookups (e.g., "Session.request",
  2056. // "stage_apply::run") since the target may rank lower in FTS when
  2057. // there are many partial matches across the qualifier parts.
  2058. const isQualified = /[.\/]|::/.test(symbol);
  2059. const limit = isQualified ? 50 : 10;
  2060. let results = cg.searchNodes(symbol, { limit });
  2061. // FTS strips colons as a special char, so `stage_apply::run` searches
  2062. // for the literal `stage_applyrun` and finds nothing. Re-search by
  2063. // the bare last part and let `matchesSymbol` filter by qualifier.
  2064. if (isQualified && results.length === 0) {
  2065. const tail = lastQualifierPart(symbol);
  2066. if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit });
  2067. }
  2068. if (results.length === 0 || !results[0]) {
  2069. return null;
  2070. }
  2071. const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
  2072. if (exactMatches.length === 1) {
  2073. return { node: exactMatches[0]!.node, note: '' };
  2074. }
  2075. if (exactMatches.length > 1) {
  2076. // Multiple exact matches - pick first, note the others
  2077. const picked = exactMatches[0]!.node;
  2078. const others = exactMatches.slice(1).map(r =>
  2079. `${r.node.name} (${r.node.kind}) at ${r.node.filePath}:${r.node.startLine}`
  2080. );
  2081. const note = `\n\n> **Note:** ${exactMatches.length} symbols named "${symbol}". Showing results for \`${picked.filePath}:${picked.startLine}\`. Others: ${others.join(', ')}`;
  2082. return { node: picked, note };
  2083. }
  2084. // No exact match. For qualified lookups, don't silently fall back
  2085. // to a fuzzy result — the user typed a specific qualifier, and
  2086. // resolving `stage_apply::nonexistent_fn` to the unrelated
  2087. // `stage_apply.rs` file would be actively misleading (#173).
  2088. if (isQualified) return null;
  2089. return { node: results[0]!.node, note: '' };
  2090. }
  2091. /**
  2092. * Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
  2093. * results across all matching symbols (e.g., multiple classes with an `execute` method).
  2094. */
  2095. private findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
  2096. let results = cg.searchNodes(symbol, { limit: 50 });
  2097. // Mirror the fallback in `findSymbol` for qualified queries — FTS
  2098. // strips colons, so a module-qualified lookup needs a second pass
  2099. // by the bare last part.
  2100. if (results.length === 0 && /[.\/]|::/.test(symbol)) {
  2101. const tail = lastQualifierPart(symbol);
  2102. if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
  2103. }
  2104. if (results.length === 0) {
  2105. return { nodes: [], note: '' };
  2106. }
  2107. const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
  2108. if (exactMatches.length <= 1) {
  2109. const node = exactMatches[0]?.node ?? results[0]!.node;
  2110. return { nodes: [node], note: '' };
  2111. }
  2112. const locations = exactMatches.map(r =>
  2113. `${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`
  2114. );
  2115. const note = `\n\n> **Note:** Aggregated results across ${exactMatches.length} symbols named "${symbol}": ${locations.join(', ')}`;
  2116. return { nodes: exactMatches.map(r => r.node), note };
  2117. }
  2118. /**
  2119. * Truncate output if it exceeds the maximum length
  2120. */
  2121. private truncateOutput(text: string): string {
  2122. if (text.length <= MAX_OUTPUT_LENGTH) return text;
  2123. const truncated = text.slice(0, MAX_OUTPUT_LENGTH);
  2124. const lastNewline = truncated.lastIndexOf('\n');
  2125. const cutPoint = lastNewline > MAX_OUTPUT_LENGTH * 0.8 ? lastNewline : MAX_OUTPUT_LENGTH;
  2126. return truncated.slice(0, cutPoint) + '\n\n... (output truncated)';
  2127. }
  2128. // =========================================================================
  2129. // Formatting helpers (compact by default to reduce context usage)
  2130. // =========================================================================
  2131. private formatSearchResults(results: SearchResult[]): string {
  2132. const lines: string[] = [`## Search Results (${results.length} found)`, ''];
  2133. for (const result of results) {
  2134. const { node } = result;
  2135. const location = node.startLine ? `:${node.startLine}` : '';
  2136. // Compact format: one line per result with key info
  2137. lines.push(`### ${node.name} (${node.kind})`);
  2138. lines.push(`${node.filePath}${location}`);
  2139. if (node.signature) lines.push(`\`${node.signature}\``);
  2140. lines.push('');
  2141. }
  2142. return lines.join('\n');
  2143. }
  2144. private formatNodeList(nodes: Node[], title: string): string {
  2145. const lines: string[] = [`## ${title} (${nodes.length} found)`, ''];
  2146. for (const node of nodes) {
  2147. const location = node.startLine ? `:${node.startLine}` : '';
  2148. // Compact: just name, kind, location
  2149. lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}`);
  2150. }
  2151. return lines.join('\n');
  2152. }
  2153. private formatImpact(symbol: string, impact: Subgraph): string {
  2154. const nodeCount = impact.nodes.size;
  2155. // Compact format: just list affected symbols grouped by file
  2156. const lines: string[] = [
  2157. `## Impact: "${symbol}" affects ${nodeCount} symbols`,
  2158. '',
  2159. ];
  2160. // Group by file
  2161. const byFile = new Map<string, Node[]>();
  2162. for (const node of impact.nodes.values()) {
  2163. const existing = byFile.get(node.filePath) || [];
  2164. existing.push(node);
  2165. byFile.set(node.filePath, existing);
  2166. }
  2167. for (const [file, nodes] of byFile) {
  2168. lines.push(`**${file}:**`);
  2169. // Compact: inline list
  2170. const nodeList = nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
  2171. lines.push(nodeList);
  2172. lines.push('');
  2173. }
  2174. return lines.join('\n');
  2175. }
  2176. /**
  2177. * Build a compact structural outline of a container symbol from its
  2178. * indexed children (methods, fields, properties, …) — name, kind,
  2179. * line number, and signature — so the agent gets the shape of a class
  2180. * without the full source of every method. Returns '' when the container
  2181. * has no indexed children, so the caller can fall back to full source.
  2182. */
  2183. private buildContainerOutline(cg: CodeGraph, node: Node): string {
  2184. const children = cg.getChildren(node.id)
  2185. .filter(c => c.kind !== 'import' && c.kind !== 'export')
  2186. .sort((a, b) => (a.startLine ?? 0) - (b.startLine ?? 0));
  2187. if (children.length === 0) return '';
  2188. const lines = [`**Members (${children.length}):**`, ''];
  2189. for (const c of children) {
  2190. const loc = c.startLine ? `:${c.startLine}` : '';
  2191. const sig = c.signature ? ` — \`${c.signature}\`` : '';
  2192. lines.push(`- ${c.name} (${c.kind})${loc}${sig}`);
  2193. }
  2194. return lines.join('\n');
  2195. }
  2196. private formatNodeDetails(node: Node, code: string | null, outline?: string | null): string {
  2197. const location = node.startLine ? `:${node.startLine}` : '';
  2198. const lines: string[] = [
  2199. `## ${node.name} (${node.kind})`,
  2200. '',
  2201. `**Location:** ${node.filePath}${location}`,
  2202. ];
  2203. if (node.signature) {
  2204. lines.push(`**Signature:** \`${node.signature}\``);
  2205. }
  2206. // Only include docstring if it's short and useful
  2207. if (node.docstring && node.docstring.length < 200) {
  2208. lines.push('', node.docstring);
  2209. }
  2210. if (outline) {
  2211. lines.push('', outline, '',
  2212. `> Structural outline only. Read \`${node.filePath}\` or call codegraph_node on a specific member for its body.`);
  2213. } else if (code) {
  2214. // Line-numbered (cat -n style, like codegraph_explore and Read) so the
  2215. // agent can cite/edit exact lines without re-Reading the file for them.
  2216. const numbered = node.startLine ? numberSourceLines(code, node.startLine) : code;
  2217. lines.push('', '```' + node.language, numbered, '```');
  2218. }
  2219. return lines.join('\n');
  2220. }
  2221. private formatTaskContext(context: TaskContext): string {
  2222. return context.summary || 'No context found';
  2223. }
  2224. private textResult(text: string): ToolResult {
  2225. return {
  2226. content: [{ type: 'text', text }],
  2227. };
  2228. }
  2229. private errorResult(message: string): ToolResult {
  2230. return {
  2231. content: [{ type: 'text', text: `Error: ${message}` }],
  2232. isError: true,
  2233. };
  2234. }
  2235. }