weavatrix 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -12
- package/package.json +1 -1
- package/skill/SKILL.md +20 -13
- package/src/analysis/dead-check.js +8 -3
- package/src/analysis/dep-check-ecosystems.js +157 -0
- package/src/analysis/dep-check.js +32 -144
- package/src/analysis/dep-rules.js +36 -23
- package/src/analysis/endpoints-rust.js +124 -0
- package/src/analysis/endpoints.js +11 -4
- package/src/analysis/graph-analysis.aggregate.js +27 -26
- package/src/analysis/graph-analysis.edges.js +21 -0
- package/src/analysis/graph-analysis.js +1 -1
- package/src/analysis/graph-analysis.summaries.js +4 -1
- package/src/analysis/internal-audit.collect.js +40 -0
- package/src/analysis/internal-audit.run.js +9 -4
- package/src/graph/builder/lang-java.js +177 -14
- package/src/graph/builder/lang-rust.js +129 -6
- package/src/graph/community.js +17 -0
- package/src/graph/internal-builder.build.js +32 -8
- package/src/graph/internal-builder.java.js +33 -0
- package/src/graph/internal-builder.resolvers.js +109 -2
- package/src/graph/relations.js +4 -0
- package/src/mcp/catalog.mjs +4 -4
- package/src/mcp/graph-context.mjs +7 -166
- package/src/mcp/graph-diff.mjs +163 -0
- package/src/mcp/sync-payload.mjs +30 -8
- package/src/mcp/tools-actions.mjs +3 -3
- package/src/mcp/tools-graph-hubs.mjs +58 -0
- package/src/mcp/tools-graph.mjs +11 -54
- package/src/mcp/tools-health.mjs +18 -6
- package/src/mcp/tools-impact.mjs +11 -6
- package/src/mcp-server.mjs +2 -2
package/src/mcp/catalog.mjs
CHANGED
|
@@ -23,7 +23,7 @@ function buildTools({tg, ti, th, ta}) {
|
|
|
23
23
|
{cap: 'graph', name: 'get_node', description: 'Get full details for a specific node by label or ID.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID to look up'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNode(g, a, ctx)},
|
|
24
24
|
{cap: 'graph', name: 'get_neighbors', description: 'Get all direct neighbors of a node with edge details (1 hop, call sites deduped). For transitive impact use get_dependents; for the impact of your current branch changes use change_impact.', inputSchema: {type: 'object', properties: {label: {type: 'string'}, relation_filter: {type: 'string', description: 'Optional: filter by relation type'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNeighbors(g, a, ctx)},
|
|
25
25
|
{cap: 'graph', name: 'query_graph', description: 'Explore the graph around a concept (BFS/DFS). Returns a focused, ranked subgraph — the closest, most-connected nodes near the matched seeds, with edges among them — not the full flood; states how many nodes were reached vs shown.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Natural language question or keyword search'}, mode: {type: 'string', enum: ['bfs', 'dfs'], default: 'bfs'}, depth: {type: 'integer', default: 3}, context_filter: {type: 'array', items: {type: 'string'}}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, required: ['question']}, run: (g, a) => tg.tQueryGraph(g, a)},
|
|
26
|
-
{cap: 'graph', name: 'god_nodes', description: 'Rank connectivity hubs by unique call/import/reference neighbors,
|
|
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
|
|
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
|
|
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 never read for sync. Rebuild graphs created before 0.1.
|
|
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) {
|
|
@@ -37,6 +37,7 @@ export function loadGraph(path) {
|
|
|
37
37
|
relation: e.relation,
|
|
38
38
|
confidence: e.confidence,
|
|
39
39
|
...(e.typeOnly === true ? {typeOnly: true} : {}),
|
|
40
|
+
...(e.compileOnly === true ? {compileOnly: true} : {}),
|
|
40
41
|
...(Number.isInteger(e.line) ? {line: e.line} : {}),
|
|
41
42
|
...(typeof e.specifier === 'string' ? {specifier: e.specifier} : {}),
|
|
42
43
|
}
|
|
@@ -51,15 +52,15 @@ export function loadGraph(path) {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
export const isSymbol = (id) => String(id).includes('#')
|
|
54
|
-
export const degreeOf = (g, id) => (g.out.get(id)?.length || 0) + (g.inn.get(id)?.length || 0)
|
|
55
55
|
export const labelOf = (g, id) => {
|
|
56
56
|
const n = g.byId.get(String(id))
|
|
57
57
|
return n ? String(n.label ?? n.id) : String(id)
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
export const connList = (list) => (list || []).filter((e) => e.relation
|
|
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
|
|
63
64
|
export const uniqueConnCount = (list) => new Set(connList(list).map((e) => String(e.id))).size
|
|
64
65
|
|
|
65
66
|
// Resolve a user-supplied "label" to a node: exact id → exact label → ci label → substring (best degree).
|
|
@@ -203,164 +204,4 @@ export function rawGraph(ctx) {
|
|
|
203
204
|
return rawGraphCache.data
|
|
204
205
|
}
|
|
205
206
|
|
|
206
|
-
|
|
207
|
-
// One previous state is enough (4-13 MB per repo — cheap): rebuild_graph snapshots the outgoing
|
|
208
|
-
// graph.json as graph.prev.json and reports the structural delta inline, at the exact moment the fix
|
|
209
|
-
// is being verified. graph_diff re-queries the same pair later. Raw node/edge dumps would be noise —
|
|
210
|
-
// the signal is aggregated: module-dependency drift, cycle count changes, newly orphaned symbols.
|
|
211
|
-
export const prevGraphPathFor = (graphPath) => String(graphPath).replace(/\.json$/, '.prev.json')
|
|
212
|
-
export const edgeEndpoint = (v) => String(v && typeof v === 'object' ? v.id : v)
|
|
213
|
-
export const fileOfId = (id) => { const s = String(id); const h = s.indexOf('#'); return h < 0 ? s : s.slice(0, h) }
|
|
214
|
-
const folderOfFile = (file) => {
|
|
215
|
-
const dirs = String(file || '').split(/[\\/]/).filter(Boolean).slice(0, -1)
|
|
216
|
-
return dirs.length ? dirs.slice(0, 2).join('/') : '(root)'
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Works on anything with {nodes, links}: the raw graph.json shape and the loadGraph struct alike.
|
|
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
|
|
224
|
-
const nodeIds = (graph) => new Set((graph.nodes || []).map((n) => String(n.id)))
|
|
225
|
-
const oldNodes = nodeIds(oldG)
|
|
226
|
-
const newNodes = nodeIds(newG)
|
|
227
|
-
|
|
228
|
-
const edgeKey = (l) => `${edgeEndpoint(l.source)}|${l.relation || ''}|${schemaMigration ? 'untyped' : l.typeOnly === true ? 'type' : 'runtime'}|${edgeEndpoint(l.target)}`
|
|
229
|
-
const edgeSet = (graph) => new Set((graph.links || []).map(edgeKey))
|
|
230
|
-
const oldEdges = edgeSet(oldG)
|
|
231
|
-
const newEdges = edgeSet(newG)
|
|
232
|
-
|
|
233
|
-
const moduleEdges = (graph, typeOnly) => {
|
|
234
|
-
const set = new Set()
|
|
235
|
-
for (const l of graph.links || []) {
|
|
236
|
-
if (l.relation === 'contains') continue
|
|
237
|
-
if ((l.typeOnly === true) !== typeOnly) continue
|
|
238
|
-
const a = folderOfFile(fileOfId(edgeEndpoint(l.source)))
|
|
239
|
-
const b = folderOfFile(fileOfId(edgeEndpoint(l.target)))
|
|
240
|
-
if (a !== b) set.add(`${a} → ${b}`)
|
|
241
|
-
}
|
|
242
|
-
return set
|
|
243
|
-
}
|
|
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)
|
|
258
|
-
|
|
259
|
-
const incoming = (graph) => {
|
|
260
|
-
const m = new Map()
|
|
261
|
-
for (const l of graph.links || []) {
|
|
262
|
-
if (l.relation === 'contains') continue
|
|
263
|
-
const t = edgeEndpoint(l.target)
|
|
264
|
-
m.set(t, (m.get(t) || 0) + 1)
|
|
265
|
-
}
|
|
266
|
-
return m
|
|
267
|
-
}
|
|
268
|
-
const oldIn = incoming(oldG)
|
|
269
|
-
const newIn = incoming(newG)
|
|
270
|
-
|
|
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
|
-
}
|
|
309
|
-
|
|
310
|
-
return {
|
|
311
|
-
schemaMigration: schemaMigration ? {from: oldEdgeTypesV, to: newEdgeTypesV} : null,
|
|
312
|
-
nodes: {
|
|
313
|
-
added: [...newNodes].filter((id) => !oldNodes.has(id)),
|
|
314
|
-
removed: [...oldNodes].filter((id) => !newNodes.has(id))
|
|
315
|
-
},
|
|
316
|
-
edges: {
|
|
317
|
-
added: [...newEdges].filter((k) => !oldEdges.has(k)).length,
|
|
318
|
-
removed: [...oldEdges].filter((k) => !newEdges.has(k)).length
|
|
319
|
-
},
|
|
320
|
-
moduleEdges: {
|
|
321
|
-
added: [...newMods].filter((k) => !oldMods.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)),
|
|
325
|
-
},
|
|
326
|
-
// survived the rebuild but lost every caller/importer — likely made dead by the change
|
|
327
|
-
orphaned: [...oldIn.keys()].filter((id) => newNodes.has(id) && !newIn.has(id)),
|
|
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
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
export function formatGraphDiff(d) {
|
|
336
|
-
if (!d.nodes.added.length && !d.nodes.removed.length && !d.edges.added && !d.edges.removed) {
|
|
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.'
|
|
340
|
-
}
|
|
341
|
-
const cap = (list, n) => list.slice(0, n).map((x) => ` ${x}`).concat(list.length > n ? [` … +${list.length - n} more`] : [])
|
|
342
|
-
const lines = [`Structural delta: nodes +${d.nodes.added.length}/−${d.nodes.removed.length}, edges +${d.edges.added}/−${d.edges.removed}.`]
|
|
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).`)
|
|
357
|
-
}
|
|
358
|
-
if (d.moduleEdges.added.length) lines.push('NEW module dependencies (architecture drift — review):', ...cap(d.moduleEdges.added, 12))
|
|
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))
|
|
362
|
-
if (d.orphaned.length) lines.push('Symbols that lost their last caller/importer (now dead?):', ...cap(d.orphaned, 10))
|
|
363
|
-
if (d.nodes.added.length) lines.push('Added nodes:', ...cap(d.nodes.added, 12))
|
|
364
|
-
if (d.nodes.removed.length) lines.push('Removed nodes:', ...cap(d.nodes.removed, 12))
|
|
365
|
-
return lines.join('\n')
|
|
366
|
-
}
|
|
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
|
+
}
|
package/src/mcp/sync-payload.mjs
CHANGED
|
@@ -9,6 +9,27 @@ function metadataString(value, max = 4096) {
|
|
|
9
9
|
: undefined;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// graph.json is derived data and may be edited independently of the repository. Never trust a path
|
|
13
|
+
// merely because it occupies an allowlisted field: sync only accepts canonical-looking repo-relative
|
|
14
|
+
// paths, on every host OS. Graph IDs append `#symbol@line`, so validate their file portion separately
|
|
15
|
+
// while preserving the complete ID on the wire.
|
|
16
|
+
function repoRelativePathString(value, max = 4096) {
|
|
17
|
+
const path = metadataString(value, max);
|
|
18
|
+
if (!path) return undefined;
|
|
19
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|[\\/])/i.test(path)) return undefined; // URI, drive path, POSIX or UNC absolute
|
|
20
|
+
const segments = path.split(/[\\/]/);
|
|
21
|
+
if (segments.some((segment) => segment === '.' || segment === '..')) return undefined;
|
|
22
|
+
return path;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function graphIdString(value) {
|
|
26
|
+
const id = metadataString(value);
|
|
27
|
+
if (!id) return undefined;
|
|
28
|
+
const hash = id.indexOf('#');
|
|
29
|
+
const file = hash < 0 ? id : id.slice(0, hash);
|
|
30
|
+
return repoRelativePathString(file) ? id : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
12
33
|
function finiteNumber(value) {
|
|
13
34
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
14
35
|
}
|
|
@@ -37,12 +58,12 @@ function sanitizeComplexity(value) {
|
|
|
37
58
|
|
|
38
59
|
function sanitizeNode(value) {
|
|
39
60
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
40
|
-
const id =
|
|
61
|
+
const id = graphIdString(value.id);
|
|
41
62
|
if (!id) return null;
|
|
42
63
|
const out = {id};
|
|
43
64
|
setIf(out, 'label', metadataString(value.label, 1024));
|
|
44
65
|
setIf(out, 'file_type', metadataString(value.file_type, 32));
|
|
45
|
-
setIf(out, 'source_file',
|
|
66
|
+
setIf(out, 'source_file', repoRelativePathString(value.source_file));
|
|
46
67
|
const sourceLocation = metadataString(value.source_location, 32);
|
|
47
68
|
const sourceEnd = metadataString(value.source_end, 32);
|
|
48
69
|
if (sourceLocation && /^L\d+$/.test(sourceLocation)) out.source_location = sourceLocation;
|
|
@@ -57,13 +78,14 @@ function sanitizeNode(value) {
|
|
|
57
78
|
|
|
58
79
|
function sanitizeLink(value) {
|
|
59
80
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
60
|
-
const source =
|
|
61
|
-
const target =
|
|
81
|
+
const source = graphIdString(value.source);
|
|
82
|
+
const target = graphIdString(value.target);
|
|
62
83
|
if (!source || !target) return null;
|
|
63
84
|
const out = {source, target};
|
|
64
85
|
setIf(out, 'relation', metadataString(value.relation, 32));
|
|
65
86
|
setIf(out, 'confidence', metadataString(value.confidence, 32));
|
|
66
87
|
if (value.typeOnly === true) out.typeOnly = true;
|
|
88
|
+
if (value.compileOnly === true) out.compileOnly = true;
|
|
67
89
|
const line = finiteNumber(value.line);
|
|
68
90
|
if (line !== undefined && Number.isInteger(line) && line >= 0) out.line = line;
|
|
69
91
|
setIf(out, 'specifier', metadataString(value.specifier));
|
|
@@ -72,7 +94,7 @@ function sanitizeLink(value) {
|
|
|
72
94
|
|
|
73
95
|
function sanitizeExternalImport(value) {
|
|
74
96
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
75
|
-
const file =
|
|
97
|
+
const file = repoRelativePathString(value.file);
|
|
76
98
|
if (!file) return null;
|
|
77
99
|
const out = {file};
|
|
78
100
|
for (const key of ['spec', 'target']) setIf(out, key, metadataString(value[key]));
|
|
@@ -89,8 +111,8 @@ export function createSyncPayload(raw) {
|
|
|
89
111
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || raw.repoBoundaryV !== 1) {
|
|
90
112
|
throw new Error('graph predates repository-boundary hardening');
|
|
91
113
|
}
|
|
92
|
-
if (!Number.isInteger(raw.edgeTypesV) || raw.edgeTypesV <
|
|
93
|
-
throw new Error('graph predates
|
|
114
|
+
if (!Number.isInteger(raw.edgeTypesV) || raw.edgeTypesV < 2) {
|
|
115
|
+
throw new Error('graph predates compile-only edge metadata');
|
|
94
116
|
}
|
|
95
117
|
const nodes = Array.isArray(raw.nodes) ? raw.nodes.map(sanitizeNode).filter(Boolean) : [];
|
|
96
118
|
const links = Array.isArray(raw.links) ? raw.links.map(sanitizeLink).filter(Boolean) : [];
|
|
@@ -100,7 +122,7 @@ export function createSyncPayload(raw) {
|
|
|
100
122
|
return {
|
|
101
123
|
syncPayloadV: 2,
|
|
102
124
|
repoBoundaryV: 1,
|
|
103
|
-
edgeTypesV:
|
|
125
|
+
edgeTypesV: 2,
|
|
104
126
|
extImportsV: Number.isInteger(raw.extImportsV) ? raw.extImportsV : 0,
|
|
105
127
|
complexityV: Number.isInteger(raw.complexityV) ? raw.complexityV : 0,
|
|
106
128
|
nodes,
|
|
@@ -45,7 +45,7 @@ export async function tOpenRepo(g, args, ctx) {
|
|
|
45
45
|
if (existsSync(graphPath)) {
|
|
46
46
|
try {
|
|
47
47
|
const saved = JSON.parse(readFileSync(graphPath, 'utf8'))
|
|
48
|
-
upgrade = !Number.isInteger(saved.edgeTypesV) || saved.edgeTypesV <
|
|
48
|
+
upgrade = !Number.isInteger(saved.edgeTypesV) || saved.edgeTypesV < 2
|
|
49
49
|
} catch {
|
|
50
50
|
upgrade = true
|
|
51
51
|
}
|
|
@@ -53,7 +53,7 @@ export async function tOpenRepo(g, args, ctx) {
|
|
|
53
53
|
if (!existsSync(graphPath) || upgrade) {
|
|
54
54
|
if (args.build === false) {
|
|
55
55
|
return upgrade
|
|
56
|
-
? `The existing graph for ${repoPath} predates
|
|
56
|
+
? `The existing graph for ${repoPath} predates compile-only edge metadata (edge schema v2). Re-call without build:false to upgrade it before switching.`
|
|
57
57
|
: `No graph yet for ${repoPath} (expected at ${graphPath}). Re-call without build:false to build one — large repos can take minutes.`
|
|
58
58
|
}
|
|
59
59
|
const mode = ['no-tests', 'tests-only', 'full'].includes(args.mode) ? args.mode : 'full'
|
|
@@ -71,7 +71,7 @@ export async function tOpenRepo(g, args, ctx) {
|
|
|
71
71
|
ctx.reload()
|
|
72
72
|
return `Failed to load ${graphPath} — still targeting the previous repo (${prev.repoRoot || 'none'}).`
|
|
73
73
|
}
|
|
74
|
-
const buildNote = built ? (upgrade ? ' (graph upgraded to
|
|
74
|
+
const buildNote = built ? (upgrade ? ' (graph upgraded to edge metadata v2)' : ' (graph built fresh)') : ''
|
|
75
75
|
return `Opened ${repoPath}${buildNote}: ${loaded.nodes.length} nodes / ${loaded.links.length} edges. All tools now target this repo.`
|
|
76
76
|
}
|
|
77
77
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Connectivity-hub reporting, split from the general graph query tools.
|
|
2
|
+
import {connList} from './graph-context.mjs'
|
|
3
|
+
|
|
4
|
+
const isCompileTimeEdge = (edge) => edge?.typeOnly === true || edge?.compileOnly === true
|
|
5
|
+
|
|
6
|
+
export function tGodNodes(g, {top_n = 10} = {}) {
|
|
7
|
+
const n = Math.max(1, Math.min(100, Number(top_n) || 10))
|
|
8
|
+
const scored = g.nodes
|
|
9
|
+
.map((node) => {
|
|
10
|
+
const rawOuts = g.out.get(String(node.id)) || []
|
|
11
|
+
const rawIns = g.inn.get(String(node.id)) || []
|
|
12
|
+
const outs = connList(rawOuts)
|
|
13
|
+
const ins = connList(rawIns)
|
|
14
|
+
const ownedMethods = new Set(rawOuts.filter((e) => e.relation === 'method').map((e) => String(e.id)))
|
|
15
|
+
const outIds = new Set(outs.map((e) => String(e.id)))
|
|
16
|
+
const inIds = new Set(ins.map((e) => String(e.id)))
|
|
17
|
+
const allIds = new Set([...outIds, ...inIds])
|
|
18
|
+
const runtimeIds = new Set([...outs, ...ins].filter((e) => !isCompileTimeEdge(e)).map((e) => String(e.id)))
|
|
19
|
+
const compileOnlyIds = new Set([...outs, ...ins].filter(isCompileTimeEdge).map((e) => String(e.id)))
|
|
20
|
+
for (const id of runtimeIds) compileOnlyIds.delete(id)
|
|
21
|
+
const occurrences = outs.length + ins.length
|
|
22
|
+
return {
|
|
23
|
+
node,
|
|
24
|
+
deg: allIds.size,
|
|
25
|
+
runtime: runtimeIds.size,
|
|
26
|
+
compileOnly: compileOnlyIds.size,
|
|
27
|
+
out: outIds.size,
|
|
28
|
+
in: inIds.size,
|
|
29
|
+
occurrences,
|
|
30
|
+
ownedMethods: ownedMethods.size,
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
.filter((entry) => entry.deg > 0 || entry.ownedMethods > 0)
|
|
34
|
+
.sort((a, b) => b.runtime - a.runtime || b.deg - a.deg || b.ownedMethods - a.ownedMethods || b.occurrences - a.occurrences)
|
|
35
|
+
const ranked = scored.slice(0, n)
|
|
36
|
+
const rankedIds = new Set(ranked.map((entry) => String(entry.node.id)))
|
|
37
|
+
// Unique neighbors measure coupling, but a large component repeatedly calling the same helper (for
|
|
38
|
+
// example i18n) can still be a valuable complexity hotspot. Preserve that second lens explicitly
|
|
39
|
+
// instead of letting repeated sites inflate the coupling rank.
|
|
40
|
+
const occurrenceHotspots = scored
|
|
41
|
+
.filter((entry) => !rankedIds.has(String(entry.node.id)))
|
|
42
|
+
.filter((entry) => entry.occurrences >= 20 && entry.occurrences - entry.deg >= 10)
|
|
43
|
+
.sort((a, b) => (b.occurrences - b.deg) - (a.occurrences - a.deg) || b.occurrences - a.occurrences)
|
|
44
|
+
.slice(0, Math.min(5, n))
|
|
45
|
+
return [
|
|
46
|
+
`Top ${ranked.length} connectivity hubs (ranked by unique runtime call/import/reference neighbors; structural ownership shown separately):`,
|
|
47
|
+
...ranked.map(
|
|
48
|
+
(r, i) =>
|
|
49
|
+
`${String(i + 1).padStart(2)}. ${r.node.label ?? r.node.id} (${r.deg} unique: ${r.runtime} runtime, ${r.compileOnly} compile-only; out ${r.out}, in ${r.in}; ${r.occurrences} edge occurrence${r.occurrences === 1 ? '' : 's'}${r.ownedMethods ? `; owns ${r.ownedMethods} method${r.ownedMethods === 1 ? '' : 's'}` : ''}) [${r.node.id}]`
|
|
50
|
+
),
|
|
51
|
+
`Repeated call sites affect the occurrence count, not the connectivity rank; compile-only neighbors are secondary to runtime coupling.`,
|
|
52
|
+
occurrenceHotspots.length ? `` : null,
|
|
53
|
+
occurrenceHotspots.length ? `High occurrence hotspots outside the connectivity rank (repeated call/reference sites; complexity signal, not broader coupling):` : null,
|
|
54
|
+
...occurrenceHotspots.map((r) =>
|
|
55
|
+
` ${r.node.label ?? r.node.id} (${r.occurrences} occurrences across ${r.deg} unique neighbors; ${r.occurrences - r.deg} repeats) [${r.node.id}]`
|
|
56
|
+
),
|
|
57
|
+
].filter((line) => line != null).join('\n')
|
|
58
|
+
}
|