@colbymchenry/codegraph-darwin-x64 1.0.0 → 1.1.0
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.
- package/lib/dist/bin/codegraph.js +258 -17
- package/lib/dist/bin/codegraph.js.map +1 -1
- package/lib/dist/bin/fatal-handler.d.ts +20 -0
- package/lib/dist/bin/fatal-handler.d.ts.map +1 -0
- package/lib/dist/bin/fatal-handler.js +118 -0
- package/lib/dist/bin/fatal-handler.js.map +1 -0
- package/lib/dist/db/index.d.ts +22 -1
- package/lib/dist/db/index.d.ts.map +1 -1
- package/lib/dist/db/index.js +46 -1
- package/lib/dist/db/index.js.map +1 -1
- package/lib/dist/db/queries.d.ts +14 -0
- package/lib/dist/db/queries.d.ts.map +1 -1
- package/lib/dist/db/queries.js +25 -0
- package/lib/dist/db/queries.js.map +1 -1
- package/lib/dist/directory.d.ts +58 -0
- package/lib/dist/directory.d.ts.map +1 -1
- package/lib/dist/directory.js +165 -0
- package/lib/dist/directory.js.map +1 -1
- package/lib/dist/extraction/grammars.d.ts +11 -3
- package/lib/dist/extraction/grammars.d.ts.map +1 -1
- package/lib/dist/extraction/grammars.js +14 -5
- package/lib/dist/extraction/grammars.js.map +1 -1
- package/lib/dist/extraction/index.d.ts.map +1 -1
- package/lib/dist/extraction/index.js +202 -32
- package/lib/dist/extraction/index.js.map +1 -1
- package/lib/dist/extraction/languages/c-cpp.d.ts.map +1 -1
- package/lib/dist/extraction/languages/c-cpp.js +47 -2
- package/lib/dist/extraction/languages/c-cpp.js.map +1 -1
- package/lib/dist/extraction/languages/csharp.d.ts.map +1 -1
- package/lib/dist/extraction/languages/csharp.js +20 -0
- package/lib/dist/extraction/languages/csharp.js.map +1 -1
- package/lib/dist/extraction/languages/dart.d.ts.map +1 -1
- package/lib/dist/extraction/languages/dart.js +22 -0
- package/lib/dist/extraction/languages/dart.js.map +1 -1
- package/lib/dist/extraction/languages/java.d.ts.map +1 -1
- package/lib/dist/extraction/languages/java.js +213 -9
- package/lib/dist/extraction/languages/java.js.map +1 -1
- package/lib/dist/extraction/languages/kotlin.d.ts.map +1 -1
- package/lib/dist/extraction/languages/kotlin.js +51 -0
- package/lib/dist/extraction/languages/kotlin.js.map +1 -1
- package/lib/dist/extraction/languages/scala.d.ts.map +1 -1
- package/lib/dist/extraction/languages/scala.js +19 -9
- package/lib/dist/extraction/languages/scala.js.map +1 -1
- package/lib/dist/extraction/parse-worker.js +4 -1
- package/lib/dist/extraction/parse-worker.js.map +1 -1
- package/lib/dist/extraction/tree-sitter-types.d.ts +13 -0
- package/lib/dist/extraction/tree-sitter-types.d.ts.map +1 -1
- package/lib/dist/extraction/tree-sitter.d.ts +119 -0
- package/lib/dist/extraction/tree-sitter.d.ts.map +1 -1
- package/lib/dist/extraction/tree-sitter.js +890 -11
- package/lib/dist/extraction/tree-sitter.js.map +1 -1
- package/lib/dist/index.d.ts +33 -0
- package/lib/dist/index.d.ts.map +1 -1
- package/lib/dist/index.js +68 -7
- package/lib/dist/index.js.map +1 -1
- package/lib/dist/installer/index.d.ts.map +1 -1
- package/lib/dist/installer/index.js +33 -56
- package/lib/dist/installer/index.js.map +1 -1
- package/lib/dist/installer/instructions-template.d.ts +3 -3
- package/lib/dist/installer/instructions-template.d.ts.map +1 -1
- package/lib/dist/installer/instructions-template.js +4 -4
- package/lib/dist/installer/targets/claude.d.ts +18 -12
- package/lib/dist/installer/targets/claude.d.ts.map +1 -1
- package/lib/dist/installer/targets/claude.js +78 -6
- package/lib/dist/installer/targets/claude.js.map +1 -1
- package/lib/dist/installer/targets/shared.d.ts +12 -2
- package/lib/dist/installer/targets/shared.d.ts.map +1 -1
- package/lib/dist/installer/targets/shared.js +13 -12
- package/lib/dist/installer/targets/shared.js.map +1 -1
- package/lib/dist/installer/targets/types.d.ts +7 -0
- package/lib/dist/installer/targets/types.d.ts.map +1 -1
- package/lib/dist/mcp/daemon-manager.d.ts +42 -0
- package/lib/dist/mcp/daemon-manager.d.ts.map +1 -0
- package/lib/dist/mcp/daemon-manager.js +129 -0
- package/lib/dist/mcp/daemon-manager.js.map +1 -0
- package/lib/dist/mcp/daemon-registry.d.ts +47 -0
- package/lib/dist/mcp/daemon-registry.d.ts.map +1 -0
- package/lib/dist/mcp/daemon-registry.js +229 -0
- package/lib/dist/mcp/daemon-registry.js.map +1 -0
- package/lib/dist/mcp/daemon.d.ts.map +1 -1
- package/lib/dist/mcp/daemon.js +5 -0
- package/lib/dist/mcp/daemon.js.map +1 -1
- package/lib/dist/mcp/engine.d.ts.map +1 -1
- package/lib/dist/mcp/engine.js +8 -0
- package/lib/dist/mcp/engine.js.map +1 -1
- package/lib/dist/mcp/index.d.ts +1 -0
- package/lib/dist/mcp/index.d.ts.map +1 -1
- package/lib/dist/mcp/index.js +13 -0
- package/lib/dist/mcp/index.js.map +1 -1
- package/lib/dist/mcp/liveness-watchdog.d.ts +18 -0
- package/lib/dist/mcp/liveness-watchdog.d.ts.map +1 -0
- package/lib/dist/mcp/liveness-watchdog.js +207 -0
- package/lib/dist/mcp/liveness-watchdog.js.map +1 -0
- package/lib/dist/mcp/server-instructions.d.ts +18 -14
- package/lib/dist/mcp/server-instructions.d.ts.map +1 -1
- package/lib/dist/mcp/server-instructions.js +57 -52
- package/lib/dist/mcp/server-instructions.js.map +1 -1
- package/lib/dist/mcp/session.d.ts.map +1 -1
- package/lib/dist/mcp/session.js +23 -18
- package/lib/dist/mcp/session.js.map +1 -1
- package/lib/dist/mcp/tools.d.ts +51 -1
- package/lib/dist/mcp/tools.d.ts.map +1 -1
- package/lib/dist/mcp/tools.js +585 -151
- package/lib/dist/mcp/tools.js.map +1 -1
- package/lib/dist/project-config.d.ts +19 -0
- package/lib/dist/project-config.d.ts.map +1 -0
- package/lib/dist/project-config.js +180 -0
- package/lib/dist/project-config.js.map +1 -0
- package/lib/dist/reasoning/config.d.ts +45 -0
- package/lib/dist/reasoning/config.d.ts.map +1 -0
- package/lib/dist/reasoning/config.js +171 -0
- package/lib/dist/reasoning/config.js.map +1 -0
- package/lib/dist/reasoning/credentials.d.ts +5 -0
- package/lib/dist/reasoning/credentials.d.ts.map +1 -0
- package/lib/dist/reasoning/credentials.js +83 -0
- package/lib/dist/reasoning/credentials.js.map +1 -0
- package/lib/dist/reasoning/login.d.ts +21 -0
- package/lib/dist/reasoning/login.d.ts.map +1 -0
- package/lib/dist/reasoning/login.js +85 -0
- package/lib/dist/reasoning/login.js.map +1 -0
- package/lib/dist/reasoning/reasoner.d.ts +43 -0
- package/lib/dist/reasoning/reasoner.d.ts.map +1 -0
- package/lib/dist/reasoning/reasoner.js +308 -0
- package/lib/dist/reasoning/reasoner.js.map +1 -0
- package/lib/dist/resolution/c-fnptr-synthesizer.d.ts +33 -0
- package/lib/dist/resolution/c-fnptr-synthesizer.d.ts.map +1 -0
- package/lib/dist/resolution/c-fnptr-synthesizer.js +352 -0
- package/lib/dist/resolution/c-fnptr-synthesizer.js.map +1 -0
- package/lib/dist/resolution/callback-synthesizer.d.ts +6 -1
- package/lib/dist/resolution/callback-synthesizer.d.ts.map +1 -1
- package/lib/dist/resolution/callback-synthesizer.js +1109 -1
- package/lib/dist/resolution/callback-synthesizer.js.map +1 -1
- package/lib/dist/resolution/frameworks/goframe.d.ts +41 -0
- package/lib/dist/resolution/frameworks/goframe.d.ts.map +1 -0
- package/lib/dist/resolution/frameworks/goframe.js +112 -0
- package/lib/dist/resolution/frameworks/goframe.js.map +1 -0
- package/lib/dist/resolution/frameworks/index.d.ts +1 -0
- package/lib/dist/resolution/frameworks/index.d.ts.map +1 -1
- package/lib/dist/resolution/frameworks/index.js +5 -1
- package/lib/dist/resolution/frameworks/index.js.map +1 -1
- package/lib/dist/resolution/frameworks/react.d.ts.map +1 -1
- package/lib/dist/resolution/frameworks/react.js +17 -60
- package/lib/dist/resolution/frameworks/react.js.map +1 -1
- package/lib/dist/resolution/goframe-synthesizer.d.ts +28 -0
- package/lib/dist/resolution/goframe-synthesizer.d.ts.map +1 -0
- package/lib/dist/resolution/goframe-synthesizer.js +158 -0
- package/lib/dist/resolution/goframe-synthesizer.js.map +1 -0
- package/lib/dist/resolution/import-resolver.d.ts.map +1 -1
- package/lib/dist/resolution/import-resolver.js +56 -0
- package/lib/dist/resolution/import-resolver.js.map +1 -1
- package/lib/dist/resolution/name-matcher.d.ts.map +1 -1
- package/lib/dist/resolution/name-matcher.js +48 -8
- package/lib/dist/resolution/name-matcher.js.map +1 -1
- package/lib/dist/resolution/strip-comments.d.ts +1 -1
- package/lib/dist/resolution/strip-comments.d.ts.map +1 -1
- package/lib/dist/resolution/strip-comments.js +2 -0
- package/lib/dist/resolution/strip-comments.js.map +1 -1
- package/lib/dist/sync/watcher.d.ts +68 -1
- package/lib/dist/sync/watcher.d.ts.map +1 -1
- package/lib/dist/sync/watcher.js +212 -14
- package/lib/dist/sync/watcher.js.map +1 -1
- package/lib/dist/telemetry/index.d.ts +0 -3
- package/lib/dist/telemetry/index.d.ts.map +1 -1
- package/lib/dist/telemetry/index.js +4 -7
- package/lib/dist/telemetry/index.js.map +1 -1
- package/lib/dist/upgrade/index.d.ts.map +1 -1
- package/lib/dist/upgrade/index.js +40 -4
- package/lib/dist/upgrade/index.js.map +1 -1
- package/lib/dist/utils.d.ts +14 -1
- package/lib/dist/utils.d.ts.map +1 -1
- package/lib/dist/utils.js +20 -2
- package/lib/dist/utils.js.map +1 -1
- package/lib/node_modules/.package-lock.json +1 -1
- package/lib/package.json +2 -2
- package/package.json +1 -1
package/lib/dist/mcp/tools.js
CHANGED
|
@@ -10,6 +10,7 @@ exports.getExploreBudget = getExploreBudget;
|
|
|
10
10
|
exports.getExploreOutputBudget = getExploreOutputBudget;
|
|
11
11
|
exports.formatStaleBanner = formatStaleBanner;
|
|
12
12
|
exports.formatStaleFooter = formatStaleFooter;
|
|
13
|
+
exports.formatDegradedBanner = formatDegradedBanner;
|
|
13
14
|
exports.getStaticTools = getStaticTools;
|
|
14
15
|
const directory_1 = require("../directory");
|
|
15
16
|
// Lazy-load the heavy CodeGraph chain off the MCP startup path — see the same
|
|
@@ -228,6 +229,28 @@ function exploreLineNumbersEnabled() {
|
|
|
228
229
|
function adaptiveExploreEnabled() {
|
|
229
230
|
return process.env.CODEGRAPH_ADAPTIVE_EXPLORE !== '0' && process.env.CODEGRAPH_ADAPTIVE_EXPLORE !== 'false';
|
|
230
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* How long the FIRST tool call waits on the post-open catch-up reconcile before
|
|
234
|
+
* giving up and serving anyway (issue #905). On a normal repo the reconcile
|
|
235
|
+
* finishes in well under this, so the gate is fully honored and nothing changes.
|
|
236
|
+
* On a very large repo (~100k files) the reconcile takes minutes — blocking the
|
|
237
|
+
* first call on all of it presents as a multi-minute hang — so we wait briefly
|
|
238
|
+
* for a clean answer, then serve and let the reconcile finish in the background
|
|
239
|
+
* (it yields to the event loop, so a concurrent read still runs).
|
|
240
|
+
*
|
|
241
|
+
* `CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS` overrides the default; `0` restores the
|
|
242
|
+
* old unbounded-wait behavior (always block until the reconcile completes).
|
|
243
|
+
*/
|
|
244
|
+
const DEFAULT_CATCHUP_GATE_TIMEOUT_MS = 3000;
|
|
245
|
+
function resolveCatchUpGateTimeoutMs() {
|
|
246
|
+
const raw = process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS;
|
|
247
|
+
if (raw === undefined || raw === '')
|
|
248
|
+
return DEFAULT_CATCHUP_GATE_TIMEOUT_MS;
|
|
249
|
+
const n = Number(raw);
|
|
250
|
+
if (!Number.isFinite(n) || n < 0)
|
|
251
|
+
return DEFAULT_CATCHUP_GATE_TIMEOUT_MS;
|
|
252
|
+
return Math.floor(n);
|
|
253
|
+
}
|
|
231
254
|
/**
|
|
232
255
|
* Prefix each line of a source slice with its 1-based line number, matching
|
|
233
256
|
* the Read tool's `cat -n` convention (number + tab) so the agent treats it
|
|
@@ -244,6 +267,22 @@ function numberSourceLines(slice, firstLineNumber) {
|
|
|
244
267
|
}
|
|
245
268
|
return out.join('\n');
|
|
246
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Unique line-prefix for a per-file source section in codegraph_explore output.
|
|
272
|
+
* Issue #778: tool results dropped ATX headings (`####`, `##`, `###`) for bold
|
|
273
|
+
* labels so Markdown-rendering MCP clients (e.g. the Claude Code VSCode
|
|
274
|
+
* extension) stop blowing every header up to H1–H4. The path is bold + a code
|
|
275
|
+
* span so it still reads as a header, and the leading ``**` `` stays a UNIQUE,
|
|
276
|
+
* greppable marker — no other explore line begins with it — that the explore
|
|
277
|
+
* truncation boundary (`handleExplore`) and the offload chunker
|
|
278
|
+
* (`reasoning/reasoner.ts`) both key off to cut on whole file sections.
|
|
279
|
+
*/
|
|
280
|
+
const FILE_SECTION_PREFIX = '**`';
|
|
281
|
+
function fileSectionHeader(filePath, suffix) {
|
|
282
|
+
return suffix
|
|
283
|
+
? `${FILE_SECTION_PREFIX}${filePath}\`** — ${suffix}`
|
|
284
|
+
: `${FILE_SECTION_PREFIX}${filePath}\`**`;
|
|
285
|
+
}
|
|
247
286
|
/**
|
|
248
287
|
* Per-file staleness banner emitted at the top of a tool response when the
|
|
249
288
|
* file watcher has pending events for files referenced by the response.
|
|
@@ -280,12 +319,26 @@ function formatStaleFooter(stale) {
|
|
|
280
319
|
return (`(Note: ${stale.length} file(s) elsewhere in this project are pending index ` +
|
|
281
320
|
`sync but were not referenced above:\n${lines.join('\n')}${more})`);
|
|
282
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* Whole-index degradation banner (issue #876). Emitted at the top of a read
|
|
324
|
+
* tool response when live watching has permanently stopped — at which point
|
|
325
|
+
* `getPendingFiles()` is empty, so the per-file banner above can't fire even
|
|
326
|
+
* though the index is now FROZEN and silently drifting stale. Leads with the
|
|
327
|
+
* agent-actionable instruction (Read directly) and carries the reason, which
|
|
328
|
+
* already names the operator remedy (`codegraph sync` / git hooks).
|
|
329
|
+
*/
|
|
330
|
+
function formatDegradedBanner(reason) {
|
|
331
|
+
return ('⚠️ CodeGraph auto-sync is DISABLED — live file watching stopped, so the index is ' +
|
|
332
|
+
'frozen and any file edited since then is stale here. Read files directly to confirm ' +
|
|
333
|
+
'current content before relying on it.' +
|
|
334
|
+
(reason ? `\n Reason: ${reason}` : ''));
|
|
335
|
+
}
|
|
283
336
|
/**
|
|
284
337
|
* Common projectPath property for cross-project queries
|
|
285
338
|
*/
|
|
286
339
|
const projectPathProperty = {
|
|
287
340
|
type: 'string',
|
|
288
|
-
description: '
|
|
341
|
+
description: 'Absolute path to the project to query (or any directory inside it) — codegraph uses the nearest .codegraph/ index at or above that path. Omit to use this session\'s default project. Pass it to query a second codebase, or when the server root has no index of its own (e.g. a monorepo where only sub-projects are indexed, so there is no default project).',
|
|
289
342
|
};
|
|
290
343
|
/**
|
|
291
344
|
* All CodeGraph MCP tools
|
|
@@ -514,28 +567,17 @@ function getStaticTools() {
|
|
|
514
567
|
return allow.size ? exports.tools.filter(t => allow.has(t.name.replace(/^codegraph_/, ''))) : exports.tools;
|
|
515
568
|
}
|
|
516
569
|
/**
|
|
517
|
-
* The MCP tools served by DEFAULT (short names).
|
|
518
|
-
*
|
|
519
|
-
* the
|
|
520
|
-
*
|
|
570
|
+
* The MCP tools served by DEFAULT (short names). Pared to ONLY `codegraph_explore`
|
|
571
|
+
* — the single tool that reliably earns its place: one capped call returns the
|
|
572
|
+
* verbatim source of the relevant symbols grouped by file. Every other tool is a
|
|
573
|
+
* narrower slice of what explore already does, and presence itself steers
|
|
574
|
+
* mis-picks, so they are no longer LISTED to agents.
|
|
521
575
|
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
* blast-radius info already arrives inline on explore (the "Blast radius"
|
|
526
|
-
* section) and node (the dependents note), so agents never need the
|
|
527
|
-
* standalone tool.
|
|
528
|
-
* - `codegraph_callees` is redundant by construction: a symbol's body (which
|
|
529
|
-
* node returns) IS its callee list, plus the caller/callee trail.
|
|
530
|
-
* - `codegraph_files` / `codegraph_status`: the tiny-repo audit (see
|
|
531
|
-
* getTools) found they "reduce to one grep"; staleness banners already
|
|
532
|
-
* inline the pending-sync info on every read tool, and the CLI covers
|
|
533
|
-
* diagnostics.
|
|
534
|
-
* - `codegraph_callers` stays: exhaustive call-site enumeration (every
|
|
535
|
-
* caller with file:line, callback registrations labeled, one section per
|
|
536
|
-
* same-named definition) is the one job explore/node don't replicate.
|
|
576
|
+
* The other defined tools (`node`, `search`, `callers`, plus callees/impact/files/
|
|
577
|
+
* status) remain fully functional — handlers stay, the library API and CLI are
|
|
578
|
+
* untouched, and `CODEGRAPH_MCP_TOOLS=explore,node,...` re-enables any of them.
|
|
537
579
|
*/
|
|
538
|
-
const DEFAULT_MCP_TOOLS = new Set(['explore'
|
|
580
|
+
const DEFAULT_MCP_TOOLS = new Set(['explore']);
|
|
539
581
|
/**
|
|
540
582
|
* Tool handler that executes tools against a CodeGraph instance
|
|
541
583
|
*
|
|
@@ -560,7 +602,9 @@ class ToolHandler {
|
|
|
560
602
|
// this, a tool call that races past `catchUpSync()` serves rows for files
|
|
561
603
|
// that were deleted (or edited) while no MCP server was running — and the
|
|
562
604
|
// per-file staleness banner can't help, because `getPendingFiles()` is
|
|
563
|
-
// populated by the watcher, not by catch-up.
|
|
605
|
+
// populated by the watcher, not by catch-up. The wait is time-boxed
|
|
606
|
+
// (see {@link resolveCatchUpGateTimeoutMs}) so a minutes-long reconcile on a
|
|
607
|
+
// huge repo can't hang the first call (#905); cleared on first await so
|
|
564
608
|
// subsequent calls don't pay any cost.
|
|
565
609
|
catchUpGate = null;
|
|
566
610
|
constructor(cg) {
|
|
@@ -582,6 +626,45 @@ class ToolHandler {
|
|
|
582
626
|
setCatchUpGate(p) {
|
|
583
627
|
this.catchUpGate = p;
|
|
584
628
|
}
|
|
629
|
+
/**
|
|
630
|
+
* Await the catch-up gate, but no longer than the configured timeout (#905).
|
|
631
|
+
* If the reconcile settles first, we got the fully-reconciled answer. If the
|
|
632
|
+
* timeout wins, we serve the call now and let the reconcile finish in the
|
|
633
|
+
* background — it yields to the event loop (see SYNC_RECONCILE_YIELD_INTERVAL),
|
|
634
|
+
* so a concurrent read still runs against the same connection. Never throws:
|
|
635
|
+
* a failed reconcile is logged by the engine, and we serve best-effort over
|
|
636
|
+
* the same potentially-stale data the un-gated path would have.
|
|
637
|
+
*/
|
|
638
|
+
async awaitCatchUpGate(gate) {
|
|
639
|
+
const timeoutMs = resolveCatchUpGateTimeoutMs();
|
|
640
|
+
if (timeoutMs <= 0) {
|
|
641
|
+
// 0 = opt back into the original unbounded wait.
|
|
642
|
+
try {
|
|
643
|
+
await gate;
|
|
644
|
+
}
|
|
645
|
+
catch { /* engine already logged */ }
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
let timer;
|
|
649
|
+
const timedOut = new Promise((resolve) => {
|
|
650
|
+
timer = setTimeout(() => resolve('timeout'), timeoutMs);
|
|
651
|
+
timer.unref?.();
|
|
652
|
+
});
|
|
653
|
+
try {
|
|
654
|
+
const outcome = await Promise.race([
|
|
655
|
+
gate.then(() => 'done', () => 'done'),
|
|
656
|
+
timedOut,
|
|
657
|
+
]);
|
|
658
|
+
if (outcome === 'timeout') {
|
|
659
|
+
process.stderr.write(`[CodeGraph MCP] Catch-up reconcile still running after ${timeoutMs}ms; serving this tool call now and finishing the reconcile in the background (#905). ` +
|
|
660
|
+
`Set CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS=0 to always wait for it.\n`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
finally {
|
|
664
|
+
if (timer)
|
|
665
|
+
clearTimeout(timer);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
585
668
|
/**
|
|
586
669
|
* Record the directory the server tried to resolve the default project from.
|
|
587
670
|
* Used only to make the "no default project" error actionable.
|
|
@@ -696,19 +779,18 @@ class ToolHandler {
|
|
|
696
779
|
const searched = this.defaultProjectHint ?? process.cwd();
|
|
697
780
|
throw new NotIndexedError('No CodeGraph project is loaded for this session.\n' +
|
|
698
781
|
`Searched for a .codegraph/ directory starting from: ${searched}\n` +
|
|
699
|
-
'
|
|
700
|
-
"the MCP client launched the server outside your
|
|
701
|
-
'workspace root.
|
|
702
|
-
'
|
|
782
|
+
'Either the server root has no index of its own (e.g. a monorepo where only ' +
|
|
783
|
+
"sub-projects are indexed), or the MCP client launched the server outside your " +
|
|
784
|
+
'project without reporting the workspace root. Either way, target the project ' +
|
|
785
|
+
'explicitly:\n' +
|
|
786
|
+
' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project" ' +
|
|
787
|
+
'(any project that has a .codegraph/ — including a sub-project of a monorepo)\n' +
|
|
703
788
|
' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]\n' +
|
|
704
|
-
'If
|
|
705
|
-
"
|
|
789
|
+
'If a project simply has no index, use your built-in tools (Read/Grep/Glob) for THAT ' +
|
|
790
|
+
"project (the user can run 'codegraph init' there to enable it) — you can still query " +
|
|
791
|
+
'other indexed projects by projectPath in the same session.');
|
|
706
792
|
}
|
|
707
|
-
return this.cg;
|
|
708
|
-
}
|
|
709
|
-
// Check cache first (using original path as key)
|
|
710
|
-
if (this.projectCache.has(projectPath)) {
|
|
711
|
-
return this.projectCache.get(projectPath);
|
|
793
|
+
return this.freshen(this.cg);
|
|
712
794
|
}
|
|
713
795
|
// Reject sensitive system directories before opening. Only validate a
|
|
714
796
|
// path that actually exists — a nested or not-yet-created sub-path of a
|
|
@@ -721,7 +803,16 @@ class ToolHandler {
|
|
|
721
803
|
throw new PathRefusalError(pathError);
|
|
722
804
|
}
|
|
723
805
|
}
|
|
724
|
-
//
|
|
806
|
+
// Always RE-RESOLVE the nearest .codegraph/ from the input path. The walk
|
|
807
|
+
// is cheap (a few existsSync up the tree) and is the only thing that
|
|
808
|
+
// notices a path whose index root CHANGED since it was first seen — most
|
|
809
|
+
// importantly a git worktree that gained its own .codegraph/ after the
|
|
810
|
+
// (long-lived) server first resolved it up to the parent checkout. We used
|
|
811
|
+
// to short-circuit on a `projectCache[projectPath]` entry before resolving,
|
|
812
|
+
// which pinned that first resolution for the server's whole lifetime, so a
|
|
813
|
+
// worktree kept being served the parent checkout's index until restart
|
|
814
|
+
// (#926). The DB connection itself is still cached (by resolved root,
|
|
815
|
+
// below), so re-resolving costs only the stat walk, never a reopen.
|
|
725
816
|
const resolvedRoot = (0, directory_1.findNearestCodeGraphRoot)(projectPath);
|
|
726
817
|
if (!resolvedRoot) {
|
|
727
818
|
throw new NotIndexedError(`The project at ${projectPath} isn't indexed with codegraph (no .codegraph/ directory found ` +
|
|
@@ -732,25 +823,43 @@ class ToolHandler {
|
|
|
732
823
|
// If the path resolves to the default project, reuse the already-open
|
|
733
824
|
// default instance rather than opening a SECOND connection to the same DB.
|
|
734
825
|
// A duplicate connection serializes reads against the watcher's auto-sync
|
|
735
|
-
// writes;
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
// instance
|
|
826
|
+
// writes; when WAL isn't in effect (e.g. a filesystem without shared-memory
|
|
827
|
+
// support) that surfaces as intermittent
|
|
828
|
+
// "database is locked" on concurrent tool calls. See issue #238. The
|
|
829
|
+
// default instance is owned/closed by the server, so it's never cached.
|
|
739
830
|
if (this.cg && this.cg.getProjectRoot() === resolvedRoot) {
|
|
740
|
-
return this.cg;
|
|
741
|
-
}
|
|
742
|
-
//
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
return
|
|
748
|
-
}
|
|
749
|
-
// Open and cache under both paths
|
|
831
|
+
return this.freshen(this.cg);
|
|
832
|
+
}
|
|
833
|
+
// Cache the open DB connection by RESOLVED ROOT only — never by the input
|
|
834
|
+
// path. One key per instance means closeAll() closes each exactly once, and
|
|
835
|
+
// a changed resolution maps to a different entry instead of a stale hit.
|
|
836
|
+
const cached = this.projectCache.get(resolvedRoot);
|
|
837
|
+
if (cached)
|
|
838
|
+
return this.freshen(cached);
|
|
750
839
|
const cg = loadCodeGraph().openSync(resolvedRoot);
|
|
751
840
|
this.projectCache.set(resolvedRoot, cg);
|
|
752
|
-
|
|
753
|
-
|
|
841
|
+
return cg;
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Heal a long-lived connection whose `.codegraph/` was removed and recreated
|
|
845
|
+
* at the same path (a worktree recreated, or `rm -rf .codegraph` + re-init)
|
|
846
|
+
* before handing it to a tool. Otherwise the daemon keeps serving the
|
|
847
|
+
* pre-removal snapshot from its now-unlinked file handle until restart — and
|
|
848
|
+
* because the daemon registry is keyed by path, a same-path recreate routes
|
|
849
|
+
* new clients straight back to this same stale daemon (#925). The check is one
|
|
850
|
+
* stat() and a no-op unless the inode actually changed; it never throws into a
|
|
851
|
+
* tool call.
|
|
852
|
+
*/
|
|
853
|
+
freshen(cg) {
|
|
854
|
+
try {
|
|
855
|
+
if (cg.reopenIfReplaced()) {
|
|
856
|
+
process.stderr.write('[CodeGraph MCP] The index was replaced on disk (e.g. a git worktree ' +
|
|
857
|
+
'recreated at the same path); reopened the live database in place.\n');
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
catch {
|
|
861
|
+
// Best-effort self-heal — a failed reopen must never break the tool call;
|
|
862
|
+
// the (still stale) handle keeps serving and the next call retries.
|
|
754
863
|
}
|
|
755
864
|
return cg;
|
|
756
865
|
}
|
|
@@ -808,18 +917,29 @@ class ToolHandler {
|
|
|
808
917
|
*/
|
|
809
918
|
worktreeMismatchFor(projectPath) {
|
|
810
919
|
const startPath = projectPath ?? this.defaultProjectHint ?? process.cwd();
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
920
|
+
// The verdict depends on BOTH the start path AND the index root it resolves
|
|
921
|
+
// to, so the cache must be keyed on the pair. Resolve the index root first
|
|
922
|
+
// (cheap — getCodeGraph re-walks to the nearest .codegraph/, no git), then
|
|
923
|
+
// key on `(startPath, indexRoot)`. The moment that root changes — most
|
|
924
|
+
// importantly when a git worktree gains its own index and the walk-up stops
|
|
925
|
+
// there instead of at the parent checkout — the key changes and the verdict
|
|
926
|
+
// is recomputed, instead of serving the stale "borrowed the parent's index"
|
|
927
|
+
// warning for the server's whole lifetime. Keying on startPath alone pinned
|
|
928
|
+
// that first verdict until restart (#926).
|
|
929
|
+
let indexRoot;
|
|
815
930
|
try {
|
|
816
|
-
|
|
931
|
+
indexRoot = this.getCodeGraph(projectPath).getProjectRoot();
|
|
817
932
|
}
|
|
818
933
|
catch {
|
|
819
934
|
// No resolvable project (or any other resolution error) → nothing to warn.
|
|
820
|
-
|
|
935
|
+
return null;
|
|
821
936
|
}
|
|
822
|
-
|
|
937
|
+
const cacheKey = `${startPath}\u0000${indexRoot}`;
|
|
938
|
+
const cached = this.worktreeMismatchCache.get(cacheKey);
|
|
939
|
+
if (cached !== undefined)
|
|
940
|
+
return cached;
|
|
941
|
+
const mismatch = (0, worktree_1.detectWorktreeIndexMismatch)(startPath, indexRoot);
|
|
942
|
+
this.worktreeMismatchCache.set(cacheKey, mismatch);
|
|
823
943
|
return mismatch;
|
|
824
944
|
}
|
|
825
945
|
/**
|
|
@@ -883,6 +1003,34 @@ class ToolHandler {
|
|
|
883
1003
|
/* getProjectRoot may throw on a closed instance — leave cg as is */
|
|
884
1004
|
}
|
|
885
1005
|
}
|
|
1006
|
+
// Whole-index degradation (#876): once live watching has permanently
|
|
1007
|
+
// stopped, getPendingFiles() is empty so the per-file banner below can't
|
|
1008
|
+
// fire — but the index is now FROZEN and silently drifting stale. Surface
|
|
1009
|
+
// one global notice instead, so the agent Reads for current content rather
|
|
1010
|
+
// than trusting a response off a no-longer-updating index. (Cross-project
|
|
1011
|
+
// calls open a watcher-less CodeGraph, so this is false there — correct: we
|
|
1012
|
+
// only know degraded state for the default session project.)
|
|
1013
|
+
let degraded = false;
|
|
1014
|
+
try {
|
|
1015
|
+
degraded = cg.isWatcherDegraded?.() ?? false;
|
|
1016
|
+
}
|
|
1017
|
+
catch {
|
|
1018
|
+
degraded = false;
|
|
1019
|
+
}
|
|
1020
|
+
if (degraded) {
|
|
1021
|
+
const [head, ...tail] = result.content;
|
|
1022
|
+
if (!head || head.type !== 'text')
|
|
1023
|
+
return result;
|
|
1024
|
+
let reason = null;
|
|
1025
|
+
try {
|
|
1026
|
+
reason = cg.getWatcherDegradedReason?.() ?? null;
|
|
1027
|
+
}
|
|
1028
|
+
catch {
|
|
1029
|
+
reason = null;
|
|
1030
|
+
}
|
|
1031
|
+
const composed = `${formatDegradedBanner(reason)}\n\n${head.text}`;
|
|
1032
|
+
return { ...result, content: [{ type: 'text', text: composed }, ...tail] };
|
|
1033
|
+
}
|
|
886
1034
|
// Defensive: some test fakes inject a partial CodeGraph stub without the
|
|
887
1035
|
// newer pending-files API. Treat missing/throwing as "no pending files."
|
|
888
1036
|
let pending = [];
|
|
@@ -929,16 +1077,16 @@ class ToolHandler {
|
|
|
929
1077
|
try {
|
|
930
1078
|
// Block the first tool call on the engine's post-open reconcile so we
|
|
931
1079
|
// never serve rows for files deleted/edited while no MCP server was
|
|
932
|
-
// running. The
|
|
933
|
-
//
|
|
934
|
-
//
|
|
1080
|
+
// running. The wait is time-boxed (#905): a huge-repo reconcile takes
|
|
1081
|
+
// minutes, and blocking the first call on all of it reads as a hang, so
|
|
1082
|
+
// we wait briefly then serve and let it finish in the background. The
|
|
1083
|
+
// gate is cleared after first await — subsequent calls pay nothing.
|
|
1084
|
+
// Catch-up failures are logged by the engine; we proceed regardless so a
|
|
1085
|
+
// transient sync error never breaks tools.
|
|
935
1086
|
if (this.catchUpGate) {
|
|
936
1087
|
const gate = this.catchUpGate;
|
|
937
1088
|
this.catchUpGate = null;
|
|
938
|
-
|
|
939
|
-
await gate;
|
|
940
|
-
}
|
|
941
|
-
catch { /* engine already logged */ }
|
|
1089
|
+
await this.awaitCatchUpGate(gate);
|
|
942
1090
|
}
|
|
943
1091
|
// Honor the optional tool allowlist (CODEGRAPH_MCP_TOOLS): a trimmed
|
|
944
1092
|
// surface rejects ablated tools defensively even if a client cached them.
|
|
@@ -1089,7 +1237,7 @@ class ToolHandler {
|
|
|
1089
1237
|
definitionHeading(group) {
|
|
1090
1238
|
const head = group[0];
|
|
1091
1239
|
const line = head.startLine ? `:${head.startLine}` : '';
|
|
1092
|
-
return
|
|
1240
|
+
return `**${head.qualifiedName}** (${head.kind}) — ${head.filePath}${line}`;
|
|
1093
1241
|
}
|
|
1094
1242
|
/**
|
|
1095
1243
|
* Handle codegraph_callers
|
|
@@ -1142,7 +1290,7 @@ class ToolHandler {
|
|
|
1142
1290
|
// agent never mistakes one app's callers for another's. Narrow with
|
|
1143
1291
|
// `file` to focus a single definition.
|
|
1144
1292
|
const lines = [
|
|
1145
|
-
|
|
1293
|
+
`**Callers of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)**`,
|
|
1146
1294
|
];
|
|
1147
1295
|
for (const group of groups) {
|
|
1148
1296
|
const { callers, labels } = collect(group);
|
|
@@ -1207,7 +1355,7 @@ class ToolHandler {
|
|
|
1207
1355
|
}
|
|
1208
1356
|
// Multiple DISTINCT definitions (#764): per-definition sections.
|
|
1209
1357
|
const lines = [
|
|
1210
|
-
|
|
1358
|
+
`**Callees of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)**`,
|
|
1211
1359
|
];
|
|
1212
1360
|
for (const group of groups) {
|
|
1213
1361
|
const { callees, labels } = collect(group);
|
|
@@ -1270,7 +1418,7 @@ class ToolHandler {
|
|
|
1270
1418
|
// merging unrelated same-named classes (one UserService per monorepo app)
|
|
1271
1419
|
// overstated impact and confused agents. Narrow with `file`.
|
|
1272
1420
|
const sections = [
|
|
1273
|
-
|
|
1421
|
+
`**Impact of ${symbol} — ${groups.length} distinct definitions (each with its own blast radius; narrow with \`file\`)**`,
|
|
1274
1422
|
];
|
|
1275
1423
|
for (const group of groups) {
|
|
1276
1424
|
const head = group[0];
|
|
@@ -1346,6 +1494,29 @@ class ToolHandler {
|
|
|
1346
1494
|
registeredAt,
|
|
1347
1495
|
};
|
|
1348
1496
|
}
|
|
1497
|
+
if (m?.synthesizedBy === 'fn-pointer-dispatch') {
|
|
1498
|
+
const via = m.via ? `\`${String(m.via)}\`` : 'a function pointer';
|
|
1499
|
+
return {
|
|
1500
|
+
label: `function-pointer dispatch via ${via} (dynamic dispatch)`,
|
|
1501
|
+
compact: `dynamic: fn-pointer ${m.via ? String(m.via) : ''}${at}`,
|
|
1502
|
+
registeredAt,
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
if (m?.synthesizedBy === 'goframe-route') {
|
|
1506
|
+
const route = m.route ? `\`${String(m.route)}\`` : 'a route';
|
|
1507
|
+
return {
|
|
1508
|
+
label: `GoFrame route ${route} — reflective Bind → controller method (dynamic dispatch)`,
|
|
1509
|
+
compact: `dynamic: GoFrame route ${m.route ? String(m.route) : ''}${at}`,
|
|
1510
|
+
registeredAt,
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
// Generic fallback for any other synthesizer (redux-thunk, gin-middleware-chain,
|
|
1514
|
+
// flutter-build, …): a synthesized hop must never read as a bare static `calls`.
|
|
1515
|
+
// It's a dynamic-dispatch bridge — label it as one and keep its wiring site.
|
|
1516
|
+
if (typeof m?.synthesizedBy === 'string') {
|
|
1517
|
+
const kind = m.synthesizedBy.replace(/-/g, ' ');
|
|
1518
|
+
return { label: `${kind} (dynamic dispatch)`, compact: `dynamic: ${kind}${at}`, registeredAt };
|
|
1519
|
+
}
|
|
1349
1520
|
return null;
|
|
1350
1521
|
}
|
|
1351
1522
|
/**
|
|
@@ -1363,7 +1534,10 @@ class ToolHandler {
|
|
|
1363
1534
|
* dropping unrelated `OmsOrderService::list`.
|
|
1364
1535
|
*/
|
|
1365
1536
|
buildFlowFromNamedSymbols(cg, query) {
|
|
1366
|
-
|
|
1537
|
+
// spineCallSites: for each spine node, the line where it CALLS the next hop —
|
|
1538
|
+
// lets the source assembler window an oversize spine method (e.g. n8n's 962-line
|
|
1539
|
+
// processRunExecutionData) to the call site instead of dumping the whole body.
|
|
1540
|
+
const EMPTY = { text: '', pathNodeIds: new Set(), namedNodeIds: new Set(), uniqueNamedNodeIds: new Set(), spineCallSites: new Map() };
|
|
1367
1541
|
try {
|
|
1368
1542
|
const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
|
|
1369
1543
|
// Strip only a REAL file extension (Create.cs → Create); KEEP qualified
|
|
@@ -1395,8 +1569,24 @@ class ToolHandler {
|
|
|
1395
1569
|
// the dynamic-boundary scan (a token is covered when ANY of its nodes
|
|
1396
1570
|
// lands on the main chain — overloads off the chain don't count against).
|
|
1397
1571
|
const tokenNodes = new Map();
|
|
1572
|
+
// token → its full same-name callable family (before the container filter).
|
|
1573
|
+
// A LARGE family that fails to connect on the chain is a polymorphic
|
|
1574
|
+
// interface/registry dispatch — surfaced by buildPolymorphicBoundaries below.
|
|
1575
|
+
const tokenFamily = new Map();
|
|
1576
|
+
// Non-callable endpoints (CONSTANT/VARIABLE/FIELD) connected by a SYNTHESIZED
|
|
1577
|
+
// edge. RTK thunks are `const X = createAsyncThunk(...)`, so a thunk→thunk hop
|
|
1578
|
+
// is constant→constant — the CALLABLE-only `named` set can't hold it, and
|
|
1579
|
+
// without this the hop is invisible to the Flow path at every tier (the
|
|
1580
|
+
// Relationships section catches it only on repos ≥500 files). Kept SEPARATE
|
|
1581
|
+
// from `named` (which drives the call-chain + source sizing, callable-only);
|
|
1582
|
+
// fed only to the dynamic-dispatch-links scan below.
|
|
1583
|
+
const dynNamed = new Map();
|
|
1584
|
+
const DYN_KINDS = new Set(['constant', 'variable', 'field', 'property']);
|
|
1585
|
+
const hasHeuristicEdge = (id) => [...cg.getCallers(id), ...cg.getCallees(id)].some(({ edge }) => edge.provenance === 'heuristic');
|
|
1398
1586
|
for (const t of tokens) {
|
|
1399
|
-
const
|
|
1587
|
+
const hits = this.findAllSymbols(cg, t).nodes;
|
|
1588
|
+
const cands = hits.filter((n) => CALLABLE.has(n.kind));
|
|
1589
|
+
tokenFamily.set(t, cands);
|
|
1400
1590
|
// A qualified or otherwise-specific name (<=3 hits) keeps all; an
|
|
1401
1591
|
// ambiguous simple name keeps only candidates whose container is named.
|
|
1402
1592
|
const specific = cands.length <= 3;
|
|
@@ -1414,21 +1604,67 @@ class ToolHandler {
|
|
|
1414
1604
|
if (specific)
|
|
1415
1605
|
uniqueNamedNodeIds.add(n.id);
|
|
1416
1606
|
}
|
|
1607
|
+
// Same token, non-callable synth endpoints (capped, precision-gated on an
|
|
1608
|
+
// actual heuristic edge so plain config constants never qualify).
|
|
1609
|
+
if (dynNamed.size < 12) {
|
|
1610
|
+
for (const n of hits) {
|
|
1611
|
+
if (CALLABLE.has(n.kind) || !DYN_KINDS.has(n.kind) || dynNamed.has(n.id))
|
|
1612
|
+
continue;
|
|
1613
|
+
if (hasHeuristicEdge(n.id))
|
|
1614
|
+
dynNamed.set(n.id, n);
|
|
1615
|
+
if (dynNamed.size >= 12)
|
|
1616
|
+
break;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1417
1619
|
if (named.size > 40)
|
|
1418
1620
|
break;
|
|
1419
1621
|
}
|
|
1622
|
+
// Surface synthesized (heuristic) edges incident to a named symbol — INCLUDING
|
|
1623
|
+
// the non-callable CONSTANT endpoints in `dynNamed`. `skipInChain` drops a hop
|
|
1624
|
+
// already shown in the rendered main chain (a 2-node chain renders nothing, so a
|
|
1625
|
+
// direct named→named synth hop still surfaces — #687).
|
|
1626
|
+
const collectSynthLinks = (skipInChain) => {
|
|
1627
|
+
const synthLines = [];
|
|
1628
|
+
const synthSeen = new Set();
|
|
1629
|
+
for (const n of [...named.values(), ...dynNamed.values()]) {
|
|
1630
|
+
if (synthLines.length >= 6)
|
|
1631
|
+
break;
|
|
1632
|
+
for (const { node: other, edge } of [...cg.getCallers(n.id), ...cg.getCallees(n.id)]) {
|
|
1633
|
+
if (synthLines.length >= 6)
|
|
1634
|
+
break;
|
|
1635
|
+
if (edge.provenance !== 'heuristic' || other.id === n.id)
|
|
1636
|
+
continue;
|
|
1637
|
+
if (skipInChain && skipInChain(edge))
|
|
1638
|
+
continue;
|
|
1639
|
+
const src = edge.source === n.id ? n : other;
|
|
1640
|
+
const tgt = edge.source === n.id ? other : n;
|
|
1641
|
+
const key = `${src.name}>${tgt.name}`;
|
|
1642
|
+
if (synthSeen.has(key))
|
|
1643
|
+
continue;
|
|
1644
|
+
synthSeen.add(key);
|
|
1645
|
+
const note = this.synthEdgeNote(edge);
|
|
1646
|
+
synthLines.push(`- ${src.name} → ${tgt.name} [${note ? note.compact : edge.kind}]`);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
return synthLines;
|
|
1650
|
+
};
|
|
1420
1651
|
if (named.size < 2) {
|
|
1421
|
-
//
|
|
1422
|
-
//
|
|
1423
|
-
//
|
|
1424
|
-
//
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
const boundaries = this.buildDynamicBoundaries(cg, [...named.values()], named);
|
|
1428
|
-
if (!boundaries)
|
|
1652
|
+
// <2 CALLABLES resolved. Two recoveries before giving up: (1) synthesized
|
|
1653
|
+
// edges among named CONSTANT/VARIABLE endpoints — RTK thunk→thunk is
|
|
1654
|
+
// constant→constant, so `named` can be empty while `dynNamed` holds the
|
|
1655
|
+
// whole chain; (2) the one resolved callable's body may hold the
|
|
1656
|
+
// dynamic-dispatch site that EXPLAINS a half-connected flow.
|
|
1657
|
+
const synthLines = collectSynthLinks(null);
|
|
1658
|
+
const boundaries = named.size === 0 ? '' : (this.buildDynamicBoundaries(cg, [...named.values()], named) || '');
|
|
1659
|
+
if (synthLines.length === 0 && !boundaries)
|
|
1429
1660
|
return EMPTY;
|
|
1430
|
-
const
|
|
1431
|
-
|
|
1661
|
+
const out = [];
|
|
1662
|
+
if (synthLines.length)
|
|
1663
|
+
out.push('**Dynamic-dispatch links among your symbols**', '(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)', '', ...synthLines, '');
|
|
1664
|
+
if (boundaries)
|
|
1665
|
+
out.push(boundaries);
|
|
1666
|
+
out.push('> Full source for these symbols is below.\n');
|
|
1667
|
+
return { text: out.join('\n'), pathNodeIds: new Set(), namedNodeIds: new Set([...named.keys(), ...dynNamed.keys()]), uniqueNamedNodeIds, spineCallSites: new Map() };
|
|
1432
1668
|
}
|
|
1433
1669
|
const MAX_HOPS = 7;
|
|
1434
1670
|
let best = null;
|
|
@@ -1477,6 +1713,16 @@ class ToolHandler {
|
|
|
1477
1713
|
}
|
|
1478
1714
|
const hasMain = !!best && best.length >= 3;
|
|
1479
1715
|
const pathIds = new Set((best ?? []).map((s) => s.node.id));
|
|
1716
|
+
// Where each spine node calls the NEXT hop (best[i+1].edge is the edge from
|
|
1717
|
+
// best[i] → best[i+1]; its line is the call site inside best[i]'s body). Lets
|
|
1718
|
+
// the assembler window an oversize spine method to the call instead of dumping it.
|
|
1719
|
+
const spineCallSites = new Map();
|
|
1720
|
+
if (best)
|
|
1721
|
+
for (let i = 0; i < best.length - 1; i++) {
|
|
1722
|
+
const ln = best[i + 1]?.edge?.line;
|
|
1723
|
+
if (ln && ln > 0 && !spineCallSites.has(best[i].node.id))
|
|
1724
|
+
spineCallSites.set(best[i].node.id, ln);
|
|
1725
|
+
}
|
|
1480
1726
|
// Dynamic-boundary scan (#687) — fires ONLY when the flow the agent
|
|
1481
1727
|
// asked about did not fully connect: some token resolved to nodes but
|
|
1482
1728
|
// none of them sit on the main chain (or there is no chain at all). A
|
|
@@ -1514,46 +1760,43 @@ class ToolHandler {
|
|
|
1514
1760
|
boundaryText = this.buildDynamicBoundaries(cg, scanList, named);
|
|
1515
1761
|
}
|
|
1516
1762
|
}
|
|
1517
|
-
//
|
|
1518
|
-
//
|
|
1519
|
-
//
|
|
1520
|
-
//
|
|
1521
|
-
//
|
|
1522
|
-
//
|
|
1523
|
-
//
|
|
1524
|
-
//
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
for (const
|
|
1531
|
-
if (
|
|
1532
|
-
break;
|
|
1533
|
-
if (edge.provenance !== 'heuristic' || other.id === n.id)
|
|
1534
|
-
continue;
|
|
1535
|
-
// "Already in the main chain" only applies when a chain RENDERS
|
|
1536
|
-
// (hasMain). A 2-node chain populates pathIds but renders nothing,
|
|
1537
|
-
// so a direct synthesized hop between two named symbols (custom
|
|
1538
|
-
// EventBus emit→handler, #687) was invisible — too short for Flow,
|
|
1539
|
-
// skipped here as in-chain. Surface it.
|
|
1540
|
-
if (hasMain && pathIds.has(edge.source) && pathIds.has(edge.target))
|
|
1541
|
-
continue;
|
|
1542
|
-
const src = edge.source === n.id ? n : other;
|
|
1543
|
-
const tgt = edge.source === n.id ? other : n;
|
|
1544
|
-
const key = `${src.name}>${tgt.name}`;
|
|
1545
|
-
if (synthSeen.has(key))
|
|
1763
|
+
// Interface/registry-dispatch announcement (extends #687 to GRAPH-visible
|
|
1764
|
+
// polymorphism). A method the agent NAMED that resolves to a large same-name
|
|
1765
|
+
// family AND did not land on the main chain is almost always a runtime
|
|
1766
|
+
// dispatch (plugin/strategy/handler interface): the concrete target is chosen
|
|
1767
|
+
// at runtime from N implementations, so no single static edge is the answer.
|
|
1768
|
+
// The body-scan above can't see this — `nodeType.execute()` is textually an
|
|
1769
|
+
// ordinary call; the polymorphism lives in the graph (implements edges), so
|
|
1770
|
+
// detect it there. Fires ONLY for an uncovered named token; a connected flow
|
|
1771
|
+
// stays silent.
|
|
1772
|
+
let polyText = '';
|
|
1773
|
+
{
|
|
1774
|
+
const POLY_MIN_FAMILY = 8; // smaller families are overload sets, not dispatch
|
|
1775
|
+
const polyCands = [];
|
|
1776
|
+
for (const [t, fam] of tokenFamily) {
|
|
1777
|
+
if (fam.length < POLY_MIN_FAMILY)
|
|
1546
1778
|
continue;
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1779
|
+
const ids = tokenNodes.get(t) || [];
|
|
1780
|
+
if (ids.some((id) => pathIds.has(id)))
|
|
1781
|
+
continue; // covered by the flow — silent
|
|
1782
|
+
polyCands.push({ token: t, family: fam });
|
|
1550
1783
|
}
|
|
1784
|
+
if (polyCands.length)
|
|
1785
|
+
polyText = this.buildPolymorphicBoundaries(cg, polyCands, named);
|
|
1551
1786
|
}
|
|
1552
|
-
|
|
1787
|
+
// Supplementary: dynamic-dispatch (synthesized) edges incident to a named
|
|
1788
|
+
// symbol (incl. the non-callable CONSTANT endpoints in `dynNamed`) — the
|
|
1789
|
+
// indirect hops an agent would otherwise grep/Read to reconstruct ("where do
|
|
1790
|
+
// the appended `validators` actually run?"). Surfaced even when the OTHER end
|
|
1791
|
+
// wasn't named. The skip drops a hop already in the rendered main chain; a
|
|
1792
|
+
// 2-node chain renders nothing (hasMain false) so a direct named→named synth
|
|
1793
|
+
// hop still surfaces — too short for Flow, but #687-visible here.
|
|
1794
|
+
const synthLines = collectSynthLinks(hasMain ? (e) => pathIds.has(e.source) && pathIds.has(e.target) : null);
|
|
1795
|
+
if (!hasMain && synthLines.length === 0 && !boundaryText && !polyText)
|
|
1553
1796
|
return EMPTY;
|
|
1554
1797
|
const out = [];
|
|
1555
1798
|
if (hasMain) {
|
|
1556
|
-
out.push('
|
|
1799
|
+
out.push('**Flow (call path among the symbols you queried)**', '');
|
|
1557
1800
|
for (let i = 0; i < best.length; i++) {
|
|
1558
1801
|
const step = best[i];
|
|
1559
1802
|
if (step.edge) {
|
|
@@ -1565,17 +1808,19 @@ class ToolHandler {
|
|
|
1565
1808
|
out.push('');
|
|
1566
1809
|
}
|
|
1567
1810
|
if (synthLines.length) {
|
|
1568
|
-
out.push('
|
|
1811
|
+
out.push('**Dynamic-dispatch links among your symbols**', '(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)', '', ...synthLines, '');
|
|
1569
1812
|
}
|
|
1570
1813
|
if (boundaryText)
|
|
1571
1814
|
out.push(boundaryText);
|
|
1815
|
+
if (polyText)
|
|
1816
|
+
out.push(polyText);
|
|
1572
1817
|
out.push('> Full source for these symbols is below — the call flow among them, followed by their bodies.', '');
|
|
1573
1818
|
// namedNodeIds = every callable the agent explicitly named (a superset of
|
|
1574
1819
|
// the spine). A file holding one is something the agent asked to SEE, so it
|
|
1575
1820
|
// must keep full source even if it's an off-spine polymorphic sibling — the
|
|
1576
1821
|
// agent named `getResponseWithInterceptorChain` / `SQLCompiler.execute_sql`
|
|
1577
1822
|
// as the mechanism, not as an interchangeable leaf. See the skeleton gate.
|
|
1578
|
-
return { text: out.join('\n'), pathNodeIds: pathIds, namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds };
|
|
1823
|
+
return { text: out.join('\n'), pathNodeIds: pathIds, namedNodeIds: new Set([...named.keys(), ...dynNamed.keys()]), uniqueNamedNodeIds, spineCallSites };
|
|
1579
1824
|
}
|
|
1580
1825
|
catch {
|
|
1581
1826
|
return EMPTY;
|
|
@@ -1645,7 +1890,7 @@ class ToolHandler {
|
|
|
1645
1890
|
if (notes.length === 0)
|
|
1646
1891
|
return '';
|
|
1647
1892
|
return [
|
|
1648
|
-
'
|
|
1893
|
+
'**Dynamic boundaries (the static path ends at runtime dispatch)**',
|
|
1649
1894
|
'',
|
|
1650
1895
|
...notes,
|
|
1651
1896
|
'',
|
|
@@ -1653,6 +1898,113 @@ class ToolHandler {
|
|
|
1653
1898
|
'',
|
|
1654
1899
|
].join('\n');
|
|
1655
1900
|
}
|
|
1901
|
+
/**
|
|
1902
|
+
* Interface/registry-dispatch announcement — #687 extended to GRAPH-visible
|
|
1903
|
+
* polymorphism (the body-scan can't see it: `nodeType.execute()` is textually
|
|
1904
|
+
* an ordinary call; the polymorphism lives in the `implements`/`extends` edges).
|
|
1905
|
+
*
|
|
1906
|
+
* A method the agent named that resolves to a large same-name family whose
|
|
1907
|
+
* definers overwhelmingly implement/extend ONE supertype is a runtime dispatch:
|
|
1908
|
+
* the concrete target is chosen at runtime from N implementations, so no single
|
|
1909
|
+
* static edge is "the answer" — the implementations ARE the continuations. We
|
|
1910
|
+
* announce the supertype, its TRUE implementer count, and a few concrete targets,
|
|
1911
|
+
* then steer to codegraph_explore. Graph-only, query-time, zero mutation; the
|
|
1912
|
+
* caller fires it ONLY for an UNCOVERED named token, so a connected flow is silent.
|
|
1913
|
+
*
|
|
1914
|
+
* Robust to FTS sampling bias: the same-name family is a capped FTS sample that
|
|
1915
|
+
* over-represents whatever FTS ranks first (n8n: DB `TableOperation.execute`
|
|
1916
|
+
* outnumbered `INodeType.execute` in the sample 7:6 even though INodeType has
|
|
1917
|
+
* 611 implementers vs a handful). So candidate supertypes are ranked by their
|
|
1918
|
+
* TRUE graph-wide implementer count, NOT their frequency in the sample.
|
|
1919
|
+
*/
|
|
1920
|
+
buildPolymorphicBoundaries(cg, candidates, named) {
|
|
1921
|
+
const CLASSY = new Set(['class', 'struct', 'interface', 'trait', 'protocol', 'abstract']);
|
|
1922
|
+
const MIN_IMPL = 8; // a supertype needs >= this many implementers to count as "polymorphic"
|
|
1923
|
+
const MIN_SUPPORT = 2; // >= this many sampled definers must share the supertype (ties it to the token)
|
|
1924
|
+
const SAMPLE = 40; // family members inspected per token
|
|
1925
|
+
const MAX_NOTES = 3;
|
|
1926
|
+
const rel = (p) => p.replace(/\\/g, '/');
|
|
1927
|
+
const containerOf = (m) => {
|
|
1928
|
+
try {
|
|
1929
|
+
const ce = cg.getIncomingEdges(m.id).find((e) => e.kind === 'contains');
|
|
1930
|
+
return ce ? cg.getNode(ce.source) : null;
|
|
1931
|
+
}
|
|
1932
|
+
catch {
|
|
1933
|
+
return null;
|
|
1934
|
+
}
|
|
1935
|
+
};
|
|
1936
|
+
const notes = [];
|
|
1937
|
+
const seenSuper = new Set();
|
|
1938
|
+
for (const { token, family } of candidates) {
|
|
1939
|
+
if (notes.length >= MAX_NOTES)
|
|
1940
|
+
break;
|
|
1941
|
+
// supertype id → how many sampled definers share it + a few example definers
|
|
1942
|
+
const supers = new Map();
|
|
1943
|
+
for (const m of family.slice(0, SAMPLE)) {
|
|
1944
|
+
const container = containerOf(m);
|
|
1945
|
+
if (!container || !CLASSY.has(container.kind))
|
|
1946
|
+
continue;
|
|
1947
|
+
let sups = [];
|
|
1948
|
+
try {
|
|
1949
|
+
sups = cg.getOutgoingEdges(container.id)
|
|
1950
|
+
.filter((e) => e.kind === 'implements' || e.kind === 'extends')
|
|
1951
|
+
.map((e) => { try {
|
|
1952
|
+
return cg.getNode(e.target);
|
|
1953
|
+
}
|
|
1954
|
+
catch {
|
|
1955
|
+
return null;
|
|
1956
|
+
} })
|
|
1957
|
+
.filter((n) => !!n && CLASSY.has(n.kind) && (n.name?.length || 0) >= 3);
|
|
1958
|
+
}
|
|
1959
|
+
catch { /* no supertypes — free function or unresolved */ }
|
|
1960
|
+
for (const s of sups) {
|
|
1961
|
+
const e = supers.get(s.id) || { node: s, count: 0, targets: [] };
|
|
1962
|
+
e.count++;
|
|
1963
|
+
if (e.targets.length < 6)
|
|
1964
|
+
e.targets.push(m);
|
|
1965
|
+
supers.set(s.id, e);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
// Pick the supertype with the most TRUE implementers (graph-wide), among
|
|
1969
|
+
// those genuinely shared by the token's definers.
|
|
1970
|
+
let best = null;
|
|
1971
|
+
for (const { node, count, targets } of supers.values()) {
|
|
1972
|
+
if (count < MIN_SUPPORT)
|
|
1973
|
+
continue;
|
|
1974
|
+
let impl = 0;
|
|
1975
|
+
try {
|
|
1976
|
+
impl = cg.getIncomingEdges(node.id).filter((e) => e.kind === 'implements' || e.kind === 'extends').length;
|
|
1977
|
+
}
|
|
1978
|
+
catch { /* leave 0 — gated out below */ }
|
|
1979
|
+
if (impl < MIN_IMPL)
|
|
1980
|
+
continue;
|
|
1981
|
+
if (!best || impl > best.impl)
|
|
1982
|
+
best = { node, impl, targets };
|
|
1983
|
+
}
|
|
1984
|
+
if (!best || seenSuper.has(best.node.id))
|
|
1985
|
+
continue;
|
|
1986
|
+
seenSuper.add(best.node.id);
|
|
1987
|
+
const namedNames = new Set([...named.values()].map((n) => n.name));
|
|
1988
|
+
const eg = best.targets.slice(0, 4).map((m) => {
|
|
1989
|
+
const cont = containerOf(m);
|
|
1990
|
+
const disp = cont ? `${cont.name}.${m.name}` : (m.qualifiedName || m.name);
|
|
1991
|
+
const mark = cont && namedNames.has(cont.name) ? ' ← you named this' : '';
|
|
1992
|
+
return `\`${disp}\` (${rel(m.filePath)}:${m.startLine})${mark}`;
|
|
1993
|
+
});
|
|
1994
|
+
const more = best.impl > eg.length ? ` +${best.impl - eg.length} more` : '';
|
|
1995
|
+
notes.push(`- \`${token}\` → runtime dispatch to **${best.impl}** types implementing \`${best.node.name}\` — the static path ends here, the target is chosen at runtime. e.g. ${eg.join(', ')}${more}`);
|
|
1996
|
+
}
|
|
1997
|
+
if (notes.length === 0)
|
|
1998
|
+
return '';
|
|
1999
|
+
return [
|
|
2000
|
+
'**Interface dispatch (a named method has many implementations)**',
|
|
2001
|
+
'',
|
|
2002
|
+
...notes,
|
|
2003
|
+
'',
|
|
2004
|
+
'> The method above is dispatched at runtime to one of the listed implementations (a registry / plugin / strategy interface) — there is no single static caller→callee edge; the implementations ARE the continuations. To follow one, run codegraph_explore on a listed target.',
|
|
2005
|
+
'',
|
|
2006
|
+
].join('\n');
|
|
2007
|
+
}
|
|
1656
2008
|
/**
|
|
1657
2009
|
* Shortlist candidate runtime targets for a dispatch key surfaced by
|
|
1658
2010
|
* {@link buildDynamicBoundaries}. Exact conventional names first (`save` →
|
|
@@ -1791,7 +2143,7 @@ class ToolHandler {
|
|
|
1791
2143
|
if (entries.length === 0)
|
|
1792
2144
|
return '';
|
|
1793
2145
|
return [
|
|
1794
|
-
'
|
|
2146
|
+
'**Blast radius — what depends on these (update/verify before editing)**',
|
|
1795
2147
|
'',
|
|
1796
2148
|
...entries,
|
|
1797
2149
|
'',
|
|
@@ -2191,6 +2543,25 @@ class ToolHandler {
|
|
|
2191
2543
|
if (n)
|
|
2192
2544
|
namedSeedFiles.add(n.filePath);
|
|
2193
2545
|
}
|
|
2546
|
+
// Multi-term corroboration tier: a file that is BOTH (a) an entry/central file
|
|
2547
|
+
// (a search root, named seed, or graph-central hub — i.e. structurally part of
|
|
2548
|
+
// the answer) AND (b) matched by ≥2 DISTINCT query terms must not be buried by
|
|
2549
|
+
// graph-centrality mass that accrued to a denser-but-off-topic cluster. In a
|
|
2550
|
+
// cross-layer monorepo (an API server alongside a much larger, internally dense
|
|
2551
|
+
// frontend that mirrors the same domain words) the Random-Walk-with-Restart mass
|
|
2552
|
+
// — seeded from text matches that skew to the bigger layer — floats hits=0
|
|
2553
|
+
// frontend files above the hits=2/3 backend service that IS the answer (its many
|
|
2554
|
+
// callers don't help: it's call-isolated from the frontend seed cluster). The
|
|
2555
|
+
// entry/central GUARD keeps this safe: an INCIDENTAL multi-term file that is
|
|
2556
|
+
// neither entry nor central (a type/util file that matches "element"+x but isn't
|
|
2557
|
+
// the flow) is NOT promoted, so it can't displace the graph-central answer file
|
|
2558
|
+
// (hits=1) the way a blunt hits-only tier would. Single-layer repos with one
|
|
2559
|
+
// cluster are unaffected (no competing mass). Set CODEGRAPH_RANK_NO_MULTITERM=1
|
|
2560
|
+
// to disable.
|
|
2561
|
+
const MULTITERM_OFF = process.env.CODEGRAPH_RANK_NO_MULTITERM === '1';
|
|
2562
|
+
const isCorroborated = (fp) => !MULTITERM_OFF &&
|
|
2563
|
+
(fileTermHits.get(fp) ?? 0) >= 2 &&
|
|
2564
|
+
(entryFiles.has(fp) || centralFiles.has(fp));
|
|
2194
2565
|
const sortedFiles = relevantFiles.sort((a, b) => {
|
|
2195
2566
|
const aPath = a[0].toLowerCase();
|
|
2196
2567
|
const bPath = b[0].toLowerCase();
|
|
@@ -2199,6 +2570,11 @@ class ToolHandler {
|
|
|
2199
2570
|
const bNamed = namedSeedFiles.has(b[0]) ? 1 : 0;
|
|
2200
2571
|
if (aNamed !== bNamed)
|
|
2201
2572
|
return bNamed - aNamed;
|
|
2573
|
+
// Corroborated (entry/central + ≥2 terms) tier, above the graph signal.
|
|
2574
|
+
const aCorr = isCorroborated(a[0]) ? 1 : 0;
|
|
2575
|
+
const bCorr = isCorroborated(b[0]) ? 1 : 0;
|
|
2576
|
+
if (aCorr !== bCorr)
|
|
2577
|
+
return bCorr - aCorr;
|
|
2202
2578
|
// Graph connectivity is the next key (small epsilon so near-ties fall
|
|
2203
2579
|
// through to the text signal rather than coin-flipping on float noise).
|
|
2204
2580
|
const aG = fileGraphScore.get(a[0]) ?? 0;
|
|
@@ -2229,7 +2605,7 @@ class ToolHandler {
|
|
|
2229
2605
|
});
|
|
2230
2606
|
// Step 3: Build relationship map
|
|
2231
2607
|
const lines = [
|
|
2232
|
-
|
|
2608
|
+
`**Exploration: ${query}**`,
|
|
2233
2609
|
'',
|
|
2234
2610
|
`Found ${subgraph.nodes.size} symbols across ${fileGroups.size} files.`,
|
|
2235
2611
|
'',
|
|
@@ -2244,7 +2620,7 @@ class ToolHandler {
|
|
|
2244
2620
|
const significantEdges = subgraph.edges.filter(e => e.kind !== 'contains' // skip contains — it's implied by file grouping
|
|
2245
2621
|
);
|
|
2246
2622
|
if (budget.includeRelationships && significantEdges.length > 0) {
|
|
2247
|
-
lines.push('
|
|
2623
|
+
lines.push('**Relationships**');
|
|
2248
2624
|
lines.push('');
|
|
2249
2625
|
// Group edges by kind for readability
|
|
2250
2626
|
const byKind = new Map();
|
|
@@ -2329,7 +2705,7 @@ class ToolHandler {
|
|
|
2329
2705
|
}
|
|
2330
2706
|
return false;
|
|
2331
2707
|
};
|
|
2332
|
-
lines.push('
|
|
2708
|
+
lines.push('**Source Code**');
|
|
2333
2709
|
lines.push('');
|
|
2334
2710
|
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.');
|
|
2335
2711
|
lines.push('');
|
|
@@ -2484,7 +2860,7 @@ class ToolHandler {
|
|
|
2484
2860
|
const tag = bodyIds.size > 0
|
|
2485
2861
|
? 'focused (the methods you named in full, the rest as signatures — codegraph_explore a signature by name for its body; do NOT Read)'
|
|
2486
2862
|
: 'skeleton (signatures only — codegraph_explore a name for its full body; do NOT Read)';
|
|
2487
|
-
lines.push(
|
|
2863
|
+
lines.push(fileSectionHeader(filePath, `${names} · ${tag}`), '', '```' + lang, skel.join('\n'), '```', '');
|
|
2488
2864
|
totalChars += skel.join('\n').length + 120;
|
|
2489
2865
|
filesIncluded++;
|
|
2490
2866
|
continue;
|
|
@@ -2522,7 +2898,7 @@ class ToolHandler {
|
|
|
2522
2898
|
.map(n => `${n.name}(${n.kind})`))];
|
|
2523
2899
|
const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
|
|
2524
2900
|
const omitted = uniqSymbols.length - headerNames.length;
|
|
2525
|
-
const wholeHeader =
|
|
2901
|
+
const wholeHeader = fileSectionHeader(filePath, omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', '));
|
|
2526
2902
|
if (!fileNecessary && totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
|
|
2527
2903
|
// Don't slice a whole file mid-method: an incidental file that doesn't
|
|
2528
2904
|
// fit is skipped; a necessary one (below) renders in full. Half a file
|
|
@@ -2586,7 +2962,12 @@ class ToolHandler {
|
|
|
2586
2962
|
importance = 6; // bridging caller/callee of an entry
|
|
2587
2963
|
else if (connectedToEntry.has(n.id))
|
|
2588
2964
|
importance = 3;
|
|
2589
|
-
|
|
2965
|
+
// On the rendered call-path spine? That IS the flow answer — its cluster
|
|
2966
|
+
// must never be dropped by the per-file budget (n8n's huge workflow-execute.ts:
|
|
2967
|
+
// processRunExecutionData, the named flow ENTRY at L1562, is a large
|
|
2968
|
+
// low-density method that lost the budget to denser blocks and got cut, so
|
|
2969
|
+
// the agent Read it back — the very thing explore exists to prevent).
|
|
2970
|
+
return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance, spine: flow.pathNodeIds.has(n.id), spineCallLine: flow.spineCallSites.get(n.id) };
|
|
2590
2971
|
});
|
|
2591
2972
|
// Add edge source locations in this file — captures template references
|
|
2592
2973
|
// (component usages, event handlers) that aren't nodes themselves.
|
|
@@ -2605,7 +2986,7 @@ class ToolHandler {
|
|
|
2605
2986
|
// Look up target name from subgraph first, fall back to edge kind
|
|
2606
2987
|
const targetNode = subgraph.nodes.get(edge.target);
|
|
2607
2988
|
const targetName = targetNode?.name ?? edge.kind;
|
|
2608
|
-
ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2 });
|
|
2989
|
+
ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2, spine: false });
|
|
2609
2990
|
}
|
|
2610
2991
|
}
|
|
2611
2992
|
ranges.sort((a, b) => a.start - b.start);
|
|
@@ -2619,6 +3000,8 @@ class ToolHandler {
|
|
|
2619
3000
|
symbols: [`${ranges[0].name}(${ranges[0].kind})`],
|
|
2620
3001
|
score: ranges[0].importance,
|
|
2621
3002
|
maxImportance: ranges[0].importance,
|
|
3003
|
+
hasSpine: ranges[0].spine,
|
|
3004
|
+
spineCallLine: ranges[0].spineCallLine,
|
|
2622
3005
|
};
|
|
2623
3006
|
for (let i = 1; i < ranges.length; i++) {
|
|
2624
3007
|
const r = ranges[i];
|
|
@@ -2627,6 +3010,8 @@ class ToolHandler {
|
|
|
2627
3010
|
current.symbols.push(`${r.name}(${r.kind})`);
|
|
2628
3011
|
current.score += r.importance;
|
|
2629
3012
|
current.maxImportance = Math.max(current.maxImportance, r.importance);
|
|
3013
|
+
current.hasSpine = current.hasSpine || r.spine;
|
|
3014
|
+
current.spineCallLine = current.spineCallLine ?? r.spineCallLine;
|
|
2630
3015
|
}
|
|
2631
3016
|
else {
|
|
2632
3017
|
clusters.push(current);
|
|
@@ -2636,6 +3021,8 @@ class ToolHandler {
|
|
|
2636
3021
|
symbols: [`${r.name}(${r.kind})`],
|
|
2637
3022
|
score: r.importance,
|
|
2638
3023
|
maxImportance: r.importance,
|
|
3024
|
+
hasSpine: r.spine,
|
|
3025
|
+
spineCallLine: r.spineCallLine,
|
|
2639
3026
|
};
|
|
2640
3027
|
}
|
|
2641
3028
|
}
|
|
@@ -2649,16 +3036,40 @@ class ToolHandler {
|
|
|
2649
3036
|
// get tail-trimmed with a marker.
|
|
2650
3037
|
const contextPadding = 3;
|
|
2651
3038
|
const withLineNumbers = exploreLineNumbersEnabled();
|
|
3039
|
+
// Language-neutral separator (no `//` — not a comment in Python, Ruby,
|
|
3040
|
+
// etc.). With line numbers on, the line-number jump also signals the gap.
|
|
3041
|
+
const GAP_MARKER = '\n\n... (gap) ...\n\n';
|
|
3042
|
+
// An oversize spine method (the call path runs THROUGH a god-method — n8n's
|
|
3043
|
+
// processRunExecutionData is 962 lines) is windowed to its next-hop CALL site
|
|
3044
|
+
// plus the signature head, NOT dumped whole. Without this the cluster is too big
|
|
3045
|
+
// for any per-file cap and gets dropped, so the agent Reads the method back —
|
|
3046
|
+
// the exact gap this closes. Bounded, so a god-method can't blow the budget yet
|
|
3047
|
+
// the spine's call still appears in context.
|
|
3048
|
+
const OVERSIZE_SPINE_LINES = 200;
|
|
3049
|
+
const SPINE_WINDOW = 28; // lines each side of the next-hop call site
|
|
2652
3050
|
const buildSection = (c) => {
|
|
3051
|
+
if (c.hasSpine && c.spineCallLine && (c.end - c.start + 1) > OVERSIZE_SPINE_LINES) {
|
|
3052
|
+
const call = c.spineCallLine;
|
|
3053
|
+
const winStart = Math.max(c.start, call - SPINE_WINDOW);
|
|
3054
|
+
const winEnd = Math.min(c.end, call + SPINE_WINDOW);
|
|
3055
|
+
const parts = [];
|
|
3056
|
+
// Signature head, only when it sits clearly above the window (else the
|
|
3057
|
+
// window already covers the method opening).
|
|
3058
|
+
const headEnd = Math.min(c.start + 4, winStart - 2);
|
|
3059
|
+
if (headEnd >= c.start) {
|
|
3060
|
+
const head = fileLines.slice(c.start - 1, headEnd).join('\n');
|
|
3061
|
+
parts.push(withLineNumbers ? numberSourceLines(head, c.start) : head);
|
|
3062
|
+
}
|
|
3063
|
+
const win = fileLines.slice(winStart - 1, winEnd).join('\n');
|
|
3064
|
+
parts.push(withLineNumbers ? numberSourceLines(win, winStart) : win);
|
|
3065
|
+
return parts.join(GAP_MARKER);
|
|
3066
|
+
}
|
|
2653
3067
|
const startIdx = Math.max(0, c.start - 1 - contextPadding);
|
|
2654
3068
|
const endIdx = Math.min(fileLines.length, c.end + contextPadding);
|
|
2655
3069
|
const slice = fileLines.slice(startIdx, endIdx).join('\n');
|
|
2656
3070
|
// startIdx is 0-based, so the slice's first line is line startIdx + 1.
|
|
2657
3071
|
return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice;
|
|
2658
3072
|
};
|
|
2659
|
-
// Language-neutral separator (no `//` — not a comment in Python, Ruby,
|
|
2660
|
-
// etc.). With line numbers on, the line-number jump also signals the gap.
|
|
2661
|
-
const GAP_MARKER = '\n\n... (gap) ...\n\n';
|
|
2662
3073
|
// Rank clusters for inclusion under the per-file cap. Entry-point
|
|
2663
3074
|
// clusters come first: a cluster containing a query entry point
|
|
2664
3075
|
// (importance 10) must outrank a dense block of mere declarations,
|
|
@@ -2672,6 +3083,12 @@ class ToolHandler {
|
|
|
2672
3083
|
const rankedClusters = clusters
|
|
2673
3084
|
.map((c, i) => ({ idx: i, span: c.end - c.start + 1, c }))
|
|
2674
3085
|
.sort((a, b) => {
|
|
3086
|
+
// Spine clusters first — the rendered call path IS the flow answer, so it
|
|
3087
|
+
// outranks any denser block of peripheral declarations (a low-density entry
|
|
3088
|
+
// method must not lose the budget to them). Within spine / within non-spine,
|
|
3089
|
+
// the existing importance → density → score → span order holds.
|
|
3090
|
+
if (a.c.hasSpine !== b.c.hasSpine)
|
|
3091
|
+
return (b.c.hasSpine ? 1 : 0) - (a.c.hasSpine ? 1 : 0);
|
|
2675
3092
|
if (b.c.maxImportance !== a.c.maxImportance)
|
|
2676
3093
|
return b.c.maxImportance - a.c.maxImportance;
|
|
2677
3094
|
const densityA = a.c.score / a.span;
|
|
@@ -2689,6 +3106,11 @@ class ToolHandler {
|
|
|
2689
3106
|
// That source-order slice is what cut Django's `_fetch_all` (L2237, importance
|
|
2690
3107
|
// 9 — agent-named) when query.py was the last of four big files to be emitted.
|
|
2691
3108
|
const fileBudget = Math.min(budget.maxCharsPerFile, Math.max(0, budget.maxOutputChars - totalChars - 200));
|
|
3109
|
+
// Spine ceiling: a flow-path cluster may exceed the per-file cap (the call
|
|
3110
|
+
// path is the answer), but bounded — at most ~2.5× the per-file cap and never
|
|
3111
|
+
// past what's left of the total output cap — so a pathological long in-file
|
|
3112
|
+
// spine can't run away or starve co-flow files entirely.
|
|
3113
|
+
const SPINE_CEILING = Math.min(budget.maxCharsPerFile * 2.5, Math.max(0, budget.maxOutputChars - totalChars - 200));
|
|
2692
3114
|
const chosenIndices = new Set();
|
|
2693
3115
|
let projectedChars = 0;
|
|
2694
3116
|
for (const rc of rankedClusters) {
|
|
@@ -2701,7 +3123,12 @@ class ToolHandler {
|
|
|
2701
3123
|
projectedChars += sectionLen;
|
|
2702
3124
|
continue;
|
|
2703
3125
|
}
|
|
2704
|
-
|
|
3126
|
+
// A spine cluster (the rendered call path) is the flow answer — include it
|
|
3127
|
+
// past the per-file budget up to the spine ceiling; non-spine clusters obey
|
|
3128
|
+
// the normal per-file budget.
|
|
3129
|
+
const fits = projectedChars + sectionLen <= fileBudget;
|
|
3130
|
+
const spineFits = rc.c.hasSpine && projectedChars + sectionLen <= SPINE_CEILING;
|
|
3131
|
+
if (!fits && !spineFits)
|
|
2705
3132
|
continue;
|
|
2706
3133
|
chosenIndices.add(rc.idx);
|
|
2707
3134
|
projectedChars += sectionLen;
|
|
@@ -2744,7 +3171,7 @@ class ToolHandler {
|
|
|
2744
3171
|
const headerSuffix = omittedCount > 0
|
|
2745
3172
|
? `${headerSymbols.join(', ')}, +${omittedCount} more`
|
|
2746
3173
|
: headerSymbols.join(', ');
|
|
2747
|
-
const fileHeader =
|
|
3174
|
+
const fileHeader = fileSectionHeader(filePath, headerSuffix);
|
|
2748
3175
|
// The total cap bounds INCIDENTAL files only. A file that DEFINES a symbol
|
|
2749
3176
|
// the agent named (or that's on the flow spine) renders even when the
|
|
2750
3177
|
// nominal total is used up — it's the answer, and the set is bounded by
|
|
@@ -2781,7 +3208,7 @@ class ToolHandler {
|
|
|
2781
3208
|
.sort((a, b) => b[1].score - a[1].score);
|
|
2782
3209
|
const remainingFiles = [...remainingRelevant, ...peripheralFiles];
|
|
2783
3210
|
if (remainingFiles.length > 0) {
|
|
2784
|
-
lines.push('
|
|
3211
|
+
lines.push('**Not shown above — explore these names for their source**');
|
|
2785
3212
|
lines.push('');
|
|
2786
3213
|
for (const [filePath, group] of remainingFiles.slice(0, 10)) {
|
|
2787
3214
|
const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
|
|
@@ -2828,13 +3255,13 @@ class ToolHandler {
|
|
|
2828
3255
|
const output = flow.text + lines.join('\n');
|
|
2829
3256
|
const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
|
|
2830
3257
|
if (output.length > hardCeiling) {
|
|
2831
|
-
// Cut at a FILE-SECTION boundary (the last
|
|
3258
|
+
// Cut at a FILE-SECTION boundary (the last ``**` `` file header before the
|
|
2832
3259
|
// ceiling) so we drop whole trailing file-sections rather than slicing
|
|
2833
3260
|
// through a method body — a half-rendered method just forces the Read this
|
|
2834
3261
|
// tool exists to prevent. Fall back to a line boundary only if no section
|
|
2835
3262
|
// header sits in the back half (degenerate single-giant-section case).
|
|
2836
3263
|
const cut = output.slice(0, hardCeiling);
|
|
2837
|
-
const lastSection = cut.lastIndexOf('\n
|
|
3264
|
+
const lastSection = cut.lastIndexOf('\n' + FILE_SECTION_PREFIX);
|
|
2838
3265
|
const boundary = lastSection > hardCeiling * 0.5 ? lastSection : cut.lastIndexOf('\n');
|
|
2839
3266
|
const safe = boundary > 0 ? cut.slice(0, boundary) : cut;
|
|
2840
3267
|
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.)');
|
|
@@ -2943,7 +3370,7 @@ class ToolHandler {
|
|
|
2943
3370
|
if (listed.length) {
|
|
2944
3371
|
const LIST_CAP = 20;
|
|
2945
3372
|
const shownList = listed.slice(0, LIST_CAP);
|
|
2946
|
-
out.push('', '
|
|
3373
|
+
out.push('', '**Other definitions**', ...shownList.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`));
|
|
2947
3374
|
if (listed.length > LIST_CAP)
|
|
2948
3375
|
out.push(`- … +${listed.length - LIST_CAP} more`);
|
|
2949
3376
|
out.push('', `> Need one of these in full? Call codegraph_node again with \`file\` (e.g. \`"${listed[0].filePath.split('/').pop()}"\`) or \`line\` — do NOT Read it.`);
|
|
@@ -3012,7 +3439,7 @@ class ToolHandler {
|
|
|
3012
3439
|
if (opts.symbolsOnly) {
|
|
3013
3440
|
const out = [`**${filePath}** — ${nodes.length} symbol${nodes.length === 1 ? '' : 's'}, ${depSummary}`, ''];
|
|
3014
3441
|
if (nodes.length)
|
|
3015
|
-
out.push(...symbolMap('
|
|
3442
|
+
out.push(...symbolMap('**Symbols**'));
|
|
3016
3443
|
else
|
|
3017
3444
|
out.push('_No indexed symbols in this file._');
|
|
3018
3445
|
out.push('', '> Drop `symbolsOnly` (or pass `offset`/`limit`) to read the source, like Read.');
|
|
@@ -3023,7 +3450,7 @@ class ToolHandler {
|
|
|
3023
3450
|
if (utils_1.CONFIG_LEAF_LANGUAGES.has(resolved.language)) {
|
|
3024
3451
|
const out = [`**${filePath}** — configuration/data file, ${depSummary}`, ''];
|
|
3025
3452
|
if (nodes.length)
|
|
3026
|
-
out.push(...symbolMap('
|
|
3453
|
+
out.push(...symbolMap('**Keys (values withheld for safety)**'));
|
|
3027
3454
|
out.push('', '> Values may be secrets, so codegraph indexes keys only. Read the file directly if you need a value.');
|
|
3028
3455
|
return this.textResult(this.truncateOutput(out.join('\n')));
|
|
3029
3456
|
}
|
|
@@ -3042,7 +3469,7 @@ class ToolHandler {
|
|
|
3042
3469
|
if (content === null) {
|
|
3043
3470
|
const out = [`**${filePath}** — could not read from disk (it may have moved since indexing). ${depSummary}`, ''];
|
|
3044
3471
|
if (nodes.length)
|
|
3045
|
-
out.push(...symbolMap('
|
|
3472
|
+
out.push(...symbolMap('**Symbols**'));
|
|
3046
3473
|
out.push('', `> Read \`${filePath}\` directly for its current content.`);
|
|
3047
3474
|
return this.textResult(this.truncateOutput(out.join('\n')));
|
|
3048
3475
|
}
|
|
@@ -3133,7 +3560,7 @@ class ToolHandler {
|
|
|
3133
3560
|
const callers = collect(cg.getCallers(node.id));
|
|
3134
3561
|
if (callees.length === 0 && callers.length === 0)
|
|
3135
3562
|
return '';
|
|
3136
|
-
const lines = ['', '
|
|
3563
|
+
const lines = ['', '**Trail — codegraph_node any of these to follow it (no Read needed)**'];
|
|
3137
3564
|
if (callees.length > 0) {
|
|
3138
3565
|
lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`);
|
|
3139
3566
|
}
|
|
@@ -3167,7 +3594,7 @@ class ToolHandler {
|
|
|
3167
3594
|
// one-liner via withWorktreeNotice. Both share the cached detection.
|
|
3168
3595
|
const mismatch = this.worktreeMismatchFor(args.projectPath);
|
|
3169
3596
|
const lines = [
|
|
3170
|
-
'
|
|
3597
|
+
'**CodeGraph Status**',
|
|
3171
3598
|
'',
|
|
3172
3599
|
];
|
|
3173
3600
|
if (mismatch) {
|
|
@@ -3189,25 +3616,32 @@ class ToolHandler {
|
|
|
3189
3616
|
lines.push(`**Journal mode:** ⚠ ${journalMode || 'unknown'} — WAL not active, so reads ` +
|
|
3190
3617
|
`can block on a concurrent write (WAL appears unsupported on this filesystem)`);
|
|
3191
3618
|
}
|
|
3192
|
-
lines.push('', '
|
|
3619
|
+
lines.push('', '**Nodes by Kind:**');
|
|
3193
3620
|
for (const [kind, count] of Object.entries(stats.nodesByKind)) {
|
|
3194
3621
|
if (count > 0) {
|
|
3195
3622
|
lines.push(`- ${kind}: ${count}`);
|
|
3196
3623
|
}
|
|
3197
3624
|
}
|
|
3198
|
-
lines.push('', '
|
|
3625
|
+
lines.push('', '**Languages:**');
|
|
3199
3626
|
for (const [lang, count] of Object.entries(stats.filesByLanguage)) {
|
|
3200
3627
|
if (count > 0) {
|
|
3201
3628
|
lines.push(`- ${lang}: ${count}`);
|
|
3202
3629
|
}
|
|
3203
3630
|
}
|
|
3631
|
+
// Whole-index degradation (#876): when live watching has permanently
|
|
3632
|
+
// stopped, getPendingFiles() is empty (so no "Pending sync" section below)
|
|
3633
|
+
// but the index is frozen — call that out explicitly here, the one place an
|
|
3634
|
+
// agent asks "is the index caught up?".
|
|
3635
|
+
if (cg.isWatcherDegraded()) {
|
|
3636
|
+
lines.push('', '**Auto-sync disabled:**', `- ${cg.getWatcherDegradedReason() ?? 'live file watching stopped'}`, '- The index is frozen; Read files directly for current content.');
|
|
3637
|
+
}
|
|
3204
3638
|
// Per-file freshness — the inverse of the auto-prepended staleness banner
|
|
3205
3639
|
// (issue #403). Surfacing it inside `status` gives the agent a single
|
|
3206
3640
|
// place to ask "is the index caught up?" rather than inferring from
|
|
3207
3641
|
// banners on other tool calls.
|
|
3208
3642
|
const pending = cg.getPendingFiles();
|
|
3209
3643
|
if (pending.length > 0) {
|
|
3210
|
-
lines.push('', '
|
|
3644
|
+
lines.push('', '**Pending sync:**');
|
|
3211
3645
|
const now = Date.now();
|
|
3212
3646
|
for (const p of pending) {
|
|
3213
3647
|
const ageMs = Math.max(0, now - p.lastSeenMs);
|
|
@@ -3287,7 +3721,7 @@ class ToolHandler {
|
|
|
3287
3721
|
* Format files as a flat list
|
|
3288
3722
|
*/
|
|
3289
3723
|
formatFilesFlat(files, includeMetadata) {
|
|
3290
|
-
const lines = [
|
|
3724
|
+
const lines = [`**Files (${files.length})**`, ''];
|
|
3291
3725
|
for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
|
|
3292
3726
|
if (includeMetadata) {
|
|
3293
3727
|
lines.push(`- ${file.path} (${file.language}, ${file.nodeCount} symbols)`);
|
|
@@ -3308,11 +3742,11 @@ class ToolHandler {
|
|
|
3308
3742
|
existing.push(file);
|
|
3309
3743
|
byLang.set(file.language, existing);
|
|
3310
3744
|
}
|
|
3311
|
-
const lines = [
|
|
3745
|
+
const lines = [`**Files by Language (${files.length} total)**`, ''];
|
|
3312
3746
|
// Sort languages by file count (descending)
|
|
3313
3747
|
const sortedLangs = [...byLang.entries()].sort((a, b) => b[1].length - a[1].length);
|
|
3314
3748
|
for (const [lang, langFiles] of sortedLangs) {
|
|
3315
|
-
lines.push(
|
|
3749
|
+
lines.push(`**${lang} (${langFiles.length})**`);
|
|
3316
3750
|
for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) {
|
|
3317
3751
|
if (includeMetadata) {
|
|
3318
3752
|
lines.push(`- ${file.path} (${file.nodeCount} symbols)`);
|
|
@@ -3348,7 +3782,7 @@ class ToolHandler {
|
|
|
3348
3782
|
}
|
|
3349
3783
|
}
|
|
3350
3784
|
// Render tree
|
|
3351
|
-
const lines = [
|
|
3785
|
+
const lines = [`**Project Structure (${files.length} files)**`, ''];
|
|
3352
3786
|
const renderNode = (node, prefix, isLast, depth) => {
|
|
3353
3787
|
if (maxDepth !== undefined && depth > maxDepth)
|
|
3354
3788
|
return;
|
|
@@ -3539,12 +3973,12 @@ class ToolHandler {
|
|
|
3539
3973
|
// Formatting helpers (compact by default to reduce context usage)
|
|
3540
3974
|
// =========================================================================
|
|
3541
3975
|
formatSearchResults(results) {
|
|
3542
|
-
const lines = [
|
|
3976
|
+
const lines = [`**Search Results (${results.length} found)**`, ''];
|
|
3543
3977
|
for (const result of results) {
|
|
3544
3978
|
const { node } = result;
|
|
3545
3979
|
const location = node.startLine ? `:${node.startLine}` : '';
|
|
3546
3980
|
// Compact format: one line per result with key info
|
|
3547
|
-
lines.push(
|
|
3981
|
+
lines.push(`**${node.name}** (${node.kind})`);
|
|
3548
3982
|
lines.push(`${node.filePath}${location}`);
|
|
3549
3983
|
if (node.signature)
|
|
3550
3984
|
lines.push(`\`${node.signature}\``);
|
|
@@ -3553,7 +3987,7 @@ class ToolHandler {
|
|
|
3553
3987
|
return lines.join('\n');
|
|
3554
3988
|
}
|
|
3555
3989
|
formatNodeList(nodes, title, labels) {
|
|
3556
|
-
const lines = [
|
|
3990
|
+
const lines = [`**${title} (${nodes.length} found)**`, ''];
|
|
3557
3991
|
for (const node of nodes) {
|
|
3558
3992
|
const location = node.startLine ? `:${node.startLine}` : '';
|
|
3559
3993
|
// Compact: just name, kind, location — plus the relationship when it
|
|
@@ -3586,7 +4020,7 @@ class ToolHandler {
|
|
|
3586
4020
|
const nodeCount = impact.nodes.size;
|
|
3587
4021
|
// Compact format: just list affected symbols grouped by file
|
|
3588
4022
|
const lines = [
|
|
3589
|
-
|
|
4023
|
+
`**Impact: "${symbol}" affects ${nodeCount} symbols**`,
|
|
3590
4024
|
'',
|
|
3591
4025
|
];
|
|
3592
4026
|
// Group by file
|
|
@@ -3629,7 +4063,7 @@ class ToolHandler {
|
|
|
3629
4063
|
formatNodeDetails(node, code, outline) {
|
|
3630
4064
|
const location = node.startLine ? `:${node.startLine}` : '';
|
|
3631
4065
|
const lines = [
|
|
3632
|
-
|
|
4066
|
+
`**${node.name}** (${node.kind})`,
|
|
3633
4067
|
'',
|
|
3634
4068
|
`**Location:** ${node.filePath}${location}`,
|
|
3635
4069
|
];
|