weavatrix 0.2.8 → 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 +82 -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/duplicate-groups.js +11 -2
- package/src/analysis/endpoints.js +209 -9
- package/src/mcp/catalog.mjs +13 -10
- package/src/mcp/evidence-snapshot.package-graph.mjs +257 -3
- package/src/mcp/graph-context.mjs +36 -1
- 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 +70 -41
- package/src/mcp/tools-source.mjs +3 -3
- package/src/mcp-server.mjs +30 -2
- package/src/mcp-source-tools.mjs +10 -7
- package/docs/releases/v0.2.8.md +0 -82
|
@@ -2,6 +2,7 @@ import {isStructuralRelation} from '../graph/relations.js'
|
|
|
2
2
|
import {boundedInteger} from '../bounds.js'
|
|
3
3
|
import {isSymbol, labelOf} from './graph-context.mjs'
|
|
4
4
|
import {toolResult} from './tool-result.mjs'
|
|
5
|
+
import {sourceExcerpt} from './tools-source.mjs'
|
|
5
6
|
|
|
6
7
|
const MAX_LINE_SAMPLES = 5
|
|
7
8
|
|
|
@@ -10,7 +11,7 @@ const fileOf = (g, id) => {
|
|
|
10
11
|
return String(node?.source_file || (isSymbol(id) ? String(id).split('#')[0] : id))
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
function aggregateEdges(g, edges, cap) {
|
|
14
|
+
function aggregateEdges(g, edges, cap, {callsiteFile = null} = {}) {
|
|
14
15
|
const groups = new Map()
|
|
15
16
|
for (const edge of edges || []) {
|
|
16
17
|
if (isStructuralRelation(edge.relation) || edge.barrelProxy === true) continue
|
|
@@ -19,7 +20,12 @@ function aggregateEdges(g, edges, cap) {
|
|
|
19
20
|
const key = `${id}\0${relation}`
|
|
20
21
|
let group = groups.get(key)
|
|
21
22
|
if (!group) {
|
|
22
|
-
|
|
23
|
+
const targetFile = fileOf(g, id)
|
|
24
|
+
group = {
|
|
25
|
+
id, label: labelOf(g, id), relation, count: 0, lines: [],
|
|
26
|
+
file: callsiteFile || targetFile,
|
|
27
|
+
...(callsiteFile && callsiteFile !== targetFile ? {targetFile} : {}),
|
|
28
|
+
}
|
|
23
29
|
groups.set(key, group)
|
|
24
30
|
}
|
|
25
31
|
group.count++
|
|
@@ -95,7 +101,8 @@ function linesForGroups(title, groups) {
|
|
|
95
101
|
const lines = [`${title}: ${groups.total} container(s)${groups.capped ? ` (${groups.shown.length} shown)` : ''}`]
|
|
96
102
|
for (const group of groups.shown) {
|
|
97
103
|
const sites = group.lines.length ? `:${group.lines.join(',')}` : ''
|
|
98
|
-
|
|
104
|
+
const destination = group.targetFile ? ` → ${group.targetFile}` : ''
|
|
105
|
+
lines.push(` ${group.count}× ${group.relation} ${group.label} [call site ${group.file}${sites}${destination}]`)
|
|
99
106
|
}
|
|
100
107
|
return lines
|
|
101
108
|
}
|
|
@@ -133,14 +140,24 @@ export async function tContextBundle(g, args = {}, ctx = {}, inspectSymbol) {
|
|
|
133
140
|
name: String(g.byId.get(inspection.definition.id)?.label || '').replace(/\(\)$/, ''),
|
|
134
141
|
}
|
|
135
142
|
const inbound = aggregateEdges(g, g.inn.get(definition.id), maxRelated)
|
|
136
|
-
const outbound = aggregateEdges(g, g.out.get(definition.id), maxRelated)
|
|
143
|
+
const outbound = aggregateEdges(g, g.out.get(definition.id), maxRelated, {callsiteFile: definition.file})
|
|
137
144
|
const reExports = exactReExportSites(g, definition, maxReExports)
|
|
138
145
|
const source = []
|
|
139
|
-
|
|
146
|
+
const append = (role, excerpt) => {
|
|
147
|
+
if (!excerpt || source.length >= maxSourceFiles) return
|
|
148
|
+
if (source.some((item) => item.file === excerpt.file && item.focusLine === excerpt.focusLine)) return
|
|
149
|
+
source.push({role, ...excerpt})
|
|
150
|
+
}
|
|
151
|
+
append('Definition', inspection.source.definition)
|
|
152
|
+
const contextLines = boundedInteger(args.context_lines, 4, 0, 12)
|
|
153
|
+
for (const group of outbound.shown) {
|
|
154
|
+
for (const line of group.lines) append('Outbound call site', sourceExcerpt(ctx.repoRoot, group.file, line, contextLines))
|
|
155
|
+
}
|
|
156
|
+
for (const group of inbound.shown) {
|
|
157
|
+
for (const line of group.lines) append('Inbound call site', sourceExcerpt(ctx.repoRoot, group.file, line, contextLines))
|
|
158
|
+
}
|
|
140
159
|
for (const excerpt of inspection.source.callers || []) {
|
|
141
|
-
|
|
142
|
-
if (source.some((item) => item.file === excerpt.file && item.focusLine === excerpt.focusLine)) continue
|
|
143
|
-
source.push({role: 'Caller', ...excerpt})
|
|
160
|
+
append('Reference', excerpt)
|
|
144
161
|
}
|
|
145
162
|
const result = {
|
|
146
163
|
status: 'OK', definition, evidence: inspection.evidence,
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import {posix} from 'node:path'
|
|
2
|
+
import {analyzeEndpointInventory} from '../analysis/endpoints.js'
|
|
3
|
+
import {createPathClassifier, hasPathClass} from '../path-classification.js'
|
|
4
|
+
import {sourceExcerpt} from './tools-source.mjs'
|
|
5
|
+
import {toolResult} from './tool-result.mjs'
|
|
6
|
+
|
|
7
|
+
const NON_PRODUCT = ['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp']
|
|
8
|
+
|
|
9
|
+
const symbolLine = (node) => Number(String(node?.source_location || '').match(/L(\d+)/)?.[1]
|
|
10
|
+
|| String(node?.id || '').match(/@(\d+)$/)?.[1] || 0)
|
|
11
|
+
|
|
12
|
+
const symbolName = (node) => String(node?.label || node?.id || '')
|
|
13
|
+
.replace(/\([^)]*\).*$/, '')
|
|
14
|
+
.split(/[.#]/).at(-1)
|
|
15
|
+
.trim()
|
|
16
|
+
|
|
17
|
+
function codeFiles(graph) {
|
|
18
|
+
return [...new Set((graph?.nodes || [])
|
|
19
|
+
.filter((node) => !String(node.id).includes('#') && node.source_file && node.file_type === 'code')
|
|
20
|
+
.map((node) => node.source_file))]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function endpointCandidates(inventory, args) {
|
|
24
|
+
const path = String(args.path || '').trim()
|
|
25
|
+
const method = args.method ? String(args.method).toUpperCase() : null
|
|
26
|
+
if (!path) return []
|
|
27
|
+
const eligible = inventory.endpoints.filter((endpoint) => !method || endpoint.method === method)
|
|
28
|
+
const exact = eligible.filter((endpoint) => endpoint.path === path)
|
|
29
|
+
return exact.length ? exact : eligible.filter((endpoint) => endpoint.path.endsWith(path))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function handlerCandidates(graph, endpoint) {
|
|
33
|
+
if (!endpoint.handler) return []
|
|
34
|
+
const wanted = endpoint.handler.toLowerCase()
|
|
35
|
+
const qualifier = String(endpoint.handlerRef || '').split('.').slice(-2, -1)[0]?.toLowerCase() || ''
|
|
36
|
+
const routeDir = posix.dirname(endpoint.file)
|
|
37
|
+
const scored = (graph.nodes || [])
|
|
38
|
+
.filter((node) => String(node.id).includes('#') && node.source_file && symbolName(node).toLowerCase() === wanted)
|
|
39
|
+
.map((node) => {
|
|
40
|
+
const file = String(node.source_file).replace(/\\/g, '/')
|
|
41
|
+
let score = 0
|
|
42
|
+
if (file === endpoint.file) score += 100
|
|
43
|
+
if (posix.dirname(file) === routeDir) score += 40
|
|
44
|
+
if (file.startsWith(`${routeDir}/`)) score += 15
|
|
45
|
+
if (qualifier) {
|
|
46
|
+
const basename = posix.basename(file).replace(/\.[^.]+$/, '').toLowerCase()
|
|
47
|
+
if (basename === qualifier || basename.endsWith(`.${qualifier}`) || basename.endsWith(`-${qualifier}`)) score += 60
|
|
48
|
+
}
|
|
49
|
+
const line = symbolLine(node)
|
|
50
|
+
if (file === endpoint.file && line >= endpoint.line && line - endpoint.line <= 250) score += 20
|
|
51
|
+
return {node, score}
|
|
52
|
+
})
|
|
53
|
+
.sort((a, b) => b.score - a.score || String(a.node.id).localeCompare(String(b.node.id)))
|
|
54
|
+
if (!scored.length) return []
|
|
55
|
+
const best = scored[0].score
|
|
56
|
+
return scored.filter((item) => item.score === best).map((item) => item.node)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function traceCalls(graph, start, {maxDepth, maxNodes, includeClassified, repoRoot}) {
|
|
60
|
+
const classifier = createPathClassifier(repoRoot)
|
|
61
|
+
const allowed = (node) => {
|
|
62
|
+
if (!node?.source_file) return false
|
|
63
|
+
if (includeClassified) return true
|
|
64
|
+
const info = classifier.explain(node.source_file, {content: ''})
|
|
65
|
+
return !info?.excluded && !NON_PRODUCT.some((name) => hasPathClass(info, name))
|
|
66
|
+
}
|
|
67
|
+
const queue = [{id: String(start.id), depth: 0}]
|
|
68
|
+
const seen = new Set([String(start.id)])
|
|
69
|
+
const edges = []
|
|
70
|
+
let truncated = false
|
|
71
|
+
for (let cursor = 0; cursor < queue.length; cursor++) {
|
|
72
|
+
const current = queue[cursor]
|
|
73
|
+
if (current.depth >= maxDepth) continue
|
|
74
|
+
const outgoing = (graph.out.get(current.id) || [])
|
|
75
|
+
.filter((edge) => edge.relation === 'calls' && edge.typeOnly !== true && edge.compileOnly !== true && edge.barrelProxy !== true)
|
|
76
|
+
.map((edge) => ({edge, target: graph.byId.get(String(edge.id))}))
|
|
77
|
+
.filter(({target}) => target && String(target.id).includes('#') && allowed(target))
|
|
78
|
+
.sort((a, b) => (Number(a.edge.line) || 0) - (Number(b.edge.line) || 0) || String(a.target.id).localeCompare(String(b.target.id)))
|
|
79
|
+
const unique = new Set()
|
|
80
|
+
for (const {edge, target} of outgoing) {
|
|
81
|
+
const targetId = String(target.id)
|
|
82
|
+
if (unique.has(targetId)) continue
|
|
83
|
+
unique.add(targetId)
|
|
84
|
+
if (edges.length >= maxNodes - 1) { truncated = true; break }
|
|
85
|
+
edges.push({
|
|
86
|
+
from: current.id,
|
|
87
|
+
to: targetId,
|
|
88
|
+
depth: current.depth + 1,
|
|
89
|
+
relation: edge.relation,
|
|
90
|
+
provenance: edge.provenance || 'UNKNOWN',
|
|
91
|
+
line: Number(edge.line) || symbolLine(graph.byId.get(current.id)) || 1,
|
|
92
|
+
})
|
|
93
|
+
if (!seen.has(targetId)) {
|
|
94
|
+
seen.add(targetId)
|
|
95
|
+
queue.push({id: targetId, depth: current.depth + 1})
|
|
96
|
+
}
|
|
97
|
+
if (unique.size >= 6) { truncated = outgoing.length > unique.size; break }
|
|
98
|
+
}
|
|
99
|
+
if (truncated && edges.length >= maxNodes - 1) break
|
|
100
|
+
}
|
|
101
|
+
return {edges, truncated, nodeCount: seen.size}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function endpointLine(endpoint) {
|
|
105
|
+
const mount = endpoint.mountChain?.length
|
|
106
|
+
? endpoint.mountChain.map((item) => `${item.file}:${item.line} ${item.path}`).join(' → ')
|
|
107
|
+
: 'no static router mount chain'
|
|
108
|
+
return `${endpoint.method} ${endpoint.path} → ${endpoint.handler || 'inline/unknown'} (${endpoint.file}:${endpoint.line}; ${endpoint.mountState}/${endpoint.confidence}; declared ${endpoint.declaredPath}; ${mount})`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function tTraceEndpoint(graph, args, ctx) {
|
|
112
|
+
if (!ctx?.repoRoot) return 'Endpoint tracing needs the repo root (not provided to this server).'
|
|
113
|
+
const inventory = analyzeEndpointInventory(ctx.repoRoot, codeFiles(graph))
|
|
114
|
+
const candidates = endpointCandidates(inventory, args)
|
|
115
|
+
if (!candidates.length) return toolResult(`No endpoint matched ${args.method ? `${String(args.method).toUpperCase()} ` : ''}${args.path || '(missing path)'}.`, {
|
|
116
|
+
status: 'NOT_FOUND', query: {path: args.path || null, method: args.method || null}, inventory: inventory.stats,
|
|
117
|
+
})
|
|
118
|
+
if (candidates.length > 1) return toolResult([
|
|
119
|
+
`Endpoint query is ambiguous (${candidates.length} matches). Pass the exact composed path and method:`,
|
|
120
|
+
...candidates.slice(0, 20).map((endpoint) => ` ${endpointLine(endpoint)}`),
|
|
121
|
+
].join('\n'), {status: 'AMBIGUOUS', candidates: candidates.slice(0, 20), inventory: inventory.stats}, {
|
|
122
|
+
completeness: {status: 'PARTIAL', reason: 'ambiguous endpoint'},
|
|
123
|
+
})
|
|
124
|
+
const endpoint = candidates[0]
|
|
125
|
+
const handlers = handlerCandidates(graph, endpoint)
|
|
126
|
+
if (handlers.length !== 1) return toolResult([
|
|
127
|
+
`Endpoint resolved, but its handler symbol is ${handlers.length ? 'ambiguous' : 'not present in the graph'}:`,
|
|
128
|
+
` ${endpointLine(endpoint)}`,
|
|
129
|
+
...handlers.slice(0, 12).map((node) => ` candidate ${node.label || node.id} (${node.source_file}:${symbolLine(node) || '?'}) [${node.id}]`),
|
|
130
|
+
].join('\n'), {status: handlers.length ? 'AMBIGUOUS_HANDLER' : 'HANDLER_NOT_FOUND', endpoint, handlers}, {
|
|
131
|
+
completeness: {status: 'PARTIAL', reason: handlers.length ? 'ambiguous handler symbol' : 'handler symbol not found'},
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const maxDepth = Math.max(1, Math.min(4, Number(args.max_depth) || 3))
|
|
135
|
+
const maxNodes = Math.max(2, Math.min(40, Number(args.max_nodes) || 20))
|
|
136
|
+
const contextLines = Math.max(0, Math.min(6, Number(args.context_lines) || 2))
|
|
137
|
+
const maxExcerpts = Math.max(0, Math.min(12, Number(args.max_excerpts) || 6))
|
|
138
|
+
const handler = handlers[0]
|
|
139
|
+
const traced = traceCalls(graph, handler, {
|
|
140
|
+
maxDepth, maxNodes, includeClassified: args.include_classified === true, repoRoot: ctx.repoRoot,
|
|
141
|
+
})
|
|
142
|
+
const excerpts = traced.edges.slice(0, maxExcerpts).map((edge) => {
|
|
143
|
+
const source = graph.byId.get(edge.from)
|
|
144
|
+
return sourceExcerpt(ctx.repoRoot, source?.source_file, edge.line, contextLines)
|
|
145
|
+
}).filter(Boolean)
|
|
146
|
+
const lines = [
|
|
147
|
+
`Endpoint trace (${traced.truncated ? 'PARTIAL' : 'COMPLETE'}; depth ≤${maxDepth}, nodes ${traced.nodeCount}/${maxNodes}):`,
|
|
148
|
+
` ${endpointLine(endpoint)}`,
|
|
149
|
+
` handler ${handler.label || handler.id} (${handler.source_file}:${symbolLine(handler) || '?'}) [${handler.id}]`,
|
|
150
|
+
traced.edges.length ? 'Call graph:' : 'Call graph: no outgoing call edges from the resolved handler.',
|
|
151
|
+
...traced.edges.map((edge) => {
|
|
152
|
+
const from = graph.byId.get(edge.from), to = graph.byId.get(edge.to)
|
|
153
|
+
return ` ${' '.repeat(Math.max(0, edge.depth - 1))}${from?.label || edge.from} → ${to?.label || edge.to} (${from?.source_file}:${edge.line}; ${edge.provenance})`
|
|
154
|
+
}),
|
|
155
|
+
...excerpts.flatMap((excerpt) => ['', `Call-site excerpt (${excerpt.file}:${excerpt.startLine}-${excerpt.endLine}, focus ${excerpt.focusLine}):`, excerpt.text]),
|
|
156
|
+
]
|
|
157
|
+
return toolResult(lines.join('\n'), {
|
|
158
|
+
status: traced.truncated ? 'PARTIAL' : 'COMPLETE', endpoint, handler: {
|
|
159
|
+
id: String(handler.id), label: handler.label || String(handler.id), file: handler.source_file, line: symbolLine(handler),
|
|
160
|
+
}, trace: traced, excerpts, inventory: inventory.stats,
|
|
161
|
+
}, {completeness: {status: traced.truncated ? 'PARTIAL' : 'COMPLETE', reason: traced.truncated ? 'bounded trace cap reached' : 'bounded outgoing call graph exhausted'}})
|
|
162
|
+
}
|
package/src/mcp/tools-graph.mjs
CHANGED
|
@@ -180,7 +180,7 @@ export function tQueryGraph(g, {
|
|
|
180
180
|
// Callers can opt back into augmentation when they explicitly want both behaviors.
|
|
181
181
|
const automatic = pinned.seeds.length && augment_seeds !== true
|
|
182
182
|
? []
|
|
183
|
-
: findSeeds(g, question, Math.max(0,
|
|
183
|
+
: findSeeds(g, question, Math.max(0, 6 - pinned.seeds.length), {repoRoot: toolCtx.repoRoot || null})
|
|
184
184
|
const seeds = [...pinned.seeds, ...automatic.filter((node) => !pinned.seeds.some((seed) => String(seed.id) === String(node.id)))]
|
|
185
185
|
if (!seeds.length) return `No nodes matched "${question}".`
|
|
186
186
|
const maxDepth = Math.max(1, Math.min(6, Number(depth) || 3))
|
package/src/mcp/tools-health.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import {classifyChangeImpact} from '../analysis/change-classification.js'
|
|
|
9
9
|
import {compareAuditDebt, normalizeAuditScopeFiles, scopeAuditFindings} from '../analysis/audit-debt.js'
|
|
10
10
|
import {summarizeFindings} from '../analysis/findings.js'
|
|
11
11
|
import {summarizeCommunities, aggregateGraph} from '../analysis/graph-analysis.js'
|
|
12
|
-
import {
|
|
12
|
+
import {analyzeEndpointInventory} from '../analysis/endpoints.js'
|
|
13
13
|
import {computeStaticTestReachability} from '../analysis/static-test-reachability.js'
|
|
14
14
|
import {computeDeadCodeReview} from '../analysis/dead-code-review.js'
|
|
15
15
|
import {computeHotPathReview} from '../analysis/hot-path-review.js'
|
|
@@ -86,7 +86,10 @@ export function tFindDuplicates(g, args, ctx) {
|
|
|
86
86
|
const suppressionNote = suppressed && !includeClassified
|
|
87
87
|
? ` ${suppressed} fragment(s) classified as tests/e2e/generated/mock/story/docs/benchmark/temp or matched by .weavatrix.json exclude were suppressed; pass include_classified:true (and include_tests:true for tests) to inspect them explicitly.`
|
|
88
88
|
: ''
|
|
89
|
-
|
|
89
|
+
const boilerplateNote = analysis.boilerplateSuppressed
|
|
90
|
+
? ` ${analysis.boilerplateSuppressed} all-router framework boilerplate group(s) were suppressed; pass include_boilerplate:true to inspect them.`
|
|
91
|
+
: ''
|
|
92
|
+
if (!groups.length) return `No clones at ≥${simMin}% similarity / ≥${tokMin} tokens (${mode} mode). Try lowering the thresholds.${smallPolicy}${suppressionNote}${boilerplateNote}`
|
|
90
93
|
const top = groups.slice(0, Math.min(30, Math.max(1, Number(args.top_n) || 15)))
|
|
91
94
|
const lines = top.map((grp, k) => {
|
|
92
95
|
const isStr = grp.members.some((f) => f.kind === 'string')
|
|
@@ -94,7 +97,7 @@ export function tFindDuplicates(g, args, ctx) {
|
|
|
94
97
|
const sites = grp.members.slice(0, 8).map((f) => ` ${f.file}:${f.start}-${f.end}`)
|
|
95
98
|
return [head, ...sites].join('\n')
|
|
96
99
|
})
|
|
97
|
-
return `Found ${groups.length} clone group(s) (${mode} mode, ≥${simMin}%, ≥${tokMin} tok${includeStrings ? ', incl. large string literals' : ''}). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nUse read_source on any two sites to compare, then extract shared logic.${smallPolicy}${suppressionNote}`
|
|
100
|
+
return `Found ${groups.length} clone group(s) (${mode} mode, ≥${simMin}%, ≥${tokMin} tok${includeStrings ? ', incl. large string literals' : ''}). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nUse read_source on any two sites to compare, then extract shared logic.${smallPolicy}${suppressionNote}${boilerplateNote}`
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
// Focused dead-code review queue. Unlike the broad run_audit surface, this includes functions and
|
|
@@ -197,6 +200,7 @@ export function tFindDeadCode(g, args, ctx) {
|
|
|
197
200
|
}
|
|
198
201
|
|
|
199
202
|
const SEVERITY_RANK = {critical: 0, high: 1, medium: 2, low: 3, info: 4}
|
|
203
|
+
const AUDIT_NON_PRODUCT_CLASSES = ['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp']
|
|
200
204
|
|
|
201
205
|
export function formatAuditFinding(f) {
|
|
202
206
|
const where = f.file ? ` (${f.file}${f.symbol ? ` ${f.symbol}` : ''})` : f.package ? ` (pkg ${f.package}${f.version ? `@${f.version}` : ''}${f.manifest ? `; ${f.manifest}` : ''})` : ''
|
|
@@ -206,12 +210,40 @@ export function formatAuditFinding(f) {
|
|
|
206
210
|
return ` [${f.severity}/${f.confidence || '?'}] ${f.rule}: ${f.title}${where}${f.reason ? `\n reason: ${f.reason}` : ''}${verification}${f.cycleRoute ? `\n route: ${f.cycleRoute}` : ''}${f.fixHint ? `\n fix: ${f.fixHint}` : ''}`
|
|
207
211
|
}
|
|
208
212
|
|
|
209
|
-
const
|
|
213
|
+
const auditFindingPaths = (finding) => {
|
|
214
|
+
const paths = []
|
|
215
|
+
if (finding?.file) paths.push(String(finding.file))
|
|
216
|
+
if (Array.isArray(finding?.files)) paths.push(...finding.files.map(String))
|
|
217
|
+
if (finding?.cycleRoute) paths.push(...String(finding.cycleRoute).split(/\s*(?:→|→|->)\s*/))
|
|
218
|
+
return [...new Set(paths.map((file) => file.replace(/\\/g, '/').trim()).filter(Boolean))]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function auditFindingPathScope(findings, {includeClassified = false, repoRoot = null} = {}) {
|
|
222
|
+
const all = Array.isArray(findings) ? findings : []
|
|
223
|
+
if (includeClassified) return {findings: all, suppressed: 0}
|
|
224
|
+
const classifier = createPathClassifier(repoRoot)
|
|
225
|
+
const cache = new Map()
|
|
226
|
+
const classified = (file) => {
|
|
227
|
+
if (!cache.has(file)) cache.set(file, classifier.explain(file, {content: ''}))
|
|
228
|
+
const info = cache.get(file)
|
|
229
|
+
return info?.excluded || AUDIT_NON_PRODUCT_CLASSES.some((name) => hasPathClass(info, name))
|
|
230
|
+
}
|
|
231
|
+
const kept = all.filter((finding) => {
|
|
232
|
+
const paths = auditFindingPaths(finding)
|
|
233
|
+
return !paths.length || paths.some((file) => !classified(file))
|
|
234
|
+
})
|
|
235
|
+
return {findings: kept, suppressed: all.length - kept.length}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const DEPENDENCY_AUDIT_RULES = new Set(['unused-dep', 'missing-dep', 'duplicate-dep', 'unresolved-import', 'lockfile-drift'])
|
|
239
|
+
export const isDependencyAuditFinding = (finding) => DEPENDENCY_AUDIT_RULES.has(String(finding?.rule || ''))
|
|
240
|
+
|
|
241
|
+
const auditFilter = (audit, args, findings = audit.findings, repoRoot = null) => {
|
|
210
242
|
const minSev = SEVERITY_RANK[args.min_severity] ?? 4
|
|
211
243
|
const category = args.category ? String(args.category) : null
|
|
212
|
-
return findings
|
|
244
|
+
return auditFindingPathScope(findings, {includeClassified: args.include_classified === true, repoRoot}).findings
|
|
213
245
|
.filter((finding) => (SEVERITY_RANK[finding.severity] ?? 4) <= minSev)
|
|
214
|
-
.filter((finding) => !category || finding.category === category)
|
|
246
|
+
.filter((finding) => !category || (category === 'dependencies' ? isDependencyAuditFinding(finding) : finding.category === category))
|
|
215
247
|
}
|
|
216
248
|
|
|
217
249
|
const auditChecksLine = (audit) => {
|
|
@@ -229,11 +261,12 @@ const auditConventionLines = (audit) => {
|
|
|
229
261
|
].filter((line) => line != null)
|
|
230
262
|
}
|
|
231
263
|
|
|
232
|
-
const formatOrdinaryAudit = (audit, args, findings = audit.findings, heading = null) => {
|
|
264
|
+
const formatOrdinaryAudit = (audit, args, findings = audit.findings, heading = null, repoRoot = null) => {
|
|
233
265
|
const max = Math.max(1, Math.min(100, Number(args.max_findings) || 30))
|
|
234
|
-
const
|
|
266
|
+
const pathScope = auditFindingPathScope(findings, {includeClassified: args.include_classified === true, repoRoot})
|
|
267
|
+
const filtered = auditFilter(audit, args, pathScope.findings, repoRoot)
|
|
235
268
|
const shown = filtered.slice(0, max)
|
|
236
|
-
const summary = summarizeFindings(findings)
|
|
269
|
+
const summary = summarizeFindings(pathScope.findings)
|
|
237
270
|
const sev = summary.bySeverity
|
|
238
271
|
const bycat = summary.byCategory
|
|
239
272
|
const deps = audit.dependencyReport || {}
|
|
@@ -241,8 +274,9 @@ const formatOrdinaryAudit = (audit, args, findings = audit.findings, heading = n
|
|
|
241
274
|
heading,
|
|
242
275
|
`Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
|
|
243
276
|
...auditConventionLines(audit),
|
|
244
|
-
`Dependency manifests: ${deps.status || 'UNKNOWN'}
|
|
277
|
+
`Dependency manifests: ${deps.status || 'UNKNOWN'} — checked ${deps.declared ?? audit.scanned.manifestDeps ?? 0} declared package(s) against ${deps.importRecords ?? audit.scanned.externalImports ?? 0} external import record(s); unused ${deps.unused ?? 'unknown'}, missing ${deps.missing ?? 'unknown'}, duplicate declarations ${deps.duplicateDeclarations ?? 'unknown'}. npm per-finding manifest/source/config verification: ${deps.perFindingVerification ? 'AVAILABLE' : 'UNAVAILABLE'}.`,
|
|
245
278
|
`Scoped severity: critical ${sev.critical}, high ${sev.high}, medium ${sev.medium}, low ${sev.low}, info ${sev.info}. Scoped categories: unused ${bycat.unused}, structure ${bycat.structure}, vulnerability ${bycat.vulnerability}, malware ${bycat.malware}.`,
|
|
279
|
+
pathScope.suppressed ? `Path policy: production-first; suppressed ${pathScope.suppressed} finding(s) whose evidence is entirely test/e2e/generated/mock/story/docs/benchmark/temp or explicitly excluded. Pass include_classified:true to include them.` : 'Path policy: production-first; no classified-only findings were suppressed.',
|
|
246
280
|
`Repository-level ${auditChecksLine(audit)}`,
|
|
247
281
|
'',
|
|
248
282
|
`Showing ${shown.length} of ${filtered.length} finding(s)${args.category ? ` in category "${args.category}"` : ''}${args.min_severity ? ` at ≥${args.min_severity}` : ''}:`,
|
|
@@ -353,12 +387,12 @@ async function runAuditWithBaseline(args, ctx, currentGraph) {
|
|
|
353
387
|
const comparison = compareAuditDebt(currentAudit, baseline.value, changedFiles, {completeChangeSet})
|
|
354
388
|
const mode = ['new', 'existing', 'all'].includes(args.debt) ? args.debt : 'new'
|
|
355
389
|
const selectedRaw = mode === 'new' ? comparison.new : mode === 'existing' ? comparison.existing : comparison.all
|
|
356
|
-
const selected = auditFilter(currentAudit, args, selectedRaw)
|
|
390
|
+
const selected = auditFilter(currentAudit, args, selectedRaw, ctx.repoRoot)
|
|
357
391
|
const max = Math.max(1, Math.min(100, Number(args.max_findings) || 30))
|
|
358
392
|
const shown = selected.slice(0, max)
|
|
359
393
|
const optional = comparison.optional.checks.map((check) => `${check.name.toUpperCase()} UNCOMPARABLE (current ${check.current}; baseline ${check.baseline})`).join('; ')
|
|
360
394
|
const stateOf = (finding) => mode === 'all' ? finding.debtState : mode
|
|
361
|
-
const fixedShown = auditFilter(baseline.value, args, comparison.fixed).slice(0, Math.min(10, max))
|
|
395
|
+
const fixedShown = auditFilter(baseline.value, args, comparison.fixed, ctx.repoRoot).slice(0, Math.min(10, max))
|
|
362
396
|
const text = [
|
|
363
397
|
`${mode.toUpperCase()} DEBT — deterministic internal audit vs ${resolved.ref} (${resolved.commit.slice(0, 12)})`,
|
|
364
398
|
`Changed scope: ${changedFiles.length} file(s) from ${scopeSource}${changedFiles.length ? ` — ${changedFiles.slice(0, 12).join(', ')}${changedFiles.length > 12 ? ', …' : ''}` : ' — working tree matches the baseline'}.`,
|
|
@@ -411,38 +445,16 @@ export async function tRunAudit(g, args, ctx) {
|
|
|
411
445
|
if (!normalized.ok) return `Changed-scope audit invalid: ${normalized.error}.`
|
|
412
446
|
const scoped = scopeAuditFindings(audit.findings, normalized.files)
|
|
413
447
|
const text = formatOrdinaryAudit(audit, args, scoped,
|
|
414
|
-
`CHANGED-SCOPE ONLY — ${normalized.files.length} explicitly supplied file(s); no baseline was provided, so these findings are not classified as new, existing, or fixed
|
|
448
|
+
`CHANGED-SCOPE ONLY — ${normalized.files.length} explicitly supplied file(s); no baseline was provided, so these findings are not classified as new, existing, or fixed.`, ctx.repoRoot)
|
|
415
449
|
return toolResult(text, {
|
|
416
450
|
status: 'COMPLETE',
|
|
417
451
|
mode: 'changed-scope',
|
|
418
452
|
scope: {files: normalized.files, source: 'explicit changed_files'},
|
|
419
453
|
comparison: {status: 'UNAVAILABLE', reason: 'base_ref was not provided; changed-scope is not a new-debt claim'},
|
|
420
|
-
findings: auditFilter(audit, args, scoped),
|
|
454
|
+
findings: auditFilter(audit, args, scoped, ctx.repoRoot),
|
|
421
455
|
}, {completeness: {status: 'COMPLETE'}})
|
|
422
456
|
}
|
|
423
|
-
|
|
424
|
-
const cat = args.category ? String(args.category) : null
|
|
425
|
-
const max = Math.max(1, Math.min(100, Number(args.max_findings) || 30))
|
|
426
|
-
const filtered = audit.findings
|
|
427
|
-
.filter((f) => (SEVERITY_RANK[f.severity] ?? 4) <= minSev)
|
|
428
|
-
.filter((f) => !cat || f.category === cat)
|
|
429
|
-
const shown = filtered.slice(0, max)
|
|
430
|
-
const sev = audit.summary.bySeverity
|
|
431
|
-
const bycat = audit.summary.byCategory
|
|
432
|
-
const deps = audit.dependencyReport || {}
|
|
433
|
-
const check = (name, state) => `${name} ${state?.status || 'ERROR'}${state?.detail ? ` — ${state.detail}` : ''}`
|
|
434
|
-
return [
|
|
435
|
-
`Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
|
|
436
|
-
`Severity: critical ${sev.critical}, high ${sev.high}, medium ${sev.medium}, low ${sev.low}, info ${sev.info}. Categories: unused ${bycat.unused}, structure ${bycat.structure}, vulnerability ${bycat.vulnerability}, malware ${bycat.malware}.`,
|
|
437
|
-
`Structure: ${audit.structureReport?.runtimeCycles ?? audit.structureReport?.cycles ?? 0} runtime cycle(s), ${audit.structureReport?.compileTimeCouplings ?? audit.structureReport?.typeCouplings ?? 0} compile-time coupling group(s), ${audit.structureReport?.orphans ?? 0} orphan(s); import edges: ${audit.structureReport?.runtimeImportEdges ?? audit.structureReport?.importEdges ?? 0} runtime + ${audit.structureReport?.typeOnlyImportEdges ?? 0} type-only + ${audit.structureReport?.compileOnlyImportEdges ?? 0} compile-only. Dead: ${audit.deadReport.deadFiles} file(s), ${audit.deadReport.unusedExports} unused export(s).`,
|
|
438
|
-
`Dependency manifests: ${deps.status || 'UNKNOWN'} — checked ${deps.declared ?? audit.scanned.manifestDeps ?? 0} declared package(s) against ${deps.importRecords ?? audit.scanned.externalImports ?? 0} external import record(s); unused ${deps.unused ?? 'unknown'}, missing ${deps.missing ?? 'unknown'}, duplicate declarations ${deps.duplicateDeclarations ?? 'unknown'}. npm per-finding manifest/source/config verification: ${deps.perFindingVerification ? 'AVAILABLE' : 'UNAVAILABLE'}.`,
|
|
439
|
-
...auditConventionLines(audit),
|
|
440
|
-
`Checks: ${check('OSV', audit.checks?.osv)}; ${check('malware', audit.checks?.malware)}. A NOT_CHECKED/PARTIAL/ERROR check is incomplete or unknown, never a clean zero.`,
|
|
441
|
-
``,
|
|
442
|
-
`Showing ${shown.length} of ${filtered.length} finding(s)${cat ? ` in category "${cat}"` : ''}${args.min_severity ? ` at ≥${args.min_severity}` : ''}:`,
|
|
443
|
-
...shown.map(formatAuditFinding),
|
|
444
|
-
filtered.length > shown.length ? ` … +${filtered.length - shown.length} more (raise max_findings or filter by category/min_severity)` : null,
|
|
445
|
-
].filter((x) => x != null).join('\n')
|
|
457
|
+
return formatOrdinaryAudit(audit, args, audit.findings, null, ctx.repoRoot)
|
|
446
458
|
}
|
|
447
459
|
|
|
448
460
|
// Named module clusters: graph communities labeled by their dominant folder instead of bare numbers.
|
|
@@ -665,12 +677,29 @@ export function tListEndpoints(g, args, ctx) {
|
|
|
665
677
|
.filter((n) => !String(n.id).includes('#') && n.source_file && n.file_type === 'code')
|
|
666
678
|
.map((n) => n.source_file)
|
|
667
679
|
)]
|
|
668
|
-
const
|
|
680
|
+
const inventory = analyzeEndpointInventory(ctx.repoRoot, codeFiles)
|
|
681
|
+
let eps = inventory.endpoints
|
|
682
|
+
const method = args.method ? String(args.method).toUpperCase() : null
|
|
683
|
+
const path = args.path ? String(args.path) : null
|
|
684
|
+
if (method) eps = eps.filter((endpoint) => endpoint.method === method)
|
|
685
|
+
if (path) eps = eps.filter((endpoint) => endpoint.path === path || endpoint.path.endsWith(path))
|
|
669
686
|
if (!eps.length) return 'No HTTP endpoints detected in the indexed code files.'
|
|
670
687
|
const max = Math.max(1, Math.min(300, Number(args.max_results) || 100))
|
|
671
688
|
const shown = eps.slice(0, max)
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
689
|
+
const stats = inventory.stats
|
|
690
|
+
const text = [
|
|
691
|
+
`${eps.length} endpoint(s) matched${eps.length > shown.length ? `, showing ${shown.length}` : ''}. Inventory: ${stats.declaredRoutes} declaration(s); ${stats.reachableStaticRoutes} statically reachable composed route(s); ${stats.localDeclarations} local/root declaration candidate(s); ${stats.staticMounts} static router mount(s)${stats.truncated ? `; TRUNCATED at ${stats.maxEndpoints}` : ''}.`,
|
|
692
|
+
...shown.map((e) => {
|
|
693
|
+
const via = e.mountChain?.length
|
|
694
|
+
? `\n declared ${e.declaredPath} in ${e.file}${e.line ? `:${e.line}` : ''}; mount chain ${e.mountChain.map((mount) => `${mount.file}:${mount.line} ${mount.path}`).join(' → ')}`
|
|
695
|
+
: ''
|
|
696
|
+
return ` ${e.method.toUpperCase().padEnd(6)} ${e.path}${e.handler ? ` → ${e.handler}` : ''} (${e.file}${e.line ? `:${e.line}` : ''}; ${e.mountState}/${e.confidence})${via}`
|
|
697
|
+
}),
|
|
675
698
|
].join('\n')
|
|
699
|
+
return toolResult(text, {
|
|
700
|
+
filters: {method, path},
|
|
701
|
+
stats,
|
|
702
|
+
endpoints: shown,
|
|
703
|
+
page: {shown: shown.length, total: eps.length, truncated: eps.length > shown.length || stats.truncated},
|
|
704
|
+
}, {completeness: {status: stats.truncated ? 'PARTIAL' : 'COMPLETE', reason: stats.truncated ? `endpoint cap ${stats.maxEndpoints} reached` : 'all indexed code files scanned'}})
|
|
676
705
|
}
|
package/src/mcp/tools-source.mjs
CHANGED
|
@@ -27,7 +27,7 @@ function candidateNodes(g, query, limit = 20) {
|
|
|
27
27
|
}))
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function
|
|
30
|
+
export function sourceExcerpt(repoRoot, file, focusLine, contextLines) {
|
|
31
31
|
const resolved = resolveRepoPath(repoRoot, file)
|
|
32
32
|
if (!resolved.ok) return null
|
|
33
33
|
let size
|
|
@@ -198,8 +198,8 @@ export async function tInspectSymbol(g, args = {}, ctx = {}) {
|
|
|
198
198
|
...(node.source_range ? {range: node.source_range} : {}),
|
|
199
199
|
...(node.complexity ? {complexity: node.complexity} : {}),
|
|
200
200
|
}
|
|
201
|
-
const definitionSource =
|
|
202
|
-
const callerSources = grouped.shown.slice(0, 5).map((group) =>
|
|
201
|
+
const definitionSource = sourceExcerpt(ctx.repoRoot, definition.file, definition.line, contextLines)
|
|
202
|
+
const callerSources = grouped.shown.slice(0, 5).map((group) => sourceExcerpt(ctx.repoRoot, group.file, group.locations[0]?.line || 1, contextLines)).filter(Boolean)
|
|
203
203
|
const result = {
|
|
204
204
|
status: 'OK',
|
|
205
205
|
definition,
|
package/src/mcp-server.mjs
CHANGED
|
@@ -270,6 +270,7 @@ async function main() {
|
|
|
270
270
|
const graphSnapshot = graph
|
|
271
271
|
const callCtx = mutatesTarget ? ctx : {...ctx}
|
|
272
272
|
const execute = async () => {
|
|
273
|
+
const startedAt = performance.now()
|
|
273
274
|
try {
|
|
274
275
|
// A queued rebuild must see a preceding retarget's graph, while non-mutating calls stay
|
|
275
276
|
// pinned to the graph that was active when their request arrived.
|
|
@@ -302,12 +303,39 @@ async function main() {
|
|
|
302
303
|
if (schemaWarning) text += `\n\nWarning: ${schemaWarning.message}`
|
|
303
304
|
if (staleLine && staleNotices.shouldShow({line: staleLine, graphPath: callCtx.graphPath, force: tool.name === 'graph_stats'})) text += `\n\n${staleLine}`
|
|
304
305
|
}
|
|
305
|
-
const
|
|
306
|
+
const structuredBytes = normalized.structured ? Buffer.byteLength(JSON.stringify(normalized.structured)) : 0
|
|
307
|
+
const textBytes = Buffer.byteLength(text)
|
|
308
|
+
const durationMs = Math.max(0, performance.now() - startedAt)
|
|
309
|
+
const response = {
|
|
310
|
+
content: [{type: 'text', text}],
|
|
311
|
+
_meta: {
|
|
312
|
+
'weavatrix/metrics': {
|
|
313
|
+
schemaVersion: 'weavatrix.metrics.v1',
|
|
314
|
+
durationMs: Math.round(durationMs * 10) / 10,
|
|
315
|
+
textBytes,
|
|
316
|
+
structuredBytes,
|
|
317
|
+
estimatedOutputTokens: Math.ceil((textBytes + structuredBytes) / 4),
|
|
318
|
+
graphFreshness: staleLine ? 'stale' : (refresh?.error ? 'stale' : 'fresh'),
|
|
319
|
+
graphUpdate: refresh?.kind || 'none',
|
|
320
|
+
graphRevision: refresh?.revision || callGraph?.graphRevision || null,
|
|
321
|
+
cache: refreshesGraph ? (refresh?.kind === 'none' ? 'graph-hit' : 'graph-refreshed') : 'not-applicable',
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
}
|
|
306
325
|
if (normalized.structured) response.structuredContent = normalized.structured
|
|
307
326
|
return reply(id, response)
|
|
308
327
|
} catch (e) {
|
|
309
328
|
log(`tool ${params?.name} threw: ${e.stack || e.message}`)
|
|
310
|
-
|
|
329
|
+
const text = `Tool error: ${e.message}`
|
|
330
|
+
return reply(id, {
|
|
331
|
+
content: [{type: 'text', text}], isError: true,
|
|
332
|
+
_meta: {'weavatrix/metrics': {
|
|
333
|
+
schemaVersion: 'weavatrix.metrics.v1', durationMs: Math.round(Math.max(0, performance.now() - startedAt) * 10) / 10,
|
|
334
|
+
textBytes: Buffer.byteLength(text), structuredBytes: 0,
|
|
335
|
+
estimatedOutputTokens: Math.ceil(Buffer.byteLength(text) / 4), graphFreshness: 'unknown',
|
|
336
|
+
graphUpdate: 'none', graphRevision: graphSnapshot?.graphRevision || null, cache: 'not-applicable',
|
|
337
|
+
}},
|
|
338
|
+
})
|
|
311
339
|
}
|
|
312
340
|
}
|
|
313
341
|
if (!mutatesTarget && !refreshesGraph) return execute()
|
package/src/mcp-source-tools.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
2
2
|
import { spawnSync } from 'node:child_process'
|
|
3
|
-
import { extname, join, relative } from 'node:path'
|
|
3
|
+
import { extname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
4
4
|
import { resolveRepoPath } from './repo-path.js'
|
|
5
5
|
import { childProcessEnv } from './child-env.js'
|
|
6
6
|
import { toolResult } from './mcp/tool-result.mjs'
|
|
@@ -11,21 +11,24 @@ const MAX_SEARCH_FILE_BYTES = 1024 * 1024
|
|
|
11
11
|
const MAX_SOURCE_FILE_BYTES = 2 * 1024 * 1024
|
|
12
12
|
const MAX_SOURCE_CONTEXT_LINES = 1000
|
|
13
13
|
|
|
14
|
-
function rgSearch(repoRoot, resolveRg, query, { isRegex, glob, maxResults }) {
|
|
14
|
+
function rgSearch(repoRoot, resolveRg, query, { isRegex, glob, maxResults }, spawnRg = spawnSync) {
|
|
15
15
|
const rg = resolveRg()
|
|
16
16
|
if (!rg) return null
|
|
17
17
|
const args = ['--line-number', '--no-heading', '--color', 'never', '--hidden', '-g', '!.git/**', '-m', '100', '-i']
|
|
18
18
|
if (!isRegex) args.push('--fixed-strings')
|
|
19
19
|
if (glob) args.push('-g', glob)
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// Run from the repository root and search `.` so path globs such as `src/**` are evaluated
|
|
21
|
+
// against repository-relative paths on Windows too. Passing an absolute search root makes rg
|
|
22
|
+
// compare the glob with a drive-prefixed path and silently return no matches.
|
|
23
|
+
args.push('--', query, '.')
|
|
24
|
+
const res = spawnRg(rg, args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: 15000, env: childProcessEnv() })
|
|
22
25
|
if (res.status !== 0 && res.status !== 1) return null
|
|
23
26
|
const out = []
|
|
24
27
|
for (const line of (res.stdout || '').split(/\r?\n/)) {
|
|
25
28
|
const match = line.match(/^(.*?):(\d+):(.*)$/)
|
|
26
29
|
if (!match) continue
|
|
27
30
|
out.push({
|
|
28
|
-
file: relative(repoRoot, match[1]).replace(/\\/g, '/'),
|
|
31
|
+
file: relative(repoRoot, isAbsolute(match[1]) ? match[1] : resolve(repoRoot, match[1])).replace(/\\/g, '/'),
|
|
29
32
|
line: Number(match[2]),
|
|
30
33
|
text: match[3].trim().slice(0, 300),
|
|
31
34
|
})
|
|
@@ -87,12 +90,12 @@ function nodeGrep(repoRoot, query, { isRegex, glob, maxResults }) {
|
|
|
87
90
|
return out
|
|
88
91
|
}
|
|
89
92
|
|
|
90
|
-
export function searchCode({ repoRoot, resolveRg }, { query, is_regex = false, max_results = 40, glob } = {}) {
|
|
93
|
+
export function searchCode({ repoRoot, resolveRg, spawnRg = spawnSync }, { query, is_regex = false, max_results = 40, glob } = {}) {
|
|
91
94
|
if (!query) return 'Provide a "query" string.'
|
|
92
95
|
if (!repoRoot || !existsSync(repoRoot)) return 'Source search unavailable: repo root not provided to this MCP server.'
|
|
93
96
|
const max = Math.max(1, Math.min(200, Number(max_results) || 40))
|
|
94
97
|
const opts = { isRegex: !!is_regex, glob: glob || null, maxResults: max }
|
|
95
|
-
let matches = rgSearch(repoRoot, resolveRg, query, opts)
|
|
98
|
+
let matches = rgSearch(repoRoot, resolveRg, query, opts, spawnRg)
|
|
96
99
|
const engine = matches ? 'ripgrep' : 'node'
|
|
97
100
|
if (!matches) matches = nodeGrep(repoRoot, query, opts)
|
|
98
101
|
const what = is_regex ? `/${query}/i` : `"${query}"`
|