@colbymchenry/codegraph-darwin-x64 0.9.3 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/lib/dist/bin/codegraph.d.ts +3 -0
  2. package/lib/dist/bin/codegraph.d.ts.map +1 -1
  3. package/lib/dist/bin/codegraph.js +238 -0
  4. package/lib/dist/bin/codegraph.js.map +1 -1
  5. package/lib/dist/context/index.d.ts +13 -0
  6. package/lib/dist/context/index.d.ts.map +1 -1
  7. package/lib/dist/context/index.js +120 -1
  8. package/lib/dist/context/index.js.map +1 -1
  9. package/lib/dist/db/index.d.ts +18 -0
  10. package/lib/dist/db/index.d.ts.map +1 -1
  11. package/lib/dist/db/index.js +31 -0
  12. package/lib/dist/db/index.js.map +1 -1
  13. package/lib/dist/db/queries.d.ts +16 -0
  14. package/lib/dist/db/queries.d.ts.map +1 -1
  15. package/lib/dist/db/queries.js +80 -27
  16. package/lib/dist/db/queries.js.map +1 -1
  17. package/lib/dist/extraction/grammars.d.ts +6 -0
  18. package/lib/dist/extraction/grammars.d.ts.map +1 -1
  19. package/lib/dist/extraction/grammars.js +17 -0
  20. package/lib/dist/extraction/grammars.js.map +1 -1
  21. package/lib/dist/extraction/tree-sitter.d.ts.map +1 -1
  22. package/lib/dist/extraction/tree-sitter.js +73 -4
  23. package/lib/dist/extraction/tree-sitter.js.map +1 -1
  24. package/lib/dist/extraction/wasm-runtime-flags.d.ts +12 -0
  25. package/lib/dist/extraction/wasm-runtime-flags.d.ts.map +1 -1
  26. package/lib/dist/extraction/wasm-runtime-flags.js +14 -2
  27. package/lib/dist/extraction/wasm-runtime-flags.js.map +1 -1
  28. package/lib/dist/graph/traversal.d.ts.map +1 -1
  29. package/lib/dist/graph/traversal.js +71 -36
  30. package/lib/dist/graph/traversal.js.map +1 -1
  31. package/lib/dist/index.d.ts.map +1 -1
  32. package/lib/dist/index.js +9 -0
  33. package/lib/dist/index.js.map +1 -1
  34. package/lib/dist/installer/instructions-template.d.ts +2 -2
  35. package/lib/dist/installer/instructions-template.d.ts.map +1 -1
  36. package/lib/dist/installer/instructions-template.js +2 -1
  37. package/lib/dist/installer/instructions-template.js.map +1 -1
  38. package/lib/dist/mcp/index.d.ts +4 -0
  39. package/lib/dist/mcp/index.d.ts.map +1 -1
  40. package/lib/dist/mcp/index.js +97 -0
  41. package/lib/dist/mcp/index.js.map +1 -1
  42. package/lib/dist/mcp/server-instructions.d.ts +1 -1
  43. package/lib/dist/mcp/server-instructions.d.ts.map +1 -1
  44. package/lib/dist/mcp/server-instructions.js +2 -0
  45. package/lib/dist/mcp/server-instructions.js.map +1 -1
  46. package/lib/dist/mcp/tools.d.ts +81 -2
  47. package/lib/dist/mcp/tools.d.ts.map +1 -1
  48. package/lib/dist/mcp/tools.js +664 -24
  49. package/lib/dist/mcp/tools.js.map +1 -1
  50. package/lib/dist/resolution/callback-synthesizer.d.ts +9 -0
  51. package/lib/dist/resolution/callback-synthesizer.d.ts.map +1 -0
  52. package/lib/dist/resolution/callback-synthesizer.js +576 -0
  53. package/lib/dist/resolution/callback-synthesizer.js.map +1 -0
  54. package/lib/dist/resolution/frameworks/csharp.d.ts.map +1 -1
  55. package/lib/dist/resolution/frameworks/csharp.js +36 -8
  56. package/lib/dist/resolution/frameworks/csharp.js.map +1 -1
  57. package/lib/dist/resolution/frameworks/drupal.d.ts.map +1 -1
  58. package/lib/dist/resolution/frameworks/drupal.js +44 -12
  59. package/lib/dist/resolution/frameworks/drupal.js.map +1 -1
  60. package/lib/dist/resolution/frameworks/express.d.ts.map +1 -1
  61. package/lib/dist/resolution/frameworks/express.js +102 -19
  62. package/lib/dist/resolution/frameworks/express.js.map +1 -1
  63. package/lib/dist/resolution/frameworks/go.d.ts.map +1 -1
  64. package/lib/dist/resolution/frameworks/go.js +6 -3
  65. package/lib/dist/resolution/frameworks/go.js.map +1 -1
  66. package/lib/dist/resolution/frameworks/index.d.ts +1 -0
  67. package/lib/dist/resolution/frameworks/index.d.ts.map +1 -1
  68. package/lib/dist/resolution/frameworks/index.js +5 -1
  69. package/lib/dist/resolution/frameworks/index.js.map +1 -1
  70. package/lib/dist/resolution/frameworks/java.d.ts.map +1 -1
  71. package/lib/dist/resolution/frameworks/java.js +70 -12
  72. package/lib/dist/resolution/frameworks/java.js.map +1 -1
  73. package/lib/dist/resolution/frameworks/laravel.d.ts.map +1 -1
  74. package/lib/dist/resolution/frameworks/laravel.js +17 -8
  75. package/lib/dist/resolution/frameworks/laravel.js.map +1 -1
  76. package/lib/dist/resolution/frameworks/play.d.ts +19 -0
  77. package/lib/dist/resolution/frameworks/play.d.ts.map +1 -0
  78. package/lib/dist/resolution/frameworks/play.js +111 -0
  79. package/lib/dist/resolution/frameworks/play.js.map +1 -0
  80. package/lib/dist/resolution/frameworks/python.d.ts.map +1 -1
  81. package/lib/dist/resolution/frameworks/python.js +134 -16
  82. package/lib/dist/resolution/frameworks/python.js.map +1 -1
  83. package/lib/dist/resolution/frameworks/react.d.ts.map +1 -1
  84. package/lib/dist/resolution/frameworks/react.js +96 -3
  85. package/lib/dist/resolution/frameworks/react.js.map +1 -1
  86. package/lib/dist/resolution/frameworks/ruby.d.ts.map +1 -1
  87. package/lib/dist/resolution/frameworks/ruby.js +106 -2
  88. package/lib/dist/resolution/frameworks/ruby.js.map +1 -1
  89. package/lib/dist/resolution/frameworks/rust.d.ts.map +1 -1
  90. package/lib/dist/resolution/frameworks/rust.js +102 -5
  91. package/lib/dist/resolution/frameworks/rust.js.map +1 -1
  92. package/lib/dist/resolution/frameworks/swift.d.ts.map +1 -1
  93. package/lib/dist/resolution/frameworks/swift.js +30 -6
  94. package/lib/dist/resolution/frameworks/swift.js.map +1 -1
  95. package/lib/dist/resolution/index.d.ts.map +1 -1
  96. package/lib/dist/resolution/index.js +61 -9
  97. package/lib/dist/resolution/index.js.map +1 -1
  98. package/lib/dist/resolution/lru-cache.d.ts +24 -0
  99. package/lib/dist/resolution/lru-cache.d.ts.map +1 -0
  100. package/lib/dist/resolution/lru-cache.js +62 -0
  101. package/lib/dist/resolution/lru-cache.js.map +1 -0
  102. package/lib/dist/resolution/types.d.ts +8 -0
  103. package/lib/dist/resolution/types.d.ts.map +1 -1
  104. package/lib/dist/utils.js +1 -1
  105. package/lib/package.json +1 -1
  106. package/package.json +1 -1
@@ -49,6 +49,20 @@ const os_1 = require("os");
49
49
  const path_1 = require("path");
50
50
  /** Maximum output length to prevent context bloat (characters) */
51
51
  const MAX_OUTPUT_LENGTH = 15000;
52
+ /**
53
+ * Maximum length for free-form string inputs (query, task, symbol).
54
+ * Bounds memory and CPU when a buggy or hostile MCP client sends a
55
+ * huge payload — without this an attacker could ship a 100MB string
56
+ * and force a full FTS5 scan / OOM the server. 10 000 characters is
57
+ * far beyond any realistic legitimate query.
58
+ */
59
+ const MAX_INPUT_LENGTH = 10_000;
60
+ /**
61
+ * Maximum length for path-like string inputs (projectPath, path
62
+ * filter, glob pattern). Paths beyond a few thousand chars are
63
+ * never legitimate and signal abuse or a bug upstream.
64
+ */
65
+ const MAX_PATH_LENGTH = 4_096;
52
66
  /**
53
67
  * Rust path roots that have no file-system equivalent — `crate` is the
54
68
  * current crate, `super` is the parent module, `self` is the current
@@ -104,12 +118,17 @@ function getExploreOutputBudget(fileCount) {
104
118
  }
105
119
  if (fileCount < 5000) {
106
120
  return {
107
- maxOutputChars: 13000,
108
- defaultMaxFiles: 6,
109
- maxCharsPerFile: 2500,
110
- gapThreshold: 10,
111
- maxSymbolsInFileHeader: 8,
112
- maxEdgesPerRelationshipKind: 8,
121
+ // Sized so ONE explore can cover a flow that centers on a god-file (e.g.
122
+ // excalidraw's 415 KB App.tsx): the previous 2500/file returned <1% of such
123
+ // a file, forcing the agent to Read it anyway. Per-file must also stay ≥ the
124
+ // smaller <500 tier (3800) — the old 2500 was non-monotonic. Tokens are
125
+ // cheap relative to a 5–10 Read round-trip spiral; favor sufficiency.
126
+ maxOutputChars: 28000,
127
+ defaultMaxFiles: 10,
128
+ maxCharsPerFile: 6500,
129
+ gapThreshold: 12,
130
+ maxSymbolsInFileHeader: 10,
131
+ maxEdgesPerRelationshipKind: 10,
113
132
  includeRelationships: true,
114
133
  includeAdditionalFiles: true,
115
134
  includeCompletenessSignal: true,
@@ -191,6 +210,18 @@ function markSessionConsulted(sessionId) {
191
210
  try {
192
211
  const hash = (0, crypto_1.createHash)('md5').update(sessionId).digest('hex').slice(0, 16);
193
212
  const markerPath = (0, path_1.join)((0, os_1.tmpdir)(), `codegraph-consulted-${hash}`);
213
+ // Refuse to follow a pre-planted symlink at the marker path (CWE-59).
214
+ // O_NOFOLLOW (below) is the atomic, TOCTOU-free guard on POSIX, but it is
215
+ // `undefined` on Windows (libuv ignores it), so the bitwise-OR silently
216
+ // drops it and openSync would follow the link. This lstat check closes that
217
+ // gap cross-platform; ENOENT (path is free) falls through to create it.
218
+ try {
219
+ if ((0, fs_1.lstatSync)(markerPath).isSymbolicLink())
220
+ return;
221
+ }
222
+ catch {
223
+ // No existing entry (or stat failed) — nothing to refuse; proceed.
224
+ }
194
225
  // O_NOFOLLOW makes openSync throw ELOOP if markerPath is already a symlink.
195
226
  // O_CREAT + O_TRUNC keep the original "create-or-overwrite" semantics, and
196
227
  // mode 0o600 prevents readback by other local users (the marker payload is
@@ -338,7 +369,7 @@ exports.tools = [
338
369
  },
339
370
  {
340
371
  name: 'codegraph_node',
341
- description: 'Get detailed info about ONE symbol (location, signature, docstring). Pass includeCode=true for source: a function/method returns its body; a class/interface/struct/enum returns a compact member OUTLINE (fields + method signatures + line numbers), not every method body — Read or codegraph_node a specific member for its body. Keep includeCode=false to minimize context. For SEVERAL related symbols, make ONE codegraph_explore (or codegraph_context) call instead of many node calls repeated node calls each re-read the whole context and cost far more.',
372
+ 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.',
342
373
  inputSchema: {
343
374
  type: 'object',
344
375
  properties: {
@@ -358,7 +389,7 @@ exports.tools = [
358
389
  },
359
390
  {
360
391
  name: 'codegraph_explore',
361
- 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".',
392
+ 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.',
362
393
  inputSchema: {
363
394
  type: 'object',
364
395
  properties: {
@@ -419,6 +450,25 @@ exports.tools = [
419
450
  },
420
451
  },
421
452
  },
453
+ {
454
+ name: 'codegraph_trace',
455
+ 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.',
456
+ inputSchema: {
457
+ type: 'object',
458
+ properties: {
459
+ from: {
460
+ type: 'string',
461
+ description: 'Symbol the flow starts at (e.g., "QuerySet", "handleRequest", "mutateElement")',
462
+ },
463
+ to: {
464
+ type: 'string',
465
+ description: 'Symbol the flow should reach (e.g., "execute_sql", "render", "setState")',
466
+ },
467
+ projectPath: projectPathProperty,
468
+ },
469
+ required: ['from', 'to'],
470
+ },
471
+ },
422
472
  ];
423
473
  /**
424
474
  * Tool handler that executes tools against a CodeGraph instance
@@ -455,18 +505,44 @@ class ToolHandler {
455
505
  hasDefaultCodeGraph() {
456
506
  return this.cg !== null;
457
507
  }
508
+ /**
509
+ * Optional allowlist of exposed tools, parsed from the CODEGRAPH_MCP_TOOLS
510
+ * env var (comma-separated short names, e.g. "trace,search,node,context").
511
+ * Unset/empty → every tool is exposed. Lets an operator (or an A/B harness)
512
+ * trim the tool surface without rebuilding the client config; the ablated
513
+ * tool is then truly absent from ListTools rather than merely denied on call.
514
+ * Matching is on the short form, so "trace" and "codegraph_trace" both work.
515
+ */
516
+ toolAllowlist() {
517
+ const raw = process.env.CODEGRAPH_MCP_TOOLS;
518
+ if (!raw || !raw.trim())
519
+ return null;
520
+ const short = (s) => s.trim().replace(/^codegraph_/, '');
521
+ const set = new Set(raw.split(',').map(short).filter(Boolean));
522
+ return set.size ? set : null;
523
+ }
524
+ /** Whether a tool name passes the CODEGRAPH_MCP_TOOLS allowlist (if any). */
525
+ isToolAllowed(name) {
526
+ const allow = this.toolAllowlist();
527
+ return !allow || allow.has(name.replace(/^codegraph_/, ''));
528
+ }
458
529
  /**
459
530
  * Get tool definitions with dynamic descriptions based on project size.
460
531
  * The codegraph_explore tool description includes a budget recommendation
461
- * scaled to the number of indexed files.
532
+ * scaled to the number of indexed files. Honors the CODEGRAPH_MCP_TOOLS
533
+ * allowlist so a trimmed surface is reflected in ListTools.
462
534
  */
463
535
  getTools() {
536
+ const allow = this.toolAllowlist();
537
+ const visible = allow
538
+ ? exports.tools.filter(t => allow.has(t.name.replace(/^codegraph_/, '')))
539
+ : exports.tools;
464
540
  if (!this.cg)
465
- return exports.tools;
541
+ return visible;
466
542
  try {
467
543
  const stats = this.cg.getStats();
468
544
  const budget = getExploreBudget(stats.fileCount);
469
- return exports.tools.map(tool => {
545
+ return visible.map(tool => {
470
546
  if (tool.name === 'codegraph_explore') {
471
547
  return {
472
548
  ...tool,
@@ -477,7 +553,7 @@ class ToolHandler {
477
553
  });
478
554
  }
479
555
  catch {
480
- return exports.tools;
556
+ return visible;
481
557
  }
482
558
  }
483
559
  /**
@@ -507,6 +583,17 @@ class ToolHandler {
507
583
  if (this.projectCache.has(projectPath)) {
508
584
  return this.projectCache.get(projectPath);
509
585
  }
586
+ // Reject sensitive system directories before opening. Only validate a
587
+ // path that actually exists — a nested or not-yet-created sub-path of a
588
+ // real project must still be allowed to resolve UP to its .codegraph/
589
+ // root below (issue #238), so we don't run the existence-checking
590
+ // validator on paths that are meant to walk up.
591
+ if ((0, fs_1.existsSync)(projectPath)) {
592
+ const pathError = (0, utils_1.validateProjectPath)(projectPath);
593
+ if (pathError) {
594
+ throw new Error(pathError);
595
+ }
596
+ }
510
597
  // Walk up parent directories to find nearest .codegraph/
511
598
  const resolvedRoot = (0, index_1.findNearestCodeGraphRoot)(projectPath);
512
599
  if (!resolvedRoot) {
@@ -547,12 +634,35 @@ class ToolHandler {
547
634
  this.projectCache.clear();
548
635
  }
549
636
  /**
550
- * Validate that a value is a non-empty string
637
+ * Validate that a value is a non-empty string within length bounds.
638
+ *
639
+ * The `maxLength` cap protects against MCP clients that ship huge
640
+ * payloads (10MB+ query strings either by accident or maliciously).
641
+ * Without this, a single oversized input can pin the FTS5 index or
642
+ * exhaust memory before any real work runs.
551
643
  */
552
- validateString(value, name) {
644
+ validateString(value, name, maxLength = MAX_INPUT_LENGTH) {
553
645
  if (typeof value !== 'string' || value.length === 0) {
554
646
  return this.errorResult(`${name} must be a non-empty string`);
555
647
  }
648
+ if (value.length > maxLength) {
649
+ return this.errorResult(`${name} exceeds maximum length of ${maxLength} characters (got ${value.length})`);
650
+ }
651
+ return value;
652
+ }
653
+ /**
654
+ * Validate an optional path-like string input. Returns the value if
655
+ * valid (or undefined), or a ToolResult with the error.
656
+ */
657
+ validateOptionalPath(value, name) {
658
+ if (value === undefined || value === null)
659
+ return undefined;
660
+ if (typeof value !== 'string') {
661
+ return this.errorResult(`${name} must be a string`);
662
+ }
663
+ if (value.length > MAX_PATH_LENGTH) {
664
+ return this.errorResult(`${name} exceeds maximum length of ${MAX_PATH_LENGTH} characters (got ${value.length})`);
665
+ }
556
666
  return value;
557
667
  }
558
668
  /**
@@ -560,6 +670,31 @@ class ToolHandler {
560
670
  */
561
671
  async execute(toolName, args) {
562
672
  try {
673
+ // Honor the optional tool allowlist (CODEGRAPH_MCP_TOOLS): a trimmed
674
+ // surface rejects ablated tools defensively even if a client cached them.
675
+ if (!this.isToolAllowed(toolName)) {
676
+ return this.errorResult(`Tool ${toolName} is disabled via CODEGRAPH_MCP_TOOLS`);
677
+ }
678
+ // Cross-cutting input validation. All tools accept an optional
679
+ // `projectPath` and most accept either `query`, `task`, or
680
+ // `symbol` — bound their lengths centrally so individual handlers
681
+ // can stay focused on tool-specific logic.
682
+ const pathCheck = this.validateOptionalPath(args.projectPath, 'projectPath');
683
+ if (typeof pathCheck === 'object' && pathCheck !== undefined) {
684
+ return pathCheck;
685
+ }
686
+ // The `path` and `pattern` properties used by codegraph_files are
687
+ // also path-shaped — apply the same cap.
688
+ if (args.path !== undefined) {
689
+ const check = this.validateOptionalPath(args.path, 'path');
690
+ if (typeof check === 'object' && check !== undefined)
691
+ return check;
692
+ }
693
+ if (args.pattern !== undefined) {
694
+ const check = this.validateOptionalPath(args.pattern, 'pattern');
695
+ if (typeof check === 'object' && check !== undefined)
696
+ return check;
697
+ }
563
698
  switch (toolName) {
564
699
  case 'codegraph_search':
565
700
  return await this.handleSearch(args);
@@ -579,6 +714,8 @@ class ToolHandler {
579
714
  return await this.handleStatus(args);
580
715
  case 'codegraph_files':
581
716
  return await this.handleFiles(args);
717
+ case 'codegraph_trace':
718
+ return await this.handleTrace(args);
582
719
  default:
583
720
  return this.errorResult(`Unknown tool: ${toolName}`);
584
721
  }
@@ -635,10 +772,10 @@ class ToolHandler {
635
772
  : '';
636
773
  // buildContext returns string when format is 'markdown'
637
774
  if (typeof context === 'string') {
638
- return this.textResult(context + reminder);
775
+ return this.textResult(this.truncateOutput(context + reminder));
639
776
  }
640
777
  // If it returns TaskContext, format it
641
- return this.textResult(this.formatTaskContext(context) + reminder);
778
+ return this.textResult(this.truncateOutput(this.formatTaskContext(context) + reminder));
642
779
  }
643
780
  /**
644
781
  * Heuristic to detect if a query looks like a feature request
@@ -764,6 +901,395 @@ class ToolHandler {
764
901
  const formatted = this.formatImpact(symbol, mergedImpact) + allMatches.note;
765
902
  return this.textResult(this.truncateOutput(formatted));
766
903
  }
904
+ /**
905
+ * Handle codegraph_trace — shortest CALL PATH between two symbols.
906
+ *
907
+ * Exposes GraphTraverser.findPath: the chain of functions from `from` to `to`,
908
+ * each hop annotated with file:line and the call-site line. This is the
909
+ * capability grep/Read structurally cannot provide. When no static path
910
+ * exists, the chain has almost certainly broken at dynamic dispatch
911
+ * (callbacks, descriptors, metaclasses) — we say so and surface the start
912
+ * symbol's outgoing calls so the agent bridges the one missing hop with
913
+ * codegraph_node rather than blindly reading.
914
+ */
915
+ async handleTrace(args) {
916
+ const from = this.validateString(args.from, 'from');
917
+ if (typeof from !== 'string')
918
+ return from;
919
+ const to = this.validateString(args.to, 'to');
920
+ if (typeof to !== 'string')
921
+ return to;
922
+ const cg = this.getCodeGraph(args.projectPath);
923
+ const fromMatches = this.findAllSymbols(cg, from);
924
+ if (fromMatches.nodes.length === 0)
925
+ return this.textResult(`Symbol "${from}" not found in the codebase`);
926
+ const toMatches = this.findAllSymbols(cg, to);
927
+ if (toMatches.nodes.length === 0)
928
+ return this.textResult(`Symbol "${to}" not found in the codebase`);
929
+ // Trace along call edges only — a true call path. Names can map to several
930
+ // nodes, so try a few from×to candidate pairs until a usable path turns up.
931
+ //
932
+ // MAX_HOPS guard: a BFS shortest path longer than this on a dense call graph
933
+ // is almost always a spurious wander through unrelated code (django's
934
+ // `_fetch_all → … → execute_sql` BFS detours through prefetch/filter), not
935
+ // the real execution flow — and a confident-but-wrong 15-hop trace is worse
936
+ // than none. Over-cap paths are rejected and reported as "no direct path"
937
+ // (which, on real code, means the flow breaks at dynamic dispatch).
938
+ const edgeKinds = ['calls'];
939
+ const MAX_HOPS = 7;
940
+ const fromTry = fromMatches.nodes.slice(0, 3);
941
+ const toTry = toMatches.nodes.slice(0, 3);
942
+ let path = null;
943
+ let overCap = null;
944
+ for (const f of fromTry) {
945
+ for (const t of toTry) {
946
+ const p = cg.findPath(f.id, t.id, edgeKinds);
947
+ if (!p || p.length <= 1)
948
+ continue;
949
+ if (p.length <= MAX_HOPS) {
950
+ path = p;
951
+ break;
952
+ }
953
+ if (!overCap || p.length < overCap.length)
954
+ overCap = p;
955
+ }
956
+ if (path)
957
+ break;
958
+ }
959
+ if (!path) {
960
+ // No static path — almost always a dynamic-dispatch break. Surface the
961
+ // start symbol's outgoing calls so the agent can bridge the gap.
962
+ const start = fromTry[0];
963
+ const callees = cg.getCallees(start.id).slice(0, 10)
964
+ .map(c => `${c.node.name} (${c.node.filePath}:${c.node.startLine})`);
965
+ const lines = [
966
+ `No direct call path from "${from}" to "${to}".`,
967
+ '',
968
+ (overCap
969
+ ? `(Only a ${overCap.length}-hop indirect chain connects them — almost certainly a BFS wander through unrelated code, not the real flow.) `
970
+ : '') +
971
+ 'The direct chain most likely breaks at **dynamic dispatch** (a callback, descriptor, ' +
972
+ 'metaclass, or attribute-as-callable) that static parsing cannot resolve into an edge. ' +
973
+ `Inspect \`${start.name}\` (${start.filePath}:${start.startLine}) with codegraph_node ` +
974
+ '(includeCode=true) — its body usually shows the dynamic call to follow next.',
975
+ ];
976
+ if (callees.length > 0) {
977
+ lines.push('', `**${start.name} statically calls:** ${callees.join(', ')}`);
978
+ }
979
+ return this.textResult(lines.join('\n') + fromMatches.note + toMatches.note);
980
+ }
981
+ const lines = [
982
+ `## Trace: ${from} → ${to}`,
983
+ '',
984
+ `Full execution path below — ${path.length} hops, each with its body, plus what the destination calls. This is the complete flow; answer from it.`,
985
+ '',
986
+ `${path.length} hops:`,
987
+ '',
988
+ ];
989
+ // Inline what each hop needs so the agent doesn't Read/Grep to get it: the
990
+ // call-site source line, the registration site for dynamic-dispatch hops, AND
991
+ // the hop's own body (capped per hop so the trace stays path-scoped). Earlier
992
+ // versions inlined only the call-site line, which left agents calling explore
993
+ // or Read for the bodies — the exact follow-up the ablation experiment measured.
994
+ const fileCache = new Map();
995
+ for (let i = 0; i < path.length; i++) {
996
+ const step = path[i];
997
+ if (step.edge) {
998
+ const synth = this.synthEdgeNote(step.edge);
999
+ if (synth) {
1000
+ lines.push(` ↓ ${synth.label}`);
1001
+ if (synth.registeredAt) {
1002
+ const regSrc = this.sourceLineAt(cg, synth.registeredAt, fileCache);
1003
+ lines.push(` ↳ registered at ${synth.registeredAt}${regSrc ? ` ${regSrc}` : ''}`);
1004
+ }
1005
+ }
1006
+ else {
1007
+ // The call happens in the PREVIOUS hop's file at edge.line.
1008
+ const prev = path[i - 1];
1009
+ const ref = prev && step.edge.line ? `${prev.node.filePath}:${step.edge.line}` : undefined;
1010
+ const callSrc = this.sourceLineAt(cg, ref, fileCache);
1011
+ lines.push(` ↓ ${step.edge.kind}${step.edge.line ? `@${step.edge.line}` : ''}${callSrc ? ` ${callSrc}` : ''}`);
1012
+ }
1013
+ }
1014
+ lines.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine}-${step.node.endLine})`);
1015
+ const body = this.sourceRangeAt(cg, step.node.filePath, step.node.startLine, step.node.endLine, fileCache, 60, 1800);
1016
+ if (body)
1017
+ lines.push(body);
1018
+ }
1019
+ // The "last mile": what the destination does next. Agents otherwise explore/Read
1020
+ // for exactly this (e.g. renderStaticScene → _renderStaticScene → the canvas draw),
1021
+ // so inlining the destination's callees is what actually stops the investigation —
1022
+ // sufficiency, not a "don't explore" instruction.
1023
+ const dest = path[path.length - 1].node;
1024
+ const destCallees = cg.getCallees(dest.id)
1025
+ .filter(c => !path.some(p => p.node.id === c.node.id))
1026
+ .slice(0, 6);
1027
+ if (destCallees.length > 0) {
1028
+ lines.push('', `### \`${dest.name}\` then calls (the destination's immediate work):`);
1029
+ for (const c of destCallees) {
1030
+ lines.push('', `- ${c.node.name} (${c.node.filePath}:${c.node.startLine}-${c.node.endLine})`);
1031
+ const body = this.sourceRangeAt(cg, c.node.filePath, c.node.startLine, c.node.endLine, fileCache, 16, 600);
1032
+ if (body)
1033
+ lines.push(body);
1034
+ }
1035
+ }
1036
+ 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.');
1037
+ return this.textResult(this.truncateOutput(lines.join('\n')));
1038
+ }
1039
+ /**
1040
+ * Describe a synthesized (dynamic-dispatch) edge for human output: how the
1041
+ * callback was wired up — the bridge static parsing can't see. Returns null
1042
+ * for ordinary static edges. Used by trace + the node trail so a synthesized
1043
+ * hop reads as "registered via onUpdate at App.tsx:3148", not a bare arrow.
1044
+ */
1045
+ synthEdgeNote(edge) {
1046
+ if (!edge || edge.provenance !== 'heuristic')
1047
+ return null;
1048
+ const m = edge.metadata;
1049
+ const registeredAt = typeof m?.registeredAt === 'string' ? m.registeredAt : undefined;
1050
+ const at = registeredAt ? ` @${registeredAt}` : '';
1051
+ if (m?.synthesizedBy === 'callback') {
1052
+ const via = m.via ? `\`${String(m.via)}\`` : 'a registrar';
1053
+ const field = m.field ? ` on .${String(m.field)}` : '';
1054
+ return {
1055
+ label: `callback — registered via ${via}${field} (dynamic dispatch)`,
1056
+ compact: `dynamic: callback via ${via}${at}`,
1057
+ registeredAt,
1058
+ };
1059
+ }
1060
+ if (m?.synthesizedBy === 'event-emitter') {
1061
+ const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
1062
+ return {
1063
+ label: `event ${ev} — emit → handler (dynamic dispatch)`,
1064
+ compact: `dynamic: event ${ev}${at}`,
1065
+ registeredAt,
1066
+ };
1067
+ }
1068
+ if (m?.synthesizedBy === 'react-render') {
1069
+ return {
1070
+ label: `React re-render — \`setState\` re-runs render() (dynamic dispatch)`,
1071
+ compact: `dynamic: React re-render via setState${at}`,
1072
+ registeredAt,
1073
+ };
1074
+ }
1075
+ if (m?.synthesizedBy === 'jsx-render') {
1076
+ const child = m.via ? `<${String(m.via)}>` : 'a child component';
1077
+ return {
1078
+ label: `renders ${child} (JSX child — dynamic dispatch)`,
1079
+ compact: `dynamic: renders ${child}`,
1080
+ registeredAt,
1081
+ };
1082
+ }
1083
+ if (m?.synthesizedBy === 'vue-handler') {
1084
+ const ev = m.event ? `@${String(m.event)}` : 'a template event';
1085
+ return {
1086
+ label: `Vue template handler — bound to ${ev} (dynamic dispatch)`,
1087
+ compact: `dynamic: Vue ${ev} handler`,
1088
+ registeredAt,
1089
+ };
1090
+ }
1091
+ if (m?.synthesizedBy === 'interface-impl') {
1092
+ return {
1093
+ label: `interface/abstract dispatch — runs the implementation override (dynamic dispatch)`,
1094
+ compact: `dynamic: interface → impl${at}`,
1095
+ registeredAt,
1096
+ };
1097
+ }
1098
+ return null;
1099
+ }
1100
+ /**
1101
+ * Read one trimmed source line at "relpath:line" (relative to the project
1102
+ * root). `cache` holds split file contents so a multi-hop trace reads each
1103
+ * file at most once. Returns null if the file/line can't be resolved.
1104
+ */
1105
+ sourceLineAt(cg, ref, cache) {
1106
+ if (!ref)
1107
+ return null;
1108
+ const i = ref.lastIndexOf(':');
1109
+ if (i < 0)
1110
+ return null;
1111
+ const filePath = ref.slice(0, i);
1112
+ const line = parseInt(ref.slice(i + 1), 10);
1113
+ if (!Number.isFinite(line) || line < 1)
1114
+ return null;
1115
+ let fileLines = cache.get(filePath);
1116
+ if (!fileLines) {
1117
+ const abs = (0, utils_1.validatePathWithinRoot)(cg.getProjectRoot(), filePath);
1118
+ if (!abs || !(0, fs_1.existsSync)(abs))
1119
+ return null;
1120
+ try {
1121
+ fileLines = (0, fs_1.readFileSync)(abs, 'utf-8').split('\n');
1122
+ }
1123
+ catch {
1124
+ return null;
1125
+ }
1126
+ cache.set(filePath, fileLines);
1127
+ }
1128
+ const raw = fileLines[line - 1];
1129
+ if (raw == null)
1130
+ return null;
1131
+ const t = raw.trim();
1132
+ return t.length > 160 ? t.slice(0, 157) + '…' : t;
1133
+ }
1134
+ /**
1135
+ * Read a hop's body — filePath lines [startLine..endLine] — for inlining into
1136
+ * a trace, capped (lines + chars) so the whole path stays path-scoped even on
1137
+ * a 7-hop chain. Dedents to the body's own indentation and marks truncation.
1138
+ * Shares `cache` with sourceLineAt so each file is read at most once per trace.
1139
+ */
1140
+ sourceRangeAt(cg, filePath, startLine, endLine, cache, maxLines = 28, maxChars = 1200) {
1141
+ if (!Number.isFinite(startLine) || startLine < 1)
1142
+ return null;
1143
+ let fileLines = cache.get(filePath);
1144
+ if (!fileLines) {
1145
+ const abs = (0, utils_1.validatePathWithinRoot)(cg.getProjectRoot(), filePath);
1146
+ if (!abs || !(0, fs_1.existsSync)(abs))
1147
+ return null;
1148
+ try {
1149
+ fileLines = (0, fs_1.readFileSync)(abs, 'utf-8').split('\n');
1150
+ }
1151
+ catch {
1152
+ return null;
1153
+ }
1154
+ cache.set(filePath, fileLines);
1155
+ }
1156
+ const end = Number.isFinite(endLine) && endLine >= startLine ? endLine : startLine;
1157
+ let slice = fileLines.slice(startLine - 1, end);
1158
+ if (slice.length === 0)
1159
+ return null;
1160
+ let omitted = 0;
1161
+ if (slice.length > maxLines) {
1162
+ omitted = slice.length - maxLines;
1163
+ slice = slice.slice(0, maxLines);
1164
+ }
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)
1173
+ text += `\n … (+${omitted} more line${omitted === 1 ? '' : 's'})`;
1174
+ return text;
1175
+ }
1176
+ /**
1177
+ * Flow-from-named-symbols: an agent's codegraph_explore query is a bag of
1178
+ * symbol names that usually spans the flow it's investigating (e.g.
1179
+ * "PmsProductController getList PmsProductService list PmsProductServiceImpl").
1180
+ * Surface the longest call chain AMONG those named symbols — scoped to what the
1181
+ * agent explicitly named, so (unlike a fuzzy relevance set) there's no
1182
+ * wrong-feature wandering. Rides synthesized edges, so controller→service-
1183
+ * interface→impl shows up. Returns '' if no chain of >=3 nodes exists.
1184
+ *
1185
+ * Ambiguous tokens (Java `list` → dozens of nodes) are disambiguated by
1186
+ * CO-NAMING: the agent names the class too, so we keep only `list` candidates
1187
+ * whose qualifiedName contains another named token (`PmsProductServiceImpl::list`),
1188
+ * dropping unrelated `OmsOrderService::list`.
1189
+ */
1190
+ buildFlowFromNamedSymbols(cg, query) {
1191
+ try {
1192
+ const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
1193
+ // Strip only a REAL file extension (Create.cs → Create); KEEP qualified
1194
+ // names (Class.method / Class::method) — the agent's most precise input,
1195
+ // resolved exactly by findAllSymbols. (The old strip mangled Class.method
1196
+ // into Class, throwing the method away.)
1197
+ const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte)$/i;
1198
+ const tokens = [...new Set(query.split(/[\s,()[\]]+/)
1199
+ .map((t) => t.replace(FILE_EXT, '').trim())
1200
+ .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)))].slice(0, 16);
1201
+ if (tokens.length < 2)
1202
+ return '';
1203
+ // Pool of name SEGMENTS (Class + method from every token) used to
1204
+ // disambiguate an ambiguous SIMPLE name: keep a candidate only if its
1205
+ // CONTAINER class is itself named in the query.
1206
+ const segPool = new Set();
1207
+ for (const t of tokens)
1208
+ for (const s of t.toLowerCase().split(/::|\./))
1209
+ if (s)
1210
+ segPool.add(s);
1211
+ const named = new Map();
1212
+ for (const t of tokens) {
1213
+ const cands = this.findAllSymbols(cg, t).nodes.filter((n) => CALLABLE.has(n.kind));
1214
+ // A qualified or otherwise-specific name (<=3 hits) keeps all; an
1215
+ // ambiguous simple name keeps only candidates whose container is named.
1216
+ const pick = cands.length <= 3
1217
+ ? cands
1218
+ : cands.filter((n) => {
1219
+ const segs = (n.qualifiedName || '').toLowerCase().split(/::|\./).filter(Boolean);
1220
+ const container = segs.length >= 2 ? segs[segs.length - 2] : '';
1221
+ return !!container && segPool.has(container);
1222
+ });
1223
+ for (const n of pick.slice(0, 6))
1224
+ named.set(n.id, n);
1225
+ if (named.size > 40)
1226
+ break;
1227
+ }
1228
+ if (named.size < 2)
1229
+ return '';
1230
+ const MAX_HOPS = 7;
1231
+ let best = null;
1232
+ // BFS the full call graph (incl. synth edges) from each named seed, but
1233
+ // only ACCEPT a sink that is also named — both ends anchored to symbols the
1234
+ // agent named, so the chain stays on-topic while bridging intermediates
1235
+ // (e.g. the exact interface overload) that the token resolution missed.
1236
+ for (const seed of [...named.values()].slice(0, 8)) {
1237
+ const parent = new Map();
1238
+ parent.set(seed.id, { prev: null, edge: null, node: seed });
1239
+ const q = [{ id: seed.id, depth: 0, streak: 0 }];
1240
+ let deep = null, deepDepth = 0;
1241
+ const MAX_BRIDGE = 1; // ≤1 consecutive UNNAMED hop: bridge one missing intermediate, never wander a god-function's fan-out
1242
+ for (let h = 0; h < q.length && parent.size < 1500; h++) {
1243
+ const { id, depth, streak } = q[h];
1244
+ if (id !== seed.id && named.has(id) && depth > deepDepth) {
1245
+ deep = id;
1246
+ deepDepth = depth;
1247
+ }
1248
+ if (depth >= MAX_HOPS - 1)
1249
+ continue;
1250
+ for (const c of cg.getCallees(id)) {
1251
+ if (c.edge.kind !== 'calls' || parent.has(c.node.id))
1252
+ continue;
1253
+ const newStreak = named.has(c.node.id) ? 0 : streak + 1;
1254
+ if (newStreak > MAX_BRIDGE)
1255
+ continue;
1256
+ parent.set(c.node.id, { prev: id, edge: c.edge, node: c.node });
1257
+ q.push({ id: c.node.id, depth: depth + 1, streak: newStreak });
1258
+ }
1259
+ }
1260
+ if (!deep)
1261
+ continue;
1262
+ const chain = [];
1263
+ let cur = deep;
1264
+ while (cur) {
1265
+ const p = parent.get(cur);
1266
+ if (!p)
1267
+ break;
1268
+ chain.push({ node: p.node, edge: p.edge });
1269
+ cur = p.prev;
1270
+ }
1271
+ chain.reverse();
1272
+ if (!best || chain.length > best.length)
1273
+ best = chain;
1274
+ }
1275
+ if (!best || best.length < 3)
1276
+ return '';
1277
+ const out = ['## Flow (call path among the symbols you queried)', ''];
1278
+ for (let i = 0; i < best.length; i++) {
1279
+ const step = best[i];
1280
+ if (step.edge) {
1281
+ const sy = this.synthEdgeNote(step.edge);
1282
+ out.push(` ↓ ${sy ? sy.compact : step.edge.kind}`);
1283
+ }
1284
+ out.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`);
1285
+ }
1286
+ out.push('', '> Full source for these symbols is below; codegraph_trace(from,to) for the exact path between two endpoints.', '');
1287
+ return out.join('\n');
1288
+ }
1289
+ catch {
1290
+ return '';
1291
+ }
1292
+ }
767
1293
  /**
768
1294
  * Handle codegraph_explore — deep exploration in a single call
769
1295
  *
@@ -805,6 +1331,43 @@ class ToolHandler {
805
1331
  if (subgraph.nodes.size === 0) {
806
1332
  return this.textResult(`No relevant code found for "${query}"`);
807
1333
  }
1334
+ // Graph-aware glue: findRelevantContext builds the subgraph from name/text
1335
+ // search, so a method that BRIDGES named symbols — e.g. App.tsx's
1336
+ // triggerRender, which calls the named triggerUpdate — is never a search hit
1337
+ // and gets missed, forcing the agent to Read the file to trace it. Pull in
1338
+ // the callers/callees of the entry (root) nodes, but ONLY those that live in
1339
+ // files the subgraph already surfaces (where the agent reads to fill gaps),
1340
+ // so we add wiring without dragging in unrelated files. These get an
1341
+ // importance boost below so they survive the per-file cluster budget.
1342
+ const glueNodeIds = new Set();
1343
+ const subgraphFiles = new Set();
1344
+ for (const n of subgraph.nodes.values())
1345
+ subgraphFiles.add(n.filePath);
1346
+ const GLUE_NODE_CAP = 60;
1347
+ for (const rootId of subgraph.roots) {
1348
+ if (glueNodeIds.size >= GLUE_NODE_CAP)
1349
+ break;
1350
+ let neighbors = [];
1351
+ try {
1352
+ neighbors = [
1353
+ ...cg.getCallers(rootId).map(c => c.node),
1354
+ ...cg.getCallees(rootId).map(c => c.node),
1355
+ ];
1356
+ }
1357
+ catch {
1358
+ continue;
1359
+ }
1360
+ for (const nb of neighbors) {
1361
+ if (glueNodeIds.size >= GLUE_NODE_CAP)
1362
+ break;
1363
+ if (subgraph.nodes.has(nb.id))
1364
+ continue;
1365
+ if (!subgraphFiles.has(nb.filePath))
1366
+ continue;
1367
+ subgraph.nodes.set(nb.id, nb);
1368
+ glueNodeIds.add(nb.id);
1369
+ }
1370
+ }
808
1371
  // Step 2: Group nodes by file, score by relevance
809
1372
  const fileGroups = new Map();
810
1373
  const entryNodeIds = new Set(subgraph.roots);
@@ -905,6 +1468,8 @@ class ToolHandler {
905
1468
  // Step 4: Read contiguous file sections
906
1469
  lines.push('### Source Code');
907
1470
  lines.push('');
1471
+ 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.');
1472
+ lines.push('');
908
1473
  let totalChars = lines.join('\n').length;
909
1474
  let filesIncluded = 0;
910
1475
  let anyFileTrimmed = false;
@@ -925,6 +1490,35 @@ class ToolHandler {
925
1490
  }
926
1491
  const fileLines = fileContent.split('\n');
927
1492
  const lang = group.nodes[0]?.language || '';
1493
+ // Whole-small-file rule: if a relevant file is small enough to afford,
1494
+ // return it ENTIRELY instead of clustering. Clustering exists to tame
1495
+ // god-files (App.tsx ~13k lines); on a ~134-line component a cluster is a
1496
+ // lossy subset of a file the agent will just Read in full anyway — costing
1497
+ // a round-trip and a re-read every later turn. Reserve clustering for files
1498
+ // too big to ship whole. Still bounded by the total maxOutputChars check.
1499
+ const WHOLE_FILE_MAX_LINES = 220;
1500
+ const WHOLE_FILE_MAX_CHARS = budget.maxCharsPerFile * 3;
1501
+ if (fileLines.length <= WHOLE_FILE_MAX_LINES && fileContent.length <= WHOLE_FILE_MAX_CHARS) {
1502
+ const body = fileContent.replace(/\n+$/, '');
1503
+ let wholeSection = exploreLineNumbersEnabled() ? numberSourceLines(body, 1) : body;
1504
+ const uniqSymbols = [...new Set(group.nodes
1505
+ .filter(n => n.kind !== 'import' && n.kind !== 'export')
1506
+ .map(n => `${n.name}(${n.kind})`))];
1507
+ const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
1508
+ const omitted = uniqSymbols.length - headerNames.length;
1509
+ const wholeHeader = `#### ${filePath} — ${omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')}`;
1510
+ if (totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
1511
+ const remaining = budget.maxOutputChars - totalChars - 200;
1512
+ if (remaining < 500)
1513
+ break;
1514
+ wholeSection = wholeSection.slice(0, remaining) + '\n... (trimmed) ...';
1515
+ anyFileTrimmed = true;
1516
+ }
1517
+ lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
1518
+ totalChars += wholeSection.length + 200;
1519
+ filesIncluded++;
1520
+ continue;
1521
+ }
928
1522
  // Cluster nearby symbols to avoid reading huge gaps between distant symbols.
929
1523
  // Sort by start line, then merge overlapping/adjacent ranges (within the
930
1524
  // adaptive gap threshold). Include both node ranges AND edge source
@@ -953,6 +1547,8 @@ class ToolHandler {
953
1547
  let importance = 1;
954
1548
  if (entryNodeIds.has(n.id))
955
1549
  importance = 10;
1550
+ else if (glueNodeIds.has(n.id))
1551
+ importance = 6; // bridging caller/callee of an entry
956
1552
  else if (connectedToEntry.has(n.id))
957
1553
  importance = 3;
958
1554
  return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance };
@@ -1145,7 +1741,7 @@ class ToolHandler {
1145
1741
  .sort((a, b) => b[1].score - a[1].score);
1146
1742
  const remainingFiles = [...remainingRelevant, ...peripheralFiles];
1147
1743
  if (remainingFiles.length > 0) {
1148
- lines.push('### Additional relevant files (not shown)');
1744
+ lines.push('### Not shown above explore these names for their source');
1149
1745
  lines.push('');
1150
1746
  for (const [filePath, group] of remainingFiles.slice(0, 10)) {
1151
1747
  const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
@@ -1163,11 +1759,11 @@ class ToolHandler {
1163
1759
  if (budget.includeCompletenessSignal) {
1164
1760
  lines.push('');
1165
1761
  lines.push('---');
1166
- lines.push(`> **Complete source code is included above for ${filesIncluded} files.** You do NOT need to re-read these files — the relevant sections are already shown in full. Only use Read/Grep for files listed under "Additional relevant files" if you need more detail.`);
1762
+ 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.`);
1167
1763
  }
1168
1764
  else if (anyFileTrimmed) {
1169
1765
  lines.push('');
1170
- lines.push(`> Some file sections were trimmed for size. Use \`codegraph_node\` or Read for the full source if needed.`);
1766
+ 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.`);
1171
1767
  }
1172
1768
  // Add explore budget note based on project size
1173
1769
  if (budget.includeBudgetNote) {
@@ -1175,7 +1771,7 @@ class ToolHandler {
1175
1771
  const stats = cg.getStats();
1176
1772
  const callBudget = getExploreBudget(stats.fileCount);
1177
1773
  lines.push('');
1178
- lines.push(`> **Explore budget: ${callBudget} calls max for this project (${stats.fileCount.toLocaleString()} files indexed).** Stop exploring and synthesize your answer once you've used ${callBudget} calls do NOT make additional explore calls beyond this budget.`);
1774
+ 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}.`);
1179
1775
  }
1180
1776
  catch {
1181
1777
  // Stats unavailable — skip budget note
@@ -1187,12 +1783,12 @@ class ToolHandler {
1187
1783
  // maxOutputChars (observed 30k against a 28k tier cap). A fat explore
1188
1784
  // payload persists in the agent's context and is re-read as cache-input
1189
1785
  // on every subsequent turn, so the overrun is paid many times over.
1190
- const output = lines.join('\n');
1786
+ const output = this.buildFlowFromNamedSymbols(cg, query) + lines.join('\n');
1191
1787
  if (output.length > budget.maxOutputChars) {
1192
1788
  const cut = output.slice(0, budget.maxOutputChars);
1193
1789
  const lastNewline = cut.lastIndexOf('\n');
1194
1790
  const safe = lastNewline > budget.maxOutputChars * 0.8 ? cut.slice(0, lastNewline) : cut;
1195
- return this.textResult(safe + '\n\n... (explore output truncated to budget — use codegraph_node or Read for more)');
1791
+ 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.)');
1196
1792
  }
1197
1793
  return this.textResult(output);
1198
1794
  }
@@ -1226,9 +1822,50 @@ class ToolHandler {
1226
1822
  code = await cg.getCode(match.node.id);
1227
1823
  }
1228
1824
  }
1229
- const formatted = this.formatNodeDetails(match.node, code, outline) + match.note;
1825
+ const trail = this.formatTrail(cg, match.node);
1826
+ const formatted = this.formatNodeDetails(match.node, code, outline) + trail + match.note;
1230
1827
  return this.textResult(this.truncateOutput(formatted));
1231
1828
  }
1829
+ /**
1830
+ * Build the "trail" for a symbol: its direct callees (what it calls) and
1831
+ * callers (what calls it), each with file:line — so codegraph_node doubles as
1832
+ * the structural Grep→Read→expand primitive: a spot PLUS where to go next.
1833
+ * Capped to stay cheap. Walk the graph by calling codegraph_node on a trail
1834
+ * entry; no Read needed for covered hops. Empty edges on a non-leaf often mean
1835
+ * dynamic dispatch the static graph couldn't resolve — that absence is itself
1836
+ * a signal (read that one hop) rather than a dead end.
1837
+ */
1838
+ formatTrail(cg, node) {
1839
+ const TRAIL_CAP = 12;
1840
+ const fmt = (e) => {
1841
+ const base = `${e.node.name} (${e.node.filePath}:${e.node.startLine})`;
1842
+ const synth = this.synthEdgeNote(e.edge);
1843
+ return synth ? `${base} [${synth.compact}]` : base;
1844
+ };
1845
+ const collect = (edges) => {
1846
+ const seen = new Set([node.id]);
1847
+ const out = [];
1848
+ for (const e of edges) {
1849
+ if (seen.has(e.node.id))
1850
+ continue;
1851
+ seen.add(e.node.id);
1852
+ out.push(e);
1853
+ }
1854
+ return out;
1855
+ };
1856
+ const callees = collect(cg.getCallees(node.id));
1857
+ const callers = collect(cg.getCallers(node.id));
1858
+ if (callees.length === 0 && callers.length === 0)
1859
+ return '';
1860
+ const lines = ['', '### Trail — codegraph_node any of these to follow it (no Read needed)'];
1861
+ if (callees.length > 0) {
1862
+ lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`);
1863
+ }
1864
+ if (callers.length > 0) {
1865
+ lines.push(`**Called by ←** ${callers.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callers.length > TRAIL_CAP ? `, +${callers.length - TRAIL_CAP} more` : ''}`);
1866
+ }
1867
+ return lines.join('\n');
1868
+ }
1232
1869
  /**
1233
1870
  * Handle codegraph_status
1234
1871
  */
@@ -1646,7 +2283,10 @@ class ToolHandler {
1646
2283
  lines.push('', outline, '', `> Structural outline only. Read \`${node.filePath}\` or call codegraph_node on a specific member for its body.`);
1647
2284
  }
1648
2285
  else if (code) {
1649
- lines.push('', '```' + node.language, code, '```');
2286
+ // Line-numbered (cat -n style, like codegraph_explore and Read) so the
2287
+ // agent can cite/edit exact lines without re-Reading the file for them.
2288
+ const numbered = node.startLine ? numberSourceLines(code, node.startLine) : code;
2289
+ lines.push('', '```' + node.language, numbered, '```');
1650
2290
  }
1651
2291
  return lines.join('\n');
1652
2292
  }