weavatrix 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/bin/weavatrix-mcp.mjs +6 -0
  4. package/package.json +50 -0
  5. package/skill/SKILL.md +56 -0
  6. package/src/analysis/coverage-reports.js +232 -0
  7. package/src/analysis/dead-check.js +136 -0
  8. package/src/analysis/dep-check.js +310 -0
  9. package/src/analysis/dep-rules.js +221 -0
  10. package/src/analysis/duplicates-worker.js +11 -0
  11. package/src/analysis/duplicates.compute.js +215 -0
  12. package/src/analysis/duplicates.js +15 -0
  13. package/src/analysis/duplicates.run.js +51 -0
  14. package/src/analysis/duplicates.tokenize.js +182 -0
  15. package/src/analysis/endpoints.js +156 -0
  16. package/src/analysis/findings.js +52 -0
  17. package/src/analysis/graph-analysis.aggregate.js +280 -0
  18. package/src/analysis/graph-analysis.js +7 -0
  19. package/src/analysis/graph-analysis.refs.js +146 -0
  20. package/src/analysis/graph-analysis.summaries.js +62 -0
  21. package/src/analysis/internal-audit.collect.js +117 -0
  22. package/src/analysis/internal-audit.js +9 -0
  23. package/src/analysis/internal-audit.reach.js +47 -0
  24. package/src/analysis/internal-audit.run.js +189 -0
  25. package/src/analysis/manifests.js +169 -0
  26. package/src/analysis/source-complexity.ast.js +97 -0
  27. package/src/analysis/source-complexity.constants.js +55 -0
  28. package/src/analysis/source-complexity.js +14 -0
  29. package/src/analysis/source-complexity.report.js +76 -0
  30. package/src/analysis/source-complexity.walk.js +174 -0
  31. package/src/build-graph.js +102 -0
  32. package/src/config.js +5 -0
  33. package/src/graph/build-worker.js +30 -0
  34. package/src/graph/builder/lang-csharp.js +40 -0
  35. package/src/graph/builder/lang-css.js +29 -0
  36. package/src/graph/builder/lang-go.js +53 -0
  37. package/src/graph/builder/lang-html.js +29 -0
  38. package/src/graph/builder/lang-java.js +29 -0
  39. package/src/graph/builder/lang-js.js +168 -0
  40. package/src/graph/builder/lang-python.js +78 -0
  41. package/src/graph/builder/lang-rust.js +45 -0
  42. package/src/graph/builder/spec-pkg.js +87 -0
  43. package/src/graph/graph-filter.js +44 -0
  44. package/src/graph/internal-builder.build.js +217 -0
  45. package/src/graph/internal-builder.js +12 -0
  46. package/src/graph/internal-builder.langs.js +86 -0
  47. package/src/graph/internal-builder.resolvers.js +116 -0
  48. package/src/graph/layout.js +35 -0
  49. package/src/infra/infra-items.js +255 -0
  50. package/src/infra/infra-registry.js +184 -0
  51. package/src/infra/infra.detect.js +119 -0
  52. package/src/infra/infra.js +38 -0
  53. package/src/infra/infra.match.js +102 -0
  54. package/src/infra/infra.scan.js +93 -0
  55. package/src/mcp/catalog.mjs +68 -0
  56. package/src/mcp/graph-context.mjs +276 -0
  57. package/src/mcp/tools-actions.mjs +132 -0
  58. package/src/mcp/tools-graph.mjs +274 -0
  59. package/src/mcp/tools-health.mjs +209 -0
  60. package/src/mcp/tools-impact.mjs +189 -0
  61. package/src/mcp-rg.mjs +51 -0
  62. package/src/mcp-server.mjs +171 -0
  63. package/src/mcp-source-tools.mjs +139 -0
  64. package/src/process.js +77 -0
  65. package/src/scan/discover.inventory.js +78 -0
  66. package/src/scan/discover.js +5 -0
  67. package/src/scan/discover.list.js +79 -0
  68. package/src/scan/discover.stack.js +227 -0
  69. package/src/scan/search.core.js +24 -0
  70. package/src/scan/search.git.js +102 -0
  71. package/src/scan/search.js +9 -0
  72. package/src/scan/search.node.js +83 -0
  73. package/src/scan/search.preview.js +49 -0
  74. package/src/scan/search.rg.js +145 -0
  75. package/src/security/advisory-store.js +177 -0
  76. package/src/security/installed.js +247 -0
  77. package/src/security/malware-file-heuristics.js +121 -0
  78. package/src/security/malware-heuristics.exclusions.js +134 -0
  79. package/src/security/malware-heuristics.js +15 -0
  80. package/src/security/malware-heuristics.roots.js +142 -0
  81. package/src/security/malware-heuristics.scan.js +87 -0
  82. package/src/security/malware-heuristics.sweep.js +122 -0
  83. package/src/security/malware-scoring.js +84 -0
  84. package/src/security/match.js +85 -0
  85. package/src/security/registry-sig.classify.js +136 -0
  86. package/src/security/registry-sig.js +18 -0
  87. package/src/security/registry-sig.rules.js +188 -0
  88. package/src/security/typosquat.js +72 -0
  89. package/src/util.js +27 -0
@@ -0,0 +1,132 @@
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).
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'
6
+ import {prevGraphPathFor, diffGraphs, formatGraphDiff} from './graph-context.mjs'
7
+ import {buildGraphForRepo} from '../build-graph.js'
8
+ import {graphOutDirForRepo} from '../graph/layout.js'
9
+ import {refreshAdvisories, storeMeta, DEFAULT_STORE} from '../security/advisory-store.js'
10
+ import {collectInstalled} from '../security/installed.js'
11
+
12
+ export async function tRebuildGraph(g, args, ctx) {
13
+ if (!ctx.repoRoot) return 'Rebuild needs the repo root (not provided to this server).'
14
+ const mode = ['no-tests', 'tests-only', 'full'].includes(args.mode) ? args.mode : 'full'
15
+ // snapshot the outgoing state: bytes → graph.prev.json (for graph_diff later), struct → inline delta
16
+ let prevBytes = null
17
+ 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 res = await buildGraphForRepo(ctx.repoRoot, {mode, scope: args.scope || ''})
20
+ if (!res || !res.ok) return `Graph rebuild failed: ${(res && res.error) || 'unknown error'}`
21
+ if (prevBytes) { try { writeFileSync(prevGraphPathFor(ctx.graphPath), prevBytes) } catch { /* snapshot is best-effort */ } }
22
+ const fresh = ctx.reload() // refresh THIS server's in-memory graph so subsequent tool calls see the new graph
23
+ const delta = before && fresh ? formatGraphDiff(diffGraphs(before, fresh)) : null
24
+ return [
25
+ `Rebuilt the graph (${mode}${args.scope ? `, scope=${args.scope}` : ''}). ${res.log || ''}. In-memory graph reloaded — graph tools now reflect it.`,
26
+ delta
27
+ ].filter(Boolean).join('\n\n')
28
+ }
29
+
30
+ // Retarget this server at ANOTHER local repository at runtime — one weavatrix registration serves any
31
+ // repo. Loads <parent>/weavatrix-graphs/<name>/graph.json (the central layout graphs
32
+ // always live in), building it first when missing. On a failed load the previous repo stays active.
33
+ 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}`
37
+ const graphPath = join(graphOutDirForRepo(repoPath), 'graph.json')
38
+ 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.`
41
+ const mode = ['no-tests', 'tests-only', 'full'].includes(args.mode) ? args.mode : 'full'
42
+ const res = await buildGraphForRepo(repoPath, {mode, scope: ''})
43
+ if (!res || !res.ok) return `Graph build failed for ${repoPath}: ${(res && res.error) || 'unknown error'}`
44
+ built = true
45
+ }
46
+ const prev = {graphPath: ctx.graphPath, repoRoot: ctx.repoRoot}
47
+ ctx.graphPath = graphPath
48
+ ctx.repoRoot = repoPath
49
+ const loaded = ctx.reload()
50
+ if (!loaded) {
51
+ ctx.graphPath = prev.graphPath
52
+ ctx.repoRoot = prev.repoRoot
53
+ ctx.reload()
54
+ return `Failed to load ${graphPath} — still targeting the previous repo (${prev.repoRoot || 'none'}).`
55
+ }
56
+ return `Opened ${repoPath}${built ? ' (graph built fresh)' : ''}: ${loaded.nodes.length} nodes / ${loaded.links.length} edges. All tools now target this repo.`
57
+ }
58
+
59
+ // Sibling repos that already have a built graph in the central weavatrix-graphs folder — open_repo candidates.
60
+ export function tListKnownRepos(g, args, ctx) {
61
+ if (!ctx.repoRoot) return 'No repo root — cannot locate the central graphs folder.'
62
+ const parent = dirname(ctx.repoRoot)
63
+ const root = join(parent, 'weavatrix-graphs')
64
+ const norm = (p) => String(p).replace(/[\\/]+/g, '/').toLowerCase()
65
+ let entries = []
66
+ try { entries = readdirSync(root, {withFileTypes: true}) } catch { return `No central graphs folder at ${root}.` }
67
+ const rows = []
68
+ for (const entry of entries) {
69
+ if (!entry.isDirectory()) continue
70
+ const graphPath = join(root, entry.name, 'graph.json')
71
+ try {
72
+ const st = statSync(graphPath)
73
+ rows.push({name: entry.name, repoPath: join(parent, entry.name), builtAt: st.mtime.toISOString()})
74
+ } catch { /* no graph built for this entry */ }
75
+ }
76
+ if (!rows.length) return `No built graphs under ${root}.`
77
+ return [
78
+ `Repos with built graphs under ${root} (switch with open_repo):`,
79
+ ...rows.map((r) => ` ${norm(r.repoPath) === norm(ctx.repoRoot) ? '»' : ' '} ${r.name} — graph built ${r.builtAt} (${r.repoPath})`),
80
+ ].join('\n')
81
+ }
82
+
83
+ // ---- online tools ('online' capability group) -----------------------------------------------------
84
+ // Scans and graph queries stay 100% offline. These two run a network call ONLY when explicitly
85
+ // invoked; registering the server with a caps list that omits 'online' removes them entirely.
86
+
87
+ // Refresh the local OSV advisory store for the current repo's lockfile-pinned packages. What leaves
88
+ // the machine: package names + versions (that is what an OSV query IS) — never source code.
89
+ export async function tRefreshAdvisories(g, args, ctx) {
90
+ if (!ctx.repoRoot) return 'No repo root — cannot collect installed packages.'
91
+ const {installed} = collectInstalled(ctx.repoRoot)
92
+ if (!installed.length) return 'No pinned packages found in lockfiles (npm/yarn/pip/poetry/uv/go) — nothing to query.'
93
+ const res = await refreshAdvisories({installed, repoKey: ctx.repoRoot, timeoutMs: Number(args.timeout_ms) || undefined})
94
+ if (res.ok === false) return `Advisory refresh failed: ${res.error}`
95
+ const meta = storeMeta()
96
+ return [
97
+ `Advisory store refreshed from OSV.dev: ${res.queried} package versions queried, ${res.vulnerable} with known advisories (${res.fetched} advisory records fetched).`,
98
+ res.unsupported ? `${res.unsupported} packages skipped (ecosystem not OSV-queryable — npm/PyPI/Go only).` : null,
99
+ res.errors?.length ? `Partial: ${res.errors.length} request error(s), first: ${res.errors[0]}` : null,
100
+ `Store: ${DEFAULT_STORE} (${meta.advisoryCount} advisories, fetched ${meta.fetchedAt}). run_audit now reflects it — offline.`,
101
+ ].filter(Boolean).join('\n')
102
+ }
103
+
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.
107
+ export async function tSyncGraph(g, args, ctx) {
108
+ const url = process.env.WEAVATRIX_SYNC_URL
109
+ if (!url) {
110
+ return 'Graph sync is not configured (optional feature). Set WEAVATRIX_SYNC_URL to the upload endpoint'
111
+ + ' (and WEAVATRIX_SYNC_TOKEN for bearer auth) in the MCP registration env, then call again.'
112
+ }
113
+ 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}` }
116
+ const repoName = String(ctx.repoRoot || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || 'repo'
117
+ try {
118
+ const res = await fetch(url, {
119
+ method: 'POST',
120
+ headers: {
121
+ 'content-type': 'application/json',
122
+ 'x-weavatrix-repo': repoName,
123
+ ...(process.env.WEAVATRIX_SYNC_TOKEN ? {authorization: `Bearer ${process.env.WEAVATRIX_SYNC_TOKEN}`} : {}),
124
+ },
125
+ body,
126
+ })
127
+ 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}.`
129
+ } catch (e) {
130
+ return `Sync failed: ${e.message} — the graph stays local.`
131
+ }
132
+ }
@@ -0,0 +1,274 @@
1
+ // Graph query tools: stats, node lookup, neighbors, hubs, communities, exploratory traversal and
2
+ // shortest path. Pure reads over the loaded graph — no filesystem or process access beyond the
3
+ // staleness probe in graph-context. Hot-reloadable (re-imported by catalog.mjs on change).
4
+ import {
5
+ isSymbol, degreeOf, labelOf, connList,
6
+ resolveNodeInfo, resolveNode, ambiguityNote, findSeeds, undirectedNeighbors,
7
+ graphStaleness, fileStalenessNote,
8
+ } from './graph-context.mjs'
9
+
10
+ export function tGraphStats(g, ctx) {
11
+ const files = g.nodes.filter((n) => !isSymbol(n.id)).length
12
+ const symbols = g.nodes.length - files
13
+ const relCount = {}
14
+ const confCount = {}
15
+ for (const e of g.links) {
16
+ relCount[e.relation ?? '?'] = (relCount[e.relation ?? '?'] || 0) + 1
17
+ if (e.confidence != null) confCount[e.confidence] = (confCount[e.confidence] || 0) + 1
18
+ }
19
+ const comm = new Map()
20
+ for (const n of g.nodes) {
21
+ const c = n.community ?? 'none'
22
+ comm.set(c, (comm.get(c) || 0) + 1)
23
+ }
24
+ const topComm = [...comm.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8)
25
+ const fmt = (o) =>
26
+ Object.entries(o)
27
+ .sort((a, b) => b[1] - a[1])
28
+ .map(([k, v]) => `${k}: ${v}`)
29
+ .join(', ')
30
+ const freshness = ctx ? graphStaleness(ctx) : null
31
+ return [
32
+ `Graph summary`,
33
+ ctx?.repoRoot ? `- Repo: ${ctx.repoRoot}` : null,
34
+ `- Nodes: ${g.nodes.length} (${files} files, ${symbols} symbols)`,
35
+ `- Edges: ${g.links.length}`,
36
+ `- Relations: ${fmt(relCount)}`,
37
+ Object.keys(confCount).length ? `- Confidence: ${fmt(confCount)}` : null,
38
+ `- Communities: ${comm.size} (top by size: ${topComm.map(([c, n]) => `#${c}=${n}`).join(', ')})`,
39
+ freshness?.builtAt ? `- Built: ${freshness.builtAt.toISOString()}${freshness.headAt ? ` (repo HEAD committed ${freshness.headAt.toISOString()})` : ''}` : null,
40
+ ]
41
+ .filter(Boolean)
42
+ .join('\n')
43
+ }
44
+
45
+ export function tGetNode(g, {label} = {}, ctx) {
46
+ const info = resolveNodeInfo(g, label)
47
+ const n = info.node
48
+ if (!n) return `No node found matching "${label}".`
49
+ const note = ambiguityNote(label, info)
50
+ const id = String(n.id)
51
+ const drift = ctx ? fileStalenessNote(ctx, n.source_file || (isSymbol(id) ? id.split('#')[0] : id)) : null
52
+ const outs = g.out.get(id) || []
53
+ const ins = g.inn.get(id) || []
54
+ const sample = (list, dir) =>
55
+ list
56
+ .slice(0, 12)
57
+ .map((e) => ` ${dir === 'out' ? '→' : '←'} ${e.relation || 'rel'} ${labelOf(g, e.id)} [${e.id}]`)
58
+ .join('\n') || ' (none)'
59
+ return [
60
+ note,
61
+ `Node: ${n.label ?? id}`,
62
+ `- id: ${id}`,
63
+ `- kind: ${isSymbol(id) ? 'symbol' : 'file'}${n.file_type ? ` (${n.file_type})` : ''}`,
64
+ n.source_file ? `- source: ${n.source_file}${n.source_location ? ` ${n.source_location}` : ''}` : null,
65
+ n.community != null ? `- community: ${n.community}` : null,
66
+ `- degree: ${outs.length + ins.length} (out ${outs.length}, in ${ins.length})`,
67
+ `Outgoing:\n${sample(outs, 'out')}`,
68
+ `Incoming:\n${sample(ins, 'in')}`,
69
+ drift,
70
+ ]
71
+ .filter(Boolean)
72
+ .join('\n')
73
+ }
74
+
75
+ // Collapse repeated edges to the same neighbor (one per call site in the graph) into `(N sites)` —
76
+ // a hub function's caller list shrinks ~2-3x with no information loss.
77
+ function dedupeEdges(list) {
78
+ const grouped = new Map()
79
+ for (const e of list) {
80
+ const key = `${e.relation || 'rel'}|${e.id}`
81
+ const cur = grouped.get(key)
82
+ if (cur) cur.count += 1
83
+ else grouped.set(key, {id: e.id, relation: e.relation, count: 1})
84
+ }
85
+ return [...grouped.values()]
86
+ }
87
+
88
+ export function tGetNeighbors(g, {label, relation_filter} = {}, ctx) {
89
+ const info = resolveNodeInfo(g, label)
90
+ const n = info.node
91
+ if (!n) return `No node found matching "${label}".`
92
+ const note = ambiguityNote(label, info)
93
+ const id = String(n.id)
94
+ const drift = ctx ? fileStalenessNote(ctx, n.source_file || (isSymbol(id) ? id.split('#')[0] : id)) : null
95
+ const rf = relation_filter ? String(relation_filter).toLowerCase() : null
96
+ const match = (e) => !rf || String(e.relation ?? '').toLowerCase() === rf
97
+ const outsRaw = (g.out.get(id) || []).filter(match)
98
+ const insRaw = (g.inn.get(id) || []).filter(match)
99
+ const outs = dedupeEdges(outsRaw)
100
+ const ins = dedupeEdges(insRaw)
101
+ const line = (e, dir) =>
102
+ ` ${dir === 'out' ? '→' : '←'} ${e.relation || 'rel'} ${labelOf(g, e.id)} [${e.id}]${e.count > 1 ? ` (${e.count} sites)` : ''}`
103
+ return [
104
+ note,
105
+ `Neighbors of ${n.label ?? id}${rf ? ` (relation=${rf})` : ''}: ${outs.length + ins.length} unique (${outsRaw.length + insRaw.length} edges)`,
106
+ `Outgoing (${outs.length}):`,
107
+ ...outs.slice(0, 60).map((e) => line(e, 'out')),
108
+ `Incoming (${ins.length}):`,
109
+ ...ins.slice(0, 60).map((e) => line(e, 'in')),
110
+ drift,
111
+ ].filter(Boolean).join('\n')
112
+ }
113
+
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
+ }
132
+
133
+ export function tGetCommunity(g, {community_id} = {}) {
134
+ const groups = new Map()
135
+ for (const node of g.nodes) {
136
+ const c = node.community
137
+ if (c == null) continue
138
+ if (!groups.has(c)) groups.set(c, [])
139
+ groups.get(c).push(node)
140
+ }
141
+ const ranked = [...groups.entries()].sort((a, b) => b[1].length - a[1].length) // 0-indexed by size
142
+ const idx = Number(community_id)
143
+ if (!Number.isInteger(idx) || idx < 0 || idx >= ranked.length)
144
+ return `Invalid community_id ${community_id}. Valid range 0..${ranked.length - 1} (0 = largest).`
145
+ const [rawId, members] = ranked[idx]
146
+ const files = members.filter((m) => !isSymbol(m.id))
147
+ return [
148
+ `Community #${idx} (raw id ${rawId}) — ${members.length} nodes, ${files.length} files:`,
149
+ ...members
150
+ .slice()
151
+ .sort((a, b) => degreeOf(g, b.id) - degreeOf(g, a.id))
152
+ .slice(0, 80)
153
+ .map((m) => ` ${m.label ?? m.id} [${m.id}]`),
154
+ members.length > 80 ? ` … +${members.length - 80} more` : null,
155
+ ]
156
+ .filter(Boolean)
157
+ .join('\n')
158
+ }
159
+
160
+ // A plain BFS/DFS flood dumps every reached node (thousands on a real graph) at near-zero signal.
161
+ // Instead: traverse to record reach + distance-from-seed, then show only the closest, most-connected
162
+ // slice as a coherent subgraph (edges kept only among shown nodes). Honest about what was trimmed.
163
+ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filter, token_budget = 2000} = {}) {
164
+ const seeds = findSeeds(g, question, 6)
165
+ if (!seeds.length) return `No nodes matched "${question}".`
166
+ const maxDepth = Math.max(1, Math.min(6, Number(depth) || 3))
167
+ const ctx = Array.isArray(context_filter) && context_filter.length ? new Set(context_filter.map((c) => String(c).toLowerCase())) : null
168
+ const relOk = (rel) => !ctx || ctx.has(String(rel ?? '').toLowerCase())
169
+ const charBudget = Math.max(400, (Number(token_budget) || 2000) * 4)
170
+ // node budget scales gently with the token budget; edges follow the surviving nodes.
171
+ const nodeBudget = Math.max(20, Math.min(120, Math.round((Number(token_budget) || 2000) / 40)))
172
+ const depthOf = new Map() // id -> shortest distance from any seed
173
+ const edges = []
174
+ const start = seeds.map((s) => String(s.id))
175
+ if (mode === 'dfs') {
176
+ const stack = start.map((id) => ({id, d: 0}))
177
+ const seen = new Set()
178
+ while (stack.length) {
179
+ const {id, d} = stack.pop()
180
+ if (!depthOf.has(id) || d < depthOf.get(id)) depthOf.set(id, d)
181
+ if (seen.has(id)) continue
182
+ seen.add(id)
183
+ if (d >= maxDepth) continue
184
+ for (const [nid, rel] of undirectedNeighbors(g, id)) {
185
+ if (!relOk(rel)) continue
186
+ edges.push([id, rel, nid])
187
+ if (!seen.has(nid)) stack.push({id: nid, d: d + 1})
188
+ }
189
+ }
190
+ } else {
191
+ let frontier = start.slice()
192
+ start.forEach((id) => depthOf.set(id, 0))
193
+ for (let d = 0; d < maxDepth && frontier.length; d++) {
194
+ const next = []
195
+ for (const id of frontier)
196
+ for (const [nid, rel] of undirectedNeighbors(g, id)) {
197
+ if (!relOk(rel)) continue
198
+ edges.push([id, rel, nid])
199
+ if (!depthOf.has(nid)) {
200
+ depthOf.set(nid, d + 1)
201
+ next.push(nid)
202
+ }
203
+ }
204
+ frontier = next
205
+ }
206
+ }
207
+ // rank reached nodes: seeds first, then by proximity (depth asc), then connectivity (degree desc)
208
+ const ranked = [...depthOf.entries()]
209
+ .map(([id, d]) => ({id, d, deg: degreeOf(g, id)}))
210
+ .sort((a, b) => a.d - b.d || b.deg - a.deg)
211
+ const shown = ranked.slice(0, nodeBudget)
212
+ const shownIds = new Set(shown.map((n) => n.id))
213
+ const edgeSeen = new Set()
214
+ const shownEdges = []
215
+ for (const [s, r, t] of edges) {
216
+ if (!shownIds.has(s) || !shownIds.has(t)) continue
217
+ const key = `${s}|${r}|${t}`
218
+ if (edgeSeen.has(key)) continue
219
+ edgeSeen.add(key)
220
+ shownEdges.push([s, r, t])
221
+ if (shownEdges.length >= 160) break
222
+ }
223
+ const head = [
224
+ `Query: "${question}" (${mode}, depth ${maxDepth}${ctx ? `, context ${[...ctx].join('/')}` : ''})`,
225
+ `Seeds: ${seeds.map((s) => s.label ?? s.id).join(', ')}`,
226
+ `Reached ${depthOf.size} nodes; showing ${shown.length} closest by proximity + connectivity, ${shownEdges.length} edges among them.`,
227
+ ``,
228
+ `Nodes:`,
229
+ ]
230
+ const nodeLines = shown.map((n) => ` [d${n.d}] ${labelOf(g, n.id)} (deg ${n.deg}) [${n.id}]`)
231
+ const edgeLines = ['', 'Edges:', ...shownEdges.map(([s, r, t]) => ` ${labelOf(g, s)} --${r || 'rel'}--> ${labelOf(g, t)}`)]
232
+ let text = [...head, ...nodeLines, ...edgeLines].join('\n')
233
+ if (text.length > charBudget) text = text.slice(0, charBudget) + `\n... (truncated to ~${token_budget} tokens)`
234
+ return text
235
+ }
236
+
237
+ export function tShortestPath(g, {source, target, max_hops = 8} = {}) {
238
+ const s = resolveNode(g, source)
239
+ const t = resolveNode(g, target)
240
+ if (!s) return `Source "${source}" not found.`
241
+ if (!t) return `Target "${target}" not found.`
242
+ const sid = String(s.id)
243
+ const tid = String(t.id)
244
+ if (sid === tid) return `Source and target are the same node: ${s.label ?? sid}.`
245
+ const cap = Math.max(1, Math.min(20, Number(max_hops) || 8))
246
+ const prev = new Map([[sid, null]])
247
+ const relTo = new Map()
248
+ let frontier = [sid]
249
+ let hops = 0
250
+ let found = false
251
+ while (frontier.length && hops < cap && !found) {
252
+ const next = []
253
+ for (const id of frontier) {
254
+ for (const [nid, rel] of undirectedNeighbors(g, id)) {
255
+ if (prev.has(nid)) continue
256
+ prev.set(nid, id)
257
+ relTo.set(nid, rel)
258
+ if (nid === tid) {
259
+ found = true
260
+ break
261
+ }
262
+ next.push(nid)
263
+ }
264
+ if (found) break
265
+ }
266
+ frontier = next
267
+ hops++
268
+ }
269
+ if (!prev.has(tid)) return `No path found between "${s.label ?? sid}" and "${t.label ?? tid}" within ${cap} hops.`
270
+ const path = []
271
+ for (let cur = tid; cur != null; cur = prev.get(cur)) path.unshift(cur)
272
+ const lines = path.map((id, i) => (i === 0 ? ` ${labelOf(g, id)}` : ` --${relTo.get(id) || 'rel'}--> ${labelOf(g, id)}`))
273
+ return [`Shortest path (${path.length - 1} hops): ${s.label ?? sid} → ${t.label ?? tid}`, ...lines].join('\n')
274
+ }
@@ -0,0 +1,209 @@
1
+ // Health tools: clone detection, the internal audit, community/module overviews, coverage mapping
2
+ // and the HTTP endpoint inventory. Hot-reloadable (re-imported by catalog.mjs on change).
3
+ import {degreeOf, rawGraph} from './graph-context.mjs'
4
+ import {computeDuplicates} from '../analysis/duplicates.js'
5
+ import {runInternalAudit} from '../analysis/internal-audit.js'
6
+ import {summarizeCommunities, aggregateGraph} from '../analysis/graph-analysis.js'
7
+ import {detectEndpoints} from '../analysis/endpoints.js'
8
+
9
+ // Group clone pairs into union-find families.
10
+ function groupClones(data, {simMin, tokMin, mode, skipTests}) {
11
+ const frags = data.frags || []
12
+ const elig = (i) => frags[i].n >= tokMin && (!skipTests || !frags[i].test)
13
+ const pairs = (data.modes?.[mode] || []).filter(([i, j, s]) => s >= simMin && elig(i) && elig(j))
14
+ const parent = new Map()
15
+ const find = (x) => { let r = x; while (parent.has(r) && parent.get(r) !== r) r = parent.get(r); return r }
16
+ for (const [i, j] of pairs) { if (!parent.has(i)) parent.set(i, i); if (!parent.has(j)) parent.set(j, j); parent.set(find(i), find(j)) }
17
+ const groups = new Map()
18
+ for (const [i, j, s] of pairs) {
19
+ const r = find(i)
20
+ if (!groups.has(r)) groups.set(r, {members: new Set(), maxSim: 0})
21
+ const g = groups.get(r); g.members.add(i); g.members.add(j); g.maxSim = Math.max(g.maxSim, s)
22
+ }
23
+ return [...groups.values()].map((g) => {
24
+ const members = [...g.members].sort((a, b) => frags[b].n - frags[a].n)
25
+ return {members: members.map((i) => frags[i]), maxSim: g.maxSim, tokens: members.reduce((n, i) => n + frags[i].n, 0)}
26
+ }).sort((a, b) => b.tokens - a.tokens)
27
+ }
28
+
29
+ export function tFindDuplicates(g, args, ctx) {
30
+ if (!ctx.repoRoot) return 'Duplicate scan needs the repo root (not provided to this server).'
31
+ const simMin = Math.min(100, Math.max(50, Number(args.min_similarity) || 80))
32
+ const tokMin = Math.min(400, Math.max(30, Number(args.min_tokens) || 50))
33
+ const mode = args.mode === 'strict' ? 'strict' : 'renamed'
34
+ const skipTests = args.include_tests ? false : true
35
+ const includeStrings = !!args.include_strings
36
+ // semantic mode: same-name symbols across files, ranked by size — LOW similarity is the signal
37
+ // (same name, drifted behavior). Token-clone pairing is skipped entirely.
38
+ if (args.mode === 'semantic') {
39
+ const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {nameTwins: true})
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')
52
+ })
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.`
54
+ }
55
+ const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {includeStrings})
56
+ const groups = groupClones(data, {simMin, tokMin, mode, skipTests})
57
+ if (!groups.length) return `No clones at ≥${simMin}% similarity / ≥${tokMin} tokens (${mode} mode). Try lowering the thresholds.`
58
+ const top = groups.slice(0, Math.min(30, Math.max(1, Number(args.top_n) || 15)))
59
+ const lines = top.map((grp, k) => {
60
+ const isStr = grp.members.some((f) => f.kind === 'string')
61
+ const head = `${k + 1}. ${grp.members.length}× "${grp.members[0].label}"${isStr ? ' [string literal]' : ''} — ≤${grp.maxSim}% similar, ${grp.tokens} duplicated tokens`
62
+ const sites = grp.members.slice(0, 8).map((f) => ` ${f.file}:${f.start}-${f.end}`)
63
+ return [head, ...sites].join('\n')
64
+ })
65
+ return `Found ${groups.length} clone group(s) (${mode} mode, ≥${simMin}%, ≥${tokMin} tok${includeStrings ? ', incl. large string literals' : ''}). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nUse read_source on any two sites to compare, then extract shared logic.`
66
+ }
67
+
68
+ const SEVERITY_RANK = {critical: 0, high: 1, medium: 2, low: 3, info: 4}
69
+
70
+ // Full internal health audit: dead code + unused exports, dependency findings (npm/go/py missing &
71
+ // unused deps), structure (import cycles / orphans / boundary rules), supply-chain (offline OSV
72
+ // advisories, typosquat, lockfile drift), optional malware heuristics.
73
+ export async function tRunAudit(g, args, ctx) {
74
+ if (!ctx.repoRoot) return 'Audit needs the repo root (not provided to this server).'
75
+ const audit = await runInternalAudit(ctx.repoRoot, {
76
+ graph: rawGraph(ctx),
77
+ skipMalwareScan: !args.include_malware_scan, // greps installed packages — slow, so opt-in
78
+ })
79
+ if (!audit.ok) return `Audit failed: ${audit.error}`
80
+ const minSev = SEVERITY_RANK[args.min_severity] ?? 4
81
+ const cat = args.category ? String(args.category) : null
82
+ const max = Math.max(1, Math.min(100, Number(args.max_findings) || 30))
83
+ const filtered = audit.findings
84
+ .filter((f) => (SEVERITY_RANK[f.severity] ?? 4) <= minSev)
85
+ .filter((f) => !cat || f.category === cat)
86
+ const shown = filtered.slice(0, max)
87
+ const sev = audit.summary.bySeverity
88
+ const bycat = audit.summary.byCategory
89
+ const line = (f) => {
90
+ const where = f.file ? ` (${f.file}${f.symbol ? ` ${f.symbol}` : ''})` : f.package ? ` (pkg ${f.package}${f.version ? `@${f.version}` : ''})` : ''
91
+ return ` [${f.severity}/${f.confidence || '?'}] ${f.rule}: ${f.title}${where}${f.fixHint ? `\n fix: ${f.fixHint}` : ''}`
92
+ }
93
+ return [
94
+ `Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
95
+ `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).',
98
+ ``,
99
+ `Showing ${shown.length} of ${filtered.length} finding(s)${cat ? ` in category "${cat}"` : ''}${args.min_severity ? ` at ≥${args.min_severity}` : ''}:`,
100
+ ...shown.map(line),
101
+ filtered.length > shown.length ? ` … +${filtered.length - shown.length} more (raise max_findings or filter by category/min_severity)` : null,
102
+ ].filter((x) => x != null).join('\n')
103
+ }
104
+
105
+ // Named module clusters: graph communities labeled by their dominant folder instead of bare numbers.
106
+ export function tListCommunities(g, args, ctx) {
107
+ const max = Math.max(1, Math.min(100, Number(args.top_n) || 20))
108
+ const list = summarizeCommunities(ctx.graphPath, max)
109
+ if (!list.length) return 'No communities found in the graph.'
110
+ return [
111
+ `Communities, largest first (list position = community_id for get_community):`,
112
+ ...list.map((c, i) => `${String(i).padStart(3)}. ${c.name} — ${c.size} nodes (raw id ${c.id}; e.g. ${[...new Set(c.files)].join(', ')})`),
113
+ ].join('\n')
114
+ }
115
+
116
+ // Folder-level architecture map: modules (top-two path segments) with file/symbol counts and the
117
+ // strongest module→module dependencies. Pure graph aggregation — no filesystem reads.
118
+ export function tModuleMap(g, args, ctx) {
119
+ const agg = aggregateGraph(rawGraph(ctx), null)
120
+ const topN = Math.max(1, Math.min(60, Number(args.top_n) || 25))
121
+ const mods = agg.modules.slice(0, topN)
122
+ const edges = agg.moduleEdges.slice(0, Math.min(50, topN * 2))
123
+ return [
124
+ `Module map: ${agg.totals.files} files in ${agg.modules.length} folder-modules, ${agg.totals.moduleEdges} module edges. Top ${mods.length}:`,
125
+ ...mods.map((m) => ` ${m.name} — ${m.fileCount} files, ${m.symbolCount} symbols`),
126
+ ``,
127
+ `Strongest module dependencies:`,
128
+ ...edges.map((e) => ` ${e.from} → ${e.to} (${e.count})`),
129
+ ].join('\n')
130
+ }
131
+
132
+ // Coverage × graph: map an EXISTING coverage report (istanbul/lcov/coverage.py/Go — read offline,
133
+ // tests are never executed here) onto files and symbols, then rank refactor risk as
134
+ // connectivity × uncovered share. Pairs with get_dependents: many dependents + low coverage ⇒ write
135
+ // tests before changing. Coverage pcts in this layer are fractions (0..1).
136
+ export function tCoverageMap(g, args, ctx) {
137
+ if (!ctx.repoRoot) return 'Coverage mapping needs the repo root (not provided to this server).'
138
+ const agg = aggregateGraph(rawGraph(ctx), ctx.repoRoot)
139
+ const pathFilter = args.path ? String(args.path).replace(/\\/g, '/').replace(/\/+$/, '') : null
140
+ const inScope = (p) => !pathFilter || p === pathFilter || String(p).startsWith(`${pathFilter}/`)
141
+ const allFiles = agg.modules.flatMap((m) => m.files.filter((f) => inScope(f.path)))
142
+ const measured = allFiles.filter((f) => f.coverage != null)
143
+ if (!measured.length) {
144
+ return [
145
+ `No coverage report found${pathFilter ? ` for ${pathFilter}` : ''} — this tool reads existing reports, it does not run tests.`,
146
+ 'Generate one with the repo\'s own test runner, then call coverage_map again:',
147
+ ' JS/TS: npx vitest run --coverage (or jest --coverage)',
148
+ ' Python: pytest --cov --cov-report=json',
149
+ ' Go: go test ./... -coverprofile=coverage.out',
150
+ 'Read locations: coverage/coverage-summary.json, coverage/coverage-final.json, (coverage/)lcov.info, coverage.json, coverage.out.',
151
+ ].join('\n')
152
+ }
153
+ const pctStr = (v) => (v == null ? 'n/a' : `${Math.round(v * 100)}%`)
154
+ const sources = [...new Set(measured.map((f) => f.coverageSource).filter(Boolean))]
155
+ const avg = measured.reduce((s, f) => s + f.coverage, 0) / measured.length
156
+ const rollup = agg.modules
157
+ .map((m) => {
158
+ const withCov = m.files.filter((f) => f.coverage != null && inScope(f.path))
159
+ if (!withCov.length) return null
160
+ return {
161
+ name: m.name,
162
+ measured: withCov.length,
163
+ total: m.files.filter((f) => inScope(f.path)).length,
164
+ avg: withCov.reduce((s, f) => s + f.coverage, 0) / withCov.length,
165
+ }
166
+ })
167
+ .filter(Boolean)
168
+ .sort((a, b) => a.avg - b.avg)
169
+ const topN = Math.max(1, Math.min(50, Number(args.top_n) || 15))
170
+ // risk = graph degree × uncovered share; only symbols below 80% matter
171
+ const risky = agg.symbols
172
+ .filter((s) => s.coverage != null && s.coverage < 0.8 && inScope(s.file))
173
+ .map((s) => ({...s, degree: degreeOf(g, s.id)}))
174
+ .filter((s) => s.degree > 0)
175
+ .sort((a, b) => b.degree * (1 - b.coverage) - a.degree * (1 - a.coverage))
176
+ .slice(0, topN)
177
+ return [
178
+ `Coverage map (${measured.length}/${allFiles.length} files measured, avg ${pctStr(avg)}; report: ${sources.join(', ') || 'unknown'}${pathFilter ? `; filter ${pathFilter}` : ''}).`,
179
+ ``,
180
+ `Modules by average coverage (worst first):`,
181
+ ...rollup.slice(0, 20).map((m) => ` ${pctStr(m.avg).padStart(5)} ${m.name} (${m.measured}/${m.total} files measured)`),
182
+ ``,
183
+ `Refactor-risk hotspots — connected symbols with low coverage (ranked by degree × uncovered):`,
184
+ ...(risky.length
185
+ ? risky.map((s) => ` ${pctStr(s.coverage).padStart(5)} deg ${String(s.degree).padStart(3)} ${s.label} (${s.file}${s.line ? `:${s.line}` : ''})`)
186
+ : [' (none — every connected symbol is ≥80% covered or unmeasured)']),
187
+ ``,
188
+ `Tip: before refactoring a hotspot, run get_dependents on it — low coverage × many dependents means write tests first.`,
189
+ ].join('\n')
190
+ }
191
+
192
+ // HTTP endpoint inventory: Express/Fastify/Nest/Flask/FastAPI/Go-mux style route definitions.
193
+ export function tListEndpoints(g, args, ctx) {
194
+ if (!ctx.repoRoot) return 'Endpoint detection needs the repo root (not provided to this server).'
195
+ const graph = rawGraph(ctx)
196
+ const codeFiles = [...new Set(
197
+ (graph.nodes || [])
198
+ .filter((n) => !String(n.id).includes('#') && n.source_file && n.file_type === 'code')
199
+ .map((n) => n.source_file)
200
+ )]
201
+ const eps = detectEndpoints(ctx.repoRoot, codeFiles)
202
+ if (!eps.length) return 'No HTTP endpoints detected in the indexed code files.'
203
+ const max = Math.max(1, Math.min(300, Number(args.max_results) || 100))
204
+ const shown = eps.slice(0, max)
205
+ return [
206
+ `${eps.length} endpoint(s) detected${eps.length > shown.length ? `, showing ${shown.length}` : ''}:`,
207
+ ...shown.map((e) => ` ${e.method.toUpperCase().padEnd(6)} ${e.path}${e.handler ? ` → ${e.handler}` : ''} (${e.file}${e.line ? `:${e.line}` : ''})`),
208
+ ].join('\n')
209
+ }