weavatrix 0.2.3 → 0.2.5
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 +91 -20
- package/SECURITY.md +18 -0
- package/package.json +3 -1
- package/skill/SKILL.md +49 -13
- package/src/analysis/dead-check.js +48 -2
- package/src/analysis/dead-code-review.js +22 -8
- package/src/analysis/duplicates.compute.js +11 -6
- package/src/analysis/git-ref-graph.js +10 -2
- package/src/analysis/hot-path-review.js +228 -0
- package/src/analysis/internal-audit.run.js +36 -0
- package/src/analysis/source-complexity.report.js +5 -0
- package/src/analysis/source-complexity.walk.js +64 -10
- package/src/build-graph.js +49 -10
- package/src/graph/build-worker.js +21 -1
- package/src/graph/builder/lang-java.js +1 -0
- package/src/graph/builder/lang-js.js +24 -0
- package/src/graph/builder/lang-python.js +161 -4
- package/src/graph/freshness-probe.js +3 -3
- package/src/graph/incremental-refresh.js +1 -1
- package/src/graph/internal-builder.barrels.js +33 -4
- package/src/graph/internal-builder.build.js +52 -8
- package/src/mcp/catalog.mjs +14 -11
- package/src/mcp/graph-context.mjs +143 -14
- package/src/mcp/sync-payload.mjs +2 -1
- package/src/mcp/tools-actions.mjs +59 -20
- package/src/mcp/tools-company.mjs +11 -3
- package/src/mcp/tools-graph.mjs +12 -6
- package/src/mcp/tools-health.mjs +112 -11
- package/src/mcp/tools-impact.mjs +24 -6
- package/src/mcp/tools-source.mjs +237 -0
- package/src/mcp-server.mjs +99 -11
- package/src/path-classification.js +2 -2
- package/src/precision/lsp-client.js +682 -0
- package/src/precision/lsp-overlay.js +872 -0
- package/src/precision/symbol-query.js +137 -0
- package/src/precision/typescript-lsp-provider.js +682 -0
package/src/mcp-server.mjs
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
// Spawned by Claude Code / Codex as a plain Node child (node mcp-server.mjs <graph.json> <repoRoot>).
|
|
8
8
|
// Speaks newline-delimited JSON-RPC 2.0 over stdio (the MCP stdio transport).
|
|
9
9
|
//
|
|
10
|
-
// .mjs on purpose: guarantees ESM parsing regardless of the nearest package.json.
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// .mjs on purpose: guarantees ESM parsing regardless of the nearest package.json. Runtime analyzers
|
|
11
|
+
// resolve only dependencies bundled with Weavatrix: web-tree-sitter grammars for graph builds and the
|
|
12
|
+
// pinned TypeScript language server for bounded JS/TS semantic evidence. Repository packages/scripts
|
|
13
|
+
// are never executed to provide either analyzer.
|
|
13
14
|
//
|
|
14
15
|
// STDOUT is the protocol channel — nothing but JSON-RPC frames may be written there. All diagnostics
|
|
15
16
|
// go to stderr. Two argv forms:
|
|
@@ -24,8 +25,10 @@ import {graphOutDirForRepo} from './graph/layout.js'
|
|
|
24
25
|
import {createRequire} from 'node:module'
|
|
25
26
|
import {createStalenessNoticeGate} from './mcp/staleness-notice.mjs'
|
|
26
27
|
import {normalizeToolResult} from './mcp/tool-result.mjs'
|
|
27
|
-
import {buildGraphForRepo} from './build-graph.js'
|
|
28
|
+
import {buildGraphForRepo, defaultPrecisionMode} from './build-graph.js'
|
|
28
29
|
import {persistedFreshnessMatches, repositoryFreshnessProbe} from './graph/freshness-probe.js'
|
|
30
|
+
import {activeLspClientCount, beginLspClientShutdown, shutdownActiveLspClients} from './precision/lsp-client.js'
|
|
31
|
+
import {PRECISION_OVERLAY_V, precisionSemanticInputsMatch, readPrecisionOverlay} from './precision/lsp-overlay.js'
|
|
29
32
|
|
|
30
33
|
// version comes from package.json so serverInfo can never drift from the published package again
|
|
31
34
|
const PKG_VERSION = (() => { try { return createRequire(import.meta.url)('../package.json').version } catch { return '0.0.0' } })()
|
|
@@ -33,6 +36,16 @@ const SERVER_INFO = {name: 'weavatrix', version: PKG_VERSION}
|
|
|
33
36
|
const DEFAULT_PROTOCOL = '2024-11-05'
|
|
34
37
|
const log = (...a) => process.stderr.write(`[weavatrix] ${a.join(' ')}\n`)
|
|
35
38
|
|
|
39
|
+
async function settleWithin(promise, timeoutMs) {
|
|
40
|
+
let timer
|
|
41
|
+
const settled = await Promise.race([
|
|
42
|
+
Promise.resolve(promise).then(() => true, () => true),
|
|
43
|
+
new Promise((resolveSettled) => { timer = setTimeout(() => resolveSettled(false), timeoutMs) }),
|
|
44
|
+
])
|
|
45
|
+
if (timer) clearTimeout(timer)
|
|
46
|
+
return settled
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
// argv[2] is a repo DIRECTORY in the npx form — derive the graph location from the standard layout;
|
|
37
50
|
// otherwise it is the graph.json path and the repo root follows it.
|
|
38
51
|
let GRAPH_PATH = process.argv[2]
|
|
@@ -77,7 +90,7 @@ async function main() {
|
|
|
77
90
|
// ctx owns the CURRENT target: rebuild_graph reloads it, open_repo retargets graphPath/repoRoot
|
|
78
91
|
// at runtime. loadInto always reads ctx.graphPath so both paths share one loader.
|
|
79
92
|
const ctx = {graphPath: GRAPH_PATH, repoRoot: REPO_ROOT, reload: null}
|
|
80
|
-
const loadInto = () => { graph = loadGraph(ctx.graphPath); graphError = null; return graph }
|
|
93
|
+
const loadInto = () => { graph = loadGraph(ctx.graphPath, {repoRoot: ctx.repoRoot}); graphError = null; return graph }
|
|
81
94
|
ctx.reload = () => { api.resetStalenessCache(); try { return loadInto() } catch (e) { graphError = e.message; return null } }
|
|
82
95
|
try {
|
|
83
96
|
if (!ctx.graphPath) throw new Error('no graph.json path given (argv[2])')
|
|
@@ -96,6 +109,8 @@ async function main() {
|
|
|
96
109
|
// ordinary tools use a per-call graph/context snapshot, so an explicitly retargetable registration
|
|
97
110
|
// cannot mix one repo's in-memory graph with another repo's source root under concurrent MCP calls.
|
|
98
111
|
let targetMutation = Promise.resolve()
|
|
112
|
+
let shuttingDown = false
|
|
113
|
+
let shutdownPromise = null
|
|
99
114
|
const refreshProbeCache = new Map()
|
|
100
115
|
const configuredDebounce = process.env.WEAVATRIX_AUTO_REFRESH_DEBOUNCE_MS == null
|
|
101
116
|
? 2_000
|
|
@@ -103,13 +118,31 @@ async function main() {
|
|
|
103
118
|
const refreshDebounceMs = Math.max(0, Math.min(5_000, Number.isFinite(configuredDebounce) ? configuredDebounce : 2_000))
|
|
104
119
|
const autoRefresh = async (callCtx, currentGraph) => {
|
|
105
120
|
if (!callCtx?.repoRoot || !callCtx?.graphPath) return {graph: null, refresh: null}
|
|
106
|
-
const
|
|
121
|
+
const activePrecision = currentGraph?.graphPrecisionMode || defaultPrecisionMode()
|
|
122
|
+
const probeKey = `${callCtx.graphPath}\0${currentGraph?.graphBuildMode || 'full'}\0${activePrecision}`
|
|
123
|
+
// Semantic inputs include ignored configs and configured project files that Git status does
|
|
124
|
+
// not see. Check their bounded fingerprint before the ordinary source freshness debounce;
|
|
125
|
+
// exact evidence must have no stale window, even on back-to-back tool calls.
|
|
126
|
+
let semanticInputsChanged = false
|
|
127
|
+
if (activePrecision === 'lsp' && currentGraph) {
|
|
128
|
+
try {
|
|
129
|
+
const overlay = readPrecisionOverlay(callCtx.graphPath, currentGraph)
|
|
130
|
+
semanticInputsChanged = typeof overlay?.semanticInputFingerprint === 'string'
|
|
131
|
+
&& !precisionSemanticInputsMatch(overlay, callCtx.repoRoot, currentGraph)
|
|
132
|
+
} catch {
|
|
133
|
+
semanticInputsChanged = true
|
|
134
|
+
}
|
|
135
|
+
}
|
|
107
136
|
const cachedProbe = refreshProbeCache.get(probeKey)
|
|
108
|
-
if (currentGraph && cachedProbe && Date.now() - cachedProbe.checkedAt < refreshDebounceMs) {
|
|
137
|
+
if (!semanticInputsChanged && currentGraph && cachedProbe && Date.now() - cachedProbe.checkedAt < refreshDebounceMs) {
|
|
109
138
|
return {graph: currentGraph, refresh: {kind: 'none', revision: currentGraph.graphRevision || null, changedFiles: 0}}
|
|
110
139
|
}
|
|
111
140
|
const beforeProbe = repositoryFreshnessProbe(callCtx.repoRoot)
|
|
112
|
-
|
|
141
|
+
const precisionMissing = activePrecision === 'lsp' && (
|
|
142
|
+
Number(currentGraph?.precisionOverlayV) !== PRECISION_OVERLAY_V
|
|
143
|
+
|| semanticInputsChanged
|
|
144
|
+
)
|
|
145
|
+
if (!precisionMissing && beforeProbe && currentGraph && (
|
|
113
146
|
cachedProbe?.probe === beforeProbe
|
|
114
147
|
|| persistedFreshnessMatches(currentGraph, beforeProbe, currentGraph.graphBuildMode || 'full')
|
|
115
148
|
)) {
|
|
@@ -118,12 +151,13 @@ async function main() {
|
|
|
118
151
|
}
|
|
119
152
|
const result = await buildGraphForRepo(callCtx.repoRoot, {
|
|
120
153
|
mode: currentGraph?.graphBuildMode || 'full',
|
|
154
|
+
precision: activePrecision,
|
|
121
155
|
scope: '',
|
|
122
156
|
outDir: dirname(callCtx.graphPath),
|
|
123
157
|
})
|
|
124
158
|
if (!result.ok) throw new Error(result.error || 'automatic graph refresh failed')
|
|
125
159
|
api.resetStalenessCache()
|
|
126
|
-
const fresh = loadGraph(callCtx.graphPath)
|
|
160
|
+
const fresh = loadGraph(callCtx.graphPath, {repoRoot: callCtx.repoRoot})
|
|
127
161
|
const afterProbe = repositoryFreshnessProbe(callCtx.repoRoot)
|
|
128
162
|
if (afterProbe && afterProbe === beforeProbe) refreshProbeCache.set(probeKey, {probe: afterProbe, checkedAt: Date.now()})
|
|
129
163
|
else refreshProbeCache.delete(probeKey)
|
|
@@ -138,10 +172,53 @@ async function main() {
|
|
|
138
172
|
},
|
|
139
173
|
}
|
|
140
174
|
}
|
|
141
|
-
const send = (msg) =>
|
|
175
|
+
const send = (msg) => {
|
|
176
|
+
// A request that was already running when the client disconnected may complete during the
|
|
177
|
+
// bounded drain. Do not write a late reply into a closed protocol pipe.
|
|
178
|
+
if (shuttingDown || process.stdout.destroyed || !process.stdout.writable) return false
|
|
179
|
+
try {
|
|
180
|
+
return process.stdout.write(JSON.stringify(msg) + '\n')
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error?.code === 'EPIPE' || error?.code === 'ERR_STREAM_DESTROYED') return false
|
|
183
|
+
throw error
|
|
184
|
+
}
|
|
185
|
+
}
|
|
142
186
|
const reply = (id, result) => send({jsonrpc: '2.0', id, result})
|
|
143
187
|
const fail = (id, code, message) => send({jsonrpc: '2.0', id, error: {code, message}})
|
|
144
188
|
|
|
189
|
+
const requestShutdown = (reason, exitCode = 0) => {
|
|
190
|
+
if (shutdownPromise) return shutdownPromise
|
|
191
|
+
shuttingDown = true
|
|
192
|
+
// This is synchronous and deliberately precedes every await: a graph build that reaches its
|
|
193
|
+
// precision phase during the drain must not create a fresh TLS/tsserver process tree.
|
|
194
|
+
beginLspClientShutdown()
|
|
195
|
+
process.stdin.pause()
|
|
196
|
+
const activeAtStart = activeLspClientCount()
|
|
197
|
+
log(`shutdown requested (${reason}); draining graph work and ${activeAtStart} semantic provider(s)`)
|
|
198
|
+
shutdownPromise = (async () => {
|
|
199
|
+
// Give an ordinary auto-refresh a chance to commit and close its provider itself. A wedged
|
|
200
|
+
// parse/query is bounded; active semantic children are then closed or tree-killed, after
|
|
201
|
+
// which the mutation gets a final window to observe that cancellation and release locks.
|
|
202
|
+
const initiallyDrained = await settleWithin(targetMutation, 2_500)
|
|
203
|
+
const semantic = await shutdownActiveLspClients({timeoutMs: 3_000})
|
|
204
|
+
const fullyDrained = initiallyDrained || await settleWithin(targetMutation, 1_500)
|
|
205
|
+
log(`shutdown cleanup: graph=${fullyDrained ? 'drained' : 'bounded-timeout'}, semantic=${semantic.requested} requested/${semantic.remaining} remaining${semantic.timedOut ? ' (forced)' : ''}`)
|
|
206
|
+
})().catch((error) => {
|
|
207
|
+
log(`shutdown cleanup failed: ${error.stack || error.message}`)
|
|
208
|
+
}).finally(() => {
|
|
209
|
+
process.exit(exitCode)
|
|
210
|
+
})
|
|
211
|
+
return shutdownPromise
|
|
212
|
+
}
|
|
213
|
+
process.stdout.on('error', (error) => {
|
|
214
|
+
if (error?.code === 'EPIPE' || error?.code === 'ERR_STREAM_DESTROYED') {
|
|
215
|
+
void requestShutdown('stdout disconnected')
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
log(`stdout error: ${error.stack || error.message}`)
|
|
219
|
+
void requestShutdown('stdout error', 1)
|
|
220
|
+
})
|
|
221
|
+
|
|
145
222
|
let loadedVersion = hotVersion(catalog.HOT_FILES)
|
|
146
223
|
let lastFailedVersion = 0
|
|
147
224
|
const maybeHotReload = async () => {
|
|
@@ -164,6 +241,10 @@ async function main() {
|
|
|
164
241
|
const handle = async (msg) => {
|
|
165
242
|
const {id, method, params} = msg
|
|
166
243
|
const isNotification = id === undefined || id === null
|
|
244
|
+
if (shuttingDown) {
|
|
245
|
+
if (!isNotification) fail(id, -32000, 'MCP server is shutting down')
|
|
246
|
+
return
|
|
247
|
+
}
|
|
167
248
|
if (method === 'initialize') {
|
|
168
249
|
if (params?.protocolVersion) protocolVersion = String(params.protocolVersion)
|
|
169
250
|
return reply(id, {protocolVersion, capabilities: {tools: {listChanged: true}}, serverInfo: SERVER_INFO})
|
|
@@ -172,10 +253,14 @@ async function main() {
|
|
|
172
253
|
if (method === 'ping') return reply(id, {})
|
|
173
254
|
if (method === 'tools/list') {
|
|
174
255
|
await maybeHotReload()
|
|
256
|
+
if (shuttingDown) return
|
|
175
257
|
return reply(id, {tools: api.tools.map(({name, description, inputSchema, outputSchema}) => ({name, description, inputSchema, outputSchema}))})
|
|
176
258
|
}
|
|
177
259
|
if (method === 'tools/call') {
|
|
178
260
|
await maybeHotReload()
|
|
261
|
+
// EOF can arrive while the hot-reload import is pending. Do not enqueue a graph mutation
|
|
262
|
+
// after requestShutdown captured the mutation chain it is responsible for draining.
|
|
263
|
+
if (shuttingDown) return
|
|
179
264
|
const tool = api.byName.get(params?.name)
|
|
180
265
|
if (!tool) return reply(id, {content: [{type: 'text', text: `Unknown tool: ${params?.name}`}], isError: true})
|
|
181
266
|
const refreshesGraph = tool.cap === 'graph' || tool.cap === 'health' || tool.refreshGraph === true
|
|
@@ -236,6 +321,7 @@ async function main() {
|
|
|
236
321
|
let buf = ''
|
|
237
322
|
process.stdin.setEncoding('utf8')
|
|
238
323
|
process.stdin.on('data', (chunk) => {
|
|
324
|
+
if (shuttingDown) return
|
|
239
325
|
buf += chunk
|
|
240
326
|
let nl
|
|
241
327
|
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
@@ -256,7 +342,9 @@ async function main() {
|
|
|
256
342
|
})
|
|
257
343
|
}
|
|
258
344
|
})
|
|
259
|
-
process.stdin.on('end', () =>
|
|
345
|
+
process.stdin.on('end', () => { void requestShutdown('stdin EOF') })
|
|
346
|
+
process.on('SIGTERM', () => { void requestShutdown('SIGTERM') })
|
|
347
|
+
process.on('SIGINT', () => { void requestShutdown('SIGINT') })
|
|
260
348
|
log('ready')
|
|
261
349
|
}
|
|
262
350
|
|
|
@@ -80,8 +80,8 @@ const DEFAULT_RULES = [
|
|
|
80
80
|
},
|
|
81
81
|
{
|
|
82
82
|
category: "temp",
|
|
83
|
-
pattern: "__temp working/generated root",
|
|
84
|
-
regex: /(^|\/)__temp(\/|$)/i,
|
|
83
|
+
pattern: "__temp or .tmp-* working/generated root",
|
|
84
|
+
regex: /(^|\/)(?:__temp|\.tmp(?:[-_][^/]*)?)(\/|$)/i,
|
|
85
85
|
},
|
|
86
86
|
];
|
|
87
87
|
|