weavatrix 0.1.4 → 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 +138 -53
- package/SECURITY.md +25 -8
- package/package.json +2 -2
- package/skill/SKILL.md +92 -30
- 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 +5 -3
- package/src/analysis/dead-code-review.js +245 -0
- package/src/analysis/dep-check-ecosystems.js +6 -0
- package/src/analysis/dep-check.js +40 -6
- package/src/analysis/dep-rules.js +12 -9
- package/src/analysis/duplicates.compute.js +47 -7
- package/src/analysis/endpoints-java.js +186 -0
- package/src/analysis/endpoints.js +8 -2
- 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 +2 -2
- package/src/analysis/graph-analysis.edges.js +3 -0
- package/src/analysis/graph-analysis.summaries.js +1 -1
- package/src/analysis/http-contracts.js +581 -0
- package/src/analysis/internal-audit.collect.js +3 -2
- package/src/analysis/internal-audit.reach.js +52 -1
- package/src/analysis/internal-audit.run.js +58 -10
- 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-js.js +71 -12
- package/src/graph/community.js +10 -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 +82 -11
- package/src/graph/internal-builder.langs.js +2 -1
- package/src/graph/layout.js +21 -5
- package/src/graph/repo-registry.js +124 -0
- package/src/mcp/catalog.mjs +62 -19
- 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 +100 -16
- package/src/mcp/graph-diff.mjs +74 -20
- package/src/mcp/staleness-notice.mjs +20 -0
- package/src/mcp/sync-evidence.mjs +418 -0
- package/src/mcp/sync-payload.mjs +164 -0
- package/src/mcp/tool-result.mjs +51 -0
- package/src/mcp/tools-actions.mjs +111 -30
- package/src/mcp/tools-architecture.mjs +144 -0
- package/src/mcp/tools-company.mjs +273 -0
- package/src/mcp/tools-graph.mjs +25 -14
- package/src/mcp/tools-health.mjs +336 -14
- package/src/mcp/tools-history.mjs +22 -0
- package/src/mcp/tools-impact-change.mjs +261 -0
- package/src/mcp/tools-impact.mjs +25 -6
- 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
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
} from './graph-context.mjs'
|
|
11
11
|
import {readCoverageForRepo} from '../analysis/coverage-reports.js'
|
|
12
12
|
import {isStructuralRelation} from '../graph/relations.js'
|
|
13
|
+
import {tChangeImpactV2} from './tools-impact-change.mjs'
|
|
14
|
+
import {buildGraphAtGitRef} from '../analysis/git-ref-graph.js'
|
|
13
15
|
|
|
14
16
|
function reverseReach(g, seeds, maxDepth) {
|
|
15
17
|
const states = new Map([...seeds].map((id) => [String(id), {
|
|
@@ -20,7 +22,7 @@ function reverseReach(g, seeds, maxDepth) {
|
|
|
20
22
|
const current = frontier[cursor]
|
|
21
23
|
if (current.depth >= maxDepth) continue
|
|
22
24
|
for (const e of g.inn.get(current.id) || []) {
|
|
23
|
-
if (isStructuralRelation(e.relation)) continue
|
|
25
|
+
if (isStructuralRelation(e.relation) || e.barrelProxy === true) continue
|
|
24
26
|
const id = String(e.id)
|
|
25
27
|
const compileOnly = current.compileOnly || e.typeOnly === true || e.compileOnly === true
|
|
26
28
|
const depth = current.depth + 1
|
|
@@ -89,11 +91,20 @@ export function tGetDependents(g, {label, depth = 3, max_nodes = 40} = {}) {
|
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
// Re-query the last rebuild's before/after pair (graph.prev.json vs graph.json), optionally scoped.
|
|
92
|
-
export function tGraphDiff(g, args, ctx) {
|
|
93
|
-
const prevPath = prevGraphPathFor(ctx.graphPath)
|
|
94
|
+
export async function tGraphDiff(g, args, ctx) {
|
|
94
95
|
let prev
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
}
|
|
97
108
|
}
|
|
98
109
|
const current = rawGraph(ctx)
|
|
99
110
|
const filter = args.path ? String(args.path).replace(/\\/g, '/') : null
|
|
@@ -101,9 +112,11 @@ export function tGraphDiff(g, args, ctx) {
|
|
|
101
112
|
nodes: (graph.nodes || []).filter((n) => String(n.id).startsWith(filter)),
|
|
102
113
|
links: (graph.links || []).filter((l) => String(edgeEndpoint(l.source)).startsWith(filter) || String(edgeEndpoint(l.target)).startsWith(filter)),
|
|
103
114
|
edgeTypesV: graph.edgeTypesV || 0,
|
|
115
|
+
barrelResolutionV: graph.barrelResolutionV || 0,
|
|
116
|
+
extractorSchemaV: graph.extractorSchemaV || 0,
|
|
104
117
|
} : graph
|
|
105
118
|
return [
|
|
106
|
-
`Graph diff (
|
|
119
|
+
`Graph diff (${baselineLabel} → current)${filter ? `, scoped to ${filter}` : ''}:`,
|
|
107
120
|
formatGraphDiff(diffGraphs(scope(prev), scope(current)))
|
|
108
121
|
].join('\n')
|
|
109
122
|
}
|
|
@@ -130,6 +143,12 @@ function resolveImpactBase(repoRoot, requested) {
|
|
|
130
143
|
// can break, ranked by proximity + connectivity, with file-level test coverage attached so the
|
|
131
144
|
// untested part of the blast radius stands out. The pre-PR review, in one call.
|
|
132
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) {
|
|
133
152
|
if (!ctx.repoRoot) return 'change_impact needs the repo root (not provided to this server).'
|
|
134
153
|
// Explicit file list (e.g. a PR's changed files) skips the local git diff entirely — this is how
|
|
135
154
|
// a NOT-checked-out PR gets its impact assessed.
|
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 } = {}) {
|