weavatrix 0.2.12 → 0.2.14
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 +90 -21
- package/SECURITY.md +2 -2
- package/docs/releases/v0.2.14.md +93 -0
- package/package.json +2 -2
- package/skill/SKILL.md +108 -43
- package/src/analysis/allowed-test-runner.js +20 -9
- package/src/analysis/architecture/contract-graph.js +119 -0
- package/src/analysis/architecture/contract-schema.js +110 -0
- package/src/analysis/architecture/contract-storage.js +35 -0
- package/src/analysis/architecture/contract-verification.js +168 -0
- package/src/analysis/architecture-contract.js +16 -343
- package/src/analysis/change-classification/diff-parser.js +103 -0
- package/src/analysis/change-classification/options.js +40 -0
- package/src/analysis/change-classification/symbol-classifier.js +177 -0
- package/src/analysis/change-classification.js +120 -519
- package/src/analysis/dead-code-review/policy.js +23 -0
- package/src/analysis/dead-code-review.js +9 -19
- package/src/analysis/dep-check.js +10 -57
- package/src/analysis/dep-rules.js +10 -303
- package/src/analysis/dependency/scoped-dependencies.js +40 -0
- package/src/analysis/endpoints/common.js +62 -0
- package/src/analysis/endpoints/extract.js +107 -0
- package/src/analysis/endpoints/inventory.js +84 -0
- package/src/analysis/endpoints/mounts.js +129 -0
- package/src/analysis/endpoints-java.js +80 -3
- package/src/analysis/endpoints.js +3 -448
- package/src/analysis/git-history/analytics.js +177 -0
- package/src/analysis/git-history/collector.js +109 -0
- package/src/analysis/git-history/options.js +68 -0
- package/src/analysis/git-history.js +128 -577
- package/src/analysis/health-capabilities.js +82 -0
- package/src/analysis/http-contracts/analysis.js +169 -0
- package/src/analysis/http-contracts/client-call-detection.js +81 -0
- package/src/analysis/http-contracts/client-call-parser.js +255 -0
- package/src/analysis/http-contracts/graph-context.js +143 -0
- package/src/analysis/http-contracts/matching.js +61 -0
- package/src/analysis/http-contracts/shared.js +86 -0
- package/src/analysis/http-contracts.js +6 -822
- package/src/analysis/internal-audit/dependency-health.js +111 -0
- package/src/analysis/internal-audit/python-manifests.js +65 -0
- package/src/analysis/internal-audit/repo-files.js +55 -0
- package/src/analysis/internal-audit/supply-chain.js +132 -0
- package/src/analysis/internal-audit.collect.js +5 -106
- package/src/analysis/internal-audit.run.js +113 -200
- package/src/analysis/jvm-dependency-evidence.js +69 -0
- package/src/analysis/source-correctness/retry-patterns.js +93 -0
- package/src/analysis/source-correctness.js +225 -0
- package/src/analysis/structure/dependency-graph.js +156 -0
- package/src/analysis/structure/findings.js +142 -0
- package/src/graph/builder/go-receiver-resolution.js +112 -0
- package/src/graph/builder/js/queries.js +48 -0
- package/src/graph/builder/lang-go.js +89 -4
- package/src/graph/builder/lang-js.js +4 -44
- package/src/graph/builder/pass2-resolution.js +68 -0
- package/src/graph/freshness-probe.js +2 -1
- package/src/graph/incremental-refresh.js +3 -2
- package/src/graph/internal-builder.build.js +22 -183
- package/src/graph/internal-builder.pass2.js +161 -0
- package/src/graph/internal-builder.resolvers.js +2 -105
- package/src/graph/resolvers/rust.js +117 -0
- package/src/mcp/actions/advisories.mjs +18 -0
- package/src/mcp/actions/graph-lifecycle.mjs +148 -0
- package/src/mcp/actions/graph-sync.mjs +195 -0
- package/src/mcp/actions/hosted-architecture.mjs +57 -0
- package/src/mcp/architecture-bootstrap.mjs +168 -0
- package/src/mcp/architecture-starter.mjs +234 -0
- package/src/mcp/catalog.mjs +22 -6
- package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
- package/src/mcp/evidence/package-graph-common.mjs +115 -0
- package/src/mcp/evidence/package-lock-graph.mjs +92 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
- package/src/mcp/graph/context-core.mjs +111 -0
- package/src/mcp/graph/context-seeds.mjs +208 -0
- package/src/mcp/graph/context-state.mjs +86 -0
- package/src/mcp/graph/tools-core.mjs +143 -0
- package/src/mcp/graph/tools-query.mjs +223 -0
- package/src/mcp/graph-context.mjs +4 -496
- package/src/mcp/health/audit-format.mjs +172 -0
- package/src/mcp/health/audit.mjs +171 -0
- package/src/mcp/health/dead-code.mjs +110 -0
- package/src/mcp/health/duplicates.mjs +83 -0
- package/src/mcp/health/endpoints.mjs +42 -0
- package/src/mcp/health/structure.mjs +219 -0
- package/src/mcp/runtime-version.mjs +32 -0
- package/src/mcp/server/auto-refresh.mjs +66 -0
- package/src/mcp/server/runtime-config.mjs +43 -0
- package/src/mcp/server/shutdown.mjs +40 -0
- package/src/mcp/sync/evidence-architecture.mjs +54 -0
- package/src/mcp/sync/evidence-common.mjs +88 -0
- package/src/mcp/sync/evidence-duplicates.mjs +100 -0
- package/src/mcp/sync/evidence-health.mjs +56 -0
- package/src/mcp/sync/evidence-packages.mjs +95 -0
- package/src/mcp/sync/payload-common.mjs +97 -0
- package/src/mcp/sync/payload-v2.mjs +53 -0
- package/src/mcp/sync/payload-v3.mjs +79 -0
- package/src/mcp/sync-evidence.mjs +7 -402
- package/src/mcp/sync-payload.mjs +10 -244
- package/src/mcp/tools-actions.mjs +18 -414
- package/src/mcp/tools-architecture.mjs +18 -70
- package/src/mcp/tools-context.mjs +56 -15
- package/src/mcp/tools-endpoints.mjs +4 -1
- package/src/mcp/tools-graph.mjs +17 -330
- package/src/mcp/tools-health.mjs +24 -705
- package/src/mcp/tools-verified-change.mjs +12 -4
- package/src/mcp-server.mjs +49 -146
- package/src/precision/lsp-client/constants.js +12 -0
- package/src/precision/lsp-client/environment.js +20 -0
- package/src/precision/lsp-client/errors.js +15 -0
- package/src/precision/lsp-client/lifecycle.js +127 -0
- package/src/precision/lsp-client/message-parser.js +81 -0
- package/src/precision/lsp-client/protocol.js +212 -0
- package/src/precision/lsp-client/registry.js +58 -0
- package/src/precision/lsp-client/repo-uri.js +60 -0
- package/src/precision/lsp-client/stdio-client.js +151 -0
- package/src/precision/lsp-client.js +10 -682
- package/src/precision/lsp-overlay/build.js +238 -0
- package/src/precision/lsp-overlay/contract.js +61 -0
- package/src/precision/lsp-overlay/reference-results.js +108 -0
- package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
- package/src/precision/lsp-overlay/source-session.js +134 -0
- package/src/precision/lsp-overlay/store.js +150 -0
- package/src/precision/lsp-overlay/target-index.js +147 -0
- package/src/precision/lsp-overlay/target-query.js +55 -0
- package/src/precision/lsp-overlay.js +11 -868
- package/src/precision/typescript-lsp-provider.js +12 -682
- package/src/precision/typescript-provider/client.js +69 -0
- package/src/precision/typescript-provider/discovery.js +124 -0
- package/src/precision/typescript-provider/project-host.js +249 -0
- package/src/precision/typescript-provider/project-safety.js +161 -0
- package/src/precision/typescript-provider/reference-usage.js +53 -0
- package/src/version.js +7 -0
- package/docs/releases/v0.2.12.md +0 -45
|
@@ -126,10 +126,18 @@ export async function tVerifiedChange(g, args = {}, ctx = {}, tools = {}, permis
|
|
|
126
126
|
})
|
|
127
127
|
: {model: 'bounded call-argument-to-parameter evidence (not CFG or taint analysis)', status: 'UNAVAILABLE', reason: 'source capability is not enabled', edges: [], unsupportedEdges: 0, capped: false}
|
|
128
128
|
const suggestedTests = targetTests(impact)
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
129
|
+
const requestedTests = Array.isArray(args.tests) ? args.tests : []
|
|
130
|
+
let testProof
|
|
131
|
+
if (!requestedTests.length) {
|
|
132
|
+
testProof = {state: 'NOT_REQUESTED', reason: 'no package scripts were requested', plan: [], results: []}
|
|
133
|
+
} else if (args.run_tests !== true) {
|
|
134
|
+
testProof = await runAllowedTests(ctx.repoRoot, requestedTests, {enabled: false, timeoutMs: args.test_timeout_ms})
|
|
135
|
+
} else if (phase === 'verify') {
|
|
136
|
+
testProof = await runAllowedTests(ctx.repoRoot, requestedTests, {enabled: true, timeoutMs: args.test_timeout_ms})
|
|
137
|
+
} else {
|
|
138
|
+
const checkedTests = validateTestRequests(ctx.repoRoot, requestedTests)
|
|
139
|
+
testProof = {state: checkedTests.ok ? 'PLANNED' : 'BLOCKED', reason: checkedTests.reason, plan: checkedTests.tests || [], results: []}
|
|
140
|
+
}
|
|
133
141
|
const testCoverageState = testCoverage(testProof, args.tests || [], suggestedTests)
|
|
134
142
|
|
|
135
143
|
const architectureValue = phase === 'verify'
|
package/src/mcp-server.mjs
CHANGED
|
@@ -16,54 +16,24 @@
|
|
|
16
16
|
// go to stderr. Two argv forms:
|
|
17
17
|
// weavatrix-mcp <repoRoot> [caps] — graph path derived from the standard layout
|
|
18
18
|
// weavatrix-mcp <graph.json> <repoRoot> [caps] — explicit graph file (classic form)
|
|
19
|
-
import {existsSync, statSync, realpathSync} from 'node:fs'
|
|
20
|
-
import {join, dirname} from 'node:path'
|
|
21
|
-
import {fileURLToPath} from 'node:url'
|
|
22
19
|
import process from 'node:process'
|
|
23
20
|
import {loadGraph} from './mcp/graph-context.mjs'
|
|
24
|
-
import {graphOutDirForRepo} from './graph/layout.js'
|
|
25
|
-
import {createRequire} from 'node:module'
|
|
26
21
|
import {createStalenessNoticeGate} from './mcp/staleness-notice.mjs'
|
|
27
22
|
import {normalizeToolResult} from './mcp/tool-result.mjs'
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
23
|
+
import {runtimeVersionStatus, staleRuntimeMessage} from './mcp/runtime-version.mjs'
|
|
24
|
+
import {createAutoRefresh} from './mcp/server/auto-refresh.mjs'
|
|
25
|
+
import {createShutdownController} from './mcp/server/shutdown.mjs'
|
|
26
|
+
import {
|
|
27
|
+
hotCatalogVersion as hotVersion, loadServerCatalog as loadCatalog, PACKAGE_JSON_PATH,
|
|
28
|
+
PACKAGE_VERSION as PKG_VERSION, resolveServerTarget, SERVER_INFO,
|
|
29
|
+
} from './mcp/server/runtime-config.mjs'
|
|
32
30
|
|
|
33
31
|
// version comes from package.json so serverInfo can never drift from the published package again
|
|
34
|
-
const PKG_VERSION = (() => { try { return createRequire(import.meta.url)('../package.json').version } catch { return '0.0.0' } })()
|
|
35
|
-
const SERVER_INFO = {name: 'weavatrix', version: PKG_VERSION}
|
|
36
32
|
const DEFAULT_PROTOCOL = '2024-11-05'
|
|
37
33
|
const log = (...a) => process.stderr.write(`[weavatrix] ${a.join(' ')}\n`)
|
|
38
34
|
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
49
|
-
// argv[2] is a repo DIRECTORY in the npx form — derive the graph location from the standard layout;
|
|
50
|
-
// otherwise it is the graph.json path and the repo root follows it.
|
|
51
|
-
let GRAPH_PATH = process.argv[2]
|
|
52
|
-
let repoArg = process.argv[3]
|
|
53
|
-
// caps ABSENT (undefined) = offline defaults (explicit local retargeting, no network).
|
|
54
|
-
// PRESENT (even the empty string) = explicit set — see catalog.loadHotApi.
|
|
55
|
-
let CAPS_ARG = process.argv[4]
|
|
56
|
-
try {
|
|
57
|
-
if (GRAPH_PATH && statSync(GRAPH_PATH).isDirectory()) {
|
|
58
|
-
repoArg = realpathSync.native(GRAPH_PATH)
|
|
59
|
-
CAPS_ARG = process.argv[3]
|
|
60
|
-
GRAPH_PATH = join(graphOutDirForRepo(repoArg), 'graph.json')
|
|
61
|
-
if (!existsSync(GRAPH_PATH)) log(`no graph built yet for ${repoArg} — ask the agent to call rebuild_graph; it builds into the standard weavatrix-graphs layout`)
|
|
62
|
-
}
|
|
63
|
-
} catch { /* argv[2] is not a directory → classic <graph.json> <repoRoot> form */ }
|
|
64
|
-
// repo source root for search_code / read_source; null → those tools degrade.
|
|
65
|
-
let REPO_ROOT = null
|
|
66
|
-
try { if (repoArg && statSync(repoArg).isDirectory()) REPO_ROOT = realpathSync.native(repoArg) } catch { /* invalid repo root */ }
|
|
35
|
+
const target = resolveServerTarget(process.argv, log)
|
|
36
|
+
const GRAPH_PATH = target.graphPath, REPO_ROOT = target.repoRoot, CAPS_ARG = target.capabilities
|
|
67
37
|
|
|
68
38
|
// ---- hot reload of tool implementations -----------------------------------------------------------
|
|
69
39
|
// Node caches modules at spawn, so edits to the tool code would otherwise be invisible until the MCP
|
|
@@ -71,25 +41,27 @@ try { if (repoArg && statSync(repoArg).isDirectory()) REPO_ROOT = realpathSync.n
|
|
|
71
41
|
// any changed on disk we re-import them through catalog.loadHotApi with a cache-busting version and
|
|
72
42
|
// swap the tool table, then notify the client. The stdio shell, graph-context (its caches), and the
|
|
73
43
|
// analysis engines are NOT swapped — changing those still needs a reconnect.
|
|
74
|
-
const MCP_DIR = join(dirname(fileURLToPath(import.meta.url)), 'mcp')
|
|
75
|
-
const CATALOG_URL = new URL('./mcp/catalog.mjs', import.meta.url)
|
|
76
|
-
const loadCatalog = (version = 0) => import(version ? `${CATALOG_URL.href}?v=${version}` : CATALOG_URL.href)
|
|
77
|
-
function hotVersion(hotFiles) {
|
|
78
|
-
let v = 0
|
|
79
|
-
for (const f of hotFiles) {
|
|
80
|
-
try { const t = statSync(join(MCP_DIR, f)).mtimeMs; if (t > v) v = t } catch { /* missing file just doesn't bump the version */ }
|
|
81
|
-
}
|
|
82
|
-
return v
|
|
83
|
-
}
|
|
84
|
-
|
|
85
44
|
async function main() {
|
|
86
45
|
let catalog = await loadCatalog()
|
|
87
46
|
let api = await catalog.loadHotApi(0, CAPS_ARG)
|
|
47
|
+
const runtimeInfo = () => ({
|
|
48
|
+
...runtimeVersionStatus({runningVersion: PKG_VERSION, packageJsonPath: PACKAGE_JSON_PATH}),
|
|
49
|
+
profile: api.profile || 'custom',
|
|
50
|
+
capabilities: [...api.caps],
|
|
51
|
+
toolCount: api.tools.length,
|
|
52
|
+
})
|
|
53
|
+
const runtimeInstructions = () => {
|
|
54
|
+
const runtime = runtimeInfo()
|
|
55
|
+
const stale = runtime.staleRuntime
|
|
56
|
+
? ` ${staleRuntimeMessage(runtime)}${runtime.staleRuntimeAllowed ? ' Development override is active.' : ''}`
|
|
57
|
+
: ''
|
|
58
|
+
return `Weavatrix ${runtime.version}; diskVersion=${runtime.diskVersion || 'unavailable'}; profile=${runtime.profile}; tools=${runtime.toolCount}; capabilities=${runtime.capabilities.join(',') || '(none)'}.${stale} If this differs from the client-visible tool list, reconnect the MCP client to discard its cached schema.`
|
|
59
|
+
}
|
|
88
60
|
let graph = null
|
|
89
61
|
let graphError = null
|
|
90
62
|
// ctx owns the CURRENT target: rebuild_graph reloads it, open_repo retargets graphPath/repoRoot
|
|
91
63
|
// at runtime. loadInto always reads ctx.graphPath so both paths share one loader.
|
|
92
|
-
const ctx = {graphPath: GRAPH_PATH, repoRoot: REPO_ROOT, reload: null}
|
|
64
|
+
const ctx = {graphPath: GRAPH_PATH, repoRoot: REPO_ROOT, reload: null, runtime: runtimeInfo()}
|
|
93
65
|
const loadInto = () => { graph = loadGraph(ctx.graphPath, {repoRoot: ctx.repoRoot}); graphError = null; return graph }
|
|
94
66
|
ctx.reload = () => { api.resetStalenessCache(); try { return loadInto() } catch (e) { graphError = e.message; return null } }
|
|
95
67
|
try {
|
|
@@ -109,73 +81,12 @@ async function main() {
|
|
|
109
81
|
// ordinary tools use a per-call graph/context snapshot, so an explicitly retargetable registration
|
|
110
82
|
// cannot mix one repo's in-memory graph with another repo's source root under concurrent MCP calls.
|
|
111
83
|
let targetMutation = Promise.resolve()
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const refreshProbeCache = new Map()
|
|
115
|
-
const configuredDebounce = process.env.WEAVATRIX_AUTO_REFRESH_DEBOUNCE_MS == null
|
|
116
|
-
? 2_000
|
|
117
|
-
: Number(process.env.WEAVATRIX_AUTO_REFRESH_DEBOUNCE_MS)
|
|
118
|
-
const refreshDebounceMs = Math.max(0, Math.min(5_000, Number.isFinite(configuredDebounce) ? configuredDebounce : 2_000))
|
|
119
|
-
const autoRefresh = async (callCtx, currentGraph) => {
|
|
120
|
-
if (!callCtx?.repoRoot || !callCtx?.graphPath) return {graph: null, refresh: null}
|
|
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
|
-
}
|
|
136
|
-
const cachedProbe = refreshProbeCache.get(probeKey)
|
|
137
|
-
if (!semanticInputsChanged && currentGraph && cachedProbe && Date.now() - cachedProbe.checkedAt < refreshDebounceMs) {
|
|
138
|
-
return {graph: currentGraph, refresh: {kind: 'none', revision: currentGraph.graphRevision || null, changedFiles: 0}}
|
|
139
|
-
}
|
|
140
|
-
const beforeProbe = repositoryFreshnessProbe(callCtx.repoRoot)
|
|
141
|
-
const precisionMissing = activePrecision === 'lsp' && (
|
|
142
|
-
Number(currentGraph?.precisionOverlayV) !== PRECISION_OVERLAY_V
|
|
143
|
-
|| semanticInputsChanged
|
|
144
|
-
)
|
|
145
|
-
if (!precisionMissing && beforeProbe && currentGraph && (
|
|
146
|
-
cachedProbe?.probe === beforeProbe
|
|
147
|
-
|| persistedFreshnessMatches(currentGraph, beforeProbe, currentGraph.graphBuildMode || 'full')
|
|
148
|
-
)) {
|
|
149
|
-
refreshProbeCache.set(probeKey, {probe: beforeProbe, checkedAt: Date.now()})
|
|
150
|
-
return {graph: currentGraph, refresh: {kind: 'none', revision: currentGraph.graphRevision || null, changedFiles: 0}}
|
|
151
|
-
}
|
|
152
|
-
const result = await buildGraphForRepo(callCtx.repoRoot, {
|
|
153
|
-
mode: currentGraph?.graphBuildMode || 'full',
|
|
154
|
-
precision: activePrecision,
|
|
155
|
-
scope: '',
|
|
156
|
-
outDir: dirname(callCtx.graphPath),
|
|
157
|
-
})
|
|
158
|
-
if (!result.ok) throw new Error(result.error || 'automatic graph refresh failed')
|
|
159
|
-
api.resetStalenessCache()
|
|
160
|
-
const fresh = loadGraph(callCtx.graphPath, {repoRoot: callCtx.repoRoot})
|
|
161
|
-
const afterProbe = repositoryFreshnessProbe(callCtx.repoRoot)
|
|
162
|
-
if (afterProbe && afterProbe === beforeProbe) refreshProbeCache.set(probeKey, {probe: afterProbe, checkedAt: Date.now()})
|
|
163
|
-
else refreshProbeCache.delete(probeKey)
|
|
164
|
-
const update = result.refresh || {kind: 'full', changedFiles: [], reason: 'automatic-refresh'}
|
|
165
|
-
return {
|
|
166
|
-
graph: fresh,
|
|
167
|
-
refresh: {
|
|
168
|
-
kind: update.kind,
|
|
169
|
-
revision: update.revision || fresh.graphRevision || null,
|
|
170
|
-
changedFiles: Array.isArray(update.changedFiles) ? update.changedFiles.length : 0,
|
|
171
|
-
notice: update.kind === 'none' ? undefined : `Graph ${update.kind === 'incremental' ? 'incrementally refreshed' : 'rebuilt'} before this answer (${update.reason || 'repository changed'}).`,
|
|
172
|
-
},
|
|
173
|
-
}
|
|
174
|
-
}
|
|
84
|
+
const shutdown = createShutdownController({log, targetMutation: () => targetMutation})
|
|
85
|
+
const autoRefresh = createAutoRefresh(() => api)
|
|
175
86
|
const send = (msg) => {
|
|
176
87
|
// A request that was already running when the client disconnected may complete during the
|
|
177
88
|
// bounded drain. Do not write a late reply into a closed protocol pipe.
|
|
178
|
-
if (
|
|
89
|
+
if (shutdown.isShuttingDown() || process.stdout.destroyed || !process.stdout.writable) return false
|
|
179
90
|
try {
|
|
180
91
|
return process.stdout.write(JSON.stringify(msg) + '\n')
|
|
181
92
|
} catch (error) {
|
|
@@ -184,32 +95,9 @@ async function main() {
|
|
|
184
95
|
}
|
|
185
96
|
}
|
|
186
97
|
const reply = (id, result) => send({jsonrpc: '2.0', id, result})
|
|
187
|
-
const fail = (id, code, message) => send({jsonrpc: '2.0', id, error: {code, message}})
|
|
98
|
+
const fail = (id, code, message, data) => send({jsonrpc: '2.0', id, error: {code, message, ...(data ? {data} : {})}})
|
|
188
99
|
|
|
189
|
-
const requestShutdown =
|
|
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
|
-
}
|
|
100
|
+
const requestShutdown = shutdown.request
|
|
213
101
|
process.stdout.on('error', (error) => {
|
|
214
102
|
if (error?.code === 'EPIPE' || error?.code === 'ERR_STREAM_DESTROYED') {
|
|
215
103
|
void requestShutdown('stdout disconnected')
|
|
@@ -229,6 +117,7 @@ async function main() {
|
|
|
229
117
|
const nextApi = await nextCatalog.loadHotApi(v, CAPS_ARG)
|
|
230
118
|
catalog = nextCatalog
|
|
231
119
|
api = nextApi
|
|
120
|
+
ctx.runtime = runtimeInfo()
|
|
232
121
|
loadedVersion = v
|
|
233
122
|
log(`hot-reloaded tool implementations from changed source (${api.tools.length} tools)`)
|
|
234
123
|
send({jsonrpc: '2.0', method: 'notifications/tools/list_changed'})
|
|
@@ -241,26 +130,40 @@ async function main() {
|
|
|
241
130
|
const handle = async (msg) => {
|
|
242
131
|
const {id, method, params} = msg
|
|
243
132
|
const isNotification = id === undefined || id === null
|
|
244
|
-
if (
|
|
133
|
+
if (shutdown.isShuttingDown()) {
|
|
245
134
|
if (!isNotification) fail(id, -32000, 'MCP server is shutting down')
|
|
246
135
|
return
|
|
247
136
|
}
|
|
137
|
+
if (method === 'initialize' || method === 'tools/list' || method === 'tools/call') {
|
|
138
|
+
const runtime = runtimeInfo()
|
|
139
|
+
ctx.runtime = runtime
|
|
140
|
+
if (runtime.staleRuntime && !runtime.staleRuntimeAllowed) {
|
|
141
|
+
if (!isNotification) fail(id, -32001, staleRuntimeMessage(runtime), {'weavatrix/runtime': runtime})
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
}
|
|
248
145
|
if (method === 'initialize') {
|
|
249
146
|
if (params?.protocolVersion) protocolVersion = String(params.protocolVersion)
|
|
250
|
-
return reply(id, {
|
|
147
|
+
return reply(id, {
|
|
148
|
+
protocolVersion, capabilities: {tools: {listChanged: true}}, serverInfo: SERVER_INFO,
|
|
149
|
+
instructions: runtimeInstructions(), _meta: {'weavatrix/runtime': runtimeInfo()},
|
|
150
|
+
})
|
|
251
151
|
}
|
|
252
152
|
if (method === 'notifications/initialized' || method === 'initialized') return
|
|
253
153
|
if (method === 'ping') return reply(id, {})
|
|
254
154
|
if (method === 'tools/list') {
|
|
255
155
|
await maybeHotReload()
|
|
256
|
-
if (
|
|
257
|
-
return reply(id, {
|
|
156
|
+
if (shutdown.isShuttingDown()) return
|
|
157
|
+
return reply(id, {
|
|
158
|
+
tools: api.tools.map(({name, description, inputSchema, outputSchema}) => ({name, description, inputSchema, outputSchema})),
|
|
159
|
+
_meta: {'weavatrix/runtime': runtimeInfo()},
|
|
160
|
+
})
|
|
258
161
|
}
|
|
259
162
|
if (method === 'tools/call') {
|
|
260
163
|
await maybeHotReload()
|
|
261
164
|
// EOF can arrive while the hot-reload import is pending. Do not enqueue a graph mutation
|
|
262
165
|
// after requestShutdown captured the mutation chain it is responsible for draining.
|
|
263
|
-
if (
|
|
166
|
+
if (shutdown.isShuttingDown()) return
|
|
264
167
|
const tool = api.byName.get(params?.name)
|
|
265
168
|
if (!tool) return reply(id, {content: [{type: 'text', text: `Unknown tool: ${params?.name}`}], isError: true})
|
|
266
169
|
const refreshesGraph = tool.cap === 'graph' || tool.cap === 'health' || tool.refreshGraph === true
|
|
@@ -349,7 +252,7 @@ async function main() {
|
|
|
349
252
|
let buf = ''
|
|
350
253
|
process.stdin.setEncoding('utf8')
|
|
351
254
|
process.stdin.on('data', (chunk) => {
|
|
352
|
-
if (
|
|
255
|
+
if (shutdown.isShuttingDown()) return
|
|
353
256
|
buf += chunk
|
|
354
257
|
let nl
|
|
355
258
|
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const DEFAULT_MAX_MESSAGE_BYTES = 8 * 1024 * 1024
|
|
2
|
+
export const DEFAULT_MAX_HEADER_BYTES = 16 * 1024
|
|
3
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 10_000
|
|
4
|
+
export const JSON_RPC_VERSION = '2.0'
|
|
5
|
+
|
|
6
|
+
export function positiveInteger(value, fallback, label) {
|
|
7
|
+
const candidate = value == null ? fallback : Number(value)
|
|
8
|
+
if (!Number.isSafeInteger(candidate) || candidate <= 0) {
|
|
9
|
+
throw new TypeError(`${label} must be a positive integer`)
|
|
10
|
+
}
|
|
11
|
+
return candidate
|
|
12
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {delimiter, dirname, join} from 'node:path'
|
|
2
|
+
import {childProcessEnv} from '../../child-env.js'
|
|
3
|
+
|
|
4
|
+
const LSP_ENV_ALLOWLIST = new Set([
|
|
5
|
+
'path', 'pathext', 'systemroot', 'windir', 'comspec',
|
|
6
|
+
'temp', 'tmp', 'tmpdir', 'home', 'userprofile', 'localappdata', 'appdata',
|
|
7
|
+
'lang', 'language', 'lc_all', 'lc_ctype',
|
|
8
|
+
])
|
|
9
|
+
|
|
10
|
+
export function lspChildProcessEnv(overrides = {}) {
|
|
11
|
+
const inherited = childProcessEnv(overrides)
|
|
12
|
+
const clean = Object.fromEntries(
|
|
13
|
+
Object.entries(inherited).filter(([key]) => LSP_ENV_ALLOWLIST.has(key.toLowerCase())),
|
|
14
|
+
)
|
|
15
|
+
const safePath = [dirname(process.execPath)]
|
|
16
|
+
const systemRoot = inherited.SystemRoot || inherited.SYSTEMROOT || inherited.WINDIR
|
|
17
|
+
if (process.platform === 'win32' && systemRoot) safePath.push(join(systemRoot, 'System32'))
|
|
18
|
+
clean.PATH = [...new Set(safePath)].join(delimiter)
|
|
19
|
+
return clean
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export class LspProtocolError extends Error {
|
|
2
|
+
constructor(message, options) {
|
|
3
|
+
super(message, options)
|
|
4
|
+
this.name = 'LspProtocolError'
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class LspTimeoutError extends Error {
|
|
9
|
+
constructor(method, timeoutMs) {
|
|
10
|
+
super(`LSP request timed out after ${timeoutMs}ms: ${method}`)
|
|
11
|
+
this.name = 'LspTimeoutError'
|
|
12
|
+
this.method = method
|
|
13
|
+
this.timeoutMs = timeoutMs
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {spawn as spawnChild} from 'node:child_process'
|
|
2
|
+
import {positiveInteger} from './constants.js'
|
|
3
|
+
import {lspChildProcessEnv} from './environment.js'
|
|
4
|
+
import {LspTimeoutError} from './errors.js'
|
|
5
|
+
import {isLspClientActive} from './registry.js'
|
|
6
|
+
import {notifyClient, rejectPendingRequests, requestFromClient} from './protocol.js'
|
|
7
|
+
|
|
8
|
+
export function failClient(client, error) {
|
|
9
|
+
if (client.state === 'closed' || client.state === 'failed') return
|
|
10
|
+
client.state = 'failed'
|
|
11
|
+
rejectPendingRequests(client, error)
|
|
12
|
+
killClient(client, error)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function killClient(client, reason = new Error('LSP client was killed')) {
|
|
16
|
+
if (client.state !== 'closed') client.state = 'closed'
|
|
17
|
+
rejectPendingRequests(client, reason)
|
|
18
|
+
client.openDocuments.clear()
|
|
19
|
+
try { client.child.stdin?.destroy() } catch { /* already closed */ }
|
|
20
|
+
if (process.platform !== 'win32' && client.processGroupPid) {
|
|
21
|
+
try {
|
|
22
|
+
process.kill(-client.processGroupPid, 'SIGKILL')
|
|
23
|
+
return
|
|
24
|
+
} catch { /* fall through to direct child */ }
|
|
25
|
+
}
|
|
26
|
+
if (client.child.exitCode != null || client.child.signalCode != null) return
|
|
27
|
+
if (process.platform === 'win32' && client.child.pid) {
|
|
28
|
+
try {
|
|
29
|
+
const killer = spawnChild('taskkill', ['/pid', String(client.child.pid), '/T', '/F'], {
|
|
30
|
+
shell: false,
|
|
31
|
+
windowsHide: true,
|
|
32
|
+
env: lspChildProcessEnv(),
|
|
33
|
+
stdio: 'ignore',
|
|
34
|
+
})
|
|
35
|
+
const fallback = () => {
|
|
36
|
+
try { client.child.kill('SIGKILL') } catch { /* already exited */ }
|
|
37
|
+
}
|
|
38
|
+
killer.once('error', fallback)
|
|
39
|
+
killer.once('exit', (code) => { if (code !== 0) fallback() })
|
|
40
|
+
} catch {
|
|
41
|
+
try { client.child.kill('SIGKILL') } catch { /* already exited */ }
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
try { client.child.kill('SIGKILL') } catch { /* already exited */ }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function killWindowsTreeAndWait(client, timeoutMs = 3_000) {
|
|
49
|
+
if (process.platform !== 'win32' || !client.child.pid || client.child.exitCode != null) return
|
|
50
|
+
await new Promise((resolveKill) => {
|
|
51
|
+
let settled = false
|
|
52
|
+
const done = () => {
|
|
53
|
+
if (settled) return
|
|
54
|
+
settled = true
|
|
55
|
+
clearTimeout(timer)
|
|
56
|
+
resolveKill()
|
|
57
|
+
}
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
try { client.child.kill() } catch { /* already exited */ }
|
|
60
|
+
done()
|
|
61
|
+
}, Math.max(250, Math.min(5_000, Number(timeoutMs) || 3_000)))
|
|
62
|
+
try {
|
|
63
|
+
const killer = spawnChild('taskkill', ['/pid', String(client.child.pid), '/T', '/F'], {
|
|
64
|
+
shell: false,
|
|
65
|
+
windowsHide: true,
|
|
66
|
+
env: lspChildProcessEnv(),
|
|
67
|
+
stdio: 'ignore',
|
|
68
|
+
})
|
|
69
|
+
const fallback = () => {
|
|
70
|
+
try { client.child.kill('SIGKILL') } catch { /* already exited */ }
|
|
71
|
+
}
|
|
72
|
+
killer.once('error', () => { fallback(); done() })
|
|
73
|
+
killer.once('exit', (code) => {
|
|
74
|
+
if (code !== 0) fallback()
|
|
75
|
+
done()
|
|
76
|
+
})
|
|
77
|
+
} catch {
|
|
78
|
+
try { client.child.kill('SIGKILL') } catch { /* already exited */ }
|
|
79
|
+
done()
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function waitForClientExit(client, timeoutMs = client.requestTimeoutMs) {
|
|
85
|
+
if (!isLspClientActive(client)) return true
|
|
86
|
+
const boundedTimeout = Math.max(100, Math.min(10_000, Number(timeoutMs) || client.requestTimeoutMs))
|
|
87
|
+
let timer
|
|
88
|
+
const outcome = await Promise.race([
|
|
89
|
+
client.exited.then(() => true),
|
|
90
|
+
new Promise((resolveExit) => { timer = setTimeout(() => resolveExit(false), boundedTimeout) }),
|
|
91
|
+
])
|
|
92
|
+
if (timer) clearTimeout(timer)
|
|
93
|
+
return outcome
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function shutdownClient(client, options = {}) {
|
|
97
|
+
if (!client.shutdownPromise) client.shutdownPromise = shutdownClientOnce(client, options)
|
|
98
|
+
return client.shutdownPromise
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function shutdownClientOnce(client, {timeoutMs = client.requestTimeoutMs} = {}) {
|
|
102
|
+
const boundedTimeout = positiveInteger(timeoutMs, client.requestTimeoutMs, 'timeoutMs')
|
|
103
|
+
try {
|
|
104
|
+
if (client.state === 'starting') await client.spawned
|
|
105
|
+
if (client.state === 'closed') {
|
|
106
|
+
await waitForClientExit(client, boundedTimeout)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
if (client.state === 'initialized') {
|
|
110
|
+
await requestFromClient(client, 'shutdown', null, {timeoutMs: boundedTimeout})
|
|
111
|
+
}
|
|
112
|
+
client.state = 'stopping'
|
|
113
|
+
await notifyClient(client, 'exit', null)
|
|
114
|
+
await killWindowsTreeAndWait(client, Math.min(3_000, boundedTimeout))
|
|
115
|
+
if (process.platform !== 'win32' && client.processGroupPid) {
|
|
116
|
+
await waitForClientExit(client, Math.min(1_000, boundedTimeout))
|
|
117
|
+
try { process.kill(-client.processGroupPid, 'SIGKILL') } catch { /* group already exited */ }
|
|
118
|
+
}
|
|
119
|
+
if (!await waitForClientExit(client, boundedTimeout)) {
|
|
120
|
+
killClient(client, new LspTimeoutError('exit', boundedTimeout))
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
killClient(client, error)
|
|
124
|
+
await killWindowsTreeAndWait(client, Math.min(2_000, boundedTimeout))
|
|
125
|
+
await waitForClientExit(client, Math.min(2_000, boundedTimeout))
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {TextDecoder} from 'node:util'
|
|
2
|
+
import {DEFAULT_MAX_HEADER_BYTES, DEFAULT_MAX_MESSAGE_BYTES, positiveInteger} from './constants.js'
|
|
3
|
+
import {LspProtocolError} from './errors.js'
|
|
4
|
+
|
|
5
|
+
const HEADER_DELIMITER = Buffer.from('\r\n\r\n', 'ascii')
|
|
6
|
+
const UTF8_DECODER = new TextDecoder('utf-8', {fatal: true})
|
|
7
|
+
|
|
8
|
+
function asBuffer(chunk) {
|
|
9
|
+
if (Buffer.isBuffer(chunk)) return chunk
|
|
10
|
+
if (chunk instanceof Uint8Array) return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
|
|
11
|
+
return Buffer.from(chunk)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseHeaders(bytes, maxMessageBytes) {
|
|
15
|
+
const lines = bytes.toString('ascii').split('\r\n')
|
|
16
|
+
let contentLength = null
|
|
17
|
+
for (const line of lines) {
|
|
18
|
+
const match = /^([!#$%&'*+\-.^_`|~0-9A-Za-z]+):[ \t]*(.*)$/.exec(line)
|
|
19
|
+
if (!match) throw new LspProtocolError('Malformed LSP header line')
|
|
20
|
+
if (match[1].toLowerCase() !== 'content-length') continue
|
|
21
|
+
if (contentLength != null) throw new LspProtocolError('Duplicate Content-Length header')
|
|
22
|
+
if (!/^(0|[1-9][0-9]*)$/.test(match[2])) throw new LspProtocolError('Invalid Content-Length header')
|
|
23
|
+
contentLength = Number(match[2])
|
|
24
|
+
if (!Number.isSafeInteger(contentLength) || contentLength <= 0) {
|
|
25
|
+
throw new LspProtocolError('Content-Length must be positive')
|
|
26
|
+
}
|
|
27
|
+
if (contentLength > maxMessageBytes) {
|
|
28
|
+
throw new LspProtocolError(`LSP message exceeds ${maxMessageBytes} byte limit`)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (contentLength == null) throw new LspProtocolError('Missing Content-Length header')
|
|
32
|
+
return contentLength
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Incremental parser for the Content-Length framed JSON-RPC transport used by LSP. */
|
|
36
|
+
export class ContentLengthMessageParser {
|
|
37
|
+
constructor({onMessage, maxMessageBytes = DEFAULT_MAX_MESSAGE_BYTES, maxHeaderBytes = DEFAULT_MAX_HEADER_BYTES} = {}) {
|
|
38
|
+
if (typeof onMessage !== 'function') throw new TypeError('onMessage must be a function')
|
|
39
|
+
this.onMessage = onMessage
|
|
40
|
+
this.maxMessageBytes = positiveInteger(maxMessageBytes, DEFAULT_MAX_MESSAGE_BYTES, 'maxMessageBytes')
|
|
41
|
+
this.maxHeaderBytes = positiveInteger(maxHeaderBytes, DEFAULT_MAX_HEADER_BYTES, 'maxHeaderBytes')
|
|
42
|
+
this.buffer = Buffer.alloc(0)
|
|
43
|
+
this.expectedBodyBytes = null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
push(chunk) {
|
|
47
|
+
const incoming = asBuffer(chunk)
|
|
48
|
+
if (incoming.length === 0) return
|
|
49
|
+
this.buffer = this.buffer.length === 0 ? incoming : Buffer.concat([this.buffer, incoming])
|
|
50
|
+
while (this.buffer.length > 0) {
|
|
51
|
+
if (this.expectedBodyBytes == null) {
|
|
52
|
+
const delimiterIndex = this.buffer.indexOf(HEADER_DELIMITER)
|
|
53
|
+
if (delimiterIndex < 0) {
|
|
54
|
+
if (this.buffer.length > this.maxHeaderBytes) throw new LspProtocolError('LSP header exceeds byte limit')
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
if (delimiterIndex === 0) throw new LspProtocolError('Empty LSP header block')
|
|
58
|
+
if (delimiterIndex > this.maxHeaderBytes) throw new LspProtocolError('LSP header exceeds byte limit')
|
|
59
|
+
this.expectedBodyBytes = parseHeaders(
|
|
60
|
+
this.buffer.subarray(0, delimiterIndex),
|
|
61
|
+
this.maxMessageBytes,
|
|
62
|
+
)
|
|
63
|
+
this.buffer = this.buffer.subarray(delimiterIndex + HEADER_DELIMITER.length)
|
|
64
|
+
}
|
|
65
|
+
if (this.buffer.length < this.expectedBodyBytes) return
|
|
66
|
+
const body = this.buffer.subarray(0, this.expectedBodyBytes)
|
|
67
|
+
this.buffer = this.buffer.subarray(this.expectedBodyBytes)
|
|
68
|
+
this.expectedBodyBytes = null
|
|
69
|
+
let value
|
|
70
|
+
try {
|
|
71
|
+
value = JSON.parse(UTF8_DECODER.decode(body))
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new LspProtocolError('Invalid UTF-8 JSON in LSP message', {cause: error})
|
|
74
|
+
}
|
|
75
|
+
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
|
|
76
|
+
throw new LspProtocolError('LSP message must be a JSON object')
|
|
77
|
+
}
|
|
78
|
+
this.onMessage(value)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|