weavatrix 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/bin/weavatrix-mcp.mjs +6 -0
- package/package.json +50 -0
- package/skill/SKILL.md +56 -0
- package/src/analysis/coverage-reports.js +232 -0
- package/src/analysis/dead-check.js +136 -0
- package/src/analysis/dep-check.js +310 -0
- package/src/analysis/dep-rules.js +221 -0
- package/src/analysis/duplicates-worker.js +11 -0
- package/src/analysis/duplicates.compute.js +215 -0
- package/src/analysis/duplicates.js +15 -0
- package/src/analysis/duplicates.run.js +51 -0
- package/src/analysis/duplicates.tokenize.js +182 -0
- package/src/analysis/endpoints.js +156 -0
- package/src/analysis/findings.js +52 -0
- package/src/analysis/graph-analysis.aggregate.js +280 -0
- package/src/analysis/graph-analysis.js +7 -0
- package/src/analysis/graph-analysis.refs.js +146 -0
- package/src/analysis/graph-analysis.summaries.js +62 -0
- package/src/analysis/internal-audit.collect.js +117 -0
- package/src/analysis/internal-audit.js +9 -0
- package/src/analysis/internal-audit.reach.js +47 -0
- package/src/analysis/internal-audit.run.js +189 -0
- package/src/analysis/manifests.js +169 -0
- package/src/analysis/source-complexity.ast.js +97 -0
- package/src/analysis/source-complexity.constants.js +55 -0
- package/src/analysis/source-complexity.js +14 -0
- package/src/analysis/source-complexity.report.js +76 -0
- package/src/analysis/source-complexity.walk.js +174 -0
- package/src/build-graph.js +102 -0
- package/src/config.js +5 -0
- package/src/graph/build-worker.js +30 -0
- package/src/graph/builder/lang-csharp.js +40 -0
- package/src/graph/builder/lang-css.js +29 -0
- package/src/graph/builder/lang-go.js +53 -0
- package/src/graph/builder/lang-html.js +29 -0
- package/src/graph/builder/lang-java.js +29 -0
- package/src/graph/builder/lang-js.js +168 -0
- package/src/graph/builder/lang-python.js +78 -0
- package/src/graph/builder/lang-rust.js +45 -0
- package/src/graph/builder/spec-pkg.js +87 -0
- package/src/graph/graph-filter.js +44 -0
- package/src/graph/internal-builder.build.js +217 -0
- package/src/graph/internal-builder.js +12 -0
- package/src/graph/internal-builder.langs.js +86 -0
- package/src/graph/internal-builder.resolvers.js +116 -0
- package/src/graph/layout.js +35 -0
- package/src/infra/infra-items.js +255 -0
- package/src/infra/infra-registry.js +184 -0
- package/src/infra/infra.detect.js +119 -0
- package/src/infra/infra.js +38 -0
- package/src/infra/infra.match.js +102 -0
- package/src/infra/infra.scan.js +93 -0
- package/src/mcp/catalog.mjs +68 -0
- package/src/mcp/graph-context.mjs +276 -0
- package/src/mcp/tools-actions.mjs +132 -0
- package/src/mcp/tools-graph.mjs +274 -0
- package/src/mcp/tools-health.mjs +209 -0
- package/src/mcp/tools-impact.mjs +189 -0
- package/src/mcp-rg.mjs +51 -0
- package/src/mcp-server.mjs +171 -0
- package/src/mcp-source-tools.mjs +139 -0
- package/src/process.js +77 -0
- package/src/scan/discover.inventory.js +78 -0
- package/src/scan/discover.js +5 -0
- package/src/scan/discover.list.js +79 -0
- package/src/scan/discover.stack.js +227 -0
- package/src/scan/search.core.js +24 -0
- package/src/scan/search.git.js +102 -0
- package/src/scan/search.js +9 -0
- package/src/scan/search.node.js +83 -0
- package/src/scan/search.preview.js +49 -0
- package/src/scan/search.rg.js +145 -0
- package/src/security/advisory-store.js +177 -0
- package/src/security/installed.js +247 -0
- package/src/security/malware-file-heuristics.js +121 -0
- package/src/security/malware-heuristics.exclusions.js +134 -0
- package/src/security/malware-heuristics.js +15 -0
- package/src/security/malware-heuristics.roots.js +142 -0
- package/src/security/malware-heuristics.scan.js +87 -0
- package/src/security/malware-heuristics.sweep.js +122 -0
- package/src/security/malware-scoring.js +84 -0
- package/src/security/match.js +85 -0
- package/src/security/registry-sig.classify.js +136 -0
- package/src/security/registry-sig.js +18 -0
- package/src/security/registry-sig.rules.js +188 -0
- package/src/security/typosquat.js +72 -0
- package/src/util.js +27 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Impact tools: transitive blast radius of one node (get_dependents), the structural diff of the
|
|
2
|
+
// last rebuild (graph_diff), and the blast radius of the current change set (change_impact).
|
|
3
|
+
// Hot-reloadable (re-imported by catalog.mjs on change).
|
|
4
|
+
import {readFileSync} from 'node:fs'
|
|
5
|
+
import {spawnSync} from 'node:child_process'
|
|
6
|
+
import {
|
|
7
|
+
isSymbol, degreeOf, labelOf, resolveNodeInfo, ambiguityNote,
|
|
8
|
+
rawGraph, prevGraphPathFor, edgeEndpoint, diffGraphs, formatGraphDiff,
|
|
9
|
+
} from './graph-context.mjs'
|
|
10
|
+
import {readCoverageForRepo} from '../analysis/coverage-reports.js'
|
|
11
|
+
|
|
12
|
+
// Transitive blast-radius: who is affected if this node changes. Walks REVERSE dependency edges
|
|
13
|
+
// (calls/imports/inherits — not structural `contains`) out to `depth`. For a symbol, also seeds its
|
|
14
|
+
// containing file, because importers depend on the file rather than the individual symbol.
|
|
15
|
+
export function tGetDependents(g, {label, depth = 3, max_nodes = 40} = {}) {
|
|
16
|
+
const info = resolveNodeInfo(g, label)
|
|
17
|
+
const n = info.node
|
|
18
|
+
if (!n) return `No node found matching "${label}".`
|
|
19
|
+
const note = ambiguityNote(label, info)
|
|
20
|
+
const maxDepth = Math.max(1, Math.min(6, Number(depth) || 3))
|
|
21
|
+
const cap = Math.max(5, Math.min(120, Number(max_nodes) || 40))
|
|
22
|
+
const id = String(n.id)
|
|
23
|
+
const seeds = new Set([id])
|
|
24
|
+
let containingFile = null
|
|
25
|
+
if (isSymbol(id)) {
|
|
26
|
+
const container = (g.inn.get(id) || []).find((e) => e.relation === 'contains')
|
|
27
|
+
if (container) {
|
|
28
|
+
containingFile = String(container.id)
|
|
29
|
+
seeds.add(containingFile)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const depthOf = new Map([...seeds].map((s) => [s, 0]))
|
|
33
|
+
const relOf = new Map()
|
|
34
|
+
let frontier = [...seeds]
|
|
35
|
+
for (let d = 0; d < maxDepth && frontier.length; d++) {
|
|
36
|
+
const next = []
|
|
37
|
+
for (const cur of frontier) {
|
|
38
|
+
for (const e of g.inn.get(cur) || []) {
|
|
39
|
+
if (e.relation === 'contains') continue // structural nesting is not a dependency
|
|
40
|
+
const nid = String(e.id)
|
|
41
|
+
if (depthOf.has(nid)) continue
|
|
42
|
+
depthOf.set(nid, d + 1)
|
|
43
|
+
relOf.set(nid, e.relation || 'rel')
|
|
44
|
+
next.push(nid)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
frontier = next
|
|
48
|
+
}
|
|
49
|
+
const ranked = [...depthOf.entries()]
|
|
50
|
+
.filter(([nid]) => !seeds.has(nid))
|
|
51
|
+
.map(([nid, d]) => ({id: nid, d, deg: degreeOf(g, nid)}))
|
|
52
|
+
.sort((a, b) => a.d - b.d || b.deg - a.deg)
|
|
53
|
+
if (!ranked.length) return [note, `No dependents found for ${n.label ?? id} within depth ${maxDepth} — nothing in the graph calls, imports, or inherits it.`].filter(Boolean).join('\n')
|
|
54
|
+
const shown = ranked.slice(0, cap)
|
|
55
|
+
return [
|
|
56
|
+
note,
|
|
57
|
+
`Dependents of ${n.label ?? id} (reverse calls/imports/inherits, depth ≤${maxDepth}): ${ranked.length} found, showing ${shown.length} by proximity + connectivity.`,
|
|
58
|
+
containingFile ? `Includes importers of its containing file ${labelOf(g, containingFile)}.` : null,
|
|
59
|
+
...shown.map((r) => ` [d${r.d}] ${relOf.get(r.id) || 'rel'} ${labelOf(g, r.id)} (deg ${r.deg}) [${r.id}]`),
|
|
60
|
+
].filter(Boolean).join('\n')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Re-query the last rebuild's before/after pair (graph.prev.json vs graph.json), optionally scoped.
|
|
64
|
+
export function tGraphDiff(g, args, ctx) {
|
|
65
|
+
const prevPath = prevGraphPathFor(ctx.graphPath)
|
|
66
|
+
let prev
|
|
67
|
+
try { prev = JSON.parse(readFileSync(prevPath, 'utf8')) } catch {
|
|
68
|
+
return `No previous graph state at ${prevPath} — rebuild_graph saves one automatically (a single prior state is kept).`
|
|
69
|
+
}
|
|
70
|
+
const current = rawGraph(ctx)
|
|
71
|
+
const filter = args.path ? String(args.path).replace(/\\/g, '/') : null
|
|
72
|
+
const scope = (graph) => filter ? {
|
|
73
|
+
nodes: (graph.nodes || []).filter((n) => String(n.id).startsWith(filter)),
|
|
74
|
+
links: (graph.links || []).filter((l) => String(edgeEndpoint(l.source)).startsWith(filter) || String(edgeEndpoint(l.target)).startsWith(filter))
|
|
75
|
+
} : graph
|
|
76
|
+
return [
|
|
77
|
+
`Graph diff (previous rebuild state → current)${filter ? `, scoped to ${filter}` : ''}:`,
|
|
78
|
+
formatGraphDiff(diffGraphs(scope(prev), scope(current)))
|
|
79
|
+
].join('\n')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- change impact -------------------------------------------------------------------------------
|
|
83
|
+
function gitLines(repoRoot, args) {
|
|
84
|
+
const res = spawnSync('git', ['-C', repoRoot, ...args], {encoding: 'utf8', timeout: 8000})
|
|
85
|
+
if (res.status !== 0) return null
|
|
86
|
+
return String(res.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveImpactBase(repoRoot, requested) {
|
|
90
|
+
const candidates = requested ? [requested] : ['origin/HEAD', 'origin/main', 'origin/master', 'main', 'master']
|
|
91
|
+
for (const ref of candidates) {
|
|
92
|
+
const ok = spawnSync('git', ['-C', repoRoot, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {encoding: 'utf8', timeout: 8000})
|
|
93
|
+
if (ok.status === 0) return ref
|
|
94
|
+
}
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Blast radius of a change, without any GitHub API: diff the CURRENT change (branch
|
|
99
|
+
// commits since the merge-base + staged/unstaged + untracked) against a base ref, map the changed
|
|
100
|
+
// files and their symbols onto the graph, and walk REVERSE dependency edges — everything the change
|
|
101
|
+
// can break, ranked by proximity + connectivity, with file-level test coverage attached so the
|
|
102
|
+
// untested part of the blast radius stands out. The pre-PR review, in one call.
|
|
103
|
+
export function tChangeImpact(g, args, ctx) {
|
|
104
|
+
if (!ctx.repoRoot) return 'change_impact needs the repo root (not provided to this server).'
|
|
105
|
+
// Explicit file list (e.g. a PR's changed files) skips the local git diff entirely — this is how
|
|
106
|
+
// a NOT-checked-out PR gets its impact assessed.
|
|
107
|
+
const explicit = Array.isArray(args.files)
|
|
108
|
+
? [...new Set(args.files.map((f) => String(f).replace(/\\/g, '/').trim()).filter(Boolean))]
|
|
109
|
+
: null
|
|
110
|
+
let changed
|
|
111
|
+
let sourceLabel
|
|
112
|
+
if (explicit) {
|
|
113
|
+
if (!explicit.length) return 'files was provided but empty — pass repo-relative paths, or omit it to diff the local change.'
|
|
114
|
+
changed = explicit
|
|
115
|
+
sourceLabel = `for ${changed.length} provided file(s)`
|
|
116
|
+
} else {
|
|
117
|
+
const base = resolveImpactBase(ctx.repoRoot, args.base ? String(args.base).trim() : '')
|
|
118
|
+
if (!base) return `Could not resolve a base ref${args.base ? ` ("${args.base}")` : ''} — pass base explicitly (e.g. origin/main or HEAD~1).`
|
|
119
|
+
const committed = gitLines(ctx.repoRoot, ['diff', '--name-only', `${base}...HEAD`])
|
|
120
|
+
if (committed === null) return `git diff against ${base} failed — is ${ctx.repoRoot} a git repository?`
|
|
121
|
+
const uncommitted = gitLines(ctx.repoRoot, ['diff', '--name-only', 'HEAD']) || []
|
|
122
|
+
const untracked = gitLines(ctx.repoRoot, ['ls-files', '--others', '--exclude-standard']) || []
|
|
123
|
+
changed = [...new Set([...committed, ...uncommitted, ...untracked])]
|
|
124
|
+
sourceLabel = `vs ${base}`
|
|
125
|
+
if (!changed.length) return `No changes vs ${base} — working tree clean and no branch commits.`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const changedSet = new Set(changed)
|
|
129
|
+
const seeds = new Set()
|
|
130
|
+
const unmapped = []
|
|
131
|
+
for (const file of changed) {
|
|
132
|
+
if (!g.byId.has(file)) { unmapped.push(file); continue }
|
|
133
|
+
seeds.add(file)
|
|
134
|
+
for (const e of g.out.get(file) || []) if (e.relation === 'contains') seeds.add(String(e.id))
|
|
135
|
+
}
|
|
136
|
+
if (!seeds.size) {
|
|
137
|
+
return [
|
|
138
|
+
`${changed.length} changed file(s) ${sourceLabel}, but none are in the graph — new files or non-code.`,
|
|
139
|
+
`Run rebuild_graph and retry for the full picture.`,
|
|
140
|
+
`Changed: ${changed.slice(0, 20).join(', ')}${changed.length > 20 ? ', …' : ''}`,
|
|
141
|
+
].join('\n')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const maxDepth = Math.max(1, Math.min(4, Number(args.depth) || 2))
|
|
145
|
+
const cap = Math.max(5, Math.min(120, Number(args.max_nodes) || 40))
|
|
146
|
+
const depthOf = new Map([...seeds].map((s) => [s, 0]))
|
|
147
|
+
const relOf = new Map()
|
|
148
|
+
let frontier = [...seeds]
|
|
149
|
+
for (let d = 0; d < maxDepth && frontier.length; d++) {
|
|
150
|
+
const next = []
|
|
151
|
+
for (const cur of frontier) {
|
|
152
|
+
for (const e of g.inn.get(cur) || []) {
|
|
153
|
+
if (e.relation === 'contains') continue
|
|
154
|
+
const nid = String(e.id)
|
|
155
|
+
if (depthOf.has(nid)) continue
|
|
156
|
+
depthOf.set(nid, d + 1)
|
|
157
|
+
relOf.set(nid, e.relation || 'rel')
|
|
158
|
+
next.push(nid)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
frontier = next
|
|
162
|
+
}
|
|
163
|
+
const fileOfNode = (id) => { const s = String(id); const h = s.indexOf('#'); return h < 0 ? s : s.slice(0, h) }
|
|
164
|
+
const impacted = [...depthOf.entries()]
|
|
165
|
+
.filter(([nid]) => !seeds.has(nid) && !changedSet.has(fileOfNode(nid)))
|
|
166
|
+
.map(([nid, d]) => ({id: nid, d, deg: degreeOf(g, nid), file: fileOfNode(nid)}))
|
|
167
|
+
.sort((a, b) => a.d - b.d || b.deg - a.deg)
|
|
168
|
+
|
|
169
|
+
// coverage overlay from EXISTING reports (fractions 0..1) — see coverage_map for details
|
|
170
|
+
const knownFiles = (rawGraph(ctx).nodes || []).filter((n) => !String(n.id).includes('#') && n.source_file).map((n) => n.source_file)
|
|
171
|
+
const coverage = readCoverageForRepo(ctx.repoRoot, knownFiles)
|
|
172
|
+
const covOf = (file) => coverage.get(String(file).replace(/\\/g, '/'))?.pct ?? null
|
|
173
|
+
const pctStr = (v) => (v == null ? 'cov n/a' : `cov ${Math.round(v * 100)}%`)
|
|
174
|
+
|
|
175
|
+
const shown = impacted.slice(0, cap)
|
|
176
|
+
const untestedHotspots = shown.filter((n) => { const c = covOf(n.file); return c != null && c < 0.5 && n.deg >= 5 })
|
|
177
|
+
return [
|
|
178
|
+
`Change impact ${sourceLabel}: ${changed.length} changed file(s) → ${seeds.size} graph seed(s) incl. their symbols${unmapped.length ? `; ${unmapped.length} file(s) not in the graph (new/non-code — rebuild_graph refreshes)` : ''}.`,
|
|
179
|
+
impacted.length
|
|
180
|
+
? `${impacted.length} impacted node(s) within ${maxDepth} reverse hop(s), showing ${shown.length}:`
|
|
181
|
+
: `Nothing else in the graph depends on the changed code within ${maxDepth} hop(s).`,
|
|
182
|
+
...shown.map((n) => ` [d${n.d}] ${relOf.get(n.id) || 'rel'} ${labelOf(g, n.id)} (deg ${n.deg}, ${pctStr(covOf(n.file))}) [${n.id}]`),
|
|
183
|
+
untestedHotspots.length ? `` : null,
|
|
184
|
+
untestedHotspots.length ? `Untested hotspots in the blast radius (<50% coverage, deg ≥5) — cover these before shipping:` : null,
|
|
185
|
+
...untestedHotspots.slice(0, 10).map((n) => ` ${labelOf(g, n.id)} (${pctStr(covOf(n.file))}, deg ${n.deg}) ${n.file}`),
|
|
186
|
+
``,
|
|
187
|
+
`Drill into any node with get_dependents / read_source; per-symbol coverage via coverage_map.`,
|
|
188
|
+
].filter((x) => x != null).join('\n')
|
|
189
|
+
}
|
package/src/mcp-rg.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readdirSync, existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import process from 'node:process'
|
|
6
|
+
|
|
7
|
+
const rgInInstall = (base) => [
|
|
8
|
+
join(base, 'resources', 'app', 'node_modules', '@vscode', 'ripgrep', 'bin', 'rg.exe'),
|
|
9
|
+
join(base, 'resources', 'app', 'node_modules', '@vscode', 'ripgrep-universal', 'bin', 'win32-x64', 'rg.exe'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
function editorRgCandidates() {
|
|
13
|
+
if (process.platform !== 'win32') return []
|
|
14
|
+
const local = process.env.LOCALAPPDATA || ''
|
|
15
|
+
const pf = process.env.PROGRAMFILES || ''
|
|
16
|
+
const roots = [local && join(local, 'Programs', 'Microsoft VS Code'), local && join(local, 'Programs', 'cursor'), pf && join(pf, 'Microsoft VS Code')].filter(Boolean)
|
|
17
|
+
const out = []
|
|
18
|
+
for (const root of roots) {
|
|
19
|
+
if (!existsSync(root)) continue
|
|
20
|
+
out.push(...rgInInstall(root))
|
|
21
|
+
try {
|
|
22
|
+
for (const d of readdirSync(root, {withFileTypes: true})) if (d.isDirectory()) out.push(...rgInInstall(join(root, d.name)))
|
|
23
|
+
} catch { /* optional editor install probe */ }
|
|
24
|
+
}
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createRgResolver(selfDir) {
|
|
29
|
+
let rgPath
|
|
30
|
+
return function resolveRg() {
|
|
31
|
+
if (rgPath !== undefined) return rgPath
|
|
32
|
+
rgPath = null
|
|
33
|
+
const env = (process.env.WEAVATRIX_RG_CMD || '').replace(/^"|"$/g, '')
|
|
34
|
+
if (env && existsSync(env)) return (rgPath = env)
|
|
35
|
+
try {
|
|
36
|
+
const {rgPath: bundledRg} = createRequire(import.meta.url)('@vscode/ripgrep')
|
|
37
|
+
if (bundledRg && existsSync(bundledRg)) return (rgPath = bundledRg)
|
|
38
|
+
} catch { /* optional bundled ripgrep module */ }
|
|
39
|
+
try {
|
|
40
|
+
const unpackedRg = join(selfDir, '..', '..', 'node_modules', '@vscode', 'ripgrep', 'bin', process.platform === 'win32' ? 'rg.exe' : 'rg')
|
|
41
|
+
if (existsSync(unpackedRg)) return (rgPath = unpackedRg)
|
|
42
|
+
} catch { /* optional packaged ripgrep path */ }
|
|
43
|
+
for (const c of editorRgCandidates()) if (existsSync(c)) return (rgPath = c)
|
|
44
|
+
try {
|
|
45
|
+
const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['rg'], {encoding: 'utf8'})
|
|
46
|
+
const p = probe.status === 0 ? probe.stdout.split(/\r?\n/)[0].trim() : ''
|
|
47
|
+
if (p && existsSync(p)) return (rgPath = p)
|
|
48
|
+
} catch { /* optional PATH probe */ }
|
|
49
|
+
return rgPath
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// weavatrix MCP server — the stdio entry point. The tool implementations live in src/mcp/*:
|
|
2
|
+
// graph-context.mjs — graph load + indexes, node resolution, staleness, raw-graph cache, diffs
|
|
3
|
+
// tools-graph.mjs — graph query tools tools-impact.mjs — dependents / diff / change impact
|
|
4
|
+
// tools-health.mjs — audit / clones / coverage / endpoints
|
|
5
|
+
// tools-actions.mjs — rebuild / open_repo / list_known_repos + the 'online' group
|
|
6
|
+
// catalog.mjs — tool catalog, capability filter, hot-reload loader
|
|
7
|
+
// Spawned by Claude Code / Codex as a plain Node child (node mcp-server.mjs <graph.json> <repoRoot>).
|
|
8
|
+
// Speaks newline-delimited JSON-RPC 2.0 over stdio (the MCP stdio transport).
|
|
9
|
+
//
|
|
10
|
+
// .mjs on purpose: guarantees ESM parsing regardless of the nearest package.json. The server itself
|
|
11
|
+
// resolves nothing from node_modules at runtime (ripgrep is probed, with a pure-Node fallback); only
|
|
12
|
+
// the graph BUILDER pulls in web-tree-sitter + its WASM grammars when a build is requested.
|
|
13
|
+
//
|
|
14
|
+
// STDOUT is the protocol channel — nothing but JSON-RPC frames may be written there. All diagnostics
|
|
15
|
+
// go to stderr. Two argv forms:
|
|
16
|
+
// weavatrix-mcp <repoRoot> [caps] — graph path derived from the standard layout
|
|
17
|
+
// weavatrix-mcp <graph.json> <repoRoot> [caps] — explicit graph file (classic form)
|
|
18
|
+
import {existsSync, statSync} from 'node:fs'
|
|
19
|
+
import {join, dirname} from 'node:path'
|
|
20
|
+
import {fileURLToPath} from 'node:url'
|
|
21
|
+
import process from 'node:process'
|
|
22
|
+
import {loadGraph} from './mcp/graph-context.mjs'
|
|
23
|
+
import {loadHotApi, HOT_FILES} from './mcp/catalog.mjs'
|
|
24
|
+
import {graphOutDirForRepo} from './graph/layout.js'
|
|
25
|
+
|
|
26
|
+
const SERVER_INFO = {name: 'weavatrix', version: '0.0.1'}
|
|
27
|
+
const DEFAULT_PROTOCOL = '2024-11-05'
|
|
28
|
+
const log = (...a) => process.stderr.write(`[weavatrix] ${a.join(' ')}\n`)
|
|
29
|
+
|
|
30
|
+
// argv[2] is a repo DIRECTORY in the npx form — derive the graph location from the standard layout;
|
|
31
|
+
// otherwise it is the graph.json path and the repo root follows it.
|
|
32
|
+
let GRAPH_PATH = process.argv[2]
|
|
33
|
+
let repoArg = process.argv[3]
|
|
34
|
+
// caps ABSENT (undefined) = no per-repo config → ALL tools. PRESENT (even the empty string, which a
|
|
35
|
+
// registration may pass for a zero-capability selection) = explicit set — see catalog.loadHotApi.
|
|
36
|
+
let CAPS_ARG = process.argv[4]
|
|
37
|
+
try {
|
|
38
|
+
if (GRAPH_PATH && statSync(GRAPH_PATH).isDirectory()) {
|
|
39
|
+
repoArg = GRAPH_PATH.replace(/[\\/]+$/, '')
|
|
40
|
+
CAPS_ARG = process.argv[3]
|
|
41
|
+
GRAPH_PATH = join(graphOutDirForRepo(repoArg), 'graph.json')
|
|
42
|
+
if (!existsSync(GRAPH_PATH)) log(`no graph built yet for ${repoArg} — ask the agent to call rebuild_graph (or open_repo) once; it builds into the standard weavatrix-graphs layout`)
|
|
43
|
+
}
|
|
44
|
+
} catch { /* argv[2] is not a directory → classic <graph.json> <repoRoot> form */ }
|
|
45
|
+
// repo source root for search_code / read_source; null → those tools degrade.
|
|
46
|
+
const REPO_ROOT = repoArg && existsSync(repoArg) ? repoArg : null
|
|
47
|
+
|
|
48
|
+
// ---- hot reload of tool implementations -----------------------------------------------------------
|
|
49
|
+
// Node caches modules at spawn, so edits to the tool code would otherwise be invisible until the MCP
|
|
50
|
+
// client reconnects. Before each tools/list|call we stat the hot-reloadable files (HOT_FILES); when
|
|
51
|
+
// any changed on disk we re-import them through catalog.loadHotApi with a cache-busting version and
|
|
52
|
+
// swap the tool table, then notify the client. The stdio shell, graph-context (its caches), and the
|
|
53
|
+
// analysis engines are NOT swapped — changing those still needs a reconnect.
|
|
54
|
+
const MCP_DIR = join(dirname(fileURLToPath(import.meta.url)), 'mcp')
|
|
55
|
+
function hotVersion() {
|
|
56
|
+
let v = 0
|
|
57
|
+
for (const f of HOT_FILES) {
|
|
58
|
+
try { const t = statSync(join(MCP_DIR, f)).mtimeMs; if (t > v) v = t } catch { /* missing file just doesn't bump the version */ }
|
|
59
|
+
}
|
|
60
|
+
return v
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function main() {
|
|
64
|
+
let api = await loadHotApi(0, CAPS_ARG)
|
|
65
|
+
let graph = null
|
|
66
|
+
let graphError = null
|
|
67
|
+
// ctx owns the CURRENT target: rebuild_graph reloads it, open_repo retargets graphPath/repoRoot
|
|
68
|
+
// at runtime. loadInto always reads ctx.graphPath so both paths share one loader.
|
|
69
|
+
const ctx = {graphPath: GRAPH_PATH, repoRoot: REPO_ROOT, reload: null}
|
|
70
|
+
const loadInto = () => { graph = loadGraph(ctx.graphPath); graphError = null; return graph }
|
|
71
|
+
ctx.reload = () => { api.resetStalenessCache(); try { return loadInto() } catch (e) { graphError = e.message; return null } }
|
|
72
|
+
try {
|
|
73
|
+
if (!ctx.graphPath) throw new Error('no graph.json path given (argv[2])')
|
|
74
|
+
loadInto()
|
|
75
|
+
log(`loaded ${graph.nodes.length} nodes / ${graph.links.length} edges from ${ctx.graphPath}`)
|
|
76
|
+
} catch (e) {
|
|
77
|
+
graphError = e.message
|
|
78
|
+
log(`failed to load graph: ${e.message}`)
|
|
79
|
+
}
|
|
80
|
+
log(`repo root: ${REPO_ROOT || '(none — source/action tools disabled)'}`)
|
|
81
|
+
log(`capabilities: ${api.caps ? [...api.caps].join(',') : 'all'} (${api.tools.length} tools)`)
|
|
82
|
+
|
|
83
|
+
let protocolVersion = DEFAULT_PROTOCOL
|
|
84
|
+
const send = (msg) => process.stdout.write(JSON.stringify(msg) + '\n')
|
|
85
|
+
const reply = (id, result) => send({jsonrpc: '2.0', id, result})
|
|
86
|
+
const fail = (id, code, message) => send({jsonrpc: '2.0', id, error: {code, message}})
|
|
87
|
+
|
|
88
|
+
let loadedVersion = hotVersion()
|
|
89
|
+
let lastFailedVersion = 0
|
|
90
|
+
const maybeHotReload = async () => {
|
|
91
|
+
const v = hotVersion()
|
|
92
|
+
if (v <= loadedVersion || v === lastFailedVersion) return
|
|
93
|
+
try {
|
|
94
|
+
api = await loadHotApi(v, CAPS_ARG)
|
|
95
|
+
loadedVersion = v
|
|
96
|
+
log(`hot-reloaded tool implementations from changed source (${api.tools.length} tools)`)
|
|
97
|
+
send({jsonrpc: '2.0', method: 'notifications/tools/list_changed'})
|
|
98
|
+
} catch (e) {
|
|
99
|
+
lastFailedVersion = v // remember the broken version so we don't retry it every call
|
|
100
|
+
log(`hot-reload failed, keeping current tools: ${e.message}`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const handle = async (msg) => {
|
|
105
|
+
const {id, method, params} = msg
|
|
106
|
+
const isNotification = id === undefined || id === null
|
|
107
|
+
if (method === 'initialize') {
|
|
108
|
+
if (params?.protocolVersion) protocolVersion = String(params.protocolVersion)
|
|
109
|
+
return reply(id, {protocolVersion, capabilities: {tools: {listChanged: true}}, serverInfo: SERVER_INFO})
|
|
110
|
+
}
|
|
111
|
+
if (method === 'notifications/initialized' || method === 'initialized') return
|
|
112
|
+
if (method === 'ping') return reply(id, {})
|
|
113
|
+
if (method === 'tools/list') {
|
|
114
|
+
await maybeHotReload()
|
|
115
|
+
return reply(id, {tools: api.tools.map(({name, description, inputSchema}) => ({name, description, inputSchema}))})
|
|
116
|
+
}
|
|
117
|
+
if (method === 'tools/call') {
|
|
118
|
+
await maybeHotReload()
|
|
119
|
+
const tool = api.byName.get(params?.name)
|
|
120
|
+
if (!tool) return reply(id, {content: [{type: 'text', text: `Unknown tool: ${params?.name}`}], isError: true})
|
|
121
|
+
// action tools (rebuild_graph) don't need a currently-loaded graph; read tools do
|
|
122
|
+
if (!graph && tool.cap !== 'build') return reply(id, {content: [{type: 'text', text: `Graph unavailable: ${graphError}`}], isError: true})
|
|
123
|
+
try {
|
|
124
|
+
let text = String(await tool.run(graph, params?.arguments || {}, ctx))
|
|
125
|
+
// Graph answers silently reflect a point-in-time build — surface staleness on every graph tool.
|
|
126
|
+
if (tool.cap === 'graph') {
|
|
127
|
+
const warn = api.stalenessLine(ctx)
|
|
128
|
+
if (warn) text += `\n\n${warn}`
|
|
129
|
+
}
|
|
130
|
+
return reply(id, {content: [{type: 'text', text}]})
|
|
131
|
+
} catch (e) {
|
|
132
|
+
log(`tool ${params?.name} threw: ${e.stack || e.message}`)
|
|
133
|
+
return reply(id, {content: [{type: 'text', text: `Tool error: ${e.message}`}], isError: true})
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!isNotification) return fail(id, -32601, `Method not found: ${method}`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let buf = ''
|
|
140
|
+
process.stdin.setEncoding('utf8')
|
|
141
|
+
process.stdin.on('data', (chunk) => {
|
|
142
|
+
buf += chunk
|
|
143
|
+
let nl
|
|
144
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
145
|
+
const line = buf.slice(0, nl).trim()
|
|
146
|
+
buf = buf.slice(nl + 1)
|
|
147
|
+
if (!line) continue
|
|
148
|
+
let msg
|
|
149
|
+
try {
|
|
150
|
+
msg = JSON.parse(line)
|
|
151
|
+
} catch {
|
|
152
|
+
log(`bad JSON line: ${line.slice(0, 120)}`)
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
// handle is async (rebuild_graph awaits a build) → catch rejections, not just sync throws
|
|
156
|
+
Promise.resolve().then(() => handle(msg)).catch((e) => {
|
|
157
|
+
log(`handler error: ${e.stack || e.message}`)
|
|
158
|
+
if (msg?.id != null) fail(msg.id, -32603, `Internal error: ${e.message}`)
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
process.stdin.on('end', () => process.exit(0))
|
|
163
|
+
log('ready')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Guard: hot-reload re-imports of tool modules never touch this entry, but keep the start guard so a
|
|
167
|
+
// stray re-import of the entry itself can never spawn a second stdio loop.
|
|
168
|
+
if (!globalThis.__weavatrixMcpStarted) {
|
|
169
|
+
globalThis.__weavatrixMcpStarted = true
|
|
170
|
+
main()
|
|
171
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import { extname, join, relative } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const SEARCH_SKIP = new Set(['.git', 'node_modules', 'dist', 'build', 'out', '.next', 'coverage', 'vendor', '.venv', 'venv', 'env', 'target', '__pycache__', '.idea', '.vscode', '.cache', 'bin', 'obj', 'weavatrix-graphs'])
|
|
6
|
+
const BINARY_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.pdf', '.zip', '.gz', '.tar', '.exe', '.dll', '.so', '.dylib', '.woff', '.woff2', '.ttf', '.eot', '.mp4', '.mp3', '.wasm', '.class', '.jar', '.node', '.bin'])
|
|
7
|
+
const MAX_SEARCH_FILE_BYTES = 1024 * 1024
|
|
8
|
+
|
|
9
|
+
function rgSearch(repoRoot, resolveRg, query, { isRegex, glob, maxResults }) {
|
|
10
|
+
const rg = resolveRg()
|
|
11
|
+
if (!rg) return null
|
|
12
|
+
const args = ['--line-number', '--no-heading', '--color', 'never', '--max-columns', '400', '-m', '30', '-i']
|
|
13
|
+
if (!isRegex) args.push('--fixed-strings')
|
|
14
|
+
if (glob) args.push('-g', glob)
|
|
15
|
+
args.push('--', query, repoRoot)
|
|
16
|
+
const res = spawnSync(rg, args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: 15000 })
|
|
17
|
+
if (res.status !== 0 && res.status !== 1) return null
|
|
18
|
+
const out = []
|
|
19
|
+
for (const line of (res.stdout || '').split(/\r?\n/)) {
|
|
20
|
+
const match = line.match(/^(.*?):(\d+):(.*)$/)
|
|
21
|
+
if (!match) continue
|
|
22
|
+
out.push({
|
|
23
|
+
file: relative(repoRoot, match[1]).replace(/\\/g, '/'),
|
|
24
|
+
line: Number(match[2]),
|
|
25
|
+
text: match[3].trim().slice(0, 300),
|
|
26
|
+
})
|
|
27
|
+
if (out.length >= maxResults) break
|
|
28
|
+
}
|
|
29
|
+
return out
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function globToRe(glob) {
|
|
33
|
+
if (!glob) return null
|
|
34
|
+
try {
|
|
35
|
+
return new RegExp(glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.'), 'i')
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function nodeGrep(repoRoot, query, { isRegex, glob, maxResults }) {
|
|
42
|
+
const out = []
|
|
43
|
+
let re = null
|
|
44
|
+
if (isRegex) {
|
|
45
|
+
try { re = new RegExp(query, 'i') } catch { return out }
|
|
46
|
+
}
|
|
47
|
+
const q = String(query).toLowerCase()
|
|
48
|
+
const globRe = globToRe(glob)
|
|
49
|
+
const stack = [repoRoot]
|
|
50
|
+
let filesScanned = 0
|
|
51
|
+
while (stack.length && out.length < maxResults) {
|
|
52
|
+
const dir = stack.pop()
|
|
53
|
+
let entries
|
|
54
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { continue }
|
|
55
|
+
for (const ent of entries) {
|
|
56
|
+
if (out.length >= maxResults) break
|
|
57
|
+
const full = join(dir, ent.name)
|
|
58
|
+
if (ent.isDirectory()) {
|
|
59
|
+
if (!SEARCH_SKIP.has(ent.name)) stack.push(full)
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
if (!ent.isFile() || BINARY_EXT.has(extname(ent.name).toLowerCase())) continue
|
|
63
|
+
const rel = relative(repoRoot, full).replace(/\\/g, '/')
|
|
64
|
+
if (globRe && !globRe.test(rel)) continue
|
|
65
|
+
let st
|
|
66
|
+
try { st = statSync(full) } catch { continue }
|
|
67
|
+
if (st.size > MAX_SEARCH_FILE_BYTES) continue
|
|
68
|
+
if (++filesScanned > 20000) return out
|
|
69
|
+
let text
|
|
70
|
+
try { text = readFileSync(full, 'utf8') } catch { continue }
|
|
71
|
+
if (text.indexOf('\0') >= 0) continue
|
|
72
|
+
const lines = text.split(/\r?\n/)
|
|
73
|
+
for (let i = 0; i < lines.length; i++) {
|
|
74
|
+
const ln = lines[i]
|
|
75
|
+
if (re ? re.test(ln) : ln.toLowerCase().includes(q)) {
|
|
76
|
+
out.push({ file: rel, line: i + 1, text: ln.trim().slice(0, 300) })
|
|
77
|
+
if (out.length >= maxResults) break
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function searchCode({ repoRoot, resolveRg }, { query, is_regex = false, max_results = 40, glob } = {}) {
|
|
86
|
+
if (!query) return 'Provide a "query" string.'
|
|
87
|
+
if (!repoRoot || !existsSync(repoRoot)) return 'Source search unavailable: repo root not provided to this MCP server.'
|
|
88
|
+
const max = Math.max(1, Math.min(200, Number(max_results) || 40))
|
|
89
|
+
const opts = { isRegex: !!is_regex, glob: glob || null, maxResults: max }
|
|
90
|
+
let matches = rgSearch(repoRoot, resolveRg, query, opts)
|
|
91
|
+
const engine = matches ? 'ripgrep' : 'node'
|
|
92
|
+
if (!matches) matches = nodeGrep(repoRoot, query, opts)
|
|
93
|
+
const what = is_regex ? `/${query}/i` : `"${query}"`
|
|
94
|
+
if (!matches.length) return `No matches for ${what}${glob ? ` in ${glob}` : ''}.`
|
|
95
|
+
return [
|
|
96
|
+
`${matches.length} match${matches.length === 1 ? '' : 'es'} for ${what}${glob ? ` (glob ${glob})` : ''} [${engine}]:`,
|
|
97
|
+
...matches.map((m) => ` ${m.file}:${m.line}: ${m.text}`),
|
|
98
|
+
].join('\n')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function readSource({ repoRoot, resolveNode, isSymbol }, g, { label, path, start_line, before = 3, after = 40 } = {}) {
|
|
102
|
+
if (!repoRoot || !existsSync(repoRoot)) return 'Source read unavailable: repo root not provided to this MCP server.'
|
|
103
|
+
let file = null
|
|
104
|
+
let focusLine = null
|
|
105
|
+
let title
|
|
106
|
+
// label resolves first; a path alongside it narrows rather than overrides — the node's focus line
|
|
107
|
+
// survives when both point at the same file (label+path used to silently return the file head).
|
|
108
|
+
const n = label && g ? resolveNode(g, label) : null
|
|
109
|
+
if (n) {
|
|
110
|
+
const nodeFile = String(n.source_file || (isSymbol(n.id) ? String(n.id).split('#')[0] : n.id))
|
|
111
|
+
if (!path || nodeFile.replace(/\\/g, '/') === String(path).replace(/\\/g, '/')) {
|
|
112
|
+
file = nodeFile
|
|
113
|
+
const match = String(n.source_location || '').match(/L(\d+)/)
|
|
114
|
+
focusLine = match ? Number(match[1]) : null
|
|
115
|
+
title = `${n.label ?? n.id} [${n.id}]`
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!file) {
|
|
119
|
+
if (!path) return label ? `No node found matching "${label}".` : 'Provide "label" or "path".'
|
|
120
|
+
file = String(path)
|
|
121
|
+
title = file
|
|
122
|
+
}
|
|
123
|
+
// explicit anchor wins: window = start_line-before .. start_line+after (how a path read escapes the file head)
|
|
124
|
+
if (start_line != null && Number(start_line) > 0) focusLine = Math.floor(Number(start_line))
|
|
125
|
+
const abs = join(repoRoot, file)
|
|
126
|
+
if (!existsSync(abs)) return `File not found: ${file}`
|
|
127
|
+
let text
|
|
128
|
+
try { text = readFileSync(abs, 'utf8') } catch (e) { return `Could not read ${file}: ${e.message}` }
|
|
129
|
+
const lines = text.split(/\r?\n/)
|
|
130
|
+
if (focusLine) focusLine = Math.min(focusLine, lines.length) // an anchor past EOF shows the tail, not nothing
|
|
131
|
+
const b = Math.max(0, Number(before) || 0)
|
|
132
|
+
const a = Math.max(1, Number(after) || 40)
|
|
133
|
+
const start = focusLine ? Math.max(1, focusLine - b) : 1
|
|
134
|
+
const end = focusLine ? Math.min(lines.length, focusLine + a) : Math.min(lines.length, 1 + b + a)
|
|
135
|
+
const width = String(end).length
|
|
136
|
+
const body = []
|
|
137
|
+
for (let i = start; i <= end; i++) body.push(`${focusLine === i ? '>' : ' '}${String(i).padStart(width)} ${lines[i - 1] ?? ''}`)
|
|
138
|
+
return [`Source: ${title}`, `${file} (lines ${start}-${end} of ${lines.length})`, '', ...body].join('\n')
|
|
139
|
+
}
|
package/src/process.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Subprocess runner shared by the search engines and security sweeps.
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
// Windows: .cmd/.ps1 shims (npx, etc.) can't be spawned directly by Node — they need a
|
|
5
|
+
// shell. With shell:true Node does NOT quote args, so we build a quoted command line ourselves
|
|
6
|
+
// (repo paths contain spaces). POSIX uses the normal argv array, no shell.
|
|
7
|
+
export function winQuote(value) {
|
|
8
|
+
const s = String(value);
|
|
9
|
+
return /[\s&()[\]{}^=;!'+,`~|<>"]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function runCommand(command, args = [], options = {}) {
|
|
13
|
+
// Windows: .cmd/.ps1/bare-name shims (npx, …) need a cmd.exe shell. But a real .exe
|
|
14
|
+
// (rg.exe, where.exe) must be spawned DIRECTLY — wrapping an .exe in the shell breaks stdin
|
|
15
|
+
// piping, which surfaced as "command returned no output".
|
|
16
|
+
const needsShell = process.platform === "win32" && !/\.exe$/i.test(command);
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const child = needsShell
|
|
19
|
+
? spawn([command, ...args].map(winQuote).join(" "), [], {
|
|
20
|
+
cwd: options.cwd || undefined,
|
|
21
|
+
env: { ...process.env, ...(options.env || {}) },
|
|
22
|
+
shell: true,
|
|
23
|
+
windowsHide: true
|
|
24
|
+
})
|
|
25
|
+
: spawn(command, args, {
|
|
26
|
+
cwd: options.cwd || undefined,
|
|
27
|
+
env: { ...process.env, ...(options.env || {}) },
|
|
28
|
+
windowsHide: true
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let stdout = "";
|
|
32
|
+
let stderr = "";
|
|
33
|
+
let timedOut = false;
|
|
34
|
+
let settled = false;
|
|
35
|
+
let hardTimer = null;
|
|
36
|
+
const finish = (fn) => {
|
|
37
|
+
if (settled) return;
|
|
38
|
+
settled = true;
|
|
39
|
+
if (timer) clearTimeout(timer);
|
|
40
|
+
if (hardTimer) clearTimeout(hardTimer);
|
|
41
|
+
fn();
|
|
42
|
+
};
|
|
43
|
+
// On timeout, kill the WHOLE process tree. With shell:true the child is cmd.exe; child.kill() would only
|
|
44
|
+
// kill the shell, leaving npx→node→tool running and the stdio pipes open → 'close' never fires → the
|
|
45
|
+
// caller hangs forever. taskkill /T kills the tree; SIGKILL group on POSIX.
|
|
46
|
+
const killTree = () => {
|
|
47
|
+
if (process.platform === "win32" && child.pid) {
|
|
48
|
+
try {
|
|
49
|
+
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true });
|
|
50
|
+
} catch {
|
|
51
|
+
try { child.kill(); } catch { /* process may already be gone */ }
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
try { child.kill("SIGKILL"); } catch { /* process may already be gone */ }
|
|
55
|
+
}
|
|
56
|
+
// hard fallback: if 'close' still never fires after the kill, settle anyway so the caller can't hang
|
|
57
|
+
hardTimer = setTimeout(() => finish(() => reject(new Error("Command timed out"))), 4000);
|
|
58
|
+
};
|
|
59
|
+
const timer = options.timeoutMs
|
|
60
|
+
? setTimeout(() => {
|
|
61
|
+
timedOut = true;
|
|
62
|
+
killTree();
|
|
63
|
+
}, options.timeoutMs)
|
|
64
|
+
: null;
|
|
65
|
+
|
|
66
|
+
child.stdout?.on("data", (chunk) => (stdout += chunk));
|
|
67
|
+
child.stderr?.on("data", (chunk) => (stderr += chunk));
|
|
68
|
+
if (options.stdin) {
|
|
69
|
+
child.stdin.write(options.stdin);
|
|
70
|
+
child.stdin.end();
|
|
71
|
+
} else {
|
|
72
|
+
child.stdin?.end();
|
|
73
|
+
}
|
|
74
|
+
child.on("error", (error) => finish(() => reject(error)));
|
|
75
|
+
child.on("close", (code) => finish(() => (timedOut ? reject(new Error("Command timed out")) : resolve({ stdout, stderr, exitCode: code }))));
|
|
76
|
+
});
|
|
77
|
+
}
|