weavatrix 0.1.3 → 0.2.0
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 +168 -63
- package/SECURITY.md +25 -8
- package/package.json +2 -2
- package/skill/SKILL.md +108 -39
- package/src/analysis/architecture-contract.js +343 -0
- package/src/analysis/audit-debt.js +94 -0
- package/src/analysis/change-classification.js +509 -0
- package/src/analysis/cycle-route.js +14 -0
- package/src/analysis/dead-check.js +13 -6
- package/src/analysis/dead-code-review.js +245 -0
- package/src/analysis/dep-check-ecosystems.js +163 -0
- package/src/analysis/dep-check.js +69 -147
- package/src/analysis/dep-rules.js +47 -31
- package/src/analysis/duplicates.compute.js +47 -7
- package/src/analysis/endpoints-java.js +186 -0
- package/src/analysis/endpoints-rust.js +124 -0
- package/src/analysis/endpoints.js +18 -5
- package/src/analysis/findings.js +8 -1
- package/src/analysis/git-history.js +549 -0
- package/src/analysis/git-ref-graph.js +74 -0
- package/src/analysis/graph-analysis.aggregate.js +29 -28
- package/src/analysis/graph-analysis.edges.js +24 -0
- package/src/analysis/graph-analysis.js +1 -1
- package/src/analysis/graph-analysis.summaries.js +4 -1
- package/src/analysis/http-contracts.js +581 -0
- package/src/analysis/internal-audit.collect.js +43 -2
- package/src/analysis/internal-audit.reach.js +52 -1
- package/src/analysis/internal-audit.run.js +66 -13
- package/src/analysis/java-source.js +36 -0
- package/src/analysis/package-reachability.js +39 -0
- package/src/analysis/static-test-reachability.js +133 -0
- package/src/build-graph.js +72 -13
- package/src/graph/build-worker.js +3 -4
- package/src/graph/builder/lang-java.js +177 -14
- package/src/graph/builder/lang-js.js +71 -12
- package/src/graph/builder/lang-rust.js +129 -6
- package/src/graph/community.js +27 -0
- package/src/graph/file-lock.js +69 -0
- package/src/graph/freshness-probe.js +141 -0
- package/src/graph/graph-filter.js +28 -11
- package/src/graph/incremental-refresh.js +232 -0
- package/src/graph/internal-builder.barrels.js +169 -0
- package/src/graph/internal-builder.build.js +111 -16
- package/src/graph/internal-builder.java.js +33 -0
- package/src/graph/internal-builder.langs.js +2 -1
- package/src/graph/internal-builder.resolvers.js +109 -2
- package/src/graph/layout.js +21 -5
- package/src/graph/relations.js +4 -0
- package/src/graph/repo-registry.js +124 -0
- package/src/mcp/catalog.mjs +64 -21
- package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
- package/src/mcp/evidence-snapshot.common.mjs +160 -0
- package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
- package/src/mcp/evidence-snapshot.health.mjs +95 -0
- package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
- package/src/mcp/evidence-snapshot.mjs +50 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
- package/src/mcp/evidence-snapshot.structure.mjs +81 -0
- package/src/mcp/graph-context.mjs +106 -181
- package/src/mcp/graph-diff.mjs +217 -0
- package/src/mcp/staleness-notice.mjs +20 -0
- package/src/mcp/sync-evidence.mjs +418 -0
- package/src/mcp/sync-payload.mjs +194 -8
- package/src/mcp/tool-result.mjs +51 -0
- package/src/mcp/tools-actions.mjs +114 -33
- package/src/mcp/tools-architecture.mjs +144 -0
- package/src/mcp/tools-company.mjs +273 -0
- package/src/mcp/tools-graph-hubs.mjs +58 -0
- package/src/mcp/tools-graph.mjs +36 -68
- package/src/mcp/tools-health.mjs +354 -20
- package/src/mcp/tools-history.mjs +22 -0
- package/src/mcp/tools-impact-change.mjs +261 -0
- package/src/mcp/tools-impact.mjs +35 -11
- package/src/mcp-server.mjs +100 -17
- package/src/mcp-source-tools.mjs +11 -3
- package/src/path-classification.js +201 -0
- package/src/path-ignore.js +69 -0
- package/src/security/typosquat.js +1 -1
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Symbol-aware change_impact implementation, isolated from the other hot impact tools so its
|
|
2
|
+
// classifier/evidence contract can be tested without perturbing get_dependents or graph_diff.
|
|
3
|
+
import {spawnSync} from 'node:child_process'
|
|
4
|
+
import {childProcessEnv} from '../child-env.js'
|
|
5
|
+
import {classifyChangeImpact} from '../analysis/change-classification.js'
|
|
6
|
+
import {readCoverageForRepo} from '../analysis/coverage-reports.js'
|
|
7
|
+
import {computeStaticTestReachability} from '../analysis/static-test-reachability.js'
|
|
8
|
+
import {isStructuralRelation} from '../graph/relations.js'
|
|
9
|
+
import {degreeOf, labelOf, rawGraph} from './graph-context.mjs'
|
|
10
|
+
import {toolResult} from './tool-result.mjs'
|
|
11
|
+
|
|
12
|
+
function gitLines(repoRoot, args) {
|
|
13
|
+
const result = spawnSync('git', ['-C', repoRoot, ...args], {encoding: 'utf8', timeout: 8000, env: childProcessEnv()})
|
|
14
|
+
if (result.status !== 0) return null
|
|
15
|
+
return String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function gitValue(repoRoot, args) {
|
|
19
|
+
const result = spawnSync('git', ['-C', repoRoot, ...args], {encoding: 'utf8', timeout: 8000, env: childProcessEnv()})
|
|
20
|
+
if (result.status !== 0) return null
|
|
21
|
+
return String(result.stdout || '').trim() || null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveImpactBase(repoRoot, requested) {
|
|
25
|
+
const candidates = requested ? [requested] : ['origin/HEAD', 'origin/main', 'origin/master', 'main', 'master']
|
|
26
|
+
for (const ref of candidates) {
|
|
27
|
+
const value = gitValue(repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
|
|
28
|
+
if (value) return ref
|
|
29
|
+
}
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function reverseReach(g, seeds, maxDepth) {
|
|
34
|
+
const states = new Map([...seeds].map((id) => [String(id), {
|
|
35
|
+
runtimeDepth: 0, runtimeRelation: null, compileDepth: null, compileRelation: null,
|
|
36
|
+
}]))
|
|
37
|
+
const frontier = [...seeds].map((id) => ({id: String(id), depth: 0, compileOnly: false}))
|
|
38
|
+
for (let cursor = 0; cursor < frontier.length; cursor++) {
|
|
39
|
+
const current = frontier[cursor]
|
|
40
|
+
if (current.depth >= maxDepth) continue
|
|
41
|
+
for (const edge of g.inn.get(current.id) || []) {
|
|
42
|
+
if (isStructuralRelation(edge.relation) || edge.barrelProxy === true) continue
|
|
43
|
+
const id = String(edge.id)
|
|
44
|
+
const compileOnly = current.compileOnly || edge.typeOnly === true || edge.compileOnly === true
|
|
45
|
+
const depth = current.depth + 1
|
|
46
|
+
const entry = states.get(id) || {runtimeDepth: null, runtimeRelation: null, compileDepth: null, compileRelation: null}
|
|
47
|
+
const depthKey = compileOnly ? 'compileDepth' : 'runtimeDepth'
|
|
48
|
+
const relationKey = compileOnly ? 'compileRelation' : 'runtimeRelation'
|
|
49
|
+
if (entry[depthKey] != null && entry[depthKey] <= depth) continue
|
|
50
|
+
entry[depthKey] = depth
|
|
51
|
+
entry[relationKey] = edge.relation || 'rel'
|
|
52
|
+
states.set(id, entry)
|
|
53
|
+
frontier.push({id, depth, compileOnly})
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return new Map([...states].map(([id, entry]) => [id, {
|
|
57
|
+
...entry,
|
|
58
|
+
depth: entry.runtimeDepth ?? entry.compileDepth ?? 0,
|
|
59
|
+
compileOnly: entry.runtimeDepth == null,
|
|
60
|
+
relation: entry.runtimeDepth != null ? entry.runtimeRelation : entry.compileRelation,
|
|
61
|
+
}]))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const impactKind = (entry) => entry?.runtimeDepth != null
|
|
65
|
+
? (entry.compileDepth != null ? `runtime + compile-time(d${entry.compileDepth})` : 'runtime')
|
|
66
|
+
: 'compile-time'
|
|
67
|
+
|
|
68
|
+
const fileOfNode = (id) => {
|
|
69
|
+
const value = String(id)
|
|
70
|
+
const hash = value.indexOf('#')
|
|
71
|
+
return hash < 0 ? value : value.slice(0, hash)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const partialResult = (text, reason, status = 'UNAVAILABLE') => toolResult(text, {
|
|
75
|
+
status,
|
|
76
|
+
verdict: 'HIGH',
|
|
77
|
+
reasons: [reason],
|
|
78
|
+
changes: [],
|
|
79
|
+
seeds: {ids: [], unmappedIds: []},
|
|
80
|
+
blastRadius: {impacted: 0, runtime: 0, compileTimeOnly: 0, nodes: []},
|
|
81
|
+
}, {completeness: {status: 'PARTIAL', reason}})
|
|
82
|
+
|
|
83
|
+
export function tChangeImpactV2(g, args = {}, ctx = {}) {
|
|
84
|
+
if (!ctx.repoRoot) return partialResult(
|
|
85
|
+
'HIGH — change impact unavailable\nNo repository root is active; no diff can be classified safely.',
|
|
86
|
+
'Repository root unavailable.'
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
const explicit = Array.isArray(args.files)
|
|
90
|
+
? [...new Set(args.files.map((file) => String(file).replace(/\\/g, '/').trim()).filter(Boolean))]
|
|
91
|
+
: null
|
|
92
|
+
if (explicit && !explicit.length) return partialResult(
|
|
93
|
+
'HIGH — invalid change set\nfiles was provided but empty; pass repo-relative paths, or omit it to diff local changes.',
|
|
94
|
+
'Explicit files list was empty.',
|
|
95
|
+
'INVALID'
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const graph = rawGraph(ctx)
|
|
99
|
+
const providedDiff = typeof args.diff === 'string'
|
|
100
|
+
let sourceLabel
|
|
101
|
+
let classification
|
|
102
|
+
if (providedDiff) {
|
|
103
|
+
sourceLabel = explicit ? `from provided unified diff + ${explicit.length} file hint(s)` : 'from provided unified diff'
|
|
104
|
+
classification = classifyChangeImpact({repoRoot: ctx.repoRoot, graph, diffText: args.diff, files: explicit || []})
|
|
105
|
+
} else if (explicit) {
|
|
106
|
+
sourceLabel = `for ${explicit.length} provided file(s) (no diff; conservative fallback)`
|
|
107
|
+
classification = classifyChangeImpact({repoRoot: ctx.repoRoot, graph, files: explicit})
|
|
108
|
+
} else {
|
|
109
|
+
const base = resolveImpactBase(ctx.repoRoot, args.base ? String(args.base).trim() : '')
|
|
110
|
+
if (!base) return partialResult(
|
|
111
|
+
`HIGH — change impact unavailable\nCould not resolve base ref${args.base ? ` "${args.base}"` : ''}; pass base explicitly (for example origin/main or HEAD~1).`,
|
|
112
|
+
'Base ref could not be resolved.'
|
|
113
|
+
)
|
|
114
|
+
const mergeBase = gitValue(ctx.repoRoot, ['merge-base', base, 'HEAD']) || base
|
|
115
|
+
const untracked = gitLines(ctx.repoRoot, ['ls-files', '--others', '--exclude-standard']) || []
|
|
116
|
+
sourceLabel = `vs ${base}${mergeBase !== base ? ` (merge-base ${mergeBase.slice(0, 12)})` : ''}`
|
|
117
|
+
classification = classifyChangeImpact({repoRoot: ctx.repoRoot, graph, base: mergeBase, files: untracked})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!classification.files.length) return toolResult(`LOW — change impact ${sourceLabel}\nNo textual or untracked changes were found.`, {
|
|
121
|
+
status: 'COMPLETE',
|
|
122
|
+
verdict: 'LOW',
|
|
123
|
+
reasons: classification.reasons,
|
|
124
|
+
changes: [],
|
|
125
|
+
seeds: {ids: [], unmappedIds: []},
|
|
126
|
+
blastRadius: {impacted: 0, runtime: 0, compileTimeOnly: 0, nodes: []},
|
|
127
|
+
}, {completeness: {status: 'COMPLETE'}})
|
|
128
|
+
|
|
129
|
+
const changed = [...new Set(classification.files
|
|
130
|
+
.flatMap((file) => [file.oldPath, file.newPath])
|
|
131
|
+
.filter((file) => file && file !== '(diff unavailable)'))]
|
|
132
|
+
const changedSet = new Set(changed)
|
|
133
|
+
const seedIds = classification.seedIds.filter((id) => g.byId.has(String(id)))
|
|
134
|
+
const seeds = new Set(seedIds)
|
|
135
|
+
const unmappedSeedIds = classification.seedIds.filter((id) => !g.byId.has(String(id)))
|
|
136
|
+
const unmappedFiles = classification.files
|
|
137
|
+
.filter((file) => ![file.oldPath, file.newPath].some((path) => path && g.byId.has(String(path))))
|
|
138
|
+
.map((file) => file.path)
|
|
139
|
+
|
|
140
|
+
const maxDepth = Math.max(1, Math.min(4, Number(args.depth) || 2))
|
|
141
|
+
const cap = Math.max(5, Math.min(120, Number(args.max_nodes) || 40))
|
|
142
|
+
const reached = seeds.size ? reverseReach(g, seeds, maxDepth) : new Map()
|
|
143
|
+
const impacted = [...reached.entries()]
|
|
144
|
+
.filter(([id]) => !seeds.has(id) && !changedSet.has(fileOfNode(id)))
|
|
145
|
+
.map(([id, entry]) => ({id, depth: entry.depth, entry, degree: degreeOf(g, id), file: fileOfNode(id)}))
|
|
146
|
+
.sort((left, right) => left.depth - right.depth
|
|
147
|
+
|| Number(left.entry.compileOnly) - Number(right.entry.compileOnly)
|
|
148
|
+
|| right.degree - left.degree
|
|
149
|
+
|| left.id.localeCompare(right.id))
|
|
150
|
+
|
|
151
|
+
// Measured coverage wins. Static reachability stays separately labelled and never becomes a
|
|
152
|
+
// synthetic coverage percentage.
|
|
153
|
+
const knownFiles = (graph.nodes || []).filter((node) => !String(node.id).includes('#') && node.source_file).map((node) => node.source_file)
|
|
154
|
+
const coverage = readCoverageForRepo(ctx.repoRoot, knownFiles)
|
|
155
|
+
const coverageOf = (file) => coverage.get(String(file).replace(/\\/g, '/'))?.pct ?? null
|
|
156
|
+
const hasCoverage = coverage.size > 0
|
|
157
|
+
const staticTests = computeStaticTestReachability(graph, {repoRoot: ctx.repoRoot})
|
|
158
|
+
const staticByFile = new Map(staticTests.reachable.map((entry) => [entry.file, entry.nearestTests[0]]))
|
|
159
|
+
const staticUnreachable = new Set(staticTests.unreachable)
|
|
160
|
+
const testEvidenceFor = (file) => {
|
|
161
|
+
const normalized = String(file).replace(/\\/g, '/')
|
|
162
|
+
const actualCoverage = coverageOf(normalized)
|
|
163
|
+
const nearest = staticByFile.get(normalized)
|
|
164
|
+
return {
|
|
165
|
+
actualCoverage,
|
|
166
|
+
staticTestReachability: nearest ? {
|
|
167
|
+
status: 'REACHABLE',
|
|
168
|
+
test: nearest.test,
|
|
169
|
+
distance: nearest.distance,
|
|
170
|
+
confidence: nearest.confidence,
|
|
171
|
+
path: nearest.path,
|
|
172
|
+
} : staticUnreachable.has(normalized) ? {status: 'NO_PATH'} : {status: 'NOT_INDEXED'},
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const evidenceLabel = (file) => {
|
|
176
|
+
const evidence = testEvidenceFor(file)
|
|
177
|
+
if (evidence.actualCoverage != null) return `cov ${Math.round(evidence.actualCoverage * 100)}%`
|
|
178
|
+
const reachability = evidence.staticTestReachability
|
|
179
|
+
if (reachability.status === 'REACHABLE') return `static-test d${reachability.distance} ${reachability.confidence}`
|
|
180
|
+
return reachability.status === 'NO_PATH' ? 'no static test path' : 'test evidence n/a'
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const shown = impacted.slice(0, cap)
|
|
184
|
+
const runtimeImpacted = impacted.filter((entry) => !entry.entry.compileOnly).length
|
|
185
|
+
const compileImpacted = impacted.length - runtimeImpacted
|
|
186
|
+
const coverageHotspots = hasCoverage
|
|
187
|
+
? shown.filter((node) => { const value = coverageOf(node.file); return value != null && value < 0.5 && node.degree >= 5 })
|
|
188
|
+
: []
|
|
189
|
+
const nodes = shown.map((node) => ({
|
|
190
|
+
id: node.id,
|
|
191
|
+
label: labelOf(g, node.id),
|
|
192
|
+
file: node.file,
|
|
193
|
+
depth: node.depth,
|
|
194
|
+
kind: impactKind(node.entry),
|
|
195
|
+
relation: node.entry.relation || 'rel',
|
|
196
|
+
degree: node.degree,
|
|
197
|
+
testEvidence: testEvidenceFor(node.file),
|
|
198
|
+
}))
|
|
199
|
+
const changeLines = classification.files.slice(0, 30).flatMap((file) => [
|
|
200
|
+
` [${file.classification}] ${file.path} — ${file.reason} (${file.seedIds.length} seed${file.seedIds.length === 1 ? '' : 's'})`,
|
|
201
|
+
...file.symbols.slice(0, 8).map((symbol) => ` ↳ [${symbol.classification}] ${symbol.label} [${symbol.id}]`),
|
|
202
|
+
])
|
|
203
|
+
const warnings = []
|
|
204
|
+
if (!classification.ok) warnings.push({code: 'CHANGE_CLASSIFICATION_PARTIAL', message: classification.reasons.join(' ')})
|
|
205
|
+
if (unmappedFiles.length) warnings.push({code: 'CHANGED_FILES_UNMAPPED', message: `${unmappedFiles.length} changed file(s) are absent from the current graph.`})
|
|
206
|
+
if (unmappedSeedIds.length) warnings.push({code: 'CHANGE_SEEDS_UNMAPPED', message: `${unmappedSeedIds.length} classified seed(s) are absent from the current graph.`})
|
|
207
|
+
|
|
208
|
+
const text = [
|
|
209
|
+
`${classification.verdict} — symbol-aware change impact ${sourceLabel}`,
|
|
210
|
+
...classification.reasons.map((reason) => `Reason: ${reason}`),
|
|
211
|
+
`Changed evidence: ${classification.files.length} file(s), ${classification.summary.symbols} mapped symbol(s) → ${seeds.size} exact graph seed(s).`,
|
|
212
|
+
...changeLines,
|
|
213
|
+
classification.files.length > 30 ? ` … +${classification.files.length - 30} more changed files` : null,
|
|
214
|
+
'',
|
|
215
|
+
impacted.length
|
|
216
|
+
? `Blast radius: ${impacted.length} impacted node(s) within ${maxDepth} reverse hop(s) (${runtimeImpacted} runtime, ${compileImpacted} compile-time-only), showing ${shown.length}:`
|
|
217
|
+
: seeds.size ? `Blast radius: nothing else depends on the exact changed symbols within ${maxDepth} hop(s).` : `Blast radius: 0 — additive/metadata-only changes do not inherit the containing file's legacy importers.`,
|
|
218
|
+
hasCoverage
|
|
219
|
+
? 'Test evidence: measured coverage is shown where mapped; static reachability remains separate in structured output.'
|
|
220
|
+
: `Test evidence: actualCoverage NOT_AVAILABLE; static paths only (${staticTests.reachableFiles}/${staticTests.productFiles} product files reachable from indexed tests).`,
|
|
221
|
+
...shown.map((node) => ` [d${node.depth} ${impactKind(node.entry)}] ${node.entry.relation || 'rel'} ${labelOf(g, node.id)} (deg ${node.degree}, ${evidenceLabel(node.file)}) [${node.id}]`),
|
|
222
|
+
coverageHotspots.length ? '' : null,
|
|
223
|
+
coverageHotspots.length ? 'Measured-coverage hotspots in the blast radius (<50%, deg ≥5):' : null,
|
|
224
|
+
...coverageHotspots.slice(0, 10).map((node) => ` ${labelOf(g, node.id)} (cov ${Math.round(coverageOf(node.file) * 100)}%, deg ${node.degree}) ${node.file}`),
|
|
225
|
+
'',
|
|
226
|
+
'Drill into an impacted node with get_dependents/read_source; coverage_map distinguishes measured coverage from static reachability.',
|
|
227
|
+
].filter((line) => line != null).join('\n')
|
|
228
|
+
|
|
229
|
+
const complete = classification.ok && !unmappedFiles.length && !unmappedSeedIds.length
|
|
230
|
+
return toolResult(text, {
|
|
231
|
+
status: complete ? 'COMPLETE' : 'PARTIAL',
|
|
232
|
+
verdict: classification.verdict,
|
|
233
|
+
reasons: classification.reasons,
|
|
234
|
+
source: {label: sourceLabel, kind: classification.source},
|
|
235
|
+
changes: classification.files,
|
|
236
|
+
classification: {summary: classification.summary, bounds: classification.bounds},
|
|
237
|
+
seeds: {ids: [...seeds].sort(), unmappedIds: unmappedSeedIds},
|
|
238
|
+
blastRadius: {
|
|
239
|
+
depth: maxDepth,
|
|
240
|
+
impacted: impacted.length,
|
|
241
|
+
runtime: runtimeImpacted,
|
|
242
|
+
compileTimeOnly: compileImpacted,
|
|
243
|
+
nodes,
|
|
244
|
+
},
|
|
245
|
+
testEvidence: {
|
|
246
|
+
actualCoverage: hasCoverage ? 'AVAILABLE' : 'NOT_AVAILABLE',
|
|
247
|
+
staticTestReachability: {
|
|
248
|
+
kind: staticTests.kind,
|
|
249
|
+
testFiles: staticTests.testFiles,
|
|
250
|
+
productFiles: staticTests.productFiles,
|
|
251
|
+
reachableFiles: staticTests.reachableFiles,
|
|
252
|
+
bounds: staticTests.bounds,
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
unmappedFiles,
|
|
256
|
+
}, {
|
|
257
|
+
warnings,
|
|
258
|
+
page: {shown: shown.length, total: impacted.length, capped: shown.length < impacted.length},
|
|
259
|
+
completeness: {status: complete ? 'COMPLETE' : 'PARTIAL'},
|
|
260
|
+
})
|
|
261
|
+
}
|
package/src/mcp/tools-impact.mjs
CHANGED
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
rawGraph, prevGraphPathFor, edgeEndpoint, diffGraphs, formatGraphDiff,
|
|
10
10
|
} from './graph-context.mjs'
|
|
11
11
|
import {readCoverageForRepo} from '../analysis/coverage-reports.js'
|
|
12
|
+
import {isStructuralRelation} from '../graph/relations.js'
|
|
13
|
+
import {tChangeImpactV2} from './tools-impact-change.mjs'
|
|
14
|
+
import {buildGraphAtGitRef} from '../analysis/git-ref-graph.js'
|
|
12
15
|
|
|
13
16
|
function reverseReach(g, seeds, maxDepth) {
|
|
14
17
|
const states = new Map([...seeds].map((id) => [String(id), {
|
|
@@ -19,9 +22,9 @@ function reverseReach(g, seeds, maxDepth) {
|
|
|
19
22
|
const current = frontier[cursor]
|
|
20
23
|
if (current.depth >= maxDepth) continue
|
|
21
24
|
for (const e of g.inn.get(current.id) || []) {
|
|
22
|
-
if (e.relation ===
|
|
25
|
+
if (isStructuralRelation(e.relation) || e.barrelProxy === true) continue
|
|
23
26
|
const id = String(e.id)
|
|
24
|
-
const compileOnly = current.compileOnly || e.typeOnly === true
|
|
27
|
+
const compileOnly = current.compileOnly || e.typeOnly === true || e.compileOnly === true
|
|
25
28
|
const depth = current.depth + 1
|
|
26
29
|
const entry = states.get(id) || {
|
|
27
30
|
runtimeDepth: null, runtimeRelation: null, compileDepth: null, compileRelation: null,
|
|
@@ -74,23 +77,34 @@ export function tGetDependents(g, {label, depth = 3, max_nodes = 40} = {}) {
|
|
|
74
77
|
const ranked = [...reached.entries()]
|
|
75
78
|
.filter(([nid]) => !seeds.has(nid))
|
|
76
79
|
.map(([nid, entry]) => ({id: nid, d: entry.depth, entry, deg: degreeOf(g, nid)}))
|
|
77
|
-
.sort((a, b) => a.d - b.d || b.deg - a.deg)
|
|
80
|
+
.sort((a, b) => a.d - b.d || Number(a.entry.compileOnly) - Number(b.entry.compileOnly) || b.deg - a.deg)
|
|
78
81
|
if (!ranked.length) return [note, `No dependents found for ${n.label ?? id} within depth ${maxDepth} — nothing in the graph calls, imports, or inherits it.`].filter(Boolean).join('\n')
|
|
79
82
|
const shown = ranked.slice(0, cap)
|
|
83
|
+
const runtimeCount = ranked.filter((entry) => !entry.entry.compileOnly).length
|
|
84
|
+
const compileCount = ranked.length - runtimeCount
|
|
80
85
|
return [
|
|
81
86
|
note,
|
|
82
|
-
`Dependents of ${n.label ?? id} (reverse calls/imports/inherits, depth ≤${maxDepth}): ${ranked.length} found, showing ${shown.length} by proximity + connectivity.`,
|
|
87
|
+
`Dependents of ${n.label ?? id} (reverse calls/imports/inherits, depth ≤${maxDepth}): ${ranked.length} found (${runtimeCount} runtime, ${compileCount} compile-time-only), showing ${shown.length} by proximity + connectivity.`,
|
|
83
88
|
containingFile ? `Includes importers of its containing file ${labelOf(g, containingFile)}.` : null,
|
|
84
89
|
...shown.map((r) => ` [d${r.d} ${impactKind(r.entry)}] ${r.entry.relation || 'rel'} ${labelOf(g, r.id)} (deg ${r.deg}) [${r.id}]`),
|
|
85
90
|
].filter(Boolean).join('\n')
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
// Re-query the last rebuild's before/after pair (graph.prev.json vs graph.json), optionally scoped.
|
|
89
|
-
export function tGraphDiff(g, args, ctx) {
|
|
90
|
-
const prevPath = prevGraphPathFor(ctx.graphPath)
|
|
94
|
+
export async function tGraphDiff(g, args, ctx) {
|
|
91
95
|
let prev
|
|
92
|
-
|
|
93
|
-
|
|
96
|
+
let baselineLabel = 'previous rebuild state'
|
|
97
|
+
if (args.base_ref) {
|
|
98
|
+
if (!ctx.repoRoot) return 'A Git-ref graph diff needs the active repository root.'
|
|
99
|
+
const built = await buildGraphAtGitRef(ctx.repoRoot, args.base_ref)
|
|
100
|
+
if (!built.ok) return `Could not build the baseline graph: ${built.error}`
|
|
101
|
+
prev = built.graph
|
|
102
|
+
baselineLabel = `${built.ref} (${built.commit.slice(0, 12)})`
|
|
103
|
+
} else {
|
|
104
|
+
const prevPath = prevGraphPathFor(ctx.graphPath)
|
|
105
|
+
try { prev = JSON.parse(readFileSync(prevPath, 'utf8')) } catch {
|
|
106
|
+
return `No previous graph state at ${prevPath} — pass base_ref (for example HEAD~1 or main), or run rebuild_graph to save one automatically.`
|
|
107
|
+
}
|
|
94
108
|
}
|
|
95
109
|
const current = rawGraph(ctx)
|
|
96
110
|
const filter = args.path ? String(args.path).replace(/\\/g, '/') : null
|
|
@@ -98,9 +112,11 @@ export function tGraphDiff(g, args, ctx) {
|
|
|
98
112
|
nodes: (graph.nodes || []).filter((n) => String(n.id).startsWith(filter)),
|
|
99
113
|
links: (graph.links || []).filter((l) => String(edgeEndpoint(l.source)).startsWith(filter) || String(edgeEndpoint(l.target)).startsWith(filter)),
|
|
100
114
|
edgeTypesV: graph.edgeTypesV || 0,
|
|
115
|
+
barrelResolutionV: graph.barrelResolutionV || 0,
|
|
116
|
+
extractorSchemaV: graph.extractorSchemaV || 0,
|
|
101
117
|
} : graph
|
|
102
118
|
return [
|
|
103
|
-
`Graph diff (
|
|
119
|
+
`Graph diff (${baselineLabel} → current)${filter ? `, scoped to ${filter}` : ''}:`,
|
|
104
120
|
formatGraphDiff(diffGraphs(scope(prev), scope(current)))
|
|
105
121
|
].join('\n')
|
|
106
122
|
}
|
|
@@ -127,6 +143,12 @@ function resolveImpactBase(repoRoot, requested) {
|
|
|
127
143
|
// can break, ranked by proximity + connectivity, with file-level test coverage attached so the
|
|
128
144
|
// untested part of the blast radius stands out. The pre-PR review, in one call.
|
|
129
145
|
export function tChangeImpact(g, args, ctx) {
|
|
146
|
+
return tChangeImpactV2(g, args, ctx)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Kept private during the v2 rollout so the surrounding get_dependents/graph_diff implementation is
|
|
150
|
+
// untouched; focused tests exercise the exported symbol-aware path above.
|
|
151
|
+
function tChangeImpactLegacy(g, args, ctx) {
|
|
130
152
|
if (!ctx.repoRoot) return 'change_impact needs the repo root (not provided to this server).'
|
|
131
153
|
// Explicit file list (e.g. a PR's changed files) skips the local git diff entirely — this is how
|
|
132
154
|
// a NOT-checked-out PR gets its impact assessed.
|
|
@@ -174,7 +196,7 @@ export function tChangeImpact(g, args, ctx) {
|
|
|
174
196
|
const impacted = [...reached.entries()]
|
|
175
197
|
.filter(([nid]) => !seeds.has(nid) && !changedSet.has(fileOfNode(nid)))
|
|
176
198
|
.map(([nid, entry]) => ({id: nid, d: entry.depth, entry, deg: degreeOf(g, nid), file: fileOfNode(nid)}))
|
|
177
|
-
.sort((a, b) => a.d - b.d || b.deg - a.deg)
|
|
199
|
+
.sort((a, b) => a.d - b.d || Number(a.entry.compileOnly) - Number(b.entry.compileOnly) || b.deg - a.deg)
|
|
178
200
|
|
|
179
201
|
// coverage overlay from EXISTING reports (fractions 0..1) — see coverage_map for details
|
|
180
202
|
const knownFiles = (rawGraph(ctx).nodes || []).filter((n) => !String(n.id).includes('#') && n.source_file).map((n) => n.source_file)
|
|
@@ -184,11 +206,13 @@ export function tChangeImpact(g, args, ctx) {
|
|
|
184
206
|
const hasCoverage = coverage.size > 0
|
|
185
207
|
|
|
186
208
|
const shown = impacted.slice(0, cap)
|
|
209
|
+
const runtimeImpacted = impacted.filter((entry) => !entry.entry.compileOnly).length
|
|
210
|
+
const compileImpacted = impacted.length - runtimeImpacted
|
|
187
211
|
const untestedHotspots = shown.filter((n) => { const c = covOf(n.file); return c != null && c < 0.5 && n.deg >= 5 })
|
|
188
212
|
return [
|
|
189
213
|
`Change impact ${sourceLabel}: ${changed.length} changed file(s) → ${seeds.size} graph seed(s) incl. their symbols${unmapped.length ? `; ${unmapped.length} file(s) not in the graph (new/non-code — rebuild_graph refreshes)` : ''}.`,
|
|
190
214
|
impacted.length
|
|
191
|
-
? `${impacted.length} impacted node(s) within ${maxDepth} reverse hop(s), showing ${shown.length}:`
|
|
215
|
+
? `${impacted.length} impacted node(s) within ${maxDepth} reverse hop(s) (${runtimeImpacted} runtime, ${compileImpacted} compile-time-only), showing ${shown.length}:`
|
|
192
216
|
: `Nothing else in the graph depends on the changed code within ${maxDepth} hop(s).`,
|
|
193
217
|
hasCoverage ? `Coverage: existing report mapped where available.` : `Coverage: unavailable (no supported report found); per-node coverage labels omitted.`,
|
|
194
218
|
...shown.map((n) => ` [d${n.d} ${impactKind(n.entry)}] ${n.entry.relation || 'rel'} ${labelOf(g, n.id)} (deg ${n.deg}${hasCoverage ? `, ${pctStr(covOf(n.file))}` : ''}) [${n.id}]`),
|
package/src/mcp-server.mjs
CHANGED
|
@@ -22,6 +22,10 @@ import process from 'node:process'
|
|
|
22
22
|
import {loadGraph} from './mcp/graph-context.mjs'
|
|
23
23
|
import {graphOutDirForRepo} from './graph/layout.js'
|
|
24
24
|
import {createRequire} from 'node:module'
|
|
25
|
+
import {createStalenessNoticeGate} from './mcp/staleness-notice.mjs'
|
|
26
|
+
import {normalizeToolResult} from './mcp/tool-result.mjs'
|
|
27
|
+
import {buildGraphForRepo} from './build-graph.js'
|
|
28
|
+
import {persistedFreshnessMatches, repositoryFreshnessProbe} from './graph/freshness-probe.js'
|
|
25
29
|
|
|
26
30
|
// version comes from package.json so serverInfo can never drift from the published package again
|
|
27
31
|
const PKG_VERSION = (() => { try { return createRequire(import.meta.url)('../package.json').version } catch { return '0.0.0' } })()
|
|
@@ -33,7 +37,7 @@ const log = (...a) => process.stderr.write(`[weavatrix] ${a.join(' ')}\n`)
|
|
|
33
37
|
// otherwise it is the graph.json path and the repo root follows it.
|
|
34
38
|
let GRAPH_PATH = process.argv[2]
|
|
35
39
|
let repoArg = process.argv[3]
|
|
36
|
-
// caps ABSENT (undefined) = offline defaults (
|
|
40
|
+
// caps ABSENT (undefined) = offline defaults (explicit local retargeting, no network).
|
|
37
41
|
// PRESENT (even the empty string) = explicit set — see catalog.loadHotApi.
|
|
38
42
|
let CAPS_ARG = process.argv[4]
|
|
39
43
|
try {
|
|
@@ -87,6 +91,53 @@ async function main() {
|
|
|
87
91
|
log(`capabilities: ${[...api.caps].join(',') || '(none)'} (${api.tools.length} tools)`)
|
|
88
92
|
|
|
89
93
|
let protocolVersion = DEFAULT_PROTOCOL
|
|
94
|
+
const staleNotices = createStalenessNoticeGate()
|
|
95
|
+
// Only build/retarget tools mutate the process-wide graph target. Serialize those mutations while
|
|
96
|
+
// ordinary tools use a per-call graph/context snapshot, so an explicitly retargetable registration
|
|
97
|
+
// cannot mix one repo's in-memory graph with another repo's source root under concurrent MCP calls.
|
|
98
|
+
let targetMutation = Promise.resolve()
|
|
99
|
+
const refreshProbeCache = new Map()
|
|
100
|
+
const configuredDebounce = process.env.WEAVATRIX_AUTO_REFRESH_DEBOUNCE_MS == null
|
|
101
|
+
? 2_000
|
|
102
|
+
: Number(process.env.WEAVATRIX_AUTO_REFRESH_DEBOUNCE_MS)
|
|
103
|
+
const refreshDebounceMs = Math.max(0, Math.min(5_000, Number.isFinite(configuredDebounce) ? configuredDebounce : 2_000))
|
|
104
|
+
const autoRefresh = async (callCtx, currentGraph) => {
|
|
105
|
+
if (!callCtx?.repoRoot || !callCtx?.graphPath) return {graph: null, refresh: null}
|
|
106
|
+
const probeKey = `${callCtx.graphPath}\0${currentGraph?.graphBuildMode || 'full'}`
|
|
107
|
+
const cachedProbe = refreshProbeCache.get(probeKey)
|
|
108
|
+
if (currentGraph && cachedProbe && Date.now() - cachedProbe.checkedAt < refreshDebounceMs) {
|
|
109
|
+
return {graph: currentGraph, refresh: {kind: 'none', revision: currentGraph.graphRevision || null, changedFiles: 0}}
|
|
110
|
+
}
|
|
111
|
+
const beforeProbe = repositoryFreshnessProbe(callCtx.repoRoot)
|
|
112
|
+
if (beforeProbe && currentGraph && (
|
|
113
|
+
cachedProbe?.probe === beforeProbe
|
|
114
|
+
|| persistedFreshnessMatches(currentGraph, beforeProbe, currentGraph.graphBuildMode || 'full')
|
|
115
|
+
)) {
|
|
116
|
+
refreshProbeCache.set(probeKey, {probe: beforeProbe, checkedAt: Date.now()})
|
|
117
|
+
return {graph: currentGraph, refresh: {kind: 'none', revision: currentGraph.graphRevision || null, changedFiles: 0}}
|
|
118
|
+
}
|
|
119
|
+
const result = await buildGraphForRepo(callCtx.repoRoot, {
|
|
120
|
+
mode: currentGraph?.graphBuildMode || 'full',
|
|
121
|
+
scope: '',
|
|
122
|
+
outDir: dirname(callCtx.graphPath),
|
|
123
|
+
})
|
|
124
|
+
if (!result.ok) throw new Error(result.error || 'automatic graph refresh failed')
|
|
125
|
+
api.resetStalenessCache()
|
|
126
|
+
const fresh = loadGraph(callCtx.graphPath)
|
|
127
|
+
const afterProbe = repositoryFreshnessProbe(callCtx.repoRoot)
|
|
128
|
+
if (afterProbe && afterProbe === beforeProbe) refreshProbeCache.set(probeKey, {probe: afterProbe, checkedAt: Date.now()})
|
|
129
|
+
else refreshProbeCache.delete(probeKey)
|
|
130
|
+
const update = result.refresh || {kind: 'full', changedFiles: [], reason: 'automatic-refresh'}
|
|
131
|
+
return {
|
|
132
|
+
graph: fresh,
|
|
133
|
+
refresh: {
|
|
134
|
+
kind: update.kind,
|
|
135
|
+
revision: update.revision || fresh.graphRevision || null,
|
|
136
|
+
changedFiles: Array.isArray(update.changedFiles) ? update.changedFiles.length : 0,
|
|
137
|
+
notice: update.kind === 'none' ? undefined : `Graph ${update.kind === 'incremental' ? 'incrementally refreshed' : 'rebuilt'} before this answer (${update.reason || 'repository changed'}).`,
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
}
|
|
90
141
|
const send = (msg) => process.stdout.write(JSON.stringify(msg) + '\n')
|
|
91
142
|
const reply = (id, result) => send({jsonrpc: '2.0', id, result})
|
|
92
143
|
const fail = (id, code, message) => send({jsonrpc: '2.0', id, error: {code, message}})
|
|
@@ -121,29 +172,61 @@ async function main() {
|
|
|
121
172
|
if (method === 'ping') return reply(id, {})
|
|
122
173
|
if (method === 'tools/list') {
|
|
123
174
|
await maybeHotReload()
|
|
124
|
-
return reply(id, {tools: api.tools.map(({name, description, inputSchema}) => ({name, description, inputSchema}))})
|
|
175
|
+
return reply(id, {tools: api.tools.map(({name, description, inputSchema, outputSchema}) => ({name, description, inputSchema, outputSchema}))})
|
|
125
176
|
}
|
|
126
177
|
if (method === 'tools/call') {
|
|
127
178
|
await maybeHotReload()
|
|
128
179
|
const tool = api.byName.get(params?.name)
|
|
129
180
|
if (!tool) return reply(id, {content: [{type: 'text', text: `Unknown tool: ${params?.name}`}], isError: true})
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
181
|
+
const refreshesGraph = tool.cap === 'graph' || tool.cap === 'health' || tool.refreshGraph === true
|
|
182
|
+
// Graph/health reads can establish a missing graph automatically when a repo root is known.
|
|
183
|
+
if (!graph && !refreshesGraph && tool.cap !== 'build' && tool.cap !== 'retarget') return reply(id, {content: [{type: 'text', text: `Graph unavailable: ${graphError}`}], isError: true})
|
|
184
|
+
const mutatesTarget = tool.cap === 'build' || tool.cap === 'retarget'
|
|
185
|
+
const graphSnapshot = graph
|
|
186
|
+
const callCtx = mutatesTarget ? ctx : {...ctx}
|
|
187
|
+
const execute = async () => {
|
|
188
|
+
try {
|
|
189
|
+
// A queued rebuild must see a preceding retarget's graph, while non-mutating calls stay
|
|
190
|
+
// pinned to the graph that was active when their request arrived.
|
|
191
|
+
let refresh = null
|
|
192
|
+
let callGraph = mutatesTarget ? graph : graphSnapshot
|
|
193
|
+
if (!mutatesTarget && refreshesGraph) {
|
|
194
|
+
const refreshed = await autoRefresh(callCtx, callGraph)
|
|
195
|
+
if (refreshed.graph) {
|
|
196
|
+
callGraph = refreshed.graph
|
|
197
|
+
refresh = refreshed.refresh
|
|
198
|
+
if (ctx.graphPath === callCtx.graphPath && ctx.repoRoot === callCtx.repoRoot) graph = callGraph
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const toolArgs = params?.arguments || {}
|
|
202
|
+
const value = await tool.run(callGraph, toolArgs, callCtx)
|
|
203
|
+
const warnings = []
|
|
204
|
+
if (callGraph && callGraph.edgeTypesV < 2 && (tool.cap === 'graph' || tool.cap === 'health')) {
|
|
205
|
+
warnings.push({code: 'EDGE_SCHEMA_OUTDATED', message: 'This saved graph predates compile-only edge metadata (edge schema v2); rebuild before acting on cycle, boundary, dependency, or blast-radius findings.'})
|
|
206
|
+
}
|
|
207
|
+
// Graph answers silently reflect a point-in-time build — surface staleness on every graph tool.
|
|
208
|
+
const staleLine = tool.cap === 'graph' ? api.stalenessLine(callCtx) : null
|
|
209
|
+
if (staleLine) warnings.push({code: 'GRAPH_STALE', message: staleLine})
|
|
210
|
+
const normalized = normalizeToolResult({
|
|
211
|
+
toolName: tool.name, value, args: toolArgs, ctx: callCtx, warnings, refresh,
|
|
212
|
+
freshness: staleLine ? 'stale' : 'fresh',
|
|
213
|
+
})
|
|
214
|
+
let text = normalized.text
|
|
215
|
+
if (toolArgs.output_format !== 'json') {
|
|
216
|
+
const schemaWarning = warnings.find((warning) => warning.code === 'EDGE_SCHEMA_OUTDATED')
|
|
217
|
+
if (schemaWarning) text += `\n\nWarning: ${schemaWarning.message}`
|
|
218
|
+
if (staleLine && staleNotices.shouldShow({line: staleLine, graphPath: callCtx.graphPath, force: tool.name === 'graph_stats'})) text += `\n\n${staleLine}`
|
|
219
|
+
}
|
|
220
|
+
return reply(id, {content: [{type: 'text', text}], structuredContent: normalized.structured})
|
|
221
|
+
} catch (e) {
|
|
222
|
+
log(`tool ${params?.name} threw: ${e.stack || e.message}`)
|
|
223
|
+
return reply(id, {content: [{type: 'text', text: `Tool error: ${e.message}`}], isError: true})
|
|
141
224
|
}
|
|
142
|
-
return reply(id, {content: [{type: 'text', text}]})
|
|
143
|
-
} catch (e) {
|
|
144
|
-
log(`tool ${params?.name} threw: ${e.stack || e.message}`)
|
|
145
|
-
return reply(id, {content: [{type: 'text', text: `Tool error: ${e.message}`}], isError: true})
|
|
146
225
|
}
|
|
226
|
+
if (!mutatesTarget && !refreshesGraph) return execute()
|
|
227
|
+
const pending = targetMutation.then(execute, execute)
|
|
228
|
+
targetMutation = pending.catch(() => {})
|
|
229
|
+
return pending
|
|
147
230
|
}
|
|
148
231
|
if (!isNotification) return fail(id, -32601, `Method not found: ${method}`)
|
|
149
232
|
}
|
package/src/mcp-source-tools.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { spawnSync } from 'node:child_process'
|
|
|
3
3
|
import { extname, join, relative } from 'node:path'
|
|
4
4
|
import { resolveRepoPath } from './repo-path.js'
|
|
5
5
|
import { childProcessEnv } from './child-env.js'
|
|
6
|
+
import { toolResult } from './mcp/tool-result.mjs'
|
|
6
7
|
|
|
7
8
|
const SEARCH_SKIP = new Set(['.git', 'node_modules', 'dist', 'build', 'out', '.next', 'coverage', 'vendor', '.venv', 'venv', 'env', 'target', '__pycache__', '.idea', '.vscode', '.cache', 'bin', 'obj', 'weavatrix-graphs'])
|
|
8
9
|
const BINARY_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.pdf', '.zip', '.gz', '.tar', '.exe', '.dll', '.so', '.dylib', '.woff', '.woff2', '.ttf', '.eot', '.mp4', '.mp3', '.wasm', '.class', '.jar', '.node', '.bin'])
|
|
@@ -13,7 +14,7 @@ const MAX_SOURCE_CONTEXT_LINES = 1000
|
|
|
13
14
|
function rgSearch(repoRoot, resolveRg, query, { isRegex, glob, maxResults }) {
|
|
14
15
|
const rg = resolveRg()
|
|
15
16
|
if (!rg) return null
|
|
16
|
-
const args = ['--line-number', '--no-heading', '--color', 'never', '--
|
|
17
|
+
const args = ['--line-number', '--no-heading', '--color', 'never', '--hidden', '-g', '!.git/**', '-m', '100', '-i']
|
|
17
18
|
if (!isRegex) args.push('--fixed-strings')
|
|
18
19
|
if (glob) args.push('-g', glob)
|
|
19
20
|
args.push('--', query, repoRoot)
|
|
@@ -95,11 +96,18 @@ export function searchCode({ repoRoot, resolveRg }, { query, is_regex = false, m
|
|
|
95
96
|
const engine = matches ? 'ripgrep' : 'node'
|
|
96
97
|
if (!matches) matches = nodeGrep(repoRoot, query, opts)
|
|
97
98
|
const what = is_regex ? `/${query}/i` : `"${query}"`
|
|
98
|
-
if (!matches.length) return
|
|
99
|
-
|
|
99
|
+
if (!matches.length) return toolResult(
|
|
100
|
+
`No matches for ${what}${glob ? ` in ${glob}` : ''}.`,
|
|
101
|
+
{query, isRegex: !!is_regex, glob: glob || null, engine, matches: []},
|
|
102
|
+
{completeness: {status: 'complete', reason: 'search completed with no matches'}},
|
|
103
|
+
)
|
|
104
|
+
const text = [
|
|
100
105
|
`${matches.length} match${matches.length === 1 ? '' : 'es'} for ${what}${glob ? ` (glob ${glob})` : ''} [${engine}]:`,
|
|
101
106
|
...matches.map((m) => ` ${m.file}:${m.line}: ${m.text}`),
|
|
102
107
|
].join('\n')
|
|
108
|
+
return toolResult(text, {query, isRegex: !!is_regex, glob: glob || null, engine, matches}, {
|
|
109
|
+
completeness: {status: matches.length >= max ? 'bounded' : 'complete', limit: max},
|
|
110
|
+
})
|
|
103
111
|
}
|
|
104
112
|
|
|
105
113
|
export function readSource({ repoRoot, resolveNode, isSymbol }, g, { label, path, start_line, before = 3, after = 40 } = {}) {
|