weavatrix 0.2.2 → 0.2.4
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 +68 -11
- package/SECURITY.md +18 -0
- package/package.json +3 -1
- package/skill/SKILL.md +36 -10
- package/src/analysis/dead-code-review.js +13 -3
- package/src/analysis/git-ref-graph.js +10 -2
- package/src/analysis/http-contract-wrappers.js +9 -0
- package/src/analysis/http-contracts.js +15 -3
- 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 +2 -0
- package/src/graph/freshness-probe.js +2 -2
- package/src/graph/incremental-refresh.js +1 -1
- package/src/graph/internal-builder.build.js +33 -4
- package/src/mcp/catalog.mjs +4 -4
- package/src/mcp/graph-context.mjs +143 -14
- 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 +46 -6
- package/src/mcp/tools-impact.mjs +21 -3
- 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 +847 -0
- package/src/precision/typescript-lsp-provider.js +682 -0
package/src/mcp/tools-health.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Health tools: clone detection, the internal audit, community/module overviews, coverage mapping
|
|
2
2
|
// and the HTTP endpoint inventory. Hot-reloadable (re-imported by catalog.mjs on change).
|
|
3
3
|
import {spawnSync} from 'node:child_process'
|
|
4
|
-
import {degreeOf, rawGraph} from './graph-context.mjs'
|
|
4
|
+
import {degreeOf, rawGraph, effectiveRawGraph} from './graph-context.mjs'
|
|
5
5
|
import {computeDuplicates} from '../analysis/duplicates.js'
|
|
6
6
|
import {runInternalAudit} from '../analysis/internal-audit.js'
|
|
7
7
|
import {classifyChangeImpact} from '../analysis/change-classification.js'
|
|
@@ -17,8 +17,9 @@ import {buildInternalGraph} from '../graph/internal-builder.js'
|
|
|
17
17
|
import {resolveGitCommit, withGitRefCheckout} from '../analysis/git-ref-graph.js'
|
|
18
18
|
import {childProcessEnv} from '../child-env.js'
|
|
19
19
|
import {toolResult} from './tool-result.mjs'
|
|
20
|
-
import {createPathClassifier} from '../path-classification.js'
|
|
20
|
+
import {createPathClassifier, hasPathClass} from '../path-classification.js'
|
|
21
21
|
import {createRepoBoundary} from '../repo-path.js'
|
|
22
|
+
import {filterGraphForMode} from '../graph/graph-filter.js'
|
|
22
23
|
|
|
23
24
|
const NON_PRODUCT_DUPLICATE_CLASSES = new Set(['generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
|
|
24
25
|
const fragmentEligible = (fragment, {tokMin, skipTests, includeClassified}) => {
|
|
@@ -117,7 +118,7 @@ export function tFindDuplicates(g, args, ctx) {
|
|
|
117
118
|
// candidates. It never returns an automatic-delete verdict.
|
|
118
119
|
export function tFindDeadCode(g, args, ctx) {
|
|
119
120
|
if (!ctx.repoRoot) return 'Dead-code review needs the repo root (not provided to this server).'
|
|
120
|
-
const graph =
|
|
121
|
+
const graph = effectiveRawGraph(ctx)
|
|
121
122
|
const boundary = createRepoBoundary(ctx.repoRoot)
|
|
122
123
|
const pkg = readRepoJson(boundary, 'package.json') || {}
|
|
123
124
|
const rules = readRepoJson(boundary, '.weavatrix-deps.json') || {}
|
|
@@ -323,7 +324,12 @@ async function runAuditWithBaseline(args, ctx, currentGraph) {
|
|
|
323
324
|
}
|
|
324
325
|
|
|
325
326
|
const baseline = await withGitRefCheckout(ctx.repoRoot, resolved.commit, async (checkout) => {
|
|
326
|
-
|
|
327
|
+
let graph = await buildInternalGraph(checkout)
|
|
328
|
+
const currentMode = ['full', 'no-tests', 'tests-only'].includes(currentGraph?.graphBuildMode)
|
|
329
|
+
? currentGraph.graphBuildMode : 'full'
|
|
330
|
+
if (currentMode !== 'full') graph = filterGraphForMode(graph, currentMode, {repoRoot: checkout})
|
|
331
|
+
graph.graphBuildMode = currentMode
|
|
332
|
+
graph.graphBuildScope = ''
|
|
327
333
|
return runInternalAudit(checkout, {graph, skipMalwareScan: true})
|
|
328
334
|
})
|
|
329
335
|
if (!baseline.ok || !baseline.value?.ok) {
|
|
@@ -385,7 +391,7 @@ export async function tRunAudit(g, args, ctx) {
|
|
|
385
391
|
if (!ctx.repoRoot) return 'Audit needs the repo root (not provided to this server).'
|
|
386
392
|
if (args.base_ref) return runAuditWithBaseline(args, ctx, rawGraph(ctx))
|
|
387
393
|
const audit = await runInternalAudit(ctx.repoRoot, {
|
|
388
|
-
graph:
|
|
394
|
+
graph: effectiveRawGraph(ctx),
|
|
389
395
|
skipMalwareScan: !args.include_malware_scan, // greps installed packages — slow, so opt-in
|
|
390
396
|
})
|
|
391
397
|
if (!audit.ok) return `Audit failed: ${audit.error}`
|
|
@@ -440,7 +446,36 @@ export function tListCommunities(g, args, ctx) {
|
|
|
440
446
|
// Folder-level architecture map: modules (top-two path segments) with file/symbol counts and the
|
|
441
447
|
// strongest module→module dependencies. Pure graph aggregation — no filesystem reads.
|
|
442
448
|
export function tModuleMap(g, args, ctx) {
|
|
443
|
-
const
|
|
449
|
+
const graph = rawGraph(ctx)
|
|
450
|
+
const testsOnly = graph.graphBuildMode === 'tests-only'
|
|
451
|
+
const includeNonProduct = args.include_non_product === true || testsOnly
|
|
452
|
+
const classifier = createPathClassifier(ctx?.repoRoot || null)
|
|
453
|
+
const nonProductFiles = new Set()
|
|
454
|
+
const classifiedFiles = new Set()
|
|
455
|
+
if (!includeNonProduct) {
|
|
456
|
+
for (const node of graph.nodes || []) {
|
|
457
|
+
if (!node?.source_file) continue
|
|
458
|
+
const sourceFile = String(node.source_file)
|
|
459
|
+
if (classifiedFiles.has(sourceFile)) continue
|
|
460
|
+
classifiedFiles.add(sourceFile)
|
|
461
|
+
const explanation = classifier.explain(sourceFile)
|
|
462
|
+
if (explanation.excluded || hasPathClass(explanation, 'test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp')) {
|
|
463
|
+
nonProductFiles.add(sourceFile)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const visibleGraph = includeNonProduct || nonProductFiles.size === 0 ? graph : (() => {
|
|
468
|
+
const keep = new Set((graph.nodes || [])
|
|
469
|
+
.filter((node) => !node?.source_file || !nonProductFiles.has(String(node.source_file)))
|
|
470
|
+
.map((node) => String(node.id)))
|
|
471
|
+
const endpoint = (value) => String(value && typeof value === 'object' ? value.id : value)
|
|
472
|
+
return {
|
|
473
|
+
...graph,
|
|
474
|
+
nodes: (graph.nodes || []).filter((node) => keep.has(String(node.id))),
|
|
475
|
+
links: (graph.links || []).filter((link) => keep.has(endpoint(link.source)) && keep.has(endpoint(link.target))),
|
|
476
|
+
}
|
|
477
|
+
})()
|
|
478
|
+
const agg = aggregateGraph(visibleGraph, null)
|
|
444
479
|
const topN = Math.max(1, Math.min(60, Number(args.top_n) || 25))
|
|
445
480
|
const mods = agg.modules.slice(0, topN)
|
|
446
481
|
const edges = agg.moduleEdges.slice(0, Math.min(50, topN * 2))
|
|
@@ -458,6 +493,11 @@ export function tModuleMap(g, args, ctx) {
|
|
|
458
493
|
collectCompileEdges(agg.compileOnlyModuleEdges, 'compileOnly')
|
|
459
494
|
const compiled = [...compileEdges.values()].sort((a, b) => b.count - a.count).slice(0, Math.min(50, topN * 2))
|
|
460
495
|
return [
|
|
496
|
+
testsOnly
|
|
497
|
+
? 'Scope: tests-only graph; classified test/e2e/fixture surfaces are retained automatically.'
|
|
498
|
+
: includeNonProduct
|
|
499
|
+
? 'Scope: all indexed files, including classified non-product surfaces.'
|
|
500
|
+
: `Scope: production-only (default); excluded ${nonProductFiles.size} classified test/fixture/benchmark/generated/docs file(s). Pass include_non_product:true for the complete graph.`,
|
|
461
501
|
`Module map: ${agg.totals.files} files in ${agg.modules.length} folder-modules, ${agg.totals.moduleEdges} runtime module dependencies and ${agg.totals.compileTimeModuleEdges || 0} compile-time dependencies (${agg.totals.typeOnlyModuleEdges || 0} type-only, ${agg.totals.compileOnlyModuleEdges || 0} compile-only). Top ${mods.length}:`,
|
|
462
502
|
...mods.map((m) => ` ${m.name} — ${m.fileCount} files, ${m.symbolCount} symbols`),
|
|
463
503
|
``,
|
package/src/mcp/tools-impact.mjs
CHANGED
|
@@ -31,9 +31,11 @@ function reverseReach(g, seeds, maxDepth) {
|
|
|
31
31
|
}
|
|
32
32
|
const depthKey = compileOnly ? 'compileDepth' : 'runtimeDepth'
|
|
33
33
|
const relationKey = compileOnly ? 'compileRelation' : 'runtimeRelation'
|
|
34
|
+
const provenanceKey = compileOnly ? 'compileProvenance' : 'runtimeProvenance'
|
|
34
35
|
if (entry[depthKey] != null && entry[depthKey] <= depth) continue
|
|
35
36
|
entry[depthKey] = depth
|
|
36
37
|
entry[relationKey] = e.relation || 'rel'
|
|
38
|
+
entry[provenanceKey] = e.provenance || 'UNKNOWN'
|
|
37
39
|
states.set(id, entry)
|
|
38
40
|
frontier.push({id, depth, compileOnly})
|
|
39
41
|
}
|
|
@@ -43,6 +45,7 @@ function reverseReach(g, seeds, maxDepth) {
|
|
|
43
45
|
depth: entry.runtimeDepth ?? entry.compileDepth ?? 0,
|
|
44
46
|
compileOnly: entry.runtimeDepth == null,
|
|
45
47
|
relation: entry.runtimeDepth != null ? entry.runtimeRelation : entry.compileRelation,
|
|
48
|
+
provenance: entry.runtimeDepth != null ? entry.runtimeProvenance : entry.compileProvenance,
|
|
46
49
|
}]))
|
|
47
50
|
}
|
|
48
51
|
|
|
@@ -86,17 +89,21 @@ export function tGetDependents(g, {label, depth = 3, max_nodes = 40} = {}) {
|
|
|
86
89
|
note,
|
|
87
90
|
`Dependents of ${n.label ?? id} (reverse calls/imports/inherits, depth ≤${maxDepth}): ${ranked.length} found (${runtimeCount} runtime, ${compileCount} compile-time-only), showing ${shown.length} by proximity + connectivity.`,
|
|
88
91
|
containingFile ? `Includes importers of its containing file ${labelOf(g, containingFile)}.` : null,
|
|
89
|
-
...shown.map((r) => ` [d${r.d} ${impactKind(r.entry)}] ${r.entry.relation || 'rel'} ${labelOf(g, r.id)} (deg ${r.deg}) [${r.id}]`),
|
|
92
|
+
...shown.map((r) => ` [d${r.d} ${impactKind(r.entry)}] ${r.entry.relation || 'rel'} [${r.entry.provenance || 'UNKNOWN'}] ${labelOf(g, r.id)} (deg ${r.deg}) [${r.id}]`),
|
|
90
93
|
].filter(Boolean).join('\n')
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
// Re-query the last rebuild's before/after pair (graph.prev.json vs graph.json), optionally scoped.
|
|
94
97
|
export async function tGraphDiff(g, args, ctx) {
|
|
98
|
+
const current = rawGraph(ctx)
|
|
99
|
+
const currentMode = ['full', 'no-tests', 'tests-only'].includes(current?.graphBuildMode)
|
|
100
|
+
? current.graphBuildMode
|
|
101
|
+
: 'full'
|
|
95
102
|
let prev
|
|
96
103
|
let baselineLabel = 'previous rebuild state'
|
|
97
104
|
if (args.base_ref) {
|
|
98
105
|
if (!ctx.repoRoot) return 'A Git-ref graph diff needs the active repository root.'
|
|
99
|
-
const built = await buildGraphAtGitRef(ctx.repoRoot, args.base_ref)
|
|
106
|
+
const built = await buildGraphAtGitRef(ctx.repoRoot, args.base_ref, {mode: currentMode})
|
|
100
107
|
if (!built.ok) return `Could not build the baseline graph: ${built.error}`
|
|
101
108
|
prev = built.graph
|
|
102
109
|
baselineLabel = `${built.ref} (${built.commit.slice(0, 12)})`
|
|
@@ -106,7 +113,17 @@ export async function tGraphDiff(g, args, ctx) {
|
|
|
106
113
|
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
114
|
}
|
|
108
115
|
}
|
|
109
|
-
|
|
116
|
+
if (!args.base_ref) {
|
|
117
|
+
const previousMode = ['full', 'no-tests', 'tests-only'].includes(prev?.graphBuildMode)
|
|
118
|
+
? prev.graphBuildMode
|
|
119
|
+
: 'full'
|
|
120
|
+
if (previousMode !== currentMode) {
|
|
121
|
+
return [
|
|
122
|
+
`Graph diff unavailable: previous graph mode is ${previousMode}, current graph mode is ${currentMode}.`,
|
|
123
|
+
'Those node/edge universes are not comparable. Run rebuild_graph once more in the same mode, or pass base_ref to build a mode-matched immutable baseline.',
|
|
124
|
+
].join('\n')
|
|
125
|
+
}
|
|
126
|
+
}
|
|
110
127
|
const filter = args.path ? String(args.path).replace(/\\/g, '/') : null
|
|
111
128
|
const scope = (graph) => filter ? {
|
|
112
129
|
nodes: (graph.nodes || []).filter((n) => String(n.id).startsWith(filter)),
|
|
@@ -118,6 +135,7 @@ export async function tGraphDiff(g, args, ctx) {
|
|
|
118
135
|
} : graph
|
|
119
136
|
return [
|
|
120
137
|
`Graph diff (${baselineLabel} → current)${filter ? `, scoped to ${filter}` : ''}:`,
|
|
138
|
+
`Build mode: ${currentMode}`,
|
|
121
139
|
formatGraphDiff(diffGraphs(scope(prev), scope(current)))
|
|
122
140
|
].join('\n')
|
|
123
141
|
}
|
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
|
|