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.
Files changed (42) hide show
  1. package/README.md +117 -21
  2. package/SECURITY.md +31 -0
  3. package/package.json +16 -4
  4. package/skill/SKILL.md +69 -12
  5. package/src/analysis/coverage-reports.js +14 -11
  6. package/src/analysis/dead-check.js +87 -6
  7. package/src/analysis/dep-check.js +84 -8
  8. package/src/analysis/dep-rules.js +74 -8
  9. package/src/analysis/duplicates.compute.js +215 -215
  10. package/src/analysis/duplicates.js +15 -15
  11. package/src/analysis/duplicates.run.js +52 -51
  12. package/src/analysis/duplicates.tokenize.js +182 -182
  13. package/src/analysis/endpoints.js +50 -5
  14. package/src/analysis/graph-analysis.aggregate.js +16 -4
  15. package/src/analysis/internal-audit.collect.js +154 -31
  16. package/src/analysis/internal-audit.reach.js +52 -11
  17. package/src/analysis/internal-audit.run.js +72 -21
  18. package/src/build-graph.js +2 -1
  19. package/src/child-env.js +7 -0
  20. package/src/graph/builder/lang-js.js +66 -23
  21. package/src/graph/internal-builder.build.js +48 -10
  22. package/src/graph/internal-builder.langs.js +49 -3
  23. package/src/graph/internal-builder.resolvers.js +96 -20
  24. package/src/mcp/catalog.mjs +10 -8
  25. package/src/mcp/graph-context.mjs +107 -17
  26. package/src/mcp/sync-payload.mjs +110 -0
  27. package/src/mcp/tools-actions.mjs +42 -18
  28. package/src/mcp/tools-graph.mjs +46 -12
  29. package/src/mcp/tools-health.mjs +42 -18
  30. package/src/mcp/tools-impact.mjs +55 -43
  31. package/src/mcp-rg.mjs +2 -1
  32. package/src/mcp-server.mjs +25 -16
  33. package/src/mcp-source-tools.mjs +16 -5
  34. package/src/process.js +4 -3
  35. package/src/repo-path.js +53 -0
  36. package/src/security/advisory-store.js +51 -7
  37. package/src/security/installed.js +44 -31
  38. package/src/security/malware-heuristics.exclusions.js +134 -134
  39. package/src/security/malware-heuristics.js +15 -15
  40. package/src/security/malware-heuristics.roots.js +142 -142
  41. package/src/security/malware-heuristics.scan.js +85 -85
  42. 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
- push(out, s, {id: t, relation: e.relation, confidence: e.confidence})
35
- push(inn, t, {id: s, relation: e.relation, confidence: e.confidence})
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
- if (statSync(join(ctx.repoRoot, String(sourceFile))).mtime > s.builtAt) {
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 oldMods = moduleEdges(oldG)
226
- const newMods = moduleEdges(newG)
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) => { try { return findSccs(buildFileImportGraph(graph).adj).length } catch { return null } }
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: {before: cycles(oldG), after: cycles(newG)}
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 'No structural change between the two graph states.'
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.cycles.before != null && d.cycles.after != null && d.cycles.before !== d.cycles.after) {
268
- lines.push(`Import cycles: ${d.cycles.before} → ${d.cycles.after}${d.cycles.after < d.cycles.before ? ' (cycle broken — fix confirmed)' : ' (NEW cycle introduced — see run_audit)'}`)
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, retarget the server at another repo, list sibling repos with
2
- // built graphs — plus the 'online' capability group (the ONLY tools that ever touch the network).
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 repoPath = String(args.path || '').trim().replace(/[\\/]+$/, '')
35
- if (!repoPath) return 'Provide "path" — an absolute path to a local repository folder.'
36
- if (!existsSync(repoPath)) return `Path not found: ${repoPath}`
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
- if (!existsSync(graphPath)) {
40
- if (args.build === false) return `No graph yet for ${repoPath} (expected at ${graphPath}). Re-call without build:false to build one — large repos can take minutes.`
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
- return `Opened ${repoPath}${built ? ' (graph built fresh)' : ''}: ${loaded.nodes.length} nodes / ${loaded.links.length} edges. All tools now target this repo.`
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 (the weavatrix site's hosted graph view,
105
- // or any self-hosted collector). Off until WEAVATRIX_SYNC_URL is set. The payload is the graph only —
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 body
115
- try { body = readFileSync(ctx.graphPath, 'utf8') } catch (e) { return `Cannot read ${ctx.graphPath}: ${e.message}` }
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} (${g.nodes.length} nodes / ${g.links.length} edges, ${Math.round(body.length / 1024)} KB) pushed to ${url}.`
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
  }
@@ -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 ranked = g.nodes
119
+ const scored = g.nodes
117
120
  .map((node) => {
118
- const o = connList(g.out.get(String(node.id))).length
119
- const i = connList(g.inn.get(String(node.id))).length
120
- return {node, deg: o + i, out: o, in: i}
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
- .sort((a, b) => b.deg - a.deg)
123
- .slice(0, n)
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 ${n} most-connected nodes (call/import/reference edges, excluding structural containment):`,
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} edges: out ${r.out}, in ${r.in}) [${r.node.id}]`
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} = {}) {