weavatrix 0.2.13 → 0.2.15

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 (145) hide show
  1. package/README.md +99 -39
  2. package/SECURITY.md +2 -2
  3. package/docs/releases/v0.2.15.md +37 -0
  4. package/package.json +2 -2
  5. package/skill/SKILL.md +57 -38
  6. package/src/analysis/architecture/contract-graph.js +119 -0
  7. package/src/analysis/architecture/contract-schema.js +110 -0
  8. package/src/analysis/architecture/contract-storage.js +35 -0
  9. package/src/analysis/architecture/contract-verification.js +168 -0
  10. package/src/analysis/architecture-contract.js +16 -343
  11. package/src/analysis/change-classification/diff-parser.js +103 -0
  12. package/src/analysis/change-classification/options.js +40 -0
  13. package/src/analysis/change-classification/symbol-classifier.js +177 -0
  14. package/src/analysis/change-classification.js +120 -519
  15. package/src/analysis/dead-code-review/policy.js +23 -0
  16. package/src/analysis/dead-code-review.js +9 -19
  17. package/src/analysis/dep-check.js +14 -71
  18. package/src/analysis/dep-rules.js +10 -303
  19. package/src/analysis/dependency/conventions.js +16 -0
  20. package/src/analysis/dependency/scoped-dependencies.js +45 -0
  21. package/src/analysis/dependency/source-references.js +21 -0
  22. package/src/analysis/duplicates.tokenize.js +12 -2
  23. package/src/analysis/endpoints/common.js +62 -0
  24. package/src/analysis/endpoints/extract.js +107 -0
  25. package/src/analysis/endpoints/inventory.js +84 -0
  26. package/src/analysis/endpoints/mounts.js +129 -0
  27. package/src/analysis/endpoints-java.js +80 -3
  28. package/src/analysis/endpoints.js +3 -448
  29. package/src/analysis/git-history/analytics.js +177 -0
  30. package/src/analysis/git-history/collector.js +109 -0
  31. package/src/analysis/git-history/options.js +68 -0
  32. package/src/analysis/git-history.js +128 -577
  33. package/src/analysis/health-capabilities.js +82 -0
  34. package/src/analysis/http-contracts/analysis.js +169 -0
  35. package/src/analysis/http-contracts/client-call-detection.js +81 -0
  36. package/src/analysis/http-contracts/client-call-parser.js +255 -0
  37. package/src/analysis/http-contracts/graph-context.js +143 -0
  38. package/src/analysis/http-contracts/matching.js +61 -0
  39. package/src/analysis/http-contracts/shared.js +86 -0
  40. package/src/analysis/http-contracts.js +6 -822
  41. package/src/analysis/internal-audit/dependency-health.js +111 -0
  42. package/src/analysis/internal-audit/python-manifests.js +65 -0
  43. package/src/analysis/internal-audit/repo-files.js +55 -0
  44. package/src/analysis/internal-audit/supply-chain.js +132 -0
  45. package/src/analysis/internal-audit.collect.js +5 -106
  46. package/src/analysis/internal-audit.reach.js +20 -0
  47. package/src/analysis/internal-audit.run.js +114 -200
  48. package/src/analysis/jvm-dependency-evidence.js +69 -0
  49. package/src/analysis/source-correctness/retry-patterns.js +93 -0
  50. package/src/analysis/source-correctness.js +225 -0
  51. package/src/analysis/structure/dependency-graph.js +156 -0
  52. package/src/analysis/structure/findings.js +142 -0
  53. package/src/analysis/task-retrieval.js +3 -14
  54. package/src/graph/builder/go-receiver-resolution.js +112 -0
  55. package/src/graph/builder/js/queries.js +48 -0
  56. package/src/graph/builder/lang-go.js +89 -4
  57. package/src/graph/builder/lang-js.js +4 -44
  58. package/src/graph/builder/pass2-resolution.js +68 -0
  59. package/src/graph/freshness-probe.js +2 -1
  60. package/src/graph/incremental-refresh.js +3 -2
  61. package/src/graph/internal-builder.build.js +22 -183
  62. package/src/graph/internal-builder.pass2.js +161 -0
  63. package/src/graph/internal-builder.resolvers.js +2 -105
  64. package/src/graph/resolvers/rust.js +117 -0
  65. package/src/mcp/actions/advisories.mjs +18 -0
  66. package/src/mcp/actions/graph-lifecycle.mjs +148 -0
  67. package/src/mcp/actions/graph-sync.mjs +195 -0
  68. package/src/mcp/actions/hosted-architecture.mjs +57 -0
  69. package/src/mcp/architecture-bootstrap.mjs +168 -0
  70. package/src/mcp/architecture-starter.mjs +234 -0
  71. package/src/mcp/catalog.mjs +20 -6
  72. package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
  73. package/src/mcp/evidence/duplicate-member-order.mjs +8 -0
  74. package/src/mcp/evidence/package-graph-common.mjs +115 -0
  75. package/src/mcp/evidence/package-lock-graph.mjs +92 -0
  76. package/src/mcp/evidence-snapshot.duplicates.mjs +3 -7
  77. package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
  78. package/src/mcp/git-output.mjs +10 -0
  79. package/src/mcp/graph/context-core.mjs +111 -0
  80. package/src/mcp/graph/context-seeds.mjs +197 -0
  81. package/src/mcp/graph/context-state.mjs +86 -0
  82. package/src/mcp/graph/reverse-reach.mjs +42 -0
  83. package/src/mcp/graph/tools-core.mjs +143 -0
  84. package/src/mcp/graph/tools-query.mjs +223 -0
  85. package/src/mcp/graph-context.mjs +4 -496
  86. package/src/mcp/health/audit-format.mjs +172 -0
  87. package/src/mcp/health/audit.mjs +171 -0
  88. package/src/mcp/health/dead-code.mjs +110 -0
  89. package/src/mcp/health/duplicates.mjs +83 -0
  90. package/src/mcp/health/endpoints.mjs +42 -0
  91. package/src/mcp/health/structure.mjs +219 -0
  92. package/src/mcp/runtime-version.mjs +32 -0
  93. package/src/mcp/server/auto-refresh.mjs +66 -0
  94. package/src/mcp/server/runtime-config.mjs +43 -0
  95. package/src/mcp/server/shutdown.mjs +40 -0
  96. package/src/mcp/sync/evidence-architecture.mjs +54 -0
  97. package/src/mcp/sync/evidence-common.mjs +88 -0
  98. package/src/mcp/sync/evidence-duplicates.mjs +97 -0
  99. package/src/mcp/sync/evidence-health.mjs +56 -0
  100. package/src/mcp/sync/evidence-packages.mjs +95 -0
  101. package/src/mcp/sync/payload-common.mjs +97 -0
  102. package/src/mcp/sync/payload-v2.mjs +53 -0
  103. package/src/mcp/sync/payload-v3.mjs +79 -0
  104. package/src/mcp/sync-evidence.mjs +7 -402
  105. package/src/mcp/sync-payload.mjs +10 -244
  106. package/src/mcp/tools-actions.mjs +18 -414
  107. package/src/mcp/tools-architecture.mjs +18 -70
  108. package/src/mcp/tools-context.mjs +56 -15
  109. package/src/mcp/tools-endpoints.mjs +4 -1
  110. package/src/mcp/tools-graph.mjs +17 -331
  111. package/src/mcp/tools-health.mjs +24 -705
  112. package/src/mcp/tools-impact-change.mjs +2 -38
  113. package/src/mcp/tools-impact.mjs +2 -43
  114. package/src/mcp-server.mjs +35 -146
  115. package/src/path-classification.js +14 -0
  116. package/src/precision/lsp-client/constants.js +12 -0
  117. package/src/precision/lsp-client/environment.js +20 -0
  118. package/src/precision/lsp-client/errors.js +15 -0
  119. package/src/precision/lsp-client/lifecycle.js +127 -0
  120. package/src/precision/lsp-client/message-parser.js +81 -0
  121. package/src/precision/lsp-client/protocol.js +212 -0
  122. package/src/precision/lsp-client/registry.js +58 -0
  123. package/src/precision/lsp-client/repo-uri.js +60 -0
  124. package/src/precision/lsp-client/stdio-client.js +151 -0
  125. package/src/precision/lsp-client.js +10 -682
  126. package/src/precision/lsp-overlay/build.js +238 -0
  127. package/src/precision/lsp-overlay/contract.js +61 -0
  128. package/src/precision/lsp-overlay/reference-results.js +108 -0
  129. package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
  130. package/src/precision/lsp-overlay/source-session.js +134 -0
  131. package/src/precision/lsp-overlay/store.js +150 -0
  132. package/src/precision/lsp-overlay/target-index.js +147 -0
  133. package/src/precision/lsp-overlay/target-query.js +55 -0
  134. package/src/precision/lsp-overlay.js +11 -868
  135. package/src/precision/typescript-lsp-provider.js +12 -682
  136. package/src/precision/typescript-provider/client.js +69 -0
  137. package/src/precision/typescript-provider/discovery.js +124 -0
  138. package/src/precision/typescript-provider/project-host.js +249 -0
  139. package/src/precision/typescript-provider/project-safety.js +161 -0
  140. package/src/precision/typescript-provider/reference-usage.js +53 -0
  141. package/src/security/malware-heuristics.sweep.js +1 -1
  142. package/src/security/registry-sig.classify.js +2 -1
  143. package/src/security/registry-sig.rules.js +3 -3
  144. package/src/version.js +7 -0
  145. package/docs/releases/v0.2.13.md +0 -48
@@ -5,16 +5,11 @@ import {childProcessEnv} from '../child-env.js'
5
5
  import {classifyChangeImpact} from '../analysis/change-classification.js'
6
6
  import {readCoverageForRepo} from '../analysis/coverage-reports.js'
7
7
  import {computeStaticTestReachability} from '../analysis/static-test-reachability.js'
8
- import {isStructuralRelation} from '../graph/relations.js'
9
8
  import {degreeOf, labelOf, rawGraph} from './graph-context.mjs'
9
+ import {reverseReach} from './graph/reverse-reach.mjs'
10
+ import {gitLines} from './git-output.mjs'
10
11
  import {toolResult} from './tool-result.mjs'
11
12
 
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
13
  function gitValue(repoRoot, args) {
19
14
  const result = spawnSync('git', ['-C', repoRoot, ...args], {encoding: 'utf8', timeout: 8000, env: childProcessEnv()})
20
15
  if (result.status !== 0) return null
@@ -30,37 +25,6 @@ function resolveImpactBase(repoRoot, requested) {
30
25
  return null
31
26
  }
32
27
 
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
28
  const impactKind = (entry) => entry?.runtimeDepth != null
65
29
  ? (entry.compileDepth != null ? `runtime + compile-time(d${entry.compileDepth})` : 'runtime')
66
30
  : 'compile-time'
@@ -9,46 +9,11 @@ import {
9
9
  rawGraph, prevGraphPathFor, edgeEndpoint, diffGraphs, formatGraphDiff,
10
10
  } from './graph-context.mjs'
11
11
  import {readCoverageForRepo} from '../analysis/coverage-reports.js'
12
- import {isStructuralRelation} from '../graph/relations.js'
12
+ import {reverseReach} from './graph/reverse-reach.mjs'
13
+ import {gitLines} from './git-output.mjs'
13
14
  import {tChangeImpactV2} from './tools-impact-change.mjs'
14
15
  import {buildGraphAtGitRef} from '../analysis/git-ref-graph.js'
15
16
 
16
- function reverseReach(g, seeds, maxDepth) {
17
- const states = new Map([...seeds].map((id) => [String(id), {
18
- runtimeDepth: 0, runtimeRelation: null, compileDepth: null, compileRelation: null,
19
- }]))
20
- const frontier = [...seeds].map((id) => ({id: String(id), depth: 0, compileOnly: false}))
21
- for (let cursor = 0; cursor < frontier.length; cursor++) {
22
- const current = frontier[cursor]
23
- if (current.depth >= maxDepth) continue
24
- for (const e of g.inn.get(current.id) || []) {
25
- if (isStructuralRelation(e.relation) || e.barrelProxy === true) continue
26
- const id = String(e.id)
27
- const compileOnly = current.compileOnly || e.typeOnly === true || e.compileOnly === true
28
- const depth = current.depth + 1
29
- const entry = states.get(id) || {
30
- runtimeDepth: null, runtimeRelation: null, compileDepth: null, compileRelation: null,
31
- }
32
- const depthKey = compileOnly ? 'compileDepth' : 'runtimeDepth'
33
- const relationKey = compileOnly ? 'compileRelation' : 'runtimeRelation'
34
- const provenanceKey = compileOnly ? 'compileProvenance' : 'runtimeProvenance'
35
- if (entry[depthKey] != null && entry[depthKey] <= depth) continue
36
- entry[depthKey] = depth
37
- entry[relationKey] = e.relation || 'rel'
38
- entry[provenanceKey] = e.provenance || 'UNKNOWN'
39
- states.set(id, entry)
40
- frontier.push({id, depth, compileOnly})
41
- }
42
- }
43
- return new Map([...states].map(([id, entry]) => [id, {
44
- ...entry,
45
- depth: entry.runtimeDepth ?? entry.compileDepth ?? 0,
46
- compileOnly: entry.runtimeDepth == null,
47
- relation: entry.runtimeDepth != null ? entry.runtimeRelation : entry.compileRelation,
48
- provenance: entry.runtimeDepth != null ? entry.runtimeProvenance : entry.compileProvenance,
49
- }]))
50
- }
51
-
52
17
  const impactKind = (entry) => {
53
18
  if (entry?.runtimeDepth != null) {
54
19
  return entry.compileDepth != null ? `runtime + compile-time(d${entry.compileDepth})` : 'runtime'
@@ -141,12 +106,6 @@ export async function tGraphDiff(g, args, ctx) {
141
106
  }
142
107
 
143
108
  // ---- change impact -------------------------------------------------------------------------------
144
- function gitLines(repoRoot, args) {
145
- const res = spawnSync('git', ['-C', repoRoot, ...args], {encoding: 'utf8', timeout: 8000, env: childProcessEnv()})
146
- if (res.status !== 0) return null
147
- return String(res.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
148
- }
149
-
150
109
  function resolveImpactBase(repoRoot, requested) {
151
110
  const candidates = requested ? [requested] : ['origin/HEAD', 'origin/main', 'origin/master', 'main', 'master']
152
111
  for (const ref of candidates) {
@@ -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 {buildGraphForRepo, defaultPrecisionMode} from './build-graph.js'
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'
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
- 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
-
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,29 +41,21 @@ 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)
88
47
  const runtimeInfo = () => ({
89
- version: PKG_VERSION,
48
+ ...runtimeVersionStatus({runningVersion: PKG_VERSION, packageJsonPath: PACKAGE_JSON_PATH}),
90
49
  profile: api.profile || 'custom',
91
50
  capabilities: [...api.caps],
92
51
  toolCount: api.tools.length,
93
52
  })
94
53
  const runtimeInstructions = () => {
95
54
  const runtime = runtimeInfo()
96
- return `Weavatrix ${runtime.version}; profile=${runtime.profile}; tools=${runtime.toolCount}; capabilities=${runtime.capabilities.join(',') || '(none)'}. If this differs from the client-visible tool list, reconnect the MCP client to discard its cached schema.`
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.`
97
59
  }
98
60
  let graph = null
99
61
  let graphError = null
@@ -119,73 +81,12 @@ async function main() {
119
81
  // ordinary tools use a per-call graph/context snapshot, so an explicitly retargetable registration
120
82
  // cannot mix one repo's in-memory graph with another repo's source root under concurrent MCP calls.
121
83
  let targetMutation = Promise.resolve()
122
- let shuttingDown = false
123
- let shutdownPromise = null
124
- const refreshProbeCache = new Map()
125
- const configuredDebounce = process.env.WEAVATRIX_AUTO_REFRESH_DEBOUNCE_MS == null
126
- ? 2_000
127
- : Number(process.env.WEAVATRIX_AUTO_REFRESH_DEBOUNCE_MS)
128
- const refreshDebounceMs = Math.max(0, Math.min(5_000, Number.isFinite(configuredDebounce) ? configuredDebounce : 2_000))
129
- const autoRefresh = async (callCtx, currentGraph) => {
130
- if (!callCtx?.repoRoot || !callCtx?.graphPath) return {graph: null, refresh: null}
131
- const activePrecision = currentGraph?.graphPrecisionMode || defaultPrecisionMode()
132
- const probeKey = `${callCtx.graphPath}\0${currentGraph?.graphBuildMode || 'full'}\0${activePrecision}`
133
- // Semantic inputs include ignored configs and configured project files that Git status does
134
- // not see. Check their bounded fingerprint before the ordinary source freshness debounce;
135
- // exact evidence must have no stale window, even on back-to-back tool calls.
136
- let semanticInputsChanged = false
137
- if (activePrecision === 'lsp' && currentGraph) {
138
- try {
139
- const overlay = readPrecisionOverlay(callCtx.graphPath, currentGraph)
140
- semanticInputsChanged = typeof overlay?.semanticInputFingerprint === 'string'
141
- && !precisionSemanticInputsMatch(overlay, callCtx.repoRoot, currentGraph)
142
- } catch {
143
- semanticInputsChanged = true
144
- }
145
- }
146
- const cachedProbe = refreshProbeCache.get(probeKey)
147
- if (!semanticInputsChanged && currentGraph && cachedProbe && Date.now() - cachedProbe.checkedAt < refreshDebounceMs) {
148
- return {graph: currentGraph, refresh: {kind: 'none', revision: currentGraph.graphRevision || null, changedFiles: 0}}
149
- }
150
- const beforeProbe = repositoryFreshnessProbe(callCtx.repoRoot)
151
- const precisionMissing = activePrecision === 'lsp' && (
152
- Number(currentGraph?.precisionOverlayV) !== PRECISION_OVERLAY_V
153
- || semanticInputsChanged
154
- )
155
- if (!precisionMissing && beforeProbe && currentGraph && (
156
- cachedProbe?.probe === beforeProbe
157
- || persistedFreshnessMatches(currentGraph, beforeProbe, currentGraph.graphBuildMode || 'full')
158
- )) {
159
- refreshProbeCache.set(probeKey, {probe: beforeProbe, checkedAt: Date.now()})
160
- return {graph: currentGraph, refresh: {kind: 'none', revision: currentGraph.graphRevision || null, changedFiles: 0}}
161
- }
162
- const result = await buildGraphForRepo(callCtx.repoRoot, {
163
- mode: currentGraph?.graphBuildMode || 'full',
164
- precision: activePrecision,
165
- scope: '',
166
- outDir: dirname(callCtx.graphPath),
167
- })
168
- if (!result.ok) throw new Error(result.error || 'automatic graph refresh failed')
169
- api.resetStalenessCache()
170
- const fresh = loadGraph(callCtx.graphPath, {repoRoot: callCtx.repoRoot})
171
- const afterProbe = repositoryFreshnessProbe(callCtx.repoRoot)
172
- if (afterProbe && afterProbe === beforeProbe) refreshProbeCache.set(probeKey, {probe: afterProbe, checkedAt: Date.now()})
173
- else refreshProbeCache.delete(probeKey)
174
- const update = result.refresh || {kind: 'full', changedFiles: [], reason: 'automatic-refresh'}
175
- return {
176
- graph: fresh,
177
- refresh: {
178
- kind: update.kind,
179
- revision: update.revision || fresh.graphRevision || null,
180
- changedFiles: Array.isArray(update.changedFiles) ? update.changedFiles.length : 0,
181
- notice: update.kind === 'none' ? undefined : `Graph ${update.kind === 'incremental' ? 'incrementally refreshed' : 'rebuilt'} before this answer (${update.reason || 'repository changed'}).`,
182
- },
183
- }
184
- }
84
+ const shutdown = createShutdownController({log, targetMutation: () => targetMutation})
85
+ const autoRefresh = createAutoRefresh(() => api)
185
86
  const send = (msg) => {
186
87
  // A request that was already running when the client disconnected may complete during the
187
88
  // bounded drain. Do not write a late reply into a closed protocol pipe.
188
- if (shuttingDown || process.stdout.destroyed || !process.stdout.writable) return false
89
+ if (shutdown.isShuttingDown() || process.stdout.destroyed || !process.stdout.writable) return false
189
90
  try {
190
91
  return process.stdout.write(JSON.stringify(msg) + '\n')
191
92
  } catch (error) {
@@ -194,32 +95,9 @@ async function main() {
194
95
  }
195
96
  }
196
97
  const reply = (id, result) => send({jsonrpc: '2.0', id, result})
197
- 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} : {})}})
198
99
 
199
- const requestShutdown = (reason, exitCode = 0) => {
200
- if (shutdownPromise) return shutdownPromise
201
- shuttingDown = true
202
- // This is synchronous and deliberately precedes every await: a graph build that reaches its
203
- // precision phase during the drain must not create a fresh TLS/tsserver process tree.
204
- beginLspClientShutdown()
205
- process.stdin.pause()
206
- const activeAtStart = activeLspClientCount()
207
- log(`shutdown requested (${reason}); draining graph work and ${activeAtStart} semantic provider(s)`)
208
- shutdownPromise = (async () => {
209
- // Give an ordinary auto-refresh a chance to commit and close its provider itself. A wedged
210
- // parse/query is bounded; active semantic children are then closed or tree-killed, after
211
- // which the mutation gets a final window to observe that cancellation and release locks.
212
- const initiallyDrained = await settleWithin(targetMutation, 2_500)
213
- const semantic = await shutdownActiveLspClients({timeoutMs: 3_000})
214
- const fullyDrained = initiallyDrained || await settleWithin(targetMutation, 1_500)
215
- log(`shutdown cleanup: graph=${fullyDrained ? 'drained' : 'bounded-timeout'}, semantic=${semantic.requested} requested/${semantic.remaining} remaining${semantic.timedOut ? ' (forced)' : ''}`)
216
- })().catch((error) => {
217
- log(`shutdown cleanup failed: ${error.stack || error.message}`)
218
- }).finally(() => {
219
- process.exit(exitCode)
220
- })
221
- return shutdownPromise
222
- }
100
+ const requestShutdown = shutdown.request
223
101
  process.stdout.on('error', (error) => {
224
102
  if (error?.code === 'EPIPE' || error?.code === 'ERR_STREAM_DESTROYED') {
225
103
  void requestShutdown('stdout disconnected')
@@ -252,19 +130,30 @@ async function main() {
252
130
  const handle = async (msg) => {
253
131
  const {id, method, params} = msg
254
132
  const isNotification = id === undefined || id === null
255
- if (shuttingDown) {
133
+ if (shutdown.isShuttingDown()) {
256
134
  if (!isNotification) fail(id, -32000, 'MCP server is shutting down')
257
135
  return
258
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
+ }
259
145
  if (method === 'initialize') {
260
146
  if (params?.protocolVersion) protocolVersion = String(params.protocolVersion)
261
- return reply(id, {protocolVersion, capabilities: {tools: {listChanged: true}}, serverInfo: SERVER_INFO, instructions: runtimeInstructions()})
147
+ return reply(id, {
148
+ protocolVersion, capabilities: {tools: {listChanged: true}}, serverInfo: SERVER_INFO,
149
+ instructions: runtimeInstructions(), _meta: {'weavatrix/runtime': runtimeInfo()},
150
+ })
262
151
  }
263
152
  if (method === 'notifications/initialized' || method === 'initialized') return
264
153
  if (method === 'ping') return reply(id, {})
265
154
  if (method === 'tools/list') {
266
155
  await maybeHotReload()
267
- if (shuttingDown) return
156
+ if (shutdown.isShuttingDown()) return
268
157
  return reply(id, {
269
158
  tools: api.tools.map(({name, description, inputSchema, outputSchema}) => ({name, description, inputSchema, outputSchema})),
270
159
  _meta: {'weavatrix/runtime': runtimeInfo()},
@@ -274,7 +163,7 @@ async function main() {
274
163
  await maybeHotReload()
275
164
  // EOF can arrive while the hot-reload import is pending. Do not enqueue a graph mutation
276
165
  // after requestShutdown captured the mutation chain it is responsible for draining.
277
- if (shuttingDown) return
166
+ if (shutdown.isShuttingDown()) return
278
167
  const tool = api.byName.get(params?.name)
279
168
  if (!tool) return reply(id, {content: [{type: 'text', text: `Unknown tool: ${params?.name}`}], isError: true})
280
169
  const refreshesGraph = tool.cap === 'graph' || tool.cap === 'health' || tool.refreshGraph === true
@@ -363,7 +252,7 @@ async function main() {
363
252
  let buf = ''
364
253
  process.stdin.setEncoding('utf8')
365
254
  process.stdin.on('data', (chunk) => {
366
- if (shuttingDown) return
255
+ if (shutdown.isShuttingDown()) return
367
256
  buf += chunk
368
257
  let nl
369
258
  while ((nl = buf.indexOf('\n')) >= 0) {
@@ -5,6 +5,20 @@ import { openSync, closeSync, readSync, readFileSync, statSync } from "node:fs";
5
5
  import { createRepoBoundary } from "./repo-path.js";
6
6
 
7
7
  export const PATH_CLASS_NAMES = Object.freeze(["test", "e2e", "generated", "mock", "story", "docs", "benchmark", "temp"]);
8
+ export const PATH_CLASS_QUERY_TERMS = Object.freeze({
9
+ test: Object.freeze(["test", "tests", "testing", "unit"]),
10
+ e2e: Object.freeze(["e2e", "playwright", "cypress"]),
11
+ generated: Object.freeze(["generated", "autogenerated", "dist"]),
12
+ mock: Object.freeze(["mock", "mocks", "fixture", "fixtures", "fake"]),
13
+ story: Object.freeze(["story", "stories", "storybook"]),
14
+ docs: Object.freeze(["doc", "docs", "documentation", "readme", "guide"]),
15
+ benchmark: Object.freeze(["benchmark", "benchmarks", "bench"]),
16
+ temp: Object.freeze(["temp", "temporary", "tmp"]),
17
+ });
18
+ export const PATH_CLASS_TASK_QUERY_TERMS = Object.freeze({
19
+ ...PATH_CLASS_QUERY_TERMS,
20
+ test: Object.freeze([...PATH_CLASS_QUERY_TERMS.test, "spec", "coverage", "verify"]),
21
+ });
8
22
  const PATH_CLASS_SET = new Set(PATH_CLASS_NAMES);
9
23
  const MAX_CONFIG_BYTES = 64 * 1024;
10
24
  const MAX_RULES_PER_CLASS = 64;
@@ -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
+ }