weavatrix 0.1.2 → 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.
Files changed (37) hide show
  1. package/README.md +92 -17
  2. package/package.json +1 -1
  3. package/skill/SKILL.md +62 -4
  4. package/src/analysis/dead-check.js +87 -6
  5. package/src/analysis/dep-check-ecosystems.js +157 -0
  6. package/src/analysis/dep-check.js +97 -133
  7. package/src/analysis/dep-rules.js +91 -12
  8. package/src/analysis/duplicates.compute.js +12 -18
  9. package/src/analysis/endpoints-rust.js +124 -0
  10. package/src/analysis/endpoints.js +56 -6
  11. package/src/analysis/graph-analysis.aggregate.js +34 -25
  12. package/src/analysis/graph-analysis.edges.js +21 -0
  13. package/src/analysis/graph-analysis.js +1 -1
  14. package/src/analysis/graph-analysis.summaries.js +4 -1
  15. package/src/analysis/internal-audit.collect.js +162 -25
  16. package/src/analysis/internal-audit.reach.js +52 -11
  17. package/src/analysis/internal-audit.run.js +71 -18
  18. package/src/graph/builder/lang-java.js +177 -14
  19. package/src/graph/builder/lang-js.js +66 -23
  20. package/src/graph/builder/lang-rust.js +129 -6
  21. package/src/graph/community.js +17 -0
  22. package/src/graph/internal-builder.build.js +79 -17
  23. package/src/graph/internal-builder.java.js +33 -0
  24. package/src/graph/internal-builder.langs.js +43 -2
  25. package/src/graph/internal-builder.resolvers.js +197 -21
  26. package/src/graph/relations.js +4 -0
  27. package/src/mcp/catalog.mjs +4 -4
  28. package/src/mcp/graph-context.mjs +22 -94
  29. package/src/mcp/graph-diff.mjs +163 -0
  30. package/src/mcp/sync-payload.mjs +36 -6
  31. package/src/mcp/tools-actions.mjs +22 -7
  32. package/src/mcp/tools-graph-hubs.mjs +58 -0
  33. package/src/mcp/tools-graph.mjs +13 -22
  34. package/src/mcp/tools-health.mjs +54 -18
  35. package/src/mcp/tools-impact.mjs +61 -45
  36. package/src/mcp-server.mjs +3 -0
  37. package/src/security/advisory-store.js +51 -7
@@ -9,6 +9,27 @@ function metadataString(value, max = 4096) {
9
9
  : undefined;
10
10
  }
11
11
 
12
+ // graph.json is derived data and may be edited independently of the repository. Never trust a path
13
+ // merely because it occupies an allowlisted field: sync only accepts canonical-looking repo-relative
14
+ // paths, on every host OS. Graph IDs append `#symbol@line`, so validate their file portion separately
15
+ // while preserving the complete ID on the wire.
16
+ function repoRelativePathString(value, max = 4096) {
17
+ const path = metadataString(value, max);
18
+ if (!path) return undefined;
19
+ if (/^(?:[a-z][a-z0-9+.-]*:|[\\/])/i.test(path)) return undefined; // URI, drive path, POSIX or UNC absolute
20
+ const segments = path.split(/[\\/]/);
21
+ if (segments.some((segment) => segment === '.' || segment === '..')) return undefined;
22
+ return path;
23
+ }
24
+
25
+ function graphIdString(value) {
26
+ const id = metadataString(value);
27
+ if (!id) return undefined;
28
+ const hash = id.indexOf('#');
29
+ const file = hash < 0 ? id : id.slice(0, hash);
30
+ return repoRelativePathString(file) ? id : undefined;
31
+ }
32
+
12
33
  function finiteNumber(value) {
13
34
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
14
35
  }
@@ -37,12 +58,12 @@ function sanitizeComplexity(value) {
37
58
 
38
59
  function sanitizeNode(value) {
39
60
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
40
- const id = metadataString(value.id);
61
+ const id = graphIdString(value.id);
41
62
  if (!id) return null;
42
63
  const out = {id};
43
64
  setIf(out, 'label', metadataString(value.label, 1024));
44
65
  setIf(out, 'file_type', metadataString(value.file_type, 32));
45
- setIf(out, 'source_file', metadataString(value.source_file));
66
+ setIf(out, 'source_file', repoRelativePathString(value.source_file));
46
67
  const sourceLocation = metadataString(value.source_location, 32);
47
68
  const sourceEnd = metadataString(value.source_end, 32);
48
69
  if (sourceLocation && /^L\d+$/.test(sourceLocation)) out.source_location = sourceLocation;
@@ -57,18 +78,23 @@ function sanitizeNode(value) {
57
78
 
58
79
  function sanitizeLink(value) {
59
80
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
60
- const source = metadataString(value.source);
61
- const target = metadataString(value.target);
81
+ const source = graphIdString(value.source);
82
+ const target = graphIdString(value.target);
62
83
  if (!source || !target) return null;
63
84
  const out = {source, target};
64
85
  setIf(out, 'relation', metadataString(value.relation, 32));
65
86
  setIf(out, 'confidence', metadataString(value.confidence, 32));
87
+ if (value.typeOnly === true) out.typeOnly = true;
88
+ if (value.compileOnly === true) out.compileOnly = true;
89
+ const line = finiteNumber(value.line);
90
+ if (line !== undefined && Number.isInteger(line) && line >= 0) out.line = line;
91
+ setIf(out, 'specifier', metadataString(value.specifier));
66
92
  return out;
67
93
  }
68
94
 
69
95
  function sanitizeExternalImport(value) {
70
96
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
71
- const file = metadataString(value.file);
97
+ const file = repoRelativePathString(value.file);
72
98
  if (!file) return null;
73
99
  const out = {file};
74
100
  for (const key of ['spec', 'target']) setIf(out, key, metadataString(value[key]));
@@ -85,14 +111,18 @@ export function createSyncPayload(raw) {
85
111
  if (!raw || typeof raw !== 'object' || Array.isArray(raw) || raw.repoBoundaryV !== 1) {
86
112
  throw new Error('graph predates repository-boundary hardening');
87
113
  }
114
+ if (!Number.isInteger(raw.edgeTypesV) || raw.edgeTypesV < 2) {
115
+ throw new Error('graph predates compile-only edge metadata');
116
+ }
88
117
  const nodes = Array.isArray(raw.nodes) ? raw.nodes.map(sanitizeNode).filter(Boolean) : [];
89
118
  const links = Array.isArray(raw.links) ? raw.links.map(sanitizeLink).filter(Boolean) : [];
90
119
  const externalImports = Array.isArray(raw.externalImports)
91
120
  ? raw.externalImports.map(sanitizeExternalImport).filter(Boolean)
92
121
  : [];
93
122
  return {
94
- syncPayloadV: 1,
123
+ syncPayloadV: 2,
95
124
  repoBoundaryV: 1,
125
+ edgeTypesV: 2,
96
126
  extImportsV: Number.isInteger(raw.extImportsV) ? raw.extImportsV : 0,
97
127
  complexityV: Number.isInteger(raw.complexityV) ? raw.complexityV : 0,
98
128
  nodes,
@@ -16,7 +16,7 @@ export async function tRebuildGraph(g, args, ctx) {
16
16
  // snapshot the outgoing state: bytes → graph.prev.json (for graph_diff later), struct → inline delta
17
17
  let prevBytes = null
18
18
  try { prevBytes = readFileSync(ctx.graphPath) } catch { /* first build — nothing to diff against */ }
19
- 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
20
20
  const res = await buildGraphForRepo(ctx.repoRoot, {mode, scope: args.scope || ''})
21
21
  if (!res || !res.ok) return `Graph rebuild failed: ${(res && res.error) || 'unknown error'}`
22
22
  if (prevBytes) { try { writeFileSync(prevGraphPathFor(ctx.graphPath), prevBytes) } catch { /* snapshot is best-effort */ } }
@@ -41,8 +41,21 @@ export async function tOpenRepo(g, args, ctx) {
41
41
  if (!existsSync(join(repoPath, '.git'))) return `Not a Git repository: ${requestedPath}`
42
42
  const graphPath = join(graphOutDirForRepo(repoPath), 'graph.json')
43
43
  let built = false
44
- if (!existsSync(graphPath)) {
45
- 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 < 2
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 compile-only edge metadata (edge schema v2). 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
+ }
46
59
  const mode = ['no-tests', 'tests-only', 'full'].includes(args.mode) ? args.mode : 'full'
47
60
  const res = await buildGraphForRepo(repoPath, {mode, scope: ''})
48
61
  if (!res || !res.ok) return `Graph build failed for ${repoPath}: ${(res && res.error) || 'unknown error'}`
@@ -58,7 +71,8 @@ export async function tOpenRepo(g, args, ctx) {
58
71
  ctx.reload()
59
72
  return `Failed to load ${graphPath} — still targeting the previous repo (${prev.repoRoot || 'none'}).`
60
73
  }
61
- 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 edge metadata v2)' : ' (graph built fresh)') : ''
75
+ return `Opened ${repoPath}${buildNote}: ${loaded.nodes.length} nodes / ${loaded.links.length} edges. All tools now target this repo.`
62
76
  }
63
77
 
64
78
  // Sibling repos that already have a built graph in the central weavatrix-graphs folder — open_repo candidates.
@@ -99,7 +113,7 @@ export async function tRefreshAdvisories(g, args, ctx) {
99
113
  if (res.ok === false) return `Advisory refresh failed: ${res.error}`
100
114
  const meta = storeMeta()
101
115
  return [
102
- `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).`,
103
117
  res.unsupported ? `${res.unsupported} packages skipped (ecosystem not OSV-queryable — npm/PyPI/Go only).` : null,
104
118
  res.errors?.length ? `Partial: ${res.errors.length} request error(s), first: ${res.errors[0]}` : null,
105
119
  `Store: ${DEFAULT_STORE} (${meta.advisoryCount} advisories, fetched ${meta.fetchedAt}). run_audit now reflects it — offline.`,
@@ -118,8 +132,8 @@ export async function tSyncGraph(g, args, ctx) {
118
132
  let raw
119
133
  try { raw = JSON.parse(readFileSync(ctx.graphPath, 'utf8')) } catch (e) { return `Cannot read ${ctx.graphPath}: ${e.message}` }
120
134
  let payload
121
- try { payload = createSyncPayload(raw) } catch {
122
- return 'This graph predates repository-boundary hardening. Run rebuild_graph once before sync_graph.'
135
+ try { payload = createSyncPayload(raw) } catch (e) {
136
+ return `Cannot sync: ${e.message}. Run rebuild_graph once before sync_graph.`
123
137
  }
124
138
  const body = JSON.stringify(payload)
125
139
  const repoName = String(ctx.repoRoot || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || 'repo'
@@ -128,6 +142,7 @@ export async function tSyncGraph(g, args, ctx) {
128
142
  method: 'POST',
129
143
  headers: {
130
144
  'content-type': 'application/json',
145
+ 'x-weavatrix-payload-version': String(payload.syncPayloadV),
131
146
  'x-weavatrix-repo': repoName,
132
147
  ...(process.env.WEAVATRIX_SYNC_TOKEN ? {authorization: `Bearer ${process.env.WEAVATRIX_SYNC_TOKEN}`} : {}),
133
148
  },
@@ -0,0 +1,58 @@
1
+ // Connectivity-hub reporting, split from the general graph query tools.
2
+ import {connList} from './graph-context.mjs'
3
+
4
+ const isCompileTimeEdge = (edge) => edge?.typeOnly === true || edge?.compileOnly === true
5
+
6
+ export function tGodNodes(g, {top_n = 10} = {}) {
7
+ const n = Math.max(1, Math.min(100, Number(top_n) || 10))
8
+ const scored = g.nodes
9
+ .map((node) => {
10
+ const rawOuts = g.out.get(String(node.id)) || []
11
+ const rawIns = g.inn.get(String(node.id)) || []
12
+ const outs = connList(rawOuts)
13
+ const ins = connList(rawIns)
14
+ const ownedMethods = new Set(rawOuts.filter((e) => e.relation === 'method').map((e) => String(e.id)))
15
+ const outIds = new Set(outs.map((e) => String(e.id)))
16
+ const inIds = new Set(ins.map((e) => String(e.id)))
17
+ const allIds = new Set([...outIds, ...inIds])
18
+ const runtimeIds = new Set([...outs, ...ins].filter((e) => !isCompileTimeEdge(e)).map((e) => String(e.id)))
19
+ const compileOnlyIds = new Set([...outs, ...ins].filter(isCompileTimeEdge).map((e) => String(e.id)))
20
+ for (const id of runtimeIds) compileOnlyIds.delete(id)
21
+ const occurrences = outs.length + ins.length
22
+ return {
23
+ node,
24
+ deg: allIds.size,
25
+ runtime: runtimeIds.size,
26
+ compileOnly: compileOnlyIds.size,
27
+ out: outIds.size,
28
+ in: inIds.size,
29
+ occurrences,
30
+ ownedMethods: ownedMethods.size,
31
+ }
32
+ })
33
+ .filter((entry) => entry.deg > 0 || entry.ownedMethods > 0)
34
+ .sort((a, b) => b.runtime - a.runtime || b.deg - a.deg || b.ownedMethods - a.ownedMethods || b.occurrences - a.occurrences)
35
+ const ranked = scored.slice(0, n)
36
+ const rankedIds = new Set(ranked.map((entry) => String(entry.node.id)))
37
+ // Unique neighbors measure coupling, but a large component repeatedly calling the same helper (for
38
+ // example i18n) can still be a valuable complexity hotspot. Preserve that second lens explicitly
39
+ // instead of letting repeated sites inflate the coupling rank.
40
+ const occurrenceHotspots = scored
41
+ .filter((entry) => !rankedIds.has(String(entry.node.id)))
42
+ .filter((entry) => entry.occurrences >= 20 && entry.occurrences - entry.deg >= 10)
43
+ .sort((a, b) => (b.occurrences - b.deg) - (a.occurrences - a.deg) || b.occurrences - a.occurrences)
44
+ .slice(0, Math.min(5, n))
45
+ return [
46
+ `Top ${ranked.length} connectivity hubs (ranked by unique runtime call/import/reference neighbors; structural ownership shown separately):`,
47
+ ...ranked.map(
48
+ (r, i) =>
49
+ `${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.ownedMethods ? `; owns ${r.ownedMethods} method${r.ownedMethods === 1 ? '' : 's'}` : ''}) [${r.node.id}]`
50
+ ),
51
+ `Repeated call sites affect the occurrence count, not the connectivity rank; compile-only neighbors are secondary to runtime coupling.`,
52
+ occurrenceHotspots.length ? `` : null,
53
+ occurrenceHotspots.length ? `High occurrence hotspots outside the connectivity rank (repeated call/reference sites; complexity signal, not broader coupling):` : null,
54
+ ...occurrenceHotspots.map((r) =>
55
+ ` ${r.node.label ?? r.node.id} (${r.occurrences} occurrences across ${r.deg} unique neighbors; ${r.occurrences - r.deg} repeats) [${r.node.id}]`
56
+ ),
57
+ ].filter((line) => line != null).join('\n')
58
+ }
@@ -7,14 +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 = {}
17
+ let typeOnlyEdges = 0
18
+ let compileOnlyEdges = 0
15
19
  for (const e of g.links) {
16
20
  relCount[e.relation ?? '?'] = (relCount[e.relation ?? '?'] || 0) + 1
17
21
  if (e.confidence != null) confCount[e.confidence] = (confCount[e.confidence] || 0) + 1
22
+ if (e.typeOnly === true) typeOnlyEdges++
23
+ if (e.compileOnly === true) compileOnlyEdges++
18
24
  }
19
25
  const comm = new Map()
20
26
  for (const n of g.nodes) {
@@ -33,6 +39,7 @@ export function tGraphStats(g, ctx) {
33
39
  ctx?.repoRoot ? `- Repo: ${ctx.repoRoot}` : null,
34
40
  `- Nodes: ${g.nodes.length} (${files} files, ${symbols} symbols)`,
35
41
  `- Edges: ${g.links.length}`,
42
+ g.edgeTypesV ? `- Typed-edge metadata: v${g.edgeTypesV} (${typeOnlyEdges} type-only, ${compileOnlyEdges} compile-only edges)` : `- Typed-edge metadata: unavailable (rebuild_graph required)`,
36
43
  `- Relations: ${fmt(relCount)}`,
37
44
  Object.keys(confCount).length ? `- Confidence: ${fmt(confCount)}` : null,
38
45
  `- Communities: ${comm.size} (top by size: ${topComm.map(([c, n]) => `#${c}=${n}`).join(', ')})`,
@@ -54,7 +61,7 @@ export function tGetNode(g, {label} = {}, ctx) {
54
61
  const sample = (list, dir) =>
55
62
  list
56
63
  .slice(0, 12)
57
- .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}]`)
58
65
  .join('\n') || ' (none)'
59
66
  return [
60
67
  note,
@@ -77,10 +84,10 @@ export function tGetNode(g, {label} = {}, ctx) {
77
84
  function dedupeEdges(list) {
78
85
  const grouped = new Map()
79
86
  for (const e of list) {
80
- const key = `${e.relation || 'rel'}|${e.id}`
87
+ const key = `${e.relation || 'rel'}|${compileKind(e) || 'runtime'}|${e.id}`
81
88
  const cur = grouped.get(key)
82
89
  if (cur) cur.count += 1
83
- else grouped.set(key, {id: e.id, relation: e.relation, count: 1})
90
+ else grouped.set(key, {id: e.id, relation: e.relation, typeOnly: e.typeOnly === true, compileOnly: e.compileOnly === true, count: 1})
84
91
  }
85
92
  return [...grouped.values()]
86
93
  }
@@ -99,7 +106,7 @@ export function tGetNeighbors(g, {label, relation_filter} = {}, ctx) {
99
106
  const outs = dedupeEdges(outsRaw)
100
107
  const ins = dedupeEdges(insRaw)
101
108
  const line = (e, dir) =>
102
- ` ${dir === 'out' ? '→' : '←'} ${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)` : ''}`
103
110
  return [
104
111
  note,
105
112
  `Neighbors of ${n.label ?? id}${rf ? ` (relation=${rf})` : ''}: ${outs.length + ins.length} unique (${outsRaw.length + insRaw.length} edges)`,
@@ -111,24 +118,8 @@ export function tGetNeighbors(g, {label, relation_filter} = {}, ctx) {
111
118
  ].filter(Boolean).join('\n')
112
119
  }
113
120
 
114
- export function tGodNodes(g, {top_n = 10} = {}) {
115
- const n = Math.max(1, Math.min(100, Number(top_n) || 10))
116
- const ranked = g.nodes
117
- .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
- })
122
- .sort((a, b) => b.deg - a.deg)
123
- .slice(0, n)
124
- return [
125
- `Top ${n} most-connected nodes (call/import/reference edges, excluding structural containment):`,
126
- ...ranked.map(
127
- (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}]`
129
- ),
130
- ].join('\n')
131
- }
121
+ export {tGodNodes} from './tools-graph-hubs.mjs'
122
+
132
123
 
133
124
  export function tGetCommunity(g, {community_id} = {}) {
134
125
  const groups = new Map()
@@ -38,19 +38,38 @@ export function tFindDuplicates(g, args, ctx) {
38
38
  if (args.mode === 'semantic') {
39
39
  const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {nameTwins: true})
40
40
  const frags = data.frags
41
- const groups = (data.nameTwins || [])
42
- .map((t) => ({...t, members: t.members.filter((i) => (!skipTests || !frags[i].test) && frags[i].n >= tokMin)}))
43
- .map((t) => ({...t, fileCount: new Set(t.members.map((i) => frags[i].file)).size}))
44
- .filter((t) => t.members.length >= 2 && t.fileCount >= 2)
45
- if (!groups.length) return 'No same-name symbol groups across files (semantic mode).'
46
- const top = groups.slice(0, Math.min(30, Math.max(1, Number(args.top_n) || 15)))
47
- const lines = top.map((t, k) => {
48
- const verdict = t.simMax < 60 ? ' ⚠ DIVERGENT — same name, different bodies (drift hazard)' : t.simMin >= 90 ? ' near-identical — extract a shared module' : ''
49
- const head = `${k + 1}. "${t.label}" ${t.members.length} definitions in ${t.fileCount} files, similarity ${t.simMin}–${t.simMax}%${verdict}`
50
- const sites = t.members.slice(0, 8).map((i) => ` ${frags[i].file}:${frags[i].start}-${frags[i].end} (${frags[i].n} tok)`)
51
- return [head, ...sites].join('\n')
41
+ const candidates = []
42
+ for (const twin of data.nameTwins || []) {
43
+ const allowed = new Set(twin.members.filter((i) => (!skipTests || !frags[i].test) && frags[i].n >= tokMin))
44
+ const pairs = (twin.pairs || []).filter((p) => allowed.has(p.a) && allowed.has(p.b))
45
+ if (!pairs.length) continue
46
+ const closest = pairs.slice().sort((a, b) => b.similarity - a.similarity)[0]
47
+ const farthest = pairs.slice().sort((a, b) => a.similarity - b.similarity)[0]
48
+ if (closest.similarity >= 85) candidates.push({kind: 'clone', label: twin.label, pair: closest})
49
+ if (farthest.similarity <= 45) candidates.push({kind: 'collision', label: twin.label, pair: farthest})
50
+ }
51
+ for (const item of candidates) item.tokens = frags[item.pair.a].n + frags[item.pair.b].n
52
+ candidates.sort((a, b) => {
53
+ if (a.kind !== b.kind) return a.kind === 'clone' ? -1 : 1
54
+ return a.kind === 'clone'
55
+ ? b.pair.similarity - a.pair.similarity || b.tokens - a.tokens
56
+ : b.tokens - a.tokens || a.pair.similarity - b.pair.similarity
52
57
  })
53
- return `Found ${groups.length} same-name symbol group(s) across files (semantic mode; similarity = renamed-token jaccard, LOW = divergent copies). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nLow-similarity groups are the risky ones — same name, drifted behavior. read_source both sites to compare.`
58
+ if (!candidates.length) return 'No actionable same-name pairs across files (semantic mode; ambiguous middle-similarity pairs are suppressed).'
59
+ const top = candidates.slice(0, Math.min(30, Math.max(1, Number(args.top_n) || 15)))
60
+ const lines = top.map((item, k) => {
61
+ const a = frags[item.pair.a]
62
+ const b = frags[item.pair.b]
63
+ const verdict = item.kind === 'clone'
64
+ ? 'near-identical duplicate candidate — review, then extract shared logic if the contract is truly shared'
65
+ : 'name collision, not a duplicate — inspect only if these definitions should share a contract'
66
+ return [
67
+ `${k + 1}. "${item.label}" — ${item.pair.similarity}% similar; ${verdict}`,
68
+ ` ${a.file}:${a.start}-${a.end} (${a.n} tok)`,
69
+ ` ${b.file}:${b.start}-${b.end} (${b.n} tok)`,
70
+ ].join('\n')
71
+ })
72
+ return `Found ${candidates.length} actionable same-name pair(s) across files (semantic mode; one closest clone and/or farthest collision per name). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nThese are review candidates, not automatic refactors. Use read_source on both sites before changing code.`
54
73
  }
55
74
  const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {includeStrings})
56
75
  const groups = groupClones(data, {simMin, tokMin, mode, skipTests})
@@ -87,14 +106,15 @@ export async function tRunAudit(g, args, ctx) {
87
106
  const sev = audit.summary.bySeverity
88
107
  const bycat = audit.summary.byCategory
89
108
  const line = (f) => {
90
- const where = f.file ? ` (${f.file}${f.symbol ? ` ${f.symbol}` : ''})` : f.package ? ` (pkg ${f.package}${f.version ? `@${f.version}` : ''})` : ''
109
+ const where = f.file ? ` (${f.file}${f.symbol ? ` ${f.symbol}` : ''})` : f.package ? ` (pkg ${f.package}${f.version ? `@${f.version}` : ''}${f.manifest ? `; ${f.manifest}` : ''})` : ''
91
110
  return ` [${f.severity}/${f.confidence || '?'}] ${f.rule}: ${f.title}${where}${f.fixHint ? `\n fix: ${f.fixHint}` : ''}`
92
111
  }
112
+ const check = (name, state) => `${name} ${state?.status || 'ERROR'}${state?.detail ? ` — ${state.detail}` : ''}`
93
113
  return [
94
114
  `Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
95
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}.`,
96
- `Structure: ${audit.structureReport?.cycles ?? 0} cycle(s), ${audit.structureReport?.orphans ?? 0} orphan(s). Dead: ${audit.deadReport.deadFiles} file(s), ${audit.deadReport.unusedExports} unused export(s).`,
97
- audit.scanned.advisoryDbDate ? `Advisory DB: ${audit.scanned.advisoryDbDate}.` : 'Advisory DB: never refreshed for this repo known-vuln matching skipped (see refresh_advisories).',
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
+ `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.`,
98
118
  ``,
99
119
  `Showing ${shown.length} of ${filtered.length} finding(s)${cat ? ` in category "${cat}"` : ''}${args.min_severity ? ` at ≥${args.min_severity}` : ''}:`,
100
120
  ...shown.map(line),
@@ -120,13 +140,29 @@ export function tModuleMap(g, args, ctx) {
120
140
  const topN = Math.max(1, Math.min(60, Number(args.top_n) || 25))
121
141
  const mods = agg.modules.slice(0, topN)
122
142
  const edges = agg.moduleEdges.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))
123
156
  return [
124
- `Module map: ${agg.totals.files} files in ${agg.modules.length} folder-modules, ${agg.totals.moduleEdges} module edges. 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}:`,
125
158
  ...mods.map((m) => ` ${m.name} — ${m.fileCount} files, ${m.symbolCount} symbols`),
126
159
  ``,
127
- `Strongest module dependencies:`,
160
+ `Strongest runtime module dependencies:`,
128
161
  ...edges.map((e) => ` ${e.from} → ${e.to} (${e.count})`),
129
- ].join('\n')
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)`),
165
+ ].filter((line) => line != null).join('\n')
130
166
  }
131
167
 
132
168
  // Coverage × graph: map an EXISTING coverage report (istanbul/lcov/coverage.py/Go — read offline,
@@ -9,6 +9,47 @@ 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'
13
+
14
+ function reverseReach(g, seeds, maxDepth) {
15
+ const states = new Map([...seeds].map((id) => [String(id), {
16
+ runtimeDepth: 0, runtimeRelation: null, compileDepth: null, compileRelation: null,
17
+ }]))
18
+ const frontier = [...seeds].map((id) => ({id: String(id), depth: 0, compileOnly: false}))
19
+ for (let cursor = 0; cursor < frontier.length; cursor++) {
20
+ const current = frontier[cursor]
21
+ if (current.depth >= maxDepth) continue
22
+ for (const e of g.inn.get(current.id) || []) {
23
+ if (isStructuralRelation(e.relation)) continue
24
+ const id = String(e.id)
25
+ const compileOnly = current.compileOnly || e.typeOnly === true || e.compileOnly === true
26
+ const depth = current.depth + 1
27
+ const entry = states.get(id) || {
28
+ runtimeDepth: null, runtimeRelation: null, compileDepth: null, compileRelation: null,
29
+ }
30
+ const depthKey = compileOnly ? 'compileDepth' : 'runtimeDepth'
31
+ const relationKey = compileOnly ? 'compileRelation' : 'runtimeRelation'
32
+ if (entry[depthKey] != null && entry[depthKey] <= depth) continue
33
+ entry[depthKey] = depth
34
+ entry[relationKey] = e.relation || 'rel'
35
+ states.set(id, entry)
36
+ frontier.push({id, depth, compileOnly})
37
+ }
38
+ }
39
+ return new Map([...states].map(([id, entry]) => [id, {
40
+ ...entry,
41
+ depth: entry.runtimeDepth ?? entry.compileDepth ?? 0,
42
+ compileOnly: entry.runtimeDepth == null,
43
+ relation: entry.runtimeDepth != null ? entry.runtimeRelation : entry.compileRelation,
44
+ }]))
45
+ }
46
+
47
+ const impactKind = (entry) => {
48
+ if (entry?.runtimeDepth != null) {
49
+ return entry.compileDepth != null ? `runtime + compile-time(d${entry.compileDepth})` : 'runtime'
50
+ }
51
+ return 'compile-time'
52
+ }
12
53
 
13
54
  // Transitive blast-radius: who is affected if this node changes. Walks REVERSE dependency edges
14
55
  // (calls/imports/inherits — not structural `contains`) out to `depth`. For a symbol, also seeds its
@@ -30,34 +71,20 @@ export function tGetDependents(g, {label, depth = 3, max_nodes = 40} = {}) {
30
71
  seeds.add(containingFile)
31
72
  }
32
73
  }
33
- const depthOf = new Map([...seeds].map((s) => [s, 0]))
34
- const relOf = new Map()
35
- let frontier = [...seeds]
36
- for (let d = 0; d < maxDepth && frontier.length; d++) {
37
- const next = []
38
- for (const cur of frontier) {
39
- for (const e of g.inn.get(cur) || []) {
40
- if (e.relation === 'contains') continue // structural nesting is not a dependency
41
- const nid = String(e.id)
42
- if (depthOf.has(nid)) continue
43
- depthOf.set(nid, d + 1)
44
- relOf.set(nid, e.relation || 'rel')
45
- next.push(nid)
46
- }
47
- }
48
- frontier = next
49
- }
50
- const ranked = [...depthOf.entries()]
74
+ const reached = reverseReach(g, seeds, maxDepth)
75
+ const ranked = [...reached.entries()]
51
76
  .filter(([nid]) => !seeds.has(nid))
52
- .map(([nid, d]) => ({id: nid, d, deg: degreeOf(g, nid)}))
53
- .sort((a, b) => a.d - b.d || b.deg - a.deg)
77
+ .map(([nid, entry]) => ({id: nid, d: entry.depth, entry, deg: degreeOf(g, nid)}))
78
+ .sort((a, b) => a.d - b.d || Number(a.entry.compileOnly) - Number(b.entry.compileOnly) || b.deg - a.deg)
54
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')
55
80
  const shown = ranked.slice(0, cap)
81
+ const runtimeCount = ranked.filter((entry) => !entry.entry.compileOnly).length
82
+ const compileCount = ranked.length - runtimeCount
56
83
  return [
57
84
  note,
58
- `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.`,
59
86
  containingFile ? `Includes importers of its containing file ${labelOf(g, containingFile)}.` : null,
60
- ...shown.map((r) => ` [d${r.d}] ${relOf.get(r.id) || 'rel'} ${labelOf(g, r.id)} (deg ${r.deg}) [${r.id}]`),
87
+ ...shown.map((r) => ` [d${r.d} ${impactKind(r.entry)}] ${r.entry.relation || 'rel'} ${labelOf(g, r.id)} (deg ${r.deg}) [${r.id}]`),
61
88
  ].filter(Boolean).join('\n')
62
89
  }
63
90
 
@@ -72,7 +99,8 @@ export function tGraphDiff(g, args, ctx) {
72
99
  const filter = args.path ? String(args.path).replace(/\\/g, '/') : null
73
100
  const scope = (graph) => filter ? {
74
101
  nodes: (graph.nodes || []).filter((n) => String(n.id).startsWith(filter)),
75
- links: (graph.links || []).filter((l) => String(edgeEndpoint(l.source)).startsWith(filter) || String(edgeEndpoint(l.target)).startsWith(filter))
102
+ links: (graph.links || []).filter((l) => String(edgeEndpoint(l.source)).startsWith(filter) || String(edgeEndpoint(l.target)).startsWith(filter)),
103
+ edgeTypesV: graph.edgeTypesV || 0,
76
104
  } : graph
77
105
  return [
78
106
  `Graph diff (previous rebuild state → current)${filter ? `, scoped to ${filter}` : ''}:`,
@@ -144,43 +172,31 @@ export function tChangeImpact(g, args, ctx) {
144
172
 
145
173
  const maxDepth = Math.max(1, Math.min(4, Number(args.depth) || 2))
146
174
  const cap = Math.max(5, Math.min(120, Number(args.max_nodes) || 40))
147
- const depthOf = new Map([...seeds].map((s) => [s, 0]))
148
- const relOf = new Map()
149
- let frontier = [...seeds]
150
- for (let d = 0; d < maxDepth && frontier.length; d++) {
151
- const next = []
152
- for (const cur of frontier) {
153
- for (const e of g.inn.get(cur) || []) {
154
- if (e.relation === 'contains') continue
155
- const nid = String(e.id)
156
- if (depthOf.has(nid)) continue
157
- depthOf.set(nid, d + 1)
158
- relOf.set(nid, e.relation || 'rel')
159
- next.push(nid)
160
- }
161
- }
162
- frontier = next
163
- }
175
+ const reached = reverseReach(g, seeds, maxDepth)
164
176
  const fileOfNode = (id) => { const s = String(id); const h = s.indexOf('#'); return h < 0 ? s : s.slice(0, h) }
165
- const impacted = [...depthOf.entries()]
177
+ const impacted = [...reached.entries()]
166
178
  .filter(([nid]) => !seeds.has(nid) && !changedSet.has(fileOfNode(nid)))
167
- .map(([nid, d]) => ({id: nid, d, deg: degreeOf(g, nid), file: fileOfNode(nid)}))
168
- .sort((a, b) => a.d - b.d || b.deg - a.deg)
179
+ .map(([nid, entry]) => ({id: nid, d: entry.depth, entry, deg: degreeOf(g, nid), file: fileOfNode(nid)}))
180
+ .sort((a, b) => a.d - b.d || Number(a.entry.compileOnly) - Number(b.entry.compileOnly) || b.deg - a.deg)
169
181
 
170
182
  // coverage overlay from EXISTING reports (fractions 0..1) — see coverage_map for details
171
183
  const knownFiles = (rawGraph(ctx).nodes || []).filter((n) => !String(n.id).includes('#') && n.source_file).map((n) => n.source_file)
172
184
  const coverage = readCoverageForRepo(ctx.repoRoot, knownFiles)
173
185
  const covOf = (file) => coverage.get(String(file).replace(/\\/g, '/'))?.pct ?? null
174
186
  const pctStr = (v) => (v == null ? 'cov n/a' : `cov ${Math.round(v * 100)}%`)
187
+ const hasCoverage = coverage.size > 0
175
188
 
176
189
  const shown = impacted.slice(0, cap)
190
+ const runtimeImpacted = impacted.filter((entry) => !entry.entry.compileOnly).length
191
+ const compileImpacted = impacted.length - runtimeImpacted
177
192
  const untestedHotspots = shown.filter((n) => { const c = covOf(n.file); return c != null && c < 0.5 && n.deg >= 5 })
178
193
  return [
179
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)` : ''}.`,
180
195
  impacted.length
181
- ? `${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}:`
182
197
  : `Nothing else in the graph depends on the changed code within ${maxDepth} hop(s).`,
183
- ...shown.map((n) => ` [d${n.d}] ${relOf.get(n.id) || 'rel'} ${labelOf(g, n.id)} (deg ${n.deg}, ${pctStr(covOf(n.file))}) [${n.id}]`),
198
+ hasCoverage ? `Coverage: existing report mapped where available.` : `Coverage: unavailable (no supported report found); per-node coverage labels omitted.`,
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}]`),
184
200
  untestedHotspots.length ? `` : null,
185
201
  untestedHotspots.length ? `Untested hotspots in the blast radius (<50% coverage, deg ≥5) — cover these before shipping:` : null,
186
202
  ...untestedHotspots.slice(0, 10).map((n) => ` ${labelOf(g, n.id)} (${pctStr(covOf(n.file))}, deg ${n.deg}) ${n.file}`),
@@ -131,6 +131,9 @@ 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 < 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
+ }
134
137
  // Graph answers silently reflect a point-in-time build — surface staleness on every graph tool.
135
138
  if (tool.cap === 'graph') {
136
139
  const warn = api.stalenessLine(ctx)