weavatrix 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -11
- package/package.json +1 -1
- package/skill/SKILL.md +54 -3
- package/src/analysis/dead-check.js +80 -4
- package/src/analysis/dep-check.js +84 -8
- package/src/analysis/dep-rules.js +74 -8
- package/src/analysis/duplicates.compute.js +12 -18
- package/src/analysis/endpoints.js +45 -2
- package/src/analysis/graph-analysis.aggregate.js +11 -3
- package/src/analysis/internal-audit.collect.js +122 -25
- package/src/analysis/internal-audit.reach.js +52 -11
- package/src/analysis/internal-audit.run.js +65 -17
- package/src/graph/builder/lang-js.js +66 -23
- package/src/graph/internal-builder.build.js +48 -10
- package/src/graph/internal-builder.langs.js +43 -2
- package/src/graph/internal-builder.resolvers.js +88 -19
- package/src/mcp/catalog.mjs +2 -2
- package/src/mcp/graph-context.mjs +100 -13
- package/src/mcp/sync-payload.mjs +9 -1
- package/src/mcp/tools-actions.mjs +22 -7
- package/src/mcp/tools-graph.mjs +46 -12
- package/src/mcp/tools-health.mjs +42 -18
- package/src/mcp/tools-impact.mjs +52 -41
- package/src/mcp-server.mjs +3 -0
- package/src/security/advisory-store.js +51 -7
|
@@ -47,29 +47,98 @@ export function buildResolvers(repoDir, fileSet) {
|
|
|
47
47
|
return cands.find((f) => f === full || f.endsWith("/" + full)) || cands[0] || null;
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
const
|
|
53
|
-
const
|
|
50
|
+
// Path aliases (tsconfig compilerOptions.paths + vite/webpack alias) are scoped to their config folder.
|
|
51
|
+
// Without nearest-config resolution, a monorepo's root `@/*` can hijack the same alias in web/.
|
|
52
|
+
const aliasContexts = new Map();
|
|
53
|
+
const cleanRel = (p) => String(p || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+/g, "/").replace(/\/$/, "").replace(/^\.$/, "");
|
|
54
|
+
const contextFor = (dir) => {
|
|
55
|
+
dir = cleanRel(dir);
|
|
56
|
+
let ctx = aliasContexts.get(dir);
|
|
57
|
+
if (!ctx) aliasContexts.set(dir, (ctx = { dir, aliases: [], baseUrls: [] }));
|
|
58
|
+
return ctx;
|
|
59
|
+
};
|
|
60
|
+
const addAlias = (ctx, a, t) => {
|
|
54
61
|
a = String(a).replace(/\/\*$/, "").replace(/\/$/, "");
|
|
55
|
-
t = String(t).replace(/\/\*$/, "")
|
|
56
|
-
if (a && t && !
|
|
62
|
+
t = cleanRel(String(t)).replace(/\/\*$/, "");
|
|
63
|
+
if (a && t && !ctx.aliases.some((x) => x.alias === a)) ctx.aliases.push({ alias: a, target: t });
|
|
57
64
|
};
|
|
58
|
-
const
|
|
59
|
-
|
|
65
|
+
const parseJsonc = (raw) => {
|
|
66
|
+
// Regex comment stripping corrupts perfectly valid path strings such as `"@/*"` followed later by
|
|
67
|
+
// `"**/*.ts"`. Strip comments/trailing commas only while outside JSON strings.
|
|
68
|
+
raw = String(raw).replace(/^\uFEFF/, "");
|
|
69
|
+
let clean = ""; let inString = false; let escaped = false;
|
|
70
|
+
for (let i = 0; i < raw.length; i++) {
|
|
71
|
+
const ch = raw[i], next = raw[i + 1];
|
|
72
|
+
if (inString) {
|
|
73
|
+
clean += ch;
|
|
74
|
+
if (escaped) escaped = false;
|
|
75
|
+
else if (ch === "\\") escaped = true;
|
|
76
|
+
else if (ch === '"') inString = false;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (ch === '"') { inString = true; clean += ch; continue; }
|
|
80
|
+
if (ch === "/" && next === "/") { while (i < raw.length && raw[i] !== "\n") i++; clean += "\n"; continue; }
|
|
81
|
+
if (ch === "/" && next === "*") {
|
|
82
|
+
i += 2;
|
|
83
|
+
while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) { if (raw[i] === "\n") clean += "\n"; i++; }
|
|
84
|
+
i++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
clean += ch;
|
|
88
|
+
}
|
|
89
|
+
let withoutTrailing = ""; inString = false; escaped = false;
|
|
90
|
+
for (let i = 0; i < clean.length; i++) {
|
|
91
|
+
const ch = clean[i];
|
|
92
|
+
if (inString) {
|
|
93
|
+
withoutTrailing += ch;
|
|
94
|
+
if (escaped) escaped = false;
|
|
95
|
+
else if (ch === "\\") escaped = true;
|
|
96
|
+
else if (ch === '"') inString = false;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (ch === '"') { inString = true; withoutTrailing += ch; continue; }
|
|
100
|
+
if (ch === ",") {
|
|
101
|
+
let j = i + 1; while (/\s/.test(clean[j] || "")) j++;
|
|
102
|
+
if (clean[j] === "}" || clean[j] === "]") continue;
|
|
103
|
+
}
|
|
104
|
+
withoutTrailing += ch;
|
|
105
|
+
}
|
|
106
|
+
return JSON.parse(withoutTrailing);
|
|
107
|
+
};
|
|
108
|
+
const configRank = (fr) => /(^|\/)tsconfig\.json$/i.test(fr) ? 0 : /(^|\/)jsconfig\.json$/i.test(fr) ? 1 : 2;
|
|
109
|
+
const configFiles = [...fileSet]
|
|
110
|
+
.filter((fr) => /(^|\/)(?:tsconfig(?:\.[^/]+)?|jsconfig)\.json$/i.test(fr))
|
|
111
|
+
.sort((a, b) => dirname(a).localeCompare(dirname(b)) || configRank(a) - configRank(b) || a.localeCompare(b));
|
|
112
|
+
for (const cfg of configFiles) {
|
|
60
113
|
try {
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
if (co.baseUrl != null && !
|
|
65
|
-
for (const [k, v] of Object.entries(paths)) {
|
|
114
|
+
const tj = parseJsonc(readLocal(cfg)); const co = tj.compilerOptions || {}; const paths = co.paths || {};
|
|
115
|
+
const cfgDir = cleanRel(dirname(cfg)); const ctx = contextFor(cfgDir);
|
|
116
|
+
const baseRoot = cleanRel(join(cfgDir || ".", String(co.baseUrl || ".")));
|
|
117
|
+
if (co.baseUrl != null && !ctx.baseUrls.includes(baseRoot)) ctx.baseUrls.push(baseRoot);
|
|
118
|
+
for (const [k, v] of Object.entries(paths)) {
|
|
119
|
+
const t = Array.isArray(v) ? v[0] : v;
|
|
120
|
+
if (t) addAlias(ctx, k, join(baseRoot || ".", String(t)));
|
|
121
|
+
}
|
|
66
122
|
} catch { /* no/invalid tsconfig */ }
|
|
67
123
|
}
|
|
68
|
-
for (const vc of [
|
|
69
|
-
try {
|
|
124
|
+
for (const vc of [...fileSet].filter((fr) => /(^|\/)(?:vite\.config\.(?:ts|js|mjs)|webpack\.config\.js)$/.test(fr))) {
|
|
125
|
+
try {
|
|
126
|
+
const cfgDir = cleanRel(dirname(vc)); const ctx = contextFor(cfgDir); const src = readLocal(vc);
|
|
127
|
+
for (const m of src.matchAll(/['"`]([^'"`]+)['"`]\s*:\s*path\.resolve\([^,]+,\s*['"`]([^'"`]+)['"`]\s*\)/g)) addAlias(ctx, m[1], join(cfgDir || ".", m[2]));
|
|
128
|
+
} catch { /* no/invalid bundler config */ }
|
|
70
129
|
}
|
|
71
|
-
|
|
72
|
-
const
|
|
130
|
+
for (const ctx of aliasContexts.values()) ctx.aliases.sort((a, b) => b.alias.length - a.alias.length);
|
|
131
|
+
const contextsForFile = (fromRel) => [...aliasContexts.values()]
|
|
132
|
+
.filter((ctx) => !ctx.dir || fromRel === ctx.dir || fromRel.startsWith(ctx.dir + "/"))
|
|
133
|
+
.sort((a, b) => b.dir.length - a.dir.length);
|
|
134
|
+
const resolveAlias = (fromRel, spec) => {
|
|
135
|
+
if (spec === undefined) { spec = fromRel; fromRel = ""; }
|
|
136
|
+
for (const ctx of contextsForFile(fromRel)) for (const { alias, target } of ctx.aliases) {
|
|
137
|
+
if (spec === alias) return target;
|
|
138
|
+
if (spec.startsWith(alias + "/")) return target + spec.slice(alias.length);
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
};
|
|
73
142
|
|
|
74
143
|
const JS_EXTS = ["", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs", ".json", "/index.js", "/index.ts", "/index.jsx", "/index.tsx"];
|
|
75
144
|
const resolveJsImport = (fromRel, spec) => {
|
|
@@ -77,10 +146,10 @@ export function buildResolvers(repoDir, fileSet) {
|
|
|
77
146
|
let base;
|
|
78
147
|
if (spec.startsWith(".")) base = join(dirname(fromRel), spec).replace(/\\/g, "/").replace(/^\.\//, "");
|
|
79
148
|
else {
|
|
80
|
-
base = resolveAlias(spec);
|
|
149
|
+
base = resolveAlias(fromRel, spec);
|
|
81
150
|
if (base == null) {
|
|
82
151
|
// baseUrl-rooted internal import ("components/Button" with baseUrl:"src") — try before calling it an npm package
|
|
83
|
-
for (const b of
|
|
152
|
+
for (const ctx of contextsForFile(fromRel)) for (const b of ctx.baseUrls) {
|
|
84
153
|
const root = (b ? b + "/" : "") + spec;
|
|
85
154
|
for (const e of JS_EXTS) { const cand = (root + e).replace(/\/+/g, "/"); if (fileSet.has(cand)) return cand; }
|
|
86
155
|
}
|
package/src/mcp/catalog.mjs
CHANGED
|
@@ -23,7 +23,7 @@ function buildTools({tg, ti, th, ta}) {
|
|
|
23
23
|
{cap: 'graph', name: 'get_node', description: 'Get full details for a specific node by label or ID.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID to look up'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNode(g, a, ctx)},
|
|
24
24
|
{cap: 'graph', name: 'get_neighbors', description: 'Get all direct neighbors of a node with edge details (1 hop, call sites deduped). For transitive impact use get_dependents; for the impact of your current branch changes use change_impact.', inputSchema: {type: 'object', properties: {label: {type: 'string'}, relation_filter: {type: 'string', description: 'Optional: filter by relation type'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNeighbors(g, a, ctx)},
|
|
25
25
|
{cap: 'graph', name: 'query_graph', description: 'Explore the graph around a concept (BFS/DFS). Returns a focused, ranked subgraph — the closest, most-connected nodes near the matched seeds, with edges among them — not the full flood; states how many nodes were reached vs shown.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Natural language question or keyword search'}, mode: {type: 'string', enum: ['bfs', 'dfs'], default: 'bfs'}, depth: {type: 'integer', default: 3}, context_filter: {type: 'array', items: {type: 'string'}}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, required: ['question']}, run: (g, a) => tg.tQueryGraph(g, a)},
|
|
26
|
-
{cap: 'graph', name: 'god_nodes', description: '
|
|
26
|
+
{cap: 'graph', name: 'god_nodes', description: 'Rank connectivity hubs by unique call/import/reference neighbors, excluding structural containment. Repeated call sites are reported separately and do not inflate the rank.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', default: 10}}}, run: (g, a) => tg.tGodNodes(g, a)},
|
|
27
27
|
{cap: 'graph', name: 'shortest_path', description: 'Find the shortest path between two concepts in the knowledge graph.', inputSchema: {type: 'object', properties: {source: {type: 'string'}, target: {type: 'string'}, max_hops: {type: 'integer', default: 8}}, required: ['source', 'target']}, run: (g, a) => tg.tShortestPath(g, a)},
|
|
28
28
|
{cap: 'graph', name: 'get_dependents', description: 'Transitive blast-radius of ONE node: everything that calls/imports/inherits it, directly or through intermediaries (reverse edges, ranked by proximity then connectivity). For a symbol, also follows importers of its containing file. Use before refactoring; get_neighbors shows only 1 hop, change_impact covers your whole current diff at once.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID'}, depth: {type: 'integer', description: 'Max reverse hops, default 3', default: 3}, max_nodes: {type: 'integer', description: 'Max dependents to list, default 40', default: 40}}, required: ['label']}, run: (g, a) => ti.tGetDependents(g, a)},
|
|
29
29
|
{cap: 'graph', name: 'change_impact', description: 'Blast radius of a change: by default diffs branch commits + staged/unstaged/untracked work against a base ref (auto merge-base with origin/main|master), OR takes an explicit `files` list (e.g. a PR\'s changed files — assesses a NOT-checked-out PR). Maps changed files/symbols onto the graph and lists everything depending on them (reverse edges, ranked by proximity + connectivity) with test coverage attached — untested hotspots called out. Run before opening or reviewing a PR; drill down with get_dependents, coverage detail via coverage_map.', inputSchema: {type: 'object', properties: {base: {type: 'string', description: 'Base ref, e.g. origin/main or HEAD~1 (default: first existing of origin/HEAD, origin/main, origin/master, main, master)'}, files: {type: 'array', items: {type: 'string'}, description: 'Explicit repo-relative changed-file list — skips the local git diff; use for PRs that are not checked out'}, depth: {type: 'integer', description: 'Max reverse hops, default 2'}, max_nodes: {type: 'integer', description: 'Max impacted nodes to list, default 40'}}}, run: (g, a, ctx) => ti.tChangeImpact(g, a, ctx)},
|
|
@@ -41,7 +41,7 @@ function buildTools({tg, ti, th, ta}) {
|
|
|
41
41
|
{cap: 'retarget', name: 'open_repo', description: 'OFFLINE RETARGET: switch this server to another local Git repository, building its graph when missing. This explicit tool call changes the active repository boundary; pass build:false to probe without building. Omit the retarget capability at registration to pin one repository.', inputSchema: {type: 'object', properties: {path: {type: 'string', description: 'Absolute path to a Git working tree'}, build: {type: 'boolean', description: 'Build the graph when missing (default true)', default: true}, mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], default: 'full'}}, required: ['path']}, run: (g, a, ctx) => ta.tOpenRepo(g, a, ctx)},
|
|
42
42
|
{cap: 'retarget', name: 'list_known_repos', description: 'OFFLINE RETARGET: list sibling repositories with existing graphs that can be selected through open_repo.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tListKnownRepos(g, a, ctx)},
|
|
43
43
|
{cap: 'online', name: 'refresh_advisories', description: "ONLINE, explicit opt-in: refresh the local OSV advisory store for this repo's lockfile-pinned packages (npm/PyPI/Go). Sends package names + versions to OSV.dev — never automatic, never source code. Afterwards run_audit reads the refreshed store fully offline (~/.weavatrix/advisories.json).", inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', description: 'Per-request timeout, default 20000'}}}, run: (g, a, ctx) => ta.tRefreshAdvisories(g, a, ctx)},
|
|
44
|
-
{cap: 'online', name: 'sync_graph', description: 'ONLINE, explicit opt-in, off until configured: send a versioned allowlist of graph metadata to an endpoint you configure (env WEAVATRIX_SYNC_URL, optional WEAVATRIX_SYNC_TOKEN bearer auth). Unknown graph fields are discarded and source file bodies are
|
|
44
|
+
{cap: 'online', name: 'sync_graph', description: 'ONLINE, explicit opt-in, off until configured: send a versioned allowlist of graph metadata to an endpoint you configure (env WEAVATRIX_SYNC_URL, optional WEAVATRIX_SYNC_TOKEN bearer auth). Unknown graph fields are discarded and source file bodies are never read for sync. Rebuild graphs created before 0.1.3 once to add typed-edge metadata.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tSyncGraph(g, a, ctx)},
|
|
45
45
|
]
|
|
46
46
|
}
|
|
47
47
|
|
|
@@ -33,10 +33,21 @@ export function loadGraph(path) {
|
|
|
33
33
|
if (!e || e.source == null || e.target == null) continue
|
|
34
34
|
const s = String(e.source)
|
|
35
35
|
const t = String(e.target)
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const metadata = {
|
|
37
|
+
relation: e.relation,
|
|
38
|
+
confidence: e.confidence,
|
|
39
|
+
...(e.typeOnly === true ? {typeOnly: true} : {}),
|
|
40
|
+
...(Number.isInteger(e.line) ? {line: e.line} : {}),
|
|
41
|
+
...(typeof e.specifier === 'string' ? {specifier: e.specifier} : {}),
|
|
42
|
+
}
|
|
43
|
+
push(out, s, {id: t, ...metadata})
|
|
44
|
+
push(inn, t, {id: s, ...metadata})
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
nodes, links, byId, byLabel, out, inn,
|
|
48
|
+
repoBoundaryV: Number(raw.repoBoundaryV) || 0,
|
|
49
|
+
edgeTypesV: Number(raw.edgeTypesV) || 0,
|
|
38
50
|
}
|
|
39
|
-
return {nodes, links, byId, byLabel, out, inn, repoBoundaryV: Number(raw.repoBoundaryV) || 0}
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
export const isSymbol = (id) => String(id).includes('#')
|
|
@@ -49,6 +60,7 @@ export const labelOf = (g, id) => {
|
|
|
49
60
|
// "connectivity" degree ignores structural `contains` (parent→symbol nesting) so god_nodes surfaces real
|
|
50
61
|
// call/import/reference hubs, not just files that hold many symbols.
|
|
51
62
|
export const connList = (list) => (list || []).filter((e) => e.relation !== 'contains')
|
|
63
|
+
export const uniqueConnCount = (list) => new Set(connList(list).map((e) => String(e.id))).size
|
|
52
64
|
|
|
53
65
|
// Resolve a user-supplied "label" to a node: exact id → exact label → ci label → substring (best degree).
|
|
54
66
|
// Returns {node, matches, alternates} so callers can disclose ambiguity instead of silently picking one.
|
|
@@ -206,27 +218,43 @@ const folderOfFile = (file) => {
|
|
|
206
218
|
|
|
207
219
|
// Works on anything with {nodes, links}: the raw graph.json shape and the loadGraph struct alike.
|
|
208
220
|
export function diffGraphs(oldG, newG) {
|
|
221
|
+
const oldEdgeTypesV = Number(oldG.edgeTypesV) || 0
|
|
222
|
+
const newEdgeTypesV = Number(newG.edgeTypesV) || 0
|
|
223
|
+
const schemaMigration = oldEdgeTypesV !== newEdgeTypesV
|
|
209
224
|
const nodeIds = (graph) => new Set((graph.nodes || []).map((n) => String(n.id)))
|
|
210
225
|
const oldNodes = nodeIds(oldG)
|
|
211
226
|
const newNodes = nodeIds(newG)
|
|
212
227
|
|
|
213
|
-
const edgeKey = (l) => `${edgeEndpoint(l.source)}|${l.relation || ''}|${edgeEndpoint(l.target)}`
|
|
228
|
+
const edgeKey = (l) => `${edgeEndpoint(l.source)}|${l.relation || ''}|${schemaMigration ? 'untyped' : l.typeOnly === true ? 'type' : 'runtime'}|${edgeEndpoint(l.target)}`
|
|
214
229
|
const edgeSet = (graph) => new Set((graph.links || []).map(edgeKey))
|
|
215
230
|
const oldEdges = edgeSet(oldG)
|
|
216
231
|
const newEdges = edgeSet(newG)
|
|
217
232
|
|
|
218
|
-
const moduleEdges = (graph) => {
|
|
233
|
+
const moduleEdges = (graph, typeOnly) => {
|
|
219
234
|
const set = new Set()
|
|
220
235
|
for (const l of graph.links || []) {
|
|
221
236
|
if (l.relation === 'contains') continue
|
|
237
|
+
if ((l.typeOnly === true) !== typeOnly) continue
|
|
222
238
|
const a = folderOfFile(fileOfId(edgeEndpoint(l.source)))
|
|
223
239
|
const b = folderOfFile(fileOfId(edgeEndpoint(l.target)))
|
|
224
240
|
if (a !== b) set.add(`${a} → ${b}`)
|
|
225
241
|
}
|
|
226
242
|
return set
|
|
227
243
|
}
|
|
228
|
-
const
|
|
229
|
-
|
|
244
|
+
const combinedModuleEdges = (graph) => {
|
|
245
|
+
const set = new Set()
|
|
246
|
+
for (const l of graph.links || []) {
|
|
247
|
+
if (l.relation === 'contains') continue
|
|
248
|
+
const a = folderOfFile(fileOfId(edgeEndpoint(l.source)))
|
|
249
|
+
const b = folderOfFile(fileOfId(edgeEndpoint(l.target)))
|
|
250
|
+
if (a !== b) set.add(`${a} → ${b}`)
|
|
251
|
+
}
|
|
252
|
+
return set
|
|
253
|
+
}
|
|
254
|
+
const oldMods = schemaMigration ? combinedModuleEdges(oldG) : moduleEdges(oldG, false)
|
|
255
|
+
const newMods = schemaMigration ? combinedModuleEdges(newG) : moduleEdges(newG, false)
|
|
256
|
+
const oldTypeMods = schemaMigration ? new Set() : moduleEdges(oldG, true)
|
|
257
|
+
const newTypeMods = schemaMigration ? new Set() : moduleEdges(newG, true)
|
|
230
258
|
|
|
231
259
|
const incoming = (graph) => {
|
|
232
260
|
const m = new Map()
|
|
@@ -240,9 +268,47 @@ export function diffGraphs(oldG, newG) {
|
|
|
240
268
|
const oldIn = incoming(oldG)
|
|
241
269
|
const newIn = incoming(newG)
|
|
242
270
|
|
|
243
|
-
const cycles = (graph) => {
|
|
271
|
+
const cycles = (graph, includeTypeOnly) => {
|
|
272
|
+
try {
|
|
273
|
+
const sccs = findSccs(buildFileImportGraph(graph, {includeTypeOnly}).adj)
|
|
274
|
+
.map((members) => members.map(String).sort())
|
|
275
|
+
.sort((a, b) => b.length - a.length || a.join('\n').localeCompare(b.join('\n')))
|
|
276
|
+
return {
|
|
277
|
+
count: sccs.length,
|
|
278
|
+
largest: sccs[0]?.length || 0,
|
|
279
|
+
groups: sccs,
|
|
280
|
+
}
|
|
281
|
+
} catch {
|
|
282
|
+
return null
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const cycleDelta = (before, after) => {
|
|
286
|
+
if (!before || !after) return null
|
|
287
|
+
const key = (group) => group.join('|')
|
|
288
|
+
const beforeKeys = new Set(before.groups.map(key))
|
|
289
|
+
const afterKeys = new Set(after.groups.map(key))
|
|
290
|
+
const overlap = (a, b) => {
|
|
291
|
+
const bSet = new Set(b)
|
|
292
|
+
return a.reduce((n, member) => n + (bSet.has(member) ? 1 : 0), 0)
|
|
293
|
+
}
|
|
294
|
+
const unmatchedBefore = before.groups.filter((group) => !afterKeys.has(key(group)))
|
|
295
|
+
const unmatchedAfter = after.groups.filter((group) => !beforeKeys.has(key(group)))
|
|
296
|
+
const changed = unmatchedAfter.filter((group) => unmatchedBefore.some((old) => overlap(group, old) >= 2))
|
|
297
|
+
const introduced = unmatchedAfter.filter((group) => !unmatchedBefore.some((old) => overlap(group, old) >= 2))
|
|
298
|
+
const resolved = unmatchedBefore.filter((group) => !unmatchedAfter.some((next) => overlap(group, next) >= 2))
|
|
299
|
+
return {
|
|
300
|
+
before: before.count,
|
|
301
|
+
after: after.count,
|
|
302
|
+
largestBefore: before.largest,
|
|
303
|
+
largestAfter: after.largest,
|
|
304
|
+
introduced: introduced.map(key),
|
|
305
|
+
resolved: resolved.map(key),
|
|
306
|
+
membershipChanged: changed.length,
|
|
307
|
+
}
|
|
308
|
+
}
|
|
244
309
|
|
|
245
310
|
return {
|
|
311
|
+
schemaMigration: schemaMigration ? {from: oldEdgeTypesV, to: newEdgeTypesV} : null,
|
|
246
312
|
nodes: {
|
|
247
313
|
added: [...newNodes].filter((id) => !oldNodes.has(id)),
|
|
248
314
|
removed: [...oldNodes].filter((id) => !newNodes.has(id))
|
|
@@ -253,25 +319,46 @@ export function diffGraphs(oldG, newG) {
|
|
|
253
319
|
},
|
|
254
320
|
moduleEdges: {
|
|
255
321
|
added: [...newMods].filter((k) => !oldMods.has(k)),
|
|
256
|
-
removed: [...oldMods].filter((k) => !newMods.has(k))
|
|
322
|
+
removed: [...oldMods].filter((k) => !newMods.has(k)),
|
|
323
|
+
typeAdded: [...newTypeMods].filter((k) => !oldTypeMods.has(k)),
|
|
324
|
+
typeRemoved: [...oldTypeMods].filter((k) => !newTypeMods.has(k)),
|
|
257
325
|
},
|
|
258
326
|
// survived the rebuild but lost every caller/importer — likely made dead by the change
|
|
259
327
|
orphaned: [...oldIn.keys()].filter((id) => newNodes.has(id) && !newIn.has(id)),
|
|
260
|
-
cycles: {
|
|
328
|
+
cycles: {
|
|
329
|
+
runtime: schemaMigration ? null : cycleDelta(cycles(oldG, false), cycles(newG, false)),
|
|
330
|
+
typeInclusive: schemaMigration ? null : cycleDelta(cycles(oldG, true), cycles(newG, true)),
|
|
331
|
+
}
|
|
261
332
|
}
|
|
262
333
|
}
|
|
263
334
|
|
|
264
335
|
export function formatGraphDiff(d) {
|
|
265
336
|
if (!d.nodes.added.length && !d.nodes.removed.length && !d.edges.added && !d.edges.removed) {
|
|
266
|
-
return
|
|
337
|
+
return d.schemaMigration
|
|
338
|
+
? `Graph edge schema upgraded v${d.schemaMigration.from} → v${d.schemaMigration.to}; typed baseline established. Runtime/type cycle and module classifications are intentionally not compared on this rebuild.`
|
|
339
|
+
: 'No structural change between the two graph states.'
|
|
267
340
|
}
|
|
268
341
|
const cap = (list, n) => list.slice(0, n).map((x) => ` ${x}`).concat(list.length > n ? [` … +${list.length - n} more`] : [])
|
|
269
342
|
const lines = [`Structural delta: nodes +${d.nodes.added.length}/−${d.nodes.removed.length}, edges +${d.edges.added}/−${d.edges.removed}.`]
|
|
270
|
-
if (d.
|
|
271
|
-
|
|
343
|
+
if (d.schemaMigration) lines.push(`Graph edge schema upgraded v${d.schemaMigration.from} → v${d.schemaMigration.to}; runtime/type cycle and module classifications are intentionally not compared until the next rebuild.`)
|
|
344
|
+
const runtime = d.cycles?.runtime
|
|
345
|
+
if (runtime && (runtime.before !== runtime.after || runtime.largestBefore !== runtime.largestAfter || runtime.introduced.length || runtime.resolved.length || runtime.membershipChanged)) {
|
|
346
|
+
const changes = []
|
|
347
|
+
if (runtime.introduced.length) changes.push(`${runtime.introduced.length} genuinely new runtime SCC(s) — review`)
|
|
348
|
+
if (runtime.resolved.length) changes.push(`${runtime.resolved.length} runtime SCC(s) resolved`)
|
|
349
|
+
if (runtime.membershipChanged) changes.push(`${runtime.membershipChanged} SCC membership change(s)`)
|
|
350
|
+
const verdict = changes.length ? `; ${changes.join('; ')}` : ''
|
|
351
|
+
lines.push(`Runtime import cycles: count ${runtime.before} → ${runtime.after}, largest SCC ${runtime.largestBefore} → ${runtime.largestAfter}${verdict}.`)
|
|
352
|
+
}
|
|
353
|
+
const all = d.cycles?.typeInclusive
|
|
354
|
+
if (all && (all.before !== all.after || all.largestBefore !== all.largestAfter) &&
|
|
355
|
+
(!runtime || all.before !== runtime.before || all.after !== runtime.after || all.largestBefore !== runtime.largestBefore || all.largestAfter !== runtime.largestAfter)) {
|
|
356
|
+
lines.push(`Type-inclusive dependency SCCs: count ${all.before} → ${all.after}, largest ${all.largestBefore} → ${all.largestAfter} (compile-time coupling, not necessarily a runtime cycle).`)
|
|
272
357
|
}
|
|
273
358
|
if (d.moduleEdges.added.length) lines.push('NEW module dependencies (architecture drift — review):', ...cap(d.moduleEdges.added, 12))
|
|
274
359
|
if (d.moduleEdges.removed.length) lines.push('Removed module dependencies (decoupling confirmed):', ...cap(d.moduleEdges.removed, 12))
|
|
360
|
+
if (d.moduleEdges.typeAdded.length) lines.push('New type-only module dependencies (compile-time coupling):', ...cap(d.moduleEdges.typeAdded, 12))
|
|
361
|
+
if (d.moduleEdges.typeRemoved.length) lines.push('Removed type-only module dependencies:', ...cap(d.moduleEdges.typeRemoved, 12))
|
|
275
362
|
if (d.orphaned.length) lines.push('Symbols that lost their last caller/importer (now dead?):', ...cap(d.orphaned, 10))
|
|
276
363
|
if (d.nodes.added.length) lines.push('Added nodes:', ...cap(d.nodes.added, 12))
|
|
277
364
|
if (d.nodes.removed.length) lines.push('Removed nodes:', ...cap(d.nodes.removed, 12))
|
package/src/mcp/sync-payload.mjs
CHANGED
|
@@ -63,6 +63,10 @@ function sanitizeLink(value) {
|
|
|
63
63
|
const out = {source, target};
|
|
64
64
|
setIf(out, 'relation', metadataString(value.relation, 32));
|
|
65
65
|
setIf(out, 'confidence', metadataString(value.confidence, 32));
|
|
66
|
+
if (value.typeOnly === true) out.typeOnly = true;
|
|
67
|
+
const line = finiteNumber(value.line);
|
|
68
|
+
if (line !== undefined && Number.isInteger(line) && line >= 0) out.line = line;
|
|
69
|
+
setIf(out, 'specifier', metadataString(value.specifier));
|
|
66
70
|
return out;
|
|
67
71
|
}
|
|
68
72
|
|
|
@@ -85,14 +89,18 @@ export function createSyncPayload(raw) {
|
|
|
85
89
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || raw.repoBoundaryV !== 1) {
|
|
86
90
|
throw new Error('graph predates repository-boundary hardening');
|
|
87
91
|
}
|
|
92
|
+
if (!Number.isInteger(raw.edgeTypesV) || raw.edgeTypesV < 1) {
|
|
93
|
+
throw new Error('graph predates typed import edges');
|
|
94
|
+
}
|
|
88
95
|
const nodes = Array.isArray(raw.nodes) ? raw.nodes.map(sanitizeNode).filter(Boolean) : [];
|
|
89
96
|
const links = Array.isArray(raw.links) ? raw.links.map(sanitizeLink).filter(Boolean) : [];
|
|
90
97
|
const externalImports = Array.isArray(raw.externalImports)
|
|
91
98
|
? raw.externalImports.map(sanitizeExternalImport).filter(Boolean)
|
|
92
99
|
: [];
|
|
93
100
|
return {
|
|
94
|
-
syncPayloadV:
|
|
101
|
+
syncPayloadV: 2,
|
|
95
102
|
repoBoundaryV: 1,
|
|
103
|
+
edgeTypesV: 1,
|
|
96
104
|
extImportsV: Number.isInteger(raw.extImportsV) ? raw.extImportsV : 0,
|
|
97
105
|
complexityV: Number.isInteger(raw.complexityV) ? raw.complexityV : 0,
|
|
98
106
|
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 < 1
|
|
49
|
+
} catch {
|
|
50
|
+
upgrade = true
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!existsSync(graphPath) || upgrade) {
|
|
54
|
+
if (args.build === false) {
|
|
55
|
+
return upgrade
|
|
56
|
+
? `The existing graph for ${repoPath} predates typed import edges. Re-call without build:false to upgrade it before switching.`
|
|
57
|
+
: `No graph yet for ${repoPath} (expected at ${graphPath}). Re-call without build:false to build one — large repos can take minutes.`
|
|
58
|
+
}
|
|
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 typed import edges)' : ' (graph built fresh)') : ''
|
|
75
|
+
return `Opened ${repoPath}${buildNote}: ${loaded.nodes.length} nodes / ${loaded.links.length} edges. All tools now target this repo.`
|
|
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
|
},
|
package/src/mcp/tools-graph.mjs
CHANGED
|
@@ -12,9 +12,11 @@ export function tGraphStats(g, ctx) {
|
|
|
12
12
|
const symbols = g.nodes.length - files
|
|
13
13
|
const relCount = {}
|
|
14
14
|
const confCount = {}
|
|
15
|
+
let typeOnlyEdges = 0
|
|
15
16
|
for (const e of g.links) {
|
|
16
17
|
relCount[e.relation ?? '?'] = (relCount[e.relation ?? '?'] || 0) + 1
|
|
17
18
|
if (e.confidence != null) confCount[e.confidence] = (confCount[e.confidence] || 0) + 1
|
|
19
|
+
if (e.typeOnly === true) typeOnlyEdges++
|
|
18
20
|
}
|
|
19
21
|
const comm = new Map()
|
|
20
22
|
for (const n of g.nodes) {
|
|
@@ -33,6 +35,7 @@ export function tGraphStats(g, ctx) {
|
|
|
33
35
|
ctx?.repoRoot ? `- Repo: ${ctx.repoRoot}` : null,
|
|
34
36
|
`- Nodes: ${g.nodes.length} (${files} files, ${symbols} symbols)`,
|
|
35
37
|
`- Edges: ${g.links.length}`,
|
|
38
|
+
g.edgeTypesV ? `- Typed-edge metadata: v${g.edgeTypesV} (${typeOnlyEdges} type-only edges)` : `- Typed-edge metadata: unavailable (rebuild_graph with Weavatrix 0.1.3+)`,
|
|
36
39
|
`- Relations: ${fmt(relCount)}`,
|
|
37
40
|
Object.keys(confCount).length ? `- Confidence: ${fmt(confCount)}` : null,
|
|
38
41
|
`- Communities: ${comm.size} (top by size: ${topComm.map(([c, n]) => `#${c}=${n}`).join(', ')})`,
|
|
@@ -77,10 +80,10 @@ export function tGetNode(g, {label} = {}, ctx) {
|
|
|
77
80
|
function dedupeEdges(list) {
|
|
78
81
|
const grouped = new Map()
|
|
79
82
|
for (const e of list) {
|
|
80
|
-
const key = `${e.relation || 'rel'}|${e.id}`
|
|
83
|
+
const key = `${e.relation || 'rel'}|${e.typeOnly === true ? 'type' : 'runtime'}|${e.id}`
|
|
81
84
|
const cur = grouped.get(key)
|
|
82
85
|
if (cur) cur.count += 1
|
|
83
|
-
else grouped.set(key, {id: e.id, relation: e.relation, count: 1})
|
|
86
|
+
else grouped.set(key, {id: e.id, relation: e.relation, typeOnly: e.typeOnly === true, count: 1})
|
|
84
87
|
}
|
|
85
88
|
return [...grouped.values()]
|
|
86
89
|
}
|
|
@@ -99,7 +102,7 @@ export function tGetNeighbors(g, {label, relation_filter} = {}, ctx) {
|
|
|
99
102
|
const outs = dedupeEdges(outsRaw)
|
|
100
103
|
const ins = dedupeEdges(insRaw)
|
|
101
104
|
const line = (e, dir) =>
|
|
102
|
-
` ${dir === 'out' ? '→' : '←'} ${e.relation || 'rel'} ${labelOf(g, e.id)} [${e.id}]${e.count > 1 ? ` (${e.count} sites)` : ''}`
|
|
105
|
+
` ${dir === 'out' ? '→' : '←'} ${e.typeOnly ? 'type-only ' : ''}${e.relation || 'rel'} ${labelOf(g, e.id)} [${e.id}]${e.count > 1 ? ` (${e.count} sites)` : ''}`
|
|
103
106
|
return [
|
|
104
107
|
note,
|
|
105
108
|
`Neighbors of ${n.label ?? id}${rf ? ` (relation=${rf})` : ''}: ${outs.length + ins.length} unique (${outsRaw.length + insRaw.length} edges)`,
|
|
@@ -113,21 +116,52 @@ export function tGetNeighbors(g, {label, relation_filter} = {}, ctx) {
|
|
|
113
116
|
|
|
114
117
|
export function tGodNodes(g, {top_n = 10} = {}) {
|
|
115
118
|
const n = Math.max(1, Math.min(100, Number(top_n) || 10))
|
|
116
|
-
const
|
|
119
|
+
const scored = g.nodes
|
|
117
120
|
.map((node) => {
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
+
const outs = connList(g.out.get(String(node.id)))
|
|
122
|
+
const ins = connList(g.inn.get(String(node.id)))
|
|
123
|
+
const outIds = new Set(outs.map((e) => String(e.id)))
|
|
124
|
+
const inIds = new Set(ins.map((e) => String(e.id)))
|
|
125
|
+
const allIds = new Set([...outIds, ...inIds])
|
|
126
|
+
const runtimeIds = new Set([...outs, ...ins].filter((e) => e.typeOnly !== true).map((e) => String(e.id)))
|
|
127
|
+
const compileOnlyIds = new Set([...outs, ...ins].filter((e) => e.typeOnly === true).map((e) => String(e.id)))
|
|
128
|
+
for (const id of runtimeIds) compileOnlyIds.delete(id)
|
|
129
|
+
const occurrences = outs.length + ins.length
|
|
130
|
+
return {
|
|
131
|
+
node,
|
|
132
|
+
deg: allIds.size,
|
|
133
|
+
runtime: runtimeIds.size,
|
|
134
|
+
compileOnly: compileOnlyIds.size,
|
|
135
|
+
out: outIds.size,
|
|
136
|
+
in: inIds.size,
|
|
137
|
+
occurrences,
|
|
138
|
+
}
|
|
121
139
|
})
|
|
122
|
-
.
|
|
123
|
-
.
|
|
140
|
+
.filter((entry) => entry.deg > 0)
|
|
141
|
+
.sort((a, b) => b.runtime - a.runtime || b.deg - a.deg || b.occurrences - a.occurrences)
|
|
142
|
+
const ranked = scored.slice(0, n)
|
|
143
|
+
const rankedIds = new Set(ranked.map((entry) => String(entry.node.id)))
|
|
144
|
+
// Unique neighbors measure coupling, but a large component repeatedly calling the same helper (for
|
|
145
|
+
// example i18n) can still be a valuable complexity hotspot. Preserve that second lens explicitly
|
|
146
|
+
// instead of letting repeated sites inflate the coupling rank.
|
|
147
|
+
const occurrenceHotspots = scored
|
|
148
|
+
.filter((entry) => !rankedIds.has(String(entry.node.id)))
|
|
149
|
+
.filter((entry) => entry.occurrences >= 20 && entry.occurrences - entry.deg >= 10)
|
|
150
|
+
.sort((a, b) => (b.occurrences - b.deg) - (a.occurrences - a.deg) || b.occurrences - a.occurrences)
|
|
151
|
+
.slice(0, Math.min(5, n))
|
|
124
152
|
return [
|
|
125
|
-
`Top ${
|
|
153
|
+
`Top ${ranked.length} connectivity hubs (ranked by unique runtime call/import/reference neighbors; structural containment excluded):`,
|
|
126
154
|
...ranked.map(
|
|
127
155
|
(r, i) =>
|
|
128
|
-
`${String(i + 1).padStart(2)}. ${r.node.label ?? r.node.id} (${r.deg}
|
|
156
|
+
`${String(i + 1).padStart(2)}. ${r.node.label ?? r.node.id} (${r.deg} unique: ${r.runtime} runtime, ${r.compileOnly} compile-only; out ${r.out}, in ${r.in}; ${r.occurrences} edge occurrence${r.occurrences === 1 ? '' : 's'}) [${r.node.id}]`
|
|
157
|
+
),
|
|
158
|
+
`Repeated call sites affect the occurrence count, not the connectivity rank; compile-only neighbors are secondary to runtime coupling.`,
|
|
159
|
+
occurrenceHotspots.length ? `` : null,
|
|
160
|
+
occurrenceHotspots.length ? `High occurrence hotspots outside the connectivity rank (repeated call/reference sites; complexity signal, not broader coupling):` : null,
|
|
161
|
+
...occurrenceHotspots.map((r) =>
|
|
162
|
+
` ${r.node.label ?? r.node.id} (${r.occurrences} occurrences across ${r.deg} unique neighbors; ${r.occurrences - r.deg} repeats) [${r.node.id}]`
|
|
129
163
|
),
|
|
130
|
-
].join('\n')
|
|
164
|
+
].filter((line) => line != null).join('\n')
|
|
131
165
|
}
|
|
132
166
|
|
|
133
167
|
export function tGetCommunity(g, {community_id} = {}) {
|