homegraph 0.9.10 → 1.1.1
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/LICENSE +21 -21
- package/README.md +243 -193
- package/dist/bin/fatal-handler.d.ts +20 -0
- package/dist/bin/fatal-handler.d.ts.map +1 -0
- package/dist/bin/fatal-handler.js +118 -0
- package/dist/bin/fatal-handler.js.map +1 -0
- package/dist/bin/homegraph.d.ts +6 -6
- package/dist/bin/homegraph.js +532 -109
- package/dist/bin/homegraph.js.map +1 -1
- package/dist/bin/node-version-check.d.ts +4 -4
- package/dist/bin/node-version-check.js +12 -12
- package/dist/bin/uninstall.d.ts +1 -1
- package/dist/bin/uninstall.js +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +8 -1
- package/dist/context/index.js.map +1 -1
- package/dist/db/index.d.ts +22 -1
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +48 -2
- package/dist/db/index.js.map +1 -1
- package/dist/db/migrations.d.ts +1 -1
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/migrations.js +26 -17
- package/dist/db/migrations.js.map +1 -1
- package/dist/db/queries.d.ts +21 -1
- package/dist/db/queries.d.ts.map +1 -1
- package/dist/db/queries.js +180 -137
- package/dist/db/queries.js.map +1 -1
- package/dist/db/schema.sql +152 -151
- package/dist/db/sqlite-adapter.d.ts +1 -1
- package/dist/db/sqlite-adapter.js +3 -3
- package/dist/directory.d.ts +87 -6
- package/dist/directory.d.ts.map +1 -1
- package/dist/directory.js +319 -57
- package/dist/directory.js.map +1 -1
- package/dist/errors.d.ts +15 -15
- package/dist/errors.js +20 -20
- package/dist/extraction/astro-extractor.d.ts +79 -0
- package/dist/extraction/astro-extractor.d.ts.map +1 -0
- package/dist/extraction/astro-extractor.js +320 -0
- package/dist/extraction/astro-extractor.js.map +1 -0
- package/dist/extraction/context.d.ts +1 -1
- package/dist/extraction/context.js +1 -1
- package/dist/extraction/extraction-version.d.ts +2 -2
- package/dist/extraction/extraction-version.d.ts.map +1 -1
- package/dist/extraction/extraction-version.js +2 -2
- package/dist/extraction/extraction-version.js.map +1 -1
- package/dist/extraction/function-ref.d.ts +118 -0
- package/dist/extraction/function-ref.d.ts.map +1 -0
- package/dist/extraction/function-ref.js +727 -0
- package/dist/extraction/function-ref.js.map +1 -0
- package/dist/extraction/generated-detection.d.ts.map +1 -1
- package/dist/extraction/generated-detection.js +3 -0
- package/dist/extraction/generated-detection.js.map +1 -1
- package/dist/extraction/grammars.d.ts +15 -5
- package/dist/extraction/grammars.d.ts.map +1 -1
- package/dist/extraction/grammars.js +44 -10
- package/dist/extraction/grammars.js.map +1 -1
- package/dist/extraction/index.d.ts +37 -0
- package/dist/extraction/index.d.ts.map +1 -1
- package/dist/extraction/index.js +562 -70
- package/dist/extraction/index.js.map +1 -1
- package/dist/extraction/languages/arkts-viewtree.d.ts +22 -0
- package/dist/extraction/languages/arkts-viewtree.d.ts.map +1 -0
- package/dist/extraction/languages/arkts-viewtree.js +133 -0
- package/dist/extraction/languages/arkts-viewtree.js.map +1 -0
- package/dist/extraction/languages/arkts.d.ts.map +1 -1
- package/dist/extraction/languages/arkts.js +211 -20
- package/dist/extraction/languages/arkts.js.map +1 -1
- package/dist/extraction/languages/c-cpp.d.ts +8 -0
- package/dist/extraction/languages/c-cpp.d.ts.map +1 -1
- package/dist/extraction/languages/c-cpp.js +104 -2
- package/dist/extraction/languages/c-cpp.js.map +1 -1
- package/dist/extraction/languages/csharp.d.ts.map +1 -1
- package/dist/extraction/languages/csharp.js +58 -3
- package/dist/extraction/languages/csharp.js.map +1 -1
- package/dist/extraction/languages/dart.d.ts.map +1 -1
- package/dist/extraction/languages/dart.js +183 -1
- package/dist/extraction/languages/dart.js.map +1 -1
- package/dist/extraction/languages/go.d.ts.map +1 -1
- package/dist/extraction/languages/go.js +37 -0
- package/dist/extraction/languages/go.js.map +1 -1
- package/dist/extraction/languages/index.d.ts.map +1 -1
- package/dist/extraction/languages/index.js +2 -0
- package/dist/extraction/languages/index.js.map +1 -1
- package/dist/extraction/languages/java.d.ts.map +1 -1
- package/dist/extraction/languages/java.js +241 -0
- package/dist/extraction/languages/java.js.map +1 -1
- package/dist/extraction/languages/javascript.d.ts.map +1 -1
- package/dist/extraction/languages/javascript.js +16 -0
- package/dist/extraction/languages/javascript.js.map +1 -1
- package/dist/extraction/languages/kotlin.d.ts.map +1 -1
- package/dist/extraction/languages/kotlin.js +94 -0
- package/dist/extraction/languages/kotlin.js.map +1 -1
- package/dist/extraction/languages/objc.d.ts.map +1 -1
- package/dist/extraction/languages/objc.js +42 -0
- package/dist/extraction/languages/objc.js.map +1 -1
- package/dist/extraction/languages/pascal.d.ts.map +1 -1
- package/dist/extraction/languages/pascal.js +11 -0
- package/dist/extraction/languages/pascal.js.map +1 -1
- package/dist/extraction/languages/php.d.ts.map +1 -1
- package/dist/extraction/languages/php.js +77 -1
- package/dist/extraction/languages/php.js.map +1 -1
- package/dist/extraction/languages/r.d.ts +3 -0
- package/dist/extraction/languages/r.d.ts.map +1 -0
- package/dist/extraction/languages/r.js +314 -0
- package/dist/extraction/languages/r.js.map +1 -0
- package/dist/extraction/languages/rust.d.ts.map +1 -1
- package/dist/extraction/languages/rust.js +29 -0
- package/dist/extraction/languages/rust.js.map +1 -1
- package/dist/extraction/languages/scala.d.ts.map +1 -1
- package/dist/extraction/languages/scala.js +44 -9
- package/dist/extraction/languages/scala.js.map +1 -1
- package/dist/extraction/languages/swift.d.ts.map +1 -1
- package/dist/extraction/languages/swift.js +61 -0
- package/dist/extraction/languages/swift.js.map +1 -1
- package/dist/extraction/languages/typescript.d.ts +13 -0
- package/dist/extraction/languages/typescript.d.ts.map +1 -1
- package/dist/extraction/languages/typescript.js +38 -0
- package/dist/extraction/languages/typescript.js.map +1 -1
- package/dist/extraction/liquid-extractor.d.ts.map +1 -1
- package/dist/extraction/liquid-extractor.js +4 -1
- package/dist/extraction/liquid-extractor.js.map +1 -1
- package/dist/extraction/parse-worker.js +4 -1
- package/dist/extraction/parse-worker.js.map +1 -1
- package/dist/extraction/svelte-extractor.d.ts.map +1 -1
- package/dist/extraction/svelte-extractor.js +6 -3
- package/dist/extraction/svelte-extractor.js.map +1 -1
- package/dist/extraction/tree-sitter-helpers.d.ts.map +1 -1
- package/dist/extraction/tree-sitter-helpers.js +59 -10
- package/dist/extraction/tree-sitter-helpers.js.map +1 -1
- package/dist/extraction/tree-sitter-types.d.ts +29 -0
- package/dist/extraction/tree-sitter-types.d.ts.map +1 -1
- package/dist/extraction/tree-sitter.d.ts +192 -0
- package/dist/extraction/tree-sitter.d.ts.map +1 -1
- package/dist/extraction/tree-sitter.js +1553 -45
- package/dist/extraction/tree-sitter.js.map +1 -1
- package/dist/extraction/vue-extractor.d.ts.map +1 -1
- package/dist/extraction/vue-extractor.js +6 -3
- package/dist/extraction/vue-extractor.js.map +1 -1
- package/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
- package/dist/extraction/wasm/tree-sitter-r.wasm +0 -0
- package/dist/graph/traversal.d.ts.map +1 -1
- package/dist/graph/traversal.js +11 -2
- package/dist/graph/traversal.js.map +1 -1
- package/dist/index.d.ts +64 -23
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +137 -41
- package/dist/index.js.map +1 -1
- package/dist/installer/config-writer.d.ts +1 -1
- package/dist/installer/config-writer.js +1 -1
- package/dist/installer/index.d.ts +4 -4
- package/dist/installer/index.d.ts.map +1 -1
- package/dist/installer/index.js +100 -73
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/instructions-template.d.ts +34 -11
- package/dist/installer/instructions-template.d.ts.map +1 -1
- package/dist/installer/instructions-template.js +44 -12
- package/dist/installer/instructions-template.js.map +1 -1
- package/dist/installer/targets/antigravity.d.ts +6 -6
- package/dist/installer/targets/antigravity.js +14 -14
- package/dist/installer/targets/claude.d.ts +19 -13
- package/dist/installer/targets/claude.d.ts.map +1 -1
- package/dist/installer/targets/claude.js +97 -29
- package/dist/installer/targets/claude.js.map +1 -1
- package/dist/installer/targets/codebuddy.d.ts +17 -0
- package/dist/installer/targets/codebuddy.d.ts.map +1 -0
- package/dist/installer/targets/codebuddy.js +150 -0
- package/dist/installer/targets/codebuddy.js.map +1 -0
- package/dist/installer/targets/codex.js +5 -7
- package/dist/installer/targets/codex.js.map +1 -1
- package/dist/installer/targets/cursor.d.ts +1 -1
- package/dist/installer/targets/cursor.js +7 -7
- package/dist/installer/targets/deveco.d.ts +23 -0
- package/dist/installer/targets/deveco.d.ts.map +1 -0
- package/dist/installer/targets/deveco.js +217 -0
- package/dist/installer/targets/deveco.js.map +1 -0
- package/dist/installer/targets/gemini.js +5 -7
- package/dist/installer/targets/gemini.js.map +1 -1
- package/dist/installer/targets/hermes.js +15 -15
- package/dist/installer/targets/kiro.d.ts +1 -1
- package/dist/installer/targets/kiro.js +4 -4
- package/dist/installer/targets/opencode.d.ts +9 -1
- package/dist/installer/targets/opencode.d.ts.map +1 -1
- package/dist/installer/targets/opencode.js +92 -41
- package/dist/installer/targets/opencode.js.map +1 -1
- package/dist/installer/targets/registry.d.ts.map +1 -1
- package/dist/installer/targets/registry.js +4 -0
- package/dist/installer/targets/registry.js.map +1 -1
- package/dist/installer/targets/shared.d.ts +28 -4
- package/dist/installer/targets/shared.d.ts.map +1 -1
- package/dist/installer/targets/shared.js +32 -15
- package/dist/installer/targets/shared.js.map +1 -1
- package/dist/installer/targets/types.d.ts +9 -2
- package/dist/installer/targets/types.d.ts.map +1 -1
- package/dist/mcp/daemon-manager.d.ts +42 -0
- package/dist/mcp/daemon-manager.d.ts.map +1 -0
- package/dist/mcp/daemon-manager.js +129 -0
- package/dist/mcp/daemon-manager.js.map +1 -0
- package/dist/mcp/daemon-paths.d.ts +1 -1
- package/dist/mcp/daemon-paths.js +3 -3
- package/dist/mcp/daemon-registry.d.ts +47 -0
- package/dist/mcp/daemon-registry.d.ts.map +1 -0
- package/dist/mcp/daemon-registry.js +229 -0
- package/dist/mcp/daemon-registry.js.map +1 -0
- package/dist/mcp/daemon.d.ts.map +1 -1
- package/dist/mcp/daemon.js +27 -6
- package/dist/mcp/daemon.js.map +1 -1
- package/dist/mcp/dynamic-boundaries.d.ts +41 -0
- package/dist/mcp/dynamic-boundaries.d.ts.map +1 -0
- package/dist/mcp/dynamic-boundaries.js +359 -0
- package/dist/mcp/dynamic-boundaries.js.map +1 -0
- package/dist/mcp/engine.d.ts +4 -4
- package/dist/mcp/engine.d.ts.map +1 -1
- package/dist/mcp/engine.js +32 -24
- package/dist/mcp/engine.js.map +1 -1
- package/dist/mcp/index.d.ts +8 -7
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +39 -18
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/liveness-watchdog.d.ts +18 -0
- package/dist/mcp/liveness-watchdog.d.ts.map +1 -0
- package/dist/mcp/liveness-watchdog.js +207 -0
- package/dist/mcp/liveness-watchdog.js.map +1 -0
- package/dist/mcp/proxy.d.ts +6 -0
- package/dist/mcp/proxy.d.ts.map +1 -1
- package/dist/mcp/proxy.js +75 -18
- package/dist/mcp/proxy.js.map +1 -1
- package/dist/mcp/server-instructions.d.ts +22 -7
- package/dist/mcp/server-instructions.d.ts.map +1 -1
- package/dist/mcp/server-instructions.js +91 -59
- package/dist/mcp/server-instructions.js.map +1 -1
- package/dist/mcp/session.d.ts +3 -1
- package/dist/mcp/session.d.ts.map +1 -1
- package/dist/mcp/session.js +43 -7
- package/dist/mcp/session.js.map +1 -1
- package/dist/mcp/stdin-teardown.d.ts +27 -0
- package/dist/mcp/stdin-teardown.d.ts.map +1 -0
- package/dist/mcp/stdin-teardown.js +49 -0
- package/dist/mcp/stdin-teardown.js.map +1 -0
- package/dist/mcp/tools.d.ts +137 -16
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +1271 -219
- package/dist/mcp/tools.js.map +1 -1
- package/dist/mcp/transport.d.ts +1 -1
- package/dist/mcp/transport.d.ts.map +1 -1
- package/dist/mcp/transport.js +20 -4
- package/dist/mcp/transport.js.map +1 -1
- package/dist/mcp/version.d.ts +1 -1
- package/dist/mcp/version.js +2 -2
- package/dist/project-config.d.ts +36 -0
- package/dist/project-config.d.ts.map +1 -0
- package/dist/project-config.js +235 -0
- package/dist/project-config.js.map +1 -0
- package/dist/reasoning/config.d.ts +45 -0
- package/dist/reasoning/config.d.ts.map +1 -0
- package/dist/reasoning/config.js +171 -0
- package/dist/reasoning/config.js.map +1 -0
- package/dist/reasoning/credentials.d.ts +5 -0
- package/dist/reasoning/credentials.d.ts.map +1 -0
- package/dist/reasoning/credentials.js +83 -0
- package/dist/reasoning/credentials.js.map +1 -0
- package/dist/reasoning/login.d.ts +21 -0
- package/dist/reasoning/login.d.ts.map +1 -0
- package/dist/reasoning/login.js +85 -0
- package/dist/reasoning/login.js.map +1 -0
- package/dist/reasoning/reasoner.d.ts +43 -0
- package/dist/reasoning/reasoner.d.ts.map +1 -0
- package/dist/reasoning/reasoner.js +308 -0
- package/dist/reasoning/reasoner.js.map +1 -0
- package/dist/resolution/c-fnptr-synthesizer.d.ts +33 -0
- package/dist/resolution/c-fnptr-synthesizer.d.ts.map +1 -0
- package/dist/resolution/c-fnptr-synthesizer.js +352 -0
- package/dist/resolution/c-fnptr-synthesizer.js.map +1 -0
- package/dist/resolution/callback-synthesizer.d.ts +6 -1
- package/dist/resolution/callback-synthesizer.d.ts.map +1 -1
- package/dist/resolution/callback-synthesizer.js +1197 -1
- package/dist/resolution/callback-synthesizer.js.map +1 -1
- package/dist/resolution/frameworks/arkts-entry.d.ts +12 -0
- package/dist/resolution/frameworks/arkts-entry.d.ts.map +1 -0
- package/dist/resolution/frameworks/arkts-entry.js +214 -0
- package/dist/resolution/frameworks/arkts-entry.js.map +1 -0
- package/dist/resolution/frameworks/arkts-napi.d.ts +3 -0
- package/dist/resolution/frameworks/arkts-napi.d.ts.map +1 -0
- package/dist/resolution/frameworks/arkts-napi.js +166 -0
- package/dist/resolution/frameworks/arkts-napi.js.map +1 -0
- package/dist/resolution/frameworks/astro.d.ts +9 -0
- package/dist/resolution/frameworks/astro.d.ts.map +1 -0
- package/dist/resolution/frameworks/astro.js +169 -0
- package/dist/resolution/frameworks/astro.js.map +1 -0
- package/dist/resolution/frameworks/goframe.d.ts +41 -0
- package/dist/resolution/frameworks/goframe.d.ts.map +1 -0
- package/dist/resolution/frameworks/goframe.js +112 -0
- package/dist/resolution/frameworks/goframe.js.map +1 -0
- package/dist/resolution/frameworks/index.d.ts +3 -0
- package/dist/resolution/frameworks/index.d.ts.map +1 -1
- package/dist/resolution/frameworks/index.js +17 -1
- package/dist/resolution/frameworks/index.js.map +1 -1
- package/dist/resolution/frameworks/java.js +6 -1
- package/dist/resolution/frameworks/java.js.map +1 -1
- package/dist/resolution/frameworks/react.d.ts.map +1 -1
- package/dist/resolution/frameworks/react.js +32 -63
- package/dist/resolution/frameworks/react.js.map +1 -1
- package/dist/resolution/frameworks/svelte.js +6 -2
- package/dist/resolution/frameworks/svelte.js.map +1 -1
- package/dist/resolution/frameworks/vue.js +24 -27
- package/dist/resolution/frameworks/vue.js.map +1 -1
- package/dist/resolution/goframe-synthesizer.d.ts +28 -0
- package/dist/resolution/goframe-synthesizer.d.ts.map +1 -0
- package/dist/resolution/goframe-synthesizer.js +158 -0
- package/dist/resolution/goframe-synthesizer.js.map +1 -0
- package/dist/resolution/import-resolver.d.ts +10 -0
- package/dist/resolution/import-resolver.d.ts.map +1 -1
- package/dist/resolution/import-resolver.js +125 -2
- package/dist/resolution/import-resolver.js.map +1 -1
- package/dist/resolution/index.d.ts +37 -0
- package/dist/resolution/index.d.ts.map +1 -1
- package/dist/resolution/index.js +306 -3
- package/dist/resolution/index.js.map +1 -1
- package/dist/resolution/name-matcher.d.ts +41 -0
- package/dist/resolution/name-matcher.d.ts.map +1 -1
- package/dist/resolution/name-matcher.js +519 -14
- package/dist/resolution/name-matcher.js.map +1 -1
- package/dist/resolution/strip-comments.d.ts +1 -1
- package/dist/resolution/strip-comments.d.ts.map +1 -1
- package/dist/resolution/strip-comments.js +2 -0
- package/dist/resolution/strip-comments.js.map +1 -1
- package/dist/resolution/types.d.ts +20 -3
- package/dist/resolution/types.d.ts.map +1 -1
- package/dist/search/query-utils.d.ts +17 -1
- package/dist/search/query-utils.d.ts.map +1 -1
- package/dist/search/query-utils.js +79 -10
- package/dist/search/query-utils.js.map +1 -1
- package/dist/sync/git-hooks.d.ts +4 -4
- package/dist/sync/git-hooks.js +5 -5
- package/dist/sync/watcher.d.ts +69 -2
- package/dist/sync/watcher.d.ts.map +1 -1
- package/dist/sync/watcher.js +226 -21
- package/dist/sync/watcher.js.map +1 -1
- package/dist/sync/worktree.d.ts +3 -3
- package/dist/sync/worktree.js +5 -5
- package/dist/telemetry/index.d.ts +143 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/index.js +541 -0
- package/dist/telemetry/index.js.map +1 -0
- package/dist/types.d.ts +18 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -1
- package/dist/types.js.map +1 -1
- package/dist/upgrade/index.d.ts.map +1 -1
- package/dist/upgrade/index.js +46 -10
- package/dist/upgrade/index.js.map +1 -1
- package/dist/utils.d.ts +46 -27
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +87 -53
- package/dist/utils.js.map +1 -1
- package/package.json +57 -57
- package/scripts/add-lang/bench.sh +60 -60
- package/scripts/add-lang/check-grammar.mjs +75 -75
- package/scripts/add-lang/dump-ast.mjs +103 -103
- package/scripts/add-lang/verify-extraction.mjs +70 -70
- package/scripts/agent-eval/ab-adoption.sh +91 -0
- package/scripts/agent-eval/ab-hook.sh +86 -0
- package/scripts/agent-eval/ab-impl.sh +78 -0
- package/scripts/agent-eval/ab-new-vs-baseline.sh +102 -0
- package/scripts/agent-eval/ab-sufficiency.sh +78 -0
- package/scripts/agent-eval/arms-F.sh +21 -21
- package/scripts/agent-eval/arms-matrix.sh +37 -37
- package/scripts/agent-eval/audit.sh +68 -68
- package/scripts/agent-eval/bench-readme.sh +28 -28
- package/scripts/agent-eval/bench-why-repo.sh +22 -22
- package/scripts/agent-eval/block-read-hook.sh +19 -19
- package/scripts/agent-eval/hook-settings.json +15 -15
- package/scripts/agent-eval/itrun.sh +120 -120
- package/scripts/agent-eval/offload-eval-3arm.sh +72 -0
- package/scripts/agent-eval/offload-eval-cost.mjs +133 -0
- package/scripts/agent-eval/offload-eval-effort.mjs +108 -0
- package/scripts/agent-eval/offload-eval-frontload-matrix.sh +25 -0
- package/scripts/agent-eval/offload-eval-frontload.sh +47 -0
- package/scripts/agent-eval/offload-eval-ground-truth.json +18 -0
- package/scripts/agent-eval/offload-eval-hook.mjs +84 -0
- package/scripts/agent-eval/offload-eval-judge.mjs +103 -0
- package/scripts/agent-eval/offload-eval-matrix.sh +20 -0
- package/scripts/agent-eval/offload-eval-metrics.mjs +94 -0
- package/scripts/agent-eval/offload-eval-refs1.sh +50 -0
- package/scripts/agent-eval/offload-eval-setup.sh +24 -0
- package/scripts/agent-eval/offload-eval-styles.sh +72 -0
- package/scripts/agent-eval/offload-eval-summarize.mjs +68 -0
- package/scripts/agent-eval/offload-eval.md +76 -0
- package/scripts/agent-eval/parse-arms.mjs +116 -116
- package/scripts/agent-eval/parse-bench-readme.mjs +84 -84
- package/scripts/agent-eval/parse-run.mjs +45 -45
- package/scripts/agent-eval/parse-session.mjs +93 -93
- package/scripts/agent-eval/probe-context.mjs +21 -21
- package/scripts/agent-eval/probe-explore.mjs +40 -40
- package/scripts/agent-eval/probe-node.mjs +20 -20
- package/scripts/agent-eval/probe-sweep.mjs +119 -119
- package/scripts/agent-eval/probe-trace.mjs +20 -20
- package/scripts/agent-eval/redirect-read-hook.sh +38 -0
- package/scripts/agent-eval/run-agent.sh +34 -34
- package/scripts/agent-eval/run-all.sh +69 -67
- package/scripts/agent-eval/run-arms.sh +56 -56
- package/scripts/agent-eval/seq-matrix.mjs +137 -137
- package/scripts/build-bundle.sh +118 -118
- package/scripts/extract-release-notes.mjs +130 -130
- package/scripts/local-install.sh +41 -41
- package/scripts/npm-sdk.js +75 -75
- package/scripts/npm-shim.js +246 -246
- package/scripts/pack-npm.sh +119 -119
- package/scripts/prepare-release.mjs +270 -270
- package/scripts/qa_eval/README.md +400 -0
- package/scripts/qa_eval/agent_runner.py +526 -0
- package/scripts/qa_eval/data/.gitignore +4 -0
- package/scripts/qa_eval/data/test-set.jsonl +22 -0
- package/scripts/qa_eval/eval_metrics.py +233 -0
- package/scripts/qa_eval/external_agent.py +577 -0
- package/scripts/qa_eval/llm_config.py +92 -0
- package/scripts/qa_eval/memory_monitor.py +132 -0
- package/scripts/qa_eval/my_answer_accuracy.py +187 -0
- package/scripts/qa_eval/requirements.txt +2 -0
- package/scripts/qa_eval/run_pipeline.py +702 -0
- package/scripts/qa_eval/stats_efficiency.py +279 -0
- package/scripts/qa_eval/stats_scores.py +207 -0
- package/dist/bin/codegraph.d.ts +0 -26
- package/dist/bin/codegraph.d.ts.map +0 -1
- package/dist/bin/codegraph.js +0 -1572
- package/dist/bin/codegraph.js.map +0 -1
- package/dist/extraction/arkanalyzer-extractor.d.ts +0 -6
- package/dist/extraction/arkanalyzer-extractor.d.ts.map +0 -1
- package/dist/extraction/arkanalyzer-extractor.js +0 -11
- package/dist/extraction/arkanalyzer-extractor.js.map +0 -1
- package/dist/extraction/arkanalyzer-project.d.ts +0 -20
- package/dist/extraction/arkanalyzer-project.d.ts.map +0 -1
- package/dist/extraction/arkanalyzer-project.js +0 -596
- package/dist/extraction/arkanalyzer-project.js.map +0 -1
package/dist/mcp/tools.js
CHANGED
|
@@ -2,27 +2,50 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* MCP Tool Definitions
|
|
4
4
|
*
|
|
5
|
-
* Defines the tools exposed by the
|
|
5
|
+
* Defines the tools exposed by the HomeGraph MCP server.
|
|
6
6
|
*/
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
-
exports.ToolHandler = exports.tools = void 0;
|
|
8
|
+
exports.ToolHandler = exports.tools = exports.PathRefusalError = exports.NotIndexedError = void 0;
|
|
9
9
|
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
|
-
// Lazy-load the heavy
|
|
16
|
+
// Lazy-load the heavy HomeGraph chain off the MCP startup path — see the same
|
|
16
17
|
// helper in engine.ts. ToolHandler must load to answer tools/list (static
|
|
17
18
|
// schemas), but it must NOT drag in sqlite/query layers before the daemon binds;
|
|
18
|
-
//
|
|
19
|
+
// HomeGraph is pulled in only when a tool actually opens a project. require() is
|
|
19
20
|
// sync + cached (CommonJS build).
|
|
20
|
-
const
|
|
21
|
+
const loadHomeGraph = () => require('../index').default;
|
|
21
22
|
const worktree_1 = require("../sync/worktree");
|
|
22
23
|
const query_utils_1 = require("../search/query-utils");
|
|
23
24
|
const fs_1 = require("fs");
|
|
24
25
|
const utils_1 = require("../utils");
|
|
25
26
|
const generated_detection_1 = require("../extraction/generated-detection");
|
|
27
|
+
const dynamic_boundaries_1 = require("./dynamic-boundaries");
|
|
28
|
+
/**
|
|
29
|
+
* An expected, recoverable "homegraph can't serve this" condition — most
|
|
30
|
+
* importantly a project with no index. The dispatch catch converts these to
|
|
31
|
+
* SUCCESS-shaped responses (guidance text, NO isError): an `isError: true`
|
|
32
|
+
* early in a session teaches the agent the toolset is broken and it stops
|
|
33
|
+
* calling homegraph entirely (observed repeatedly), which is exactly wrong
|
|
34
|
+
* for conditions the agent can simply work around (use built-in tools for
|
|
35
|
+
* that codebase / pass projectPath). isError is reserved for "stop trying"
|
|
36
|
+
* cases: security refusals ({@link PathRefusalError}) and genuine
|
|
37
|
+
* malfunctions.
|
|
38
|
+
*/
|
|
39
|
+
class NotIndexedError extends Error {
|
|
40
|
+
}
|
|
41
|
+
exports.NotIndexedError = NotIndexedError;
|
|
42
|
+
/**
|
|
43
|
+
* A security refusal (sensitive system path). Stays `isError: true` WITHOUT
|
|
44
|
+
* retry guidance — abandoning this path is the desired agent reaction.
|
|
45
|
+
*/
|
|
46
|
+
class PathRefusalError extends Error {
|
|
47
|
+
}
|
|
48
|
+
exports.PathRefusalError = PathRefusalError;
|
|
26
49
|
const path_1 = require("path");
|
|
27
50
|
/** Maximum output length to prevent context bloat (characters) */
|
|
28
51
|
const MAX_OUTPUT_LENGTH = 15000;
|
|
@@ -206,6 +229,28 @@ function exploreLineNumbersEnabled() {
|
|
|
206
229
|
function adaptiveExploreEnabled() {
|
|
207
230
|
return process.env.HOMEGRAPH_ADAPTIVE_EXPLORE !== '0' && process.env.HOMEGRAPH_ADAPTIVE_EXPLORE !== 'false';
|
|
208
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
|
+
* `HOMEGRAPH_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.HOMEGRAPH_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
|
+
}
|
|
209
254
|
/**
|
|
210
255
|
* Prefix each line of a source slice with its 1-based line number, matching
|
|
211
256
|
* the Read tool's `cat -n` convention (number + tab) so the agent treats it
|
|
@@ -222,6 +267,22 @@ function numberSourceLines(slice, firstLineNumber) {
|
|
|
222
267
|
}
|
|
223
268
|
return out.join('\n');
|
|
224
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Unique line-prefix for a per-file source section in homegraph_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
|
+
}
|
|
225
286
|
/**
|
|
226
287
|
* Per-file staleness banner emitted at the top of a tool response when the
|
|
227
288
|
* file watcher has pending events for files referenced by the response.
|
|
@@ -236,7 +297,7 @@ function formatStaleBanner(stale) {
|
|
|
236
297
|
return ` - ${p.path} (edited ${ageMs}ms ago, ${label})`;
|
|
237
298
|
});
|
|
238
299
|
return ('⚠️ Some files referenced below were edited since the last index sync — ' +
|
|
239
|
-
'their
|
|
300
|
+
'their homegraph entries may be stale:\n' +
|
|
240
301
|
lines.join('\n') +
|
|
241
302
|
'\nFor accurate content of those specific files, Read them directly. ' +
|
|
242
303
|
'The rest of this response is fresh.');
|
|
@@ -258,15 +319,29 @@ function formatStaleFooter(stale) {
|
|
|
258
319
|
return (`(Note: ${stale.length} file(s) elsewhere in this project are pending index ` +
|
|
259
320
|
`sync but were not referenced above:\n${lines.join('\n')}${more})`);
|
|
260
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 (`homegraph sync` / git hooks).
|
|
329
|
+
*/
|
|
330
|
+
function formatDegradedBanner(reason) {
|
|
331
|
+
return ('⚠️ HomeGraph 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
|
+
}
|
|
261
336
|
/**
|
|
262
337
|
* Common projectPath property for cross-project queries
|
|
263
338
|
*/
|
|
264
339
|
const projectPathProperty = {
|
|
265
340
|
type: 'string',
|
|
266
|
-
description: '
|
|
341
|
+
description: 'Absolute path to the project to query (or any directory inside it) — homegraph uses the nearest .homegraph/ 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).',
|
|
267
342
|
};
|
|
268
343
|
/**
|
|
269
|
-
* All
|
|
344
|
+
* All HomeGraph MCP tools
|
|
270
345
|
*
|
|
271
346
|
* Designed for minimal context usage - use homegraph_explore as the primary tool
|
|
272
347
|
* (one call usually answers the whole question), and only use other tools for
|
|
@@ -310,6 +385,10 @@ exports.tools = [
|
|
|
310
385
|
type: 'string',
|
|
311
386
|
description: 'Name of the function, method, or class to find callers for',
|
|
312
387
|
},
|
|
388
|
+
file: {
|
|
389
|
+
type: 'string',
|
|
390
|
+
description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist (e.g. one UserService per app in a monorepo)',
|
|
391
|
+
},
|
|
313
392
|
limit: {
|
|
314
393
|
type: 'number',
|
|
315
394
|
description: 'Maximum number of callers to return (default: 20)',
|
|
@@ -330,6 +409,10 @@ exports.tools = [
|
|
|
330
409
|
type: 'string',
|
|
331
410
|
description: 'Name of the function, method, or class to find callees for',
|
|
332
411
|
},
|
|
412
|
+
file: {
|
|
413
|
+
type: 'string',
|
|
414
|
+
description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist',
|
|
415
|
+
},
|
|
333
416
|
limit: {
|
|
334
417
|
type: 'number',
|
|
335
418
|
description: 'Maximum number of callees to return (default: 20)',
|
|
@@ -350,6 +433,10 @@ exports.tools = [
|
|
|
350
433
|
type: 'string',
|
|
351
434
|
description: 'Name of the symbol to analyze impact for',
|
|
352
435
|
},
|
|
436
|
+
file: {
|
|
437
|
+
type: 'string',
|
|
438
|
+
description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist',
|
|
439
|
+
},
|
|
353
440
|
depth: {
|
|
354
441
|
type: 'number',
|
|
355
442
|
description: 'How many levels of dependencies to traverse (default: 2)',
|
|
@@ -362,41 +449,54 @@ exports.tools = [
|
|
|
362
449
|
},
|
|
363
450
|
{
|
|
364
451
|
name: 'homegraph_node',
|
|
365
|
-
description: '
|
|
452
|
+
description: 'Two modes. (1) READ A FILE — use INSTEAD of the Read tool: pass `file` (a path or basename) with no `symbol` and it returns that file\'s current on-disk source with line numbers, exactly the shape Read gives you (`<n>\\t<line>`, safe to Edit from), narrowable with `offset`/`limit` just like Read — PLUS a one-line note of which files depend on it. Same bytes as Read, faster (served from the index), with the blast radius attached. Use it whenever you would Read a source file. (2) ONE SYMBOL you can name — its location, signature, verbatim source (includeCode=true) and caller/callee trail in one call, so before changing it you see what calls it and what your edit would break. For an AMBIGUOUS name it returns EVERY matching definition\'s body in one call (so you never Read a file to find the right overload); pass `file`/`line` to pin one. Use homegraph_explore for several related symbols or the full flow.',
|
|
366
453
|
inputSchema: {
|
|
367
454
|
type: 'object',
|
|
368
455
|
properties: {
|
|
369
456
|
symbol: {
|
|
370
457
|
type: 'string',
|
|
371
|
-
description: 'Name of the symbol to
|
|
458
|
+
description: 'Name of the symbol to read (symbol mode). Omit it and pass `file` alone to read a whole file like Read.',
|
|
372
459
|
},
|
|
373
460
|
includeCode: {
|
|
374
461
|
type: 'boolean',
|
|
375
|
-
description: '
|
|
462
|
+
description: 'Symbol mode: include the symbol\'s full body (default: false). Ignored in file mode, which always returns source unless `symbolsOnly` is set.',
|
|
376
463
|
default: false,
|
|
377
464
|
},
|
|
378
465
|
file: {
|
|
379
466
|
type: 'string',
|
|
380
|
-
description: '
|
|
467
|
+
description: 'A file path or basename (e.g. "harness.rs", "src/auth/session.ts"). Pass it ALONE (no symbol) to READ the file like the Read tool — its full source with line numbers + which files depend on it. Or pass it WITH a symbol to disambiguate an overloaded name to the definition in this file.',
|
|
468
|
+
},
|
|
469
|
+
offset: {
|
|
470
|
+
type: 'number',
|
|
471
|
+
description: 'File mode: 1-based line to start reading from, exactly like Read\'s offset. Defaults to the start of the file.',
|
|
472
|
+
},
|
|
473
|
+
limit: {
|
|
474
|
+
type: 'number',
|
|
475
|
+
description: 'File mode: maximum number of lines to return, exactly like Read\'s limit. Defaults to the whole file (capped at 2000 lines, like Read).',
|
|
476
|
+
},
|
|
477
|
+
symbolsOnly: {
|
|
478
|
+
type: 'boolean',
|
|
479
|
+
description: 'File mode: return just the file\'s symbol map + dependents (a cheap structural overview) instead of its source.',
|
|
480
|
+
default: false,
|
|
381
481
|
},
|
|
382
482
|
line: {
|
|
383
483
|
type: 'number',
|
|
384
|
-
description: '
|
|
484
|
+
description: 'Symbol mode only: disambiguate to the definition at/around this line (use with the file:line a trail showed you).',
|
|
385
485
|
},
|
|
386
486
|
projectPath: projectPathProperty,
|
|
387
487
|
},
|
|
388
|
-
required: [
|
|
488
|
+
required: [],
|
|
389
489
|
},
|
|
390
490
|
},
|
|
391
491
|
{
|
|
392
492
|
name: 'homegraph_explore',
|
|
393
|
-
description: 'PRIMARY TOOL — call FIRST for almost any question: how does X work, architecture, a bug, where/what is X,
|
|
493
|
+
description: 'PRIMARY TOOL — call FIRST for almost any question OR before an edit: how does X work, architecture, a bug, where/what is X, surveying an area, or the symbols you are about to change. Returns the verbatim source of the relevant symbols grouped by file in ONE capped call (Read-equivalent — treat the shown source as already Read; do NOT re-open those files), plus the call path among them. Query can be a natural-language question OR a bag of symbol/file names. Usually the ONLY call you need — more accurate context, in far fewer tokens and round-trips than a search/Read/Grep loop.',
|
|
394
494
|
inputSchema: {
|
|
395
495
|
type: 'object',
|
|
396
496
|
properties: {
|
|
397
497
|
query: {
|
|
398
498
|
type: 'string',
|
|
399
|
-
description: 'Symbol names, file names, or short code terms to explore (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts").
|
|
499
|
+
description: 'Symbol names, file names, or short code terms to explore (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts"). For a flow question, name the symbols spanning the flow (e.g. "mutateElement renderScene"). A natural-language question works too — no prior homegraph_search needed.',
|
|
400
500
|
},
|
|
401
501
|
maxFiles: {
|
|
402
502
|
type: 'number',
|
|
@@ -455,25 +555,38 @@ exports.tools = [
|
|
|
455
555
|
/**
|
|
456
556
|
* Allowlist-filtered tool definitions WITHOUT an engine — the static surface the
|
|
457
557
|
* proxy answers `tools/list` with before any project is open. Mirrors
|
|
458
|
-
* `ToolHandler.getTools()` in the no-
|
|
558
|
+
* `ToolHandler.getTools()` in the no-HomeGraph case (the dynamic per-repo budget
|
|
459
559
|
* note in a description only adds once `cg` is loaded; the schemas are static).
|
|
460
560
|
*/
|
|
461
561
|
function getStaticTools() {
|
|
462
|
-
const raw = process.env.HOMEGRAPH_MCP_TOOLS;
|
|
463
|
-
if (!raw || !raw.trim())
|
|
464
|
-
return exports.tools;
|
|
465
|
-
|
|
562
|
+
const raw = process.env.HOMEGRAPH_MCP_TOOLS ?? process.env.HOMEGRAPH_MCP_TOOLS;
|
|
563
|
+
if (!raw || !raw.trim()) {
|
|
564
|
+
return exports.tools.filter(t => DEFAULT_MCP_TOOLS.has(t.name.replace(/^homegraph_/, '')));
|
|
565
|
+
}
|
|
566
|
+
const allow = new Set(raw.split(',').map(s => s.trim().replace(/^homegraph_/, '').replace(/^homegraph_/, '')).filter(Boolean));
|
|
466
567
|
return allow.size ? exports.tools.filter(t => allow.has(t.name.replace(/^homegraph_/, ''))) : exports.tools;
|
|
467
568
|
}
|
|
468
569
|
/**
|
|
469
|
-
*
|
|
570
|
+
* The MCP tools served by DEFAULT (short names). Pared to ONLY `homegraph_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.
|
|
575
|
+
*
|
|
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 `HOMEGRAPH_MCP_TOOLS=explore,node,...` re-enables any of them.
|
|
579
|
+
*/
|
|
580
|
+
const DEFAULT_MCP_TOOLS = new Set(['explore']);
|
|
581
|
+
/**
|
|
582
|
+
* Tool handler that executes tools against a HomeGraph instance
|
|
470
583
|
*
|
|
471
584
|
* Supports cross-project queries via the projectPath parameter.
|
|
472
585
|
* Other projects are opened on-demand and cached for performance.
|
|
473
586
|
*/
|
|
474
587
|
class ToolHandler {
|
|
475
588
|
cg;
|
|
476
|
-
// Cache of opened
|
|
589
|
+
// Cache of opened HomeGraph instances for cross-project queries
|
|
477
590
|
projectCache = new Map();
|
|
478
591
|
// The directory the server last searched for a default project. Surfaced in
|
|
479
592
|
// the "not initialized" error so users can see why detection missed.
|
|
@@ -489,16 +602,18 @@ class ToolHandler {
|
|
|
489
602
|
// this, a tool call that races past `catchUpSync()` serves rows for files
|
|
490
603
|
// that were deleted (or edited) while no MCP server was running — and the
|
|
491
604
|
// per-file staleness banner can't help, because `getPendingFiles()` is
|
|
492
|
-
// 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
|
|
493
608
|
// subsequent calls don't pay any cost.
|
|
494
609
|
catchUpGate = null;
|
|
495
610
|
constructor(cg) {
|
|
496
611
|
this.cg = cg;
|
|
497
612
|
}
|
|
498
613
|
/**
|
|
499
|
-
* Update the default
|
|
614
|
+
* Update the default HomeGraph instance (e.g. after lazy initialization)
|
|
500
615
|
*/
|
|
501
|
-
|
|
616
|
+
setDefaultHomeGraph(cg) {
|
|
502
617
|
this.cg = cg;
|
|
503
618
|
}
|
|
504
619
|
/**
|
|
@@ -511,6 +626,45 @@ class ToolHandler {
|
|
|
511
626
|
setCatchUpGate(p) {
|
|
512
627
|
this.catchUpGate = p;
|
|
513
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(`[HomeGraph MCP] Catch-up reconcile still running after ${timeoutMs}ms; serving this tool call now and finishing the reconcile in the background (#905). ` +
|
|
660
|
+
`Set HOMEGRAPH_CATCHUP_GATE_TIMEOUT_MS=0 to always wait for it.\n`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
finally {
|
|
664
|
+
if (timer)
|
|
665
|
+
clearTimeout(timer);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
514
668
|
/**
|
|
515
669
|
* Record the directory the server tried to resolve the default project from.
|
|
516
670
|
* Used only to make the "no default project" error actionable.
|
|
@@ -519,9 +673,9 @@ class ToolHandler {
|
|
|
519
673
|
this.defaultProjectHint = searchedPath;
|
|
520
674
|
}
|
|
521
675
|
/**
|
|
522
|
-
* Whether a default
|
|
676
|
+
* Whether a default HomeGraph instance is available
|
|
523
677
|
*/
|
|
524
|
-
|
|
678
|
+
hasDefaultHomeGraph() {
|
|
525
679
|
return this.cg !== null;
|
|
526
680
|
}
|
|
527
681
|
/**
|
|
@@ -533,7 +687,7 @@ class ToolHandler {
|
|
|
533
687
|
* Matching is on the short form, so "node" and "homegraph_node" both work.
|
|
534
688
|
*/
|
|
535
689
|
toolAllowlist() {
|
|
536
|
-
const raw = process.env.HOMEGRAPH_MCP_TOOLS;
|
|
690
|
+
const raw = process.env.HOMEGRAPH_MCP_TOOLS ?? process.env.HOMEGRAPH_MCP_TOOLS;
|
|
537
691
|
if (!raw || !raw.trim())
|
|
538
692
|
return null;
|
|
539
693
|
const short = (s) => s.trim().replace(/^homegraph_/, '');
|
|
@@ -553,18 +707,22 @@ class ToolHandler {
|
|
|
553
707
|
*/
|
|
554
708
|
getTools() {
|
|
555
709
|
const allow = this.toolAllowlist();
|
|
710
|
+
// No explicit allowlist → the default 4-tool surface (see
|
|
711
|
+
// DEFAULT_MCP_TOOLS for the evidence). An allowlist replaces the
|
|
712
|
+
// default entirely, so any defined tool can be re-enabled.
|
|
556
713
|
let visible = allow
|
|
557
714
|
? exports.tools.filter(t => allow.has(t.name.replace(/^homegraph_/, '')))
|
|
558
|
-
: exports.tools;
|
|
715
|
+
: exports.tools.filter(t => DEFAULT_MCP_TOOLS.has(t.name.replace(/^homegraph_/, '')));
|
|
559
716
|
if (!this.cg)
|
|
560
717
|
return visible;
|
|
561
718
|
try {
|
|
562
719
|
const stats = this.cg.getStats();
|
|
563
720
|
const budget = getExploreBudget(stats.fileCount);
|
|
564
721
|
// Tiny-repo tool gating: on projects under TINY_REPO_FILE_THRESHOLD
|
|
565
|
-
// files, only expose the
|
|
566
|
-
//
|
|
567
|
-
//
|
|
722
|
+
// files, only expose the core trio (search, node, explore) — one
|
|
723
|
+
// below even the 4-tool default: at this scale callers, too, reduces
|
|
724
|
+
// to one grep. (Historical note: the audit below ran when context and
|
|
725
|
+
// trace still existed; its "5 core tools" are today's trio.)
|
|
568
726
|
//
|
|
569
727
|
// n=2 audits ruled out cutting below 5 tools:
|
|
570
728
|
// - 3-tool gate (search + context + trace): cost regressed on
|
|
@@ -607,31 +765,32 @@ class ToolHandler {
|
|
|
607
765
|
}
|
|
608
766
|
}
|
|
609
767
|
/**
|
|
610
|
-
* Get
|
|
768
|
+
* Get HomeGraph instance for a project
|
|
611
769
|
*
|
|
612
|
-
* If projectPath is provided, opens that project's
|
|
613
|
-
* Otherwise returns the default
|
|
770
|
+
* If projectPath is provided, opens that project's HomeGraph (cached).
|
|
771
|
+
* Otherwise returns the default HomeGraph instance.
|
|
614
772
|
*
|
|
615
773
|
* Walks up parent directories to find the nearest .homegraph/ folder,
|
|
616
774
|
* similar to how git finds .git/ directories.
|
|
617
775
|
*/
|
|
618
|
-
|
|
776
|
+
getHomeGraph(projectPath) {
|
|
619
777
|
if (!projectPath) {
|
|
620
778
|
if (!this.cg) {
|
|
621
779
|
const searched = this.defaultProjectHint ?? process.cwd();
|
|
622
|
-
throw new
|
|
780
|
+
throw new NotIndexedError('No HomeGraph project is loaded for this session.\n' +
|
|
623
781
|
`Searched for a .homegraph/ directory starting from: ${searched}\n` +
|
|
624
|
-
'
|
|
625
|
-
"the MCP client launched the server outside your
|
|
626
|
-
'workspace root.
|
|
627
|
-
'
|
|
628
|
-
' •
|
|
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 .homegraph/ — including a sub-project of a monorepo)\n' +
|
|
788
|
+
' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]\n' +
|
|
789
|
+
'If a project simply has no index, use your built-in tools (Read/Grep/Glob) for THAT ' +
|
|
790
|
+
"project (the user can run 'homegraph init' there to enable it) — you can still query " +
|
|
791
|
+
'other indexed projects by projectPath in the same session.');
|
|
629
792
|
}
|
|
630
|
-
return this.cg;
|
|
631
|
-
}
|
|
632
|
-
// Check cache first (using original path as key)
|
|
633
|
-
if (this.projectCache.has(projectPath)) {
|
|
634
|
-
return this.projectCache.get(projectPath);
|
|
793
|
+
return this.freshen(this.cg);
|
|
635
794
|
}
|
|
636
795
|
// Reject sensitive system directories before opening. Only validate a
|
|
637
796
|
// path that actually exists — a nested or not-yet-created sub-path of a
|
|
@@ -641,36 +800,66 @@ class ToolHandler {
|
|
|
641
800
|
if ((0, fs_1.existsSync)(projectPath)) {
|
|
642
801
|
const pathError = (0, utils_1.validateProjectPath)(projectPath);
|
|
643
802
|
if (pathError) {
|
|
644
|
-
throw new
|
|
803
|
+
throw new PathRefusalError(pathError);
|
|
645
804
|
}
|
|
646
805
|
}
|
|
647
|
-
//
|
|
648
|
-
|
|
806
|
+
// Always RE-RESOLVE the nearest .homegraph/ 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 .homegraph/ 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.
|
|
816
|
+
const resolvedRoot = (0, directory_1.findNearestHomeGraphRoot)(projectPath);
|
|
649
817
|
if (!resolvedRoot) {
|
|
650
|
-
throw new
|
|
818
|
+
throw new NotIndexedError(`The project at ${projectPath} isn't indexed with homegraph (no .homegraph/ directory found ` +
|
|
819
|
+
'walking up from it), so homegraph cannot query it. Use your built-in tools (Read/Grep/Glob) ' +
|
|
820
|
+
"for that codebase instead, and don't call homegraph for it again this session. " +
|
|
821
|
+
"Indexing is the user's decision — they can run 'homegraph init' in that project to enable it.");
|
|
651
822
|
}
|
|
652
823
|
// If the path resolves to the default project, reuse the already-open
|
|
653
824
|
// default instance rather than opening a SECOND connection to the same DB.
|
|
654
825
|
// A duplicate connection serializes reads against the watcher's auto-sync
|
|
655
|
-
// writes;
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
// 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.
|
|
659
830
|
if (this.cg && this.cg.getProjectRoot() === resolvedRoot) {
|
|
660
|
-
return this.cg;
|
|
661
|
-
}
|
|
662
|
-
// Check if we already have this resolved root cached (different path, same project)
|
|
663
|
-
if (this.projectCache.has(resolvedRoot)) {
|
|
664
|
-
const cg = this.projectCache.get(resolvedRoot);
|
|
665
|
-
// Cache under original path too for faster future lookups
|
|
666
|
-
this.projectCache.set(projectPath, cg);
|
|
667
|
-
return cg;
|
|
831
|
+
return this.freshen(this.cg);
|
|
668
832
|
}
|
|
669
|
-
//
|
|
670
|
-
|
|
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);
|
|
839
|
+
const cg = loadHomeGraph().openSync(resolvedRoot);
|
|
671
840
|
this.projectCache.set(resolvedRoot, cg);
|
|
672
|
-
|
|
673
|
-
|
|
841
|
+
return cg;
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Heal a long-lived connection whose `.homegraph/` was removed and recreated
|
|
845
|
+
* at the same path (a worktree recreated, or `rm -rf .homegraph` + 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('[HomeGraph 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.
|
|
674
863
|
}
|
|
675
864
|
return cg;
|
|
676
865
|
}
|
|
@@ -728,18 +917,29 @@ class ToolHandler {
|
|
|
728
917
|
*/
|
|
729
918
|
worktreeMismatchFor(projectPath) {
|
|
730
919
|
const startPath = projectPath ?? this.defaultProjectHint ?? process.cwd();
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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 — getHomeGraph re-walks to the nearest .homegraph/, 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;
|
|
735
930
|
try {
|
|
736
|
-
|
|
931
|
+
indexRoot = this.getHomeGraph(projectPath).getProjectRoot();
|
|
737
932
|
}
|
|
738
933
|
catch {
|
|
739
934
|
// No resolvable project (or any other resolution error) → nothing to warn.
|
|
740
|
-
|
|
935
|
+
return null;
|
|
741
936
|
}
|
|
742
|
-
|
|
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);
|
|
743
943
|
return mismatch;
|
|
744
944
|
}
|
|
745
945
|
/**
|
|
@@ -781,12 +981,12 @@ class ToolHandler {
|
|
|
781
981
|
return result;
|
|
782
982
|
let cg;
|
|
783
983
|
try {
|
|
784
|
-
cg = this.
|
|
984
|
+
cg = this.getHomeGraph(projectPath);
|
|
785
985
|
}
|
|
786
986
|
catch {
|
|
787
987
|
return result; // no default project — leave as is
|
|
788
988
|
}
|
|
789
|
-
// Cross-project `projectPath` calls open a cached
|
|
989
|
+
// Cross-project `projectPath` calls open a cached HomeGraph WITHOUT a
|
|
790
990
|
// watcher (watchers are only attached to the default session project).
|
|
791
991
|
// When the cross-project path happens to be the same project as the
|
|
792
992
|
// default cg, the cached instance is the wrong one — its pendingFiles is
|
|
@@ -803,7 +1003,35 @@ class ToolHandler {
|
|
|
803
1003
|
/* getProjectRoot may throw on a closed instance — leave cg as is */
|
|
804
1004
|
}
|
|
805
1005
|
}
|
|
806
|
-
//
|
|
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 HomeGraph, 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
|
+
}
|
|
1034
|
+
// Defensive: some test fakes inject a partial HomeGraph stub without the
|
|
807
1035
|
// newer pending-files API. Treat missing/throwing as "no pending files."
|
|
808
1036
|
let pending = [];
|
|
809
1037
|
try {
|
|
@@ -822,7 +1050,7 @@ class ToolHandler {
|
|
|
822
1050
|
const elsewhere = [];
|
|
823
1051
|
for (const p of pending) {
|
|
824
1052
|
// Substring match against the project-relative POSIX path — that's
|
|
825
|
-
// exactly the format both the watcher and every
|
|
1053
|
+
// exactly the format both the watcher and every homegraph response
|
|
826
1054
|
// emit, so a plain includes() is sufficient and avoids regex pitfalls.
|
|
827
1055
|
if (text.includes(p.path))
|
|
828
1056
|
inResponse.push(p);
|
|
@@ -849,16 +1077,16 @@ class ToolHandler {
|
|
|
849
1077
|
try {
|
|
850
1078
|
// Block the first tool call on the engine's post-open reconcile so we
|
|
851
1079
|
// never serve rows for files deleted/edited while no MCP server was
|
|
852
|
-
// running. The
|
|
853
|
-
//
|
|
854
|
-
//
|
|
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.
|
|
855
1086
|
if (this.catchUpGate) {
|
|
856
1087
|
const gate = this.catchUpGate;
|
|
857
1088
|
this.catchUpGate = null;
|
|
858
|
-
|
|
859
|
-
await gate;
|
|
860
|
-
}
|
|
861
|
-
catch { /* engine already logged */ }
|
|
1089
|
+
await this.awaitCatchUpGate(gate);
|
|
862
1090
|
}
|
|
863
1091
|
// Honor the optional tool allowlist (HOMEGRAPH_MCP_TOOLS): a trimmed
|
|
864
1092
|
// surface rejects ablated tools defensively even if a client cached them.
|
|
@@ -926,7 +1154,19 @@ class ToolHandler {
|
|
|
926
1154
|
return this.withStalenessNotice(withWorktree, args.projectPath);
|
|
927
1155
|
}
|
|
928
1156
|
catch (err) {
|
|
929
|
-
|
|
1157
|
+
// Expected condition, not a malfunction: answer as a SUCCESS so the
|
|
1158
|
+
// agent keeps trusting the toolset for projects that ARE indexed.
|
|
1159
|
+
// (An isError here teaches session-long abandonment — see NotIndexedError.)
|
|
1160
|
+
if (err instanceof NotIndexedError) {
|
|
1161
|
+
return this.textResult(err.message);
|
|
1162
|
+
}
|
|
1163
|
+
// Security refusal: a clean error, no retry encouragement.
|
|
1164
|
+
if (err instanceof PathRefusalError) {
|
|
1165
|
+
return this.errorResult(err.message);
|
|
1166
|
+
}
|
|
1167
|
+
return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
1168
|
+
'This is an internal homegraph error — retry the call once; if it persists, ' +
|
|
1169
|
+
'continue without homegraph for this task.');
|
|
930
1170
|
}
|
|
931
1171
|
}
|
|
932
1172
|
/**
|
|
@@ -936,8 +1176,12 @@ class ToolHandler {
|
|
|
936
1176
|
const query = this.validateString(args.query, 'query');
|
|
937
1177
|
if (typeof query !== 'string')
|
|
938
1178
|
return query;
|
|
939
|
-
const cg = this.
|
|
940
|
-
const
|
|
1179
|
+
const cg = this.getHomeGraph(args.projectPath);
|
|
1180
|
+
const rawKind = args.kind;
|
|
1181
|
+
// The schema enum says 'type' (what agents naturally reach for); the
|
|
1182
|
+
// NodeKind is 'type_alias'. Without the mapping, kind: "type" silently
|
|
1183
|
+
// matched nothing — a filter value we advertise must work.
|
|
1184
|
+
const kind = rawKind === 'type' ? 'type_alias' : rawKind;
|
|
941
1185
|
const rawLimit = Number(args.limit) || 10;
|
|
942
1186
|
const limit = (0, utils_1.clamp)(rawLimit, 1, 100);
|
|
943
1187
|
const results = cg.searchNodes(query, {
|
|
@@ -958,6 +1202,43 @@ class ToolHandler {
|
|
|
958
1202
|
const formatted = this.formatSearchResults(ranked);
|
|
959
1203
|
return this.textResult(this.truncateOutput(formatted));
|
|
960
1204
|
}
|
|
1205
|
+
/**
|
|
1206
|
+
* Group symbol matches into DISTINCT DEFINITIONS — one group per
|
|
1207
|
+
* (filePath, qualifiedName), so same-file overloads stay together while
|
|
1208
|
+
* unrelated same-named classes across a monorepo's apps (#764: one
|
|
1209
|
+
* `UserService` per NestJS app) are kept apart. Optionally narrowed by a
|
|
1210
|
+
* `file` path/suffix first.
|
|
1211
|
+
*/
|
|
1212
|
+
groupDefinitions(nodes, fileFilter) {
|
|
1213
|
+
let pool = nodes;
|
|
1214
|
+
let filteredOut = false;
|
|
1215
|
+
if (fileFilter) {
|
|
1216
|
+
const wanted = fileFilter.replace(/^\.\//, '');
|
|
1217
|
+
const narrowed = pool.filter((n) => n.filePath === wanted || n.filePath.endsWith(wanted) || n.filePath.endsWith(`/${wanted}`));
|
|
1218
|
+
if (narrowed.length > 0) {
|
|
1219
|
+
pool = narrowed;
|
|
1220
|
+
}
|
|
1221
|
+
else {
|
|
1222
|
+
filteredOut = true;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
const byDef = new Map();
|
|
1226
|
+
for (const n of pool) {
|
|
1227
|
+
const key = `${n.filePath}|${n.qualifiedName}`;
|
|
1228
|
+
const group = byDef.get(key);
|
|
1229
|
+
if (group)
|
|
1230
|
+
group.push(n);
|
|
1231
|
+
else
|
|
1232
|
+
byDef.set(key, [n]);
|
|
1233
|
+
}
|
|
1234
|
+
return { groups: [...byDef.values()], filteredOut };
|
|
1235
|
+
}
|
|
1236
|
+
/** Section heading for one distinct definition in grouped output. */
|
|
1237
|
+
definitionHeading(group) {
|
|
1238
|
+
const head = group[0];
|
|
1239
|
+
const line = head.startLine ? `:${head.startLine}` : '';
|
|
1240
|
+
return `**${head.qualifiedName}** (${head.kind}) — ${head.filePath}${line}`;
|
|
1241
|
+
}
|
|
961
1242
|
/**
|
|
962
1243
|
* Handle homegraph_callers
|
|
963
1244
|
*/
|
|
@@ -965,28 +1246,66 @@ class ToolHandler {
|
|
|
965
1246
|
const symbol = this.validateString(args.symbol, 'symbol');
|
|
966
1247
|
if (typeof symbol !== 'string')
|
|
967
1248
|
return symbol;
|
|
968
|
-
const cg = this.
|
|
1249
|
+
const cg = this.getHomeGraph(args.projectPath);
|
|
969
1250
|
const limit = (0, utils_1.clamp)(args.limit || 20, 1, 100);
|
|
1251
|
+
const fileFilter = typeof args.file === 'string' ? args.file : undefined;
|
|
970
1252
|
const allMatches = this.findAllSymbols(cg, symbol);
|
|
971
1253
|
if (allMatches.nodes.length === 0) {
|
|
972
1254
|
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
|
973
1255
|
}
|
|
974
|
-
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1256
|
+
const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
|
|
1257
|
+
const filterNote = filteredOut
|
|
1258
|
+
? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
|
|
1259
|
+
: '';
|
|
1260
|
+
const collect = (defNodes) => {
|
|
1261
|
+
const seen = new Set();
|
|
1262
|
+
const callers = [];
|
|
1263
|
+
const labels = new Map();
|
|
1264
|
+
for (const node of defNodes) {
|
|
1265
|
+
for (const c of cg.getCallers(node.id)) {
|
|
1266
|
+
if (!seen.has(c.node.id)) {
|
|
1267
|
+
seen.add(c.node.id);
|
|
1268
|
+
callers.push(c.node);
|
|
1269
|
+
const label = this.edgeLabel(c.edge);
|
|
1270
|
+
if (label)
|
|
1271
|
+
labels.set(c.node.id, label);
|
|
1272
|
+
}
|
|
982
1273
|
}
|
|
983
1274
|
}
|
|
1275
|
+
return { callers, labels };
|
|
1276
|
+
};
|
|
1277
|
+
// Single definition (or same-file overloads): the familiar flat list.
|
|
1278
|
+
if (groups.length === 1) {
|
|
1279
|
+
const { callers, labels } = collect(groups[0]);
|
|
1280
|
+
if (callers.length === 0) {
|
|
1281
|
+
return this.textResult(`No callers found for "${symbol}"${allMatches.note}${filterNote}`);
|
|
1282
|
+
}
|
|
1283
|
+
// A successful `file` narrowing makes the multi-symbol aggregation note
|
|
1284
|
+
// stale — suppress it.
|
|
1285
|
+
const note = fileFilter && !filteredOut ? '' : allMatches.note;
|
|
1286
|
+
const formatted = this.formatNodeList(callers.slice(0, limit), `Callers of ${symbol}`, labels) + note + filterNote;
|
|
1287
|
+
return this.textResult(this.truncateOutput(formatted));
|
|
984
1288
|
}
|
|
985
|
-
|
|
986
|
-
|
|
1289
|
+
// Multiple DISTINCT definitions (#764): one section per definition so an
|
|
1290
|
+
// agent never mistakes one app's callers for another's. Narrow with
|
|
1291
|
+
// `file` to focus a single definition.
|
|
1292
|
+
const lines = [
|
|
1293
|
+
`**Callers of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)**`,
|
|
1294
|
+
];
|
|
1295
|
+
for (const group of groups) {
|
|
1296
|
+
const { callers, labels } = collect(group);
|
|
1297
|
+
lines.push('', this.definitionHeading(group));
|
|
1298
|
+
if (callers.length === 0) {
|
|
1299
|
+
lines.push('- (no callers)');
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
for (const node of callers.slice(0, limit)) {
|
|
1303
|
+
const location = node.startLine ? `:${node.startLine}` : '';
|
|
1304
|
+
const label = labels.get(node.id);
|
|
1305
|
+
lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
|
|
1306
|
+
}
|
|
987
1307
|
}
|
|
988
|
-
|
|
989
|
-
return this.textResult(this.truncateOutput(formatted));
|
|
1308
|
+
return this.textResult(this.truncateOutput(lines.join('\n') + filterNote));
|
|
990
1309
|
}
|
|
991
1310
|
/**
|
|
992
1311
|
* Handle homegraph_callees
|
|
@@ -995,28 +1314,63 @@ class ToolHandler {
|
|
|
995
1314
|
const symbol = this.validateString(args.symbol, 'symbol');
|
|
996
1315
|
if (typeof symbol !== 'string')
|
|
997
1316
|
return symbol;
|
|
998
|
-
const cg = this.
|
|
1317
|
+
const cg = this.getHomeGraph(args.projectPath);
|
|
999
1318
|
const limit = (0, utils_1.clamp)(args.limit || 20, 1, 100);
|
|
1319
|
+
const fileFilter = typeof args.file === 'string' ? args.file : undefined;
|
|
1000
1320
|
const allMatches = this.findAllSymbols(cg, symbol);
|
|
1001
1321
|
if (allMatches.nodes.length === 0) {
|
|
1002
1322
|
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
|
1003
1323
|
}
|
|
1004
|
-
|
|
1005
|
-
const
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1324
|
+
const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
|
|
1325
|
+
const filterNote = filteredOut
|
|
1326
|
+
? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
|
|
1327
|
+
: '';
|
|
1328
|
+
const collect = (defNodes) => {
|
|
1329
|
+
const seen = new Set();
|
|
1330
|
+
const callees = [];
|
|
1331
|
+
const labels = new Map();
|
|
1332
|
+
for (const node of defNodes) {
|
|
1333
|
+
for (const c of cg.getCallees(node.id)) {
|
|
1334
|
+
if (!seen.has(c.node.id)) {
|
|
1335
|
+
seen.add(c.node.id);
|
|
1336
|
+
callees.push(c.node);
|
|
1337
|
+
const label = this.edgeLabel(c.edge);
|
|
1338
|
+
if (label)
|
|
1339
|
+
labels.set(c.node.id, label);
|
|
1340
|
+
}
|
|
1012
1341
|
}
|
|
1013
1342
|
}
|
|
1343
|
+
return { callees, labels };
|
|
1344
|
+
};
|
|
1345
|
+
if (groups.length === 1) {
|
|
1346
|
+
const { callees, labels } = collect(groups[0]);
|
|
1347
|
+
if (callees.length === 0) {
|
|
1348
|
+
return this.textResult(`No callees found for "${symbol}"${allMatches.note}${filterNote}`);
|
|
1349
|
+
}
|
|
1350
|
+
// A successful `file` narrowing makes the multi-symbol aggregation note
|
|
1351
|
+
// stale — suppress it.
|
|
1352
|
+
const note = fileFilter && !filteredOut ? '' : allMatches.note;
|
|
1353
|
+
const formatted = this.formatNodeList(callees.slice(0, limit), `Callees of ${symbol}`, labels) + note + filterNote;
|
|
1354
|
+
return this.textResult(this.truncateOutput(formatted));
|
|
1014
1355
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1356
|
+
// Multiple DISTINCT definitions (#764): per-definition sections.
|
|
1357
|
+
const lines = [
|
|
1358
|
+
`**Callees of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)**`,
|
|
1359
|
+
];
|
|
1360
|
+
for (const group of groups) {
|
|
1361
|
+
const { callees, labels } = collect(group);
|
|
1362
|
+
lines.push('', this.definitionHeading(group));
|
|
1363
|
+
if (callees.length === 0) {
|
|
1364
|
+
lines.push('- (no callees)');
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1367
|
+
for (const node of callees.slice(0, limit)) {
|
|
1368
|
+
const location = node.startLine ? `:${node.startLine}` : '';
|
|
1369
|
+
const label = labels.get(node.id);
|
|
1370
|
+
lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
|
|
1371
|
+
}
|
|
1017
1372
|
}
|
|
1018
|
-
|
|
1019
|
-
return this.textResult(this.truncateOutput(formatted));
|
|
1373
|
+
return this.textResult(this.truncateOutput(lines.join('\n') + filterNote));
|
|
1020
1374
|
}
|
|
1021
1375
|
/**
|
|
1022
1376
|
* Handle homegraph_impact
|
|
@@ -1025,36 +1379,53 @@ class ToolHandler {
|
|
|
1025
1379
|
const symbol = this.validateString(args.symbol, 'symbol');
|
|
1026
1380
|
if (typeof symbol !== 'string')
|
|
1027
1381
|
return symbol;
|
|
1028
|
-
const cg = this.
|
|
1382
|
+
const cg = this.getHomeGraph(args.projectPath);
|
|
1029
1383
|
const depth = (0, utils_1.clamp)(args.depth || 2, 1, 10);
|
|
1384
|
+
const fileFilter = typeof args.file === 'string' ? args.file : undefined;
|
|
1030
1385
|
const allMatches = this.findAllSymbols(cg, symbol);
|
|
1031
1386
|
if (allMatches.nodes.length === 0) {
|
|
1032
1387
|
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
|
1033
1388
|
}
|
|
1034
|
-
|
|
1035
|
-
const
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
const
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
const
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1389
|
+
const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
|
|
1390
|
+
const filterNote = filteredOut
|
|
1391
|
+
? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
|
|
1392
|
+
: '';
|
|
1393
|
+
const impactOf = (defNodes) => {
|
|
1394
|
+
const mergedNodes = new Map();
|
|
1395
|
+
const mergedEdges = [];
|
|
1396
|
+
const seenEdges = new Set();
|
|
1397
|
+
for (const node of defNodes) {
|
|
1398
|
+
const impact = cg.getImpactRadius(node.id, depth);
|
|
1399
|
+
for (const [id, n] of impact.nodes) {
|
|
1400
|
+
mergedNodes.set(id, n);
|
|
1401
|
+
}
|
|
1402
|
+
for (const e of impact.edges) {
|
|
1403
|
+
const key = `${e.source}->${e.target}:${e.kind}`;
|
|
1404
|
+
if (!seenEdges.has(key)) {
|
|
1405
|
+
seenEdges.add(key);
|
|
1406
|
+
mergedEdges.push(e);
|
|
1407
|
+
}
|
|
1048
1408
|
}
|
|
1049
1409
|
}
|
|
1050
|
-
|
|
1051
|
-
const mergedImpact = {
|
|
1052
|
-
nodes: mergedNodes,
|
|
1053
|
-
edges: mergedEdges,
|
|
1054
|
-
roots: allMatches.nodes.map(n => n.id),
|
|
1410
|
+
return { nodes: mergedNodes, edges: mergedEdges, roots: defNodes.map((n) => n.id) };
|
|
1055
1411
|
};
|
|
1056
|
-
|
|
1057
|
-
|
|
1412
|
+
// Single definition (or same-file overloads): the familiar merged report.
|
|
1413
|
+
if (groups.length === 1) {
|
|
1414
|
+
const formatted = this.formatImpact(symbol, impactOf(groups[0])) + (fileFilter && !filteredOut ? "" : allMatches.note) + filterNote;
|
|
1415
|
+
return this.textResult(this.truncateOutput(formatted));
|
|
1416
|
+
}
|
|
1417
|
+
// Multiple DISTINCT definitions (#764): a blast radius PER definition —
|
|
1418
|
+
// merging unrelated same-named classes (one UserService per monorepo app)
|
|
1419
|
+
// overstated impact and confused agents. Narrow with `file`.
|
|
1420
|
+
const sections = [
|
|
1421
|
+
`**Impact of ${symbol} — ${groups.length} distinct definitions (each with its own blast radius; narrow with \`file\`)**`,
|
|
1422
|
+
];
|
|
1423
|
+
for (const group of groups) {
|
|
1424
|
+
const head = group[0];
|
|
1425
|
+
const line = head.startLine ? `:${head.startLine}` : '';
|
|
1426
|
+
sections.push('', this.formatImpact(`${head.qualifiedName} (${head.filePath}${line})`, impactOf(group)));
|
|
1427
|
+
}
|
|
1428
|
+
return this.textResult(this.truncateOutput(sections.join('\n') + filterNote));
|
|
1058
1429
|
}
|
|
1059
1430
|
/**
|
|
1060
1431
|
* Describe a synthesized (dynamic-dispatch) edge for human output: how the
|
|
@@ -1123,6 +1494,29 @@ class ToolHandler {
|
|
|
1123
1494
|
registeredAt,
|
|
1124
1495
|
};
|
|
1125
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
|
+
}
|
|
1126
1520
|
return null;
|
|
1127
1521
|
}
|
|
1128
1522
|
/**
|
|
@@ -1140,14 +1534,17 @@ class ToolHandler {
|
|
|
1140
1534
|
* dropping unrelated `OmsOrderService::list`.
|
|
1141
1535
|
*/
|
|
1142
1536
|
buildFlowFromNamedSymbols(cg, query) {
|
|
1143
|
-
|
|
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() };
|
|
1144
1541
|
try {
|
|
1145
1542
|
const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
|
|
1146
1543
|
// Strip only a REAL file extension (Create.cs → Create); KEEP qualified
|
|
1147
1544
|
// names (Class.method / Class::method) — the agent's most precise input,
|
|
1148
1545
|
// resolved exactly by findAllSymbols. (The old strip mangled Class.method
|
|
1149
1546
|
// into Class, throwing the method away.)
|
|
1150
|
-
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;
|
|
1547
|
+
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|astro)$/i;
|
|
1151
1548
|
const tokens = [...new Set(query.split(/[\s,()[\]]+/)
|
|
1152
1549
|
.map((t) => t.replace(FILE_EXT, '').trim())
|
|
1153
1550
|
.filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)))].slice(0, 16);
|
|
@@ -1168,8 +1565,28 @@ class ToolHandler {
|
|
|
1168
1565
|
// (`as_sql`, 110 defs across every Expression/Compiler subclass) is NOT here,
|
|
1169
1566
|
// so naming it doesn't keep every backend variant full and flood the budget.
|
|
1170
1567
|
const uniqueNamedNodeIds = new Set();
|
|
1568
|
+
// token → resolved node ids: drives the token-coverage check that gates
|
|
1569
|
+
// the dynamic-boundary scan (a token is covered when ANY of its nodes
|
|
1570
|
+
// lands on the main chain — overloads off the chain don't count against).
|
|
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');
|
|
1171
1586
|
for (const t of tokens) {
|
|
1172
|
-
const
|
|
1587
|
+
const hits = this.findAllSymbols(cg, t).nodes;
|
|
1588
|
+
const cands = hits.filter((n) => CALLABLE.has(n.kind));
|
|
1589
|
+
tokenFamily.set(t, cands);
|
|
1173
1590
|
// A qualified or otherwise-specific name (<=3 hits) keeps all; an
|
|
1174
1591
|
// ambiguous simple name keeps only candidates whose container is named.
|
|
1175
1592
|
const specific = cands.length <= 3;
|
|
@@ -1180,16 +1597,75 @@ class ToolHandler {
|
|
|
1180
1597
|
const container = segs.length >= 2 ? segs[segs.length - 2] : '';
|
|
1181
1598
|
return !!container && segPool.has(container);
|
|
1182
1599
|
});
|
|
1183
|
-
|
|
1600
|
+
const kept = pick.slice(0, 6);
|
|
1601
|
+
tokenNodes.set(t, kept.map((n) => n.id));
|
|
1602
|
+
for (const n of kept) {
|
|
1184
1603
|
named.set(n.id, n);
|
|
1185
1604
|
if (specific)
|
|
1186
1605
|
uniqueNamedNodeIds.add(n.id);
|
|
1187
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
|
+
}
|
|
1188
1619
|
if (named.size > 40)
|
|
1189
1620
|
break;
|
|
1190
1621
|
}
|
|
1191
|
-
|
|
1192
|
-
|
|
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
|
+
};
|
|
1651
|
+
if (named.size < 2) {
|
|
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)
|
|
1660
|
+
return EMPTY;
|
|
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() };
|
|
1668
|
+
}
|
|
1193
1669
|
const MAX_HOPS = 7;
|
|
1194
1670
|
let best = null;
|
|
1195
1671
|
// BFS the full call graph (incl. synth edges) from each named seed, but
|
|
@@ -1237,41 +1713,90 @@ class ToolHandler {
|
|
|
1237
1713
|
}
|
|
1238
1714
|
const hasMain = !!best && best.length >= 3;
|
|
1239
1715
|
const pathIds = new Set((best ?? []).map((s) => s.node.id));
|
|
1240
|
-
//
|
|
1241
|
-
//
|
|
1242
|
-
//
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
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
|
+
}
|
|
1726
|
+
// Dynamic-boundary scan (#687) — fires ONLY when the flow the agent
|
|
1727
|
+
// asked about did not fully connect: some token resolved to nodes but
|
|
1728
|
+
// none of them sit on the main chain (or there is no chain at all). A
|
|
1729
|
+
// healthy flow skips this entirely. Scan order: the chain's dead end
|
|
1730
|
+
// first (where the partial flow stops), then the disconnected symbols,
|
|
1731
|
+
// agent-specific (unique-named) ones first.
|
|
1732
|
+
let boundaryText = '';
|
|
1733
|
+
{
|
|
1734
|
+
const uncovered = [];
|
|
1735
|
+
if (!hasMain) {
|
|
1736
|
+
// No rendered chain — but a 2-node chain still CONNECTS its two
|
|
1737
|
+
// endpoints (e.g. via one synthesized hop, surfaced below as a
|
|
1738
|
+
// dynamic-dispatch link). Only nodes off that short chain are
|
|
1739
|
+
// unexplained breaks worth scanning.
|
|
1740
|
+
for (const n of named.values())
|
|
1741
|
+
if (!pathIds.has(n.id))
|
|
1742
|
+
uncovered.push(n);
|
|
1743
|
+
}
|
|
1744
|
+
else {
|
|
1745
|
+
for (const ids of tokenNodes.values()) {
|
|
1746
|
+
if (ids.length === 0 || ids.some((id) => pathIds.has(id)))
|
|
1747
|
+
continue;
|
|
1748
|
+
for (const id of ids) {
|
|
1749
|
+
const n = named.get(id);
|
|
1750
|
+
if (n)
|
|
1751
|
+
uncovered.push(n);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
if (uncovered.length > 0) {
|
|
1756
|
+
const scanList = [];
|
|
1757
|
+
if (hasMain)
|
|
1758
|
+
scanList.push(best[best.length - 1].node);
|
|
1759
|
+
scanList.push(...uncovered.sort((a, b) => (uniqueNamedNodeIds.has(b.id) ? 1 : 0) - (uniqueNamedNodeIds.has(a.id) ? 1 : 0)));
|
|
1760
|
+
boundaryText = this.buildDynamicBoundaries(cg, scanList, named);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
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)
|
|
1264
1778
|
continue;
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
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 });
|
|
1268
1783
|
}
|
|
1784
|
+
if (polyCands.length)
|
|
1785
|
+
polyText = this.buildPolymorphicBoundaries(cg, polyCands, named);
|
|
1269
1786
|
}
|
|
1270
|
-
|
|
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)
|
|
1271
1796
|
return EMPTY;
|
|
1272
1797
|
const out = [];
|
|
1273
1798
|
if (hasMain) {
|
|
1274
|
-
out.push('
|
|
1799
|
+
out.push('**Flow (call path among the symbols you queried)**', '');
|
|
1275
1800
|
for (let i = 0; i < best.length; i++) {
|
|
1276
1801
|
const step = best[i];
|
|
1277
1802
|
if (step.edge) {
|
|
@@ -1283,20 +1808,288 @@ class ToolHandler {
|
|
|
1283
1808
|
out.push('');
|
|
1284
1809
|
}
|
|
1285
1810
|
if (synthLines.length) {
|
|
1286
|
-
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, '');
|
|
1287
1812
|
}
|
|
1813
|
+
if (boundaryText)
|
|
1814
|
+
out.push(boundaryText);
|
|
1815
|
+
if (polyText)
|
|
1816
|
+
out.push(polyText);
|
|
1288
1817
|
out.push('> Full source for these symbols is below — the call flow among them, followed by their bodies.', '');
|
|
1289
1818
|
// namedNodeIds = every callable the agent explicitly named (a superset of
|
|
1290
1819
|
// the spine). A file holding one is something the agent asked to SEE, so it
|
|
1291
1820
|
// must keep full source even if it's an off-spine polymorphic sibling — the
|
|
1292
1821
|
// agent named `getResponseWithInterceptorChain` / `SQLCompiler.execute_sql`
|
|
1293
1822
|
// as the mechanism, not as an interchangeable leaf. See the skeleton gate.
|
|
1294
|
-
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 };
|
|
1295
1824
|
}
|
|
1296
1825
|
catch {
|
|
1297
1826
|
return EMPTY;
|
|
1298
1827
|
}
|
|
1299
1828
|
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Dynamic-boundary surfacing (#687): when the flow among the agent's named
|
|
1831
|
+
* symbols does not fully connect, scan the disconnected symbols' bodies for
|
|
1832
|
+
* dynamic-dispatch sites (computed member calls, getattr, reflection, typed
|
|
1833
|
+
* message buses, runtime-keyed emits) and ANNOUNCE the boundary — the exact
|
|
1834
|
+
* site, the form, and (when a key is statically visible) candidate targets —
|
|
1835
|
+
* instead of guessing edges. The answer to "how does A reach B" when no
|
|
1836
|
+
* static path exists IS the dispatch site: that's where the flow continues
|
|
1837
|
+
* at runtime. Query-time, deterministic, zero graph mutation; a fully
|
|
1838
|
+
* connected flow never reaches this method.
|
|
1839
|
+
*/
|
|
1840
|
+
buildDynamicBoundaries(cg, scanList, named) {
|
|
1841
|
+
const MAX_NOTES = 4; // boundary bullets per explore
|
|
1842
|
+
const MAX_SCAN = 8; // bodies scanned
|
|
1843
|
+
const MAX_TOTAL_CHARS = 200_000;
|
|
1844
|
+
let projectRoot;
|
|
1845
|
+
try {
|
|
1846
|
+
projectRoot = cg.getProjectRoot();
|
|
1847
|
+
}
|
|
1848
|
+
catch {
|
|
1849
|
+
return '';
|
|
1850
|
+
}
|
|
1851
|
+
const notes = [];
|
|
1852
|
+
const seenNode = new Set();
|
|
1853
|
+
const seenSite = new Set();
|
|
1854
|
+
let scanned = 0, charsScanned = 0;
|
|
1855
|
+
for (const node of scanList) {
|
|
1856
|
+
if (notes.length >= MAX_NOTES || scanned >= MAX_SCAN || charsScanned > MAX_TOTAL_CHARS)
|
|
1857
|
+
break;
|
|
1858
|
+
if (seenNode.has(node.id) || !node.startLine || !node.endLine)
|
|
1859
|
+
continue;
|
|
1860
|
+
seenNode.add(node.id);
|
|
1861
|
+
const absPath = (0, utils_1.validatePathWithinRoot)(projectRoot, node.filePath);
|
|
1862
|
+
if (!absPath || !(0, fs_1.existsSync)(absPath))
|
|
1863
|
+
continue;
|
|
1864
|
+
let content;
|
|
1865
|
+
try {
|
|
1866
|
+
content = (0, fs_1.readFileSync)(absPath, 'utf-8');
|
|
1867
|
+
}
|
|
1868
|
+
catch {
|
|
1869
|
+
continue;
|
|
1870
|
+
}
|
|
1871
|
+
const body = content.split('\n').slice(node.startLine - 1, node.endLine).join('\n');
|
|
1872
|
+
scanned++;
|
|
1873
|
+
charsScanned += body.length;
|
|
1874
|
+
for (const m of (0, dynamic_boundaries_1.scanDynamicDispatch)(body, node.language || '', node.startLine)) {
|
|
1875
|
+
if (notes.length >= MAX_NOTES)
|
|
1876
|
+
break;
|
|
1877
|
+
const siteKey = `${node.filePath}:${m.line}:${m.form}`;
|
|
1878
|
+
if (seenSite.has(siteKey))
|
|
1879
|
+
continue;
|
|
1880
|
+
seenSite.add(siteKey);
|
|
1881
|
+
const more = m.moreSites ? ` (+${m.moreSites} more such site${m.moreSites > 1 ? 's' : ''} in this body)` : '';
|
|
1882
|
+
notes.push(`- \`${node.name}\` (${node.filePath}:${m.line}) — ${m.label}: \`${m.snippet}\`${more}`);
|
|
1883
|
+
if (m.key) {
|
|
1884
|
+
const cand = this.boundaryCandidates(cg, m.key, !!m.keyIsType, named, node.id);
|
|
1885
|
+
if (cand)
|
|
1886
|
+
notes.push(` ${cand}`);
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
if (notes.length === 0)
|
|
1891
|
+
return '';
|
|
1892
|
+
return [
|
|
1893
|
+
'**Dynamic boundaries (the static path ends at runtime dispatch)**',
|
|
1894
|
+
'',
|
|
1895
|
+
...notes,
|
|
1896
|
+
'',
|
|
1897
|
+
'> These sites choose their call target at runtime (registry / bus / reflection) — the site shown IS where the flow continues. To follow it, run homegraph_explore or homegraph_node on a candidate; source for the sites above is included below.',
|
|
1898
|
+
'',
|
|
1899
|
+
].join('\n');
|
|
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 homegraph_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 homegraph_explore on a listed target.',
|
|
2005
|
+
'',
|
|
2006
|
+
].join('\n');
|
|
2007
|
+
}
|
|
2008
|
+
/**
|
|
2009
|
+
* Shortlist candidate runtime targets for a dispatch key surfaced by
|
|
2010
|
+
* {@link buildDynamicBoundaries}. Exact conventional names first (`save` →
|
|
2011
|
+
* `onSave`/`handleSave`; `CreateCmd` → `CreateCmdHandler`), then FTS, with a
|
|
2012
|
+
* normalized-containment post-filter (FTS camel-splitting is fuzzier than a
|
|
2013
|
+
* candidate list should be). Symbols the agent already named sort first and
|
|
2014
|
+
* are marked — that's the "you were right, here's the wiring" case.
|
|
2015
|
+
*/
|
|
2016
|
+
boundaryCandidates(cg, key, keyIsType, named, selfId) {
|
|
2017
|
+
const CALLABLE = new Set(['method', 'function', 'component', 'constructor', 'class']);
|
|
2018
|
+
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
2019
|
+
const keyNorm = norm(key);
|
|
2020
|
+
if (keyNorm.length < 3)
|
|
2021
|
+
return '';
|
|
2022
|
+
const cands = new Map();
|
|
2023
|
+
const consider = (n) => {
|
|
2024
|
+
if (!n || n.id === selfId || !CALLABLE.has(n.kind) || cands.has(n.id))
|
|
2025
|
+
return;
|
|
2026
|
+
const nameNorm = norm(n.name || '');
|
|
2027
|
+
if (nameNorm.length < 3)
|
|
2028
|
+
return;
|
|
2029
|
+
if (!nameNorm.includes(keyNorm) && !keyNorm.includes(nameNorm))
|
|
2030
|
+
return;
|
|
2031
|
+
cands.set(n.id, n);
|
|
2032
|
+
};
|
|
2033
|
+
const cap = key.charAt(0).toUpperCase() + key.slice(1);
|
|
2034
|
+
const probes = keyIsType
|
|
2035
|
+
? [`${key}Handler`, key]
|
|
2036
|
+
: [key, `on${cap}`, `handle${cap}`, `${key}Handler`, `handle_${key}`];
|
|
2037
|
+
for (const p of probes) {
|
|
2038
|
+
try {
|
|
2039
|
+
for (const n of cg.getNodesByName(p))
|
|
2040
|
+
consider(n);
|
|
2041
|
+
}
|
|
2042
|
+
catch { /* exact probe miss is fine */ }
|
|
2043
|
+
}
|
|
2044
|
+
let raw = 0;
|
|
2045
|
+
try {
|
|
2046
|
+
const results = cg.searchNodes(key, { limit: 12 });
|
|
2047
|
+
raw = results.length;
|
|
2048
|
+
for (const r of results)
|
|
2049
|
+
consider(r.node);
|
|
2050
|
+
}
|
|
2051
|
+
catch { /* FTS syntax edge — exact probes already ran */ }
|
|
2052
|
+
if (cands.size === 0) {
|
|
2053
|
+
return raw >= 12 && key.length < 5 ? `key \`${key}\` is too generic to shortlist (${raw}+ matches)` : '';
|
|
2054
|
+
}
|
|
2055
|
+
// A constructor candidate duplicates its class: extractors emit ctors as
|
|
2056
|
+
// METHOD nodes named like the class (C#/Java `Foo::Foo`) — keep the class.
|
|
2057
|
+
const all = [...cands.values()];
|
|
2058
|
+
const classKey = new Set(all.filter((n) => n.kind === 'class').map((n) => `${n.name}|${n.filePath}`));
|
|
2059
|
+
const namedNames = new Set([...named.values()].map((n) => n.name));
|
|
2060
|
+
const isNamed = (n) => named.has(n.id) || namedNames.has(n.name); // the flow's named set holds callables only — transfer the mark to the class
|
|
2061
|
+
const list = all
|
|
2062
|
+
.filter((n) => !(n.kind !== 'class' && classKey.has(`${n.name}|${n.filePath}`)))
|
|
2063
|
+
.sort((a, b) => (isNamed(b) ? 1 : 0) - (isNamed(a) ? 1 : 0))
|
|
2064
|
+
.slice(0, 4)
|
|
2065
|
+
.map((n) => {
|
|
2066
|
+
// Typed-bus convention: the runtime target is the candidate class's
|
|
2067
|
+
// Handle/Execute/Consume method — name the exact node, not just the class.
|
|
2068
|
+
let display = n.qualifiedName || n.name;
|
|
2069
|
+
let at = `${n.filePath}:${n.startLine}`;
|
|
2070
|
+
if (keyIsType && n.kind === 'class') {
|
|
2071
|
+
try {
|
|
2072
|
+
const HANDLER_METHODS = /^(handle|handleAsync|execute|executeAsync|consume|consumeAsync|run|__invoke)$/i;
|
|
2073
|
+
const method = cg.getOutgoingEdges(n.id)
|
|
2074
|
+
.filter((e) => e.kind === 'contains')
|
|
2075
|
+
.map((e) => { try {
|
|
2076
|
+
return cg.getNode(e.target);
|
|
2077
|
+
}
|
|
2078
|
+
catch {
|
|
2079
|
+
return null;
|
|
2080
|
+
} })
|
|
2081
|
+
.find((c) => !!c && c.kind === 'method' && HANDLER_METHODS.test(c.name));
|
|
2082
|
+
if (method) {
|
|
2083
|
+
display = `${n.name}.${method.name}`;
|
|
2084
|
+
at = `${method.filePath}:${method.startLine}`;
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
catch { /* class without resolvable members — show the class itself */ }
|
|
2088
|
+
}
|
|
2089
|
+
return `\`${display}\` (${at})${isNamed(n) ? ' ← you named this' : ''}`;
|
|
2090
|
+
});
|
|
2091
|
+
return `candidates for key \`${key}\`: ${list.join(', ')}`;
|
|
2092
|
+
}
|
|
1300
2093
|
/**
|
|
1301
2094
|
* Compact "blast radius" for the entry symbols of an explore result: who
|
|
1302
2095
|
* depends on each (callers) and which test files cover it — LOCATIONS ONLY,
|
|
@@ -1350,7 +2143,7 @@ class ToolHandler {
|
|
|
1350
2143
|
if (entries.length === 0)
|
|
1351
2144
|
return '';
|
|
1352
2145
|
return [
|
|
1353
|
-
'
|
|
2146
|
+
'**Blast radius — what depends on these (update/verify before editing)**',
|
|
1354
2147
|
'',
|
|
1355
2148
|
...entries,
|
|
1356
2149
|
'',
|
|
@@ -1361,7 +2154,7 @@ class ToolHandler {
|
|
|
1361
2154
|
* PageRank) from the query's matched SEED nodes over the call/reference graph.
|
|
1362
2155
|
*
|
|
1363
2156
|
* This is the ranking signal text search (FTS/bm25) CANNOT provide, and it's
|
|
1364
|
-
*
|
|
2157
|
+
* homegraph's home turf: relevance by STRUCTURE, not words. A file whose
|
|
1365
2158
|
* symbols are call-connected to the matched cluster accrues walk mass and
|
|
1366
2159
|
* ranks high; a lone TEXT match — e.g. `LensSwitcher.swift` matched the word
|
|
1367
2160
|
* "switch" from `switchOrganization`, but calls none of `setUser`/`fetchUser`
|
|
@@ -1453,7 +2246,7 @@ class ToolHandler {
|
|
|
1453
2246
|
const query = this.validateString(args.query, 'query');
|
|
1454
2247
|
if (typeof query !== 'string')
|
|
1455
2248
|
return query;
|
|
1456
|
-
const cg = this.
|
|
2249
|
+
const cg = this.getHomeGraph(args.projectPath);
|
|
1457
2250
|
const projectRoot = cg.getProjectRoot();
|
|
1458
2251
|
// Resolve adaptive output budget from project size. Falls back to the
|
|
1459
2252
|
// largest-tier defaults if stats aren't available, which preserves
|
|
@@ -1527,7 +2320,7 @@ class ToolHandler {
|
|
|
1527
2320
|
// agent explicitly named is in the subgraph and its file is scored.
|
|
1528
2321
|
const namedSeedIds = new Set();
|
|
1529
2322
|
{
|
|
1530
|
-
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;
|
|
2323
|
+
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|astro)$/i;
|
|
1531
2324
|
const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
|
|
1532
2325
|
const isTestPath = (p) => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
|
|
1533
2326
|
const bodyLines = (n) => Math.max(0, (n.endLine ?? n.startLine) - n.startLine);
|
|
@@ -1538,8 +2331,12 @@ class ToolHandler {
|
|
|
1538
2331
|
// agent writes "DataRequest task validate", the `task`/`validate` it wants
|
|
1539
2332
|
// are DataRequest's, NOT the same-named overloads in Validation.swift /
|
|
1540
2333
|
// Concurrency.swift / the abstract base. Used below to bias overloaded
|
|
1541
|
-
// names toward the file/class the query also names.
|
|
1542
|
-
|
|
2334
|
+
// names toward the file/class the query also names. EXCLUDE the project
|
|
2335
|
+
// name (a PascalCase token a user naturally includes) — it names the whole
|
|
2336
|
+
// repo, so biasing toward it just pulls overloads to whichever stack
|
|
2337
|
+
// embeds it, re-burying the rest (#720).
|
|
2338
|
+
const projectNameTokens = cg.getProjectNameTokens();
|
|
2339
|
+
const typeTokens = tokens.filter((o) => /^[A-Z][A-Za-z0-9]{3,}/.test(o) && !projectNameTokens.has((0, query_utils_1.normalizeNameToken)(o)));
|
|
1543
2340
|
const inNamedContext = (n) => typeTokens.some((ct) => {
|
|
1544
2341
|
const lc = ct.toLowerCase();
|
|
1545
2342
|
return n.filePath.toLowerCase().includes(lc) || n.qualifiedName.toLowerCase().includes(lc);
|
|
@@ -1596,6 +2393,12 @@ class ToolHandler {
|
|
|
1596
2393
|
// Skip import/export nodes — they add noise without information
|
|
1597
2394
|
if (node.kind === 'import' || node.kind === 'export')
|
|
1598
2395
|
continue;
|
|
2396
|
+
// SECURITY (#383): never render the on-disk source of a config-leaf
|
|
2397
|
+
// (Spring application.{yml,properties} key) — its line is `key = <secret>`,
|
|
2398
|
+
// so whole-file/cluster rendering here would push secrets into context
|
|
2399
|
+
// unbidden. The key still appears in the flow/symbol listing above.
|
|
2400
|
+
if ((0, utils_1.isConfigLeafNode)(node))
|
|
2401
|
+
continue;
|
|
1599
2402
|
const group = fileGroups.get(node.filePath) || { nodes: [], score: 0 };
|
|
1600
2403
|
group.nodes.push(node);
|
|
1601
2404
|
// Score: a NAMED-SEED node (a symbol the agent named that FTS missed, now
|
|
@@ -1740,6 +2543,25 @@ class ToolHandler {
|
|
|
1740
2543
|
if (n)
|
|
1741
2544
|
namedSeedFiles.add(n.filePath);
|
|
1742
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 HOMEGRAPH_RANK_NO_MULTITERM=1
|
|
2560
|
+
// to disable.
|
|
2561
|
+
const MULTITERM_OFF = process.env.HOMEGRAPH_RANK_NO_MULTITERM === '1';
|
|
2562
|
+
const isCorroborated = (fp) => !MULTITERM_OFF &&
|
|
2563
|
+
(fileTermHits.get(fp) ?? 0) >= 2 &&
|
|
2564
|
+
(entryFiles.has(fp) || centralFiles.has(fp));
|
|
1743
2565
|
const sortedFiles = relevantFiles.sort((a, b) => {
|
|
1744
2566
|
const aPath = a[0].toLowerCase();
|
|
1745
2567
|
const bPath = b[0].toLowerCase();
|
|
@@ -1748,6 +2570,11 @@ class ToolHandler {
|
|
|
1748
2570
|
const bNamed = namedSeedFiles.has(b[0]) ? 1 : 0;
|
|
1749
2571
|
if (aNamed !== bNamed)
|
|
1750
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;
|
|
1751
2578
|
// Graph connectivity is the next key (small epsilon so near-ties fall
|
|
1752
2579
|
// through to the text signal rather than coin-flipping on float noise).
|
|
1753
2580
|
const aG = fileGraphScore.get(a[0]) ?? 0;
|
|
@@ -1778,7 +2605,7 @@ class ToolHandler {
|
|
|
1778
2605
|
});
|
|
1779
2606
|
// Step 3: Build relationship map
|
|
1780
2607
|
const lines = [
|
|
1781
|
-
|
|
2608
|
+
`**Exploration: ${query}**`,
|
|
1782
2609
|
'',
|
|
1783
2610
|
`Found ${subgraph.nodes.size} symbols across ${fileGroups.size} files.`,
|
|
1784
2611
|
'',
|
|
@@ -1793,7 +2620,7 @@ class ToolHandler {
|
|
|
1793
2620
|
const significantEdges = subgraph.edges.filter(e => e.kind !== 'contains' // skip contains — it's implied by file grouping
|
|
1794
2621
|
);
|
|
1795
2622
|
if (budget.includeRelationships && significantEdges.length > 0) {
|
|
1796
|
-
lines.push('
|
|
2623
|
+
lines.push('**Relationships**');
|
|
1797
2624
|
lines.push('');
|
|
1798
2625
|
// Group edges by kind for readability
|
|
1799
2626
|
const byKind = new Map();
|
|
@@ -1878,7 +2705,7 @@ class ToolHandler {
|
|
|
1878
2705
|
}
|
|
1879
2706
|
return false;
|
|
1880
2707
|
};
|
|
1881
|
-
lines.push('
|
|
2708
|
+
lines.push('**Source Code**');
|
|
1882
2709
|
lines.push('');
|
|
1883
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.');
|
|
1884
2711
|
lines.push('');
|
|
@@ -2033,7 +2860,7 @@ class ToolHandler {
|
|
|
2033
2860
|
const tag = bodyIds.size > 0
|
|
2034
2861
|
? 'focused (the methods you named in full, the rest as signatures — homegraph_explore a signature by name for its body; do NOT Read)'
|
|
2035
2862
|
: 'skeleton (signatures only — homegraph_explore a name for its full body; do NOT Read)';
|
|
2036
|
-
lines.push(
|
|
2863
|
+
lines.push(fileSectionHeader(filePath, `${names} · ${tag}`), '', '```' + lang, skel.join('\n'), '```', '');
|
|
2037
2864
|
totalChars += skel.join('\n').length + 120;
|
|
2038
2865
|
filesIncluded++;
|
|
2039
2866
|
continue;
|
|
@@ -2071,7 +2898,7 @@ class ToolHandler {
|
|
|
2071
2898
|
.map(n => `${n.name}(${n.kind})`))];
|
|
2072
2899
|
const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
|
|
2073
2900
|
const omitted = uniqSymbols.length - headerNames.length;
|
|
2074
|
-
const wholeHeader =
|
|
2901
|
+
const wholeHeader = fileSectionHeader(filePath, omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', '));
|
|
2075
2902
|
if (!fileNecessary && totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
|
|
2076
2903
|
// Don't slice a whole file mid-method: an incidental file that doesn't
|
|
2077
2904
|
// fit is skipped; a necessary one (below) renders in full. Half a file
|
|
@@ -2135,7 +2962,12 @@ class ToolHandler {
|
|
|
2135
2962
|
importance = 6; // bridging caller/callee of an entry
|
|
2136
2963
|
else if (connectedToEntry.has(n.id))
|
|
2137
2964
|
importance = 3;
|
|
2138
|
-
|
|
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) };
|
|
2139
2971
|
});
|
|
2140
2972
|
// Add edge source locations in this file — captures template references
|
|
2141
2973
|
// (component usages, event handlers) that aren't nodes themselves.
|
|
@@ -2154,7 +2986,7 @@ class ToolHandler {
|
|
|
2154
2986
|
// Look up target name from subgraph first, fall back to edge kind
|
|
2155
2987
|
const targetNode = subgraph.nodes.get(edge.target);
|
|
2156
2988
|
const targetName = targetNode?.name ?? edge.kind;
|
|
2157
|
-
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 });
|
|
2158
2990
|
}
|
|
2159
2991
|
}
|
|
2160
2992
|
ranges.sort((a, b) => a.start - b.start);
|
|
@@ -2168,6 +3000,8 @@ class ToolHandler {
|
|
|
2168
3000
|
symbols: [`${ranges[0].name}(${ranges[0].kind})`],
|
|
2169
3001
|
score: ranges[0].importance,
|
|
2170
3002
|
maxImportance: ranges[0].importance,
|
|
3003
|
+
hasSpine: ranges[0].spine,
|
|
3004
|
+
spineCallLine: ranges[0].spineCallLine,
|
|
2171
3005
|
};
|
|
2172
3006
|
for (let i = 1; i < ranges.length; i++) {
|
|
2173
3007
|
const r = ranges[i];
|
|
@@ -2176,6 +3010,8 @@ class ToolHandler {
|
|
|
2176
3010
|
current.symbols.push(`${r.name}(${r.kind})`);
|
|
2177
3011
|
current.score += r.importance;
|
|
2178
3012
|
current.maxImportance = Math.max(current.maxImportance, r.importance);
|
|
3013
|
+
current.hasSpine = current.hasSpine || r.spine;
|
|
3014
|
+
current.spineCallLine = current.spineCallLine ?? r.spineCallLine;
|
|
2179
3015
|
}
|
|
2180
3016
|
else {
|
|
2181
3017
|
clusters.push(current);
|
|
@@ -2185,6 +3021,8 @@ class ToolHandler {
|
|
|
2185
3021
|
symbols: [`${r.name}(${r.kind})`],
|
|
2186
3022
|
score: r.importance,
|
|
2187
3023
|
maxImportance: r.importance,
|
|
3024
|
+
hasSpine: r.spine,
|
|
3025
|
+
spineCallLine: r.spineCallLine,
|
|
2188
3026
|
};
|
|
2189
3027
|
}
|
|
2190
3028
|
}
|
|
@@ -2198,16 +3036,40 @@ class ToolHandler {
|
|
|
2198
3036
|
// get tail-trimmed with a marker.
|
|
2199
3037
|
const contextPadding = 3;
|
|
2200
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
|
|
2201
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
|
+
}
|
|
2202
3067
|
const startIdx = Math.max(0, c.start - 1 - contextPadding);
|
|
2203
3068
|
const endIdx = Math.min(fileLines.length, c.end + contextPadding);
|
|
2204
3069
|
const slice = fileLines.slice(startIdx, endIdx).join('\n');
|
|
2205
3070
|
// startIdx is 0-based, so the slice's first line is line startIdx + 1.
|
|
2206
3071
|
return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice;
|
|
2207
3072
|
};
|
|
2208
|
-
// Language-neutral separator (no `//` — not a comment in Python, Ruby,
|
|
2209
|
-
// etc.). With line numbers on, the line-number jump also signals the gap.
|
|
2210
|
-
const GAP_MARKER = '\n\n... (gap) ...\n\n';
|
|
2211
3073
|
// Rank clusters for inclusion under the per-file cap. Entry-point
|
|
2212
3074
|
// clusters come first: a cluster containing a query entry point
|
|
2213
3075
|
// (importance 10) must outrank a dense block of mere declarations,
|
|
@@ -2221,6 +3083,12 @@ class ToolHandler {
|
|
|
2221
3083
|
const rankedClusters = clusters
|
|
2222
3084
|
.map((c, i) => ({ idx: i, span: c.end - c.start + 1, c }))
|
|
2223
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);
|
|
2224
3092
|
if (b.c.maxImportance !== a.c.maxImportance)
|
|
2225
3093
|
return b.c.maxImportance - a.c.maxImportance;
|
|
2226
3094
|
const densityA = a.c.score / a.span;
|
|
@@ -2238,6 +3106,11 @@ class ToolHandler {
|
|
|
2238
3106
|
// That source-order slice is what cut Django's `_fetch_all` (L2237, importance
|
|
2239
3107
|
// 9 — agent-named) when query.py was the last of four big files to be emitted.
|
|
2240
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));
|
|
2241
3114
|
const chosenIndices = new Set();
|
|
2242
3115
|
let projectedChars = 0;
|
|
2243
3116
|
for (const rc of rankedClusters) {
|
|
@@ -2250,7 +3123,12 @@ class ToolHandler {
|
|
|
2250
3123
|
projectedChars += sectionLen;
|
|
2251
3124
|
continue;
|
|
2252
3125
|
}
|
|
2253
|
-
|
|
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)
|
|
2254
3132
|
continue;
|
|
2255
3133
|
chosenIndices.add(rc.idx);
|
|
2256
3134
|
projectedChars += sectionLen;
|
|
@@ -2293,7 +3171,7 @@ class ToolHandler {
|
|
|
2293
3171
|
const headerSuffix = omittedCount > 0
|
|
2294
3172
|
? `${headerSymbols.join(', ')}, +${omittedCount} more`
|
|
2295
3173
|
: headerSymbols.join(', ');
|
|
2296
|
-
const fileHeader =
|
|
3174
|
+
const fileHeader = fileSectionHeader(filePath, headerSuffix);
|
|
2297
3175
|
// The total cap bounds INCIDENTAL files only. A file that DEFINES a symbol
|
|
2298
3176
|
// the agent named (or that's on the flow spine) renders even when the
|
|
2299
3177
|
// nominal total is used up — it's the answer, and the set is bounded by
|
|
@@ -2330,7 +3208,7 @@ class ToolHandler {
|
|
|
2330
3208
|
.sort((a, b) => b[1].score - a[1].score);
|
|
2331
3209
|
const remainingFiles = [...remainingRelevant, ...peripheralFiles];
|
|
2332
3210
|
if (remainingFiles.length > 0) {
|
|
2333
|
-
lines.push('
|
|
3211
|
+
lines.push('**Not shown above — explore these names for their source**');
|
|
2334
3212
|
lines.push('');
|
|
2335
3213
|
for (const [filePath, group] of remainingFiles.slice(0, 10)) {
|
|
2336
3214
|
const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
|
|
@@ -2377,13 +3255,13 @@ class ToolHandler {
|
|
|
2377
3255
|
const output = flow.text + lines.join('\n');
|
|
2378
3256
|
const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
|
|
2379
3257
|
if (output.length > hardCeiling) {
|
|
2380
|
-
// Cut at a FILE-SECTION boundary (the last
|
|
3258
|
+
// Cut at a FILE-SECTION boundary (the last ``**` `` file header before the
|
|
2381
3259
|
// ceiling) so we drop whole trailing file-sections rather than slicing
|
|
2382
3260
|
// through a method body — a half-rendered method just forces the Read this
|
|
2383
3261
|
// tool exists to prevent. Fall back to a line boundary only if no section
|
|
2384
3262
|
// header sits in the back half (degenerate single-giant-section case).
|
|
2385
3263
|
const cut = output.slice(0, hardCeiling);
|
|
2386
|
-
const lastSection = cut.lastIndexOf('\n
|
|
3264
|
+
const lastSection = cut.lastIndexOf('\n' + FILE_SECTION_PREFIX);
|
|
2387
3265
|
const boundary = lastSection > hardCeiling * 0.5 ? lastSection : cut.lastIndexOf('\n');
|
|
2388
3266
|
const safe = boundary > 0 ? cut.slice(0, boundary) : cut;
|
|
2389
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 homegraph_explore with the specific names — do NOT Read these files.)');
|
|
@@ -2394,14 +3272,26 @@ class ToolHandler {
|
|
|
2394
3272
|
* Handle homegraph_node
|
|
2395
3273
|
*/
|
|
2396
3274
|
async handleNode(args) {
|
|
2397
|
-
const
|
|
2398
|
-
if (typeof symbol !== 'string')
|
|
2399
|
-
return symbol;
|
|
2400
|
-
const cg = this.getCodeGraph(args.projectPath);
|
|
3275
|
+
const cg = this.getHomeGraph(args.projectPath);
|
|
2401
3276
|
// Default to false to minimize context usage
|
|
2402
3277
|
const includeCode = args.includeCode === true;
|
|
2403
3278
|
const fileHint = typeof args.file === 'string' && args.file.trim() ? args.file.trim() : undefined;
|
|
2404
3279
|
const lineHint = typeof args.line === 'number' && args.line > 0 ? args.line : undefined;
|
|
3280
|
+
const offset = typeof args.offset === 'number' && args.offset > 0 ? Math.floor(args.offset) : undefined;
|
|
3281
|
+
const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.floor(args.limit) : undefined;
|
|
3282
|
+
const symbolsOnly = args.symbolsOnly === true;
|
|
3283
|
+
const symbolRaw = typeof args.symbol === 'string' ? args.symbol.trim() : '';
|
|
3284
|
+
// FILE READ MODE: a `file` with no `symbol` reads that file like the Read
|
|
3285
|
+
// tool — its current on-disk source with line numbers, narrowable with
|
|
3286
|
+
// `offset`/`limit` exactly as Read does — PLUS a one-line blast-radius
|
|
3287
|
+
// header (which files depend on it). `symbolsOnly` returns just the
|
|
3288
|
+
// structural map instead. Backed by the index: same bytes Read gives you.
|
|
3289
|
+
if (!symbolRaw && fileHint) {
|
|
3290
|
+
return this.handleFileView(cg, fileHint, { offset, limit, symbolsOnly });
|
|
3291
|
+
}
|
|
3292
|
+
const symbol = this.validateString(args.symbol, 'symbol');
|
|
3293
|
+
if (typeof symbol !== 'string')
|
|
3294
|
+
return symbol;
|
|
2405
3295
|
let matches = this.findSymbolMatches(cg, symbol);
|
|
2406
3296
|
if (matches.length === 0) {
|
|
2407
3297
|
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
|
@@ -2480,13 +3370,147 @@ class ToolHandler {
|
|
|
2480
3370
|
if (listed.length) {
|
|
2481
3371
|
const LIST_CAP = 20;
|
|
2482
3372
|
const shownList = listed.slice(0, LIST_CAP);
|
|
2483
|
-
out.push('', '
|
|
3373
|
+
out.push('', '**Other definitions**', ...shownList.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`));
|
|
2484
3374
|
if (listed.length > LIST_CAP)
|
|
2485
3375
|
out.push(`- … +${listed.length - LIST_CAP} more`);
|
|
2486
3376
|
out.push('', `> Need one of these in full? Call homegraph_node again with \`file\` (e.g. \`"${listed[0].filePath.split('/').pop()}"\`) or \`line\` — do NOT Read it.`);
|
|
2487
3377
|
}
|
|
2488
3378
|
return this.textResult(this.truncateOutput(out.join('\n')));
|
|
2489
3379
|
}
|
|
3380
|
+
/**
|
|
3381
|
+
* FILE READ MODE: resolve `fileArg` (path or basename) to an indexed file and
|
|
3382
|
+
* read it like the Read tool — its current on-disk source with line numbers,
|
|
3383
|
+
* narrowable with `offset`/`limit` exactly as Read's are — preceded by a
|
|
3384
|
+
* one-line blast-radius header (which files depend on it). `symbolsOnly`
|
|
3385
|
+
* returns just the structural map (symbols + dependents) instead of source.
|
|
3386
|
+
*
|
|
3387
|
+
* Parity goal: the numbered source block is byte-for-byte the shape Read
|
|
3388
|
+
* returns (`<n>\t<line>`, no padding), so the agent treats it as a Read — only
|
|
3389
|
+
* faster (served from the index) and with the blast radius attached. Security:
|
|
3390
|
+
* yaml/properties files are summarized by key, never dumped (#383); reads go
|
|
3391
|
+
* through validatePathWithinRoot (#527).
|
|
3392
|
+
*/
|
|
3393
|
+
async handleFileView(cg, fileArg, opts = {}) {
|
|
3394
|
+
const normalize = (p) => p.replace(/\\/g, '/').replace(/^(?:\.?\/+)+/, '').replace(/\/+$/, '');
|
|
3395
|
+
const wantLower = normalize(fileArg).toLowerCase();
|
|
3396
|
+
const allFiles = cg.getFiles();
|
|
3397
|
+
if (allFiles.length === 0)
|
|
3398
|
+
return this.textResult('No files indexed. Run `homegraph index` first.');
|
|
3399
|
+
let resolved = allFiles.find((f) => f.path.toLowerCase() === wantLower);
|
|
3400
|
+
let candidates = [];
|
|
3401
|
+
if (!resolved) {
|
|
3402
|
+
candidates = allFiles.filter((f) => f.path.toLowerCase().endsWith('/' + wantLower));
|
|
3403
|
+
if (candidates.length === 1)
|
|
3404
|
+
resolved = candidates[0];
|
|
3405
|
+
}
|
|
3406
|
+
if (!resolved && candidates.length === 0) {
|
|
3407
|
+
candidates = allFiles.filter((f) => f.path.toLowerCase().includes(wantLower));
|
|
3408
|
+
if (candidates.length === 1)
|
|
3409
|
+
resolved = candidates[0];
|
|
3410
|
+
}
|
|
3411
|
+
if (!resolved && candidates.length > 1) {
|
|
3412
|
+
return this.textResult([`"${fileArg}" matches ${candidates.length} indexed files — pass a longer path:`, '',
|
|
3413
|
+
...candidates.slice(0, 25).map((f) => `- ${f.path}`)].join('\n'));
|
|
3414
|
+
}
|
|
3415
|
+
if (!resolved) {
|
|
3416
|
+
return this.textResult(`No indexed file matches "${fileArg}". Codegraph indexes source files; configs/docs it doesn't parse won't appear — Read those directly.`);
|
|
3417
|
+
}
|
|
3418
|
+
const filePath = resolved.path;
|
|
3419
|
+
const nodes = cg.getNodesInFile(filePath)
|
|
3420
|
+
.filter((n) => n.kind !== 'file' && n.kind !== 'import' && n.kind !== 'export')
|
|
3421
|
+
.sort((a, b) => a.startLine - b.startLine);
|
|
3422
|
+
const dependents = cg.getFileDependents(filePath);
|
|
3423
|
+
// Compact, one-line blast radius (homegraph's value-add over a plain Read).
|
|
3424
|
+
const depSummary = dependents.length
|
|
3425
|
+
? `used by ${dependents.length} file${dependents.length === 1 ? '' : 's'}: ${dependents.slice(0, 8).join(', ')}${dependents.length > 8 ? `, +${dependents.length - 8} more` : ''}`
|
|
3426
|
+
: 'no other indexed file depends on it';
|
|
3427
|
+
// Symbol-map renderer — for symbolsOnly, the config fallback, and read errors.
|
|
3428
|
+
const symbolMap = (heading, limit = 200) => {
|
|
3429
|
+
const lines = [heading];
|
|
3430
|
+
for (const n of nodes.slice(0, limit)) {
|
|
3431
|
+
const sig = n.signature ? ` ${n.signature.replace(/\s+/g, ' ').trim()}` : '';
|
|
3432
|
+
lines.push(`- \`${n.name}\` (${n.kind})${sig} — :${n.startLine}`);
|
|
3433
|
+
}
|
|
3434
|
+
if (nodes.length > limit)
|
|
3435
|
+
lines.push(`- … +${nodes.length - limit} more`);
|
|
3436
|
+
return lines;
|
|
3437
|
+
};
|
|
3438
|
+
// symbolsOnly → the cheap structural overview, no source.
|
|
3439
|
+
if (opts.symbolsOnly) {
|
|
3440
|
+
const out = [`**${filePath}** — ${nodes.length} symbol${nodes.length === 1 ? '' : 's'}, ${depSummary}`, ''];
|
|
3441
|
+
if (nodes.length)
|
|
3442
|
+
out.push(...symbolMap('**Symbols**'));
|
|
3443
|
+
else
|
|
3444
|
+
out.push('_No indexed symbols in this file._');
|
|
3445
|
+
out.push('', '> Drop `symbolsOnly` (or pass `offset`/`limit`) to read the source, like Read.');
|
|
3446
|
+
return this.textResult(this.truncateOutput(out.join('\n')));
|
|
3447
|
+
}
|
|
3448
|
+
// SECURITY (#383): never dump a raw config/data file — a yaml/properties
|
|
3449
|
+
// line is `key: <secret>`. Summarize by key and point to a real Read.
|
|
3450
|
+
if (utils_1.CONFIG_LEAF_LANGUAGES.has(resolved.language)) {
|
|
3451
|
+
const out = [`**${filePath}** — configuration/data file, ${depSummary}`, ''];
|
|
3452
|
+
if (nodes.length)
|
|
3453
|
+
out.push(...symbolMap('**Keys (values withheld for safety)**'));
|
|
3454
|
+
out.push('', '> Values may be secrets, so homegraph indexes keys only. Read the file directly if you need a value.');
|
|
3455
|
+
return this.textResult(this.truncateOutput(out.join('\n')));
|
|
3456
|
+
}
|
|
3457
|
+
// Read the current bytes from disk through the security chokepoint
|
|
3458
|
+
// (validatePathWithinRoot: blocks `../` traversal and symlink escapes, #527).
|
|
3459
|
+
const abs = (0, utils_1.validatePathWithinRoot)(cg.getProjectRoot(), filePath);
|
|
3460
|
+
let content = null;
|
|
3461
|
+
if (abs) {
|
|
3462
|
+
try {
|
|
3463
|
+
content = (0, fs_1.readFileSync)(abs, 'utf-8');
|
|
3464
|
+
}
|
|
3465
|
+
catch {
|
|
3466
|
+
content = null;
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
if (content === null) {
|
|
3470
|
+
const out = [`**${filePath}** — could not read from disk (it may have moved since indexing). ${depSummary}`, ''];
|
|
3471
|
+
if (nodes.length)
|
|
3472
|
+
out.push(...symbolMap('**Symbols**'));
|
|
3473
|
+
out.push('', `> Read \`${filePath}\` directly for its current content.`);
|
|
3474
|
+
return this.textResult(this.truncateOutput(out.join('\n')));
|
|
3475
|
+
}
|
|
3476
|
+
// Split exactly as Read does — keep the trailing empty line a final newline
|
|
3477
|
+
// produces (Read numbers it too), so line numbers line up byte-for-byte.
|
|
3478
|
+
const fileLines = content.split('\n');
|
|
3479
|
+
const total = fileLines.length;
|
|
3480
|
+
// Read-parity windowing: `offset`/`limit` mean exactly what they do on Read
|
|
3481
|
+
// (1-based start line; max line count). Default: the whole file, capped like
|
|
3482
|
+
// Read at 2000 lines and bounded by a char budget that tracks explore's
|
|
3483
|
+
// proven-safe ~38k response ceiling. Overflow is stated explicitly (Read
|
|
3484
|
+
// paginates too) — never the silent 15k truncateOutput chop.
|
|
3485
|
+
const CHAR_BUDGET = 38000;
|
|
3486
|
+
const DEFAULT_LIMIT = 2000;
|
|
3487
|
+
const offset = Math.max(1, opts.offset ?? 1);
|
|
3488
|
+
if (offset > total) {
|
|
3489
|
+
return this.textResult(`**${filePath}** has ${total} line${total === 1 ? '' : 's'} — offset ${offset} is past the end. ${depSummary}`);
|
|
3490
|
+
}
|
|
3491
|
+
const maxLines = Math.max(1, opts.limit ?? DEFAULT_LIMIT);
|
|
3492
|
+
const start = offset - 1; // 0-based
|
|
3493
|
+
const header = `**${filePath}** — ${total} lines, ${nodes.length} symbol${nodes.length === 1 ? '' : 's'} · ${depSummary}`;
|
|
3494
|
+
// Numbered lines, byte-for-byte Read's shape: `<n>\t<line>`, no left-pad.
|
|
3495
|
+
const numbered = [];
|
|
3496
|
+
let used = header.length + 8;
|
|
3497
|
+
let i = start;
|
|
3498
|
+
for (; i < total && numbered.length < maxLines; i++) {
|
|
3499
|
+
const ln = `${i + 1}\t${fileLines[i]}`;
|
|
3500
|
+
if (used + ln.length + 1 > CHAR_BUDGET && numbered.length > 0)
|
|
3501
|
+
break;
|
|
3502
|
+
numbered.push(ln);
|
|
3503
|
+
used += ln.length + 1;
|
|
3504
|
+
}
|
|
3505
|
+
const shownEnd = start + numbered.length;
|
|
3506
|
+
const complete = offset === 1 && shownEnd >= total;
|
|
3507
|
+
const out = [header, '', ...numbered];
|
|
3508
|
+
if (!complete) {
|
|
3509
|
+
out.push('', `(lines ${offset}–${shownEnd} of ${total} — pass \`offset\`/\`limit\` for another range, or \`homegraph_node <symbol>\` for one symbol in full)`);
|
|
3510
|
+
}
|
|
3511
|
+
// Self-bounded to CHAR_BUDGET — do NOT route through truncateOutput (15k).
|
|
3512
|
+
return this.textResult(out.join('\n'));
|
|
3513
|
+
}
|
|
2490
3514
|
/** Render one symbol: details + (optional) body/outline + its caller/callee trail. */
|
|
2491
3515
|
async renderNodeSection(cg, node, includeCode) {
|
|
2492
3516
|
let code = null;
|
|
@@ -2536,7 +3560,7 @@ class ToolHandler {
|
|
|
2536
3560
|
const callers = collect(cg.getCallers(node.id));
|
|
2537
3561
|
if (callees.length === 0 && callers.length === 0)
|
|
2538
3562
|
return '';
|
|
2539
|
-
const lines = ['', '
|
|
3563
|
+
const lines = ['', '**Trail — homegraph_node any of these to follow it (no Read needed)**'];
|
|
2540
3564
|
if (callees.length > 0) {
|
|
2541
3565
|
lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`);
|
|
2542
3566
|
}
|
|
@@ -2549,7 +3573,7 @@ class ToolHandler {
|
|
|
2549
3573
|
* Handle homegraph_status
|
|
2550
3574
|
*/
|
|
2551
3575
|
async handleStatus(args) {
|
|
2552
|
-
let cg = this.
|
|
3576
|
+
let cg = this.getHomeGraph(args.projectPath);
|
|
2553
3577
|
// Same trick as withStalenessNotice — when an explicit projectPath
|
|
2554
3578
|
// resolves to the same project as the default session cg, prefer the
|
|
2555
3579
|
// default so getPendingFiles() (only populated by the default's watcher)
|
|
@@ -2570,7 +3594,7 @@ class ToolHandler {
|
|
|
2570
3594
|
// one-liner via withWorktreeNotice. Both share the cached detection.
|
|
2571
3595
|
const mismatch = this.worktreeMismatchFor(args.projectPath);
|
|
2572
3596
|
const lines = [
|
|
2573
|
-
'
|
|
3597
|
+
'**HomeGraph Status**',
|
|
2574
3598
|
'',
|
|
2575
3599
|
];
|
|
2576
3600
|
if (mismatch) {
|
|
@@ -2592,25 +3616,32 @@ class ToolHandler {
|
|
|
2592
3616
|
lines.push(`**Journal mode:** ⚠ ${journalMode || 'unknown'} — WAL not active, so reads ` +
|
|
2593
3617
|
`can block on a concurrent write (WAL appears unsupported on this filesystem)`);
|
|
2594
3618
|
}
|
|
2595
|
-
lines.push('', '
|
|
3619
|
+
lines.push('', '**Nodes by Kind:**');
|
|
2596
3620
|
for (const [kind, count] of Object.entries(stats.nodesByKind)) {
|
|
2597
3621
|
if (count > 0) {
|
|
2598
3622
|
lines.push(`- ${kind}: ${count}`);
|
|
2599
3623
|
}
|
|
2600
3624
|
}
|
|
2601
|
-
lines.push('', '
|
|
3625
|
+
lines.push('', '**Languages:**');
|
|
2602
3626
|
for (const [lang, count] of Object.entries(stats.filesByLanguage)) {
|
|
2603
3627
|
if (count > 0) {
|
|
2604
3628
|
lines.push(`- ${lang}: ${count}`);
|
|
2605
3629
|
}
|
|
2606
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
|
+
}
|
|
2607
3638
|
// Per-file freshness — the inverse of the auto-prepended staleness banner
|
|
2608
3639
|
// (issue #403). Surfacing it inside `status` gives the agent a single
|
|
2609
3640
|
// place to ask "is the index caught up?" rather than inferring from
|
|
2610
3641
|
// banners on other tool calls.
|
|
2611
3642
|
const pending = cg.getPendingFiles();
|
|
2612
3643
|
if (pending.length > 0) {
|
|
2613
|
-
lines.push('', '
|
|
3644
|
+
lines.push('', '**Pending sync:**');
|
|
2614
3645
|
const now = Date.now();
|
|
2615
3646
|
for (const p of pending) {
|
|
2616
3647
|
const ageMs = Math.max(0, now - p.lastSeenMs);
|
|
@@ -2624,7 +3655,7 @@ class ToolHandler {
|
|
|
2624
3655
|
* Handle homegraph_files - get project file structure from the index
|
|
2625
3656
|
*/
|
|
2626
3657
|
async handleFiles(args) {
|
|
2627
|
-
const cg = this.
|
|
3658
|
+
const cg = this.getHomeGraph(args.projectPath);
|
|
2628
3659
|
const pathFilter = args.path;
|
|
2629
3660
|
const pattern = args.pattern;
|
|
2630
3661
|
const format = args.format || 'tree';
|
|
@@ -2690,7 +3721,7 @@ class ToolHandler {
|
|
|
2690
3721
|
* Format files as a flat list
|
|
2691
3722
|
*/
|
|
2692
3723
|
formatFilesFlat(files, includeMetadata) {
|
|
2693
|
-
const lines = [
|
|
3724
|
+
const lines = [`**Files (${files.length})**`, ''];
|
|
2694
3725
|
for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
|
|
2695
3726
|
if (includeMetadata) {
|
|
2696
3727
|
lines.push(`- ${file.path} (${file.language}, ${file.nodeCount} symbols)`);
|
|
@@ -2711,11 +3742,11 @@ class ToolHandler {
|
|
|
2711
3742
|
existing.push(file);
|
|
2712
3743
|
byLang.set(file.language, existing);
|
|
2713
3744
|
}
|
|
2714
|
-
const lines = [
|
|
3745
|
+
const lines = [`**Files by Language (${files.length} total)**`, ''];
|
|
2715
3746
|
// Sort languages by file count (descending)
|
|
2716
3747
|
const sortedLangs = [...byLang.entries()].sort((a, b) => b[1].length - a[1].length);
|
|
2717
3748
|
for (const [lang, langFiles] of sortedLangs) {
|
|
2718
|
-
lines.push(
|
|
3749
|
+
lines.push(`**${lang} (${langFiles.length})**`);
|
|
2719
3750
|
for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) {
|
|
2720
3751
|
if (includeMetadata) {
|
|
2721
3752
|
lines.push(`- ${file.path} (${file.nodeCount} symbols)`);
|
|
@@ -2751,7 +3782,7 @@ class ToolHandler {
|
|
|
2751
3782
|
}
|
|
2752
3783
|
}
|
|
2753
3784
|
// Render tree
|
|
2754
|
-
const lines = [
|
|
3785
|
+
const lines = [`**Project Structure (${files.length} files)**`, ''];
|
|
2755
3786
|
const renderNode = (node, prefix, isLast, depth) => {
|
|
2756
3787
|
if (maxDepth !== undefined && depth > maxDepth)
|
|
2757
3788
|
return;
|
|
@@ -2942,12 +3973,12 @@ class ToolHandler {
|
|
|
2942
3973
|
// Formatting helpers (compact by default to reduce context usage)
|
|
2943
3974
|
// =========================================================================
|
|
2944
3975
|
formatSearchResults(results) {
|
|
2945
|
-
const lines = [
|
|
3976
|
+
const lines = [`**Search Results (${results.length} found)**`, ''];
|
|
2946
3977
|
for (const result of results) {
|
|
2947
3978
|
const { node } = result;
|
|
2948
3979
|
const location = node.startLine ? `:${node.startLine}` : '';
|
|
2949
3980
|
// Compact format: one line per result with key info
|
|
2950
|
-
lines.push(
|
|
3981
|
+
lines.push(`**${node.name}** (${node.kind})`);
|
|
2951
3982
|
lines.push(`${node.filePath}${location}`);
|
|
2952
3983
|
if (node.signature)
|
|
2953
3984
|
lines.push(`\`${node.signature}\``);
|
|
@@ -2955,20 +3986,41 @@ class ToolHandler {
|
|
|
2955
3986
|
}
|
|
2956
3987
|
return lines.join('\n');
|
|
2957
3988
|
}
|
|
2958
|
-
formatNodeList(nodes, title) {
|
|
2959
|
-
const lines = [
|
|
3989
|
+
formatNodeList(nodes, title, labels) {
|
|
3990
|
+
const lines = [`**${title} (${nodes.length} found)**`, ''];
|
|
2960
3991
|
for (const node of nodes) {
|
|
2961
3992
|
const location = node.startLine ? `:${node.startLine}` : '';
|
|
2962
|
-
// Compact: just name, kind, location
|
|
2963
|
-
|
|
3993
|
+
// Compact: just name, kind, location — plus the relationship when it
|
|
3994
|
+
// isn't a plain call (callback registration, instantiation, …).
|
|
3995
|
+
const label = labels?.get(node.id);
|
|
3996
|
+
lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
|
|
2964
3997
|
}
|
|
2965
3998
|
return lines.join('\n');
|
|
2966
3999
|
}
|
|
4000
|
+
/**
|
|
4001
|
+
* Relationship label for a non-`calls` edge in callers/callees lists. A
|
|
4002
|
+
* function-as-value edge (#756) is the high-signal one: `callers(cb)`
|
|
4003
|
+
* showing "via callback registration" tells the agent this is where the
|
|
4004
|
+
* callback is WIRED, not where it's invoked.
|
|
4005
|
+
*/
|
|
4006
|
+
edgeLabel(edge) {
|
|
4007
|
+
if (edge.kind === 'calls')
|
|
4008
|
+
return null;
|
|
4009
|
+
if (edge.metadata?.fnRef === true)
|
|
4010
|
+
return 'callback registration';
|
|
4011
|
+
if (edge.kind === 'instantiates')
|
|
4012
|
+
return 'instantiation';
|
|
4013
|
+
if (edge.kind === 'imports')
|
|
4014
|
+
return 'import';
|
|
4015
|
+
if (edge.kind === 'references')
|
|
4016
|
+
return 'reference';
|
|
4017
|
+
return edge.kind;
|
|
4018
|
+
}
|
|
2967
4019
|
formatImpact(symbol, impact) {
|
|
2968
4020
|
const nodeCount = impact.nodes.size;
|
|
2969
4021
|
// Compact format: just list affected symbols grouped by file
|
|
2970
4022
|
const lines = [
|
|
2971
|
-
|
|
4023
|
+
`**Impact: "${symbol}" affects ${nodeCount} symbols**`,
|
|
2972
4024
|
'',
|
|
2973
4025
|
];
|
|
2974
4026
|
// Group by file
|
|
@@ -3011,7 +4063,7 @@ class ToolHandler {
|
|
|
3011
4063
|
formatNodeDetails(node, code, outline) {
|
|
3012
4064
|
const location = node.startLine ? `:${node.startLine}` : '';
|
|
3013
4065
|
const lines = [
|
|
3014
|
-
|
|
4066
|
+
`**${node.name}** (${node.kind})`,
|
|
3015
4067
|
'',
|
|
3016
4068
|
`**Location:** ${node.filePath}${location}`,
|
|
3017
4069
|
];
|