weavatrix 0.1.3 → 0.1.4

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.
@@ -7,16 +7,20 @@ import {
7
7
  graphStaleness, fileStalenessNote,
8
8
  } from './graph-context.mjs'
9
9
 
10
+ const compileKind = (edge) => edge?.typeOnly === true ? 'type-only' : edge?.compileOnly === true ? 'compile-only' : null
11
+
10
12
  export function tGraphStats(g, ctx) {
11
13
  const files = g.nodes.filter((n) => !isSymbol(n.id)).length
12
14
  const symbols = g.nodes.length - files
13
15
  const relCount = {}
14
16
  const confCount = {}
15
17
  let typeOnlyEdges = 0
18
+ let compileOnlyEdges = 0
16
19
  for (const e of g.links) {
17
20
  relCount[e.relation ?? '?'] = (relCount[e.relation ?? '?'] || 0) + 1
18
21
  if (e.confidence != null) confCount[e.confidence] = (confCount[e.confidence] || 0) + 1
19
22
  if (e.typeOnly === true) typeOnlyEdges++
23
+ if (e.compileOnly === true) compileOnlyEdges++
20
24
  }
21
25
  const comm = new Map()
22
26
  for (const n of g.nodes) {
@@ -35,7 +39,7 @@ export function tGraphStats(g, ctx) {
35
39
  ctx?.repoRoot ? `- Repo: ${ctx.repoRoot}` : null,
36
40
  `- Nodes: ${g.nodes.length} (${files} files, ${symbols} symbols)`,
37
41
  `- 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+)`,
42
+ g.edgeTypesV ? `- Typed-edge metadata: v${g.edgeTypesV} (${typeOnlyEdges} type-only, ${compileOnlyEdges} compile-only edges)` : `- Typed-edge metadata: unavailable (rebuild_graph required)`,
39
43
  `- Relations: ${fmt(relCount)}`,
40
44
  Object.keys(confCount).length ? `- Confidence: ${fmt(confCount)}` : null,
41
45
  `- Communities: ${comm.size} (top by size: ${topComm.map(([c, n]) => `#${c}=${n}`).join(', ')})`,
@@ -57,7 +61,7 @@ export function tGetNode(g, {label} = {}, ctx) {
57
61
  const sample = (list, dir) =>
58
62
  list
59
63
  .slice(0, 12)
60
- .map((e) => ` ${dir === 'out' ? '→' : '←'} ${e.relation || 'rel'} ${labelOf(g, e.id)} [${e.id}]`)
64
+ .map((e) => ` ${dir === 'out' ? '→' : '←'} ${compileKind(e) ? `${compileKind(e)} ` : ''}${e.relation || 'rel'} ${labelOf(g, e.id)} [${e.id}]`)
61
65
  .join('\n') || ' (none)'
62
66
  return [
63
67
  note,
@@ -80,10 +84,10 @@ export function tGetNode(g, {label} = {}, ctx) {
80
84
  function dedupeEdges(list) {
81
85
  const grouped = new Map()
82
86
  for (const e of list) {
83
- const key = `${e.relation || 'rel'}|${e.typeOnly === true ? 'type' : 'runtime'}|${e.id}`
87
+ const key = `${e.relation || 'rel'}|${compileKind(e) || 'runtime'}|${e.id}`
84
88
  const cur = grouped.get(key)
85
89
  if (cur) cur.count += 1
86
- else grouped.set(key, {id: e.id, relation: e.relation, typeOnly: e.typeOnly === true, count: 1})
90
+ else grouped.set(key, {id: e.id, relation: e.relation, typeOnly: e.typeOnly === true, compileOnly: e.compileOnly === true, count: 1})
87
91
  }
88
92
  return [...grouped.values()]
89
93
  }
@@ -102,7 +106,7 @@ export function tGetNeighbors(g, {label, relation_filter} = {}, ctx) {
102
106
  const outs = dedupeEdges(outsRaw)
103
107
  const ins = dedupeEdges(insRaw)
104
108
  const line = (e, dir) =>
105
- ` ${dir === 'out' ? '→' : '←'} ${e.typeOnly ? 'type-only ' : ''}${e.relation || 'rel'} ${labelOf(g, e.id)} [${e.id}]${e.count > 1 ? ` (${e.count} sites)` : ''}`
109
+ ` ${dir === 'out' ? '→' : '←'} ${compileKind(e) ? `${compileKind(e)} ` : ''}${e.relation || 'rel'} ${labelOf(g, e.id)} [${e.id}]${e.count > 1 ? ` (${e.count} sites)` : ''}`
106
110
  return [
107
111
  note,
108
112
  `Neighbors of ${n.label ?? id}${rf ? ` (relation=${rf})` : ''}: ${outs.length + ins.length} unique (${outsRaw.length + insRaw.length} edges)`,
@@ -114,55 +118,8 @@ export function tGetNeighbors(g, {label, relation_filter} = {}, ctx) {
114
118
  ].filter(Boolean).join('\n')
115
119
  }
116
120
 
117
- export function tGodNodes(g, {top_n = 10} = {}) {
118
- const n = Math.max(1, Math.min(100, Number(top_n) || 10))
119
- const scored = g.nodes
120
- .map((node) => {
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
- }
139
- })
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))
152
- return [
153
- `Top ${ranked.length} connectivity hubs (ranked by unique runtime call/import/reference neighbors; structural containment excluded):`,
154
- ...ranked.map(
155
- (r, i) =>
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}]`
163
- ),
164
- ].filter((line) => line != null).join('\n')
165
- }
121
+ export {tGodNodes} from './tools-graph-hubs.mjs'
122
+
166
123
 
167
124
  export function tGetCommunity(g, {community_id} = {}) {
168
125
  const groups = new Map()
@@ -113,7 +113,7 @@ export async function tRunAudit(g, args, ctx) {
113
113
  return [
114
114
  `Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
115
115
  `Severity: critical ${sev.critical}, high ${sev.high}, medium ${sev.medium}, low ${sev.low}, info ${sev.info}. Categories: unused ${bycat.unused}, structure ${bycat.structure}, vulnerability ${bycat.vulnerability}, malware ${bycat.malware}.`,
116
- `Structure: ${audit.structureReport?.runtimeCycles ?? audit.structureReport?.cycles ?? 0} runtime cycle(s), ${audit.structureReport?.typeCouplings ?? 0} type-induced coupling group(s), ${audit.structureReport?.orphans ?? 0} orphan(s); import edges: ${audit.structureReport?.runtimeImportEdges ?? audit.structureReport?.importEdges ?? 0} runtime + ${audit.structureReport?.typeOnlyImportEdges ?? 0} type-only. Dead: ${audit.deadReport.deadFiles} file(s), ${audit.deadReport.unusedExports} unused export(s).`,
116
+ `Structure: ${audit.structureReport?.runtimeCycles ?? audit.structureReport?.cycles ?? 0} runtime cycle(s), ${audit.structureReport?.compileTimeCouplings ?? audit.structureReport?.typeCouplings ?? 0} compile-time coupling group(s), ${audit.structureReport?.orphans ?? 0} orphan(s); import edges: ${audit.structureReport?.runtimeImportEdges ?? audit.structureReport?.importEdges ?? 0} runtime + ${audit.structureReport?.typeOnlyImportEdges ?? 0} type-only + ${audit.structureReport?.compileOnlyImportEdges ?? 0} compile-only. Dead: ${audit.deadReport.deadFiles} file(s), ${audit.deadReport.unusedExports} unused export(s).`,
117
117
  `Checks: ${check('OSV', audit.checks?.osv)}; ${check('malware', audit.checks?.malware)}. A NOT_CHECKED/PARTIAL/ERROR check is incomplete or unknown, never a clean zero.`,
118
118
  ``,
119
119
  `Showing ${shown.length} of ${filtered.length} finding(s)${cat ? ` in category "${cat}"` : ''}${args.min_severity ? ` at ≥${args.min_severity}` : ''}:`,
@@ -140,16 +140,28 @@ export function tModuleMap(g, args, ctx) {
140
140
  const topN = Math.max(1, Math.min(60, Number(args.top_n) || 25))
141
141
  const mods = agg.modules.slice(0, topN)
142
142
  const edges = agg.moduleEdges.slice(0, Math.min(50, topN * 2))
143
- const typeEdges = (agg.typeOnlyModuleEdges || []).slice(0, Math.min(50, topN * 2))
143
+ const compileEdges = new Map()
144
+ const collectCompileEdges = (list, kind) => {
145
+ for (const edge of list || []) {
146
+ const key = `${edge.from}\0${edge.to}`
147
+ const current = compileEdges.get(key) || {from: edge.from, to: edge.to, count: 0, typeOnly: 0, compileOnly: 0}
148
+ current.count += edge.count
149
+ current[kind] += edge.count
150
+ compileEdges.set(key, current)
151
+ }
152
+ }
153
+ collectCompileEdges(agg.typeOnlyModuleEdges, 'typeOnly')
154
+ collectCompileEdges(agg.compileOnlyModuleEdges, 'compileOnly')
155
+ const compiled = [...compileEdges.values()].sort((a, b) => b.count - a.count).slice(0, Math.min(50, topN * 2))
144
156
  return [
145
- `Module map: ${agg.totals.files} files in ${agg.modules.length} folder-modules, ${agg.totals.moduleEdges} runtime module dependencies and ${agg.totals.typeOnlyModuleEdges || 0} type-only dependencies. Top ${mods.length}:`,
157
+ `Module map: ${agg.totals.files} files in ${agg.modules.length} folder-modules, ${agg.totals.moduleEdges} runtime module dependencies and ${agg.totals.compileTimeModuleEdges || 0} compile-time dependencies (${agg.totals.typeOnlyModuleEdges || 0} type-only, ${agg.totals.compileOnlyModuleEdges || 0} compile-only). Top ${mods.length}:`,
146
158
  ...mods.map((m) => ` ${m.name} — ${m.fileCount} files, ${m.symbolCount} symbols`),
147
159
  ``,
148
160
  `Strongest runtime module dependencies:`,
149
161
  ...edges.map((e) => ` ${e.from} → ${e.to} (${e.count})`),
150
- typeEdges.length ? `` : null,
151
- typeEdges.length ? `Type-only module dependencies (compile-time contracts, not runtime coupling):` : null,
152
- ...typeEdges.map((e) => ` ${e.from} → ${e.to} (${e.count})`),
162
+ compiled.length ? `` : null,
163
+ compiled.length ? `Compile-time module dependencies (not runtime coupling):` : null,
164
+ ...compiled.map((e) => ` ${e.from} → ${e.to} (${e.count}; ${e.typeOnly} type-only, ${e.compileOnly} compile-only)`),
153
165
  ].filter((line) => line != null).join('\n')
154
166
  }
155
167
 
@@ -9,6 +9,7 @@ import {
9
9
  rawGraph, prevGraphPathFor, edgeEndpoint, diffGraphs, formatGraphDiff,
10
10
  } from './graph-context.mjs'
11
11
  import {readCoverageForRepo} from '../analysis/coverage-reports.js'
12
+ import {isStructuralRelation} from '../graph/relations.js'
12
13
 
13
14
  function reverseReach(g, seeds, maxDepth) {
14
15
  const states = new Map([...seeds].map((id) => [String(id), {
@@ -19,9 +20,9 @@ function reverseReach(g, seeds, maxDepth) {
19
20
  const current = frontier[cursor]
20
21
  if (current.depth >= maxDepth) continue
21
22
  for (const e of g.inn.get(current.id) || []) {
22
- if (e.relation === 'contains') continue
23
+ if (isStructuralRelation(e.relation)) continue
23
24
  const id = String(e.id)
24
- const compileOnly = current.compileOnly || e.typeOnly === true
25
+ const compileOnly = current.compileOnly || e.typeOnly === true || e.compileOnly === true
25
26
  const depth = current.depth + 1
26
27
  const entry = states.get(id) || {
27
28
  runtimeDepth: null, runtimeRelation: null, compileDepth: null, compileRelation: null,
@@ -74,12 +75,14 @@ export function tGetDependents(g, {label, depth = 3, max_nodes = 40} = {}) {
74
75
  const ranked = [...reached.entries()]
75
76
  .filter(([nid]) => !seeds.has(nid))
76
77
  .map(([nid, entry]) => ({id: nid, d: entry.depth, entry, deg: degreeOf(g, nid)}))
77
- .sort((a, b) => a.d - b.d || b.deg - a.deg)
78
+ .sort((a, b) => a.d - b.d || Number(a.entry.compileOnly) - Number(b.entry.compileOnly) || b.deg - a.deg)
78
79
  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')
79
80
  const shown = ranked.slice(0, cap)
81
+ const runtimeCount = ranked.filter((entry) => !entry.entry.compileOnly).length
82
+ const compileCount = ranked.length - runtimeCount
80
83
  return [
81
84
  note,
82
- `Dependents of ${n.label ?? id} (reverse calls/imports/inherits, depth ≤${maxDepth}): ${ranked.length} found, showing ${shown.length} by proximity + connectivity.`,
85
+ `Dependents of ${n.label ?? id} (reverse calls/imports/inherits, depth ≤${maxDepth}): ${ranked.length} found (${runtimeCount} runtime, ${compileCount} compile-time-only), showing ${shown.length} by proximity + connectivity.`,
83
86
  containingFile ? `Includes importers of its containing file ${labelOf(g, containingFile)}.` : null,
84
87
  ...shown.map((r) => ` [d${r.d} ${impactKind(r.entry)}] ${r.entry.relation || 'rel'} ${labelOf(g, r.id)} (deg ${r.deg}) [${r.id}]`),
85
88
  ].filter(Boolean).join('\n')
@@ -174,7 +177,7 @@ export function tChangeImpact(g, args, ctx) {
174
177
  const impacted = [...reached.entries()]
175
178
  .filter(([nid]) => !seeds.has(nid) && !changedSet.has(fileOfNode(nid)))
176
179
  .map(([nid, entry]) => ({id: nid, d: entry.depth, entry, deg: degreeOf(g, nid), file: fileOfNode(nid)}))
177
- .sort((a, b) => a.d - b.d || b.deg - a.deg)
180
+ .sort((a, b) => a.d - b.d || Number(a.entry.compileOnly) - Number(b.entry.compileOnly) || b.deg - a.deg)
178
181
 
179
182
  // coverage overlay from EXISTING reports (fractions 0..1) — see coverage_map for details
180
183
  const knownFiles = (rawGraph(ctx).nodes || []).filter((n) => !String(n.id).includes('#') && n.source_file).map((n) => n.source_file)
@@ -184,11 +187,13 @@ export function tChangeImpact(g, args, ctx) {
184
187
  const hasCoverage = coverage.size > 0
185
188
 
186
189
  const shown = impacted.slice(0, cap)
190
+ const runtimeImpacted = impacted.filter((entry) => !entry.entry.compileOnly).length
191
+ const compileImpacted = impacted.length - runtimeImpacted
187
192
  const untestedHotspots = shown.filter((n) => { const c = covOf(n.file); return c != null && c < 0.5 && n.deg >= 5 })
188
193
  return [
189
194
  `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)` : ''}.`,
190
195
  impacted.length
191
- ? `${impacted.length} impacted node(s) within ${maxDepth} reverse hop(s), showing ${shown.length}:`
196
+ ? `${impacted.length} impacted node(s) within ${maxDepth} reverse hop(s) (${runtimeImpacted} runtime, ${compileImpacted} compile-time-only), showing ${shown.length}:`
192
197
  : `Nothing else in the graph depends on the changed code within ${maxDepth} hop(s).`,
193
198
  hasCoverage ? `Coverage: existing report mapped where available.` : `Coverage: unavailable (no supported report found); per-node coverage labels omitted.`,
194
199
  ...shown.map((n) => ` [d${n.d} ${impactKind(n.entry)}] ${n.entry.relation || 'rel'} ${labelOf(g, n.id)} (deg ${n.deg}${hasCoverage ? `, ${pctStr(covOf(n.file))}` : ''}) [${n.id}]`),
@@ -131,8 +131,8 @@ async function main() {
131
131
  if (!graph && tool.cap !== 'build' && tool.cap !== 'retarget') return reply(id, {content: [{type: 'text', text: `Graph unavailable: ${graphError}`}], isError: true})
132
132
  try {
133
133
  let text = String(await tool.run(graph, params?.arguments || {}, ctx))
134
- if (graph && graph.edgeTypesV < 1 && (tool.cap === 'graph' || tool.cap === 'health')) {
135
- text += '\n\nWarning: this saved graph predates typed import edges, so type-only imports cannot be separated from runtime dependencies. Call rebuild_graph once before acting on cycle, boundary, dependency, or blast-radius findings.'
134
+ if (graph && graph.edgeTypesV < 2 && (tool.cap === 'graph' || tool.cap === 'health')) {
135
+ text += '\n\nWarning: this saved graph predates compile-only edge metadata (edge schema v2), so Rust module/use dependencies may be absent or misclassified. Call rebuild_graph once before acting on cycle, boundary, dependency, or blast-radius findings.'
136
136
  }
137
137
  // Graph answers silently reflect a point-in-time build — surface staleness on every graph tool.
138
138
  if (tool.cap === 'graph') {