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.
Files changed (70) hide show
  1. package/README.md +138 -53
  2. package/SECURITY.md +25 -8
  3. package/package.json +2 -2
  4. package/skill/SKILL.md +92 -30
  5. package/src/analysis/architecture-contract.js +343 -0
  6. package/src/analysis/audit-debt.js +94 -0
  7. package/src/analysis/change-classification.js +509 -0
  8. package/src/analysis/cycle-route.js +14 -0
  9. package/src/analysis/dead-check.js +5 -3
  10. package/src/analysis/dead-code-review.js +245 -0
  11. package/src/analysis/dep-check-ecosystems.js +6 -0
  12. package/src/analysis/dep-check.js +40 -6
  13. package/src/analysis/dep-rules.js +12 -9
  14. package/src/analysis/duplicates.compute.js +47 -7
  15. package/src/analysis/endpoints-java.js +186 -0
  16. package/src/analysis/endpoints.js +8 -2
  17. package/src/analysis/findings.js +8 -1
  18. package/src/analysis/git-history.js +549 -0
  19. package/src/analysis/git-ref-graph.js +74 -0
  20. package/src/analysis/graph-analysis.aggregate.js +2 -2
  21. package/src/analysis/graph-analysis.edges.js +3 -0
  22. package/src/analysis/graph-analysis.summaries.js +1 -1
  23. package/src/analysis/http-contracts.js +581 -0
  24. package/src/analysis/internal-audit.collect.js +3 -2
  25. package/src/analysis/internal-audit.reach.js +52 -1
  26. package/src/analysis/internal-audit.run.js +58 -10
  27. package/src/analysis/java-source.js +36 -0
  28. package/src/analysis/package-reachability.js +39 -0
  29. package/src/analysis/static-test-reachability.js +133 -0
  30. package/src/build-graph.js +72 -13
  31. package/src/graph/build-worker.js +3 -4
  32. package/src/graph/builder/lang-js.js +71 -12
  33. package/src/graph/community.js +10 -0
  34. package/src/graph/file-lock.js +69 -0
  35. package/src/graph/freshness-probe.js +141 -0
  36. package/src/graph/graph-filter.js +28 -11
  37. package/src/graph/incremental-refresh.js +232 -0
  38. package/src/graph/internal-builder.barrels.js +169 -0
  39. package/src/graph/internal-builder.build.js +82 -11
  40. package/src/graph/internal-builder.langs.js +2 -1
  41. package/src/graph/layout.js +21 -5
  42. package/src/graph/repo-registry.js +124 -0
  43. package/src/mcp/catalog.mjs +62 -19
  44. package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
  45. package/src/mcp/evidence-snapshot.common.mjs +160 -0
  46. package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
  47. package/src/mcp/evidence-snapshot.health.mjs +95 -0
  48. package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
  49. package/src/mcp/evidence-snapshot.mjs +50 -0
  50. package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
  51. package/src/mcp/evidence-snapshot.structure.mjs +81 -0
  52. package/src/mcp/graph-context.mjs +100 -16
  53. package/src/mcp/graph-diff.mjs +74 -20
  54. package/src/mcp/staleness-notice.mjs +20 -0
  55. package/src/mcp/sync-evidence.mjs +418 -0
  56. package/src/mcp/sync-payload.mjs +164 -0
  57. package/src/mcp/tool-result.mjs +51 -0
  58. package/src/mcp/tools-actions.mjs +111 -30
  59. package/src/mcp/tools-architecture.mjs +144 -0
  60. package/src/mcp/tools-company.mjs +273 -0
  61. package/src/mcp/tools-graph.mjs +25 -14
  62. package/src/mcp/tools-health.mjs +336 -14
  63. package/src/mcp/tools-history.mjs +22 -0
  64. package/src/mcp/tools-impact-change.mjs +261 -0
  65. package/src/mcp/tools-impact.mjs +25 -6
  66. package/src/mcp-server.mjs +100 -17
  67. package/src/mcp-source-tools.mjs +11 -3
  68. package/src/path-classification.js +201 -0
  69. package/src/path-ignore.js +69 -0
  70. 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
+ }
@@ -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
- try { prev = JSON.parse(readFileSync(prevPath, 'utf8')) } catch {
96
- return `No previous graph state at ${prevPath} — rebuild_graph saves one automatically (a single prior state is kept).`
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 (previous rebuild state → current)${filter ? `, scoped to ${filter}` : ''}:`,
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.
@@ -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 (including explicit-call repo retargeting, no network).
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
- // action tools can establish/retarget a graph; read tools need one already loaded
131
- if (!graph && tool.cap !== 'build' && tool.cap !== 'retarget') return reply(id, {content: [{type: 'text', text: `Graph unavailable: ${graphError}`}], isError: true})
132
- try {
133
- let text = String(await tool.run(graph, params?.arguments || {}, ctx))
134
- if (graph && graph.edgeTypesV < 2 && (tool.cap === 'graph' || tool.cap === 'health')) {
135
- text += '\n\nWarning: this saved graph predates compile-only edge metadata (edge schema v2), so Rust module/use dependencies may be absent or misclassified. Call rebuild_graph once before acting on cycle, boundary, dependency, or blast-radius findings.'
136
- }
137
- // Graph answers silently reflect a point-in-time build — surface staleness on every graph tool.
138
- if (tool.cap === 'graph') {
139
- const warn = api.stalenessLine(ctx)
140
- if (warn) text += `\n\n${warn}`
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
  }
@@ -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', '--max-columns', '400', '-m', '30', '-i']
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 `No matches for ${what}${glob ? ` in ${glob}` : ''}.`
99
- return [
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 } = {}) {