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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/bin/weavatrix-mcp.mjs +6 -0
  4. package/package.json +50 -0
  5. package/skill/SKILL.md +56 -0
  6. package/src/analysis/coverage-reports.js +232 -0
  7. package/src/analysis/dead-check.js +136 -0
  8. package/src/analysis/dep-check.js +310 -0
  9. package/src/analysis/dep-rules.js +221 -0
  10. package/src/analysis/duplicates-worker.js +11 -0
  11. package/src/analysis/duplicates.compute.js +215 -0
  12. package/src/analysis/duplicates.js +15 -0
  13. package/src/analysis/duplicates.run.js +51 -0
  14. package/src/analysis/duplicates.tokenize.js +182 -0
  15. package/src/analysis/endpoints.js +156 -0
  16. package/src/analysis/findings.js +52 -0
  17. package/src/analysis/graph-analysis.aggregate.js +280 -0
  18. package/src/analysis/graph-analysis.js +7 -0
  19. package/src/analysis/graph-analysis.refs.js +146 -0
  20. package/src/analysis/graph-analysis.summaries.js +62 -0
  21. package/src/analysis/internal-audit.collect.js +117 -0
  22. package/src/analysis/internal-audit.js +9 -0
  23. package/src/analysis/internal-audit.reach.js +47 -0
  24. package/src/analysis/internal-audit.run.js +189 -0
  25. package/src/analysis/manifests.js +169 -0
  26. package/src/analysis/source-complexity.ast.js +97 -0
  27. package/src/analysis/source-complexity.constants.js +55 -0
  28. package/src/analysis/source-complexity.js +14 -0
  29. package/src/analysis/source-complexity.report.js +76 -0
  30. package/src/analysis/source-complexity.walk.js +174 -0
  31. package/src/build-graph.js +102 -0
  32. package/src/config.js +5 -0
  33. package/src/graph/build-worker.js +30 -0
  34. package/src/graph/builder/lang-csharp.js +40 -0
  35. package/src/graph/builder/lang-css.js +29 -0
  36. package/src/graph/builder/lang-go.js +53 -0
  37. package/src/graph/builder/lang-html.js +29 -0
  38. package/src/graph/builder/lang-java.js +29 -0
  39. package/src/graph/builder/lang-js.js +168 -0
  40. package/src/graph/builder/lang-python.js +78 -0
  41. package/src/graph/builder/lang-rust.js +45 -0
  42. package/src/graph/builder/spec-pkg.js +87 -0
  43. package/src/graph/graph-filter.js +44 -0
  44. package/src/graph/internal-builder.build.js +217 -0
  45. package/src/graph/internal-builder.js +12 -0
  46. package/src/graph/internal-builder.langs.js +86 -0
  47. package/src/graph/internal-builder.resolvers.js +116 -0
  48. package/src/graph/layout.js +35 -0
  49. package/src/infra/infra-items.js +255 -0
  50. package/src/infra/infra-registry.js +184 -0
  51. package/src/infra/infra.detect.js +119 -0
  52. package/src/infra/infra.js +38 -0
  53. package/src/infra/infra.match.js +102 -0
  54. package/src/infra/infra.scan.js +93 -0
  55. package/src/mcp/catalog.mjs +68 -0
  56. package/src/mcp/graph-context.mjs +276 -0
  57. package/src/mcp/tools-actions.mjs +132 -0
  58. package/src/mcp/tools-graph.mjs +274 -0
  59. package/src/mcp/tools-health.mjs +209 -0
  60. package/src/mcp/tools-impact.mjs +189 -0
  61. package/src/mcp-rg.mjs +51 -0
  62. package/src/mcp-server.mjs +171 -0
  63. package/src/mcp-source-tools.mjs +139 -0
  64. package/src/process.js +77 -0
  65. package/src/scan/discover.inventory.js +78 -0
  66. package/src/scan/discover.js +5 -0
  67. package/src/scan/discover.list.js +79 -0
  68. package/src/scan/discover.stack.js +227 -0
  69. package/src/scan/search.core.js +24 -0
  70. package/src/scan/search.git.js +102 -0
  71. package/src/scan/search.js +9 -0
  72. package/src/scan/search.node.js +83 -0
  73. package/src/scan/search.preview.js +49 -0
  74. package/src/scan/search.rg.js +145 -0
  75. package/src/security/advisory-store.js +177 -0
  76. package/src/security/installed.js +247 -0
  77. package/src/security/malware-file-heuristics.js +121 -0
  78. package/src/security/malware-heuristics.exclusions.js +134 -0
  79. package/src/security/malware-heuristics.js +15 -0
  80. package/src/security/malware-heuristics.roots.js +142 -0
  81. package/src/security/malware-heuristics.scan.js +87 -0
  82. package/src/security/malware-heuristics.sweep.js +122 -0
  83. package/src/security/malware-scoring.js +84 -0
  84. package/src/security/match.js +85 -0
  85. package/src/security/registry-sig.classify.js +136 -0
  86. package/src/security/registry-sig.js +18 -0
  87. package/src/security/registry-sig.rules.js +188 -0
  88. package/src/security/typosquat.js +72 -0
  89. 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
+ }