weavatrix 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/bin/weavatrix-mcp.mjs +6 -0
  4. package/package.json +50 -0
  5. package/skill/SKILL.md +56 -0
  6. package/src/analysis/coverage-reports.js +232 -0
  7. package/src/analysis/dead-check.js +136 -0
  8. package/src/analysis/dep-check.js +310 -0
  9. package/src/analysis/dep-rules.js +221 -0
  10. package/src/analysis/duplicates-worker.js +11 -0
  11. package/src/analysis/duplicates.compute.js +215 -0
  12. package/src/analysis/duplicates.js +15 -0
  13. package/src/analysis/duplicates.run.js +51 -0
  14. package/src/analysis/duplicates.tokenize.js +182 -0
  15. package/src/analysis/endpoints.js +156 -0
  16. package/src/analysis/findings.js +52 -0
  17. package/src/analysis/graph-analysis.aggregate.js +280 -0
  18. package/src/analysis/graph-analysis.js +7 -0
  19. package/src/analysis/graph-analysis.refs.js +146 -0
  20. package/src/analysis/graph-analysis.summaries.js +62 -0
  21. package/src/analysis/internal-audit.collect.js +117 -0
  22. package/src/analysis/internal-audit.js +9 -0
  23. package/src/analysis/internal-audit.reach.js +47 -0
  24. package/src/analysis/internal-audit.run.js +189 -0
  25. package/src/analysis/manifests.js +169 -0
  26. package/src/analysis/source-complexity.ast.js +97 -0
  27. package/src/analysis/source-complexity.constants.js +55 -0
  28. package/src/analysis/source-complexity.js +14 -0
  29. package/src/analysis/source-complexity.report.js +76 -0
  30. package/src/analysis/source-complexity.walk.js +174 -0
  31. package/src/build-graph.js +102 -0
  32. package/src/config.js +5 -0
  33. package/src/graph/build-worker.js +30 -0
  34. package/src/graph/builder/lang-csharp.js +40 -0
  35. package/src/graph/builder/lang-css.js +29 -0
  36. package/src/graph/builder/lang-go.js +53 -0
  37. package/src/graph/builder/lang-html.js +29 -0
  38. package/src/graph/builder/lang-java.js +29 -0
  39. package/src/graph/builder/lang-js.js +168 -0
  40. package/src/graph/builder/lang-python.js +78 -0
  41. package/src/graph/builder/lang-rust.js +45 -0
  42. package/src/graph/builder/spec-pkg.js +87 -0
  43. package/src/graph/graph-filter.js +44 -0
  44. package/src/graph/internal-builder.build.js +217 -0
  45. package/src/graph/internal-builder.js +12 -0
  46. package/src/graph/internal-builder.langs.js +86 -0
  47. package/src/graph/internal-builder.resolvers.js +116 -0
  48. package/src/graph/layout.js +35 -0
  49. package/src/infra/infra-items.js +255 -0
  50. package/src/infra/infra-registry.js +184 -0
  51. package/src/infra/infra.detect.js +119 -0
  52. package/src/infra/infra.js +38 -0
  53. package/src/infra/infra.match.js +102 -0
  54. package/src/infra/infra.scan.js +93 -0
  55. package/src/mcp/catalog.mjs +68 -0
  56. package/src/mcp/graph-context.mjs +276 -0
  57. package/src/mcp/tools-actions.mjs +132 -0
  58. package/src/mcp/tools-graph.mjs +274 -0
  59. package/src/mcp/tools-health.mjs +209 -0
  60. package/src/mcp/tools-impact.mjs +189 -0
  61. package/src/mcp-rg.mjs +51 -0
  62. package/src/mcp-server.mjs +171 -0
  63. package/src/mcp-source-tools.mjs +139 -0
  64. package/src/process.js +77 -0
  65. package/src/scan/discover.inventory.js +78 -0
  66. package/src/scan/discover.js +5 -0
  67. package/src/scan/discover.list.js +79 -0
  68. package/src/scan/discover.stack.js +227 -0
  69. package/src/scan/search.core.js +24 -0
  70. package/src/scan/search.git.js +102 -0
  71. package/src/scan/search.js +9 -0
  72. package/src/scan/search.node.js +83 -0
  73. package/src/scan/search.preview.js +49 -0
  74. package/src/scan/search.rg.js +145 -0
  75. package/src/security/advisory-store.js +177 -0
  76. package/src/security/installed.js +247 -0
  77. package/src/security/malware-file-heuristics.js +121 -0
  78. package/src/security/malware-heuristics.exclusions.js +134 -0
  79. package/src/security/malware-heuristics.js +15 -0
  80. package/src/security/malware-heuristics.roots.js +142 -0
  81. package/src/security/malware-heuristics.scan.js +87 -0
  82. package/src/security/malware-heuristics.sweep.js +122 -0
  83. package/src/security/malware-scoring.js +84 -0
  84. package/src/security/match.js +85 -0
  85. package/src/security/registry-sig.classify.js +136 -0
  86. package/src/security/registry-sig.js +18 -0
  87. package/src/security/registry-sig.rules.js +188 -0
  88. package/src/security/typosquat.js +72 -0
  89. package/src/util.js +27 -0
@@ -0,0 +1,38 @@
1
+ // Infrastructure / backing-service detection. graph-builder's graph.json only contains code FILES and
2
+ // symbols, so the GUI board's external-service towers were inferred purely from FILE PATHS
3
+ // (relIsDb + a name-regex). That misses any datastore whose connector file isn't literally named
4
+ // after it — e.g. a service that `import`s @clickhouse/client from services/metrics.js, or wires
5
+ // Influx through a logging helper. This module reads the REAL high-signal sources instead:
6
+ // 1. dependency manifests (package.json / go.mod / requirements.txt / pom.xml / build.gradle /
7
+ // Cargo.toml / *.csproj / Gemfile / composer.json) — most reliable
8
+ // 2. container/orchestration (docker-compose / Dockerfile / k8s manifests) — image names
9
+ // 3. env / config (.env*, k8s env:) — variable-NAME conventions (KEYS ONLY, never values)
10
+ // 4. source imports — to attribute each service to its connector file(s) for the io edges
11
+ // and matches them against a curated signature registry → a structured list of services the repo
12
+ // talks to, each with kind/name/colour + the files that connect to it.
13
+ //
14
+ // PRIVACY: env files are read for KEY NAMES only (the part before `=`); values are never parsed,
15
+ // stored, logged, or returned. Secrets in .env stay in .env.
16
+ //
17
+ // This file is now a facade — the implementation lives in the sibling modules below; every public
18
+ // export is re-exported here so existing import paths keep working unchanged:
19
+ // infra.match.js — deterministic matchers (deps / images / env keys) + manifest dep extraction
20
+ // infra.scan.js — the single filesystem walk (manifests, compose/k8s images, env KEY names)
21
+ // infra.detect.js — signature matching, connector-file attribution, cached public API
22
+
23
+ // ---- signature registry ----
24
+ // One row per backing service, loaded from infra-registry.js (generated + adversarially verified by the
25
+ // infra-signature-registry workflow). Matching is DETERMINISTIC token comparison (no free-form regex over
26
+ // arbitrary text), so false positives stay near zero:
27
+ // deps — exact manifest dependency names; token T matches dep D when D===T or D starts with T/ T: T@.
28
+ // images — docker image repo names, matched by path-segment SUFFIX ("redis" hits "bitnami/redis";
29
+ // "mongo" misses "mongo-express").
30
+ // envPrefixes — UPPERCASE env-var KEY prefixes; token P matches key K when K===P or K starts with P_.
31
+ // Prefixes listed in envWeak only count when the key ALSO ends in an infra suffix
32
+ // (HOST/URL/DSN/PORT/BROKER/…), so DATABASE_URL-style keys can't over-fire.
33
+ // imports — quoted substrings in source import/require lines; used to attribute connector files.
34
+ // kind ∈ db|ts|cache|queue|cloud|api|fs|logs — drives the GUI board tower glyph/colour (GUI core KCFG).
35
+ export { INFRA_SERVICES } from "./infra-registry.js";
36
+
37
+ export { depMatches, normImageRepo, imageMatches, envMatches, depsFromManifest } from "./infra.match.js";
38
+ export { detectInfraFromScan, detectInfra } from "./infra.detect.js";
@@ -0,0 +1,102 @@
1
+ // Deterministic matchers + manifest dependency extraction for infrastructure detection.
2
+ // Split out of infra.js (which remains the public facade); see that file's header for the full
3
+ // detection pipeline and the registry/matching contract.
4
+ import { lc } from "./infra-items.js";
5
+
6
+ // env-var name suffixes that, when a generic prefix carries one, confirm it really configures a
7
+ // backing service (so PG_/DATABASE_/S3_/ES_ only fire on PG_HOST, DATABASE_URL, S3_BUCKET, …).
8
+ const ENV_SUFFIX = /_(HOSTS?|URLS?|URI|DSN|PORTS?|ADDR|ADDRESS(ES)?|BROKERS?|SERVERS?|ENDPOINTS?|CONN|CONNECTION(S)?|DB|DATABASE|PASS(WORD)?|USER(NAME)?|SECRET|TOPICS?|BUCKETS?|REGION|GROUP|CLUSTER|NAMESPACE|REPLICA(SET)?|MEASUREMENT|TLS|SSL|KEY|TOKEN)$/;
9
+
10
+ // ---- matchers (exported for unit tests) ---------------------------------------------------------
11
+ export function depMatches(manifestDep, token) {
12
+ const m = lc(manifestDep), d = lc(token);
13
+ if (!m || !d) return false;
14
+ return m === d || m.startsWith(d + "/") || m.startsWith(d + ":") || m.startsWith(d + "@");
15
+ }
16
+
17
+ // strip registry host + tag + digest → bare repo path (lowercased)
18
+ export function normImageRepo(ref) {
19
+ let s = String(ref || "").trim().replace(/^['"]|['"]$/g, "");
20
+ if (!s) return "";
21
+ const at = s.indexOf("@");
22
+ if (at >= 0) s = s.slice(0, at); // strip @sha256:...
23
+ const slash = s.lastIndexOf("/");
24
+ const lastSeg = s.slice(slash + 1);
25
+ const colon = lastSeg.indexOf(":"); // tag lives only in the last path segment (host may have :port)
26
+ if (colon >= 0) s = s.slice(0, slash + 1) + lastSeg.slice(0, colon);
27
+ return lc(s);
28
+ }
29
+ // repo path-segments end with the token's segments (so "redis" hits "bitnami/redis", "mongo" misses "mongo-express")
30
+ export function imageMatches(repoSegs, token) {
31
+ const t = lc(token).split("/").filter(Boolean);
32
+ if (!t.length || t.length > repoSegs.length) return false;
33
+ for (let i = 1; i <= t.length; i++) if (repoSegs[repoSegs.length - i] !== t[t.length - i]) return false;
34
+ return true;
35
+ }
36
+
37
+ export function envMatches(key, token, weak) {
38
+ const K = String(key || "").toUpperCase(), P = String(token || "").toUpperCase();
39
+ if (!K || !P) return false;
40
+ if (K !== P && !K.startsWith(P + "_")) return false;
41
+ if (weak) {
42
+ if (K === P) return false; // a bare generic prefix alone proves nothing
43
+ if (!ENV_SUFFIX.test(K.slice(P.length))) return false; // require an infra-ish suffix
44
+ }
45
+ return true;
46
+ }
47
+
48
+ // ---- manifest dependency extraction -------------------------------------------------------------
49
+
50
+ // Returns a Set of dependency token strings pulled from one manifest file. PROD deps only where the
51
+ // format distinguishes them (package.json/composer.json), so test-only clients (ioredis-mock,
52
+ // mongodb-memory-server, testcontainers) don't manufacture a phantom service.
53
+ // test doubles / in-memory fakes / emulators — these pull in a client name but mean "this is tested
54
+ // against a fake", not "depends on the live service". Never let them register a backing service.
55
+ const DEV_DOUBLE = /(-mock|_mock|mock-|fake|memory-server|inmemory|testcontainers|-local$|localstack|azurite)/i;
56
+
57
+ export function depsFromManifest(name, text) {
58
+ const out = new Set();
59
+ const add = (v) => { const s = String(v || "").trim(); if (s && !DEV_DOUBLE.test(s)) out.add(s); };
60
+ const low = name.toLowerCase();
61
+ try {
62
+ if (low === "package.json") {
63
+ const j = JSON.parse(text);
64
+ for (const sect of ["dependencies", "optionalDependencies", "peerDependencies"]) {
65
+ if (j[sect] && typeof j[sect] === "object") for (const k of Object.keys(j[sect])) add(k);
66
+ }
67
+ } else if (low === "composer.json") {
68
+ const j = JSON.parse(text);
69
+ if (j.require && typeof j.require === "object") for (const k of Object.keys(j.require)) add(k);
70
+ } else if (low === "go.mod") {
71
+ // `require x.y/z v1` and `require ( … )` blocks
72
+ for (const m of text.matchAll(/^\s*(?:require\s+)?([a-z0-9.-]+\.[a-z]{2,}\/[^\s]+)\s+v[0-9]/gim)) add(m[1]);
73
+ } else if (low === "go.sum") {
74
+ for (const m of text.matchAll(/^([a-z0-9.-]+\.[a-z]{2,}\/[^\s]+)\s+v[0-9]/gim)) add(m[1]);
75
+ } else if (low === "requirements.txt" || low === "constraints.txt") {
76
+ for (const line of text.split(/\r?\n/)) {
77
+ const m = line.match(/^\s*([A-Za-z0-9._-]+)/);
78
+ if (m && !line.trim().startsWith("#")) add(m[1]);
79
+ }
80
+ } else if (low === "pipfile" || low === "pyproject.toml" || low === "cargo.toml") {
81
+ // TOML dependency tables — pull bare `name = ...` keys and array-of-strings `"name>=x"`.
82
+ for (const m of text.matchAll(/^\s*["']?([A-Za-z0-9._-]+)["']?\s*=/gm)) add(m[1]);
83
+ for (const m of text.matchAll(/["']([A-Za-z0-9._-]+)\s*[<>=~!^*]/g)) add(m[1]);
84
+ } else if (low === "pom.xml") {
85
+ // pair each <artifactId> with the nearest preceding <groupId> → group:artifact AND bare artifact
86
+ const re = /<groupId>\s*([^<]+?)\s*<\/groupId>\s*<artifactId>\s*([^<]+?)\s*<\/artifactId>/g;
87
+ let m;
88
+ while ((m = re.exec(text))) { add(`${m[1]}:${m[2]}`); add(m[2]); }
89
+ for (const a of text.matchAll(/<artifactId>\s*([^<]+?)\s*<\/artifactId>/g)) add(a[1]);
90
+ } else if (low.endsWith(".gradle") || low.endsWith(".gradle.kts")) {
91
+ // implementation('group:artifact:ver') / "group:artifact:ver"
92
+ for (const m of text.matchAll(/["']([A-Za-z0-9._-]+:[A-Za-z0-9._-]+)(?::[^"']*)?["']/g)) { add(m[1]); add(m[1].split(":")[1]); }
93
+ } else if (low.endsWith(".csproj") || low === "packages.config" || low.endsWith(".fsproj")) {
94
+ for (const m of text.matchAll(/(?:PackageReference|package)\s+(?:Include|id)\s*=\s*"([^"]+)"/gi)) add(m[1]);
95
+ } else if (low === "gemfile") {
96
+ for (const m of text.matchAll(/^\s*gem\s+["']([^"']+)["']/gim)) add(m[1]);
97
+ }
98
+ } catch {
99
+ /* malformed manifest — skip */
100
+ }
101
+ return out;
102
+ }
@@ -0,0 +1,93 @@
1
+ // Repo filesystem scan for infrastructure detection: one walk collects manifest dependency tokens,
2
+ // container image refs, env-var KEY names (never values — see the privacy note in infra.js), and
3
+ // code files for the import-attribution pass. Split out of infra.js (which remains the public facade).
4
+ import { readdirSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { safeRead } from "./infra-items.js";
7
+ import { depsFromManifest, normImageRepo } from "./infra.match.js";
8
+
9
+ // ---- scanning bounds (mirror apimap.js) ---------------------------------------------------------
10
+ const SKIP_DIRS = new Set([
11
+ ".git", "node_modules", "dist", "build", "out", ".next", "coverage", "vendor",
12
+ ".venv", "venv", "env", "target", "__pycache__", ".idea", ".vscode", ".cache", "bin", "obj",
13
+ ]);
14
+ const CODE_EXT = new Set([
15
+ ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".vue", ".svelte",
16
+ ".py", ".go", ".java", ".kt", ".rb", ".php", ".cs", ".scala", ".rs",
17
+ ]);
18
+ const MAX_FILES = 60000;
19
+ // graph-analysis IPC stays responsive on large repos — detection itself (manifests/images/env) is unaffected
20
+
21
+ const MANIFEST_NAMES = new Set([
22
+ "package.json", "composer.json", "go.mod", "go.sum", "requirements.txt", "constraints.txt",
23
+ "pipfile", "pyproject.toml", "cargo.toml", "pom.xml", "packages.config", "gemfile",
24
+ ]);
25
+ const isManifest = (name) => {
26
+ const n = name.toLowerCase();
27
+ return MANIFEST_NAMES.has(n) || n.endsWith(".gradle") || n.endsWith(".gradle.kts") || n.endsWith(".csproj") || n.endsWith(".fsproj");
28
+ };
29
+ const isComposeFile = (name) => /^(docker-)?compose([.-].*)?\.ya?ml$/i.test(name);
30
+ const isDockerfile = (name) => /^dockerfile(\..+)?$/i.test(name) || /\.dockerfile$/i.test(name);
31
+ const isYaml = (name) => /\.ya?ml$/i.test(name);
32
+
33
+ // ---- the scan -----------------------------------------------------------------------------------
34
+ export function scanRepo(repoPath) {
35
+ const deps = new Set(); // manifest dependency tokens
36
+ const imageRefs = []; // normalized image repo paths
37
+ const envKeys = new Set(); // UPPERCASE env-var names (keys only)
38
+ const codeFiles = []; // { path(rel, fwd-slash), full } for the import pass
39
+ const manifests = new Set(); // which manifest kinds were seen (for diagnostics)
40
+
41
+ let count = 0;
42
+ const stack = [repoPath];
43
+ while (stack.length) {
44
+ const cur = stack.pop();
45
+ let entries;
46
+ try { entries = readdirSync(cur, { withFileTypes: true }); } catch { continue; }
47
+ for (const entry of entries) {
48
+ if (entry.isDirectory()) {
49
+ if (!SKIP_DIRS.has(entry.name)) stack.push(join(cur, entry.name));
50
+ continue;
51
+ }
52
+ if (!entry.isFile()) continue;
53
+ if (++count > MAX_FILES) break;
54
+ const name = entry.name;
55
+ const full = join(cur, name);
56
+ const rel = full.slice(repoPath.length).replace(/^[\\/]/, "").replace(/\\/g, "/");
57
+
58
+ if (isManifest(name)) {
59
+ manifests.add(name.toLowerCase());
60
+ for (const d of depsFromManifest(name, safeRead(full))) deps.add(d);
61
+ continue;
62
+ }
63
+ if (/^\.env(\..+)?$/i.test(name)) {
64
+ const text = safeRead(full);
65
+ // KEYS ONLY — split on the first '=' and keep the left side; never read the value.
66
+ for (const line of text.split(/\r?\n/)) {
67
+ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/);
68
+ if (m) envKeys.add(m[1].toUpperCase());
69
+ }
70
+ continue;
71
+ }
72
+ const dockery = isComposeFile(name) || isDockerfile(name);
73
+ if (dockery || isYaml(name)) {
74
+ const text = safeRead(full);
75
+ if (!text) continue;
76
+ const k8sLike = /(^|\n)\s*kind:\s*\S/.test(text) && /(^|\n)\s*(image|env):/.test(text);
77
+ if (!dockery && !k8sLike) continue; // a random *.yaml that isn't a manifest — skip
78
+ for (const m of text.matchAll(/(?:^|\n)\s*(?:-\s*)?image:\s*["']?([^\s"']+)/gi)) imageRefs.push(normImageRepo(m[1]));
79
+ for (const m of text.matchAll(/(?:^|\n)\s*FROM\s+(?:--platform=\S+\s+)?([^\s]+)/gi)) imageRefs.push(normImageRepo(m[1]));
80
+ // env var NAMES: k8s `- name: KEY`, compose `KEY: value` / `- KEY=value`, Dockerfile `ENV KEY`
81
+ for (const m of text.matchAll(/\bname:\s*["']?([A-Z_][A-Z0-9_]{2,})\b/g)) envKeys.add(m[1].toUpperCase());
82
+ for (const m of text.matchAll(/(?:^|\n)\s*(?:-\s*)?([A-Z_][A-Z0-9_]{2,})\s*[:=]/g)) envKeys.add(m[1].toUpperCase());
83
+ continue;
84
+ }
85
+ const dot = name.lastIndexOf(".");
86
+ const ext = dot >= 0 ? name.slice(dot).toLowerCase() : "";
87
+ if (CODE_EXT.has(ext)) codeFiles.push({ path: rel, full });
88
+ }
89
+ if (count > MAX_FILES) break;
90
+ }
91
+ const imageSegs = imageRefs.filter(Boolean).map((r) => ({ raw: r, segs: r.split("/").filter(Boolean) }));
92
+ return { deps, imageSegs, envKeys, codeFiles, manifests: [...manifests] };
93
+ }
@@ -0,0 +1,68 @@
1
+ // Tool catalog + hot-reload loader. Every tool declares a capability GROUP; the stdio shell exposes
2
+ // only the groups enabled for this registration (argv[4]). loadHotApi() re-imports the tool modules
3
+ // with a cache-busting version so edits to src/mcp/tools-*.mjs (or this file) go live without an MCP
4
+ // reconnect. graph-context.mjs is imported STATICALLY everywhere — it holds process-lifetime caches
5
+ // and does not hot-reload (nor do the ../analysis engines; changing those needs a reconnect).
6
+ import {dirname} from 'node:path'
7
+ import {fileURLToPath} from 'node:url'
8
+ import {resolveNode, isSymbol, stalenessLine, resetStalenessCache} from './graph-context.mjs'
9
+ import {createRgResolver} from '../mcp-rg.mjs'
10
+ import {readSource, searchCode} from '../mcp-source-tools.mjs'
11
+
12
+ const SELF_DIR = dirname(fileURLToPath(import.meta.url))
13
+ const resolveRg = createRgResolver(SELF_DIR)
14
+
15
+ // The files whose mtime the stdio shell watches for hot reload — keep in sync with the imports in
16
+ // loadHotApi below (catalog.mjs itself is last: a change here re-derives the whole table).
17
+ export const HOT_FILES = ['tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-actions.mjs', 'catalog.mjs']
18
+
19
+ function buildTools({tg, ti, th, ta}) {
20
+ return [
21
+ {cap: 'graph', name: 'graph_stats', description: 'Return summary statistics: node count, edge count, communities, confidence breakdown, and graph build time vs repo HEAD (staleness).', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tg.tGraphStats(g, ctx)},
22
+ {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)},
23
+ {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)},
24
+ {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)},
25
+ {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: '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)},
27
+ {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)},
28
+ {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)},
29
+ {cap: 'graph', name: 'get_community', description: 'Get all nodes in a community by community ID (0-indexed by size).', inputSchema: {type: 'object', properties: {community_id: {type: 'integer', description: 'Community ID (0-indexed by size)'}}, required: ['community_id']}, run: (g, a) => tg.tGetCommunity(g, a)},
30
+ {cap: 'search', name: 'search_code', description: 'Full-text or regex search across the repo source (ripgrep-backed, Node fallback). The graph only stores structure — use this to find literal text/patterns, then get_node/get_neighbors for structure.', inputSchema: {type: 'object', properties: {query: {type: 'string', description: 'text or regex to search for'}, is_regex: {type: 'boolean', default: false}, glob: {type: 'string', description: 'optional path glob, e.g. "*.js" or "src/**"'}, max_results: {type: 'integer', default: 40}}, required: ['query']}, run: (g, a, ctx) => searchCode({repoRoot: ctx.repoRoot, resolveRg}, a)},
31
+ {cap: 'source', name: 'read_source', description: "Read the actual source of a node (by label/ID) or a repo-relative file path — the symbol's lines with context. The graph stores only locations, not source text. For a path read, pass start_line to anchor the window anywhere in the file (otherwise it shows the head).", inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'node label or ID'}, path: {type: 'string', description: 'or a repo-relative file path'}, start_line: {type: 'integer', description: 'anchor line: window = start_line-before .. start_line+after'}, before: {type: 'integer', default: 3}, after: {type: 'integer', default: 40}}}, run: (g, a, ctx) => readSource({repoRoot: ctx.repoRoot, resolveNode, isSymbol}, g, a)},
32
+ {cap: 'health', name: 'find_duplicates', description: "Content-based clone detection over the repo (MOSS winnowing over method bodies — finds copy-paste even with renamed variables, not just matching names). Returns clone groups with file:line sites. Use to guide de-duplication refactors. mode 'semantic' flips the lens: same-name symbols across files ranked by size, where LOW similarity marks divergent copies (same name, drifted behavior) — min_similarity is ignored there.", inputSchema: {type: 'object', properties: {min_similarity: {type: 'integer', description: '50-100, default 80 (ignored in semantic mode)'}, min_tokens: {type: 'integer', description: 'min fragment size, default 50'}, mode: {type: 'string', enum: ['renamed', 'strict', 'semantic'], default: 'renamed'}, include_tests: {type: 'boolean', default: false}, include_strings: {type: 'boolean', description: 'Also clone-check large multi-line string literals (embedded C#/SQL/PowerShell templates) — the code pass strips string bodies, so these are otherwise invisible', default: false}, top_n: {type: 'integer', default: 15}}}, run: (g, a, ctx) => th.tFindDuplicates(g, a, ctx)},
33
+ {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)},
34
+ {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)},
35
+ {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)},
36
+ {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)},
37
+ {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)},
38
+ {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)},
39
+ {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)},
40
+ {cap: 'build', name: 'open_repo', description: 'Retarget this server at another local repository: loads its graph from the central weavatrix-graphs layout next to the repo, building it first when missing (large repos can take minutes; pass build:false to probe without building). Afterwards every tool answers for the new repo.', inputSchema: {type: 'object', properties: {path: {type: 'string', description: 'Absolute path to the repository folder'}, 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)},
41
+ {cap: 'graph', name: 'list_known_repos', description: 'List sibling repositories that already have a built graph in the central weavatrix-graphs folder next to the current repo — ready targets for open_repo.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tListKnownRepos(g, a, ctx)},
42
+ {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)},
43
+ {cap: 'online', name: 'sync_graph', description: 'ONLINE, explicit opt-in, off until configured: push the current graph.json to your weavatrix site or self-hosted endpoint (env WEAVATRIX_SYNC_URL, optional WEAVATRIX_SYNC_TOKEN bearer auth) for a hosted graph view. Payload is the graph only — file paths, symbol names, edges — never file contents.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tSyncGraph(g, a, ctx)},
44
+ ]
45
+ }
46
+
47
+ // Import the tool modules (cache-busted when version > 0), build the catalog, apply the caps filter.
48
+ // capsArg semantics: undefined/null = no per-repo config → ALL tools; a present string (even '') is an
49
+ // explicit selection → expose exactly those groups, so "select nothing" really exposes nothing.
50
+ export async function loadHotApi(version, capsArg) {
51
+ const v = version ? `?v=${version}` : ''
52
+ const [tg, ti, th, ta] = await Promise.all([
53
+ import(new URL(`./tools-graph.mjs${v}`, import.meta.url).href),
54
+ import(new URL(`./tools-impact.mjs${v}`, import.meta.url).href),
55
+ import(new URL(`./tools-health.mjs${v}`, import.meta.url).href),
56
+ import(new URL(`./tools-actions.mjs${v}`, import.meta.url).href),
57
+ ])
58
+ const all = buildTools({tg, ti, th, ta})
59
+ const caps = capsArg == null ? null : new Set(String(capsArg).split(',').map((s) => s.trim()).filter(Boolean))
60
+ const tools = caps ? all.filter((t) => caps.has(t.cap)) : all
61
+ return {
62
+ tools,
63
+ byName: new Map(tools.map((t) => [t.name, t])),
64
+ caps,
65
+ stalenessLine,
66
+ resetStalenessCache,
67
+ }
68
+ }
@@ -0,0 +1,276 @@
1
+ // Shared graph core for the MCP tool modules: graph loading + indexes, node resolution, staleness,
2
+ // the raw-graph cache, and the structural diff helpers. This module holds process-lifetime CACHES
3
+ // (staleness, raw graph), so every tool module imports it STATICALLY — it is the one part of src/mcp
4
+ // that does NOT hot-reload (editing it needs an MCP reconnect, same as the analysis engines).
5
+ import {readFileSync, statSync} from 'node:fs'
6
+ import {join} from 'node:path'
7
+ import {spawnSync} from 'node:child_process'
8
+ import {buildFileImportGraph, findSccs} from '../analysis/dep-rules.js'
9
+
10
+ // ---- graph load + indexes -----------------------------------------------------------------------
11
+ export function loadGraph(path) {
12
+ const raw = JSON.parse(readFileSync(path, 'utf8'))
13
+ const nodes = Array.isArray(raw.nodes) ? raw.nodes : []
14
+ const links = Array.isArray(raw.links) ? raw.links : []
15
+ const byId = new Map()
16
+ const byLabel = new Map()
17
+ for (const n of nodes) {
18
+ if (!n || n.id == null) continue
19
+ byId.set(String(n.id), n)
20
+ const key = String(n.label ?? n.id).toLowerCase()
21
+ if (!byLabel.has(key)) byLabel.set(key, [])
22
+ byLabel.get(key).push(n)
23
+ }
24
+ const out = new Map() // id -> [{id, relation, confidence}]
25
+ const inn = new Map()
26
+ const push = (map, k, v) => {
27
+ if (!map.has(k)) map.set(k, [])
28
+ map.get(k).push(v)
29
+ }
30
+ for (const e of links) {
31
+ if (!e || e.source == null || e.target == null) continue
32
+ const s = String(e.source)
33
+ const t = String(e.target)
34
+ push(out, s, {id: t, relation: e.relation, confidence: e.confidence})
35
+ push(inn, t, {id: s, relation: e.relation, confidence: e.confidence})
36
+ }
37
+ return {nodes, links, byId, byLabel, out, inn}
38
+ }
39
+
40
+ export const isSymbol = (id) => String(id).includes('#')
41
+ export const degreeOf = (g, id) => (g.out.get(id)?.length || 0) + (g.inn.get(id)?.length || 0)
42
+ export const labelOf = (g, id) => {
43
+ const n = g.byId.get(String(id))
44
+ return n ? String(n.label ?? n.id) : String(id)
45
+ }
46
+
47
+ // "connectivity" degree ignores structural `contains` (parent→symbol nesting) so god_nodes surfaces real
48
+ // call/import/reference hubs, not just files that hold many symbols.
49
+ export const connList = (list) => (list || []).filter((e) => e.relation !== 'contains')
50
+
51
+ // Resolve a user-supplied "label" to a node: exact id → exact label → ci label → substring (best degree).
52
+ // Returns {node, matches, alternates} so callers can disclose ambiguity instead of silently picking one.
53
+ export function resolveNodeInfo(g, query) {
54
+ const q = String(query ?? '').trim()
55
+ if (!q) return {node: null, matches: 0, alternates: []}
56
+ if (g.byId.has(q)) return {node: g.byId.get(q), matches: 1, alternates: []}
57
+ const lc = q.toLowerCase()
58
+ const exactLabel = g.byLabel.get(lc)
59
+ if (exactLabel?.length) return pickBest(g, exactLabel)
60
+ // substring over id + label
61
+ const hits = []
62
+ for (const n of g.nodes) {
63
+ const id = String(n.id).toLowerCase()
64
+ const lbl = String(n.label ?? '').toLowerCase()
65
+ if (id.includes(lc) || lbl.includes(lc)) hits.push(n)
66
+ if (hits.length > 500) break
67
+ }
68
+ return hits.length ? pickBest(g, hits) : {node: null, matches: 0, alternates: []}
69
+ }
70
+ export const resolveNode = (g, query) => resolveNodeInfo(g, query).node
71
+ function pickBest(g, list) {
72
+ const node = bestByDegree(g, list)
73
+ const alternates = list
74
+ .filter((n) => n !== node)
75
+ .sort((a, b) => degreeOf(g, b.id) - degreeOf(g, a.id))
76
+ .slice(0, 4)
77
+ .map((n) => `${n.label ?? n.id} [${n.id}]`)
78
+ return {node, matches: list.length, alternates}
79
+ }
80
+ // One-line disclosure when a fuzzy label matched several nodes — silently picking one hides wrong-node errors.
81
+ export function ambiguityNote(query, info) {
82
+ if (!info.node || info.matches <= 1) return null
83
+ const more = info.matches - 1 - info.alternates.length
84
+ return `Note: "${query}" matched ${info.matches} nodes; using the best-connected. Others: ${info.alternates.join(', ')}${more > 0 ? ` (+${more} more)` : ''}`
85
+ }
86
+ const bestByDegree = (g, list) =>
87
+ list.reduce((best, n) => (degreeOf(g, n.id) > degreeOf(g, best.id) ? n : best), list[0])
88
+
89
+ // seeds for traversal/search: rank substring/token matches by degree
90
+ export function findSeeds(g, query, limit = 8) {
91
+ const q = String(query ?? '').trim().toLowerCase()
92
+ if (!q) return []
93
+ const tokens = q.split(/[^a-z0-9_]+/i).filter((t) => t.length > 1)
94
+ const scored = []
95
+ for (const n of g.nodes) {
96
+ const hay = `${String(n.id)} ${String(n.label ?? '')} ${String(n.source_file ?? '')}`.toLowerCase()
97
+ let score = 0
98
+ if (hay.includes(q)) score += 5
99
+ for (const t of tokens) if (hay.includes(t)) score += 1
100
+ if (score > 0) scored.push({n, score: score + Math.min(3, degreeOf(g, n.id) / 20)})
101
+ }
102
+ scored.sort((a, b) => b.score - a.score)
103
+ return scored.slice(0, limit).map((s) => s.n)
104
+ }
105
+
106
+ // undirected adjacency for reachability (query/shortest path)
107
+ export function undirectedNeighbors(g, id) {
108
+ const seen = new Map()
109
+ for (const e of g.out.get(id) || []) seen.set(e.id, e.relation)
110
+ for (const e of g.inn.get(id) || []) if (!seen.has(e.id)) seen.set(e.id, e.relation)
111
+ return seen
112
+ }
113
+
114
+ // ---- staleness ----------------------------------------------------------------------------------
115
+ // The graph is a point-in-time build of graph.json; without a freshness signal an agent cannot tell
116
+ // whether answers reflect the current code. Compare graph.json mtime with the repo's latest commit
117
+ // (cheap `git log -1`), cached for 60s so per-tool warnings don't spawn git on every call.
118
+ let stalenessCache = {key: '', checkedAt: 0, info: null}
119
+ export function graphStaleness(ctx) {
120
+ const now = Date.now()
121
+ if (stalenessCache.info && stalenessCache.key === ctx.graphPath && now - stalenessCache.checkedAt < 60_000) return stalenessCache.info
122
+ const info = {builtAt: null, headAt: null, stale: false, behind: null}
123
+ try { info.builtAt = statSync(ctx.graphPath).mtime } catch { /* no graph file — nothing to report */ }
124
+ if (ctx.repoRoot && info.builtAt) {
125
+ try {
126
+ const head = spawnSync('git', ['-C', ctx.repoRoot, 'log', '-1', '--format=%cI'], {encoding: 'utf8', timeout: 4000})
127
+ const iso = (head.stdout || '').trim()
128
+ if (head.status === 0 && iso) {
129
+ info.headAt = new Date(iso)
130
+ if (info.headAt > info.builtAt) {
131
+ info.stale = true
132
+ const cnt = spawnSync('git', ['-C', ctx.repoRoot, 'rev-list', '--count', `--since=${info.builtAt.toISOString()}`, 'HEAD'], {encoding: 'utf8', timeout: 4000})
133
+ if (cnt.status === 0) info.behind = Number(cnt.stdout.trim()) || null
134
+ }
135
+ }
136
+ } catch { /* git unavailable — degrade to builtAt only */ }
137
+ // Uncommitted work drifts line numbers just as hard as commits do (that is how agents get bitten:
138
+ // they edit, then re-query). Count dirty files actually TOUCHED after the build — a dirty file
139
+ // older than the graph was already part of it.
140
+ try {
141
+ const st = spawnSync('git', ['-C', ctx.repoRoot, 'status', '--porcelain'], {encoding: 'utf8', timeout: 4000})
142
+ if (st.status === 0) {
143
+ let newer = 0
144
+ for (const ln of String(st.stdout || '').split(/\r?\n/).filter(Boolean).slice(0, 200)) {
145
+ const p = ln.slice(3).trim().replace(/^"|"$/g, '')
146
+ try { if (statSync(join(ctx.repoRoot, p)).mtime > info.builtAt) newer++ } catch { newer++ } // deleted counts as drift
147
+ }
148
+ info.dirtyNewer = newer
149
+ if (newer > 0) info.stale = true
150
+ }
151
+ } catch { /* git unavailable */ }
152
+ }
153
+ stalenessCache = {key: ctx.graphPath, checkedAt: now, info}
154
+ return info
155
+ }
156
+ export const resetStalenessCache = () => { stalenessCache = {key: '', checkedAt: 0, info: null} }
157
+ export function stalenessLine(ctx) {
158
+ const s = graphStaleness(ctx)
159
+ if (!s.stale) return null
160
+ const bits = []
161
+ if (s.headAt && s.headAt > s.builtAt) bits.push(`${s.behind != null ? `${s.behind} commit${s.behind === 1 ? '' : 's'}` : 'commits'} newer than the graph`)
162
+ if (s.dirtyNewer) bits.push(`${s.dirtyNewer} uncommitted file(s) edited after the build`)
163
+ return `Warning: graph may be stale — the repo has ${bits.join(' and ')} (built ${s.builtAt.toISOString()}). Line numbers may have drifted; call rebuild_graph.`
164
+ }
165
+
166
+ // Per-file drift check for tools that print exact line numbers: the global warning says the REPO
167
+ // moved; this says THIS file moved — the difference between "be careful" and "these numbers are off".
168
+ export function fileStalenessNote(ctx, sourceFile) {
169
+ if (!ctx?.repoRoot || !sourceFile) return null
170
+ const s = graphStaleness(ctx)
171
+ if (!s.builtAt) return null
172
+ try {
173
+ if (statSync(join(ctx.repoRoot, String(sourceFile))).mtime > s.builtAt) {
174
+ return `Note: ${sourceFile} changed after the graph was built — line numbers above may have drifted (rebuild_graph refreshes them).`
175
+ }
176
+ } catch { /* file gone — the read tools will surface that themselves */ }
177
+ return null
178
+ }
179
+
180
+ // Raw graph.json (with externalImports, file_type, source_end …) for the analysis modules — the MCP's
181
+ // own loadGraph struct strips those fields. Cached by mtime; rebuild_graph changes the mtime → refresh.
182
+ let rawGraphCache = {path: '', mtimeMs: 0, data: null}
183
+ export function rawGraph(ctx) {
184
+ const mtimeMs = statSync(ctx.graphPath).mtimeMs
185
+ if (!rawGraphCache.data || rawGraphCache.path !== ctx.graphPath || rawGraphCache.mtimeMs !== mtimeMs) {
186
+ rawGraphCache = {path: ctx.graphPath, mtimeMs, data: JSON.parse(readFileSync(ctx.graphPath, 'utf8'))}
187
+ }
188
+ return rawGraphCache.data
189
+ }
190
+
191
+ // ---- graph diff ----------------------------------------------------------------------------------
192
+ // One previous state is enough (4-13 MB per repo — cheap): rebuild_graph snapshots the outgoing
193
+ // graph.json as graph.prev.json and reports the structural delta inline, at the exact moment the fix
194
+ // is being verified. graph_diff re-queries the same pair later. Raw node/edge dumps would be noise —
195
+ // the signal is aggregated: module-dependency drift, cycle count changes, newly orphaned symbols.
196
+ export const prevGraphPathFor = (graphPath) => String(graphPath).replace(/\.json$/, '.prev.json')
197
+ export const edgeEndpoint = (v) => String(v && typeof v === 'object' ? v.id : v)
198
+ export const fileOfId = (id) => { const s = String(id); const h = s.indexOf('#'); return h < 0 ? s : s.slice(0, h) }
199
+ const folderOfFile = (file) => {
200
+ const dirs = String(file || '').split(/[\\/]/).filter(Boolean).slice(0, -1)
201
+ return dirs.length ? dirs.slice(0, 2).join('/') : '(root)'
202
+ }
203
+
204
+ // Works on anything with {nodes, links}: the raw graph.json shape and the loadGraph struct alike.
205
+ export function diffGraphs(oldG, newG) {
206
+ const nodeIds = (graph) => new Set((graph.nodes || []).map((n) => String(n.id)))
207
+ const oldNodes = nodeIds(oldG)
208
+ const newNodes = nodeIds(newG)
209
+
210
+ const edgeKey = (l) => `${edgeEndpoint(l.source)}|${l.relation || ''}|${edgeEndpoint(l.target)}`
211
+ const edgeSet = (graph) => new Set((graph.links || []).map(edgeKey))
212
+ const oldEdges = edgeSet(oldG)
213
+ const newEdges = edgeSet(newG)
214
+
215
+ const moduleEdges = (graph) => {
216
+ const set = new Set()
217
+ for (const l of graph.links || []) {
218
+ if (l.relation === 'contains') continue
219
+ const a = folderOfFile(fileOfId(edgeEndpoint(l.source)))
220
+ const b = folderOfFile(fileOfId(edgeEndpoint(l.target)))
221
+ if (a !== b) set.add(`${a} → ${b}`)
222
+ }
223
+ return set
224
+ }
225
+ const oldMods = moduleEdges(oldG)
226
+ const newMods = moduleEdges(newG)
227
+
228
+ const incoming = (graph) => {
229
+ const m = new Map()
230
+ for (const l of graph.links || []) {
231
+ if (l.relation === 'contains') continue
232
+ const t = edgeEndpoint(l.target)
233
+ m.set(t, (m.get(t) || 0) + 1)
234
+ }
235
+ return m
236
+ }
237
+ const oldIn = incoming(oldG)
238
+ const newIn = incoming(newG)
239
+
240
+ const cycles = (graph) => { try { return findSccs(buildFileImportGraph(graph).adj).length } catch { return null } }
241
+
242
+ return {
243
+ nodes: {
244
+ added: [...newNodes].filter((id) => !oldNodes.has(id)),
245
+ removed: [...oldNodes].filter((id) => !newNodes.has(id))
246
+ },
247
+ edges: {
248
+ added: [...newEdges].filter((k) => !oldEdges.has(k)).length,
249
+ removed: [...oldEdges].filter((k) => !newEdges.has(k)).length
250
+ },
251
+ moduleEdges: {
252
+ added: [...newMods].filter((k) => !oldMods.has(k)),
253
+ removed: [...oldMods].filter((k) => !newMods.has(k))
254
+ },
255
+ // survived the rebuild but lost every caller/importer — likely made dead by the change
256
+ orphaned: [...oldIn.keys()].filter((id) => newNodes.has(id) && !newIn.has(id)),
257
+ cycles: {before: cycles(oldG), after: cycles(newG)}
258
+ }
259
+ }
260
+
261
+ export function formatGraphDiff(d) {
262
+ if (!d.nodes.added.length && !d.nodes.removed.length && !d.edges.added && !d.edges.removed) {
263
+ return 'No structural change between the two graph states.'
264
+ }
265
+ const cap = (list, n) => list.slice(0, n).map((x) => ` ${x}`).concat(list.length > n ? [` … +${list.length - n} more`] : [])
266
+ const lines = [`Structural delta: nodes +${d.nodes.added.length}/−${d.nodes.removed.length}, edges +${d.edges.added}/−${d.edges.removed}.`]
267
+ if (d.cycles.before != null && d.cycles.after != null && d.cycles.before !== d.cycles.after) {
268
+ 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)'}`)
269
+ }
270
+ if (d.moduleEdges.added.length) lines.push('NEW module dependencies (architecture drift — review):', ...cap(d.moduleEdges.added, 12))
271
+ if (d.moduleEdges.removed.length) lines.push('Removed module dependencies (decoupling confirmed):', ...cap(d.moduleEdges.removed, 12))
272
+ if (d.orphaned.length) lines.push('Symbols that lost their last caller/importer (now dead?):', ...cap(d.orphaned, 10))
273
+ if (d.nodes.added.length) lines.push('Added nodes:', ...cap(d.nodes.added, 12))
274
+ if (d.nodes.removed.length) lines.push('Removed nodes:', ...cap(d.nodes.removed, 12))
275
+ return lines.join('\n')
276
+ }