weavatrix 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +92 -17
  2. package/package.json +1 -1
  3. package/skill/SKILL.md +62 -4
  4. package/src/analysis/dead-check.js +87 -6
  5. package/src/analysis/dep-check-ecosystems.js +157 -0
  6. package/src/analysis/dep-check.js +97 -133
  7. package/src/analysis/dep-rules.js +91 -12
  8. package/src/analysis/duplicates.compute.js +12 -18
  9. package/src/analysis/endpoints-rust.js +124 -0
  10. package/src/analysis/endpoints.js +56 -6
  11. package/src/analysis/graph-analysis.aggregate.js +34 -25
  12. package/src/analysis/graph-analysis.edges.js +21 -0
  13. package/src/analysis/graph-analysis.js +1 -1
  14. package/src/analysis/graph-analysis.summaries.js +4 -1
  15. package/src/analysis/internal-audit.collect.js +162 -25
  16. package/src/analysis/internal-audit.reach.js +52 -11
  17. package/src/analysis/internal-audit.run.js +71 -18
  18. package/src/graph/builder/lang-java.js +177 -14
  19. package/src/graph/builder/lang-js.js +66 -23
  20. package/src/graph/builder/lang-rust.js +129 -6
  21. package/src/graph/community.js +17 -0
  22. package/src/graph/internal-builder.build.js +79 -17
  23. package/src/graph/internal-builder.java.js +33 -0
  24. package/src/graph/internal-builder.langs.js +43 -2
  25. package/src/graph/internal-builder.resolvers.js +197 -21
  26. package/src/graph/relations.js +4 -0
  27. package/src/mcp/catalog.mjs +4 -4
  28. package/src/mcp/graph-context.mjs +22 -94
  29. package/src/mcp/graph-diff.mjs +163 -0
  30. package/src/mcp/sync-payload.mjs +36 -6
  31. package/src/mcp/tools-actions.mjs +22 -7
  32. package/src/mcp/tools-graph-hubs.mjs +58 -0
  33. package/src/mcp/tools-graph.mjs +13 -22
  34. package/src/mcp/tools-health.mjs +54 -18
  35. package/src/mcp/tools-impact.mjs +61 -45
  36. package/src/mcp-server.mjs +3 -0
  37. package/src/security/advisory-store.js +51 -7
@@ -1,5 +1,6 @@
1
1
  // Per-repo resolution context shared by the language modules: JS/TS path-aliases + relative imports, Python
2
- // dotted/relative modules, Go package dirs, Java class files, and web hrefs / the CSS selector index.
2
+ // dotted/relative modules, Go package dirs, Rust crate/module paths, Java class files, and web hrefs /
3
+ // the CSS selector index.
3
4
  // (Split from internal-builder.js — see its doc comment for the overall architecture.)
4
5
  import { readFileSync } from "node:fs";
5
6
  import { join, dirname } from "node:path";
@@ -47,29 +48,204 @@ export function buildResolvers(repoDir, fileSet) {
47
48
  return cands.find((f) => f === full || f.endsWith("/" + full)) || cands[0] || null;
48
49
  };
49
50
 
50
- // path aliases (tsconfig compilerOptions.paths + vite/webpack alias) without these, @components/@/etc
51
- // imports are missed and their targets look falsely DEAD.
52
- const aliasList = [];
53
- const addAlias = (a, t) => {
51
+ // Rust modules are files, but their paths are logical rather than simple source-relative imports:
52
+ // `foo.rs` owns children below `foo/`, while lib.rs/main.rs/mod.rs own siblings. Keep the resolver
53
+ // filesystem-only and crate-local: Cargo/external dependencies belong to dependency analysis, not to
54
+ // the internal module graph.
55
+ const cleanRustRel = (p) => String(p || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+/g, "/").replace(/\/$/, "").replace(/^\.$/, "");
56
+ const rustDir = (p) => { p = cleanRustRel(p); const i = p.lastIndexOf("/"); return i < 0 ? "" : p.slice(0, i); };
57
+ const rustBase = (p) => { p = cleanRustRel(p); const i = p.lastIndexOf("/"); return i < 0 ? p : p.slice(i + 1); };
58
+ const rustJoin = (...parts) => cleanRustRel(join(...parts.filter((x) => x != null && x !== "")));
59
+ const rustFiles = new Set([...fileSet].filter((fr) => fr.endsWith(".rs")));
60
+ const rustRoots = new Map();
61
+ for (const fr of rustFiles) {
62
+ const base = rustBase(fr);
63
+ if (base !== "lib.rs" && base !== "main.rs") continue;
64
+ const dir = rustDir(fr);
65
+ let root = rustRoots.get(dir);
66
+ if (!root) rustRoots.set(dir, (root = { base: dir, lib: null, main: null }));
67
+ root[base === "lib.rs" ? "lib" : "main"] = fr;
68
+ }
69
+ const rustRootList = [...rustRoots.values()].sort((a, b) => b.base.length - a.base.length);
70
+ const rustContext = (fromRel) => {
71
+ fromRel = cleanRustRel(fromRel);
72
+ const base = rustBase(fromRel);
73
+ const dir = rustDir(fromRel);
74
+ if (base === "lib.rs" || base === "main.rs") return { base: dir, rootFile: fromRel };
75
+
76
+ // Cargo treats direct files in these conventional folders as independent crate roots. This only
77
+ // affects paths originating in the root file itself; nested module ownership still comes from mod/use.
78
+ if (/^(?:bin|examples|tests|benches)$/.test(rustBase(dir))) return { base: dir, rootFile: fromRel };
79
+ for (const root of rustRootList) {
80
+ if (!root.base || fromRel.startsWith(root.base + "/")) return { base: root.base, rootFile: root.lib || root.main };
81
+ }
82
+ return { base: dir, rootFile: fromRel };
83
+ };
84
+ const rustModuleBase = (fromRel) => {
85
+ const ctx = rustContext(fromRel);
86
+ if (ctx.rootFile === cleanRustRel(fromRel)) return rustDir(fromRel);
87
+ const name = rustBase(fromRel);
88
+ if (name === "lib.rs" || name === "main.rs" || name === "mod.rs") return rustDir(fromRel);
89
+ return rustJoin(rustDir(fromRel), name.replace(/\.rs$/, ""));
90
+ };
91
+ const rustInlineBase = (fromRel, inlineModules = []) => {
92
+ let base = rustModuleBase(fromRel);
93
+ for (let i = 0; i < inlineModules.length; i++) {
94
+ const mod = inlineModules[i] || {};
95
+ if (mod.path) {
96
+ // Rust Reference: a path attribute on the first inline module is relative to the source file's
97
+ // directory; nested attributes are relative to their containing module's search directory.
98
+ const parent = i === 0 ? rustDir(fromRel) : base;
99
+ base = rustJoin(parent, mod.path);
100
+ } else base = rustJoin(base, mod.name);
101
+ }
102
+ return base;
103
+ };
104
+ const rustModuleFile = (moduleBase, ctx) => {
105
+ moduleBase = cleanRustRel(moduleBase);
106
+ if (moduleBase === cleanRustRel(ctx.base) && ctx.rootFile && rustFiles.has(ctx.rootFile)) return ctx.rootFile;
107
+ const flat = moduleBase + ".rs";
108
+ if (rustFiles.has(flat)) return flat;
109
+ const legacy = rustJoin(moduleBase, "mod.rs");
110
+ return rustFiles.has(legacy) ? legacy : null;
111
+ };
112
+ const resolveRustMod = (fromRel, name, { inlineModules = [], explicitPath = "" } = {}) => {
113
+ fromRel = cleanRustRel(fromRel);
114
+ if (explicitPath) {
115
+ const parent = inlineModules.length ? rustInlineBase(fromRel, inlineModules) : rustDir(fromRel);
116
+ const target = rustJoin(parent, explicitPath);
117
+ return rustFiles.has(target) ? target : null;
118
+ }
119
+ const targetBase = rustJoin(rustInlineBase(fromRel, inlineModules), String(name || "").replace(/^r#/, ""));
120
+ return rustModuleFile(targetBase, rustContext(fromRel));
121
+ };
122
+ const resolveRustPath = (fromRel, rawSegments, { inlineModules = [], unqualified = true } = {}) => {
123
+ fromRel = cleanRustRel(fromRel);
124
+ const segments = (Array.isArray(rawSegments) ? rawSegments : String(rawSegments || "").split("::"))
125
+ .map((s) => String(s).trim().replace(/^r#/, "")).filter(Boolean);
126
+ if (!segments.length) return null;
127
+ const ctx = rustContext(fromRel);
128
+ const current = rustInlineBase(fromRel, inlineModules);
129
+ let rest = [...segments];
130
+ const starts = [];
131
+ let anchored = false;
132
+ if (rest[0] === "crate") { anchored = true; rest.shift(); starts.push(ctx.base); }
133
+ else if (rest[0] === "self") { anchored = true; rest.shift(); starts.push(current); }
134
+ else if (rest[0] === "super") {
135
+ anchored = true;
136
+ let base = current;
137
+ while (rest[0] === "super") { rest.shift(); base = rustDir(base); }
138
+ if (ctx.base && base !== ctx.base && !base.startsWith(ctx.base + "/")) return null;
139
+ starts.push(base);
140
+ } else if (unqualified) {
141
+ // Rust 2018 resolves a bare use path from the crate root/external prelude. Prefer an internal root
142
+ // module, then allow a lexically-local module for qualified expressions in nested modules.
143
+ starts.push(ctx.base);
144
+ if (current !== ctx.base) starts.push(current);
145
+ } else return null;
146
+
147
+ for (const start of starts) {
148
+ const min = anchored ? 0 : 1; // never reinterpret an unresolved external `serde::X` as the crate root
149
+ for (let used = rest.length; used >= min; used--) {
150
+ const target = rustModuleFile(rustJoin(start, ...rest.slice(0, used)), ctx);
151
+ if (target) return { targetFile: target, consumed: segments.length - rest.length + used, remaining: rest.slice(used), anchored };
152
+ }
153
+ }
154
+ return null;
155
+ };
156
+
157
+ // Path aliases (tsconfig compilerOptions.paths + vite/webpack alias) are scoped to their config folder.
158
+ // Without nearest-config resolution, a monorepo's root `@/*` can hijack the same alias in web/.
159
+ const aliasContexts = new Map();
160
+ const cleanRel = (p) => String(p || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+/g, "/").replace(/\/$/, "").replace(/^\.$/, "");
161
+ const contextFor = (dir) => {
162
+ dir = cleanRel(dir);
163
+ let ctx = aliasContexts.get(dir);
164
+ if (!ctx) aliasContexts.set(dir, (ctx = { dir, aliases: [], baseUrls: [] }));
165
+ return ctx;
166
+ };
167
+ const addAlias = (ctx, a, t) => {
54
168
  a = String(a).replace(/\/\*$/, "").replace(/\/$/, "");
55
- t = String(t).replace(/\/\*$/, "").replace(/^\.\//, "").replace(/\/$/, "");
56
- if (a && t && !aliasList.some((x) => x.alias === a)) aliasList.push({ alias: a, target: t });
169
+ t = cleanRel(String(t)).replace(/\/\*$/, "");
170
+ if (a && t && !ctx.aliases.some((x) => x.alias === a)) ctx.aliases.push({ alias: a, target: t });
171
+ };
172
+ const parseJsonc = (raw) => {
173
+ // Regex comment stripping corrupts perfectly valid path strings such as `"@/*"` followed later by
174
+ // `"**/*.ts"`. Strip comments/trailing commas only while outside JSON strings.
175
+ raw = String(raw).replace(/^\uFEFF/, "");
176
+ let clean = ""; let inString = false; let escaped = false;
177
+ for (let i = 0; i < raw.length; i++) {
178
+ const ch = raw[i], next = raw[i + 1];
179
+ if (inString) {
180
+ clean += ch;
181
+ if (escaped) escaped = false;
182
+ else if (ch === "\\") escaped = true;
183
+ else if (ch === '"') inString = false;
184
+ continue;
185
+ }
186
+ if (ch === '"') { inString = true; clean += ch; continue; }
187
+ if (ch === "/" && next === "/") { while (i < raw.length && raw[i] !== "\n") i++; clean += "\n"; continue; }
188
+ if (ch === "/" && next === "*") {
189
+ i += 2;
190
+ while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) { if (raw[i] === "\n") clean += "\n"; i++; }
191
+ i++;
192
+ continue;
193
+ }
194
+ clean += ch;
195
+ }
196
+ let withoutTrailing = ""; inString = false; escaped = false;
197
+ for (let i = 0; i < clean.length; i++) {
198
+ const ch = clean[i];
199
+ if (inString) {
200
+ withoutTrailing += ch;
201
+ if (escaped) escaped = false;
202
+ else if (ch === "\\") escaped = true;
203
+ else if (ch === '"') inString = false;
204
+ continue;
205
+ }
206
+ if (ch === '"') { inString = true; withoutTrailing += ch; continue; }
207
+ if (ch === ",") {
208
+ let j = i + 1; while (/\s/.test(clean[j] || "")) j++;
209
+ if (clean[j] === "}" || clean[j] === "]") continue;
210
+ }
211
+ withoutTrailing += ch;
212
+ }
213
+ return JSON.parse(withoutTrailing);
57
214
  };
58
- const jsBaseUrls = []; // tsconfig/jsconfig baseUrl roots bare "components/Button" may be baseUrl-rooted, not an npm package
59
- for (const cfg of ["tsconfig.json", "tsconfig.app.json", "tsconfig.base.json", "jsconfig.json"]) {
215
+ const configRank = (fr) => /(^|\/)tsconfig\.json$/i.test(fr) ? 0 : /(^|\/)jsconfig\.json$/i.test(fr) ? 1 : 2;
216
+ const configFiles = [...fileSet]
217
+ .filter((fr) => /(^|\/)(?:tsconfig(?:\.[^/]+)?|jsconfig)\.json$/i.test(fr))
218
+ .sort((a, b) => dirname(a).localeCompare(dirname(b)) || configRank(a) - configRank(b) || a.localeCompare(b));
219
+ for (const cfg of configFiles) {
60
220
  try {
61
- const raw = readLocal(cfg).replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/,(\s*[}\]])/g, "$1");
62
- const tj = JSON.parse(raw); const co = tj.compilerOptions || {}; const paths = co.paths || {};
63
- const baseUrl = String(co.baseUrl || ".").replace(/^\.\/?/, "").replace(/\/$/, "");
64
- if (co.baseUrl != null && !jsBaseUrls.includes(baseUrl)) jsBaseUrls.push(baseUrl);
65
- for (const [k, v] of Object.entries(paths)) { const t = Array.isArray(v) ? v[0] : v; if (t) addAlias(k, (baseUrl && !String(t).startsWith("./") ? baseUrl + "/" : "") + t); }
221
+ const tj = parseJsonc(readLocal(cfg)); const co = tj.compilerOptions || {}; const paths = co.paths || {};
222
+ const cfgDir = cleanRel(dirname(cfg)); const ctx = contextFor(cfgDir);
223
+ const baseRoot = cleanRel(join(cfgDir || ".", String(co.baseUrl || ".")));
224
+ if (co.baseUrl != null && !ctx.baseUrls.includes(baseRoot)) ctx.baseUrls.push(baseRoot);
225
+ for (const [k, v] of Object.entries(paths)) {
226
+ const t = Array.isArray(v) ? v[0] : v;
227
+ if (t) addAlias(ctx, k, join(baseRoot || ".", String(t)));
228
+ }
66
229
  } catch { /* no/invalid tsconfig */ }
67
230
  }
68
- for (const vc of ["vite.config.ts", "vite.config.js", "vite.config.mjs", "webpack.config.js"]) {
69
- try { const src = readLocal(vc); for (const m of src.matchAll(/['"`]([^'"`]+)['"`]\s*:\s*path\.resolve\([^,]+,\s*['"`]([^'"`]+)['"`]\s*\)/g)) addAlias(m[1], m[2]); } catch { /* no bundler config */ }
231
+ for (const vc of [...fileSet].filter((fr) => /(^|\/)(?:vite\.config\.(?:ts|js|mjs)|webpack\.config\.js)$/.test(fr))) {
232
+ try {
233
+ const cfgDir = cleanRel(dirname(vc)); const ctx = contextFor(cfgDir); const src = readLocal(vc);
234
+ for (const m of src.matchAll(/['"`]([^'"`]+)['"`]\s*:\s*path\.resolve\([^,]+,\s*['"`]([^'"`]+)['"`]\s*\)/g)) addAlias(ctx, m[1], join(cfgDir || ".", m[2]));
235
+ } catch { /* no/invalid bundler config */ }
70
236
  }
71
- aliasList.sort((a, b) => b.alias.length - a.alias.length);
72
- const resolveAlias = (spec) => { for (const { alias, target } of aliasList) { if (spec === alias) return target; if (spec.startsWith(alias + "/")) return target + spec.slice(alias.length); } return null; };
237
+ for (const ctx of aliasContexts.values()) ctx.aliases.sort((a, b) => b.alias.length - a.alias.length);
238
+ const contextsForFile = (fromRel) => [...aliasContexts.values()]
239
+ .filter((ctx) => !ctx.dir || fromRel === ctx.dir || fromRel.startsWith(ctx.dir + "/"))
240
+ .sort((a, b) => b.dir.length - a.dir.length);
241
+ const resolveAlias = (fromRel, spec) => {
242
+ if (spec === undefined) { spec = fromRel; fromRel = ""; }
243
+ for (const ctx of contextsForFile(fromRel)) for (const { alias, target } of ctx.aliases) {
244
+ if (spec === alias) return target;
245
+ if (spec.startsWith(alias + "/")) return target + spec.slice(alias.length);
246
+ }
247
+ return null;
248
+ };
73
249
 
74
250
  const JS_EXTS = ["", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs", ".json", "/index.js", "/index.ts", "/index.jsx", "/index.tsx"];
75
251
  const resolveJsImport = (fromRel, spec) => {
@@ -77,10 +253,10 @@ export function buildResolvers(repoDir, fileSet) {
77
253
  let base;
78
254
  if (spec.startsWith(".")) base = join(dirname(fromRel), spec).replace(/\\/g, "/").replace(/^\.\//, "");
79
255
  else {
80
- base = resolveAlias(spec);
256
+ base = resolveAlias(fromRel, spec);
81
257
  if (base == null) {
82
258
  // baseUrl-rooted internal import ("components/Button" with baseUrl:"src") — try before calling it an npm package
83
- for (const b of jsBaseUrls) {
259
+ for (const ctx of contextsForFile(fromRel)) for (const b of ctx.baseUrls) {
84
260
  const root = (b ? b + "/" : "") + spec;
85
261
  for (const e of JS_EXTS) { const cand = (root + e).replace(/\/+/g, "/"); if (fileSet.has(cand)) return cand; }
86
262
  }
@@ -119,5 +295,5 @@ export function buildResolvers(repoDir, fileSet) {
119
295
  return fileSet.has(cand) ? cand : null;
120
296
  };
121
297
 
122
- return { resolveJsImport, resolveAlias, resolvePyPath, pyBaseDir, pyTopDirs, resolveGoImport, dirFiles, resolveJavaImport, resolveHref, selectorIndex, htmlUsages, goModule, goRequires };
298
+ return { resolveJsImport, resolveAlias, resolvePyPath, pyBaseDir, pyTopDirs, resolveGoImport, dirFiles, resolveRustMod, resolveRustPath, resolveJavaImport, resolveHref, selectorIndex, htmlUsages, goModule, goRequires };
123
299
  }
@@ -0,0 +1,4 @@
1
+ // Ownership/nesting edges are useful for navigation, but are not evidence of code usage.
2
+ const STRUCTURAL_RELATIONS = new Set(["contains", "method"]);
3
+
4
+ export const isStructuralRelation = (relation) => STRUCTURAL_RELATIONS.has(String(relation || ""));
@@ -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: 'Return the most connected nodes - the core abstractions of the knowledge graph.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', default: 10}}}, run: (g, a) => tg.tGodNodes(g, a)},
26
+ {cap: 'graph', name: 'god_nodes', description: 'Rank connectivity hubs by unique call/import/reference neighbors, with class/method ownership reported separately from runtime connectivity. Repeated call sites 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)},
@@ -34,14 +34,14 @@ function buildTools({tg, ti, th, ta}) {
34
34
  {cap: 'health', name: 'run_audit', description: 'Full internal health audit of the repo: dead code (unused files/exports), dependency health (missing/undeclared and unused npm/Go/Python deps), structure (import cycles, orphan files, boundary-rule violations), and offline supply-chain checks (known OSV vulnerabilities, typosquats, lockfile drift). Filter by category/severity. Malware heuristics are opt-in (slow).', inputSchema: {type: 'object', properties: {category: {type: 'string', enum: ['unused', 'structure', 'vulnerability', 'malware'], description: 'Only findings of this category'}, min_severity: {type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'], description: 'Minimum severity to include'}, max_findings: {type: 'integer', description: 'Max findings to list, default 30'}, include_malware_scan: {type: 'boolean', description: 'Also grep installed packages for malware heuristics (slow)', default: false}}}, run: (g, a, ctx) => th.tRunAudit(g, a, ctx)},
35
35
  {cap: 'health', name: 'coverage_map', description: 'Map an existing test-coverage report (istanbul/lcov/coverage.py/Go cover.out — read offline, never runs tests) onto the code graph: per-module coverage plus refactor-risk hotspots — well-connected symbols with low coverage, ranked by degree × uncovered. Pair with get_dependents: many dependents + low coverage ⇒ write tests before changing.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max risk hotspots to list, default 15'}, path: {type: 'string', description: 'Optional repo-relative path prefix filter, e.g. src/query'}}}, run: (g, a, ctx) => th.tCoverageMap(g, a, ctx)},
36
36
  {cap: 'graph', name: 'list_communities', description: 'List graph communities named by their dominant folder (largest first) with sample files — a readable module overview; feed the list position into get_community.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max communities to list, default 20'}}}, run: (g, a, ctx) => th.tListCommunities(g, a, ctx)},
37
- {cap: 'graph', name: 'module_map', description: 'Folder-level architecture map: modules with file/symbol counts plus the strongest module→module dependency edges. Fast orientation before diving into files.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max modules to list, default 25'}}}, run: (g, a, ctx) => th.tModuleMap(g, a, ctx)},
38
- {cap: 'source', name: 'list_endpoints', description: 'Inventory of HTTP endpoints defined in the repo (Express/Fastify/Nest/Flask/FastAPI/Go mux …): method, path, handler, and file:line, deduped across code and OpenAPI docs.', inputSchema: {type: 'object', properties: {max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
37
+ {cap: 'graph', name: 'module_map', description: 'Folder-level architecture map with file/symbol counts and strongest module dependencies, separating runtime, TypeScript type-only and language compile-only coupling.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max modules to list, default 25'}}}, run: (g, a, ctx) => th.tModuleMap(g, a, ctx)},
38
+ {cap: 'source', name: 'list_endpoints', description: 'Inventory of HTTP endpoints defined in the repo (Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web …): method, path, handler, and file:line.', inputSchema: {type: 'object', properties: {max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
39
39
  {cap: 'build', name: 'rebuild_graph', description: "Rebuild this repo's code graph from current source (weavatrix's own web-tree-sitter builder), reload it in-memory, and report the STRUCTURAL DELTA vs the previous state — new/removed module dependencies, cycle changes, newly orphaned symbols. The prior state is saved as graph.prev.json for graph_diff. Call after significant edits.", inputSchema: {type: 'object', properties: {mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], default: 'full'}, scope: {type: 'string', description: 'optional path prefix to limit the graph'}}}, run: (g, a, ctx) => ta.tRebuildGraph(g, a, ctx)},
40
40
  {cap: 'graph', name: 'graph_diff', description: 'Structural diff of the last rebuild: previous graph state (graph.prev.json, saved by rebuild_graph) vs current — architecture drift (new module dependencies), broken or introduced import cycles, symbols that lost their last caller. The semantic complement to the textual git diff for validating a refactor.', inputSchema: {type: 'object', properties: {path: {type: 'string', description: 'Optional node-id/path prefix to scope the diff, e.g. src/query'}}}, run: (g, a, ctx) => ti.tGraphDiff(g, a, ctx)},
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 not read for sync. Graphs built before 0.1.2 must be rebuilt once before syncing.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tSyncGraph(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 never read for sync. Rebuild graphs created before 0.1.4 once to add edge metadata v2.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tSyncGraph(g, a, ctx)},
45
45
  ]
46
46
  }
47
47
 
@@ -6,8 +6,8 @@ import {readFileSync, statSync} from 'node:fs'
6
6
  import {join} from 'node:path'
7
7
  import {spawnSync} from 'node:child_process'
8
8
  import {childProcessEnv} from '../child-env.js'
9
- import {buildFileImportGraph, findSccs} from '../analysis/dep-rules.js'
10
9
  import {resolveRepoPath} from '../repo-path.js'
10
+ import {isStructuralRelation} from '../graph/relations.js'
11
11
 
12
12
  // ---- graph load + indexes -----------------------------------------------------------------------
13
13
  export function loadGraph(path) {
@@ -33,22 +33,35 @@ 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
- push(out, s, {id: t, relation: e.relation, confidence: e.confidence})
37
- push(inn, t, {id: s, relation: e.relation, confidence: e.confidence})
36
+ const metadata = {
37
+ relation: e.relation,
38
+ confidence: e.confidence,
39
+ ...(e.typeOnly === true ? {typeOnly: true} : {}),
40
+ ...(e.compileOnly === true ? {compileOnly: true} : {}),
41
+ ...(Number.isInteger(e.line) ? {line: e.line} : {}),
42
+ ...(typeof e.specifier === 'string' ? {specifier: e.specifier} : {}),
43
+ }
44
+ push(out, s, {id: t, ...metadata})
45
+ push(inn, t, {id: s, ...metadata})
46
+ }
47
+ return {
48
+ nodes, links, byId, byLabel, out, inn,
49
+ repoBoundaryV: Number(raw.repoBoundaryV) || 0,
50
+ edgeTypesV: Number(raw.edgeTypesV) || 0,
38
51
  }
39
- return {nodes, links, byId, byLabel, out, inn, repoBoundaryV: Number(raw.repoBoundaryV) || 0}
40
52
  }
41
53
 
42
54
  export const isSymbol = (id) => String(id).includes('#')
43
- export const degreeOf = (g, id) => (g.out.get(id)?.length || 0) + (g.inn.get(id)?.length || 0)
44
55
  export const labelOf = (g, id) => {
45
56
  const n = g.byId.get(String(id))
46
57
  return n ? String(n.label ?? n.id) : String(id)
47
58
  }
48
59
 
49
- // "connectivity" degree ignores structural `contains` (parent→symbol nesting) so god_nodes surfaces real
50
- // call/import/reference hubs, not just files that hold many symbols.
51
- export const connList = (list) => (list || []).filter((e) => e.relation !== 'contains')
60
+ // Connectivity ignores structural file/symbol and class/method ownership, so runtime/compile-time
61
+ // dependency ranks never treat nesting as a call or import.
62
+ export const connList = (list) => (list || []).filter((e) => !isStructuralRelation(e.relation))
63
+ export const degreeOf = (g, id) => connList(g.out.get(id)).length + connList(g.inn.get(id)).length
64
+ export const uniqueConnCount = (list) => new Set(connList(list).map((e) => String(e.id))).size
52
65
 
53
66
  // Resolve a user-supplied "label" to a node: exact id → exact label → ci label → substring (best degree).
54
67
  // Returns {node, matches, alternates} so callers can disclose ambiguity instead of silently picking one.
@@ -191,89 +204,4 @@ export function rawGraph(ctx) {
191
204
  return rawGraphCache.data
192
205
  }
193
206
 
194
- // ---- graph diff ----------------------------------------------------------------------------------
195
- // One previous state is enough (4-13 MB per repo — cheap): rebuild_graph snapshots the outgoing
196
- // graph.json as graph.prev.json and reports the structural delta inline, at the exact moment the fix
197
- // is being verified. graph_diff re-queries the same pair later. Raw node/edge dumps would be noise —
198
- // the signal is aggregated: module-dependency drift, cycle count changes, newly orphaned symbols.
199
- export const prevGraphPathFor = (graphPath) => String(graphPath).replace(/\.json$/, '.prev.json')
200
- export const edgeEndpoint = (v) => String(v && typeof v === 'object' ? v.id : v)
201
- export const fileOfId = (id) => { const s = String(id); const h = s.indexOf('#'); return h < 0 ? s : s.slice(0, h) }
202
- const folderOfFile = (file) => {
203
- const dirs = String(file || '').split(/[\\/]/).filter(Boolean).slice(0, -1)
204
- return dirs.length ? dirs.slice(0, 2).join('/') : '(root)'
205
- }
206
-
207
- // Works on anything with {nodes, links}: the raw graph.json shape and the loadGraph struct alike.
208
- export function diffGraphs(oldG, newG) {
209
- const nodeIds = (graph) => new Set((graph.nodes || []).map((n) => String(n.id)))
210
- const oldNodes = nodeIds(oldG)
211
- const newNodes = nodeIds(newG)
212
-
213
- const edgeKey = (l) => `${edgeEndpoint(l.source)}|${l.relation || ''}|${edgeEndpoint(l.target)}`
214
- const edgeSet = (graph) => new Set((graph.links || []).map(edgeKey))
215
- const oldEdges = edgeSet(oldG)
216
- const newEdges = edgeSet(newG)
217
-
218
- const moduleEdges = (graph) => {
219
- const set = new Set()
220
- for (const l of graph.links || []) {
221
- if (l.relation === 'contains') continue
222
- const a = folderOfFile(fileOfId(edgeEndpoint(l.source)))
223
- const b = folderOfFile(fileOfId(edgeEndpoint(l.target)))
224
- if (a !== b) set.add(`${a} → ${b}`)
225
- }
226
- return set
227
- }
228
- const oldMods = moduleEdges(oldG)
229
- const newMods = moduleEdges(newG)
230
-
231
- const incoming = (graph) => {
232
- const m = new Map()
233
- for (const l of graph.links || []) {
234
- if (l.relation === 'contains') continue
235
- const t = edgeEndpoint(l.target)
236
- m.set(t, (m.get(t) || 0) + 1)
237
- }
238
- return m
239
- }
240
- const oldIn = incoming(oldG)
241
- const newIn = incoming(newG)
242
-
243
- const cycles = (graph) => { try { return findSccs(buildFileImportGraph(graph).adj).length } catch { return null } }
244
-
245
- return {
246
- nodes: {
247
- added: [...newNodes].filter((id) => !oldNodes.has(id)),
248
- removed: [...oldNodes].filter((id) => !newNodes.has(id))
249
- },
250
- edges: {
251
- added: [...newEdges].filter((k) => !oldEdges.has(k)).length,
252
- removed: [...oldEdges].filter((k) => !newEdges.has(k)).length
253
- },
254
- moduleEdges: {
255
- added: [...newMods].filter((k) => !oldMods.has(k)),
256
- removed: [...oldMods].filter((k) => !newMods.has(k))
257
- },
258
- // survived the rebuild but lost every caller/importer — likely made dead by the change
259
- orphaned: [...oldIn.keys()].filter((id) => newNodes.has(id) && !newIn.has(id)),
260
- cycles: {before: cycles(oldG), after: cycles(newG)}
261
- }
262
- }
263
-
264
- export function formatGraphDiff(d) {
265
- if (!d.nodes.added.length && !d.nodes.removed.length && !d.edges.added && !d.edges.removed) {
266
- return 'No structural change between the two graph states.'
267
- }
268
- const cap = (list, n) => list.slice(0, n).map((x) => ` ${x}`).concat(list.length > n ? [` … +${list.length - n} more`] : [])
269
- const lines = [`Structural delta: nodes +${d.nodes.added.length}/−${d.nodes.removed.length}, edges +${d.edges.added}/−${d.edges.removed}.`]
270
- if (d.cycles.before != null && d.cycles.after != null && d.cycles.before !== d.cycles.after) {
271
- lines.push(`Import cycles: ${d.cycles.before} → ${d.cycles.after}${d.cycles.after < d.cycles.before ? ' (cycle broken — fix confirmed)' : ' (NEW cycle introduced — see run_audit)'}`)
272
- }
273
- if (d.moduleEdges.added.length) lines.push('NEW module dependencies (architecture drift — review):', ...cap(d.moduleEdges.added, 12))
274
- if (d.moduleEdges.removed.length) lines.push('Removed module dependencies (decoupling confirmed):', ...cap(d.moduleEdges.removed, 12))
275
- if (d.orphaned.length) lines.push('Symbols that lost their last caller/importer (now dead?):', ...cap(d.orphaned, 10))
276
- if (d.nodes.added.length) lines.push('Added nodes:', ...cap(d.nodes.added, 12))
277
- if (d.nodes.removed.length) lines.push('Removed nodes:', ...cap(d.nodes.removed, 12))
278
- return lines.join('\n')
279
- }
207
+ export {prevGraphPathFor, edgeEndpoint, fileOfId, diffGraphs, formatGraphDiff} from './graph-diff.mjs'
@@ -0,0 +1,163 @@
1
+ // Structural graph delta helpers split from graph-context so the process-lifetime graph indexes stay small.
2
+ import {buildFileImportGraph, findSccs} from '../analysis/dep-rules.js'
3
+ import {folderModuleOf} from '../analysis/graph-analysis.aggregate.js'
4
+ import {isStructuralRelation} from '../graph/relations.js'
5
+
6
+ // ---- graph diff ----------------------------------------------------------------------------------
7
+ // One previous state is enough (4-13 MB per repo — cheap): rebuild_graph snapshots the outgoing
8
+ // graph.json as graph.prev.json and reports the structural delta inline, at the exact moment the fix
9
+ // is being verified. graph_diff re-queries the same pair later. Raw node/edge dumps would be noise —
10
+ // the signal is aggregated: module-dependency drift, cycle count changes, newly orphaned symbols.
11
+ export const prevGraphPathFor = (graphPath) => String(graphPath).replace(/\.json$/, '.prev.json')
12
+ export const edgeEndpoint = (v) => String(v && typeof v === 'object' ? v.id : v)
13
+ export const fileOfId = (id) => { const s = String(id); const h = s.indexOf('#'); return h < 0 ? s : s.slice(0, h) }
14
+ const folderOfFile = folderModuleOf
15
+
16
+ // Works on anything with {nodes, links}: the raw graph.json shape and the loadGraph struct alike.
17
+ export function diffGraphs(oldG, newG) {
18
+ const oldEdgeTypesV = Number(oldG.edgeTypesV) || 0
19
+ const newEdgeTypesV = Number(newG.edgeTypesV) || 0
20
+ const schemaMigration = oldEdgeTypesV !== newEdgeTypesV
21
+ const nodeIds = (graph) => new Set((graph.nodes || []).map((n) => String(n.id)))
22
+ const oldNodes = nodeIds(oldG)
23
+ const newNodes = nodeIds(newG)
24
+
25
+ const edgeClass = (l) => l.typeOnly === true ? 'type' : l.compileOnly === true ? 'compile' : 'runtime'
26
+ const edgeKey = (l) => `${edgeEndpoint(l.source)}|${l.relation || ''}|${schemaMigration ? 'untyped' : edgeClass(l)}|${edgeEndpoint(l.target)}`
27
+ const edgeSet = (graph) => new Set((graph.links || []).map(edgeKey))
28
+ const oldEdges = edgeSet(oldG)
29
+ const newEdges = edgeSet(newG)
30
+
31
+ const moduleEdges = (graph, classification) => {
32
+ const set = new Set()
33
+ for (const l of graph.links || []) {
34
+ if (isStructuralRelation(l.relation)) continue
35
+ if (edgeClass(l) !== classification) continue
36
+ const a = folderOfFile(fileOfId(edgeEndpoint(l.source)))
37
+ const b = folderOfFile(fileOfId(edgeEndpoint(l.target)))
38
+ if (a !== b) set.add(`${a} → ${b}`)
39
+ }
40
+ return set
41
+ }
42
+ // A parser/schema upgrade can discover edges that the old graph could not represent (Rust use/mod in
43
+ // edgeTypesV2). Do not call that architecture drift: establish a fresh classification baseline.
44
+ const oldMods = schemaMigration ? new Set() : moduleEdges(oldG, 'runtime')
45
+ const newMods = schemaMigration ? new Set() : moduleEdges(newG, 'runtime')
46
+ const oldTypeMods = schemaMigration ? new Set() : moduleEdges(oldG, 'type')
47
+ const newTypeMods = schemaMigration ? new Set() : moduleEdges(newG, 'type')
48
+ const oldCompileMods = schemaMigration ? new Set() : moduleEdges(oldG, 'compile')
49
+ const newCompileMods = schemaMigration ? new Set() : moduleEdges(newG, 'compile')
50
+
51
+ const incoming = (graph) => {
52
+ const m = new Map()
53
+ for (const l of graph.links || []) {
54
+ if (isStructuralRelation(l.relation)) continue
55
+ const t = edgeEndpoint(l.target)
56
+ m.set(t, (m.get(t) || 0) + 1)
57
+ }
58
+ return m
59
+ }
60
+ const oldIn = incoming(oldG)
61
+ const newIn = incoming(newG)
62
+
63
+ const cycles = (graph, includeTypeOnly) => {
64
+ try {
65
+ const sccs = findSccs(buildFileImportGraph(graph, {includeTypeOnly}).adj)
66
+ .map((members) => members.map(String).sort())
67
+ .sort((a, b) => b.length - a.length || a.join('\n').localeCompare(b.join('\n')))
68
+ return {
69
+ count: sccs.length,
70
+ largest: sccs[0]?.length || 0,
71
+ groups: sccs,
72
+ }
73
+ } catch {
74
+ return null
75
+ }
76
+ }
77
+ const cycleDelta = (before, after) => {
78
+ if (!before || !after) return null
79
+ const key = (group) => group.join('|')
80
+ const beforeKeys = new Set(before.groups.map(key))
81
+ const afterKeys = new Set(after.groups.map(key))
82
+ const overlap = (a, b) => {
83
+ const bSet = new Set(b)
84
+ return a.reduce((n, member) => n + (bSet.has(member) ? 1 : 0), 0)
85
+ }
86
+ const unmatchedBefore = before.groups.filter((group) => !afterKeys.has(key(group)))
87
+ const unmatchedAfter = after.groups.filter((group) => !beforeKeys.has(key(group)))
88
+ const changed = unmatchedAfter.filter((group) => unmatchedBefore.some((old) => overlap(group, old) >= 2))
89
+ const introduced = unmatchedAfter.filter((group) => !unmatchedBefore.some((old) => overlap(group, old) >= 2))
90
+ const resolved = unmatchedBefore.filter((group) => !unmatchedAfter.some((next) => overlap(group, next) >= 2))
91
+ return {
92
+ before: before.count,
93
+ after: after.count,
94
+ largestBefore: before.largest,
95
+ largestAfter: after.largest,
96
+ introduced: introduced.map(key),
97
+ resolved: resolved.map(key),
98
+ membershipChanged: changed.length,
99
+ }
100
+ }
101
+
102
+ return {
103
+ schemaMigration: schemaMigration ? {from: oldEdgeTypesV, to: newEdgeTypesV} : null,
104
+ nodes: {
105
+ added: [...newNodes].filter((id) => !oldNodes.has(id)),
106
+ removed: [...oldNodes].filter((id) => !newNodes.has(id))
107
+ },
108
+ edges: {
109
+ added: [...newEdges].filter((k) => !oldEdges.has(k)).length,
110
+ removed: [...oldEdges].filter((k) => !newEdges.has(k)).length
111
+ },
112
+ moduleEdges: {
113
+ added: [...newMods].filter((k) => !oldMods.has(k)),
114
+ removed: [...oldMods].filter((k) => !newMods.has(k)),
115
+ typeAdded: [...newTypeMods].filter((k) => !oldTypeMods.has(k)),
116
+ typeRemoved: [...oldTypeMods].filter((k) => !newTypeMods.has(k)),
117
+ compileAdded: [...newCompileMods].filter((k) => !oldCompileMods.has(k)),
118
+ compileRemoved: [...oldCompileMods].filter((k) => !newCompileMods.has(k)),
119
+ },
120
+ // survived the rebuild but lost every caller/importer — likely made dead by the change
121
+ orphaned: [...oldIn.keys()].filter((id) => newNodes.has(id) && !newIn.has(id)),
122
+ cycles: {
123
+ runtime: schemaMigration ? null : cycleDelta(cycles(oldG, false), cycles(newG, false)),
124
+ // Backward-compatible field name: since edgeTypesV 2 this includes typeOnly and compileOnly.
125
+ typeInclusive: schemaMigration ? null : cycleDelta(cycles(oldG, true), cycles(newG, true)),
126
+ }
127
+ }
128
+ }
129
+
130
+ export function formatGraphDiff(d) {
131
+ if (!d.nodes.added.length && !d.nodes.removed.length && !d.edges.added && !d.edges.removed) {
132
+ return d.schemaMigration
133
+ ? `Graph edge schema upgraded v${d.schemaMigration.from} → v${d.schemaMigration.to}; compile-time baseline established. Runtime/compile-time cycle and module classifications are intentionally not compared on this rebuild.`
134
+ : 'No structural change between the two graph states.'
135
+ }
136
+ const cap = (list, n) => list.slice(0, n).map((x) => ` ${x}`).concat(list.length > n ? [` … +${list.length - n} more`] : [])
137
+ const lines = [`Structural delta: nodes +${d.nodes.added.length}/−${d.nodes.removed.length}, edges +${d.edges.added}/−${d.edges.removed}.`]
138
+ if (d.schemaMigration) lines.push(`Graph edge schema upgraded v${d.schemaMigration.from} → v${d.schemaMigration.to}; runtime/compile-time cycle and module classifications are intentionally not compared until the next rebuild.`)
139
+ const runtime = d.cycles?.runtime
140
+ if (runtime && (runtime.before !== runtime.after || runtime.largestBefore !== runtime.largestAfter || runtime.introduced.length || runtime.resolved.length || runtime.membershipChanged)) {
141
+ const changes = []
142
+ if (runtime.introduced.length) changes.push(`${runtime.introduced.length} genuinely new runtime SCC(s) — review`)
143
+ if (runtime.resolved.length) changes.push(`${runtime.resolved.length} runtime SCC(s) resolved`)
144
+ if (runtime.membershipChanged) changes.push(`${runtime.membershipChanged} SCC membership change(s)`)
145
+ const verdict = changes.length ? `; ${changes.join('; ')}` : ''
146
+ lines.push(`Runtime import cycles: count ${runtime.before} → ${runtime.after}, largest SCC ${runtime.largestBefore} → ${runtime.largestAfter}${verdict}.`)
147
+ }
148
+ const all = d.cycles?.typeInclusive
149
+ if (all && (all.before !== all.after || all.largestBefore !== all.largestAfter) &&
150
+ (!runtime || all.before !== runtime.before || all.after !== runtime.after || all.largestBefore !== runtime.largestBefore || all.largestAfter !== runtime.largestAfter)) {
151
+ lines.push(`Compile-time-inclusive dependency SCCs: count ${all.before} → ${all.after}, largest ${all.largestBefore} → ${all.largestAfter} (compile-time coupling, not necessarily a runtime cycle).`)
152
+ }
153
+ if (d.moduleEdges.added.length) lines.push('NEW module dependencies (architecture drift — review):', ...cap(d.moduleEdges.added, 12))
154
+ if (d.moduleEdges.removed.length) lines.push('Removed module dependencies (decoupling confirmed):', ...cap(d.moduleEdges.removed, 12))
155
+ if (d.moduleEdges.typeAdded.length) lines.push('New type-only module dependencies (compile-time coupling):', ...cap(d.moduleEdges.typeAdded, 12))
156
+ if (d.moduleEdges.typeRemoved.length) lines.push('Removed type-only module dependencies:', ...cap(d.moduleEdges.typeRemoved, 12))
157
+ if (d.moduleEdges.compileAdded.length) lines.push('New compile-only module dependencies (compile-time coupling):', ...cap(d.moduleEdges.compileAdded, 12))
158
+ if (d.moduleEdges.compileRemoved.length) lines.push('Removed compile-only module dependencies:', ...cap(d.moduleEdges.compileRemoved, 12))
159
+ if (d.orphaned.length) lines.push('Symbols that lost their last caller/importer (now dead?):', ...cap(d.orphaned, 10))
160
+ if (d.nodes.added.length) lines.push('Added nodes:', ...cap(d.nodes.added, 12))
161
+ if (d.nodes.removed.length) lines.push('Removed nodes:', ...cap(d.nodes.removed, 12))
162
+ return lines.join('\n')
163
+ }