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.
- package/README.md +92 -17
- package/package.json +1 -1
- package/skill/SKILL.md +62 -4
- package/src/analysis/dead-check.js +87 -6
- package/src/analysis/dep-check-ecosystems.js +157 -0
- package/src/analysis/dep-check.js +97 -133
- package/src/analysis/dep-rules.js +91 -12
- package/src/analysis/duplicates.compute.js +12 -18
- package/src/analysis/endpoints-rust.js +124 -0
- package/src/analysis/endpoints.js +56 -6
- package/src/analysis/graph-analysis.aggregate.js +34 -25
- package/src/analysis/graph-analysis.edges.js +21 -0
- package/src/analysis/graph-analysis.js +1 -1
- package/src/analysis/graph-analysis.summaries.js +4 -1
- package/src/analysis/internal-audit.collect.js +162 -25
- package/src/analysis/internal-audit.reach.js +52 -11
- package/src/analysis/internal-audit.run.js +71 -18
- package/src/graph/builder/lang-java.js +177 -14
- package/src/graph/builder/lang-js.js +66 -23
- package/src/graph/builder/lang-rust.js +129 -6
- package/src/graph/community.js +17 -0
- package/src/graph/internal-builder.build.js +79 -17
- package/src/graph/internal-builder.java.js +33 -0
- package/src/graph/internal-builder.langs.js +43 -2
- package/src/graph/internal-builder.resolvers.js +197 -21
- package/src/graph/relations.js +4 -0
- package/src/mcp/catalog.mjs +4 -4
- package/src/mcp/graph-context.mjs +22 -94
- package/src/mcp/graph-diff.mjs +163 -0
- package/src/mcp/sync-payload.mjs +36 -6
- package/src/mcp/tools-actions.mjs +22 -7
- package/src/mcp/tools-graph-hubs.mjs +58 -0
- package/src/mcp/tools-graph.mjs +13 -22
- package/src/mcp/tools-health.mjs +54 -18
- package/src/mcp/tools-impact.mjs +61 -45
- package/src/mcp-server.mjs +3 -0
- package/src/security/advisory-store.js +51 -7
package/src/mcp/sync-payload.mjs
CHANGED
|
@@ -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 =
|
|
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',
|
|
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 =
|
|
61
|
-
const 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 =
|
|
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:
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/mcp/tools-graph.mjs
CHANGED
|
@@ -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
|
|
115
|
-
|
|
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()
|
package/src/mcp/tools-health.mjs
CHANGED
|
@@ -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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
.filter((
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
package/src/mcp/tools-impact.mjs
CHANGED
|
@@ -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
|
|
34
|
-
const
|
|
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,
|
|
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}
|
|
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
|
|
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 = [...
|
|
177
|
+
const impacted = [...reached.entries()]
|
|
166
178
|
.filter(([nid]) => !seeds.has(nid) && !changedSet.has(fileOfNode(nid)))
|
|
167
|
-
.map(([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
|
-
|
|
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}`),
|
package/src/mcp-server.mjs
CHANGED
|
@@ -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)
|