weavatrix 0.1.2 → 0.1.3

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