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
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
// built from EXTRACTED import edges → high confidence.
|
|
8
8
|
import { makeFinding } from "./findings.js";
|
|
9
9
|
import { ENTRY_FILE } from "./dead-check.js";
|
|
10
|
-
|
|
11
10
|
const TEST_FILE_RE = /(^|[/])(test|tests|__tests__|spec|e2e|__mocks__)([/]|$)|[._-](test|spec)\.[a-z0-9]+$/i;
|
|
12
11
|
// config/data/docs: never "orphans" — nothing imports them by design
|
|
13
12
|
const NON_CODE_RE = /\.(json|ya?ml|sh|ps1|md|txt|html?|css|scss|less)$|(^|[/])(dockerfile|containerfile)/i;
|
|
@@ -15,18 +14,19 @@ const NON_CODE_RE = /\.(json|ya?ml|sh|ps1|md|txt|html?|css|scss|less)$|(^|[/])(d
|
|
|
15
14
|
const ep = (v) => String(v && typeof v === "object" ? v.id : v);
|
|
16
15
|
const fileOf = (v) => { const s = ep(v); const h = s.indexOf("#"); return h < 0 ? s : s.slice(0, h); };
|
|
17
16
|
const dirOf = (f) => (f.includes("/") ? f.slice(0, f.lastIndexOf("/")) : "");
|
|
18
|
-
|
|
19
17
|
// File-level import adjacency. Go same-directory edges are excluded: a Go package IS the directory, the
|
|
20
18
|
// compiler forbids real cross-package cycles, and our suffix-fallback Go resolution could fake them.
|
|
21
|
-
export function buildFileImportGraph(graph, { includeTypeOnly = false } = {}) {
|
|
19
|
+
export function buildFileImportGraph(graph, { includeTypeOnly = false, includeCompileOnly = false } = {}) {
|
|
22
20
|
const fileIds = new Set();
|
|
23
21
|
for (const n of graph.nodes || []) if (!String(n.id).includes("#")) fileIds.add(String(n.id));
|
|
24
22
|
const runtimeAdj = new Map(); // runtime/value imports only
|
|
25
|
-
const allAdj = new Map(); // runtime +
|
|
23
|
+
const allAdj = new Map(); // runtime + compile-time-only, used to describe architectural coupling
|
|
26
24
|
const edges = [];
|
|
27
25
|
const allEdges = [];
|
|
28
26
|
const typeOnlyEdges = [];
|
|
29
|
-
const
|
|
27
|
+
const compileOnlyEdges = [];
|
|
28
|
+
const compileTimeEdges = [];
|
|
29
|
+
const runtimeSeen = new Set(), allSeen = new Set(), typeSeen = new Set(), compileSeen = new Set(), compileTimeSeen = new Set();
|
|
30
30
|
const add = (map, a, b) => {
|
|
31
31
|
let set = map.get(a);
|
|
32
32
|
if (!set) map.set(a, (set = new Set()));
|
|
@@ -39,22 +39,28 @@ export function buildFileImportGraph(graph, { includeTypeOnly = false } = {}) {
|
|
|
39
39
|
if (a.endsWith(".go") && b.endsWith(".go") && dirOf(a) === dirOf(b)) continue;
|
|
40
40
|
const key = `${a}\0${b}`;
|
|
41
41
|
if (!allSeen.has(key)) { allSeen.add(key); add(allAdj, a, b); allEdges.push([a, b]); }
|
|
42
|
-
if (l.typeOnly === true) {
|
|
43
|
-
if (!typeSeen.has(key)) { typeSeen.add(key); typeOnlyEdges.push([a, b]); }
|
|
42
|
+
if (l.typeOnly === true || l.compileOnly === true) {
|
|
43
|
+
if (l.typeOnly === true && !typeSeen.has(key)) { typeSeen.add(key); typeOnlyEdges.push([a, b]); }
|
|
44
|
+
if (l.compileOnly === true && !compileSeen.has(key)) { compileSeen.add(key); compileOnlyEdges.push([a, b]); }
|
|
45
|
+
if (!compileTimeSeen.has(key)) { compileTimeSeen.add(key); compileTimeEdges.push([a, b]); }
|
|
44
46
|
continue;
|
|
45
47
|
}
|
|
46
48
|
if (!runtimeSeen.has(key)) { runtimeSeen.add(key); add(runtimeAdj, a, b); edges.push([a, b]); }
|
|
47
49
|
}
|
|
48
50
|
const pureTypeOnlyEdges = typeOnlyEdges.filter(([a, b]) => !runtimeSeen.has(`${a}\0${b}`));
|
|
51
|
+
const pureCompileOnlyEdges = compileOnlyEdges.filter(([a, b]) => !runtimeSeen.has(`${a}\0${b}`));
|
|
52
|
+
const pureCompileTimeEdges = compileTimeEdges.filter(([a, b]) => !runtimeSeen.has(`${a}\0${b}`));
|
|
49
53
|
return {
|
|
50
54
|
fileIds,
|
|
51
|
-
adj: includeTypeOnly ? allAdj : runtimeAdj,
|
|
52
|
-
edges: includeTypeOnly ? allEdges : edges,
|
|
55
|
+
adj: includeTypeOnly || includeCompileOnly ? allAdj : runtimeAdj,
|
|
56
|
+
edges: includeTypeOnly || includeCompileOnly ? allEdges : edges,
|
|
53
57
|
runtimeAdj,
|
|
54
58
|
runtimeEdges: edges,
|
|
55
59
|
allAdj,
|
|
56
60
|
allEdges,
|
|
57
61
|
typeOnlyEdges: pureTypeOnlyEdges,
|
|
62
|
+
compileOnlyEdges: pureCompileOnlyEdges,
|
|
63
|
+
compileTimeEdges: pureCompileTimeEdges,
|
|
58
64
|
};
|
|
59
65
|
}
|
|
60
66
|
|
|
@@ -167,12 +173,10 @@ export function checkBoundaries(edges, rules = {}) {
|
|
|
167
173
|
|
|
168
174
|
const MAX_CYCLE_FINDINGS = 50;
|
|
169
175
|
const MAX_BOUNDARY_FINDINGS = 100;
|
|
170
|
-
|
|
171
176
|
// Assemble everything into unified Findings. rules comes from the repo's .weavatrix-deps.json (optional).
|
|
172
177
|
export function computeStructureFindings(graph, { rules = {}, entrySet = new Set(), externalImportFiles = new Set() } = {}) {
|
|
173
|
-
const { adj, edges, allAdj, allEdges, typeOnlyEdges } = buildFileImportGraph(graph);
|
|
178
|
+
const { adj, edges, allAdj, allEdges, typeOnlyEdges, compileOnlyEdges, compileTimeEdges } = buildFileImportGraph(graph);
|
|
174
179
|
const findings = [];
|
|
175
|
-
|
|
176
180
|
const sccs = findSccs(adj).sort((a, b) => b.length - a.length);
|
|
177
181
|
for (const scc of sccs.slice(0, MAX_CYCLE_FINDINGS)) {
|
|
178
182
|
const cycle = representativeCycle(adj, scc);
|
|
@@ -191,33 +195,37 @@ export function computeStructureFindings(graph, { rules = {}, entrySet = new Set
|
|
|
191
195
|
}));
|
|
192
196
|
}
|
|
193
197
|
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
198
|
+
// TypeScript `import type` and Rust module/use edges are compile-time coupling. They can reveal real
|
|
199
|
+
// architecture, but cannot create an initialization-order/runtime cycle. Report SCCs that require
|
|
200
|
+
// either classification separately so agents do not churn working code for a phantom runtime hazard.
|
|
197
201
|
const runtimeKeys = new Set(sccs.map((s) => [...s].sort().join("\0")));
|
|
198
202
|
const allSccs = findSccs(allAdj).sort((a, b) => b.length - a.length);
|
|
199
|
-
const
|
|
203
|
+
const compileTimeCouplings = allSccs.filter((s) => !runtimeKeys.has([...s].sort().join("\0")));
|
|
200
204
|
const edgeCountIn = (scc, list) => {
|
|
201
205
|
const inside = new Set(scc);
|
|
202
206
|
return list.reduce((n, [a, b]) => n + (inside.has(a) && inside.has(b) ? 1 : 0), 0);
|
|
203
207
|
};
|
|
204
|
-
for (const scc of
|
|
208
|
+
for (const scc of compileTimeCouplings.slice(0, MAX_CYCLE_FINDINGS)) {
|
|
205
209
|
const cycle = representativeCycle(allAdj, scc);
|
|
206
210
|
const runtimeInside = edgeCountIn(scc, edges);
|
|
207
211
|
const typeInside = edgeCountIn(scc, typeOnlyEdges);
|
|
212
|
+
const compileInside = edgeCountIn(scc, compileOnlyEdges);
|
|
208
213
|
const containsRuntimeCycle = sccs.some((runtime) => runtime.every((f) => scc.includes(f)));
|
|
214
|
+
const typeSpecific = compileInside === 0;
|
|
209
215
|
findings.push(makeFinding({
|
|
210
216
|
category: "structure",
|
|
211
|
-
rule: "type-coupling",
|
|
217
|
+
rule: typeSpecific ? "type-coupling" : "compile-time-coupling",
|
|
212
218
|
severity: "info",
|
|
213
219
|
confidence: "high",
|
|
214
|
-
title: `${containsRuntimeCycle
|
|
215
|
-
|
|
220
|
+
title: `${containsRuntimeCycle
|
|
221
|
+
? (typeSpecific ? "Type imports expand dependency coupling" : "Compile-time edges expand dependency coupling")
|
|
222
|
+
: (typeSpecific ? "Type-induced dependency cycle (no runtime cycle)" : "Compile-time dependency cycle (no runtime cycle)")}: ${scc.length} files`,
|
|
223
|
+
detail: `${cycle.join(" → ")}. This strongly-connected group needs compile-time-only edges to close; it contains ${runtimeInside} runtime edge(s), ${typeInside} type-only edge(s), and ${compileInside} compile-only edge(s)${containsRuntimeCycle ? ", with a smaller runtime cycle reported separately" : ", while its runtime import graph is acyclic"}. Treat this as design coupling, not an initialization-order failure.`,
|
|
216
224
|
file: cycle[0],
|
|
217
225
|
graphNodeId: cycle[0],
|
|
218
226
|
evidence: cycle.map((f) => ({ file: f, line: 0, snippet: "" })),
|
|
219
227
|
source: "internal",
|
|
220
|
-
fixHint: "review the
|
|
228
|
+
fixHint: "review the compile-time ownership only if the coupling impedes changes; no runtime-cycle fix is required",
|
|
221
229
|
}));
|
|
222
230
|
}
|
|
223
231
|
if (sccs.length > MAX_CYCLE_FINDINGS) {
|
|
@@ -275,11 +283,16 @@ export function computeStructureFindings(graph, { rules = {}, entrySet = new Set
|
|
|
275
283
|
importEdges: allEdges.length,
|
|
276
284
|
runtimeImportEdges: edges.length,
|
|
277
285
|
typeOnlyImportEdges: typeOnlyEdges.length,
|
|
286
|
+
compileOnlyImportEdges: compileOnlyEdges.length,
|
|
287
|
+
compileTimeImportEdges: compileTimeEdges.length,
|
|
278
288
|
cycles: sccs.length,
|
|
279
289
|
runtimeCycles: sccs.length,
|
|
280
290
|
largestCycle: sccs[0]?.length || 0,
|
|
281
|
-
|
|
282
|
-
|
|
291
|
+
// Backward-compatible aliases remain for edgeTypesV 1 consumers.
|
|
292
|
+
typeCouplings: compileTimeCouplings.length,
|
|
293
|
+
largestTypeCoupling: compileTimeCouplings[0]?.length || 0,
|
|
294
|
+
compileTimeCouplings: compileTimeCouplings.length,
|
|
295
|
+
largestCompileTimeCoupling: compileTimeCouplings[0]?.length || 0,
|
|
283
296
|
orphans: findings.filter((f) => f.rule === "orphan-file").length,
|
|
284
297
|
boundaryViolations: violations.length,
|
|
285
298
|
},
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const RUST_ROUTE_METHOD = "get|post|put|patch|delete|head|options|trace|connect|any";
|
|
2
|
+
|
|
3
|
+
// Find a call's closing parenthesis without mistaking parens inside strings for syntax. This is deliberately
|
|
4
|
+
// small (not a Rust parser), but keeps route-handler expressions bounded instead of using a cross-statement
|
|
5
|
+
// regex that could turn an unrelated function call into a route.
|
|
6
|
+
function closingParen(text, openAt) {
|
|
7
|
+
let depth = 0, quote = "", escaped = false, blockComment = 0;
|
|
8
|
+
for (let i = openAt; i < text.length; i++) {
|
|
9
|
+
const ch = text[i];
|
|
10
|
+
if (quote) {
|
|
11
|
+
if (escaped) escaped = false;
|
|
12
|
+
else if (ch === "\\") escaped = true;
|
|
13
|
+
else if (ch === quote) quote = "";
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (blockComment) {
|
|
17
|
+
if (ch === "/" && text[i + 1] === "*") { blockComment++; i++; }
|
|
18
|
+
else if (ch === "*" && text[i + 1] === "/") { blockComment--; i++; }
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
22
|
+
const newline = text.indexOf("\n", i + 2);
|
|
23
|
+
if (newline < 0) return -1;
|
|
24
|
+
i = newline;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (ch === "/" && text[i + 1] === "*") { blockComment = 1; i++; continue; }
|
|
28
|
+
if (ch === '"') { quote = ch; continue; }
|
|
29
|
+
if (ch === "'") {
|
|
30
|
+
// Skip a Rust character literal, but do not mistake a lifetime (`'a`) for an unterminated string.
|
|
31
|
+
const char = /^'(?:\\.|[^\\'\r\n])'/.exec(text.slice(i));
|
|
32
|
+
if (char) { i += char[0].length - 1; continue; }
|
|
33
|
+
}
|
|
34
|
+
if (ch === "(") depth++;
|
|
35
|
+
else if (ch === ")" && --depth === 0) return i;
|
|
36
|
+
}
|
|
37
|
+
return -1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Parse axum's MethodRouter expression, including direct method chains:
|
|
41
|
+
// get(list).post(create) / axum::routing::get(show).delete(remove)
|
|
42
|
+
// Calls inside a handler closure are not scanned and therefore cannot become fake endpoint methods.
|
|
43
|
+
function axumRoutes(expr) {
|
|
44
|
+
const found = [];
|
|
45
|
+
let rest = String(expr || ""), offset = 0;
|
|
46
|
+
const firstRe = new RegExp(`^\\s*(?:(?:axum\\s*::\\s*)?routing\\s*::\\s*)?(${RUST_ROUTE_METHOD})\\s*\\(`, "i");
|
|
47
|
+
let match = firstRe.exec(rest);
|
|
48
|
+
if (!match) return found;
|
|
49
|
+
|
|
50
|
+
while (match) {
|
|
51
|
+
const openAt = offset + match.index + match[0].lastIndexOf("(");
|
|
52
|
+
const closeAt = closingParen(expr, openAt);
|
|
53
|
+
if (closeAt < 0) break;
|
|
54
|
+
found.push({ method: match[1], handler: expr.slice(openAt + 1, closeAt), index: openAt });
|
|
55
|
+
offset = closeAt + 1;
|
|
56
|
+
rest = expr.slice(offset);
|
|
57
|
+
match = new RegExp(`^\\s*\\.\\s*(${RUST_ROUTE_METHOD})\\s*\\(`, "i").exec(rest);
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Actix's builder form is structurally distinct from axum and from HTTP clients:
|
|
63
|
+
// web::get().to(handler) / actix_web::web::post().to(handler)
|
|
64
|
+
function actixRoutes(expr) {
|
|
65
|
+
const found = [];
|
|
66
|
+
const re = new RegExp(`(?:actix_web\\s*::\\s*)?web\\s*::\\s*(${RUST_ROUTE_METHOD})\\s*\\(\\s*\\)\\s*\\.\\s*to\\s*\\(`, "gi");
|
|
67
|
+
let match;
|
|
68
|
+
while ((match = re.exec(expr))) {
|
|
69
|
+
const openAt = re.lastIndex - 1;
|
|
70
|
+
const closeAt = closingParen(expr, openAt);
|
|
71
|
+
if (closeAt < 0) continue;
|
|
72
|
+
found.push({ method: match[1], handler: expr.slice(openAt + 1, closeAt), index: match.index });
|
|
73
|
+
re.lastIndex = closeAt + 1;
|
|
74
|
+
}
|
|
75
|
+
return found;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function extractRustEndpoints(text, add) {
|
|
79
|
+
let match;
|
|
80
|
+
|
|
81
|
+
// Axum and actix both expose a path-first `.route(path, ...)` builder. Requiring either an axum
|
|
82
|
+
// MethodRouter or actix's web::<method>().to(...) avoids treating arbitrary Rust APIs named `route` as HTTP.
|
|
83
|
+
const routeRe = /\.\s*route\s*\(\s*"(\/(?:\\.|[^"\\])*)"\s*,/g;
|
|
84
|
+
while ((match = routeRe.exec(text))) {
|
|
85
|
+
const openAt = text.indexOf("(", match.index);
|
|
86
|
+
const closeAt = closingParen(text, openAt);
|
|
87
|
+
if (closeAt < 0) continue;
|
|
88
|
+
const exprStart = routeRe.lastIndex;
|
|
89
|
+
const expr = text.slice(exprStart, closeAt);
|
|
90
|
+
const routes = [...axumRoutes(expr), ...actixRoutes(expr)];
|
|
91
|
+
for (const route of routes) add(route.method, match[1], route.handler, exprStart + route.index);
|
|
92
|
+
routeRe.lastIndex = closeAt + 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// actix `web::resource(path)` associates several method routes with one path.
|
|
96
|
+
const resourceRe = /(?:actix_web\s*::\s*)?web\s*::\s*resource\s*\(\s*"(\/(?:\\.|[^"\\])*)"\s*\)/g;
|
|
97
|
+
while ((match = resourceRe.exec(text))) {
|
|
98
|
+
const tail = text.slice(resourceRe.lastIndex);
|
|
99
|
+
const nextResource = tail.search(/(?:actix_web\s*::\s*)?web\s*::\s*resource\s*\(/);
|
|
100
|
+
const semicolon = text.indexOf(";", resourceRe.lastIndex);
|
|
101
|
+
let end = Math.min(text.length, resourceRe.lastIndex + 4000);
|
|
102
|
+
if (nextResource >= 0) end = Math.min(end, resourceRe.lastIndex + nextResource);
|
|
103
|
+
if (semicolon >= 0) end = Math.min(end, semicolon);
|
|
104
|
+
const chain = text.slice(resourceRe.lastIndex, end);
|
|
105
|
+
for (const route of actixRoutes(chain)) add(route.method, match[1], route.handler, resourceRe.lastIndex + route.index);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Actix attribute macros. The route macro can declare more than one method for the same handler.
|
|
109
|
+
const macroRe = new RegExp(`#\\s*\\[\\s*(?:(?:actix_web|actix)\\s*::\\s*)?(${RUST_ROUTE_METHOD})\\s*\\(\\s*"(\\/(?:\\\\.|[^"\\\\])*)"(?:\\s*,[^\\]]*)?\\s*\\)\\s*\\]`, "gi");
|
|
110
|
+
while ((match = macroRe.exec(text))) {
|
|
111
|
+
const after = text.slice(macroRe.lastIndex, macroRe.lastIndex + 600);
|
|
112
|
+
const fn = /\b(?:pub(?:\s*\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_][\w]*)/.exec(after);
|
|
113
|
+
add(match[1], match[2], fn ? fn[1] : "", match.index);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const multiMacroRe = /#\s*\[\s*(?:(?:actix_web|actix)\s*::\s*)?route\s*\(\s*"(\/(?:\\.|[^"\\])*)"([\s\S]*?)\)\s*\]/gi;
|
|
117
|
+
while ((match = multiMacroRe.exec(text))) {
|
|
118
|
+
const after = text.slice(multiMacroRe.lastIndex, multiMacroRe.lastIndex + 600);
|
|
119
|
+
const fn = /\b(?:pub(?:\s*\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_][\w]*)/.exec(after);
|
|
120
|
+
const methodRe = /\bmethod\s*=\s*"(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE|CONNECT)"/gi;
|
|
121
|
+
let method;
|
|
122
|
+
while ((method = methodRe.exec(match[2]))) add(method[1], match[1], fn ? fn[1] : "", match.index);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
// endpoints.js — detect the repo's OWN HTTP API surface (routes it EXPOSES), so the Health tab can
|
|
2
2
|
// surface endpoints the way infra towers surface the services a repo TALKS TO. Each endpoint carries a
|
|
3
3
|
// best-effort handler NAME so the UI can join it to that method's health (O(n) complexity, criticality,
|
|
4
|
-
// coverage). Covers the common shapes across JS/TS, Python and
|
|
4
|
+
// coverage). Covers the common shapes across JS/TS, Python, Go and Rust:
|
|
5
5
|
// • object routes (Bun.serve / custom): "/path": { GET: handler, POST: fn } | "/path": handler
|
|
6
6
|
// • method-call routes (Express/Fastify/Koa/Hono/gin/echo): app.get("/path", handler)
|
|
7
7
|
// • decorators (FastAPI/Flask/NestJS): @app.get("/path") / @Get("/path")
|
|
8
8
|
// • Go net/http: mux.HandleFunc("/path", handler)
|
|
9
|
+
// • Rust axum/actix-web: .route("/path", get(handler)) / #[get("/path")]
|
|
9
10
|
import { safeRead } from "../util.js";
|
|
10
11
|
import { createRepoBoundary } from "../repo-path.js";
|
|
12
|
+
import { extractRustEndpoints } from "./endpoints-rust.js";
|
|
11
13
|
|
|
12
14
|
const MAX_FILES = 3000;
|
|
13
|
-
const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "ALL", "ANY"]);
|
|
15
|
+
const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "CONNECT", "ALL", "ANY"]);
|
|
14
16
|
// UNAMBIGUOUS HTTP CLIENTS (make requests) vs servers (define routes) — reject `axios.get("/x")`-style client
|
|
15
17
|
// calls in frontend code. Ambiguous names (api/client/service — could be a server router) are NOT listed;
|
|
16
18
|
// they're filtered by the handler requirement instead (a client call has no handler / a config object arg).
|
|
@@ -31,7 +33,9 @@ function lineAt(text, index) {
|
|
|
31
33
|
function handlerName(expr) {
|
|
32
34
|
const s = String(expr || "").trim();
|
|
33
35
|
if (!s) return "";
|
|
34
|
-
if (/=>/.test(s) || /^\s*(async\s+)?function\b/.test(s)) return "";
|
|
36
|
+
if (/=>/.test(s) || /^\s*(async\s+)?function\b/.test(s) || /^\s*(?:async\s+)?(?:move\s+)?\|[^|]*\|/.test(s)) return "";
|
|
37
|
+
const turbofish = /(?:^|::)([A-Za-z_][\w]*)\s*::<[\s\S]*>\s*$/.exec(s);
|
|
38
|
+
if (turbofish) return turbofish[1];
|
|
35
39
|
const ids = s.match(/[A-Za-z_$][\w$]*/g);
|
|
36
40
|
if (!ids) return "";
|
|
37
41
|
const SKIP = new Set(["async", "function", "await", "req", "res", "ctx", "request", "response", "next", "return"]);
|
|
@@ -68,6 +72,7 @@ export function nextRoutePath(file) {
|
|
|
68
72
|
export function extractEndpointsFromText(text, file) {
|
|
69
73
|
const out = [];
|
|
70
74
|
const py = /\.py$/i.test(file);
|
|
75
|
+
const rust = /\.rs$/i.test(file);
|
|
71
76
|
const add = (method, path, expr, idx) => {
|
|
72
77
|
const p = cleanPath(path);
|
|
73
78
|
if (!looksLikePath(p)) return;
|
|
@@ -101,6 +106,8 @@ export function extractEndpointsFromText(text, file) {
|
|
|
101
106
|
}
|
|
102
107
|
}
|
|
103
108
|
|
|
109
|
+
if (rust) extractRustEndpoints(text, add);
|
|
110
|
+
|
|
104
111
|
// ---- object routes: "/path": { GET: fn, POST: fn2 } or "/path": handler --------------------
|
|
105
112
|
// find each "…": { or "…": expr, where the key looks like a path
|
|
106
113
|
const objKeyRe = /(["'`])(\/[^"'`]*)\1\s*:\s*(\{)?/g;
|
|
@@ -171,7 +178,7 @@ export function detectEndpoints(repoPath, codeFiles) {
|
|
|
171
178
|
const boundary = createRepoBoundary(repoPath);
|
|
172
179
|
for (const f of files) {
|
|
173
180
|
const rel = f.path || f;
|
|
174
|
-
if (!/\.(js|ts|tsx|jsx|cjs|mjs|py|go)$/i.test(rel)) continue;
|
|
181
|
+
if (!/\.(js|ts|tsx|jsx|cjs|mjs|py|go|rs)$/i.test(rel)) continue;
|
|
175
182
|
const resolved = boundary.resolve(rel);
|
|
176
183
|
if (!resolved.ok) continue;
|
|
177
184
|
const text = safeRead(resolved.path);
|
|
@@ -6,6 +6,8 @@ import { join } from "node:path";
|
|
|
6
6
|
import { normalizeRepoParts, readCoverageForRepo, pctFromCounts } from "./coverage-reports.js";
|
|
7
7
|
import { bareSymbolName, countLocalRefsOutsideOwnRange, computeSymbolExternalRefs } from "./graph-analysis.refs.js";
|
|
8
8
|
import { createRepoBoundary } from "../repo-path.js";
|
|
9
|
+
import { edgeList, folderModuleOf } from "./graph-analysis.edges.js";
|
|
10
|
+
export { folderModuleOf } from "./graph-analysis.edges.js";
|
|
9
11
|
|
|
10
12
|
// Aggregate a built graph.json into the file- and module-level view the UI needs:
|
|
11
13
|
// - graph-builder nodes are FILES *and* their symbols (functions/methods), linked file→symbol by the
|
|
@@ -13,7 +15,6 @@ import { createRepoBoundary } from "../repo-path.js";
|
|
|
13
15
|
// - Each file is assigned to ONE module via its file-node's community (symbols may cluster apart,
|
|
14
16
|
// but the file itself belongs where its file-node sits) → exact, non-overlapping file counts.
|
|
15
17
|
// - Real edges (everything except "contains") roll up to file→file and module→module relations.
|
|
16
|
-
// Module names match summarizeCommunities (dominant first-two-path-parts), so they line up with cards.
|
|
17
18
|
export function analyzeGraph(graphJsonPath, repoRoot) {
|
|
18
19
|
let graph;
|
|
19
20
|
try {
|
|
@@ -40,15 +41,11 @@ export function aggregateGraph(graph, repoRoot) {
|
|
|
40
41
|
// MODULE = the file's own top FOLDER (its "territory"), up to 2 path levels — NOT a dependency
|
|
41
42
|
// cluster. e.g. src/widget/foo.js → "src/widget"; benchmarks/cleanup.py → "benchmarks" (so all
|
|
42
43
|
// benchmarks files are one module); a top-level file like index.js → "(root)".
|
|
43
|
-
const folderOf = (file) => {
|
|
44
|
-
const dirs = String(file || "").split(/[\\/]/).filter(Boolean).slice(0, -1);
|
|
45
|
-
return dirs.length ? dirs.slice(0, 2).join("/") : "(root)";
|
|
46
|
-
};
|
|
47
44
|
// In a merged (cross-repo) graph every node carries a `repo`; qualify file & module identity by it
|
|
48
45
|
// so same-named folders/files in different repos never merge. Single-repo graphs have no `repo`,
|
|
49
46
|
// so identity stays the bare path (unchanged behavior).
|
|
50
47
|
const fileIdOf = (node) => (node.repo ? `${node.repo}::${node.source_file}` : node.source_file);
|
|
51
|
-
const moduleOfNode = (node) => (node.repo ? `${node.repo}/` : "") +
|
|
48
|
+
const moduleOfNode = (node) => (node.repo ? `${node.repo}/` : "") + folderModuleOf(node.source_file);
|
|
52
49
|
|
|
53
50
|
// file → module (folder) + symbols + display path, all keyed by repo-qualified file id
|
|
54
51
|
const fileModule = new Map();
|
|
@@ -102,40 +99,36 @@ export function aggregateGraph(graph, repoRoot) {
|
|
|
102
99
|
const moduleEdges = new Map();
|
|
103
100
|
const typeOnlyFileEdges = new Map();
|
|
104
101
|
const typeOnlyModuleEdges = new Map();
|
|
102
|
+
const compileOnlyFileEdges = new Map();
|
|
103
|
+
const compileOnlyModuleEdges = new Map();
|
|
104
|
+
const compileTimeFileEdges = new Map();
|
|
105
|
+
const compileTimeModuleEdges = new Map();
|
|
106
|
+
const addFileEdge = (map, key, link) => {
|
|
107
|
+
let edge = map.get(key);
|
|
108
|
+
if (!edge) map.set(key, (edge = { count: 0, rels: {} }));
|
|
109
|
+
edge.count++;
|
|
110
|
+
if (link.relation) edge.rels[link.relation] = (edge.rels[link.relation] || 0) + 1;
|
|
111
|
+
};
|
|
105
112
|
for (const link of links) {
|
|
106
113
|
if (link.relation === "contains") continue;
|
|
107
114
|
const fromFile = id2file.get(endpoint(link.source));
|
|
108
115
|
const toFile = id2file.get(endpoint(link.target));
|
|
109
116
|
if (fromFile && toFile && fromFile !== toFile) {
|
|
110
117
|
const key = `${fromFile} ${toFile}`;
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (link.relation) fe.rels[link.relation] = (fe.rels[link.relation] || 0) + 1;
|
|
118
|
+
const compileTime = link.typeOnly === true || link.compileOnly === true;
|
|
119
|
+
const targetFileEdges = link.typeOnly === true ? typeOnlyFileEdges : link.compileOnly === true ? compileOnlyFileEdges : fileEdges;
|
|
120
|
+
addFileEdge(targetFileEdges, key, link);
|
|
121
|
+
if (compileTime) addFileEdge(compileTimeFileEdges, key, link);
|
|
116
122
|
const fromMod = fileModule.get(fromFile);
|
|
117
123
|
const toMod = fileModule.get(toFile);
|
|
118
124
|
if (fromMod && toMod && fromMod !== toMod) {
|
|
119
125
|
const mkey = `${fromMod} ${toMod}`;
|
|
120
|
-
const targetModuleEdges = link.typeOnly === true ? typeOnlyModuleEdges : moduleEdges;
|
|
126
|
+
const targetModuleEdges = link.typeOnly === true ? typeOnlyModuleEdges : link.compileOnly === true ? compileOnlyModuleEdges : moduleEdges;
|
|
121
127
|
targetModuleEdges.set(mkey, (targetModuleEdges.get(mkey) || 0) + 1);
|
|
128
|
+
if (compileTime) compileTimeModuleEdges.set(mkey, (compileTimeModuleEdges.get(mkey) || 0) + 1);
|
|
122
129
|
}
|
|
123
130
|
}
|
|
124
131
|
}
|
|
125
|
-
const split = (key) => {
|
|
126
|
-
const i = key.indexOf(" ");
|
|
127
|
-
return [key.slice(0, i), key.slice(i + 1)];
|
|
128
|
-
};
|
|
129
|
-
const edgeList = (map) =>
|
|
130
|
-
[...map.entries()]
|
|
131
|
-
.map(([key, v]) => {
|
|
132
|
-
const [from, to] = split(key);
|
|
133
|
-
if (typeof v === "number") return { from, to, count: v }; // moduleEdges (no relation breakdown)
|
|
134
|
-
const dom = Object.entries(v.rels).sort((a, b) => b[1] - a[1])[0];
|
|
135
|
-
return { from, to, count: v.count, relation: dom ? dom[0] : null };
|
|
136
|
-
})
|
|
137
|
-
.sort((a, b) => b.count - a.count);
|
|
138
|
-
|
|
139
132
|
// symbol-level (function/method) call graph: edges between symbol nodes (calls/method), each symbol
|
|
140
133
|
// tagged with its file's module so the Symbols view can still cluster into module regions.
|
|
141
134
|
const symEdges = new Map();
|
|
@@ -268,8 +261,12 @@ export function aggregateGraph(graph, repoRoot) {
|
|
|
268
261
|
.sort((a, b) => b.fileCount - a.fileCount),
|
|
269
262
|
moduleEdges: edgeList(moduleEdges),
|
|
270
263
|
typeOnlyModuleEdges: edgeList(typeOnlyModuleEdges),
|
|
264
|
+
compileOnlyModuleEdges: edgeList(compileOnlyModuleEdges),
|
|
265
|
+
compileTimeModuleEdges: edgeList(compileTimeModuleEdges),
|
|
271
266
|
fileEdges: edgeList(fileEdges),
|
|
272
267
|
typeOnlyFileEdges: edgeList(typeOnlyFileEdges),
|
|
268
|
+
compileOnlyFileEdges: edgeList(compileOnlyFileEdges),
|
|
269
|
+
compileTimeFileEdges: edgeList(compileTimeFileEdges),
|
|
273
270
|
symbols,
|
|
274
271
|
symbolEdges,
|
|
275
272
|
symbolRefs: [...new Set([...symbolLocalRefs.keys(), ...symbolExternalRefs.keys()])].map((id) => {
|
|
@@ -283,8 +280,12 @@ export function aggregateGraph(graph, repoRoot) {
|
|
|
283
280
|
nodes: nodes.filter((n) => n.file_type === "code").length,
|
|
284
281
|
fileEdges: fileEdges.size,
|
|
285
282
|
typeOnlyFileEdges: typeOnlyFileEdges.size,
|
|
283
|
+
compileOnlyFileEdges: compileOnlyFileEdges.size,
|
|
284
|
+
compileTimeFileEdges: compileTimeFileEdges.size,
|
|
286
285
|
moduleEdges: moduleEdges.size,
|
|
287
286
|
typeOnlyModuleEdges: typeOnlyModuleEdges.size,
|
|
287
|
+
compileOnlyModuleEdges: compileOnlyModuleEdges.size,
|
|
288
|
+
compileTimeModuleEdges: compileTimeModuleEdges.size,
|
|
288
289
|
symbols: symbols.length,
|
|
289
290
|
symbolEdges: symbolEdges.length
|
|
290
291
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Stable folder territories used by module_map. Nested package source roots keep the first directory below
|
|
2
|
+
// `src`; otherwise a monorepo's entire crate/app would collapse into one module with no visible dependencies.
|
|
3
|
+
export function folderModuleOf(file) {
|
|
4
|
+
const dirs = String(file || "").split(/[\\/]/).filter(Boolean).slice(0, -1);
|
|
5
|
+
if (!dirs.length) return "(root)";
|
|
6
|
+
const sourceRoot = dirs.lastIndexOf("src");
|
|
7
|
+
if (sourceRoot >= 0 && dirs.length > sourceRoot + 1) return dirs.slice(0, sourceRoot + 2).join("/");
|
|
8
|
+
return dirs.slice(0, 2).join("/");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function edgeList(map) {
|
|
12
|
+
return [...map.entries()]
|
|
13
|
+
.map(([key, value]) => {
|
|
14
|
+
const splitAt = key.indexOf(" ");
|
|
15
|
+
const from = key.slice(0, splitAt), to = key.slice(splitAt + 1);
|
|
16
|
+
if (typeof value === "number") return { from, to, count: value };
|
|
17
|
+
const dominant = Object.entries(value.rels).sort((a, b) => b[1] - a[1])[0];
|
|
18
|
+
return { from, to, count: value.count, relation: dominant ? dominant[0] : null };
|
|
19
|
+
})
|
|
20
|
+
.sort((a, b) => b.count - a.count);
|
|
21
|
+
}
|
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
// Facade: implementation lives in graph-analysis.summaries.js and graph-analysis.aggregate.js
|
|
5
5
|
// (with internal helpers in graph-analysis.refs.js).
|
|
6
6
|
export { summarizeCommunities, summarizeHotspots } from "./graph-analysis.summaries.js";
|
|
7
|
-
export { analyzeGraph, aggregateGraph } from "./graph-analysis.aggregate.js";
|
|
7
|
+
export { analyzeGraph, aggregateGraph, folderModuleOf } from "./graph-analysis.aggregate.js";
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Reading a built graph.json and summarizing it for the UI cards: named communities and degree
|
|
2
2
|
// hotspots. Both read the graph file from disk and return [] on any failure.
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
|
+
import { communityTerritoryOf } from "../graph/community.js";
|
|
5
|
+
import { isStructuralRelation } from "../graph/relations.js";
|
|
4
6
|
|
|
5
7
|
// graph-builder labels communities "Community N" without an LLM. Derive a real name from each
|
|
6
8
|
// community's dominant folder + sample files so the UI shows modules, not bare numbers.
|
|
@@ -16,7 +18,7 @@ export function summarizeCommunities(graphJsonPath, max = 40) {
|
|
|
16
18
|
const entry = byCommunity.get(community);
|
|
17
19
|
entry.size += 1;
|
|
18
20
|
const parts = String(node.source_file || "").split(/[\\/]/).filter(Boolean);
|
|
19
|
-
const dir =
|
|
21
|
+
const dir = communityTerritoryOf(node.source_file);
|
|
20
22
|
entry.dirs.set(dir, (entry.dirs.get(dir) || 0) + 1);
|
|
21
23
|
if (entry.files.length < 4) entry.files.push(parts[parts.length - 1] || node.source_file || "");
|
|
22
24
|
}
|
|
@@ -41,6 +43,7 @@ export function summarizeHotspots(graphJsonPath, max = 15) {
|
|
|
41
43
|
const inDeg = new Map();
|
|
42
44
|
const outDeg = new Map();
|
|
43
45
|
for (const link of graph.links || []) {
|
|
46
|
+
if (isStructuralRelation(link.relation)) continue;
|
|
44
47
|
const s = endpoint(link.source);
|
|
45
48
|
const t = endpoint(link.target);
|
|
46
49
|
outDeg.set(s, (outDeg.get(s) || 0) + 1);
|
|
@@ -50,6 +50,46 @@ export function listRepoFiles(repoRoot) {
|
|
|
50
50
|
return files;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
const NON_RUNTIME_DIR_RE = /^(?:templates?|examples?|samples?|fixtures?|snippets?|__fixtures__)$/i;
|
|
54
|
+
const NON_RUNTIME_README_RE = /(?:\b(?:reusable|copyable|reference)\b[\s\S]{0,160}\b(?:templates?|snippets?|examples?|samples?)\b|\b(?:these|contents?)\s+are\s+templates?\b)/i;
|
|
55
|
+
const normConfiguredRoot = (value) => {
|
|
56
|
+
const root = String(value || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+|\/+$/g, "");
|
|
57
|
+
if (!root || root === "." || root.split("/").some((part) => part === "..")) return "";
|
|
58
|
+
return root;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Runtime health findings should not treat copy-paste catalogs as deployed applications. Conventional
|
|
62
|
+
// template/example directories are safe to infer; a top-level custom catalog is inferred only when its
|
|
63
|
+
// own README explicitly describes reusable templates/snippets. Projects can declare additional roots in
|
|
64
|
+
// `.weavatrix-deps.json` through `nonRuntimeRoots` / `templateRoots`.
|
|
65
|
+
export function collectNonRuntimeRoots(repoRoot, rules = {}) {
|
|
66
|
+
const files = listRepoFiles(repoRoot);
|
|
67
|
+
const roots = new Set();
|
|
68
|
+
const configured = [
|
|
69
|
+
rules.nonRuntimeRoots, rules.templateRoots,
|
|
70
|
+
rules.dependencies?.nonRuntimeRoots, rules.dependencies?.templateRoots,
|
|
71
|
+
].flatMap((value) => Array.isArray(value) ? value : typeof value === "string" ? [value] : []);
|
|
72
|
+
for (const value of configured) {
|
|
73
|
+
const root = normConfiguredRoot(value);
|
|
74
|
+
if (root) roots.add(root);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const file of files) {
|
|
78
|
+
const parts = file.replace(/\\/g, "/").split("/");
|
|
79
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
80
|
+
if (NON_RUNTIME_DIR_RE.test(parts[i])) roots.add(parts.slice(0, i + 1).join("/"));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const boundary = createRepoBoundary(repoRoot);
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
if (!/^[^/]+\/README\.md$/i.test(file)) continue; // custom inference is deliberately top-level only
|
|
87
|
+
const text = readRepoText(boundary, file);
|
|
88
|
+
if (text != null && NON_RUNTIME_README_RE.test(text)) roots.add(normRoot(dirname(file)));
|
|
89
|
+
}
|
|
90
|
+
return [...roots].filter(Boolean).sort((a, b) => a.localeCompare(b));
|
|
91
|
+
}
|
|
92
|
+
|
|
53
93
|
export function collectSourceTexts(repoRoot, graph) {
|
|
54
94
|
const sources = new Map();
|
|
55
95
|
const boundary = createRepoBoundary(repoRoot);
|
|
@@ -16,7 +16,7 @@ import { scanMalware } from "../security/malware-heuristics.js";
|
|
|
16
16
|
import { classifyTyposquat } from "../security/typosquat.js";
|
|
17
17
|
import {
|
|
18
18
|
readJson, readRepoText, readRepoJson, collectSourceTexts, collectConfigTexts, workspacePkgNames,
|
|
19
|
-
collectPackageScopes, collectPyManifest, TEST_FILE_RE,
|
|
19
|
+
collectPackageScopes, collectPyManifest, collectNonRuntimeRoots, TEST_FILE_RE,
|
|
20
20
|
} from "./internal-audit.collect.js";
|
|
21
21
|
import { entryFiles, computeReachability } from "./internal-audit.reach.js";
|
|
22
22
|
import { createRepoBoundary } from "../repo-path.js";
|
|
@@ -40,19 +40,23 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
40
40
|
|
|
41
41
|
// Graphs can be stale or miss a helper file; text fallbacks must scan the real repo tree too.
|
|
42
42
|
const sources = collectSourceTexts(repoPath, graph);
|
|
43
|
+
const nonRuntimeRoots = collectNonRuntimeRoots(repoPath, rules);
|
|
43
44
|
|
|
44
45
|
const entries = entryFiles(graph, packageScopes, dynamicTargets, {
|
|
45
46
|
declaredEntries: rules.entrypoints || rules.entries || [],
|
|
46
47
|
sources,
|
|
47
48
|
});
|
|
49
|
+
for (const file of sources.keys()) {
|
|
50
|
+
if (nonRuntimeRoots.some((root) => file === root || file.startsWith(`${root}/`))) entries.add(file);
|
|
51
|
+
}
|
|
48
52
|
const dead = computeDead(graph, sources, { entrySet: entries });
|
|
49
53
|
const unusedExports = computeUnusedExports(graph, sources, { dynamicTargets, entrySet: entries });
|
|
50
54
|
const reachable = computeReachability(graph, entries);
|
|
51
55
|
const configTexts = collectConfigTexts(repoPath);
|
|
52
|
-
const dep = computeScopedDepFindings({ externalImports, packageScopes, workspacePkgNames: workspacePkgNames(repoPath, pkg), configTexts });
|
|
56
|
+
const dep = computeScopedDepFindings({ externalImports, packageScopes, workspacePkgNames: workspacePkgNames(repoPath, pkg), configTexts, nonRuntimeRoots });
|
|
53
57
|
// non-npm ecosystems: Go (go.mod) + Python (requirements/pyproject/Pipfile) — same findings shape
|
|
54
58
|
const goModText = readRepoText(boundary, "go.mod");
|
|
55
|
-
const goDep = computeGoDepFindings({ externalImports, goMod: goModText != null ? parseGoMod(goModText) : null });
|
|
59
|
+
const goDep = computeGoDepFindings({ externalImports, goMod: goModText != null ? parseGoMod(goModText) : null, nonRuntimeRoots });
|
|
56
60
|
const asList = (v) => Array.isArray(v) ? v : typeof v === "string" ? [v] : [];
|
|
57
61
|
const pyRules = rules.python || {};
|
|
58
62
|
const depRules = rules.dependencies || {};
|
|
@@ -66,7 +70,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
66
70
|
])];
|
|
67
71
|
const pyDep = computePyDepFindings({
|
|
68
72
|
externalImports, pyManifest: collectPyManifest(repoPath), configTexts,
|
|
69
|
-
managedDependencies: managedPython, ignoredDependencies: ignoredPython,
|
|
73
|
+
managedDependencies: managedPython, ignoredDependencies: ignoredPython, nonRuntimeRoots,
|
|
70
74
|
});
|
|
71
75
|
|
|
72
76
|
// structure: cycles / orphans / boundary rules. Rules come from the repo's optional .weavatrix-deps.json
|
|
@@ -229,6 +233,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
229
233
|
malwareStatus: checks.malware.status,
|
|
230
234
|
packageScopes: packageScopes.length,
|
|
231
235
|
managedPythonDependencies: managedPython.length,
|
|
236
|
+
nonRuntimeRoots,
|
|
232
237
|
},
|
|
233
238
|
summary: summarizeFindings(sorted),
|
|
234
239
|
findings: sorted,
|