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.
- package/README.md +101 -27
- package/SECURITY.md +30 -7
- package/docs/releases/v0.2.9.md +123 -0
- package/package.json +3 -3
- package/skill/SKILL.md +12 -7
- package/src/analysis/dead-check.js +24 -2
- package/src/analysis/dep-check-ecosystems.js +41 -2
- package/src/analysis/duplicate-groups.js +11 -2
- package/src/analysis/endpoints.js +256 -22
- package/src/analysis/internal-audit.collect.js +53 -20
- package/src/graph/builder/lang-rust.js +49 -12
- package/src/mcp/catalog.mjs +13 -10
- package/src/mcp/evidence-snapshot.package-graph.mjs +257 -3
- package/src/mcp/graph-context.mjs +72 -2
- package/src/mcp/tools-actions.mjs +170 -36
- package/src/mcp/tools-context.mjs +25 -8
- package/src/mcp/tools-endpoints.mjs +162 -0
- package/src/mcp/tools-graph.mjs +1 -1
- package/src/mcp/tools-health.mjs +92 -42
- package/src/mcp/tools-source.mjs +3 -3
- package/src/mcp/tools-verified-change.mjs +16 -0
- package/src/mcp-server.mjs +30 -2
- package/src/mcp-source-tools.mjs +10 -7
- package/src/path-classification.js +1 -1
- package/src/precision/symbol-query.js +51 -0
- package/docs/releases/v0.2.7.md +0 -93
package/src/mcp/catalog.mjs
CHANGED
|
@@ -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;
|
|
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,
|
|
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: '
|
|
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):
|
|
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: '
|
|
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
|
|
245
|
-
return
|
|
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
|
-
|
|
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) =>
|
|
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
|
})
|