weavatrix 0.2.7 → 0.2.9

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.
@@ -26,14 +26,14 @@ const PROFILE_CAPS = Object.freeze({
26
26
 
27
27
  // The files whose mtime the stdio shell watches for hot reload — keep in sync with the imports in
28
28
  // loadHotApi below (catalog.mjs itself is last: a change here re-derives the whole table).
29
- export const HOT_FILES = ['tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-source.mjs', 'tools-context.mjs', 'tools-actions.mjs', 'tools-architecture.mjs', 'tools-history.mjs', 'tools-company.mjs', 'tools-verified-change.mjs', 'catalog.mjs']
29
+ export const HOT_FILES = ['tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-source.mjs', 'tools-context.mjs', 'tools-endpoints.mjs', 'tools-actions.mjs', 'tools-architecture.mjs', 'tools-history.mjs', 'tools-company.mjs', 'tools-verified-change.mjs', 'catalog.mjs']
30
30
 
31
- function buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc, tv, caps}) {
31
+ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
32
32
  const tools = [
33
33
  {cap: 'graph', name: 'graph_stats', description: 'Return summary statistics: node count, edge count, communities, versioned edge-provenance/legacy-confidence breakdowns, and graph build time vs repo HEAD (staleness).', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tg.tGraphStats(g, ctx)},
34
34
  {cap: 'graph', name: 'get_node', description: 'Get full details for a specific node by label or ID.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID to look up'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNode(g, a, ctx)},
35
35
  {cap: 'graph', name: 'get_neighbors', description: 'Get all direct neighbors of a node with edge details (1 hop, call sites deduped). For transitive impact use get_dependents; for the impact of your current branch changes use change_impact.', inputSchema: {type: 'object', properties: {label: {type: 'string'}, relation_filter: {type: 'string', description: 'Optional: filter by relation type'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNeighbors(g, a, ctx)},
36
- {cap: 'graph', name: 'query_graph', description: 'Explore a focused production-first graph around a concept (BFS/DFS). Exact seed files stay pinned; classified paths and unreferenced constant/field leaves are suppressed unless the question or explicit flags request them. Reports policy and suppression instead of silently flooding the result.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Natural language question or keyword search'}, mode: {type: 'string', enum: ['bfs', 'dfs'], default: 'bfs'}, depth: {type: 'integer', default: 3}, context_filter: {type: 'array', items: {type: 'string'}}, seed_files: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Optional exact repo-relative file paths. When resolved, these are the only seeds unless augment_seeds is true'}, augment_seeds: {type: 'boolean', default: false, description: 'With seed_files, also add fuzzy question-derived seeds; false keeps traversal strictly pinned'}, include_classified: {type: 'boolean', default: false, description: 'Allow traversal through tests/e2e/generated/mocks/stories/docs/benchmarks/temp and explicitly excluded paths. An explicit class term in the question enables only that class.'}, include_low_signal: {type: 'boolean', default: false, description: 'Include unreferenced constant/field leaf symbols that do not match a query term'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, required: ['question']}, run: (g, a, ctx) => tg.tQueryGraph(g, a, ctx)},
36
+ {cap: 'graph', name: 'query_graph', description: 'Explore a focused production-first graph around a concept (BFS/DFS). Exact seed files stay pinned; a code-shaped identifier uses only exact same-name declarations. Classified paths and unreferenced constant/field leaves are suppressed unless the question or explicit flags request them. Reports policy and suppression instead of silently flooding the result.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Natural language question or keyword search'}, mode: {type: 'string', enum: ['bfs', 'dfs'], default: 'bfs'}, depth: {type: 'integer', default: 3}, context_filter: {type: 'array', items: {type: 'string'}}, seed_files: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Optional exact repo-relative file paths. When resolved, these are the only seeds unless augment_seeds is true'}, augment_seeds: {type: 'boolean', default: false, description: 'With seed_files, also add fuzzy question-derived seeds; false keeps traversal strictly pinned'}, include_classified: {type: 'boolean', default: false, description: 'Allow traversal through tests/e2e/generated/mocks/stories/docs/benchmarks/temp and explicitly excluded paths. An explicit class term in the question enables only that class.'}, include_low_signal: {type: 'boolean', default: false, description: 'Include unreferenced constant/field leaf symbols that do not match a query term'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, required: ['question']}, run: (g, a, ctx) => tg.tQueryGraph(g, a, ctx)},
37
37
  {cap: 'graph', name: 'god_nodes', description: 'Rank production-code connectivity hubs by unique call/import/reference neighbors, with class/method ownership reported separately from runtime connectivity. Repeated call sites do not inflate the rank; classified tests, generated/build output and other non-product paths are excluded by default.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', default: 10}, include_classified: {type: 'boolean', default: false, description: 'Include tests/e2e/generated/build output/mocks/stories/docs/benchmarks/temp and paths explicitly excluded by repository classification'}}}, run: (g, a, ctx) => tg.tGodNodes(g, a, ctx)},
38
38
  {cap: 'graph', name: 'shortest_path', description: 'Find the shortest path between two concepts in the knowledge graph.', inputSchema: {type: 'object', properties: {source: {type: 'string'}, target: {type: 'string'}, max_hops: {type: 'integer', default: 8}}, required: ['source', 'target']}, run: (g, a) => tg.tShortestPath(g, a)},
39
39
  {cap: 'graph', name: 'get_dependents', description: 'Transitive blast-radius of ONE node: everything that calls/imports/inherits it, directly or through intermediaries (reverse edges, ranked by proximity then connectivity). Symbol queries stay symbol-precise by default; set include_container_importers only for a conservative module-wide radius. Use before refactoring; get_neighbors shows only 1 hop, change_impact covers your whole current diff at once.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID'}, depth: {type: 'integer', description: 'Max reverse hops, default 3', default: 3}, max_nodes: {type: 'integer', description: 'Max dependents to list, default 40', default: 40}, include_container_importers: {type: 'boolean', description: 'Also seed importers of the symbol containing file (broader, conservative; default false)', default: false}}, required: ['label']}, run: (g, a) => ti.tGetDependents(g, a)},
@@ -106,14 +106,15 @@ function buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc, tv, caps}) {
106
106
  {cap: 'source', name: 'read_source', description: "Read the actual source of a node (by label/ID) or a repo-relative file path — the symbol's lines with context. The graph stores only locations, not source text. For a path read, pass start_line to anchor the window anywhere in the file (otherwise it shows the head).", inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'node label or ID'}, path: {type: 'string', description: 'or a repo-relative file path'}, start_line: {type: 'integer', description: 'anchor line: window = start_line-before .. start_line+after'}, before: {type: 'integer', default: 3}, after: {type: 'integer', default: 40}}}, run: (g, a, ctx) => readSource({repoRoot: ctx.repoRoot, resolveNode, isSymbol}, g, a)},
107
107
  {cap: 'source', refreshGraph: true, name: 'inspect_symbol', description: 'Inspect one exact symbol with an on-demand TypeScript/JavaScript LSP reference query, grouped occurrence containers, graph blast radius, complexity facts and bounded local source context. Ambiguous labels fail closed; point queries never replace the broad precision overlay.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_containers: {type: 'integer', minimum: 1, maximum: 50, default: 15}, context_lines: {type: 'integer', minimum: 0, maximum: 40, default: 8}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => ts.tInspectSymbol(g, a, ctx)},
108
108
  {cap: 'source', refreshGraph: true, name: 'context_bundle', description: 'Return one compact, bounded source bundle for an exact symbol: definition, aggregated inbound/outbound containers, exact re-export sites, on-demand TS/JS reference evidence and a few focused source excerpts. Use before an edit when query_graph would be too broad.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_related: {type: 'integer', minimum: 1, maximum: 30, default: 10}, max_reexports: {type: 'integer', minimum: 1, maximum: 100, default: 20}, max_source_files: {type: 'integer', minimum: 1, maximum: 8, default: 4}, context_lines: {type: 'integer', minimum: 0, maximum: 12, default: 4}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => tb.tContextBundle(g, a, ctx, ts.tInspectSymbol)},
109
- {cap: 'health', name: 'find_duplicates', description: "Content-based clone detection over production code (MOSS winnowing over method bodies). Supports high-confidence small clones down to 12 tokens when min_tokens is lowered. Tests, e2e, generated code, mocks, stories, docs, benchmarks and temporary roots are classified separately and excluded by default; opt them in explicitly.", inputSchema: {type: 'object', properties: {min_similarity: {type: 'integer', description: '50-100, default 80 (ignored in semantic mode)'}, min_tokens: {type: 'integer', minimum: 12, maximum: 400, description: 'min fragment size, 12-400; default 50'}, mode: {type: 'string', enum: ['renamed', 'strict', 'semantic'], default: 'renamed'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, include_strings: {type: 'boolean', description: 'Also clone-check large multi-line string literals', default: false}, top_n: {type: 'integer', default: 15}}}, run: (g, a, ctx) => th.tFindDuplicates(g, a, ctx)},
109
+ {cap: 'health', name: 'find_duplicates', description: "Content-based clone detection over production code (MOSS winnowing over method bodies). Supports high-confidence small clones down to 12 tokens when min_tokens is lowered. Tests, classified non-product paths, and all-router framework boilerplate clone groups are excluded by default; opt them in explicitly.", inputSchema: {type: 'object', properties: {min_similarity: {type: 'integer', description: '50-100, default 80 (ignored in semantic mode)'}, min_tokens: {type: 'integer', minimum: 12, maximum: 400, description: 'min fragment size, 12-400; default 50'}, mode: {type: 'string', enum: ['renamed', 'strict', 'semantic'], default: 'renamed'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, include_boilerplate: {type: 'boolean', default: false, description: 'Include clone groups made entirely of conventional *.router.js/ts router symbols'}, include_strings: {type: 'boolean', description: 'Also clone-check large multi-line string literals', default: false}, top_n: {type: 'integer', default: 15}}}, run: (g, a, ctx) => th.tFindDuplicates(g, a, ctx)},
110
110
  {cap: 'health', name: 'find_dead_code', description: 'Conservative review queue for statically unreferenced files, functions, methods and symbols. Returns confidence, reason, bounded evidence and explicit framework/dynamic/reflection/public-API caveats; never an auto-delete verdict. Tests, generated code, mocks, stories, docs, benchmarks and temporary roots are excluded by default.', inputSchema: {type: 'object', properties: {path: {type: 'string', maxLength: 1024, description: 'Optional repo-relative path prefix'}, kinds: {type: 'array', items: {type: 'string', enum: ['file', 'function', 'method', 'symbol']}, maxItems: 4, uniqueItems: true, description: 'Optional candidate kinds; defaults to all'}, min_confidence: {type: 'string', enum: ['high', 'medium', 'low'], default: 'medium', description: 'Minimum confidence to include. low explicitly includes public/framework/dynamic review candidates'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, top_n: {type: 'integer', minimum: 1, maximum: 100, default: 30}}}, run: (g, a, ctx) => th.tFindDeadCode(g, a, ctx)},
111
- {cap: 'health', name: 'run_audit', description: 'Full internal health audit. With base_ref, builds and audits an immutable Git checkout, derives changed files, and compares stable deterministic finding IDs; debt defaults to genuinely new findings. Supply-chain checks remain explicitly uncomparable across the source-only baseline. changed_files without base_ref is only changed-scope, never a new-debt claim.', inputSchema: {type: 'object', properties: {category: {type: 'string', enum: ['unused', 'structure', 'vulnerability', 'malware'], description: 'Only findings of this category'}, min_severity: {type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'], description: 'Minimum severity to include'}, max_findings: {type: 'integer', description: 'Max findings to list, default 30'}, include_malware_scan: {type: 'boolean', description: 'Also grep installed packages for malware heuristics (slow)', default: false}, base_ref: {type: 'string', maxLength: 200, description: 'Optional immutable Git baseline (for example HEAD~1 or origin/main). Enables honest new/existing/fixed debt comparison'}, changed_files: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 500, description: 'Optional explicit repo-relative scope. Without base_ref this is changed-scope only; when omitted with base_ref, files are derived from the Git diff'}, debt: {type: 'string', enum: ['new', 'existing', 'all'], default: 'new', description: 'Baseline comparison view. Defaults to genuinely new deterministic findings when base_ref is present'}}}, run: (g, a, ctx) => th.tRunAudit(g, a, ctx)},
111
+ {cap: 'health', name: 'run_audit', description: 'Production-first internal health audit. Findings whose evidence is entirely test/e2e/generated/mock/story/docs/benchmark/temp or explicitly excluded are suppressed by default; opt them in with include_classified. category=dependencies is a dedicated dependency-health projection across missing, unused and duplicate declarations while preserving each finding\'s native category. With base_ref, builds and audits an immutable Git checkout and compares stable deterministic finding IDs; debt defaults to genuinely new findings. Supply-chain checks remain explicitly uncomparable across the source-only baseline. changed_files without base_ref is only changed-scope, never a new-debt claim.', inputSchema: {type: 'object', properties: {category: {type: 'string', enum: ['dependencies', 'unused', 'structure', 'vulnerability', 'malware'], description: 'Only findings of this category; dependencies selects dependency manifest/import findings across native categories'}, min_severity: {type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'], description: 'Minimum severity to include'}, max_findings: {type: 'integer', description: 'Max findings to list, default 30'}, include_classified: {type: 'boolean', default: false, description: 'Include findings whose evidence is entirely tests/e2e/generated/mocks/stories/docs/benchmarks/temp or explicitly excluded'}, include_malware_scan: {type: 'boolean', description: 'Also grep installed packages for malware heuristics (slow)', default: false}, base_ref: {type: 'string', maxLength: 200, description: 'Optional immutable Git baseline (for example HEAD~1 or origin/main). Enables honest new/existing/fixed debt comparison'}, changed_files: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 500, description: 'Optional explicit repo-relative scope. Without base_ref this is changed-scope only; when omitted with base_ref, files are derived from the Git diff'}, debt: {type: 'string', enum: ['new', 'existing', 'all'], default: 'new', description: 'Baseline comparison view. Defaults to genuinely new deterministic findings when base_ref is present'}}}, run: (g, a, ctx) => th.tRunAudit(g, a, ctx)},
112
112
  {cap: 'health', name: 'coverage_map', description: 'Map a real existing coverage report onto the graph. If no report exists, return clearly labelled static test reachability (a test imports/reaches a source file) with actualCoverage=NOT_AVAILABLE; reachability is never presented as measured coverage.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max risk hotspots to list, default 15'}, path: {type: 'string', description: 'Optional repo-relative path prefix filter, e.g. src/query'}}}, run: (g, a, ctx) => th.tCoverageMap(g, a, ctx)},
113
113
  {cap: 'health', name: 'hot_path_review', description: 'Rank a focused production-symbol hot-path queue from parser-derived local complexity, inside-loop allocations/copies/scans/sorts/recursion, graph fan-in/fan-out, and measured coverage or clearly labelled static test reachability. The default score gate is 85 with a narrow strong-local fallback; set min_score=0 for the full diagnostic queue. This is not profiler data or interprocedural Big-O.', inputSchema: {type: 'object', properties: {path: {type: 'string', maxLength: 1024, description: 'Optional repository-relative path prefix'}, top_n: {type: 'integer', minimum: 1, maximum: 100, default: 20}, min_score: {type: 'integer', minimum: 0, maximum: 100, default: 85, description: 'Focused default is 85; lower explicitly to broaden, or use 0 for every threshold-matching diagnostic candidate'}, cyclomatic_threshold: {type: 'integer', minimum: 2, maximum: 1000, default: 8}, call_threshold: {type: 'integer', minimum: 1, maximum: 10000, default: 12}, loop_depth_threshold: {type: 'integer', minimum: 1, maximum: 10, default: 2}, time_rank_threshold: {type: 'integer', minimum: 0, maximum: 5, default: 2}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and explicitly excluded paths; tests still require include_tests'}}}, run: (g, a, ctx) => th.tHotPathReview(g, a, ctx)},
114
114
  {cap: 'graph', name: 'list_communities', description: 'List graph communities named by their dominant folder (largest first) with sample files — a readable module overview; feed the list position into get_community.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max communities to list, default 20'}}}, run: (g, a, ctx) => th.tListCommunities(g, a, ctx)},
115
115
  {cap: 'graph', name: 'module_map', description: 'Production-first folder architecture map with file/symbol counts and strongest module dependencies, separating runtime, TypeScript type-only and language compile-only coupling.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max modules to list, default 25'}, include_non_product: {type: 'boolean', default: false, description: 'Include tests, fixtures, benchmarks, generated output, docs and other classified non-product files; false by default'}}}, run: (g, a, ctx) => th.tModuleMap(g, a, ctx)},
116
- {cap: 'source', refreshGraph: true, name: 'list_endpoints', description: 'Inventory of HTTP endpoints defined in the repo (Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web/Spring MVC and WebFlux): method, composed path, handler, and file:line.', inputSchema: {type: 'object', properties: {max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
116
+ {cap: 'source', refreshGraph: true, name: 'list_endpoints', description: 'Inventory of HTTP endpoints defined in the repo (Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web/Spring MVC and WebFlux): declared and reachable composed paths, static mount provenance, confidence, handler, and file:line.', inputSchema: {type: 'object', properties: {method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT', 'ALL', 'ANY']}, path: {type: 'string', maxLength: 2048, description: 'Optional exact composed path or segment-aligned suffix'}, max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
117
+ {cap: 'source', refreshGraph: true, name: 'trace_endpoint', description: 'Resolve one exact reachable HTTP endpoint, prove its router mount chain, bind its handler symbol, and return a bounded production-only multi-hop call graph with call-site excerpts. This is a focused projection of the repository graph, not text-search inference.', inputSchema: {type: 'object', properties: {path: {type: 'string', minLength: 1, maxLength: 2048, description: 'Exact composed path; a suffix is accepted only when unambiguous'}, method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT', 'ALL', 'ANY']}, max_depth: {type: 'integer', minimum: 1, maximum: 4, default: 3}, max_nodes: {type: 'integer', minimum: 2, maximum: 40, default: 20}, max_excerpts: {type: 'integer', minimum: 0, maximum: 12, default: 6}, context_lines: {type: 'integer', minimum: 0, maximum: 6, default: 2}, include_classified: {type: 'boolean', default: false, description: 'Include test/e2e/generated/mock/story/docs/benchmark/temp and explicitly excluded targets'}}, required: ['path']}, run: (g, a, ctx) => te.tTraceEndpoint(g, a, ctx)},
117
118
  {cap: 'build', name: 'rebuild_graph', description: "Rebuild the active full-repository graph and report a structural delta. Omitted mode/precision preserve the active graph; a first build uses full and the startup precision setting (lsp unless WEAVATRIX_PRECISION=off). The local TypeScript/JavaScript LSP overlay validates bounded ambiguous edges; precision:off is an explicit fallback. With scope, build an isolated diagnostic graph without replacing or diffing the full graph.", inputSchema: {type: 'object', properties: {mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], description: 'Build mode; omit to preserve the active mode (or use full for a first build)'}, precision: {type: 'string', enum: ['lsp', 'off'], description: 'Semantic precision; omit to preserve the active mode (or use the startup setting for a first build)'}, scope: {type: 'string', description: 'Optional isolated diagnostic path prefix; never replaces the active full graph'}}}, run: (g, a, ctx) => ta.tRebuildGraph(g, a, ctx)},
118
119
  {cap: 'graph', name: 'graph_diff', description: 'Structural graph diff: compare the current graph with an immutable Git-ref baseline (base_ref such as HEAD~1 or main), or with graph.prev.json from the last rebuild when base_ref is omitted. Reports architecture drift, cycle changes and symbols that lost their last caller.', inputSchema: {type: 'object', properties: {base_ref: {type: 'string', maxLength: 256, description: 'Optional immutable Git baseline to build in isolation, e.g. HEAD~1, main or origin/main; never checks out or mutates the working tree'}, path: {type: 'string', description: 'Optional node-id/path prefix to scope the diff, e.g. src/query'}}}, run: (g, a, ctx) => ti.tGraphDiff(g, a, ctx)},
119
120
  {cap: 'graph', name: 'get_architecture_contract', description: 'Return the executable target-architecture contract, quality budgets and ratchet mode that an agent must follow before editing.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tar.tGetArchitectureContract(g, a, ctx)},
@@ -124,8 +125,9 @@ function buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc, tv, caps}) {
124
125
  {cap: 'retarget', name: 'open_repo', description: 'OFFLINE RETARGET: switch this server to another local Git repository, building its graph when missing. This explicit tool call changes the active repository boundary; pass build:false to probe without building. Omitted mode/precision preserve an existing graph; a new graph uses full and the startup precision setting (lsp unless WEAVATRIX_PRECISION=off). Omit the retarget capability at registration to pin one repository.', inputSchema: {type: 'object', properties: {path: {type: 'string', description: 'Absolute path to a Git working tree'}, build: {type: 'boolean', description: 'Build the graph when missing (default true)', default: true}, mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], description: 'Optional build mode override; omit to preserve an existing graph'}, precision: {type: 'string', enum: ['lsp', 'off'], description: 'Optional semantic precision override; omit to preserve an existing graph or use the startup setting for a new graph'}} , required: ['path']}, run: (g, a, ctx) => ta.tOpenRepo(g, a, ctx)},
125
126
  {cap: 'retarget', name: 'list_known_repos', description: 'OFFLINE RETARGET: list every registered local repository graph from the global per-user registry, regardless of parent folder.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tListKnownRepos(g, a, ctx)},
126
127
  {cap: 'advisories', name: 'refresh_advisories', description: "NETWORK / explicit advisories profile: refresh the local OSV advisory store for this repo's lockfile-pinned packages (npm/PyPI/Go). Sends package names + versions to OSV.dev — never automatic, never source code. Afterwards run_audit reads the refreshed store fully offline (~/.weavatrix/advisories.json).", inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', description: 'Per-request timeout, default 20000'}}}, run: (g, a, ctx) => ta.tRefreshAdvisories(g, a, ctx)},
127
- {cap: 'hosted', name: 'pull_architecture_contract', description: 'NETWORK / explicit hosted profile: fetch the owner-approved target architecture for the active stable repository UUID, validate it and cache it locally. Sends only the opaque UUID with bearer auth; never source or paths.', inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000}}}, run: (g, a, ctx) => ta.tPullArchitectureContract(g, a, ctx)},
128
- {cap: 'hosted', name: 'sync_graph', description: 'NETWORK / explicit hosted profile, off until configured: derive a bounded evidence snapshot locally and send it with an allowlisted graph payload to an endpoint you configure (env WEAVATRIX_SYNC_URL, WEAVATRIX_SYNC_TOKEN bearer auth). Local analyzers may read source files, but file bodies, snippets, absolute paths and unknown fields are never uploaded. Payload v3 includes architecture/health/stack/package evidence; request v2 only for graph-only compatibility.', inputSchema: {type: 'object', properties: {payload_version: {type: 'integer', enum: [2, 3], default: 3, description: '3 includes the evidence snapshot; 2 is graph-only compatibility and is never selected as a silent fallback'}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000, description: 'Network timeout in milliseconds'}}}, run: (g, a, ctx) => ta.tSyncGraph(g, a, ctx)},
128
+ {cap: 'hosted', name: 'pull_architecture_contract', description: 'NETWORK / explicit hosted profile: fetch the owner-approved target architecture for the active stable repository UUID, validate it and cache it locally. Sends only the opaque UUID with bearer auth; never source or paths. HTTP failures are returned as actionable AUTH_REQUIRED/FORBIDDEN/NOT_FOUND/REPOSITORY_NOT_READY states.', inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000}}}, run: (g, a, ctx) => ta.tPullArchitectureContract(g, a, ctx)},
129
+ {cap: 'hosted', name: 'preview_sync', description: 'LOCAL SAFETY PREVIEW / no network: serialize the exact bounded hosted payload, validate the HTTPS destination, and show hostname/path, repository UUID, fields, sections, node/edge counts, bytes and body hash. Returns a five-minute confirmation token for sync_graph. Source bodies, snippets, absolute paths, environment values, credentials, Git remotes and unknown fields are excluded.', inputSchema: {type: 'object', properties: {payload_version: {type: 'integer', enum: [2, 3], default: 3, description: '3 includes bounded architecture/health/stack/package evidence; 2 is explicit graph-only compatibility'}}}, run: (g, a, ctx) => ta.tPreviewSyncGraph(g, a, ctx)},
130
+ {cap: 'hosted', name: 'sync_graph', description: 'NETWORK / explicit hosted profile. Uploads only the exact payload created by preview_sync and requires dry_run:false plus its short-lived confirm_token after user approval. Calling without dry_run:false remains a no-network preview compatibility alias. Source bodies, snippets, absolute paths and unknown fields are never uploaded.', inputSchema: {type: 'object', properties: {payload_version: {type: 'integer', enum: [2, 3], default: 3, description: 'Used only when producing a compatibility preview; the approved token pins the exact serialized payload'}, dry_run: {type: 'boolean', default: true, description: 'Compatibility safety switch. Must be explicitly false to send.'}, confirm_token: {type: 'string', maxLength: 64, description: 'Short-lived token returned by preview_sync for the exact destination and payload'}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000, description: 'Network timeout in milliseconds'}}}, run: (g, a, ctx) => ta.tSyncGraph(g, a, ctx)},
129
131
  ]
130
132
  // Every tool supports the same machine-output switch. Text mode is TextContent-only so large
131
133
  // analysis payloads are not duplicated into an agent's context; JSON opts into structuredContent
@@ -153,12 +155,13 @@ function buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc, tv, caps}) {
153
155
  // present string (even '') is an explicit selection, so "select nothing" really exposes nothing.
154
156
  export async function loadHotApi(version, capsArg) {
155
157
  const v = version ? `?v=${version}` : ''
156
- const [tg, ti, th, ts, tb, ta, tar, thi, tc, tv] = await Promise.all([
158
+ const [tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv] = await Promise.all([
157
159
  import(new URL(`./tools-graph.mjs${v}`, import.meta.url).href),
158
160
  import(new URL(`./tools-impact.mjs${v}`, import.meta.url).href),
159
161
  import(new URL(`./tools-health.mjs${v}`, import.meta.url).href),
160
162
  import(new URL(`./tools-source.mjs${v}`, import.meta.url).href),
161
163
  import(new URL(`./tools-context.mjs${v}`, import.meta.url).href),
164
+ import(new URL(`./tools-endpoints.mjs${v}`, import.meta.url).href),
162
165
  import(new URL(`./tools-actions.mjs${v}`, import.meta.url).href),
163
166
  import(new URL(`./tools-architecture.mjs${v}`, import.meta.url).href),
164
167
  import(new URL(`./tools-history.mjs${v}`, import.meta.url).href),
@@ -169,7 +172,7 @@ export async function loadHotApi(version, capsArg) {
169
172
  const selected = PROFILE_CAPS[raw] || raw.split(',').map((s) => s.trim()).filter(Boolean)
170
173
  .flatMap((cap) => cap === 'online' ? ['advisories', 'hosted'] : [cap])
171
174
  const caps = new Set(selected)
172
- const all = buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc, tv, caps})
175
+ const all = buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps})
173
176
  const tools = all.filter((t) => caps.has(t.cap))
174
177
  return {
175
178
  tools,
@@ -3,7 +3,7 @@ import {readFileSync, statSync} from 'node:fs'
3
3
  import {createRepoBoundary} from '../repo-path.js'
4
4
  import {CAPS, STATE, compareText, safeToken} from './evidence-snapshot.common.mjs'
5
5
 
6
- const LOCKFILES = ['npm-shrinkwrap.json', 'package-lock.json']
6
+ const LOCKFILES = ['npm-shrinkwrap.json', 'package-lock.json', 'bun.lock']
7
7
  const MAX_LOCKFILE_BYTES = 64 * 1024 * 1024
8
8
  const MAX_PACKAGE_RECORDS = 50_000
9
9
  const MAX_DEPENDENCY_DECLARATIONS = 200_000
@@ -191,6 +191,10 @@ function parseLock(lock, lockfile) {
191
191
  if (declarationLimitReached) reasons.push('LOCKFILE_DEPENDENCY_DECLARATION_LIMIT_REACHED')
192
192
  if (declarations.unresolved > 0) reasons.push('UNRESOLVED_LOCKFILE_DEPENDENCIES')
193
193
 
194
+ return finalizeGraph({externalNodes, edgeMap, declarations, reasons, lockfile, lockfileVersion})
195
+ }
196
+
197
+ function finalizeGraph({externalNodes, edgeMap, declarations, reasons, lockfile, lockfileVersion}) {
194
198
  const allNodes = [...externalNodes.values()].sort((a, b) =>
195
199
  Number(b.direct) - Number(a.direct) || compareText(a.name, b.name) ||
196
200
  compareText(a.version, b.version) || compareText(a.id, b.id))
@@ -223,6 +227,255 @@ function parseLock(lock, lockfile) {
223
227
  }
224
228
  }
225
229
 
230
+ // Bun >= 1.2 writes a text lockfile (bun.lock) as JSONC: JSON plus comments and
231
+ // trailing commas. parseJsonc strips both (outside of strings) with no new deps.
232
+ function parseJsonc(text) {
233
+ const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
234
+ const out = []
235
+ let i = 0
236
+ const length = source.length
237
+ const skipComment = (index) => {
238
+ if (source[index] !== '/') return index
239
+ if (source[index + 1] === '/') {
240
+ let cursor = index + 2
241
+ while (cursor < length && source[cursor] !== '\n') cursor++
242
+ return cursor
243
+ }
244
+ if (source[index + 1] === '*') {
245
+ let cursor = index + 2
246
+ while (cursor < length && !(source[cursor] === '*' && source[cursor + 1] === '/')) cursor++
247
+ return Math.min(cursor + 2, length)
248
+ }
249
+ return index
250
+ }
251
+ while (i < length) {
252
+ const char = source[i]
253
+ if (char === '"') {
254
+ const start = i
255
+ i++
256
+ while (i < length && source[i] !== '"') i += source[i] === '\\' ? 2 : 1
257
+ i = Math.min(i + 1, length)
258
+ out.push(source.slice(start, i))
259
+ continue
260
+ }
261
+ if (char === '/') {
262
+ const next = skipComment(i)
263
+ if (next !== i) { i = next; continue }
264
+ }
265
+ if (char === ',') {
266
+ let ahead = i + 1
267
+ while (ahead < length) {
268
+ if (/\s/.test(source[ahead])) { ahead++; continue }
269
+ const next = skipComment(ahead)
270
+ if (next !== ahead) { ahead = next; continue }
271
+ break
272
+ }
273
+ if (ahead < length && (source[ahead] === '}' || source[ahead] === ']')) { i++; continue }
274
+ }
275
+ out.push(char)
276
+ i++
277
+ }
278
+ return JSON.parse(out.join(''))
279
+ }
280
+
281
+ // bun.lock package keys are slash-joined chains of package names describing the
282
+ // hoisted install position, e.g. "make-dir/semver" (semver as resolved for
283
+ // make-dir). Scoped names keep their own slash: "@scope/pkg/dep".
284
+ function bunKeySegments(key) {
285
+ if (typeof key !== 'string' || key.length === 0 || key.length > 4096 || /[\u0000-\u001f\u007f]/.test(key)) return null
286
+ const parts = key.split('/')
287
+ const segments = []
288
+ for (let index = 0; index < parts.length; index++) {
289
+ let segment = parts[index]
290
+ if (segment.startsWith('@')) {
291
+ if (index + 1 >= parts.length) return null
292
+ segment = `${segment}/${parts[++index]}`
293
+ }
294
+ if (segment.length > 256 || !PACKAGE_NAME.test(segment)) return null
295
+ segments.push(segment)
296
+ }
297
+ return segments
298
+ }
299
+
300
+ // Entry shape: "key": ["name@version", registry, {dependencies, peerDependencies,
301
+ // optionalPeers, ...}, integrity]. Workspace members resolve to "name@workspace:path".
302
+ function bunPackageRecord(entry) {
303
+ if (!Array.isArray(entry) || typeof entry[0] !== 'string' || entry[0].length > 1024) return null
304
+ const at = entry[0].lastIndexOf('@')
305
+ if (at <= 0) return null
306
+ const name = entry[0].slice(0, at)
307
+ if (name.length > 256 || !PACKAGE_NAME.test(name)) return null
308
+ const rawVersion = entry[0].slice(at + 1)
309
+ const meta = entry.slice(1).find((value) => value && typeof value === 'object' && !Array.isArray(value)) || {}
310
+ return {name, rawVersion, workspace: rawVersion.startsWith('workspace:'), meta}
311
+ }
312
+
313
+ // Adapts a bun dependency map ({dependencies, devDependencies, optionalDependencies,
314
+ // peerDependencies, optionalPeers: [names]}) to the npm record shape consumed by
315
+ // dependencyDeclarations, so declaration kinds and precedence stay identical.
316
+ function bunDeclarationRecord(meta) {
317
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return {}
318
+ const peerDependenciesMeta = {}
319
+ if (Array.isArray(meta.optionalPeers)) {
320
+ for (const name of meta.optionalPeers) {
321
+ if (typeof name === 'string') peerDependenciesMeta[name] = {optional: true}
322
+ }
323
+ }
324
+ return {
325
+ dependencies: meta.dependencies,
326
+ devDependencies: meta.devDependencies,
327
+ optionalDependencies: meta.optionalDependencies,
328
+ peerDependencies: meta.peerDependencies,
329
+ peerDependenciesMeta,
330
+ }
331
+ }
332
+
333
+ // Mirrors bun resolution: the most specific override key wins ("a/b/name"),
334
+ // falling back segment by segment to the hoisted top-level key ("name").
335
+ function resolveBunDependency(sourceSegments, name, records) {
336
+ for (let depth = sourceSegments.length; depth >= 0; depth--) {
337
+ const key = [...sourceSegments.slice(0, depth), name].join('/')
338
+ const record = records.get(key)
339
+ if (record) return {key, record}
340
+ }
341
+ return null
342
+ }
343
+
344
+ // bun.lock does not persist npm's per-package dev/optional/peer flags, so they are
345
+ // approximated from graph reachability the same way npm derives them: a package is
346
+ // dev when unreachable from the root without dev edges, optional (or peer) when
347
+ // unreachable without optional (or peer) edges.
348
+ function applyBunReachabilityFlags(nodes, edges) {
349
+ const adjacency = new Map()
350
+ for (const edge of edges) {
351
+ if (!adjacency.has(edge.from)) adjacency.set(edge.from, [])
352
+ adjacency.get(edge.from).push(edge)
353
+ }
354
+ const reach = (excluded) => {
355
+ const seen = new Set(['(root)'])
356
+ const queue = ['(root)']
357
+ while (queue.length) {
358
+ for (const edge of adjacency.get(queue.pop()) || []) {
359
+ if (excluded.has(edge.kind) || seen.has(edge.to)) continue
360
+ seen.add(edge.to)
361
+ queue.push(edge.to)
362
+ }
363
+ }
364
+ return seen
365
+ }
366
+ const withoutDev = reach(new Set(['dev']))
367
+ const withoutOptional = reach(new Set(['optional', 'optional-peer']))
368
+ const withoutPeer = reach(new Set(['peer', 'optional-peer']))
369
+ for (const node of nodes) {
370
+ node.dev = !withoutDev.has(node.id)
371
+ node.optional = !withoutOptional.has(node.id)
372
+ node.peer = !withoutPeer.has(node.id)
373
+ }
374
+ }
375
+
376
+ function parseBunLock(lock, lockfile) {
377
+ const lockfileVersion = Number(lock?.lockfileVersion)
378
+ const extras = {lockfile, lockfileVersion: Number.isFinite(lockfileVersion) ? Math.trunc(lockfileVersion) : 0}
379
+ if (!lock?.packages || typeof lock.packages !== 'object' || Array.isArray(lock.packages)) {
380
+ return emptyGraph(STATE.PARTIAL, 'BUN_LOCK_PACKAGES_REQUIRED', extras)
381
+ }
382
+
383
+ const allKeys = Object.keys(lock.packages).sort(compareText)
384
+ const selectedKeys = allKeys.slice(0, MAX_PACKAGE_RECORDS)
385
+ const reasons = []
386
+ if (selectedKeys.length < allKeys.length) reasons.push('LOCKFILE_PACKAGE_RECORD_LIMIT_REACHED')
387
+
388
+ const records = new Map()
389
+ let invalidRecords = 0
390
+ for (const key of selectedKeys) {
391
+ const segments = bunKeySegments(key)
392
+ const record = segments ? bunPackageRecord(lock.packages[key]) : null
393
+ if (!record) {
394
+ invalidRecords++
395
+ continue
396
+ }
397
+ records.set(key, {...record, segments})
398
+ }
399
+ if (invalidRecords > 0) reasons.push('INVALID_LOCKFILE_PACKAGE_RECORDS')
400
+
401
+ // Node ids reuse packageId over the bun install-position key, so ids keep the
402
+ // exact "npm:name@version:hash12" shape the npm parser and sync sanitizer use,
403
+ // and node.name stays the bare package name that hosted BFS grounding matches.
404
+ const externalNodes = new Map()
405
+ let invalidPackageVersions = 0
406
+ for (const [key, record] of records) {
407
+ if (record.workspace) continue
408
+ const version = packageVersion(record.rawVersion)
409
+ if (!version) { invalidPackageVersions++; continue }
410
+ externalNodes.set(key, {
411
+ id: packageId(record.name, version, key),
412
+ name: record.name,
413
+ version,
414
+ direct: false,
415
+ dev: false,
416
+ optional: false,
417
+ peer: false,
418
+ })
419
+ }
420
+ if (invalidPackageVersions > 0) reasons.push('INVALID_LOCKFILE_PACKAGE_VERSIONS')
421
+
422
+ const workspaces = lock?.workspaces && typeof lock.workspaces === 'object' && !Array.isArray(lock.workspaces)
423
+ ? lock.workspaces
424
+ : null
425
+ if (!workspaces) reasons.push('BUN_LOCK_WORKSPACES_MISSING')
426
+
427
+ // Sources: every workspace member acts as '(root)' (matching how the npm parser
428
+ // treats workspace-local records), then every resolved external package.
429
+ const sources = []
430
+ for (const path of Object.keys(workspaces || {}).sort(compareText)) {
431
+ const meta = workspaces[path]
432
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta)) continue
433
+ const workspaceName = path === '' ? null : safeToken(meta.name)
434
+ const segments = workspaceName && records.has(workspaceName) ? [workspaceName] : []
435
+ sources.push({sourceId: '(root)', segments, declarationRecord: bunDeclarationRecord(meta)})
436
+ }
437
+ for (const [key, record] of [...records].sort(([a], [b]) => compareText(a, b))) {
438
+ const node = externalNodes.get(key)
439
+ if (!node) continue
440
+ sources.push({sourceId: node.id, segments: record.segments, declarationRecord: bunDeclarationRecord(record.meta)})
441
+ }
442
+
443
+ const edgeMap = new Map()
444
+ const declarations = emptyDeclarations()
445
+ let declarationLimitReached = false
446
+ outer: for (const source of sources) {
447
+ for (const declaration of dependencyDeclarations(source.declarationRecord)) {
448
+ if (declarations.total >= MAX_DEPENDENCY_DECLARATIONS) {
449
+ declarationLimitReached = true
450
+ break outer
451
+ }
452
+ declarations.total++
453
+ const resolved = resolveBunDependency(source.segments, declaration.name, records)
454
+ if (resolved?.record.workspace) {
455
+ declarations.local++
456
+ continue
457
+ }
458
+ const node = resolved ? externalNodes.get(resolved.key) : null
459
+ if (!node) {
460
+ if (declaration.kind === 'optional' || declaration.kind === 'optional-peer') declarations.optionalMissing++
461
+ else declarations.unresolved++
462
+ continue
463
+ }
464
+ declarations.resolved++
465
+ if (source.sourceId === '(root)') node.direct = true
466
+ if (source.sourceId === node.id) continue
467
+ const edge = {from: source.sourceId, to: node.id, kind: declaration.kind}
468
+ edgeMap.set(`${edge.from}\0${edge.to}\0${edge.kind}`, edge)
469
+ }
470
+ }
471
+ if (declarationLimitReached) reasons.push('LOCKFILE_DEPENDENCY_DECLARATION_LIMIT_REACHED')
472
+ if (declarations.unresolved > 0) reasons.push('UNRESOLVED_LOCKFILE_DEPENDENCIES')
473
+
474
+ applyBunReachabilityFlags(externalNodes.values(), edgeMap.values())
475
+
476
+ return finalizeGraph({externalNodes, edgeMap, declarations, reasons, lockfile, lockfileVersion: extras.lockfileVersion})
477
+ }
478
+
226
479
  export function buildPackageDependencyGraph(repoRoot) {
227
480
  const boundary = createRepoBoundary(repoRoot)
228
481
  if (!boundary.root) return emptyGraph(STATE.ERROR, 'INVALID_REPOSITORY_ROOT')
@@ -241,8 +494,9 @@ export function buildPackageDependencyGraph(repoRoot) {
241
494
  if (statSync(selected.path).size > MAX_LOCKFILE_BYTES) {
242
495
  return emptyGraph(STATE.ERROR, 'PACKAGE_LOCK_SIZE_LIMIT_REACHED', {lockfile: selected.lockfile})
243
496
  }
244
- const lock = JSON.parse(readFileSync(selected.path, 'utf8'))
245
- return parseLock(lock, selected.lockfile)
497
+ const raw = readFileSync(selected.path, 'utf8')
498
+ if (selected.lockfile === 'bun.lock') return parseBunLock(parseJsonc(raw), selected.lockfile)
499
+ return parseLock(JSON.parse(raw), selected.lockfile)
246
500
  } catch {
247
501
  return emptyGraph(STATE.ERROR, 'PACKAGE_LOCK_READ_ERROR', {lockfile: selected.lockfile})
248
502
  }
@@ -133,7 +133,10 @@ export function ambiguityNote(query, info) {
133
133
  const bestByDegree = (g, list) =>
134
134
  list.reduce((best, n) => (degreeOf(g, n.id) > degreeOf(g, best.id) ? n : best), list[0])
135
135
 
136
- const QUERY_STOP = new Set('a an and are around architecture code do does explain find for from how in is me of or project repository show the through to trace what where which with'.split(' '))
136
+ // Instructional words describe the requested answer, not repository concepts. Letting them become
137
+ // fuzzy seeds made broad prompts select symbols such as `jest.config.cjs#path` for an HTTP request
138
+ // path. Exact file/symbol questions still have seed_files / inspect_symbol as explicit controls.
139
+ const QUERY_STOP = new Set('a an and are around architecture best code do does exact explain find focus focused for from how identify in inspect inspection is logic me of or path production project repository request requests rest show symbol symbols the through to trace what where which with'.split(' '))
137
140
  const QUERY_INTENTS = [
138
141
  ['bootstrap', ['bootstrap', 'startup', 'entrypoint', 'entry', 'main', 'root', 'app', 'application', 'applications', 'index', 'server', 'cli']],
139
142
  ['tool-execution', ['tool', 'tools', 'tooling', 'mcp', 'execution', 'execute', 'invocation', 'invoke', 'dispatch', 'dispatcher', 'handler', 'catalog', 'registry']],
@@ -163,6 +166,30 @@ const CLASS_QUERY_TERMS = Object.freeze({
163
166
  })
164
167
  const CODE_FILE_RE = /\.(?:[cm]?[jt]sx?|py|go|java|rs|kt|kts|cs|rb|php)$/i
165
168
  const DATA_OR_PROSE_RE = /\.(?:json|ya?ml|toml|ini|md|mdx|rst|adoc|html?|css|scss|less|svg)$/i
169
+ const LANGUAGE_EXTENSIONS = Object.freeze({
170
+ rust: ['rs'], python: ['py', 'pyi'], typescript: ['ts', 'tsx', 'mts', 'cts'],
171
+ javascript: ['js', 'jsx', 'mjs', 'cjs'], go: ['go'], java: ['java'], csharp: ['cs'],
172
+ })
173
+
174
+ function requestedLanguages(query) {
175
+ const raw = String(query || '')
176
+ const words = new Set(wordsOf(query))
177
+ const requested = new Set()
178
+ if (words.has('rust')) requested.add('rust')
179
+ if (words.has('python') || words.has('py')) requested.add('python')
180
+ if (words.has('typescript') || words.has('ts') || /typescript/i.test(raw)) requested.add('typescript')
181
+ if (words.has('javascript') || words.has('js') || words.has('nodejs') || /javascript|node\.?js/i.test(raw)) requested.add('javascript')
182
+ if (words.has('golang') || /(?:^|[^A-Za-z])Go(?:[^A-Za-z]|$)/.test(raw)) requested.add('go')
183
+ if (words.has('java')) requested.add('java')
184
+ if (words.has('csharp') || words.has('dotnet') || /csharp|(?:^|[^A-Za-z])C#(?:[^A-Za-z]|$)/i.test(raw)) requested.add('csharp')
185
+ return new Set([...requested].flatMap((language) => LANGUAGE_EXTENSIONS[language]))
186
+ }
187
+
188
+ const matchesLanguage = (node, extensions) => {
189
+ if (!extensions.size) return true
190
+ const match = /\.([^.\/]+)$/.exec(sourceFileOf(node))
191
+ return !!match && extensions.has(match[1].toLowerCase())
192
+ }
166
193
 
167
194
  const sourceFileOf = (node) => {
168
195
  const source = node?.source_file || String(node?.id ?? '').split('#', 1)[0]
@@ -251,6 +278,36 @@ function queryConcepts(query) {
251
278
  return concepts
252
279
  }
253
280
 
281
+ // A code-shaped identifier in prose is stronger evidence than surrounding architecture words.
282
+ // For example, `startMitigate` should seed its exact controller/service/messaging declarations,
283
+ // not one arbitrary file for each of "controller", "service", and "flow". This remains bounded
284
+ // label matching rather than semantic search; ambiguous identifiers are deliberately all retained.
285
+ function exactIdentifierSeeds(g, query, limit, {repoRoot = null} = {}) {
286
+ const identifiers = [...new Set((String(query || '').match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || [])
287
+ .filter((token) => /(?:[a-z0-9][A-Z]|_)/.test(token))
288
+ .map((token) => token.toLowerCase()))]
289
+ if (!identifiers.length) return []
290
+ const requestedClasses = requestedPathClasses(query)
291
+ const languageExtensions = requestedLanguages(query)
292
+ const classifier = createPathClassifier(repoRoot)
293
+ const classificationCache = new Map()
294
+ const matches = []
295
+ for (const identifier of identifiers) {
296
+ const candidates = g.nodes.filter((node) => {
297
+ const label = String(node.label || '').replace(/\(\)$/, '').toLowerCase()
298
+ return label === identifier
299
+ && matchesLanguage(node, languageExtensions)
300
+ && isQueryEligible(node, requestedClasses, classificationCache, classifier)
301
+ }).sort((left, right) => degreeOf(g, right.id) - degreeOf(g, left.id)
302
+ || String(left.id).localeCompare(String(right.id)))
303
+ for (const node of candidates) {
304
+ if (!matches.some((existing) => String(existing.id) === String(node.id))) matches.push(node)
305
+ if (matches.length >= limit) return matches
306
+ }
307
+ }
308
+ return matches
309
+ }
310
+
254
311
  function conceptScore(g, node, concept, queryContext) {
255
312
  const id = normPath(node.id)
256
313
  const label = String(node.label ?? '').toLowerCase()
@@ -266,6 +323,14 @@ function conceptScore(g, node, concept, queryContext) {
266
323
  else if (words.has(term)) match = Math.max(match, primary ? 36 : 25)
267
324
  else if (term.length >= 4 && term !== 'tool' && term !== 'tools' && (id.includes(term) || label.includes(term))) match = Math.max(match, primary ? 12 : 7)
268
325
  })
326
+ const extension = (/\.([^.\/]+)$/.exec(source) || [])[1] || ''
327
+ const languageConcept = {
328
+ rust: ['rs'], python: ['py', 'pyi'], py: ['py', 'pyi'],
329
+ typescript: ['ts', 'tsx', 'mts', 'cts'], ts: ['ts', 'tsx', 'mts', 'cts'],
330
+ javascript: ['js', 'jsx', 'mjs', 'cjs'], js: ['js', 'jsx', 'mjs', 'cjs'], nodejs: ['js', 'jsx', 'mjs', 'cjs'],
331
+ golang: ['go'], go: ['go'], java: ['java'], csharp: ['cs'], dotnet: ['cs'],
332
+ }[concept.raw]
333
+ if (languageConcept?.includes(extension) && queryContext.languageExtensions.has(extension)) match = Math.max(match, 58)
269
334
  const fileNode = !isSymbol(node.id)
270
335
  const depth = source ? source.split('/').length : 9
271
336
  if (concept.id === 'bootstrap') match = Math.max(match, entrypointSignal(g, node, source, stem))
@@ -285,16 +350,21 @@ function conceptScore(g, node, concept, queryContext) {
285
350
  // Natural-language graph search keeps one strong candidate per concept before filling by aggregate
286
351
  // score. This prevents a broad architecture question from spending every seed on one dense API area.
287
352
  export function findSeeds(g, query, limit = 8, {repoRoot = null} = {}) {
353
+ const exact = exactIdentifierSeeds(g, query, limit, {repoRoot})
354
+ if (exact.length) return exact
288
355
  const concepts = queryConcepts(query)
289
356
  if (!concepts.length || limit <= 0) return []
290
357
  const requestedClasses = requestedPathClasses(query)
358
+ const languageExtensions = requestedLanguages(query)
291
359
  const queryContext = {
292
360
  runtimeIntent: concepts.some((concept) => concept.id === 'bootstrap' || concept.id === 'tool-execution'),
293
361
  maintenanceIntent: wordsOf(query).some((word) => ['script', 'scripts', 'build', 'release', 'publish', 'packaging'].includes(word)),
362
+ languageExtensions,
294
363
  }
295
364
  const classifier = createPathClassifier(repoRoot)
296
365
  const classificationCache = new Map()
297
- const rows = g.nodes.filter((node) => isQueryEligible(node, requestedClasses, classificationCache, classifier)).map((node) => {
366
+ const rows = g.nodes.filter((node) => matchesLanguage(node, languageExtensions)
367
+ && isQueryEligible(node, requestedClasses, classificationCache, classifier)).map((node) => {
298
368
  const scores = concepts.map((concept) => conceptScore(g, node, concept, queryContext))
299
369
  return {node, scores, total: Math.max(...scores) + scores.reduce((sum, score) => sum + score, 0) / 10}
300
370
  })