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.
@@ -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 + type-only, used to describe architectural coupling
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 runtimeSeen = new Set(), allSeen = new Set(), typeSeen = new Set();
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
- // A TypeScript `import type` is erased before runtime. It can still reveal architectural coupling, but
195
- // it cannot create an initialization-order/runtime cycle. Report SCCs that grow only because of type
196
- // edges separately and at info severity so agents do not churn working code to fix a phantom hazard.
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 typeCouplings = allSccs.filter((s) => !runtimeKeys.has([...s].sort().join("\0")));
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 typeCouplings.slice(0, MAX_CYCLE_FINDINGS)) {
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 ? "Type imports expand dependency coupling" : "Type-induced dependency cycle (no runtime cycle)"}: ${scc.length} files`,
215
- detail: `${cycle.join(" → ")}. This strongly-connected group needs type-only edges to close; it contains ${runtimeInside} runtime edge(s) and ${typeInside} type-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.`,
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 shared type ownership only if the coupling impedes changes; no runtime-cycle fix is required",
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
- typeCouplings: typeCouplings.length,
282
- largestTypeCoupling: typeCouplings[0]?.length || 0,
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 Go:
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}/` : "") + folderOf(node.source_file);
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 targetFileEdges = link.typeOnly === true ? typeOnlyFileEdges : fileEdges;
112
- let fe = targetFileEdges.get(key);
113
- if (!fe) targetFileEdges.set(key, (fe = { count: 0, rels: {} }));
114
- fe.count++;
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 = parts.length > 1 ? parts.slice(0, 2).join("/") : "(root)";
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,