trail-code 0.0.0-stage → 0.28.0-semantic.3
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 -0
- package/README.md +567 -2
- package/dist/ai/crux.d.ts +88 -0
- package/dist/ai/crux.d.ts.map +1 -0
- package/dist/ai/crux.js +219 -0
- package/dist/ai/crux.js.map +1 -0
- package/dist/ai/failure.d.ts +60 -0
- package/dist/ai/failure.d.ts.map +1 -0
- package/dist/ai/failure.js +86 -0
- package/dist/ai/failure.js.map +1 -0
- package/dist/ai/llm/anthropic.d.ts +37 -0
- package/dist/ai/llm/anthropic.d.ts.map +1 -0
- package/dist/ai/llm/anthropic.js +144 -0
- package/dist/ai/llm/anthropic.js.map +1 -0
- package/dist/ai/llm/factory.d.ts +28 -0
- package/dist/ai/llm/factory.d.ts.map +1 -0
- package/dist/ai/llm/factory.js +36 -0
- package/dist/ai/llm/factory.js.map +1 -0
- package/dist/ai/llm/litellm.d.ts +34 -0
- package/dist/ai/llm/litellm.d.ts.map +1 -0
- package/dist/ai/llm/litellm.js +45 -0
- package/dist/ai/llm/litellm.js.map +1 -0
- package/dist/ai/llm/openai.d.ts +145 -0
- package/dist/ai/llm/openai.d.ts.map +1 -0
- package/dist/ai/llm/openai.js +629 -0
- package/dist/ai/llm/openai.js.map +1 -0
- package/dist/ai/llm/orcarouter.d.ts +34 -0
- package/dist/ai/llm/orcarouter.d.ts.map +1 -0
- package/dist/ai/llm/orcarouter.js +45 -0
- package/dist/ai/llm/orcarouter.js.map +1 -0
- package/dist/ai/llm/recover-tool.d.ts +21 -0
- package/dist/ai/llm/recover-tool.d.ts.map +1 -0
- package/dist/ai/llm/recover-tool.js +109 -0
- package/dist/ai/llm/recover-tool.js.map +1 -0
- package/dist/ai/llm/types.d.ts +122 -0
- package/dist/ai/llm/types.d.ts.map +1 -0
- package/dist/ai/llm/types.js +33 -0
- package/dist/ai/llm/types.js.map +1 -0
- package/dist/ai/providers.d.ts +59 -0
- package/dist/ai/providers.d.ts.map +1 -0
- package/dist/ai/providers.js +54 -0
- package/dist/ai/providers.js.map +1 -0
- package/dist/ai/summarize.d.ts +25 -0
- package/dist/ai/summarize.d.ts.map +1 -0
- package/dist/ai/summarize.js +34 -0
- package/dist/ai/summarize.js.map +1 -0
- package/dist/ai/synthesize.d.ts +40 -0
- package/dist/ai/synthesize.d.ts.map +1 -0
- package/dist/ai/synthesize.js +230 -0
- package/dist/ai/synthesize.js.map +1 -0
- package/dist/ask/ask.d.ts +199 -0
- package/dist/ask/ask.d.ts.map +1 -0
- package/dist/ask/ask.js +1291 -0
- package/dist/ask/ask.js.map +1 -0
- package/dist/ask/file-rank.d.ts +72 -0
- package/dist/ask/file-rank.d.ts.map +1 -0
- package/dist/ask/file-rank.js +160 -0
- package/dist/ask/file-rank.js.map +1 -0
- package/dist/ask/file-selection.d.ts +22 -0
- package/dist/ask/file-selection.d.ts.map +1 -0
- package/dist/ask/file-selection.js +66 -0
- package/dist/ask/file-selection.js.map +1 -0
- package/dist/ask/fuse.d.ts +195 -0
- package/dist/ask/fuse.d.ts.map +1 -0
- package/dist/ask/fuse.js +344 -0
- package/dist/ask/fuse.js.map +1 -0
- package/dist/ask/graphrank.d.ts +61 -0
- package/dist/ask/graphrank.d.ts.map +1 -0
- package/dist/ask/graphrank.js +130 -0
- package/dist/ask/graphrank.js.map +1 -0
- package/dist/ask/index-file.d.ts +44 -0
- package/dist/ask/index-file.d.ts.map +1 -0
- package/dist/ask/index-file.js +128 -0
- package/dist/ask/index-file.js.map +1 -0
- package/dist/ask/semantic.d.ts +42 -0
- package/dist/ask/semantic.d.ts.map +1 -0
- package/dist/ask/semantic.js +293 -0
- package/dist/ask/semantic.js.map +1 -0
- package/dist/bin/graft.d.ts +3 -0
- package/dist/bin/graft.d.ts.map +1 -0
- package/dist/bin/graft.js +14 -0
- package/dist/bin/graft.js.map +1 -0
- package/dist/bin/trail.d.ts +3 -0
- package/dist/bin/trail.d.ts.map +1 -0
- package/dist/bin/trail.js +13 -0
- package/dist/bin/trail.js.map +1 -0
- package/dist/blast/blast-cli.d.ts +35 -0
- package/dist/blast/blast-cli.d.ts.map +1 -0
- package/dist/blast/blast-cli.js +155 -0
- package/dist/blast/blast-cli.js.map +1 -0
- package/dist/blast/blast.d.ts +128 -0
- package/dist/blast/blast.d.ts.map +1 -0
- package/dist/blast/blast.js +383 -0
- package/dist/blast/blast.js.map +1 -0
- package/dist/blast/diff.d.ts +53 -0
- package/dist/blast/diff.d.ts.map +1 -0
- package/dist/blast/diff.js +193 -0
- package/dist/blast/diff.js.map +1 -0
- package/dist/blast/evidence.d.ts +51 -0
- package/dist/blast/evidence.d.ts.map +1 -0
- package/dist/blast/evidence.js +162 -0
- package/dist/blast/evidence.js.map +1 -0
- package/dist/blast/modules.d.ts +33 -0
- package/dist/blast/modules.d.ts.map +1 -0
- package/dist/blast/modules.js +83 -0
- package/dist/blast/modules.js.map +1 -0
- package/dist/blast/name.d.ts +74 -0
- package/dist/blast/name.d.ts.map +1 -0
- package/dist/blast/name.js +269 -0
- package/dist/blast/name.js.map +1 -0
- package/dist/blast/owners.d.ts +85 -0
- package/dist/blast/owners.d.ts.map +1 -0
- package/dist/blast/owners.js +272 -0
- package/dist/blast/owners.js.map +1 -0
- package/dist/blast/render.d.ts +46 -0
- package/dist/blast/render.d.ts.map +1 -0
- package/dist/blast/render.js +422 -0
- package/dist/blast/render.js.map +1 -0
- package/dist/blast/viz.d.ts +37 -0
- package/dist/blast/viz.d.ts.map +1 -0
- package/dist/blast/viz.js +180 -0
- package/dist/blast/viz.js.map +1 -0
- package/dist/brand.d.ts +111 -0
- package/dist/brand.d.ts.map +1 -0
- package/dist/brand.js +282 -0
- package/dist/brand.js.map +1 -0
- package/dist/claude/build-progress.d.ts +30 -0
- package/dist/claude/build-progress.d.ts.map +1 -0
- package/dist/claude/build-progress.js +81 -0
- package/dist/claude/build-progress.js.map +1 -0
- package/dist/claude/hermes-skill-template.d.ts +16 -0
- package/dist/claude/hermes-skill-template.d.ts.map +1 -0
- package/dist/claude/hermes-skill-template.js +154 -0
- package/dist/claude/hermes-skill-template.js.map +1 -0
- package/dist/claude/init.d.ts +65 -0
- package/dist/claude/init.d.ts.map +1 -0
- package/dist/claude/init.js +258 -0
- package/dist/claude/init.js.map +1 -0
- package/dist/claude/paths.d.ts +9 -0
- package/dist/claude/paths.d.ts.map +1 -0
- package/dist/claude/paths.js +22 -0
- package/dist/claude/paths.js.map +1 -0
- package/dist/claude/session-metrics.d.ts +73 -0
- package/dist/claude/session-metrics.d.ts.map +1 -0
- package/dist/claude/session-metrics.js +209 -0
- package/dist/claude/session-metrics.js.map +1 -0
- package/dist/claude/settings-merge.d.ts +76 -0
- package/dist/claude/settings-merge.d.ts.map +1 -0
- package/dist/claude/settings-merge.js +283 -0
- package/dist/claude/settings-merge.js.map +1 -0
- package/dist/claude/skill-template.d.ts +13 -0
- package/dist/claude/skill-template.d.ts.map +1 -0
- package/dist/claude/skill-template.js +290 -0
- package/dist/claude/skill-template.js.map +1 -0
- package/dist/claude/state.d.ts +85 -0
- package/dist/claude/state.d.ts.map +1 -0
- package/dist/claude/state.js +88 -0
- package/dist/claude/state.js.map +1 -0
- package/dist/claude/stats.d.ts +5 -0
- package/dist/claude/stats.d.ts.map +1 -0
- package/dist/claude/stats.js +24 -0
- package/dist/claude/stats.js.map +1 -0
- package/dist/cli-epilogue.d.ts +12 -0
- package/dist/cli-epilogue.d.ts.map +1 -0
- package/dist/cli-epilogue.js +72 -0
- package/dist/cli-epilogue.js.map +1 -0
- package/dist/cli-meta.d.ts +82 -0
- package/dist/cli-meta.d.ts.map +1 -0
- package/dist/cli-meta.js +238 -0
- package/dist/cli-meta.js.map +1 -0
- package/dist/cli-picker.d.ts +139 -0
- package/dist/cli-picker.d.ts.map +1 -0
- package/dist/cli-picker.js +439 -0
- package/dist/cli-picker.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1638 -0
- package/dist/cli.js.map +1 -0
- package/dist/context/batches.d.ts +29 -0
- package/dist/context/batches.d.ts.map +1 -0
- package/dist/context/batches.js +130 -0
- package/dist/context/batches.js.map +1 -0
- package/dist/context/build.d.ts +57 -0
- package/dist/context/build.d.ts.map +1 -0
- package/dist/context/build.js +396 -0
- package/dist/context/build.js.map +1 -0
- package/dist/context/check.d.ts +42 -0
- package/dist/context/check.d.ts.map +1 -0
- package/dist/context/check.js +162 -0
- package/dist/context/check.js.map +1 -0
- package/dist/context/map-dir.d.ts +13 -0
- package/dist/context/map-dir.d.ts.map +1 -0
- package/dist/context/map-dir.js +81 -0
- package/dist/context/map-dir.js.map +1 -0
- package/dist/context/node-file.d.ts +109 -0
- package/dist/context/node-file.d.ts.map +1 -0
- package/dist/context/node-file.js +390 -0
- package/dist/context/node-file.js.map +1 -0
- package/dist/context/price.d.ts +49 -0
- package/dist/context/price.d.ts.map +1 -0
- package/dist/context/price.js +83 -0
- package/dist/context/price.js.map +1 -0
- package/dist/context/savings.d.ts +67 -0
- package/dist/context/savings.d.ts.map +1 -0
- package/dist/context/savings.js +125 -0
- package/dist/context/savings.js.map +1 -0
- package/dist/engine.d.ts +85 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +122 -0
- package/dist/engine.js.map +1 -0
- package/dist/graph/bindings.d.ts +64 -0
- package/dist/graph/bindings.d.ts.map +1 -0
- package/dist/graph/bindings.js +713 -0
- package/dist/graph/bindings.js.map +1 -0
- package/dist/graph/build-run.d.ts +25 -0
- package/dist/graph/build-run.d.ts.map +1 -0
- package/dist/graph/build-run.js +121 -0
- package/dist/graph/build-run.js.map +1 -0
- package/dist/graph/build.d.ts +88 -0
- package/dist/graph/build.d.ts.map +1 -0
- package/dist/graph/build.js +386 -0
- package/dist/graph/build.js.map +1 -0
- package/dist/graph/cards.d.ts +46 -0
- package/dist/graph/cards.d.ts.map +1 -0
- package/dist/graph/cards.js +280 -0
- package/dist/graph/cards.js.map +1 -0
- package/dist/graph/check.d.ts +25 -0
- package/dist/graph/check.d.ts.map +1 -0
- package/dist/graph/check.js +192 -0
- package/dist/graph/check.js.map +1 -0
- package/dist/graph/container.d.ts +63 -0
- package/dist/graph/container.d.ts.map +1 -0
- package/dist/graph/container.js +191 -0
- package/dist/graph/container.js.map +1 -0
- package/dist/graph/depth-parser.d.ts +61 -0
- package/dist/graph/depth-parser.d.ts.map +1 -0
- package/dist/graph/depth-parser.js +153 -0
- package/dist/graph/depth-parser.js.map +1 -0
- package/dist/graph/enrich.d.ts +62 -0
- package/dist/graph/enrich.d.ts.map +1 -0
- package/dist/graph/enrich.js +258 -0
- package/dist/graph/enrich.js.map +1 -0
- package/dist/graph/extract-cache.d.ts +101 -0
- package/dist/graph/extract-cache.d.ts.map +1 -0
- package/dist/graph/extract-cache.js +199 -0
- package/dist/graph/extract-cache.js.map +1 -0
- package/dist/graph/extract.d.ts +85 -0
- package/dist/graph/extract.d.ts.map +1 -0
- package/dist/graph/extract.js +2377 -0
- package/dist/graph/extract.js.map +1 -0
- package/dist/graph/fingerprint.d.ts +76 -0
- package/dist/graph/fingerprint.d.ts.map +1 -0
- package/dist/graph/fingerprint.js +160 -0
- package/dist/graph/fingerprint.js.map +1 -0
- package/dist/graph/generic.d.ts +74 -0
- package/dist/graph/generic.d.ts.map +1 -0
- package/dist/graph/generic.js +773 -0
- package/dist/graph/generic.js.map +1 -0
- package/dist/graph/invariants.d.ts +33 -0
- package/dist/graph/invariants.d.ts.map +1 -0
- package/dist/graph/invariants.js +52 -0
- package/dist/graph/invariants.js.map +1 -0
- package/dist/graph/load.d.ts +29 -0
- package/dist/graph/load.d.ts.map +1 -0
- package/dist/graph/load.js +97 -0
- package/dist/graph/load.js.map +1 -0
- package/dist/graph/lsp/client.d.ts +61 -0
- package/dist/graph/lsp/client.d.ts.map +1 -0
- package/dist/graph/lsp/client.js +217 -0
- package/dist/graph/lsp/client.js.map +1 -0
- package/dist/graph/lsp/enrich.d.ts +11 -0
- package/dist/graph/lsp/enrich.d.ts.map +1 -0
- package/dist/graph/lsp/enrich.js +163 -0
- package/dist/graph/lsp/enrich.js.map +1 -0
- package/dist/graph/lsp/registry.d.ts +22 -0
- package/dist/graph/lsp/registry.d.ts.map +1 -0
- package/dist/graph/lsp/registry.js +71 -0
- package/dist/graph/lsp/registry.js.map +1 -0
- package/dist/graph/map.d.ts +107 -0
- package/dist/graph/map.d.ts.map +1 -0
- package/dist/graph/map.js +239 -0
- package/dist/graph/map.js.map +1 -0
- package/dist/graph/markdown.d.ts +22 -0
- package/dist/graph/markdown.d.ts.map +1 -0
- package/dist/graph/markdown.js +143 -0
- package/dist/graph/markdown.js.map +1 -0
- package/dist/graph/native.d.ts +46 -0
- package/dist/graph/native.d.ts.map +1 -0
- package/dist/graph/native.js +169 -0
- package/dist/graph/native.js.map +1 -0
- package/dist/graph/queries/c.scm +13 -0
- package/dist/graph/queries/c_sharp.scm +19 -0
- package/dist/graph/queries/clojure.scm +58 -0
- package/dist/graph/queries/cpp.scm +23 -0
- package/dist/graph/queries/dart.scm +42 -0
- package/dist/graph/queries/elixir.scm +54 -0
- package/dist/graph/queries/gleam.scm +51 -0
- package/dist/graph/queries/java.scm +20 -0
- package/dist/graph/queries/kotlin.scm +43 -0
- package/dist/graph/queries/lua.scm +36 -0
- package/dist/graph/queries/nix.scm +32 -0
- package/dist/graph/queries/php.scm +40 -0
- package/dist/graph/queries/ruby.scm +65 -0
- package/dist/graph/queries/rust.scm +41 -0
- package/dist/graph/queries/scala.scm +66 -0
- package/dist/graph/queries/solidity.scm +43 -0
- package/dist/graph/queries/swift.scm +51 -0
- package/dist/graph/refresh.d.ts +106 -0
- package/dist/graph/refresh.d.ts.map +1 -0
- package/dist/graph/refresh.js +430 -0
- package/dist/graph/refresh.js.map +1 -0
- package/dist/graph/relations.d.ts +14 -0
- package/dist/graph/relations.d.ts.map +1 -0
- package/dist/graph/relations.js +8 -0
- package/dist/graph/relations.js.map +1 -0
- package/dist/graph/resolve.d.ts +17 -0
- package/dist/graph/resolve.d.ts.map +1 -0
- package/dist/graph/resolve.js +716 -0
- package/dist/graph/resolve.js.map +1 -0
- package/dist/graph/root.d.ts +11 -0
- package/dist/graph/root.d.ts.map +1 -0
- package/dist/graph/root.js +40 -0
- package/dist/graph/root.js.map +1 -0
- package/dist/graph/scopes.d.ts +40 -0
- package/dist/graph/scopes.d.ts.map +1 -0
- package/dist/graph/scopes.js +383 -0
- package/dist/graph/scopes.js.map +1 -0
- package/dist/graph/seed.d.ts +38 -0
- package/dist/graph/seed.d.ts.map +1 -0
- package/dist/graph/seed.js +229 -0
- package/dist/graph/seed.js.map +1 -0
- package/dist/graph/source-files.d.ts +46 -0
- package/dist/graph/source-files.d.ts.map +1 -0
- package/dist/graph/source-files.js +118 -0
- package/dist/graph/source-files.js.map +1 -0
- package/dist/graph/traverse-cli.d.ts +55 -0
- package/dist/graph/traverse-cli.d.ts.map +1 -0
- package/dist/graph/traverse-cli.js +189 -0
- package/dist/graph/traverse-cli.js.map +1 -0
- package/dist/graph/traverse.d.ts +112 -0
- package/dist/graph/traverse.d.ts.map +1 -0
- package/dist/graph/traverse.js +192 -0
- package/dist/graph/traverse.js.map +1 -0
- package/dist/graph/types.d.ts +78 -0
- package/dist/graph/types.d.ts.map +1 -0
- package/dist/graph/types.js +14 -0
- package/dist/graph/types.js.map +1 -0
- package/dist/graph/wasm-runtime.d.ts +19 -0
- package/dist/graph/wasm-runtime.d.ts.map +1 -0
- package/dist/graph/wasm-runtime.js +38 -0
- package/dist/graph/wasm-runtime.js.map +1 -0
- package/dist/graph/workspace-cli.d.ts +56 -0
- package/dist/graph/workspace-cli.d.ts.map +1 -0
- package/dist/graph/workspace-cli.js +111 -0
- package/dist/graph/workspace-cli.js.map +1 -0
- package/dist/graph/workspace.d.ts +172 -0
- package/dist/graph/workspace.d.ts.map +1 -0
- package/dist/graph/workspace.js +714 -0
- package/dist/graph/workspace.js.map +1 -0
- package/dist/graph/write.d.ts +13 -0
- package/dist/graph/write.d.ts.map +1 -0
- package/dist/graph/write.js +86 -0
- package/dist/graph/write.js.map +1 -0
- package/dist/hosts/antigravity.d.ts +14 -0
- package/dist/hosts/antigravity.d.ts.map +1 -0
- package/dist/hosts/antigravity.js +59 -0
- package/dist/hosts/antigravity.js.map +1 -0
- package/dist/hosts/auto.d.ts +37 -0
- package/dist/hosts/auto.d.ts.map +1 -0
- package/dist/hosts/auto.js +141 -0
- package/dist/hosts/auto.js.map +1 -0
- package/dist/hosts/claude-global.d.ts +72 -0
- package/dist/hosts/claude-global.d.ts.map +1 -0
- package/dist/hosts/claude-global.js +230 -0
- package/dist/hosts/claude-global.js.map +1 -0
- package/dist/hosts/config-write.d.ts +36 -0
- package/dist/hosts/config-write.d.ts.map +1 -0
- package/dist/hosts/config-write.js +64 -0
- package/dist/hosts/config-write.js.map +1 -0
- package/dist/hosts/init.d.ts +28 -0
- package/dist/hosts/init.d.ts.map +1 -0
- package/dist/hosts/init.js +89 -0
- package/dist/hosts/init.js.map +1 -0
- package/dist/hosts/instructions.d.ts +14 -0
- package/dist/hosts/instructions.d.ts.map +1 -0
- package/dist/hosts/instructions.js +95 -0
- package/dist/hosts/instructions.js.map +1 -0
- package/dist/hosts/mcp-config.d.ts +78 -0
- package/dist/hosts/mcp-config.d.ts.map +1 -0
- package/dist/hosts/mcp-config.js +251 -0
- package/dist/hosts/mcp-config.js.map +1 -0
- package/dist/hosts/plan.d.ts +32 -0
- package/dist/hosts/plan.d.ts.map +1 -0
- package/dist/hosts/plan.js +73 -0
- package/dist/hosts/plan.js.map +1 -0
- package/dist/hosts/registry.d.ts +41 -0
- package/dist/hosts/registry.d.ts.map +1 -0
- package/dist/hosts/registry.js +164 -0
- package/dist/hosts/registry.js.map +1 -0
- package/dist/hosts/retract.d.ts +47 -0
- package/dist/hosts/retract.d.ts.map +1 -0
- package/dist/hosts/retract.js +520 -0
- package/dist/hosts/retract.js.map +1 -0
- package/dist/hosts/sections.d.ts +47 -0
- package/dist/hosts/sections.d.ts.map +1 -0
- package/dist/hosts/sections.js +127 -0
- package/dist/hosts/sections.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest/fs.d.ts +79 -0
- package/dist/ingest/fs.d.ts.map +1 -0
- package/dist/ingest/fs.js +306 -0
- package/dist/ingest/fs.js.map +1 -0
- package/dist/init-guard.d.ts +16 -0
- package/dist/init-guard.d.ts.map +1 -0
- package/dist/init-guard.js +27 -0
- package/dist/init-guard.js.map +1 -0
- package/dist/mcp/instructions.d.ts +5 -0
- package/dist/mcp/instructions.d.ts.map +1 -0
- package/dist/mcp/instructions.js +64 -0
- package/dist/mcp/instructions.js.map +1 -0
- package/dist/mcp/kept-listing.d.ts +27 -0
- package/dist/mcp/kept-listing.d.ts.map +1 -0
- package/dist/mcp/kept-listing.js +63 -0
- package/dist/mcp/kept-listing.js.map +1 -0
- package/dist/mcp/server.d.ts +4 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +147 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tool-names.d.ts +27 -0
- package/dist/mcp/tool-names.d.ts.map +1 -0
- package/dist/mcp/tool-names.js +47 -0
- package/dist/mcp/tool-names.js.map +1 -0
- package/dist/mcp/tools.d.ts +22 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +440 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/node-version.d.ts +15 -0
- package/dist/node-version.d.ts.map +1 -0
- package/dist/node-version.js +23 -0
- package/dist/node-version.js.map +1 -0
- package/dist/search/grep-cli.d.ts +35 -0
- package/dist/search/grep-cli.d.ts.map +1 -0
- package/dist/search/grep-cli.js +115 -0
- package/dist/search/grep-cli.js.map +1 -0
- package/dist/search/grep.d.ts +60 -0
- package/dist/search/grep.d.ts.map +1 -0
- package/dist/search/grep.js +143 -0
- package/dist/search/grep.js.map +1 -0
- package/dist/search/ignored-code.d.ts +9 -0
- package/dist/search/ignored-code.d.ts.map +1 -0
- package/dist/search/ignored-code.js +73 -0
- package/dist/search/ignored-code.js.map +1 -0
- package/dist/search/user-regexp.d.ts +26 -0
- package/dist/search/user-regexp.d.ts.map +1 -0
- package/dist/search/user-regexp.js +150 -0
- package/dist/search/user-regexp.js.map +1 -0
- package/dist/telemetry/contract.d.ts +117 -0
- package/dist/telemetry/contract.d.ts.map +1 -0
- package/dist/telemetry/contract.js +341 -0
- package/dist/telemetry/contract.js.map +1 -0
- package/dist/telemetry/flush.d.ts +21 -0
- package/dist/telemetry/flush.d.ts.map +1 -0
- package/dist/telemetry/flush.js +85 -0
- package/dist/telemetry/flush.js.map +1 -0
- package/dist/telemetry/gate.d.ts +30 -0
- package/dist/telemetry/gate.d.ts.map +1 -0
- package/dist/telemetry/gate.js +81 -0
- package/dist/telemetry/gate.js.map +1 -0
- package/dist/telemetry/identity.d.ts +40 -0
- package/dist/telemetry/identity.d.ts.map +1 -0
- package/dist/telemetry/identity.js +80 -0
- package/dist/telemetry/identity.js.map +1 -0
- package/dist/telemetry/index.d.ts +18 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/index.js +16 -0
- package/dist/telemetry/index.js.map +1 -0
- package/dist/telemetry/key.d.ts +21 -0
- package/dist/telemetry/key.d.ts.map +1 -0
- package/dist/telemetry/key.js +49 -0
- package/dist/telemetry/key.js.map +1 -0
- package/dist/telemetry/notice.d.ts +19 -0
- package/dist/telemetry/notice.d.ts.map +1 -0
- package/dist/telemetry/notice.js +93 -0
- package/dist/telemetry/notice.js.map +1 -0
- package/dist/telemetry/queue.d.ts +35 -0
- package/dist/telemetry/queue.d.ts.map +1 -0
- package/dist/telemetry/queue.js +162 -0
- package/dist/telemetry/queue.js.map +1 -0
- package/dist/telemetry/send.d.ts +19 -0
- package/dist/telemetry/send.d.ts.map +1 -0
- package/dist/telemetry/send.js +84 -0
- package/dist/telemetry/send.js.map +1 -0
- package/dist/telemetry/sessions.d.ts +25 -0
- package/dist/telemetry/sessions.d.ts.map +1 -0
- package/dist/telemetry/sessions.js +104 -0
- package/dist/telemetry/sessions.js.map +1 -0
- package/dist/telemetry/track.d.ts +62 -0
- package/dist/telemetry/track.d.ts.map +1 -0
- package/dist/telemetry/track.js +143 -0
- package/dist/telemetry/track.js.map +1 -0
- package/dist/upkeep-run.d.ts +22 -0
- package/dist/upkeep-run.d.ts.map +1 -0
- package/dist/upkeep-run.js +85 -0
- package/dist/upkeep-run.js.map +1 -0
- package/dist/upkeep.d.ts +127 -0
- package/dist/upkeep.d.ts.map +1 -0
- package/dist/upkeep.js +252 -0
- package/dist/upkeep.js.map +1 -0
- package/dist/util/actions-folder.d.ts +2 -0
- package/dist/util/actions-folder.d.ts.map +1 -0
- package/dist/util/actions-folder.js +9 -0
- package/dist/util/actions-folder.js.map +1 -0
- package/dist/util/browser.d.ts +3 -0
- package/dist/util/browser.d.ts.map +1 -0
- package/dist/util/browser.js +14 -0
- package/dist/util/browser.js.map +1 -0
- package/dist/util/child-process.d.ts +15 -0
- package/dist/util/child-process.d.ts.map +1 -0
- package/dist/util/child-process.js +15 -0
- package/dist/util/child-process.js.map +1 -0
- package/dist/util/front-matter.d.ts +46 -0
- package/dist/util/front-matter.d.ts.map +1 -0
- package/dist/util/front-matter.js +113 -0
- package/dist/util/front-matter.js.map +1 -0
- package/dist/util/id.d.ts +7 -0
- package/dist/util/id.d.ts.map +1 -0
- package/dist/util/id.js +14 -0
- package/dist/util/id.js.map +1 -0
- package/dist/util/paths.d.ts +29 -0
- package/dist/util/paths.d.ts.map +1 -0
- package/dist/util/paths.js +71 -0
- package/dist/util/paths.js.map +1 -0
- package/dist/util/private-file.d.ts +14 -0
- package/dist/util/private-file.d.ts.map +1 -0
- package/dist/util/private-file.js +82 -0
- package/dist/util/private-file.js.map +1 -0
- package/dist/util/source.d.ts +4 -0
- package/dist/util/source.d.ts.map +1 -0
- package/dist/util/source.js +12 -0
- package/dist/util/source.js.map +1 -0
- package/dist/util/spinner.d.ts +15 -0
- package/dist/util/spinner.d.ts.map +1 -0
- package/dist/util/spinner.js +52 -0
- package/dist/util/spinner.js.map +1 -0
- package/dist/util/state.d.ts +120 -0
- package/dist/util/state.d.ts.map +1 -0
- package/dist/util/state.js +264 -0
- package/dist/util/state.js.map +1 -0
- package/dist/vendor/grammars/tree-sitter-go.LICENSE +21 -0
- package/dist/vendor/grammars/tree-sitter-go.wasm +0 -0
- package/dist/vendor/grammars/tree-sitter-java.LICENSE +21 -0
- package/dist/vendor/grammars/tree-sitter-java.wasm +0 -0
- package/dist/vendor/grammars/tree-sitter-kotlin.LICENSE +9 -0
- package/dist/vendor/grammars/tree-sitter-kotlin.wasm +0 -0
- package/dist/vendor/grammars/tree-sitter-php.LICENSE +22 -0
- package/dist/vendor/grammars/tree-sitter-php.wasm +0 -0
- package/dist/vendor/grammars/tree-sitter-python.LICENSE +21 -0
- package/dist/vendor/grammars/tree-sitter-python.wasm +0 -0
- package/dist/vendor/grammars/tree-sitter-r.LICENSE +9 -0
- package/dist/vendor/grammars/tree-sitter-r.wasm +0 -0
- package/dist/vendor/grammars/tree-sitter-swift.LICENSE +21 -0
- package/dist/vendor/grammars/tree-sitter-swift.wasm +0 -0
- package/dist/vendor/grammars/tree-sitter-tsx.wasm +0 -0
- package/dist/vendor/grammars/tree-sitter-typescript.LICENSE +21 -0
- package/dist/vendor/grammars/tree-sitter-typescript.wasm +0 -0
- package/dist/viewer/app.js +2 -0
- package/dist/viewer/index.html +49 -0
- package/dist/viewer/style.css +154 -0
- package/dist/viz/assemble.d.ts +83 -0
- package/dist/viz/assemble.d.ts.map +1 -0
- package/dist/viz/assemble.js +101 -0
- package/dist/viz/assemble.js.map +1 -0
- package/dist/viz/export.d.ts +39 -0
- package/dist/viz/export.d.ts.map +1 -0
- package/dist/viz/export.js +98 -0
- package/dist/viz/export.js.map +1 -0
- package/dist/viz/serve.d.ts +13 -0
- package/dist/viz/serve.d.ts.map +1 -0
- package/dist/viz/serve.js +130 -0
- package/dist/viz/serve.js.map +1 -0
- package/package.json +115 -4
- package/scripts/build-grammar-wasm.mjs +112 -0
- package/scripts/build-viewer.mjs +44 -0
- package/scripts/graph-quality.mjs +117 -0
- package/scripts/pack-trail.mjs +53 -0
- package/scripts/postinstall.mjs +65 -0
- package/scripts/run-tests.mjs +72 -0
- package/scripts/stamp-telemetry-key.mjs +56 -0
- package/scripts/tally-audit.mjs +140 -0
- package/scripts/vendor-grammars.mjs +53 -0
package/dist/ask/ask.js
ADDED
|
@@ -0,0 +1,1291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `graft ask "<task>"` — the ACTIVE channel.
|
|
3
|
+
*
|
|
4
|
+
* One tool that routes a plain-words query to the right graph and returns a lean,
|
|
5
|
+
* ranked context pack (prose + exact `file:line` + related), never raw JSON and
|
|
6
|
+
* never whole files:
|
|
7
|
+
*
|
|
8
|
+
* - Structural intent ("what calls X", "callers of X", "what does X import")
|
|
9
|
+
* → walk the wiring graph's edges and return the neighbour symbols with spans.
|
|
10
|
+
* - Everything else → lexical rank over concept nodes (prose) and wiring symbols
|
|
11
|
+
* (name + signature + summary), returning the best few with pointers.
|
|
12
|
+
*
|
|
13
|
+
* v1 is deterministic and $0 (term-overlap scoring, no LLM, no embeddings). The
|
|
14
|
+
* output is markdown so it drops straight into an agent's context.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
17
|
+
import { join, resolve } from "node:path";
|
|
18
|
+
import { matter } from "../util/front-matter.js";
|
|
19
|
+
import { contextDirFor } from "../context/node-file.js";
|
|
20
|
+
import { withSavings, savingsFor, savingsTurnNudge } from "../context/savings.js";
|
|
21
|
+
import { loadGraphCached, loadAskIndexCached } from "../graph/load.js";
|
|
22
|
+
import { assertPrefixIndexed, pathUnderPrefix, scopeLabel, scopeOf, scopesHereClause, scopesOfGraph, } from "../graph/scopes.js";
|
|
23
|
+
import { normalizePathPrefix } from "../util/paths.js";
|
|
24
|
+
import { resolveSymbol } from "../graph/traverse.js";
|
|
25
|
+
import { combineComparableScopes, rankScopesAndFuse, } from "./fuse.js";
|
|
26
|
+
import { rankFilesBounded, } from "./file-rank.js";
|
|
27
|
+
import { fileFirstRoundRobin, roundRobinQueues } from "./file-selection.js";
|
|
28
|
+
import { personalizedPageRank, personalizedPageRankPrepared, preparePageRankPartitions, } from "./graphrank.js";
|
|
29
|
+
import { readSourceFile } from "../util/source.js";
|
|
30
|
+
import { counts, tokenize } from "./index-file.js";
|
|
31
|
+
import { cmd, tag } from "../brand.js";
|
|
32
|
+
/** Max source lines to inline per hit — a definition longer than this is
|
|
33
|
+
* truncated with a marker, so one giant function can't blow up the pack. */
|
|
34
|
+
const MAX_SPAN_LINES = 80;
|
|
35
|
+
/** First real prose line of a node body (skips headings, markers, blanks). */
|
|
36
|
+
function firstProse(body) {
|
|
37
|
+
for (const raw of body.split("\n")) {
|
|
38
|
+
const line = raw.trim();
|
|
39
|
+
if (!line || line.startsWith("#") || line.startsWith("<!--") || line.startsWith("-"))
|
|
40
|
+
continue;
|
|
41
|
+
return line;
|
|
42
|
+
}
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
function loadCorpus(outDir) {
|
|
46
|
+
const concepts = [];
|
|
47
|
+
if (existsSync(outDir)) {
|
|
48
|
+
for (const entry of readdirSync(outDir)) {
|
|
49
|
+
if (!entry.endsWith(".md") || entry === "INDEX.md")
|
|
50
|
+
continue;
|
|
51
|
+
const parsed = matter(readFileSync(join(outDir, entry), "utf8"));
|
|
52
|
+
const fm = parsed.data;
|
|
53
|
+
const sources = Array.isArray(fm.sources)
|
|
54
|
+
? fm.sources.map((s) => s.path)
|
|
55
|
+
: [];
|
|
56
|
+
const related = Array.isArray(fm.links)
|
|
57
|
+
? fm.links.map((l) => l.to)
|
|
58
|
+
: [];
|
|
59
|
+
const snippet = firstProse(parsed.content);
|
|
60
|
+
concepts.push({
|
|
61
|
+
slug: String(fm.slug ?? entry.replace(/\.md$/, "")),
|
|
62
|
+
name: String(fm.name ?? ""),
|
|
63
|
+
sources,
|
|
64
|
+
related,
|
|
65
|
+
snippet,
|
|
66
|
+
text: `${fm.name ?? ""} ${snippet} ${sources.join(" ")}`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { concepts, graph: loadGraphCached(outDir), askIndex: loadAskIndexCached(outDir) };
|
|
71
|
+
}
|
|
72
|
+
/** Score a document's token counts against the query counts (name field
|
|
73
|
+
* pre-weighted by caller). Each shared term is weighted by its inverse document
|
|
74
|
+
* frequency (`idf`), so a word that appears in many nodes — "overlay" across a
|
|
75
|
+
* whole widget subsystem, or a repeated word in a pasted issue body — counts for
|
|
76
|
+
* far less than a rare, discriminating identifier ("scrolling"). Without idf,
|
|
77
|
+
* pure term-frequency lets an incidental common word dominate the ranking; this
|
|
78
|
+
* is the lexical half of the keyword-collision fix (graph-rank is the other). */
|
|
79
|
+
/** Test files rarely answer "how does X work" / "where is Y" — they mirror the
|
|
80
|
+
* real symbol's tokens, so a test can out-score the definition on a lexical tie
|
|
81
|
+
* and land as the top hit (observed: an `ask` returning a `Test…` function first,
|
|
82
|
+
* sending the agent to the wrong file). A multiplicative de-rank keeps tests in
|
|
83
|
+
* the results (they still matter for "where are the tests") but below the real
|
|
84
|
+
* definition. Covers Go (`_test.go`), JS/TS (`.test.` / `.spec.`), and test dirs. */
|
|
85
|
+
export function isTestPath(path) {
|
|
86
|
+
return /(^|\/)(tests?|__tests__|spec)\/|(_test|\.test|\.spec)\.[a-z]+$|(^|\/)(test_[^/]+|conftest)\.py$/i.test(path || "");
|
|
87
|
+
}
|
|
88
|
+
const TEST_RANK_PENALTY = 0.35;
|
|
89
|
+
function score(query, doc, idf) {
|
|
90
|
+
let s = 0;
|
|
91
|
+
for (const [t, qn] of query) {
|
|
92
|
+
const dn = doc.get(t);
|
|
93
|
+
if (dn)
|
|
94
|
+
s += qn * dn * (idf.get(t) ?? 1);
|
|
95
|
+
}
|
|
96
|
+
return s;
|
|
97
|
+
}
|
|
98
|
+
/** Inverse document frequency over the scored corpus. `df` counts how many
|
|
99
|
+
* documents (concept nodes + symbol nodes) contain each token; N is the corpus
|
|
100
|
+
* size. `log(1 + N/(1+df))` is the smoothed standard form: monotonically
|
|
101
|
+
* decreasing in df, always positive, ~0 extra weight for a token in every doc. */
|
|
102
|
+
function idfFromDf(df, n) {
|
|
103
|
+
const idf = new Map();
|
|
104
|
+
for (const [t, d] of df)
|
|
105
|
+
idf.set(t, Math.log(1 + n / (1 + d)));
|
|
106
|
+
return idf;
|
|
107
|
+
}
|
|
108
|
+
function computeIdf(docBags) {
|
|
109
|
+
const df = new Map();
|
|
110
|
+
for (const bag of docBags)
|
|
111
|
+
for (const t of bag)
|
|
112
|
+
df.set(t, (df.get(t) ?? 0) + 1);
|
|
113
|
+
return idfFromDf(df, docBags.length);
|
|
114
|
+
}
|
|
115
|
+
/** Same idf as {@link computeIdf}, but the symbol half of `df` and the doc
|
|
116
|
+
* count come from the build-time sidecar instead of tokenizing the corpus
|
|
117
|
+
* live; only the concept bags (always tokenized live — there are only dozens)
|
|
118
|
+
* are folded in here, the same way `computeIdf` would fold them into the full
|
|
119
|
+
* docBags array. `df` is a commutative sum and `n` is the same total document
|
|
120
|
+
* count either way, so this produces byte-identical idf values to `computeIdf`
|
|
121
|
+
* run over concept-bags + live-tokenized-symbol-bags. */
|
|
122
|
+
function computeIdfFromIndex(index, conceptBags) {
|
|
123
|
+
const df = new Map(index.df);
|
|
124
|
+
for (const bag of conceptBags)
|
|
125
|
+
for (const t of bag)
|
|
126
|
+
df.set(t, (df.get(t) ?? 0) + 1);
|
|
127
|
+
return idfFromDf(df, index.docCount + conceptBags.length);
|
|
128
|
+
}
|
|
129
|
+
/** BM25 term score for a document field, used for the (potentially large) body
|
|
130
|
+
* field. Unlike raw term-frequency, BM25 saturates repeated occurrences (`k1`)
|
|
131
|
+
* and normalizes by document length (`b`, against the corpus-average length
|
|
132
|
+
* `avgdl`), so a big definition — a sprawling test class — can't outrank a
|
|
133
|
+
* tight, on-point function merely by containing more words. Standard params. */
|
|
134
|
+
function bm25(query, doc, idf, dl, avgdl) {
|
|
135
|
+
const k1 = 1.2;
|
|
136
|
+
const b = 0.75;
|
|
137
|
+
const norm = k1 * (1 - b + (b * dl) / (avgdl || 1));
|
|
138
|
+
let s = 0;
|
|
139
|
+
for (const t of query.keys()) {
|
|
140
|
+
const tf = doc.get(t);
|
|
141
|
+
if (tf)
|
|
142
|
+
s += (idf.get(t) ?? 1) * ((tf * (k1 + 1)) / (tf + norm));
|
|
143
|
+
}
|
|
144
|
+
return s;
|
|
145
|
+
}
|
|
146
|
+
/** Exact query terms awarded by the existing lexical fields. Bounded ranking uses
|
|
147
|
+
* presence only: raw name/path/BM25 magnitudes cannot enter the file residual. */
|
|
148
|
+
function matchedLexicalTerms(query, name, path, body) {
|
|
149
|
+
const terms = new Set();
|
|
150
|
+
for (const term of query.keys())
|
|
151
|
+
if (name.has(term) || path.has(term) || body.has(term))
|
|
152
|
+
terms.add(term);
|
|
153
|
+
return terms;
|
|
154
|
+
}
|
|
155
|
+
/** NAME-only query terms behind file-level strong coverage. This intentionally
|
|
156
|
+
* mirrors {@link strongShare}'s plural folding while excluding file nodes at
|
|
157
|
+
* the call site. */
|
|
158
|
+
function matchedStrongTerms(query, name) {
|
|
159
|
+
const terms = new Set();
|
|
160
|
+
for (const term of query.keys())
|
|
161
|
+
if (hasTerm(name, term))
|
|
162
|
+
terms.add(term);
|
|
163
|
+
return terms;
|
|
164
|
+
}
|
|
165
|
+
// ── Structural intent ──────────────────────────────────────────────────────
|
|
166
|
+
const INCOMING = /\b(caller|callers|calls?\s+into|who\s+calls|what\s+calls|called\s+by|used\s+by|uses)\b/;
|
|
167
|
+
const OUTGOING = /\b(callee|callees|what\s+does\s+\w+\s+call|calls\s+what|imports?|depends\s+on)\b/;
|
|
168
|
+
const INCOMING_RELS = ["calls", "references", "implements", "extends"];
|
|
169
|
+
const OUTGOING_RELS = ["calls", "references", "imports", "implements", "extends"];
|
|
170
|
+
/** Split a prose query into word-like tokens, keeping dots so a qualified name
|
|
171
|
+
* ("Cache.get") or package-qualified name ("pkg.Fn") survives as one token —
|
|
172
|
+
* `resolveSymbol`'s own suffix/last-segment matching handles the rest. */
|
|
173
|
+
function subjectWords(query) {
|
|
174
|
+
return [...new Set(query.split(/[^A-Za-z0-9_.]+/).filter(Boolean))];
|
|
175
|
+
}
|
|
176
|
+
/** Resolve the query's structural subject via {@link resolveSymbol}, trying
|
|
177
|
+
* each word longest-first — the intended subject of a structural query is
|
|
178
|
+
* usually the most specific (longest) identifier-shaped word, never a short
|
|
179
|
+
* verb like "calls" — until one resolves. `tried` is the word that was tried
|
|
180
|
+
* last regardless of outcome, so a caller can name *something* in a fallthrough
|
|
181
|
+
* note even when nothing resolved (e.g. `subjects.length === 0`). This is what
|
|
182
|
+
* makes a qualified/dotted subject like `Cache.get` resolve, where the old
|
|
183
|
+
* plain name-equality lookup (`findSubject`) never matched it.
|
|
184
|
+
*
|
|
185
|
+
* `inPrefix`, when given, filters each word's `resolveSymbol` matches down to
|
|
186
|
+
* nodes under that path prefix BEFORE checking whether any survived — same
|
|
187
|
+
* "narrow the subject resolution" semantics `callers`/`callees`/`impact --in`
|
|
188
|
+
* already have (segment-aware here, via `pathUnderPrefix`). A word that
|
|
189
|
+
* resolves globally but has nothing under the prefix does NOT count as a
|
|
190
|
+
* match; the next (shorter) word is tried instead, same as an unresolved word
|
|
191
|
+
* today — and if nothing survives for ANY word, `subjects.length === 0`
|
|
192
|
+
* naturally falls through to the existing loud fallthrough note. */
|
|
193
|
+
function findSubjectNodes(query, graph, inPrefix) {
|
|
194
|
+
const words = subjectWords(query).sort((a, b) => b.length - a.length);
|
|
195
|
+
for (const w of words) {
|
|
196
|
+
let nodes = resolveSymbol(graph, w);
|
|
197
|
+
if (inPrefix)
|
|
198
|
+
nodes = nodes.filter((n) => pathUnderPrefix(n.path, inPrefix));
|
|
199
|
+
if (nodes.length > 0)
|
|
200
|
+
return { nodes, tried: w };
|
|
201
|
+
}
|
|
202
|
+
return { nodes: [], tried: words[0] ?? query };
|
|
203
|
+
}
|
|
204
|
+
function fallthroughNoteFor(subject) {
|
|
205
|
+
return (`structural index: no entries for '${subject}' — showing lexical matches; ` +
|
|
206
|
+
`for precise edges try ${cmd("graft callers")} '${subject}', or ${cmd("graft grep")} '${subject}' for every reference (loosen the pattern if it returns nothing)`);
|
|
207
|
+
}
|
|
208
|
+
function structural(query, graph, limit, inPrefix) {
|
|
209
|
+
const wantsIn = INCOMING.test(query);
|
|
210
|
+
const wantsOut = OUTGOING.test(query);
|
|
211
|
+
if (!wantsIn && !wantsOut)
|
|
212
|
+
return null;
|
|
213
|
+
const { nodes: subjects, tried } = findSubjectNodes(query, graph, inPrefix);
|
|
214
|
+
if (subjects.length === 0)
|
|
215
|
+
return { fallthroughNote: fallthroughNoteFor(tried) };
|
|
216
|
+
const ids = new Set(subjects.map((n) => n.id));
|
|
217
|
+
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
218
|
+
const outgoing = wantsOut && !wantsIn; // "what does X call / import" — else default to callers
|
|
219
|
+
const rels = new Set(outgoing ? OUTGOING_RELS : INCOMING_RELS);
|
|
220
|
+
const hits = [];
|
|
221
|
+
const seen = new Set();
|
|
222
|
+
for (const e of graph.edges) {
|
|
223
|
+
if (!rels.has(e.relation))
|
|
224
|
+
continue;
|
|
225
|
+
const anchor = outgoing ? e.source : e.target;
|
|
226
|
+
const other = outgoing ? e.target : e.source;
|
|
227
|
+
if (!ids.has(anchor) || seen.has(other + e.relation))
|
|
228
|
+
continue;
|
|
229
|
+
seen.add(other + e.relation);
|
|
230
|
+
const node = byId.get(other);
|
|
231
|
+
hits.push({
|
|
232
|
+
kind: outgoing ? "callee" : "caller",
|
|
233
|
+
title: node ? node.name : other, // unresolved import target → raw module string
|
|
234
|
+
pointer: node ? `${node.path}:${node.span}` : other,
|
|
235
|
+
snippet: node?.summary?.split("\n")[0].trim() ?? node?.signature ?? "",
|
|
236
|
+
relation: e.relation,
|
|
237
|
+
score: 1,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
// Subject resolved but the graph has no indexed edges for it — same "loud,
|
|
241
|
+
// never a bare empty" contract as `graft callers`/`callees` — fall through
|
|
242
|
+
// to lexical rather than returning a structural result with zero hits.
|
|
243
|
+
if (hits.length === 0)
|
|
244
|
+
return { fallthroughNote: fallthroughNoteFor(subjects[0].name) };
|
|
245
|
+
hits.sort((a, b) => a.pointer.localeCompare(b.pointer));
|
|
246
|
+
return {
|
|
247
|
+
result: {
|
|
248
|
+
query,
|
|
249
|
+
mode: "structural",
|
|
250
|
+
subject: subjects[0].name,
|
|
251
|
+
hits: hits.slice(0, limit),
|
|
252
|
+
note: outgoing
|
|
253
|
+
? `outgoing edges from ${subjects[0].name}`
|
|
254
|
+
: `callers / references of ${subjects[0].name}`,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
// ── Lexical rank ─────────────────────────────────────────────────────────────
|
|
259
|
+
/** How much a node's graph-centrality (0..1) can lift its blended score. A
|
|
260
|
+
* lexically-perfect node scores 1.0 on the lexical axis; a graph weight of 0.5
|
|
261
|
+
* lets connectivity reorder near-ties and separate a connected hit from an
|
|
262
|
+
* isolated same-word collision, without letting structure override a clear
|
|
263
|
+
* lexical winner. */
|
|
264
|
+
const GRAPH_WEIGHT = 0.5;
|
|
265
|
+
/** A node the query never word-matched is pulled into the results only if the
|
|
266
|
+
* walk gives it at least this share of the top node's mass — i.e. it is
|
|
267
|
+
* genuinely central to the matched cluster, not incidentally reachable. This is
|
|
268
|
+
* what surfaces the config/helper a task depends on but didn't name. */
|
|
269
|
+
const RESCUE_FLOOR = 0.15;
|
|
270
|
+
/** Term presence with plural folding — "packs" finds "pack" and vice versa, so
|
|
271
|
+
* a natural-language plural doesn't read as a miss against identifier tokens. */
|
|
272
|
+
function hasTerm(f, t) {
|
|
273
|
+
return f.has(t) || f.has(`${t}s`) || (t.endsWith("s") && f.has(t.slice(0, -1)));
|
|
274
|
+
}
|
|
275
|
+
/** Idf-weighted share (0..1) of the query a document matches: each query term
|
|
276
|
+
* counts by its rarity, so this separates task prompts from chatter in a way a
|
|
277
|
+
* raw term count can't. A conversational prompt's words are either off-corpus
|
|
278
|
+
* (rare → heavy, unmatched → sinks the ratio) or generic code words ("list",
|
|
279
|
+
* "write": common → near-weightless even when they collide with a symbol name);
|
|
280
|
+
* a task prompt matches exactly its rare, discriminating identifiers. Terms the
|
|
281
|
+
* corpus has never seen take `dfltIdf` (the df=0 weight). */
|
|
282
|
+
/** The match-STRENGTH share behind `coverageStrong`: idf-weighted share of the
|
|
283
|
+
* query matched in the node's NAME field ONLY. The PATH field is deliberately
|
|
284
|
+
* excluded — a coincidental generic directory (`gateway/`) or file basename
|
|
285
|
+
* (`gateway.ts`) in an unrelated repo would otherwise clear the strength gate
|
|
286
|
+
* and float that repo to the top; an on-topic path-organized repo is still
|
|
287
|
+
* rescued by the broad-coverage HIGH_FLOOR clause instead. For the same reason
|
|
288
|
+
* a `kind:"file"` node contributes zero strength: its `name` is a basename (a
|
|
289
|
+
* path component), not a symbol name, so a file whose basename happens to share
|
|
290
|
+
* a query word is not a strong match. */
|
|
291
|
+
export function strongShare(q, node, doc, idf, dfltIdf) {
|
|
292
|
+
if (node.kind === "file")
|
|
293
|
+
return 0;
|
|
294
|
+
return matchedIdfShare(q, [doc.name], idf, dfltIdf);
|
|
295
|
+
}
|
|
296
|
+
function matchedIdfShare(q, fields, idf, dfltIdf) {
|
|
297
|
+
let matched = 0;
|
|
298
|
+
let total = 0;
|
|
299
|
+
for (const t of q.keys()) {
|
|
300
|
+
const w = idf.get(t) ?? dfltIdf;
|
|
301
|
+
total += w;
|
|
302
|
+
if (fields.some((f) => hasTerm(f, t)))
|
|
303
|
+
matched += w;
|
|
304
|
+
}
|
|
305
|
+
return total > 0 ? matched / total : 0;
|
|
306
|
+
}
|
|
307
|
+
/** Normalized query-term IDF mass used by file-level union coverage. Presence
|
|
308
|
+
* comes from the exact term-contribution maps, so plural folding cannot
|
|
309
|
+
* manufacture evidence that the lexical scorer did not award. */
|
|
310
|
+
function normalizedQueryWeights(q, idf, dfltIdf) {
|
|
311
|
+
let total = 0;
|
|
312
|
+
for (const t of q.keys())
|
|
313
|
+
total += idf.get(t) ?? dfltIdf;
|
|
314
|
+
if (total <= 0)
|
|
315
|
+
return new Map();
|
|
316
|
+
return new Map([...q.keys()].map((term) => [term, (idf.get(term) ?? dfltIdf) / total]));
|
|
317
|
+
}
|
|
318
|
+
function lexical(query, corpus, limit, graphRank, inPrefix, fileFirst = true, fileComplement = false, fileTopLock = false, includeRankingMetadata = false) {
|
|
319
|
+
// Binary query terms: a word repeated in a pasted issue body counts once, so a
|
|
320
|
+
// ranty description can't linearly amplify an incidental word.
|
|
321
|
+
const q = new Map([...counts(tokenize(query)).keys()].map((t) => [t, 1]));
|
|
322
|
+
const graph = corpus.graph;
|
|
323
|
+
// Test-file de-ranking is query-aware: a query that ASKS about tests wants test
|
|
324
|
+
// files on top, so it gets no penalty; every other query wants the real
|
|
325
|
+
// definition first. This stops a test that merely contains the query's literals
|
|
326
|
+
// (e.g. `TestDownloadStall` matching "download stall") from outranking the
|
|
327
|
+
// source it exercises — the "tests ranked above the actual code" trap.
|
|
328
|
+
const wantsTests = /\b(tests?|specs?|coverage|assert(?:ion)?s?|fixtures?|mocks?)\b/i.test(query);
|
|
329
|
+
const testFactor = (path) => (!wantsTests && isTestPath(path) ? TEST_RANK_PENALTY : 1);
|
|
330
|
+
// ── Pass 1: tokenize every scored field once, and collect per-document token
|
|
331
|
+
// bags so IDF can down-weight words that occur across the whole corpus. `--in`
|
|
332
|
+
// drops a concept unless at least one of its sources falls under the prefix —
|
|
333
|
+
// filtering happens here, before any scoring, so a narrowed query never sees
|
|
334
|
+
// an unrelated concept from outside the prefix. ──
|
|
335
|
+
const conceptDocs = corpus.concepts
|
|
336
|
+
.filter((c) => !inPrefix || c.sources.some((p) => pathUnderPrefix(p, inPrefix)))
|
|
337
|
+
.map((c) => ({
|
|
338
|
+
c,
|
|
339
|
+
name: counts(tokenize(c.name)),
|
|
340
|
+
body: counts(tokenize(c.text)),
|
|
341
|
+
}));
|
|
342
|
+
// A build-time sidecar (`.cache/ask-index.json`) is usable only when it covers
|
|
343
|
+
// every node in the current graph: `docs.length === graph.nodes.length` is a
|
|
344
|
+
// cheap staleness guard (an id-level check below catches a same-count but
|
|
345
|
+
// mismatched set, e.g. a hand-edited or corrupt sidecar). Anything short of
|
|
346
|
+
// that — missing, unparseable, unknown version, wrong count, mismatched ids —
|
|
347
|
+
// falls back to live tokenization so results never depend on the sidecar
|
|
348
|
+
// existing; see `readAskIndex`'s own null-on-anything-off contract.
|
|
349
|
+
//
|
|
350
|
+
// `--in` does NOT disable the sidecar here: `writeGraph` strips `body_text`
|
|
351
|
+
// from every node it serializes (see `write.ts`) — the sidecar's per-doc
|
|
352
|
+
// `docs[].body` bags are the ONLY place a symbol's body tokens survive on a
|
|
353
|
+
// graph loaded from disk. Bypassing the sidecar under `--in` would silently
|
|
354
|
+
// empty out `body` for every node (falling back to `n.body_text ?? ""`,
|
|
355
|
+
// always `""` post-slim-serialization), losing every body-only match under
|
|
356
|
+
// the filtered prefix. What DOES need bypassing under `--in` is the
|
|
357
|
+
// sidecar's GLOBAL `df`/`docCount`/`avgBodyLen` — those cover the whole
|
|
358
|
+
// corpus, not the filtered subset — so each read of those three fields below
|
|
359
|
+
// is separately gated on `!inPrefix`, while the per-doc bags (used to build
|
|
360
|
+
// `symbolDocs` right after this) are used unconditionally.
|
|
361
|
+
let docById = null;
|
|
362
|
+
if (corpus.askIndex && graph && corpus.askIndex.docs.length === graph.nodes.length) {
|
|
363
|
+
const map = new Map(corpus.askIndex.docs.map((d) => [d.id, d]));
|
|
364
|
+
if (graph.nodes.every((n) => map.has(n.id)))
|
|
365
|
+
docById = map;
|
|
366
|
+
}
|
|
367
|
+
const askIndex = docById ? corpus.askIndex : null;
|
|
368
|
+
// Symbols AND file nodes are scored: a symbol's body_text is its definition;
|
|
369
|
+
// a file's body_text is the module-level residual (imports/constants not in
|
|
370
|
+
// any symbol). Including files closes the recall gap where a gold file's only
|
|
371
|
+
// query-relevant text lives outside every function/class. `--in` filters the
|
|
372
|
+
// node set here, at the very top, before any scoring — every downstream stat
|
|
373
|
+
// (idf, avgdl, candidates, the per-scope partition below) then naturally
|
|
374
|
+
// reflects only the filtered set with no other code needing to know about it.
|
|
375
|
+
const graphNodes = inPrefix ? (graph?.nodes ?? []).filter((n) => pathUnderPrefix(n.path, inPrefix)) : (graph?.nodes ?? []);
|
|
376
|
+
const symbolDocs = graphNodes.map((n) => {
|
|
377
|
+
const d = docById?.get(n.id);
|
|
378
|
+
if (d)
|
|
379
|
+
return { n, name: new Map(d.name), path: new Map(d.path), body: new Map(d.body) };
|
|
380
|
+
return {
|
|
381
|
+
n,
|
|
382
|
+
name: counts(tokenize(n.name)),
|
|
383
|
+
path: counts(tokenize(n.path)),
|
|
384
|
+
// The body (indexed at build) joins the signature + summary as the low-weight
|
|
385
|
+
// body field, so a term that appears only in the code — not the name/signature
|
|
386
|
+
// — still makes the node findable. IDF (from these same bags) keeps a word
|
|
387
|
+
// common across many bodies from dominating. `body_text` is absent on every
|
|
388
|
+
// node loaded from a wiring.json written after the slim-serialization change
|
|
389
|
+
// (it's stripped there — see `write.ts`) as well as on file nodes and
|
|
390
|
+
// pre-body_text graphs; `?? ""` degrades gracefully to signature+summary
|
|
391
|
+
// only, it never crashes on the missing field.
|
|
392
|
+
body: counts(tokenize(`${n.signature ?? ""} ${n.summary ?? ""} ${n.body_text ?? ""}`)),
|
|
393
|
+
};
|
|
394
|
+
});
|
|
395
|
+
const conceptBags = conceptDocs.map((d) => new Set([...d.name.keys(), ...d.body.keys()]));
|
|
396
|
+
// IDF must see the SAME inputs either way: with a sidecar, its `df` (stored
|
|
397
|
+
// WITHOUT concepts) plus these live concept bags reproduces exactly what
|
|
398
|
+
// `computeIdf` below would compute from concept+symbol bags tokenized live.
|
|
399
|
+
// `askIndex.df` is a corpus-GLOBAL count, so `--in` can't use it — the
|
|
400
|
+
// filtered doc set needs its own idf, computed from (already-filtered)
|
|
401
|
+
// `symbolDocs`' own bags, regardless of where those bags came from.
|
|
402
|
+
const idf = askIndex && !inPrefix
|
|
403
|
+
? computeIdfFromIndex(askIndex, conceptBags)
|
|
404
|
+
: computeIdf([...conceptBags, ...symbolDocs.map((d) => new Set([...d.name.keys(), ...d.path.keys(), ...d.body.keys()]))]);
|
|
405
|
+
// Per-hit idf-weighted coverage, kept out of the public AskHit shape; only
|
|
406
|
+
// the top hit's value survives, as `coverage` on the result. The df=0 weight
|
|
407
|
+
// mirrors idfFromDf's formula at df=0 for whichever corpus size idf was
|
|
408
|
+
// computed from. Same `--in` reasoning as `idf` above: `askIndex.docCount`
|
|
409
|
+
// is global.
|
|
410
|
+
const nDocs = askIndex && !inPrefix
|
|
411
|
+
? askIndex.docCount + conceptBags.length
|
|
412
|
+
: conceptBags.length + symbolDocs.length;
|
|
413
|
+
const dfltIdf = Math.log(1 + nDocs);
|
|
414
|
+
const matchedOf = new Map();
|
|
415
|
+
// Parallel to matchedOf, but over NAME+PATH only (body dropped) — the
|
|
416
|
+
// match-strength signal exported as `coverageStrong`.
|
|
417
|
+
const matchedStrongOf = new Map();
|
|
418
|
+
// Final listwise selection groups code hits by exact source path. Concepts
|
|
419
|
+
// use singleton pseudo-groups so they remain ordinary ranked candidates
|
|
420
|
+
// without masquerading as every file listed in their sources.
|
|
421
|
+
const selectionGroupOf = new Map();
|
|
422
|
+
// ── Concepts (prose nodes; not in the wiring graph) ──
|
|
423
|
+
const conceptHits = [];
|
|
424
|
+
let maxConcept = 0;
|
|
425
|
+
for (const { c, name, body } of conceptDocs) {
|
|
426
|
+
const total = score(q, name, idf) * 3 + score(q, body, idf);
|
|
427
|
+
if (total > 0) {
|
|
428
|
+
maxConcept = Math.max(maxConcept, total);
|
|
429
|
+
const hit = {
|
|
430
|
+
kind: "concept",
|
|
431
|
+
title: c.name || c.slug,
|
|
432
|
+
pointer: c.sources.join(", ") || c.slug,
|
|
433
|
+
snippet: c.snippet,
|
|
434
|
+
related: c.related,
|
|
435
|
+
score: total,
|
|
436
|
+
};
|
|
437
|
+
matchedOf.set(hit, matchedIdfShare(q, [name, body], idf, dfltIdf));
|
|
438
|
+
matchedStrongOf.set(hit, matchedIdfShare(q, [name], idf, dfltIdf)); // concepts have no path field
|
|
439
|
+
// A concept is one singleton partition even if two source entries reuse
|
|
440
|
+
// the same slug; include its stable encounter index to prevent accidental
|
|
441
|
+
// grouping by frontmatter alone.
|
|
442
|
+
selectionGroupOf.set(hit, `concept:${c.slug}:${conceptHits.length}`);
|
|
443
|
+
conceptHits.push(hit);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
// ── Symbols (wiring graph): lexical score per node, keyed by id ──
|
|
447
|
+
const byId = new Map((graph?.nodes ?? []).map((n) => [n.id, n]));
|
|
448
|
+
const bodyLen = (m) => {
|
|
449
|
+
let s = 0;
|
|
450
|
+
for (const v of m.values())
|
|
451
|
+
s += v;
|
|
452
|
+
return s;
|
|
453
|
+
};
|
|
454
|
+
const docsById = new Map(symbolDocs.map((d) => [d.n.id, d]));
|
|
455
|
+
const symbolHits = [];
|
|
456
|
+
const baselineSymbolHits = [];
|
|
457
|
+
const baselineHitById = new Map();
|
|
458
|
+
const fileQueueHitsByGroup = new Map();
|
|
459
|
+
const baselineQueueHitsByGroup = new Map();
|
|
460
|
+
const fileGroups = [];
|
|
461
|
+
const needsFileQueues = fileComplement && (fileTopLock || includeRankingMetadata);
|
|
462
|
+
const makeSymbolHit = (id, hitScore, scope) => {
|
|
463
|
+
const n = byId.get(id);
|
|
464
|
+
if (!n)
|
|
465
|
+
return null;
|
|
466
|
+
const hit = {
|
|
467
|
+
kind: "symbol",
|
|
468
|
+
title: `${n.name} · ${n.kind}`,
|
|
469
|
+
pointer: n.kind === "file" ? n.path : `${n.path}:${n.span}`,
|
|
470
|
+
snippet: n.summary?.split("\n")[0].trim() ?? n.signature ?? "",
|
|
471
|
+
score: hitScore,
|
|
472
|
+
...(scope === undefined ? {} : { scope }),
|
|
473
|
+
};
|
|
474
|
+
const d = docsById.get(id);
|
|
475
|
+
matchedOf.set(hit, d ? matchedIdfShare(q, [d.name, d.path, d.body], idf, dfltIdf) : 0);
|
|
476
|
+
matchedStrongOf.set(hit, d ? strongShare(q, n, d, idf, dfltIdf) : 0);
|
|
477
|
+
selectionGroupOf.set(hit, `file:${n.path}`);
|
|
478
|
+
return hit;
|
|
479
|
+
};
|
|
480
|
+
const symbolTitle = (id) => {
|
|
481
|
+
const n = byId.get(id);
|
|
482
|
+
return n ? `${n.name} · ${n.kind}` : id;
|
|
483
|
+
};
|
|
484
|
+
// The single-vs-multi-scope branch keys on `scopesOfGraph` ONLY: one scope
|
|
485
|
+
// (or no graph) keeps the original local scoring path.
|
|
486
|
+
const scopes = graph ? scopesOfGraph(graph) : null;
|
|
487
|
+
let scopeMeta;
|
|
488
|
+
// The BM25 length prior, corpus-global like `idf`/`dfltIdf` above. Hoisted out
|
|
489
|
+
// of the single-scope branch so BOTH paths score against the same statistics
|
|
490
|
+
// — that shared basis is half of what makes multi-scope scores comparable
|
|
491
|
+
// (see fuse.ts's module header). Under `--in`, the sidecar's `avgBodyLen`
|
|
492
|
+
// covers the whole corpus and must not be reused for the filtered set.
|
|
493
|
+
const avgBodyLen = askIndex && !inPrefix
|
|
494
|
+
? askIndex.avgBodyLen
|
|
495
|
+
: symbolDocs.length
|
|
496
|
+
? symbolDocs.reduce((a, d) => a + bodyLen(d.body), 0) / symbolDocs.length
|
|
497
|
+
: 0;
|
|
498
|
+
// Populated only for bounded file ranking. The baseline path pays no allocation
|
|
499
|
+
// or matched-term tracking cost, and their scalar lexical path remains exact.
|
|
500
|
+
const rawLexicalById = new Map();
|
|
501
|
+
const matchedTermsById = new Map();
|
|
502
|
+
const matchedStrongTermsById = new Map();
|
|
503
|
+
const fileQueryWeights = fileComplement
|
|
504
|
+
? normalizedQueryWeights(q, idf, dfltIdf)
|
|
505
|
+
: new Map();
|
|
506
|
+
const spanStart = (span) => {
|
|
507
|
+
const match = span.match(/^L(\d+)-L\d+$/);
|
|
508
|
+
return match ? Number(match[1]) : Number.POSITIVE_INFINITY;
|
|
509
|
+
};
|
|
510
|
+
if (scopes && scopes.length > 1) {
|
|
511
|
+
// ── Multi-scope repo: partition by sub-project and walk each subgraph
|
|
512
|
+
// separately — so the big scope can't drown the small one — but score every
|
|
513
|
+
// scope against the repo-wide statistics, so the results stay comparable and
|
|
514
|
+
// can be ordered by score. Same scoring functions and blend as the
|
|
515
|
+
// single-scope path; the combination itself lives in fuse.ts. ──
|
|
516
|
+
const byScope = new Map();
|
|
517
|
+
const scopeById = new Map();
|
|
518
|
+
for (const d of symbolDocs) {
|
|
519
|
+
const s = scopeOf(d.n.path, scopes).prefix;
|
|
520
|
+
scopeById.set(d.n.id, s);
|
|
521
|
+
const list = byScope.get(s);
|
|
522
|
+
if (list)
|
|
523
|
+
list.push(d);
|
|
524
|
+
else
|
|
525
|
+
byScope.set(s, [d]);
|
|
526
|
+
}
|
|
527
|
+
// Lazily build every scope's PageRank topology in one node/edge pass. The
|
|
528
|
+
// old nodeFilter path rebuilt the full repo adjacency once per matching
|
|
529
|
+
// scope; on large multi-project repos that turned one query into hundreds
|
|
530
|
+
// of full graph scans before the participation gate could run. Waiting for
|
|
531
|
+
// the first walk keeps no-match and graphRank:false queries edge-scan free.
|
|
532
|
+
let pageRankByScope;
|
|
533
|
+
let collapsedComponents = [];
|
|
534
|
+
let collapsedComponentById;
|
|
535
|
+
let collapsedFileByRepresentative;
|
|
536
|
+
const fusion = rankScopesAndFuse([...byScope.keys()].sort(), {
|
|
537
|
+
lex: (s) => {
|
|
538
|
+
const docs = byScope.get(s);
|
|
539
|
+
// Repo-wide IDF and length prior — the SAME `idf`/`avgBodyLen` the
|
|
540
|
+
// single-scope path uses. Scoring each scope against its own corpus
|
|
541
|
+
// (as this did before #117) made a term worth a different amount in
|
|
542
|
+
// every scope, so the resulting scores could not be compared and the
|
|
543
|
+
// combined order had to fall back on rank. Only the PARTITION is
|
|
544
|
+
// per-scope now; the statistics are global.
|
|
545
|
+
const out = new Map();
|
|
546
|
+
for (const { n, name, path, body } of docs) {
|
|
547
|
+
const factor = testFactor(n.path);
|
|
548
|
+
const total = (score(q, name, idf) * 3 +
|
|
549
|
+
score(q, path, idf) * 2 +
|
|
550
|
+
bm25(q, body, idf, bodyLen(body), avgBodyLen)) *
|
|
551
|
+
factor;
|
|
552
|
+
if (total > 0) {
|
|
553
|
+
out.set(n.id, total);
|
|
554
|
+
if (fileComplement) {
|
|
555
|
+
rawLexicalById.set(n.id, total);
|
|
556
|
+
matchedTermsById.set(n.id, matchedLexicalTerms(q, name, path, body));
|
|
557
|
+
if (n.kind !== "file")
|
|
558
|
+
matchedStrongTermsById.set(n.id, matchedStrongTerms(q, name));
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return out;
|
|
563
|
+
},
|
|
564
|
+
// Body-only suppression: does any doc in this scope carry a query term
|
|
565
|
+
// in an identifier (name) or its path, rather than only in prose?
|
|
566
|
+
hasIdentifierMatch: (s) => (byScope.get(s) ?? []).some(({ name, path }) => {
|
|
567
|
+
for (const t of q.keys())
|
|
568
|
+
if (hasTerm(name, t) || hasTerm(path, t))
|
|
569
|
+
return true;
|
|
570
|
+
return false;
|
|
571
|
+
}),
|
|
572
|
+
walk: (s, seeds) => {
|
|
573
|
+
if (!graphRank)
|
|
574
|
+
return new Map();
|
|
575
|
+
pageRankByScope ??= preparePageRankPartitions(graph, (id) => scopeById.get(id));
|
|
576
|
+
const topology = pageRankByScope?.get(s);
|
|
577
|
+
return topology
|
|
578
|
+
? personalizedPageRankPrepared(topology, seeds)
|
|
579
|
+
: new Map();
|
|
580
|
+
},
|
|
581
|
+
rankFactor: (_s, id) => testFactor(byId.get(id)?.path ?? ""),
|
|
582
|
+
collapseCandidates: fileComplement
|
|
583
|
+
? (candidates) => {
|
|
584
|
+
collapsedComponents = [...candidates];
|
|
585
|
+
collapsedComponentById = new Map(candidates.map((candidate) => [candidate.id, candidate]));
|
|
586
|
+
const fileCandidates = candidates.map((candidate) => {
|
|
587
|
+
const n = byId.get(candidate.id);
|
|
588
|
+
const rawLexical = rawLexicalById.get(candidate.id) ?? 0;
|
|
589
|
+
return {
|
|
590
|
+
id: candidate.id,
|
|
591
|
+
file: n?.path ?? "",
|
|
592
|
+
kind: n?.kind === "file" ? "file" : "symbol",
|
|
593
|
+
rawLexical,
|
|
594
|
+
lexical: candidate.lexical,
|
|
595
|
+
graph: candidate.graph,
|
|
596
|
+
rankFactor: candidate.rankFactor,
|
|
597
|
+
baselineScore: candidate.score,
|
|
598
|
+
baselineTieKey: symbolTitle(candidate.id),
|
|
599
|
+
matchedTerms: matchedTermsById.get(candidate.id) ?? new Set(),
|
|
600
|
+
matchedStrongTerms: matchedStrongTermsById.get(candidate.id) ?? new Set(),
|
|
601
|
+
eligible: Boolean(n && n.kind !== "file" && rawLexical > 0),
|
|
602
|
+
spanStart: n ? spanStart(n.span) : undefined,
|
|
603
|
+
};
|
|
604
|
+
});
|
|
605
|
+
const rankedFiles = rankFilesBounded(fileCandidates, fileQueryWeights);
|
|
606
|
+
collapsedFileByRepresentative = new Map(rankedFiles.map((file) => [file.representative.id, file]));
|
|
607
|
+
return rankedFiles.map((file) => {
|
|
608
|
+
const component = collapsedComponentById.get(file.representative.id);
|
|
609
|
+
return {
|
|
610
|
+
id: file.representative.id,
|
|
611
|
+
scope: component.scope,
|
|
612
|
+
score: file.score,
|
|
613
|
+
};
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
: undefined,
|
|
617
|
+
}, GRAPH_WEIGHT, RESCUE_FLOOR);
|
|
618
|
+
if (needsFileQueues) {
|
|
619
|
+
const baselineFusion = combineComparableScopes(collapsedComponents.map(({ id, scope, score }) => ({ id, scope, score })));
|
|
620
|
+
const baselineScoreById = new Map(baselineFusion.ranked.map((ranked) => [ranked.id, ranked.score]));
|
|
621
|
+
const baselineDisplayOrder = (a, b) => b.score - a.score || symbolTitle(a.id).localeCompare(symbolTitle(b.id));
|
|
622
|
+
let baselineTopRanked = baselineFusion.ranked[0];
|
|
623
|
+
for (const ranked of baselineFusion.ranked.slice(1))
|
|
624
|
+
if (baselineTopRanked && baselineDisplayOrder(ranked, baselineTopRanked) < 0)
|
|
625
|
+
baselineTopRanked = ranked;
|
|
626
|
+
const baselineHitsToBuild = includeRankingMetadata
|
|
627
|
+
? [...baselineFusion.ranked].sort(baselineDisplayOrder)
|
|
628
|
+
: baselineTopRanked
|
|
629
|
+
? [baselineTopRanked]
|
|
630
|
+
: [];
|
|
631
|
+
for (const ranked of baselineHitsToBuild) {
|
|
632
|
+
const hit = makeSymbolHit(ranked.id, ranked.score, ranked.scope);
|
|
633
|
+
if (hit) {
|
|
634
|
+
baselineSymbolHits.push(hit);
|
|
635
|
+
baselineHitById.set(ranked.id, hit);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
for (const ranked of fusion.ranked) {
|
|
639
|
+
const file = collapsedFileByRepresentative?.get(ranked.id);
|
|
640
|
+
if (!file)
|
|
641
|
+
continue;
|
|
642
|
+
const materializeFileQueue = () => file.queue.flatMap((member, index) => {
|
|
643
|
+
const component = collapsedComponentById?.get(member.id);
|
|
644
|
+
if (index > 0) {
|
|
645
|
+
const baselineHit = baselineHitById.get(member.id);
|
|
646
|
+
if (baselineHit)
|
|
647
|
+
return [baselineHit];
|
|
648
|
+
}
|
|
649
|
+
const hit = makeSymbolHit(member.id, index === 0
|
|
650
|
+
? ranked.score
|
|
651
|
+
: baselineScoreById.get(member.id) ?? member.baselineScore, component?.scope ?? ranked.scope);
|
|
652
|
+
return hit ? [hit] : [];
|
|
653
|
+
});
|
|
654
|
+
const materializeBaselineQueue = () => file.queue.flatMap((member) => {
|
|
655
|
+
const hit = makeSymbolHit(member.id, baselineScoreById.get(member.id) ?? member.baselineScore, collapsedComponentById?.get(member.id)?.scope ?? ranked.scope);
|
|
656
|
+
return hit ? [hit] : [];
|
|
657
|
+
});
|
|
658
|
+
const hits = includeRankingMetadata
|
|
659
|
+
? materializeFileQueue()
|
|
660
|
+
: (() => {
|
|
661
|
+
const leader = file.queue[0];
|
|
662
|
+
if (!leader)
|
|
663
|
+
return [];
|
|
664
|
+
const hit = makeSymbolHit(leader.id, ranked.score, collapsedComponentById?.get(leader.id)?.scope ?? ranked.scope);
|
|
665
|
+
return hit ? [hit] : [];
|
|
666
|
+
})();
|
|
667
|
+
if (hits.length === 0)
|
|
668
|
+
continue;
|
|
669
|
+
const group = {
|
|
670
|
+
key: `file:${file.file}`,
|
|
671
|
+
hits,
|
|
672
|
+
coverage: file.unionCoverage,
|
|
673
|
+
coverageStrong: file.unionStrongCoverage,
|
|
674
|
+
};
|
|
675
|
+
fileQueueHitsByGroup.set(group.key, materializeFileQueue);
|
|
676
|
+
baselineQueueHitsByGroup.set(group.key, materializeBaselineQueue);
|
|
677
|
+
fileGroups.push(group);
|
|
678
|
+
symbolHits.push(hits[0]);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
for (const ranked of fusion.ranked) {
|
|
683
|
+
const hit = makeSymbolHit(ranked.id, ranked.score, ranked.scope);
|
|
684
|
+
if (hit)
|
|
685
|
+
symbolHits.push(hit);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
// Label + footer only when federation actually happened (or a scope was
|
|
689
|
+
// gated out and is worth mentioning) — a query matching one scope of a
|
|
690
|
+
// multi-scope repo reads exactly like today.
|
|
691
|
+
if (fusion.federated.length > 1 || fusion.alsoMatched.length > 0)
|
|
692
|
+
scopeMeta = { federated: fusion.federated, alsoMatched: fusion.alsoMatched };
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
// Single-scope: the pre-scopes ranking path, byte-identical output when
|
|
696
|
+
// `--in` is absent (Task 3's regression guarantee: `askIndex && !inPrefix`
|
|
697
|
+
// reduces to plain `askIndex` when `inPrefix` is undefined). `avgBodyLen` is
|
|
698
|
+
// now defined once above and shared with the multi-scope branch.
|
|
699
|
+
const lex = new Map(); // node id → lexical score (>0 only)
|
|
700
|
+
let maxLex = 0;
|
|
701
|
+
for (const { n, name, path, body } of symbolDocs) {
|
|
702
|
+
// Name and path are short identifiers → plain idf-weighted overlap; the body
|
|
703
|
+
// is length-normalized via BM25 so long definitions don't win on bulk.
|
|
704
|
+
const factor = testFactor(n.path);
|
|
705
|
+
const total = (score(q, name, idf) * 3 +
|
|
706
|
+
score(q, path, idf) * 2 +
|
|
707
|
+
bm25(q, body, idf, bodyLen(body), avgBodyLen)) *
|
|
708
|
+
factor;
|
|
709
|
+
if (total > 0) {
|
|
710
|
+
lex.set(n.id, total);
|
|
711
|
+
maxLex = Math.max(maxLex, total);
|
|
712
|
+
if (fileComplement) {
|
|
713
|
+
rawLexicalById.set(n.id, total);
|
|
714
|
+
matchedTermsById.set(n.id, matchedLexicalTerms(q, name, path, body));
|
|
715
|
+
if (n.kind !== "file")
|
|
716
|
+
matchedStrongTermsById.set(n.id, matchedStrongTerms(q, name));
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// ── Graph-rank: let connectivity to the matched set reorder and rescue ──
|
|
721
|
+
// `--in` restricts the walk itself, not just the seeds: without a nodeFilter
|
|
722
|
+
// here, a RESCUE_FLOOR-qualifying neighbour OUTSIDE the prefix could still
|
|
723
|
+
// surface, defeating the "filters before scoring" contract.
|
|
724
|
+
const pr = graphRank && graph && lex.size > 0
|
|
725
|
+
? personalizedPageRank(graph, lex, inPrefix ? { nodeFilter: (id) => pathUnderPrefix(byId.get(id)?.path ?? "", inPrefix) } : {})
|
|
726
|
+
: new Map();
|
|
727
|
+
// Candidate symbols = everything the query word-matched, plus nodes the walk
|
|
728
|
+
// found strongly central even without a word match (RESCUE_FLOOR).
|
|
729
|
+
const candidates = new Set(lex.keys());
|
|
730
|
+
for (const [id, p] of pr)
|
|
731
|
+
if (p >= RESCUE_FLOOR)
|
|
732
|
+
candidates.add(id);
|
|
733
|
+
const singleCandidates = [];
|
|
734
|
+
for (const id of candidates) {
|
|
735
|
+
const n = byId.get(id);
|
|
736
|
+
if (!n)
|
|
737
|
+
continue;
|
|
738
|
+
const lexical = maxLex > 0 ? (lex.get(id) ?? 0) / maxLex : 0;
|
|
739
|
+
const graphScore = pr.get(id) ?? 0;
|
|
740
|
+
const rankFactor = testFactor(n.path);
|
|
741
|
+
// Apply the test penalty after normalization too. Applying it only to the
|
|
742
|
+
// raw lexical score is not enough: if a test is still the strongest raw
|
|
743
|
+
// match, dividing by maxLex restores it to 1.0 and erases the de-rank.
|
|
744
|
+
const baseline = (lexical + GRAPH_WEIGHT * graphScore) * rankFactor;
|
|
745
|
+
if (baseline <= 0)
|
|
746
|
+
continue;
|
|
747
|
+
singleCandidates.push({ id, n, lexical, graph: graphScore, rankFactor, baseline });
|
|
748
|
+
}
|
|
749
|
+
const candidateById = fileComplement
|
|
750
|
+
? new Map(singleCandidates.map((candidate) => [candidate.id, candidate]))
|
|
751
|
+
: undefined;
|
|
752
|
+
const rankedFiles = fileComplement
|
|
753
|
+
? rankFilesBounded(singleCandidates.map((candidate) => {
|
|
754
|
+
const rawLexical = rawLexicalById.get(candidate.id) ?? 0;
|
|
755
|
+
return {
|
|
756
|
+
id: candidate.id,
|
|
757
|
+
file: candidate.n.path,
|
|
758
|
+
kind: candidate.n.kind === "file" ? "file" : "symbol",
|
|
759
|
+
rawLexical,
|
|
760
|
+
lexical: candidate.lexical,
|
|
761
|
+
graph: candidate.graph,
|
|
762
|
+
rankFactor: candidate.rankFactor,
|
|
763
|
+
baselineScore: candidate.baseline,
|
|
764
|
+
baselineTieKey: symbolTitle(candidate.id),
|
|
765
|
+
matchedTerms: matchedTermsById.get(candidate.id) ?? new Set(),
|
|
766
|
+
matchedStrongTerms: matchedStrongTermsById.get(candidate.id) ?? new Set(),
|
|
767
|
+
eligible: candidate.n.kind !== "file" && rawLexical > 0,
|
|
768
|
+
spanStart: spanStart(candidate.n.span),
|
|
769
|
+
};
|
|
770
|
+
}), fileQueryWeights)
|
|
771
|
+
: undefined;
|
|
772
|
+
if (needsFileQueues && rankedFiles) {
|
|
773
|
+
const baselineCandidateOrder = (a, b) => b.baseline - a.baseline ||
|
|
774
|
+
`${a.n.name} · ${a.n.kind}`.localeCompare(`${b.n.name} · ${b.n.kind}`);
|
|
775
|
+
let baselineTopCandidate = singleCandidates[0];
|
|
776
|
+
for (const candidate of singleCandidates.slice(1))
|
|
777
|
+
if (baselineTopCandidate && baselineCandidateOrder(candidate, baselineTopCandidate) < 0)
|
|
778
|
+
baselineTopCandidate = candidate;
|
|
779
|
+
const baselineCandidatesToBuild = includeRankingMetadata
|
|
780
|
+
? [...singleCandidates].sort(baselineCandidateOrder)
|
|
781
|
+
: baselineTopCandidate
|
|
782
|
+
? [baselineTopCandidate]
|
|
783
|
+
: [];
|
|
784
|
+
for (const candidate of baselineCandidatesToBuild) {
|
|
785
|
+
const hit = makeSymbolHit(candidate.id, candidate.baseline);
|
|
786
|
+
if (hit) {
|
|
787
|
+
baselineSymbolHits.push(hit);
|
|
788
|
+
baselineHitById.set(candidate.id, hit);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
for (const file of rankedFiles) {
|
|
792
|
+
const materializeFileQueue = () => file.queue.flatMap((member, index) => {
|
|
793
|
+
if (index > 0) {
|
|
794
|
+
const baselineHit = baselineHitById.get(member.id);
|
|
795
|
+
if (baselineHit)
|
|
796
|
+
return [baselineHit];
|
|
797
|
+
}
|
|
798
|
+
const hit = makeSymbolHit(member.id, index === 0 ? file.score : member.baselineScore);
|
|
799
|
+
return hit ? [hit] : [];
|
|
800
|
+
});
|
|
801
|
+
const materializeBaselineQueue = () => file.queue.flatMap((member) => {
|
|
802
|
+
const hit = makeSymbolHit(member.id, member.baselineScore);
|
|
803
|
+
return hit ? [hit] : [];
|
|
804
|
+
});
|
|
805
|
+
const hits = includeRankingMetadata
|
|
806
|
+
? materializeFileQueue()
|
|
807
|
+
: (() => {
|
|
808
|
+
const leader = file.queue[0];
|
|
809
|
+
if (!leader)
|
|
810
|
+
return [];
|
|
811
|
+
const hit = makeSymbolHit(leader.id, file.score);
|
|
812
|
+
return hit ? [hit] : [];
|
|
813
|
+
})();
|
|
814
|
+
if (hits.length === 0)
|
|
815
|
+
continue;
|
|
816
|
+
const group = {
|
|
817
|
+
key: `file:${file.file}`,
|
|
818
|
+
hits,
|
|
819
|
+
coverage: file.unionCoverage,
|
|
820
|
+
coverageStrong: file.unionStrongCoverage,
|
|
821
|
+
};
|
|
822
|
+
fileQueueHitsByGroup.set(group.key, materializeFileQueue);
|
|
823
|
+
baselineQueueHitsByGroup.set(group.key, materializeBaselineQueue);
|
|
824
|
+
fileGroups.push(group);
|
|
825
|
+
symbolHits.push(hits[0]);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
else if (rankedFiles) {
|
|
829
|
+
for (const file of rankedFiles) {
|
|
830
|
+
const candidate = candidateById?.get(file.representative.id);
|
|
831
|
+
const hit = candidate ? makeSymbolHit(candidate.id, file.score) : null;
|
|
832
|
+
if (hit)
|
|
833
|
+
symbolHits.push(hit);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
else {
|
|
837
|
+
for (const candidate of singleCandidates) {
|
|
838
|
+
const hit = makeSymbolHit(candidate.id, candidate.baseline);
|
|
839
|
+
if (hit)
|
|
840
|
+
symbolHits.push(hit);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
// Concepts and symbols live on comparable 0..~1.5 scales so they merge fairly:
|
|
845
|
+
// concept scores are normalized to their own max; symbol scores use the
|
|
846
|
+
// lexical-normalized + graph-weighted blend.
|
|
847
|
+
for (const h of conceptHits)
|
|
848
|
+
h.score = maxConcept > 0 ? h.score / maxConcept : 0;
|
|
849
|
+
const scored = [...conceptHits, ...symbolHits];
|
|
850
|
+
scored.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
|
|
851
|
+
const baselineScored = needsFileQueues
|
|
852
|
+
? [...conceptHits, ...baselineSymbolHits].sort((a, b) => b.score - a.score || a.title.localeCompare(b.title))
|
|
853
|
+
: scored;
|
|
854
|
+
const conceptGroups = conceptHits.map((hit, index) => ({
|
|
855
|
+
key: selectionGroupOf.get(hit) ?? `concept:${index}`,
|
|
856
|
+
hits: [hit],
|
|
857
|
+
coverage: matchedOf.get(hit) ?? 0,
|
|
858
|
+
coverageStrong: matchedStrongOf.get(hit) ?? 0,
|
|
859
|
+
}));
|
|
860
|
+
const unlockedGroups = [...conceptGroups, ...fileGroups].sort((a, b) => b.hits[0].score - a.hits[0].score ||
|
|
861
|
+
a.hits[0].title.localeCompare(b.hits[0].title));
|
|
862
|
+
const sameHit = (a, b) => a.kind === b.kind && a.pointer === b.pointer && a.title === b.title;
|
|
863
|
+
let projectedGroups = unlockedGroups;
|
|
864
|
+
let lockedGroupKey;
|
|
865
|
+
if (fileTopLock && fileFirst && baselineScored[0]) {
|
|
866
|
+
const baselineTop = baselineScored[0];
|
|
867
|
+
const key = selectionGroupOf.get(baselineTop) ?? "locked:top";
|
|
868
|
+
lockedGroupKey = key;
|
|
869
|
+
const existing = unlockedGroups.find((group) => group.key === key);
|
|
870
|
+
const baselineQueue = includeRankingMetadata || limit > unlockedGroups.length
|
|
871
|
+
? baselineQueueHitsByGroup.get(key)?.() ?? baselineScored.filter((hit) => (selectionGroupOf.get(hit) ?? "") === key)
|
|
872
|
+
: [];
|
|
873
|
+
const locked = existing
|
|
874
|
+
? {
|
|
875
|
+
...existing,
|
|
876
|
+
hits: [
|
|
877
|
+
baselineTop,
|
|
878
|
+
...baselineQueue.filter((hit) => !sameHit(hit, baselineTop)),
|
|
879
|
+
],
|
|
880
|
+
}
|
|
881
|
+
: {
|
|
882
|
+
key,
|
|
883
|
+
hits: baselineQueue.length ? baselineQueue : [baselineTop],
|
|
884
|
+
coverage: matchedOf.get(baselineTop) ?? 0,
|
|
885
|
+
coverageStrong: matchedStrongOf.get(baselineTop) ?? 0,
|
|
886
|
+
};
|
|
887
|
+
projectedGroups = [
|
|
888
|
+
locked,
|
|
889
|
+
...unlockedGroups.filter((group) => group.key !== key),
|
|
890
|
+
];
|
|
891
|
+
}
|
|
892
|
+
// Ranking always sees the complete candidate universe, but a normal bounded
|
|
893
|
+
// request usually needs only one leader per file. Materialize secondary span
|
|
894
|
+
// queues only when the requested output can actually reach a second round.
|
|
895
|
+
if (fileTopLock &&
|
|
896
|
+
fileFirst &&
|
|
897
|
+
!includeRankingMetadata &&
|
|
898
|
+
limit > projectedGroups.length) {
|
|
899
|
+
projectedGroups = projectedGroups.map((group) => {
|
|
900
|
+
if (group.key === lockedGroupKey)
|
|
901
|
+
return group;
|
|
902
|
+
const materialize = fileQueueHitsByGroup.get(group.key);
|
|
903
|
+
return materialize ? { ...group, hits: materialize() } : group;
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
// File-aware ranking feeds latent groups to the existing file-first frontier.
|
|
907
|
+
// Leaders are listed first so every file/concept emits once before any
|
|
908
|
+
// secondary span; each group's remaining queue retains baseline order.
|
|
909
|
+
const selected = fileTopLock && fileFirst
|
|
910
|
+
? roundRobinQueues(projectedGroups.map((group) => group.hits), limit)
|
|
911
|
+
: fileFirst
|
|
912
|
+
? fileFirstRoundRobin(scored.map((hit, index) => ({
|
|
913
|
+
group: selectionGroupOf.get(hit) ?? `ungrouped:${index}`,
|
|
914
|
+
value: hit,
|
|
915
|
+
})), limit)
|
|
916
|
+
: scored;
|
|
917
|
+
const top = selected[0] ?? scored[0];
|
|
918
|
+
if (fileTopLock && top?.scope && scopeMeta) {
|
|
919
|
+
scopeMeta = {
|
|
920
|
+
federated: [...new Set([top.scope, ...scopeMeta.federated])],
|
|
921
|
+
alsoMatched: scopeMeta.alsoMatched.filter((match) => match.scope !== top.scope),
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
const ranking = includeRankingMetadata && needsFileQueues
|
|
925
|
+
? {
|
|
926
|
+
groups: unlockedGroups.slice(0, limit).map((group) => ({
|
|
927
|
+
...group,
|
|
928
|
+
hits: group.hits.slice(0, limit),
|
|
929
|
+
baselineHits: baselineScored
|
|
930
|
+
.filter((hit) => selectionGroupOf.get(hit) === group.key)
|
|
931
|
+
.slice(0, limit),
|
|
932
|
+
})),
|
|
933
|
+
baseline: baselineScored.slice(0, limit).map((hit, index) => ({
|
|
934
|
+
group: selectionGroupOf.get(hit) ?? `ungrouped:${index}`,
|
|
935
|
+
hit,
|
|
936
|
+
})),
|
|
937
|
+
baselineCoverage: baselineScored[0]
|
|
938
|
+
? matchedOf.get(baselineScored[0]) ?? 0
|
|
939
|
+
: undefined,
|
|
940
|
+
baselineCoverageStrong: baselineScored[0]
|
|
941
|
+
? matchedStrongOf.get(baselineScored[0]) ?? 0
|
|
942
|
+
: undefined,
|
|
943
|
+
}
|
|
944
|
+
: undefined;
|
|
945
|
+
return {
|
|
946
|
+
query,
|
|
947
|
+
mode: scored.length ? "lexical" : "empty",
|
|
948
|
+
hits: selected.slice(0, limit),
|
|
949
|
+
scopes: scopeMeta,
|
|
950
|
+
coverage: top && q.size > 0 ? matchedOf.get(top) ?? 0 : undefined,
|
|
951
|
+
coverageStrong: top && q.size > 0 ? matchedStrongOf.get(top) ?? 0 : undefined,
|
|
952
|
+
...(ranking ? { ranking } : {}),
|
|
953
|
+
// Zero hits on a genuinely multi-scope graph names the scopes that exist,
|
|
954
|
+
// so a query that missed everywhere still tells the caller where to look.
|
|
955
|
+
note: scored.length
|
|
956
|
+
? undefined
|
|
957
|
+
: `no matching nodes — try different words, or \`${cmd("graft build")}\` if the code map is empty${scopesHereClause(scopes ?? [])}`,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
/** Parse a `path:Lx-Ly` pointer into its parts, or null if it isn't one
|
|
961
|
+
* (concept multi-path lists and raw import strings return null). */
|
|
962
|
+
function parseSpan(pointer) {
|
|
963
|
+
const m = pointer.match(/^(.*):L(\d+)-L(\d+)$/);
|
|
964
|
+
if (!m)
|
|
965
|
+
return null;
|
|
966
|
+
return { path: m[1], from: Number(m[2]), to: Number(m[3]) };
|
|
967
|
+
}
|
|
968
|
+
/** Read the source lines [from, to] (1-indexed, inclusive) of `path` under `root`,
|
|
969
|
+
* capped at {@link MAX_SPAN_LINES}. Returns null if the file can't be read. */
|
|
970
|
+
function sliceSpan(root, path, from, to) {
|
|
971
|
+
try {
|
|
972
|
+
const source = readSourceFile(join(root, path));
|
|
973
|
+
if (source === null)
|
|
974
|
+
return null; // unsupported encoding (e.g. UTF-16BE)
|
|
975
|
+
const lines = source.split("\n");
|
|
976
|
+
const start = Math.max(1, from);
|
|
977
|
+
const end = Math.min(lines.length, to);
|
|
978
|
+
const slice = lines.slice(start - 1, end);
|
|
979
|
+
if (slice.length > MAX_SPAN_LINES) {
|
|
980
|
+
const head = slice.slice(0, MAX_SPAN_LINES);
|
|
981
|
+
head.push(`… (+${slice.length - MAX_SPAN_LINES} more lines; open ${path}:L${start}-L${end})`);
|
|
982
|
+
return head.join("\n");
|
|
983
|
+
}
|
|
984
|
+
return slice.join("\n");
|
|
985
|
+
}
|
|
986
|
+
catch {
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
/** Attach inlined source to every hit whose pointer is a real `path:span`.
|
|
991
|
+
* Crux-first: when the graph carries an LLM-chosen crux for the hit's node and
|
|
992
|
+
* `full` is off, inline that ≤8-line excerpt (with an escalation marker) rather
|
|
993
|
+
* than the whole definition — most hits only need the decision point, and the
|
|
994
|
+
* marker tells the agent exactly how to get the rest when this one doesn't. */
|
|
995
|
+
function inlineSource(root, hits, graph, full) {
|
|
996
|
+
const cruxByPointer = new Map();
|
|
997
|
+
if (!full && graph) {
|
|
998
|
+
for (const n of graph.nodes)
|
|
999
|
+
if (n.crux?.code)
|
|
1000
|
+
cruxByPointer.set(`${n.path}:${n.span}`, n.crux.code);
|
|
1001
|
+
}
|
|
1002
|
+
for (const h of hits) {
|
|
1003
|
+
const s = parseSpan(h.pointer);
|
|
1004
|
+
if (!s)
|
|
1005
|
+
continue;
|
|
1006
|
+
const crux = cruxByPointer.get(h.pointer);
|
|
1007
|
+
if (crux) {
|
|
1008
|
+
h.code = `${crux}\n… (crux — full definition at ${h.pointer}; rerun with --full)`;
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
const code = sliceSpan(root, s.path, s.from, s.to);
|
|
1012
|
+
if (code)
|
|
1013
|
+
h.code = code;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
/** Every repo-relative file path a set of hits points into (dedup). A symbol
|
|
1017
|
+
* pointer is `path:span`; a concept pointer is a comma-joined path list. */
|
|
1018
|
+
function hitFiles(hits) {
|
|
1019
|
+
const files = new Set();
|
|
1020
|
+
for (const h of hits) {
|
|
1021
|
+
const span = parseSpan(h.pointer);
|
|
1022
|
+
if (span) {
|
|
1023
|
+
files.add(span.path);
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
for (const p of h.pointer.split(",").map((s) => s.trim()))
|
|
1027
|
+
if (p && !p.includes(" "))
|
|
1028
|
+
files.add(p);
|
|
1029
|
+
}
|
|
1030
|
+
return files;
|
|
1031
|
+
}
|
|
1032
|
+
/** Baseline = whole size of the files these hits cover, read from the sizes the
|
|
1033
|
+
* build stored on each file node. Zero when the graph predates the `chars` field
|
|
1034
|
+
* (a pre-upgrade index) — the caller then just omits the estimate. */
|
|
1035
|
+
function baselineFor(hits, graph) {
|
|
1036
|
+
if (!graph)
|
|
1037
|
+
return undefined;
|
|
1038
|
+
return savingsFor(graph, hitFiles(hits));
|
|
1039
|
+
}
|
|
1040
|
+
/** Answer a query from the graft/ graph at `dir`. Deterministic, $0. */
|
|
1041
|
+
export function ask(dir, query, opts = {}) {
|
|
1042
|
+
const root = resolve(dir);
|
|
1043
|
+
const outDir = contextDirFor(root, opts.contextDir);
|
|
1044
|
+
const limit = opts.limit ?? 8;
|
|
1045
|
+
const corpus = loadCorpus(outDir);
|
|
1046
|
+
const graphRank = opts.graphRank ?? true;
|
|
1047
|
+
const fileFirst = opts.fileFirst ?? true;
|
|
1048
|
+
// The production path uses bounded file scoring plus an exact baseline top lock.
|
|
1049
|
+
// Internal/raw callers can still request the baseline by disabling
|
|
1050
|
+
// file-first selection. A top lock is projected only on a file-first list.
|
|
1051
|
+
const fileTopLock = opts.fileTopLock ?? fileFirst;
|
|
1052
|
+
const fileComplement = (opts.fileComplement ?? false) || fileTopLock;
|
|
1053
|
+
// Normalized ONCE, up front, and this value used everywhere below
|
|
1054
|
+
// (validation, filtering, structural). `ask --in frontend/` must work exactly
|
|
1055
|
+
// like `--in frontend` — `scopeLabel`/the footer's own `--in <scope>/`
|
|
1056
|
+
// suggestion always includes the slash, so rejecting it would make the tool's
|
|
1057
|
+
// own suggested next command fail — and `--in frontend\sub` must work too,
|
|
1058
|
+
// because that is what a Windows shell's tab-completion produces.
|
|
1059
|
+
const inPrefix = opts.in ? normalizePathPrefix(opts.in) : undefined;
|
|
1060
|
+
// `--in` validated up front, before any mode runs.
|
|
1061
|
+
if (inPrefix && corpus.graph)
|
|
1062
|
+
assertPrefixIndexed(corpus.graph, inPrefix);
|
|
1063
|
+
let result;
|
|
1064
|
+
if (corpus.graph) {
|
|
1065
|
+
const outcome = structural(query, corpus.graph, limit, inPrefix);
|
|
1066
|
+
if (outcome && "result" in outcome) {
|
|
1067
|
+
result = outcome.result;
|
|
1068
|
+
}
|
|
1069
|
+
else {
|
|
1070
|
+
result = lexical(query, corpus, limit, graphRank, inPrefix, fileFirst, fileComplement, fileTopLock, opts.includeRankingMetadata ?? false);
|
|
1071
|
+
// A structural-intent query that couldn't be answered structurally still
|
|
1072
|
+
// gets a prominent note on the lexical fallback result — never silent.
|
|
1073
|
+
if (outcome && "fallthroughNote" in outcome) {
|
|
1074
|
+
result.note = result.note ? `${outcome.fallthroughNote}\n${result.note}` : outcome.fallthroughNote;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
else {
|
|
1079
|
+
result = lexical(query, corpus, limit, graphRank, inPrefix, fileFirst, fileComplement, fileTopLock, opts.includeRankingMetadata ?? false);
|
|
1080
|
+
}
|
|
1081
|
+
if (opts.source) {
|
|
1082
|
+
inlineSource(root, result.hits, corpus.graph, opts.full ?? false);
|
|
1083
|
+
if (result.ranking) {
|
|
1084
|
+
const internalHits = [
|
|
1085
|
+
...result.ranking.groups.flatMap((group) => group.hits),
|
|
1086
|
+
...result.ranking.groups.flatMap((group) => group.baselineHits ?? []),
|
|
1087
|
+
...result.ranking.baseline.map((entry) => entry.hit),
|
|
1088
|
+
];
|
|
1089
|
+
inlineSource(root, [...new Set(internalHits)], corpus.graph, opts.full ?? false);
|
|
1090
|
+
}
|
|
1091
|
+
// The pack is truly substitutive only in retriever mode (spans inlined), so
|
|
1092
|
+
// the "vs reading whole files" estimate is only honest here.
|
|
1093
|
+
result.saved = baselineFor(result.hits, corpus.graph);
|
|
1094
|
+
}
|
|
1095
|
+
else if (opts.full && corpus.graph) {
|
|
1096
|
+
// --full without --source: for section nodes, inline their stored body_text
|
|
1097
|
+
// so the agent sees the full markdown section without opening the file.
|
|
1098
|
+
// Code nodes have no body_text without --deep, so they're unaffected.
|
|
1099
|
+
const bodyByPointer = new Map();
|
|
1100
|
+
for (const n of corpus.graph.nodes) {
|
|
1101
|
+
if (n.kind === 'section' && n.body_text) {
|
|
1102
|
+
bodyByPointer.set(`${n.path}:${n.span}`, n.body_text);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
for (const h of result.hits) {
|
|
1106
|
+
if (!h.code) {
|
|
1107
|
+
const body = bodyByPointer.get(h.pointer);
|
|
1108
|
+
if (body)
|
|
1109
|
+
h.code = body;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
return result;
|
|
1114
|
+
}
|
|
1115
|
+
/** Signatures-only view of one file, straight from the wiring graph — the
|
|
1116
|
+
* cheapest way to understand a file's API surface (~10× less than reading it).
|
|
1117
|
+
* `file` is matched as an exact repo-relative path, then as a basename. */
|
|
1118
|
+
export function skeleton(dir, file, opts = {}) {
|
|
1119
|
+
const outDir = contextDirFor(resolve(dir), opts.contextDir);
|
|
1120
|
+
const graph = loadGraphCached(outDir);
|
|
1121
|
+
if (!graph)
|
|
1122
|
+
return { file, entries: [], note: `no wiring graph — run \`${cmd("graft build")}\` first` };
|
|
1123
|
+
let defs = graph.nodes.filter((n) => n.kind !== "file" && n.path === file);
|
|
1124
|
+
if (!defs.length) {
|
|
1125
|
+
const matches = new Set(graph.nodes.filter((n) => n.path === file || n.path.endsWith(`/${file}`)).map((n) => n.path));
|
|
1126
|
+
if (matches.size > 1)
|
|
1127
|
+
return { file, entries: [], note: `ambiguous — matches: ${[...matches].sort().join(", ")}` };
|
|
1128
|
+
const [path] = matches;
|
|
1129
|
+
if (path)
|
|
1130
|
+
defs = graph.nodes.filter((n) => n.kind !== "file" && n.path === path);
|
|
1131
|
+
}
|
|
1132
|
+
if (!defs.length)
|
|
1133
|
+
return { file, entries: [], note: "no definitions indexed for this file" };
|
|
1134
|
+
const startLine = (span) => Number(span.match(/^L(\d+)/)?.[1] ?? 0);
|
|
1135
|
+
defs.sort((a, b) => startLine(a.span) - startLine(b.span));
|
|
1136
|
+
return {
|
|
1137
|
+
file: defs[0].path,
|
|
1138
|
+
entries: defs.map((n) => ({
|
|
1139
|
+
name: n.name,
|
|
1140
|
+
kind: n.kind,
|
|
1141
|
+
span: n.span,
|
|
1142
|
+
signature: n.signature,
|
|
1143
|
+
summary: n.summary?.split("\n")[0].trim() || undefined,
|
|
1144
|
+
})),
|
|
1145
|
+
saved: savingsFor(graph, [defs[0].path]),
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
/** Render a {@link SkeletonResult} as compact markdown. */
|
|
1149
|
+
export function formatSkeleton(r) {
|
|
1150
|
+
const head = `${cmd("graft skeleton")} — ${r.file}`;
|
|
1151
|
+
if (!r.entries.length)
|
|
1152
|
+
return `${head}\n\n${r.note ?? "no definitions."}\n`;
|
|
1153
|
+
const lines = r.entries.map((e) => {
|
|
1154
|
+
const sig = e.signature ? ` ${e.signature}` : "";
|
|
1155
|
+
const sum = e.summary ? ` — ${e.summary}` : "";
|
|
1156
|
+
return `- ${e.span} ${e.kind} ${e.name}${sig}${sum}`;
|
|
1157
|
+
});
|
|
1158
|
+
const body = `${head}\n${lines.join("\n")}`;
|
|
1159
|
+
return withSavings(body, r.saved) + "\n";
|
|
1160
|
+
}
|
|
1161
|
+
/** Rough tokens for a byte length (≈ 4 chars/token; good enough for an estimate). */
|
|
1162
|
+
function toTokens(chars) {
|
|
1163
|
+
return Math.round(chars / 4);
|
|
1164
|
+
}
|
|
1165
|
+
/** The rendered pack, before the savings header: `body` when there are hits to
|
|
1166
|
+
* show, or the finished `text` when there are none (no savings line then). */
|
|
1167
|
+
function renderAsk(r) {
|
|
1168
|
+
const head = `${cmd("graft ask")} — "${r.query}" (${r.mode})`;
|
|
1169
|
+
// The note prints as its own prominent line(s) right under the header —
|
|
1170
|
+
// above every hit — so a loud structural-fallthrough note (or the
|
|
1171
|
+
// no-structural-edges / no-lexical-match note) can never be missed by only
|
|
1172
|
+
// being embedded inline in the header parenthetical. Only a genuine
|
|
1173
|
+
// fallthrough/warning line (from `fallthroughNoteFor`, always prefixed
|
|
1174
|
+
// "structural index:") gets the ⚠ marker; the benign structural header note
|
|
1175
|
+
// ("callers / references of X" / "outgoing edges from X") prints plain —
|
|
1176
|
+
// it's informational, not a warning.
|
|
1177
|
+
const noteBlock = r.note
|
|
1178
|
+
? r.note
|
|
1179
|
+
.split("\n")
|
|
1180
|
+
.map((l) => (l.startsWith("structural index:") ? `⚠ ${l}` : l))
|
|
1181
|
+
.join("\n")
|
|
1182
|
+
: "";
|
|
1183
|
+
// Notes go first was here; learnings removed.
|
|
1184
|
+
const teamNotes = [];
|
|
1185
|
+
if (r.notesHint)
|
|
1186
|
+
teamNotes.push(r.notesHint, "");
|
|
1187
|
+
if (r.hits.length === 0) {
|
|
1188
|
+
return { text: `${head}\n\n${noteBlock || "no matches."}${escalationNudge(r)}\n` };
|
|
1189
|
+
}
|
|
1190
|
+
const lines = noteBlock ? [head, "", noteBlock, "", ...teamNotes] : [head, "", ...teamNotes];
|
|
1191
|
+
if (r.mode === "structural") {
|
|
1192
|
+
for (const h of r.hits) {
|
|
1193
|
+
const tail = h.snippet ? ` — ${h.snippet}` : "";
|
|
1194
|
+
lines.push(`- ${h.title} ${h.pointer} (${h.relation})${tail}`);
|
|
1195
|
+
if (h.code)
|
|
1196
|
+
lines.push("", "```", h.code, "```", "");
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
else {
|
|
1200
|
+
r.hits.forEach((h, i) => {
|
|
1201
|
+
// Multi-scope results label each hit with its sub-project (root scope
|
|
1202
|
+
// "" stays unlabeled) so a reviewer can tell WHICH codebase answered.
|
|
1203
|
+
const label = r.scopes && h.scope ? `[${h.scope}/] ` : "";
|
|
1204
|
+
lines.push(`${i + 1}. ${label}${h.title} [${h.kind}]`);
|
|
1205
|
+
lines.push(` ${h.pointer}`);
|
|
1206
|
+
if (h.snippet)
|
|
1207
|
+
lines.push(` ${h.snippet}`);
|
|
1208
|
+
if (h.related?.length)
|
|
1209
|
+
lines.push(` related: ${h.related.join(", ")}`);
|
|
1210
|
+
if (h.code)
|
|
1211
|
+
lines.push("", "```", h.code, "```");
|
|
1212
|
+
lines.push("");
|
|
1213
|
+
});
|
|
1214
|
+
lines.push(...scopeFooterLines(r));
|
|
1215
|
+
}
|
|
1216
|
+
return { body: lines.join("\n").trimEnd() };
|
|
1217
|
+
}
|
|
1218
|
+
/** Render an {@link AskResult} as a compact markdown context pack. */
|
|
1219
|
+
export function formatAsk(r) {
|
|
1220
|
+
return formatAskWithSaved(r).text;
|
|
1221
|
+
}
|
|
1222
|
+
/** {@link formatAsk}, plus the tokens its pack saved (what {@link askSavedTokens} says), from the one render. */
|
|
1223
|
+
export function formatAskWithSaved(r) {
|
|
1224
|
+
const out = renderAsk(r);
|
|
1225
|
+
if ("text" in out)
|
|
1226
|
+
return { text: out.text, savedTokens: 0 };
|
|
1227
|
+
const savings = askSavingsLine(r, out.body);
|
|
1228
|
+
return { text: (savings ? `${savings}\n\n${out.body}` : out.body) + escalationNudge(r) + "\n", savedTokens: savedTokens(r, out.body) };
|
|
1229
|
+
}
|
|
1230
|
+
/** Tokens this lookup's pack saved against reading the files it covers whole:
|
|
1231
|
+
* the number `formatAsk` prints as "tokens saved ≈ N", or 0 when it prints none. */
|
|
1232
|
+
export function askSavedTokens(r) {
|
|
1233
|
+
const out = renderAsk(r);
|
|
1234
|
+
return "body" in out ? savedTokens(r, out.body) : 0;
|
|
1235
|
+
}
|
|
1236
|
+
/** Baseline tokens minus pack tokens, or 0 when there is no estimate or no saving to claim. */
|
|
1237
|
+
function savedTokens(r, body) {
|
|
1238
|
+
if (!r.saved || r.saved.baselineChars <= 0)
|
|
1239
|
+
return 0;
|
|
1240
|
+
return Math.max(0, toTokens(r.saved.baselineChars) - toTokens(body.length));
|
|
1241
|
+
}
|
|
1242
|
+
/** When a lexical `ask` returns thin/no results, the productive next move is a
|
|
1243
|
+
* different TOOL, not a re-phrased ask — re-asking is the "over-ask" trap that
|
|
1244
|
+
* inflates cost. Surface the escalation in the output so the agent switches to
|
|
1245
|
+
* grep/skeleton/callers instead. Silent when the result is already rich (>3
|
|
1246
|
+
* hits) or in structural mode (a resolved who-calls IS the answer). */
|
|
1247
|
+
function escalationNudge(r) {
|
|
1248
|
+
if ((r.mode !== "lexical" && r.mode !== "empty") || r.hits.length > 3)
|
|
1249
|
+
return "";
|
|
1250
|
+
const n = r.hits.length;
|
|
1251
|
+
return (`\n\n${tag()} ${n === 0 ? "no hits" : `only ${n} hit${n === 1 ? "" : "s"}`} — don't re-ask with new wording; switch tool: ` +
|
|
1252
|
+
`\`${cmd("graft grep")} "<literal>"\` for every occurrence · \`${cmd("graft skeleton")} <file>\` for a file's full API · \`${cmd("graft callers")} <symbol>\` for who-uses.`);
|
|
1253
|
+
}
|
|
1254
|
+
/** The one-line token-saving estimate `ask` prepends in retriever mode, so the
|
|
1255
|
+
* agent gets the number for free in the tool output — no extra work on its end.
|
|
1256
|
+
* `packChars` is measured from the rendered body: exactly what the agent reads.
|
|
1257
|
+
* Header, not footer, for the reason documented on `withSavings`: a trailing
|
|
1258
|
+
* line dies to `head -N` and to host output truncation. */
|
|
1259
|
+
function askSavingsLine(r, body) {
|
|
1260
|
+
const saved = savedTokens(r, body);
|
|
1261
|
+
if (saved <= 0 || !r.saved)
|
|
1262
|
+
return ""; // no saving to claim (tiny files); stay quiet
|
|
1263
|
+
const pack = toTokens(body.length);
|
|
1264
|
+
const base = toTokens(r.saved.baselineChars);
|
|
1265
|
+
const pct = Math.round((saved / base) * 100);
|
|
1266
|
+
return (`${tag()} tokens saved ≈ ${saved.toLocaleString()} (${pct}%) — this pack ≈ ` +
|
|
1267
|
+
`${pack.toLocaleString()} tok vs reading the ${r.saved.files} source file(s) whole ≈ ` +
|
|
1268
|
+
`${base.toLocaleString()} tok. Estimate (baseline = those files read in full).` +
|
|
1269
|
+
savingsTurnNudge(saved));
|
|
1270
|
+
}
|
|
1271
|
+
/** Multi-scope footer: answers a reviewer's two questions — "which
|
|
1272
|
+
* sub-projects did this pack come from?" (`matched in:`, displayed-hit counts,
|
|
1273
|
+
* biggest first) and "did anything else match that I should know about?"
|
|
1274
|
+
* (`also matched:` for scopes gated out of fusion, with the flag to narrow).
|
|
1275
|
+
* Empty (zero output) whenever the result isn't a federated multi-scope one. */
|
|
1276
|
+
function scopeFooterLines(r) {
|
|
1277
|
+
if (!r.scopes)
|
|
1278
|
+
return [];
|
|
1279
|
+
const perScope = new Map();
|
|
1280
|
+
for (const h of r.hits)
|
|
1281
|
+
if (h.scope !== undefined)
|
|
1282
|
+
perScope.set(h.scope, (perScope.get(h.scope) ?? 0) + 1);
|
|
1283
|
+
const parts = [...perScope]
|
|
1284
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
1285
|
+
.map(([s, c]) => `${scopeLabel(s)} (${c})`);
|
|
1286
|
+
const out = parts.length ? [`matched in: ${parts.join(" · ")}`] : [];
|
|
1287
|
+
for (const m of r.scopes.alsoMatched)
|
|
1288
|
+
out.push(`also matched: ${scopeLabel(m.scope)} — narrow with --in ${scopeLabel(m.scope)}`);
|
|
1289
|
+
return out;
|
|
1290
|
+
}
|
|
1291
|
+
//# sourceMappingURL=ask.js.map
|