weavatrix 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -21
- package/SECURITY.md +31 -0
- package/package.json +16 -4
- package/skill/SKILL.md +69 -12
- package/src/analysis/coverage-reports.js +14 -11
- package/src/analysis/dead-check.js +87 -6
- package/src/analysis/dep-check.js +84 -8
- package/src/analysis/dep-rules.js +74 -8
- package/src/analysis/duplicates.compute.js +215 -215
- package/src/analysis/duplicates.js +15 -15
- package/src/analysis/duplicates.run.js +52 -51
- package/src/analysis/duplicates.tokenize.js +182 -182
- package/src/analysis/endpoints.js +50 -5
- package/src/analysis/graph-analysis.aggregate.js +16 -4
- package/src/analysis/internal-audit.collect.js +154 -31
- package/src/analysis/internal-audit.reach.js +52 -11
- package/src/analysis/internal-audit.run.js +72 -21
- package/src/build-graph.js +2 -1
- package/src/child-env.js +7 -0
- package/src/graph/builder/lang-js.js +66 -23
- package/src/graph/internal-builder.build.js +48 -10
- package/src/graph/internal-builder.langs.js +49 -3
- package/src/graph/internal-builder.resolvers.js +96 -20
- package/src/mcp/catalog.mjs +10 -8
- package/src/mcp/graph-context.mjs +107 -17
- package/src/mcp/sync-payload.mjs +110 -0
- package/src/mcp/tools-actions.mjs +42 -18
- package/src/mcp/tools-graph.mjs +46 -12
- package/src/mcp/tools-health.mjs +42 -18
- package/src/mcp/tools-impact.mjs +55 -43
- package/src/mcp-rg.mjs +2 -1
- package/src/mcp-server.mjs +25 -16
- package/src/mcp-source-tools.mjs +16 -5
- package/src/process.js +4 -3
- package/src/repo-path.js +53 -0
- package/src/security/advisory-store.js +51 -7
- package/src/security/installed.js +44 -31
- package/src/security/malware-heuristics.exclusions.js +134 -134
- package/src/security/malware-heuristics.js +15 -15
- package/src/security/malware-heuristics.roots.js +142 -142
- package/src/security/malware-heuristics.scan.js +85 -85
- package/src/security/malware-heuristics.sweep.js +122 -122
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
import {readFileSync, statSync} from 'node:fs'
|
|
6
6
|
import {join} from 'node:path'
|
|
7
7
|
import {spawnSync} from 'node:child_process'
|
|
8
|
+
import {childProcessEnv} from '../child-env.js'
|
|
8
9
|
import {buildFileImportGraph, findSccs} from '../analysis/dep-rules.js'
|
|
10
|
+
import {resolveRepoPath} from '../repo-path.js'
|
|
9
11
|
|
|
10
12
|
// ---- graph load + indexes -----------------------------------------------------------------------
|
|
11
13
|
export function loadGraph(path) {
|
|
@@ -31,10 +33,21 @@ export function loadGraph(path) {
|
|
|
31
33
|
if (!e || e.source == null || e.target == null) continue
|
|
32
34
|
const s = String(e.source)
|
|
33
35
|
const t = String(e.target)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
const metadata = {
|
|
37
|
+
relation: e.relation,
|
|
38
|
+
confidence: e.confidence,
|
|
39
|
+
...(e.typeOnly === true ? {typeOnly: true} : {}),
|
|
40
|
+
...(Number.isInteger(e.line) ? {line: e.line} : {}),
|
|
41
|
+
...(typeof e.specifier === 'string' ? {specifier: e.specifier} : {}),
|
|
42
|
+
}
|
|
43
|
+
push(out, s, {id: t, ...metadata})
|
|
44
|
+
push(inn, t, {id: s, ...metadata})
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
nodes, links, byId, byLabel, out, inn,
|
|
48
|
+
repoBoundaryV: Number(raw.repoBoundaryV) || 0,
|
|
49
|
+
edgeTypesV: Number(raw.edgeTypesV) || 0,
|
|
36
50
|
}
|
|
37
|
-
return {nodes, links, byId, byLabel, out, inn}
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
export const isSymbol = (id) => String(id).includes('#')
|
|
@@ -47,6 +60,7 @@ export const labelOf = (g, id) => {
|
|
|
47
60
|
// "connectivity" degree ignores structural `contains` (parent→symbol nesting) so god_nodes surfaces real
|
|
48
61
|
// call/import/reference hubs, not just files that hold many symbols.
|
|
49
62
|
export const connList = (list) => (list || []).filter((e) => e.relation !== 'contains')
|
|
63
|
+
export const uniqueConnCount = (list) => new Set(connList(list).map((e) => String(e.id))).size
|
|
50
64
|
|
|
51
65
|
// Resolve a user-supplied "label" to a node: exact id → exact label → ci label → substring (best degree).
|
|
52
66
|
// Returns {node, matches, alternates} so callers can disclose ambiguity instead of silently picking one.
|
|
@@ -123,13 +137,13 @@ export function graphStaleness(ctx) {
|
|
|
123
137
|
try { info.builtAt = statSync(ctx.graphPath).mtime } catch { /* no graph file — nothing to report */ }
|
|
124
138
|
if (ctx.repoRoot && info.builtAt) {
|
|
125
139
|
try {
|
|
126
|
-
const head = spawnSync('git', ['-C', ctx.repoRoot, 'log', '-1', '--format=%cI'], {encoding: 'utf8', timeout: 4000})
|
|
140
|
+
const head = spawnSync('git', ['-C', ctx.repoRoot, 'log', '-1', '--format=%cI'], {encoding: 'utf8', timeout: 4000, env: childProcessEnv()})
|
|
127
141
|
const iso = (head.stdout || '').trim()
|
|
128
142
|
if (head.status === 0 && iso) {
|
|
129
143
|
info.headAt = new Date(iso)
|
|
130
144
|
if (info.headAt > info.builtAt) {
|
|
131
145
|
info.stale = true
|
|
132
|
-
const cnt = spawnSync('git', ['-C', ctx.repoRoot, 'rev-list', '--count', `--since=${info.builtAt.toISOString()}`, 'HEAD'], {encoding: 'utf8', timeout: 4000})
|
|
146
|
+
const cnt = spawnSync('git', ['-C', ctx.repoRoot, 'rev-list', '--count', `--since=${info.builtAt.toISOString()}`, 'HEAD'], {encoding: 'utf8', timeout: 4000, env: childProcessEnv()})
|
|
133
147
|
if (cnt.status === 0) info.behind = Number(cnt.stdout.trim()) || null
|
|
134
148
|
}
|
|
135
149
|
}
|
|
@@ -138,7 +152,7 @@ export function graphStaleness(ctx) {
|
|
|
138
152
|
// they edit, then re-query). Count dirty files actually TOUCHED after the build — a dirty file
|
|
139
153
|
// older than the graph was already part of it.
|
|
140
154
|
try {
|
|
141
|
-
const st = spawnSync('git', ['-C', ctx.repoRoot, 'status', '--porcelain'], {encoding: 'utf8', timeout: 4000})
|
|
155
|
+
const st = spawnSync('git', ['-C', ctx.repoRoot, 'status', '--porcelain'], {encoding: 'utf8', timeout: 4000, env: childProcessEnv()})
|
|
142
156
|
if (st.status === 0) {
|
|
143
157
|
let newer = 0
|
|
144
158
|
for (const ln of String(st.stdout || '').split(/\r?\n/).filter(Boolean).slice(0, 200)) {
|
|
@@ -170,7 +184,8 @@ export function fileStalenessNote(ctx, sourceFile) {
|
|
|
170
184
|
const s = graphStaleness(ctx)
|
|
171
185
|
if (!s.builtAt) return null
|
|
172
186
|
try {
|
|
173
|
-
|
|
187
|
+
const resolved = resolveRepoPath(ctx.repoRoot, String(sourceFile))
|
|
188
|
+
if (resolved.ok && statSync(resolved.path).mtime > s.builtAt) {
|
|
174
189
|
return `Note: ${sourceFile} changed after the graph was built — line numbers above may have drifted (rebuild_graph refreshes them).`
|
|
175
190
|
}
|
|
176
191
|
} catch { /* file gone — the read tools will surface that themselves */ }
|
|
@@ -203,27 +218,43 @@ const folderOfFile = (file) => {
|
|
|
203
218
|
|
|
204
219
|
// Works on anything with {nodes, links}: the raw graph.json shape and the loadGraph struct alike.
|
|
205
220
|
export function diffGraphs(oldG, newG) {
|
|
221
|
+
const oldEdgeTypesV = Number(oldG.edgeTypesV) || 0
|
|
222
|
+
const newEdgeTypesV = Number(newG.edgeTypesV) || 0
|
|
223
|
+
const schemaMigration = oldEdgeTypesV !== newEdgeTypesV
|
|
206
224
|
const nodeIds = (graph) => new Set((graph.nodes || []).map((n) => String(n.id)))
|
|
207
225
|
const oldNodes = nodeIds(oldG)
|
|
208
226
|
const newNodes = nodeIds(newG)
|
|
209
227
|
|
|
210
|
-
const edgeKey = (l) => `${edgeEndpoint(l.source)}|${l.relation || ''}|${edgeEndpoint(l.target)}`
|
|
228
|
+
const edgeKey = (l) => `${edgeEndpoint(l.source)}|${l.relation || ''}|${schemaMigration ? 'untyped' : l.typeOnly === true ? 'type' : 'runtime'}|${edgeEndpoint(l.target)}`
|
|
211
229
|
const edgeSet = (graph) => new Set((graph.links || []).map(edgeKey))
|
|
212
230
|
const oldEdges = edgeSet(oldG)
|
|
213
231
|
const newEdges = edgeSet(newG)
|
|
214
232
|
|
|
215
|
-
const moduleEdges = (graph) => {
|
|
233
|
+
const moduleEdges = (graph, typeOnly) => {
|
|
216
234
|
const set = new Set()
|
|
217
235
|
for (const l of graph.links || []) {
|
|
218
236
|
if (l.relation === 'contains') continue
|
|
237
|
+
if ((l.typeOnly === true) !== typeOnly) continue
|
|
219
238
|
const a = folderOfFile(fileOfId(edgeEndpoint(l.source)))
|
|
220
239
|
const b = folderOfFile(fileOfId(edgeEndpoint(l.target)))
|
|
221
240
|
if (a !== b) set.add(`${a} → ${b}`)
|
|
222
241
|
}
|
|
223
242
|
return set
|
|
224
243
|
}
|
|
225
|
-
const
|
|
226
|
-
|
|
244
|
+
const combinedModuleEdges = (graph) => {
|
|
245
|
+
const set = new Set()
|
|
246
|
+
for (const l of graph.links || []) {
|
|
247
|
+
if (l.relation === 'contains') continue
|
|
248
|
+
const a = folderOfFile(fileOfId(edgeEndpoint(l.source)))
|
|
249
|
+
const b = folderOfFile(fileOfId(edgeEndpoint(l.target)))
|
|
250
|
+
if (a !== b) set.add(`${a} → ${b}`)
|
|
251
|
+
}
|
|
252
|
+
return set
|
|
253
|
+
}
|
|
254
|
+
const oldMods = schemaMigration ? combinedModuleEdges(oldG) : moduleEdges(oldG, false)
|
|
255
|
+
const newMods = schemaMigration ? combinedModuleEdges(newG) : moduleEdges(newG, false)
|
|
256
|
+
const oldTypeMods = schemaMigration ? new Set() : moduleEdges(oldG, true)
|
|
257
|
+
const newTypeMods = schemaMigration ? new Set() : moduleEdges(newG, true)
|
|
227
258
|
|
|
228
259
|
const incoming = (graph) => {
|
|
229
260
|
const m = new Map()
|
|
@@ -237,9 +268,47 @@ export function diffGraphs(oldG, newG) {
|
|
|
237
268
|
const oldIn = incoming(oldG)
|
|
238
269
|
const newIn = incoming(newG)
|
|
239
270
|
|
|
240
|
-
const cycles = (graph) => {
|
|
271
|
+
const cycles = (graph, includeTypeOnly) => {
|
|
272
|
+
try {
|
|
273
|
+
const sccs = findSccs(buildFileImportGraph(graph, {includeTypeOnly}).adj)
|
|
274
|
+
.map((members) => members.map(String).sort())
|
|
275
|
+
.sort((a, b) => b.length - a.length || a.join('\n').localeCompare(b.join('\n')))
|
|
276
|
+
return {
|
|
277
|
+
count: sccs.length,
|
|
278
|
+
largest: sccs[0]?.length || 0,
|
|
279
|
+
groups: sccs,
|
|
280
|
+
}
|
|
281
|
+
} catch {
|
|
282
|
+
return null
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const cycleDelta = (before, after) => {
|
|
286
|
+
if (!before || !after) return null
|
|
287
|
+
const key = (group) => group.join('|')
|
|
288
|
+
const beforeKeys = new Set(before.groups.map(key))
|
|
289
|
+
const afterKeys = new Set(after.groups.map(key))
|
|
290
|
+
const overlap = (a, b) => {
|
|
291
|
+
const bSet = new Set(b)
|
|
292
|
+
return a.reduce((n, member) => n + (bSet.has(member) ? 1 : 0), 0)
|
|
293
|
+
}
|
|
294
|
+
const unmatchedBefore = before.groups.filter((group) => !afterKeys.has(key(group)))
|
|
295
|
+
const unmatchedAfter = after.groups.filter((group) => !beforeKeys.has(key(group)))
|
|
296
|
+
const changed = unmatchedAfter.filter((group) => unmatchedBefore.some((old) => overlap(group, old) >= 2))
|
|
297
|
+
const introduced = unmatchedAfter.filter((group) => !unmatchedBefore.some((old) => overlap(group, old) >= 2))
|
|
298
|
+
const resolved = unmatchedBefore.filter((group) => !unmatchedAfter.some((next) => overlap(group, next) >= 2))
|
|
299
|
+
return {
|
|
300
|
+
before: before.count,
|
|
301
|
+
after: after.count,
|
|
302
|
+
largestBefore: before.largest,
|
|
303
|
+
largestAfter: after.largest,
|
|
304
|
+
introduced: introduced.map(key),
|
|
305
|
+
resolved: resolved.map(key),
|
|
306
|
+
membershipChanged: changed.length,
|
|
307
|
+
}
|
|
308
|
+
}
|
|
241
309
|
|
|
242
310
|
return {
|
|
311
|
+
schemaMigration: schemaMigration ? {from: oldEdgeTypesV, to: newEdgeTypesV} : null,
|
|
243
312
|
nodes: {
|
|
244
313
|
added: [...newNodes].filter((id) => !oldNodes.has(id)),
|
|
245
314
|
removed: [...oldNodes].filter((id) => !newNodes.has(id))
|
|
@@ -250,25 +319,46 @@ export function diffGraphs(oldG, newG) {
|
|
|
250
319
|
},
|
|
251
320
|
moduleEdges: {
|
|
252
321
|
added: [...newMods].filter((k) => !oldMods.has(k)),
|
|
253
|
-
removed: [...oldMods].filter((k) => !newMods.has(k))
|
|
322
|
+
removed: [...oldMods].filter((k) => !newMods.has(k)),
|
|
323
|
+
typeAdded: [...newTypeMods].filter((k) => !oldTypeMods.has(k)),
|
|
324
|
+
typeRemoved: [...oldTypeMods].filter((k) => !newTypeMods.has(k)),
|
|
254
325
|
},
|
|
255
326
|
// survived the rebuild but lost every caller/importer — likely made dead by the change
|
|
256
327
|
orphaned: [...oldIn.keys()].filter((id) => newNodes.has(id) && !newIn.has(id)),
|
|
257
|
-
cycles: {
|
|
328
|
+
cycles: {
|
|
329
|
+
runtime: schemaMigration ? null : cycleDelta(cycles(oldG, false), cycles(newG, false)),
|
|
330
|
+
typeInclusive: schemaMigration ? null : cycleDelta(cycles(oldG, true), cycles(newG, true)),
|
|
331
|
+
}
|
|
258
332
|
}
|
|
259
333
|
}
|
|
260
334
|
|
|
261
335
|
export function formatGraphDiff(d) {
|
|
262
336
|
if (!d.nodes.added.length && !d.nodes.removed.length && !d.edges.added && !d.edges.removed) {
|
|
263
|
-
return
|
|
337
|
+
return d.schemaMigration
|
|
338
|
+
? `Graph edge schema upgraded v${d.schemaMigration.from} → v${d.schemaMigration.to}; typed baseline established. Runtime/type cycle and module classifications are intentionally not compared on this rebuild.`
|
|
339
|
+
: 'No structural change between the two graph states.'
|
|
264
340
|
}
|
|
265
341
|
const cap = (list, n) => list.slice(0, n).map((x) => ` ${x}`).concat(list.length > n ? [` … +${list.length - n} more`] : [])
|
|
266
342
|
const lines = [`Structural delta: nodes +${d.nodes.added.length}/−${d.nodes.removed.length}, edges +${d.edges.added}/−${d.edges.removed}.`]
|
|
267
|
-
if (d.
|
|
268
|
-
|
|
343
|
+
if (d.schemaMigration) lines.push(`Graph edge schema upgraded v${d.schemaMigration.from} → v${d.schemaMigration.to}; runtime/type cycle and module classifications are intentionally not compared until the next rebuild.`)
|
|
344
|
+
const runtime = d.cycles?.runtime
|
|
345
|
+
if (runtime && (runtime.before !== runtime.after || runtime.largestBefore !== runtime.largestAfter || runtime.introduced.length || runtime.resolved.length || runtime.membershipChanged)) {
|
|
346
|
+
const changes = []
|
|
347
|
+
if (runtime.introduced.length) changes.push(`${runtime.introduced.length} genuinely new runtime SCC(s) — review`)
|
|
348
|
+
if (runtime.resolved.length) changes.push(`${runtime.resolved.length} runtime SCC(s) resolved`)
|
|
349
|
+
if (runtime.membershipChanged) changes.push(`${runtime.membershipChanged} SCC membership change(s)`)
|
|
350
|
+
const verdict = changes.length ? `; ${changes.join('; ')}` : ''
|
|
351
|
+
lines.push(`Runtime import cycles: count ${runtime.before} → ${runtime.after}, largest SCC ${runtime.largestBefore} → ${runtime.largestAfter}${verdict}.`)
|
|
352
|
+
}
|
|
353
|
+
const all = d.cycles?.typeInclusive
|
|
354
|
+
if (all && (all.before !== all.after || all.largestBefore !== all.largestAfter) &&
|
|
355
|
+
(!runtime || all.before !== runtime.before || all.after !== runtime.after || all.largestBefore !== runtime.largestBefore || all.largestAfter !== runtime.largestAfter)) {
|
|
356
|
+
lines.push(`Type-inclusive dependency SCCs: count ${all.before} → ${all.after}, largest ${all.largestBefore} → ${all.largestAfter} (compile-time coupling, not necessarily a runtime cycle).`)
|
|
269
357
|
}
|
|
270
358
|
if (d.moduleEdges.added.length) lines.push('NEW module dependencies (architecture drift — review):', ...cap(d.moduleEdges.added, 12))
|
|
271
359
|
if (d.moduleEdges.removed.length) lines.push('Removed module dependencies (decoupling confirmed):', ...cap(d.moduleEdges.removed, 12))
|
|
360
|
+
if (d.moduleEdges.typeAdded.length) lines.push('New type-only module dependencies (compile-time coupling):', ...cap(d.moduleEdges.typeAdded, 12))
|
|
361
|
+
if (d.moduleEdges.typeRemoved.length) lines.push('Removed type-only module dependencies:', ...cap(d.moduleEdges.typeRemoved, 12))
|
|
272
362
|
if (d.orphaned.length) lines.push('Symbols that lost their last caller/importer (now dead?):', ...cap(d.orphaned, 10))
|
|
273
363
|
if (d.nodes.added.length) lines.push('Added nodes:', ...cap(d.nodes.added, 12))
|
|
274
364
|
if (d.nodes.removed.length) lines.push('Removed nodes:', ...cap(d.nodes.removed, 12))
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Versioned, explicit wire schema for sync_graph. Never forward graph.json wholesale: it is a local
|
|
2
|
+
// cache file and may contain future fields or attacker-injected data that are not safe to upload.
|
|
3
|
+
|
|
4
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
5
|
+
|
|
6
|
+
function metadataString(value, max = 4096) {
|
|
7
|
+
return typeof value === 'string' && value.length > 0 && value.length <= max && !CONTROL_CHARS.test(value)
|
|
8
|
+
? value
|
|
9
|
+
: undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function finiteNumber(value) {
|
|
13
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function setIf(out, key, value) {
|
|
17
|
+
if (value !== undefined) out[key] = value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const COMPLEXITY_NUMBERS = [
|
|
21
|
+
'startLine', 'endLine', 'loc', 'params', 'objectFields', 'branches', 'cyclomatic',
|
|
22
|
+
'loops', 'maxLoopDepth', 'returns', 'awaits', 'callCount', 'externalCalls',
|
|
23
|
+
'asyncBoundaries', 'allocations', 'objectLiterals', 'spreadCopies', 'sorts',
|
|
24
|
+
'linearOps', 'timeRank', 'timeScore', 'memoryRank', 'memoryScore',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function sanitizeComplexity(value) {
|
|
28
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const key of COMPLEXITY_NUMBERS) setIf(out, key, finiteNumber(value[key]));
|
|
31
|
+
if (typeof value.recursion === 'boolean') out.recursion = value.recursion;
|
|
32
|
+
for (const key of ['family', 'scope', 'complexityScope', 'confidence']) {
|
|
33
|
+
setIf(out, key, metadataString(value[key], 32));
|
|
34
|
+
}
|
|
35
|
+
return Object.keys(out).length ? out : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sanitizeNode(value) {
|
|
39
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
40
|
+
const id = metadataString(value.id);
|
|
41
|
+
if (!id) return null;
|
|
42
|
+
const out = {id};
|
|
43
|
+
setIf(out, 'label', metadataString(value.label, 1024));
|
|
44
|
+
setIf(out, 'file_type', metadataString(value.file_type, 32));
|
|
45
|
+
setIf(out, 'source_file', metadataString(value.source_file));
|
|
46
|
+
const sourceLocation = metadataString(value.source_location, 32);
|
|
47
|
+
const sourceEnd = metadataString(value.source_end, 32);
|
|
48
|
+
if (sourceLocation && /^L\d+$/.test(sourceLocation)) out.source_location = sourceLocation;
|
|
49
|
+
if (sourceEnd && /^L\d+$/.test(sourceEnd)) out.source_end = sourceEnd;
|
|
50
|
+
const community = finiteNumber(value.community);
|
|
51
|
+
if (community !== undefined && Number.isInteger(community) && community >= 0) out.community = community;
|
|
52
|
+
if (typeof value.exported === 'boolean') out.exported = value.exported;
|
|
53
|
+
if (typeof value.decorated === 'boolean') out.decorated = value.decorated;
|
|
54
|
+
setIf(out, 'complexity', sanitizeComplexity(value.complexity));
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sanitizeLink(value) {
|
|
59
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
60
|
+
const source = metadataString(value.source);
|
|
61
|
+
const target = metadataString(value.target);
|
|
62
|
+
if (!source || !target) return null;
|
|
63
|
+
const out = {source, target};
|
|
64
|
+
setIf(out, 'relation', metadataString(value.relation, 32));
|
|
65
|
+
setIf(out, 'confidence', metadataString(value.confidence, 32));
|
|
66
|
+
if (value.typeOnly === true) out.typeOnly = true;
|
|
67
|
+
const line = finiteNumber(value.line);
|
|
68
|
+
if (line !== undefined && Number.isInteger(line) && line >= 0) out.line = line;
|
|
69
|
+
setIf(out, 'specifier', metadataString(value.specifier));
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sanitizeExternalImport(value) {
|
|
74
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
75
|
+
const file = metadataString(value.file);
|
|
76
|
+
if (!file) return null;
|
|
77
|
+
const out = {file};
|
|
78
|
+
for (const key of ['spec', 'target']) setIf(out, key, metadataString(value[key]));
|
|
79
|
+
for (const key of ['pkg', 'kind', 'ecosystem']) setIf(out, key, metadataString(value[key], 256));
|
|
80
|
+
const line = finiteNumber(value.line);
|
|
81
|
+
if (line !== undefined && Number.isInteger(line) && line >= 0) out.line = line;
|
|
82
|
+
for (const key of ['builtin', 'dynamic', 'unresolved']) {
|
|
83
|
+
if (typeof value[key] === 'boolean') out[key] = value[key];
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createSyncPayload(raw) {
|
|
89
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || raw.repoBoundaryV !== 1) {
|
|
90
|
+
throw new Error('graph predates repository-boundary hardening');
|
|
91
|
+
}
|
|
92
|
+
if (!Number.isInteger(raw.edgeTypesV) || raw.edgeTypesV < 1) {
|
|
93
|
+
throw new Error('graph predates typed import edges');
|
|
94
|
+
}
|
|
95
|
+
const nodes = Array.isArray(raw.nodes) ? raw.nodes.map(sanitizeNode).filter(Boolean) : [];
|
|
96
|
+
const links = Array.isArray(raw.links) ? raw.links.map(sanitizeLink).filter(Boolean) : [];
|
|
97
|
+
const externalImports = Array.isArray(raw.externalImports)
|
|
98
|
+
? raw.externalImports.map(sanitizeExternalImport).filter(Boolean)
|
|
99
|
+
: [];
|
|
100
|
+
return {
|
|
101
|
+
syncPayloadV: 2,
|
|
102
|
+
repoBoundaryV: 1,
|
|
103
|
+
edgeTypesV: 1,
|
|
104
|
+
extImportsV: Number.isInteger(raw.extImportsV) ? raw.extImportsV : 0,
|
|
105
|
+
complexityV: Number.isInteger(raw.complexityV) ? raw.complexityV : 0,
|
|
106
|
+
nodes,
|
|
107
|
+
links,
|
|
108
|
+
externalImports,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
// Action tools: rebuild the graph,
|
|
2
|
-
//
|
|
1
|
+
// Action tools: rebuild the graph, the explicit 'retarget' group, and the explicit 'online' group
|
|
2
|
+
// (the ONLY tools that ever touch the network).
|
|
3
3
|
// Hot-reloadable (re-imported by catalog.mjs on change).
|
|
4
|
-
import {readFileSync, writeFileSync, existsSync, readdirSync, statSync} from 'node:fs'
|
|
5
|
-
import {join, dirname} from 'node:path'
|
|
4
|
+
import {readFileSync, writeFileSync, existsSync, readdirSync, statSync, realpathSync} from 'node:fs'
|
|
5
|
+
import {join, dirname, isAbsolute} from 'node:path'
|
|
6
6
|
import {prevGraphPathFor, diffGraphs, formatGraphDiff} from './graph-context.mjs'
|
|
7
7
|
import {buildGraphForRepo} from '../build-graph.js'
|
|
8
8
|
import {graphOutDirForRepo} from '../graph/layout.js'
|
|
9
9
|
import {refreshAdvisories, storeMeta, DEFAULT_STORE} from '../security/advisory-store.js'
|
|
10
10
|
import {collectInstalled} from '../security/installed.js'
|
|
11
|
+
import {createSyncPayload} from './sync-payload.mjs'
|
|
11
12
|
|
|
12
13
|
export async function tRebuildGraph(g, args, ctx) {
|
|
13
14
|
if (!ctx.repoRoot) return 'Rebuild needs the repo root (not provided to this server).'
|
|
@@ -15,7 +16,7 @@ export async function tRebuildGraph(g, args, ctx) {
|
|
|
15
16
|
// snapshot the outgoing state: bytes → graph.prev.json (for graph_diff later), struct → inline delta
|
|
16
17
|
let prevBytes = null
|
|
17
18
|
try { prevBytes = readFileSync(ctx.graphPath) } catch { /* first build — nothing to diff against */ }
|
|
18
|
-
const before = g?.nodes ? {nodes: g.nodes, links: g.links} : null
|
|
19
|
+
const before = g?.nodes ? {nodes: g.nodes, links: g.links, edgeTypesV: g.edgeTypesV || 0} : null
|
|
19
20
|
const res = await buildGraphForRepo(ctx.repoRoot, {mode, scope: args.scope || ''})
|
|
20
21
|
if (!res || !res.ok) return `Graph rebuild failed: ${(res && res.error) || 'unknown error'}`
|
|
21
22
|
if (prevBytes) { try { writeFileSync(prevGraphPathFor(ctx.graphPath), prevBytes) } catch { /* snapshot is best-effort */ } }
|
|
@@ -31,13 +32,30 @@ export async function tRebuildGraph(g, args, ctx) {
|
|
|
31
32
|
// repo. Loads <parent>/weavatrix-graphs/<name>/graph.json (the central layout graphs
|
|
32
33
|
// always live in), building it first when missing. On a failed load the previous repo stays active.
|
|
33
34
|
export async function tOpenRepo(g, args, ctx) {
|
|
34
|
-
const
|
|
35
|
-
if (!
|
|
36
|
-
if (!
|
|
35
|
+
const requestedPath = String(args.path || '').trim()
|
|
36
|
+
if (!requestedPath) return 'Provide "path" — an absolute path to a local repository folder.'
|
|
37
|
+
if (!isAbsolute(requestedPath)) return 'open_repo requires an absolute repository path.'
|
|
38
|
+
let repoPath
|
|
39
|
+
try { repoPath = realpathSync.native(requestedPath) } catch { return `Path not found: ${requestedPath}` }
|
|
40
|
+
try { if (!statSync(repoPath).isDirectory()) return `Not a directory: ${requestedPath}` } catch { return `Path not found: ${requestedPath}` }
|
|
41
|
+
if (!existsSync(join(repoPath, '.git'))) return `Not a Git repository: ${requestedPath}`
|
|
37
42
|
const graphPath = join(graphOutDirForRepo(repoPath), 'graph.json')
|
|
38
43
|
let built = false
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
let upgrade = false
|
|
45
|
+
if (existsSync(graphPath)) {
|
|
46
|
+
try {
|
|
47
|
+
const saved = JSON.parse(readFileSync(graphPath, 'utf8'))
|
|
48
|
+
upgrade = !Number.isInteger(saved.edgeTypesV) || saved.edgeTypesV < 1
|
|
49
|
+
} catch {
|
|
50
|
+
upgrade = true
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!existsSync(graphPath) || upgrade) {
|
|
54
|
+
if (args.build === false) {
|
|
55
|
+
return upgrade
|
|
56
|
+
? `The existing graph for ${repoPath} predates typed import edges. Re-call without build:false to upgrade it before switching.`
|
|
57
|
+
: `No graph yet for ${repoPath} (expected at ${graphPath}). Re-call without build:false to build one — large repos can take minutes.`
|
|
58
|
+
}
|
|
41
59
|
const mode = ['no-tests', 'tests-only', 'full'].includes(args.mode) ? args.mode : 'full'
|
|
42
60
|
const res = await buildGraphForRepo(repoPath, {mode, scope: ''})
|
|
43
61
|
if (!res || !res.ok) return `Graph build failed for ${repoPath}: ${(res && res.error) || 'unknown error'}`
|
|
@@ -53,7 +71,8 @@ export async function tOpenRepo(g, args, ctx) {
|
|
|
53
71
|
ctx.reload()
|
|
54
72
|
return `Failed to load ${graphPath} — still targeting the previous repo (${prev.repoRoot || 'none'}).`
|
|
55
73
|
}
|
|
56
|
-
|
|
74
|
+
const buildNote = built ? (upgrade ? ' (graph upgraded to typed import edges)' : ' (graph built fresh)') : ''
|
|
75
|
+
return `Opened ${repoPath}${buildNote}: ${loaded.nodes.length} nodes / ${loaded.links.length} edges. All tools now target this repo.`
|
|
57
76
|
}
|
|
58
77
|
|
|
59
78
|
// Sibling repos that already have a built graph in the central weavatrix-graphs folder — open_repo candidates.
|
|
@@ -94,16 +113,15 @@ export async function tRefreshAdvisories(g, args, ctx) {
|
|
|
94
113
|
if (res.ok === false) return `Advisory refresh failed: ${res.error}`
|
|
95
114
|
const meta = storeMeta()
|
|
96
115
|
return [
|
|
97
|
-
`Advisory store refreshed from OSV.dev: ${res.queried} package versions queried, ${res.vulnerable} with known advisories (${res.fetched} advisory records fetched).`,
|
|
116
|
+
`Advisory store ${res.status === 'PARTIAL' ? 'partially refreshed' : 'refreshed'} from OSV.dev: ${res.queriedOk ?? res.queried}/${res.queried} package versions queried successfully, ${res.vulnerable} with known advisories (${res.fetched} advisory records fetched).`,
|
|
98
117
|
res.unsupported ? `${res.unsupported} packages skipped (ecosystem not OSV-queryable — npm/PyPI/Go only).` : null,
|
|
99
118
|
res.errors?.length ? `Partial: ${res.errors.length} request error(s), first: ${res.errors[0]}` : null,
|
|
100
119
|
`Store: ${DEFAULT_STORE} (${meta.advisoryCount} advisories, fetched ${meta.fetchedAt}). run_audit now reflects it — offline.`,
|
|
101
120
|
].filter(Boolean).join('\n')
|
|
102
121
|
}
|
|
103
122
|
|
|
104
|
-
// Push the current graph.json to a user-configured endpoint
|
|
105
|
-
//
|
|
106
|
-
// file paths, symbol names, and edges — never file contents.
|
|
123
|
+
// Push the current graph.json to a user-configured endpoint. Off until WEAVATRIX_SYNC_URL is set.
|
|
124
|
+
// The payload is graph metadata (paths, symbols/ranges, imports, edges, metrics), never file contents.
|
|
107
125
|
export async function tSyncGraph(g, args, ctx) {
|
|
108
126
|
const url = process.env.WEAVATRIX_SYNC_URL
|
|
109
127
|
if (!url) {
|
|
@@ -111,21 +129,27 @@ export async function tSyncGraph(g, args, ctx) {
|
|
|
111
129
|
+ ' (and WEAVATRIX_SYNC_TOKEN for bearer auth) in the MCP registration env, then call again.'
|
|
112
130
|
}
|
|
113
131
|
if (!g) return 'No graph loaded — build one first (open_repo / rebuild_graph).'
|
|
114
|
-
let
|
|
115
|
-
try {
|
|
132
|
+
let raw
|
|
133
|
+
try { raw = JSON.parse(readFileSync(ctx.graphPath, 'utf8')) } catch (e) { return `Cannot read ${ctx.graphPath}: ${e.message}` }
|
|
134
|
+
let payload
|
|
135
|
+
try { payload = createSyncPayload(raw) } catch (e) {
|
|
136
|
+
return `Cannot sync: ${e.message}. Run rebuild_graph once before sync_graph.`
|
|
137
|
+
}
|
|
138
|
+
const body = JSON.stringify(payload)
|
|
116
139
|
const repoName = String(ctx.repoRoot || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || 'repo'
|
|
117
140
|
try {
|
|
118
141
|
const res = await fetch(url, {
|
|
119
142
|
method: 'POST',
|
|
120
143
|
headers: {
|
|
121
144
|
'content-type': 'application/json',
|
|
145
|
+
'x-weavatrix-payload-version': String(payload.syncPayloadV),
|
|
122
146
|
'x-weavatrix-repo': repoName,
|
|
123
147
|
...(process.env.WEAVATRIX_SYNC_TOKEN ? {authorization: `Bearer ${process.env.WEAVATRIX_SYNC_TOKEN}`} : {}),
|
|
124
148
|
},
|
|
125
149
|
body,
|
|
126
150
|
})
|
|
127
151
|
if (!res.ok) return `Sync endpoint answered HTTP ${res.status} — graph NOT accepted.`
|
|
128
|
-
return `Graph for ${repoName} (${
|
|
152
|
+
return `Graph for ${repoName} (${payload.nodes.length} nodes / ${payload.links.length} edges, ${Math.round(Buffer.byteLength(body) / 1024)} KB) pushed to ${url}.`
|
|
129
153
|
} catch (e) {
|
|
130
154
|
return `Sync failed: ${e.message} — the graph stays local.`
|
|
131
155
|
}
|
package/src/mcp/tools-graph.mjs
CHANGED
|
@@ -12,9 +12,11 @@ export function tGraphStats(g, ctx) {
|
|
|
12
12
|
const symbols = g.nodes.length - files
|
|
13
13
|
const relCount = {}
|
|
14
14
|
const confCount = {}
|
|
15
|
+
let typeOnlyEdges = 0
|
|
15
16
|
for (const e of g.links) {
|
|
16
17
|
relCount[e.relation ?? '?'] = (relCount[e.relation ?? '?'] || 0) + 1
|
|
17
18
|
if (e.confidence != null) confCount[e.confidence] = (confCount[e.confidence] || 0) + 1
|
|
19
|
+
if (e.typeOnly === true) typeOnlyEdges++
|
|
18
20
|
}
|
|
19
21
|
const comm = new Map()
|
|
20
22
|
for (const n of g.nodes) {
|
|
@@ -33,6 +35,7 @@ export function tGraphStats(g, ctx) {
|
|
|
33
35
|
ctx?.repoRoot ? `- Repo: ${ctx.repoRoot}` : null,
|
|
34
36
|
`- Nodes: ${g.nodes.length} (${files} files, ${symbols} symbols)`,
|
|
35
37
|
`- Edges: ${g.links.length}`,
|
|
38
|
+
g.edgeTypesV ? `- Typed-edge metadata: v${g.edgeTypesV} (${typeOnlyEdges} type-only edges)` : `- Typed-edge metadata: unavailable (rebuild_graph with Weavatrix 0.1.3+)`,
|
|
36
39
|
`- Relations: ${fmt(relCount)}`,
|
|
37
40
|
Object.keys(confCount).length ? `- Confidence: ${fmt(confCount)}` : null,
|
|
38
41
|
`- Communities: ${comm.size} (top by size: ${topComm.map(([c, n]) => `#${c}=${n}`).join(', ')})`,
|
|
@@ -77,10 +80,10 @@ export function tGetNode(g, {label} = {}, ctx) {
|
|
|
77
80
|
function dedupeEdges(list) {
|
|
78
81
|
const grouped = new Map()
|
|
79
82
|
for (const e of list) {
|
|
80
|
-
const key = `${e.relation || 'rel'}|${e.id}`
|
|
83
|
+
const key = `${e.relation || 'rel'}|${e.typeOnly === true ? 'type' : 'runtime'}|${e.id}`
|
|
81
84
|
const cur = grouped.get(key)
|
|
82
85
|
if (cur) cur.count += 1
|
|
83
|
-
else grouped.set(key, {id: e.id, relation: e.relation, count: 1})
|
|
86
|
+
else grouped.set(key, {id: e.id, relation: e.relation, typeOnly: e.typeOnly === true, count: 1})
|
|
84
87
|
}
|
|
85
88
|
return [...grouped.values()]
|
|
86
89
|
}
|
|
@@ -99,7 +102,7 @@ export function tGetNeighbors(g, {label, relation_filter} = {}, ctx) {
|
|
|
99
102
|
const outs = dedupeEdges(outsRaw)
|
|
100
103
|
const ins = dedupeEdges(insRaw)
|
|
101
104
|
const line = (e, dir) =>
|
|
102
|
-
` ${dir === 'out' ? '→' : '←'} ${e.relation || 'rel'} ${labelOf(g, e.id)} [${e.id}]${e.count > 1 ? ` (${e.count} sites)` : ''}`
|
|
105
|
+
` ${dir === 'out' ? '→' : '←'} ${e.typeOnly ? 'type-only ' : ''}${e.relation || 'rel'} ${labelOf(g, e.id)} [${e.id}]${e.count > 1 ? ` (${e.count} sites)` : ''}`
|
|
103
106
|
return [
|
|
104
107
|
note,
|
|
105
108
|
`Neighbors of ${n.label ?? id}${rf ? ` (relation=${rf})` : ''}: ${outs.length + ins.length} unique (${outsRaw.length + insRaw.length} edges)`,
|
|
@@ -113,21 +116,52 @@ export function tGetNeighbors(g, {label, relation_filter} = {}, ctx) {
|
|
|
113
116
|
|
|
114
117
|
export function tGodNodes(g, {top_n = 10} = {}) {
|
|
115
118
|
const n = Math.max(1, Math.min(100, Number(top_n) || 10))
|
|
116
|
-
const
|
|
119
|
+
const scored = g.nodes
|
|
117
120
|
.map((node) => {
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
+
const outs = connList(g.out.get(String(node.id)))
|
|
122
|
+
const ins = connList(g.inn.get(String(node.id)))
|
|
123
|
+
const outIds = new Set(outs.map((e) => String(e.id)))
|
|
124
|
+
const inIds = new Set(ins.map((e) => String(e.id)))
|
|
125
|
+
const allIds = new Set([...outIds, ...inIds])
|
|
126
|
+
const runtimeIds = new Set([...outs, ...ins].filter((e) => e.typeOnly !== true).map((e) => String(e.id)))
|
|
127
|
+
const compileOnlyIds = new Set([...outs, ...ins].filter((e) => e.typeOnly === true).map((e) => String(e.id)))
|
|
128
|
+
for (const id of runtimeIds) compileOnlyIds.delete(id)
|
|
129
|
+
const occurrences = outs.length + ins.length
|
|
130
|
+
return {
|
|
131
|
+
node,
|
|
132
|
+
deg: allIds.size,
|
|
133
|
+
runtime: runtimeIds.size,
|
|
134
|
+
compileOnly: compileOnlyIds.size,
|
|
135
|
+
out: outIds.size,
|
|
136
|
+
in: inIds.size,
|
|
137
|
+
occurrences,
|
|
138
|
+
}
|
|
121
139
|
})
|
|
122
|
-
.
|
|
123
|
-
.
|
|
140
|
+
.filter((entry) => entry.deg > 0)
|
|
141
|
+
.sort((a, b) => b.runtime - a.runtime || b.deg - a.deg || b.occurrences - a.occurrences)
|
|
142
|
+
const ranked = scored.slice(0, n)
|
|
143
|
+
const rankedIds = new Set(ranked.map((entry) => String(entry.node.id)))
|
|
144
|
+
// Unique neighbors measure coupling, but a large component repeatedly calling the same helper (for
|
|
145
|
+
// example i18n) can still be a valuable complexity hotspot. Preserve that second lens explicitly
|
|
146
|
+
// instead of letting repeated sites inflate the coupling rank.
|
|
147
|
+
const occurrenceHotspots = scored
|
|
148
|
+
.filter((entry) => !rankedIds.has(String(entry.node.id)))
|
|
149
|
+
.filter((entry) => entry.occurrences >= 20 && entry.occurrences - entry.deg >= 10)
|
|
150
|
+
.sort((a, b) => (b.occurrences - b.deg) - (a.occurrences - a.deg) || b.occurrences - a.occurrences)
|
|
151
|
+
.slice(0, Math.min(5, n))
|
|
124
152
|
return [
|
|
125
|
-
`Top ${
|
|
153
|
+
`Top ${ranked.length} connectivity hubs (ranked by unique runtime call/import/reference neighbors; structural containment excluded):`,
|
|
126
154
|
...ranked.map(
|
|
127
155
|
(r, i) =>
|
|
128
|
-
`${String(i + 1).padStart(2)}. ${r.node.label ?? r.node.id} (${r.deg}
|
|
156
|
+
`${String(i + 1).padStart(2)}. ${r.node.label ?? r.node.id} (${r.deg} unique: ${r.runtime} runtime, ${r.compileOnly} compile-only; out ${r.out}, in ${r.in}; ${r.occurrences} edge occurrence${r.occurrences === 1 ? '' : 's'}) [${r.node.id}]`
|
|
157
|
+
),
|
|
158
|
+
`Repeated call sites affect the occurrence count, not the connectivity rank; compile-only neighbors are secondary to runtime coupling.`,
|
|
159
|
+
occurrenceHotspots.length ? `` : null,
|
|
160
|
+
occurrenceHotspots.length ? `High occurrence hotspots outside the connectivity rank (repeated call/reference sites; complexity signal, not broader coupling):` : null,
|
|
161
|
+
...occurrenceHotspots.map((r) =>
|
|
162
|
+
` ${r.node.label ?? r.node.id} (${r.occurrences} occurrences across ${r.deg} unique neighbors; ${r.occurrences - r.deg} repeats) [${r.node.id}]`
|
|
129
163
|
),
|
|
130
|
-
].join('\n')
|
|
164
|
+
].filter((line) => line != null).join('\n')
|
|
131
165
|
}
|
|
132
166
|
|
|
133
167
|
export function tGetCommunity(g, {community_id} = {}) {
|