weavatrix 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +117 -21
  2. package/SECURITY.md +31 -0
  3. package/package.json +16 -4
  4. package/skill/SKILL.md +69 -12
  5. package/src/analysis/coverage-reports.js +14 -11
  6. package/src/analysis/dead-check.js +87 -6
  7. package/src/analysis/dep-check.js +84 -8
  8. package/src/analysis/dep-rules.js +74 -8
  9. package/src/analysis/duplicates.compute.js +215 -215
  10. package/src/analysis/duplicates.js +15 -15
  11. package/src/analysis/duplicates.run.js +52 -51
  12. package/src/analysis/duplicates.tokenize.js +182 -182
  13. package/src/analysis/endpoints.js +50 -5
  14. package/src/analysis/graph-analysis.aggregate.js +16 -4
  15. package/src/analysis/internal-audit.collect.js +154 -31
  16. package/src/analysis/internal-audit.reach.js +52 -11
  17. package/src/analysis/internal-audit.run.js +72 -21
  18. package/src/build-graph.js +2 -1
  19. package/src/child-env.js +7 -0
  20. package/src/graph/builder/lang-js.js +66 -23
  21. package/src/graph/internal-builder.build.js +48 -10
  22. package/src/graph/internal-builder.langs.js +49 -3
  23. package/src/graph/internal-builder.resolvers.js +96 -20
  24. package/src/mcp/catalog.mjs +10 -8
  25. package/src/mcp/graph-context.mjs +107 -17
  26. package/src/mcp/sync-payload.mjs +110 -0
  27. package/src/mcp/tools-actions.mjs +42 -18
  28. package/src/mcp/tools-graph.mjs +46 -12
  29. package/src/mcp/tools-health.mjs +42 -18
  30. package/src/mcp/tools-impact.mjs +55 -43
  31. package/src/mcp-rg.mjs +2 -1
  32. package/src/mcp-server.mjs +25 -16
  33. package/src/mcp-source-tools.mjs +16 -5
  34. package/src/process.js +4 -3
  35. package/src/repo-path.js +53 -0
  36. package/src/security/advisory-store.js +51 -7
  37. package/src/security/installed.js +44 -31
  38. package/src/security/malware-heuristics.exclusions.js +134 -134
  39. package/src/security/malware-heuristics.js +15 -15
  40. package/src/security/malware-heuristics.roots.js +142 -142
  41. package/src/security/malware-heuristics.scan.js +85 -85
  42. package/src/security/malware-heuristics.sweep.js +122 -122
@@ -26,6 +26,16 @@ const BIN_PKG = {
26
26
  playwright: "@playwright/test",
27
27
  };
28
28
 
29
+ // Required peers consumed inside a framework/build tool rather than imported by application source.
30
+ // These contracts are package-scope local and deliberately narrow; declaring the provider is required
31
+ // for suppression, so an unrelated app still gets the normal unused-dependency finding.
32
+ const FRAMEWORK_RUNTIME_PEERS = new Map([
33
+ ["next", ["react-dom"]],
34
+ ["vinext", ["@vitejs/plugin-react", "@vitejs/plugin-rsc", "react-server-dom-webpack", "vite"]],
35
+ ["electron-vite", ["vite"]],
36
+ ["@cloudflare/vite-plugin", ["vite", "wrangler"]],
37
+ ]);
38
+
29
39
  const escRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
30
40
  // word-ish mention: the name not embedded inside a longer identifier/path segment
31
41
  const mentioned = (blob, name) => new RegExp(`(^|[^\\w@.-])${escRe(name)}($|[^\\w.-])`).test(blob);
@@ -35,8 +45,21 @@ const mentioned = (blob, name) => new RegExp(`(^|[^\\w@.-])${escRe(name)}($|[^\\
35
45
  // pkg — parsed package.json ({} for non-JS repos → no dep findings)
36
46
  // workspacePkgNames — Set of monorepo-local package names (never "missing")
37
47
  // configTexts — Map<fileName, text> of root config files + CI workflows (mention scanning)
38
- export function computeDepFindings({ externalImports = [], pkg = {}, workspacePkgNames = new Set(), configTexts = new Map() } = {}) {
48
+ export function computeDepFindings({
49
+ externalImports = [], pkg = {}, workspacePkgNames = new Set(), configTexts = new Map(),
50
+ aliases = [], scope = "", manifest = "package.json",
51
+ } = {}) {
39
52
  const findings = [];
53
+ const meta = { scope: scope || ".", manifest };
54
+ const isAliasSpec = (spec) => aliases.some((a) => {
55
+ const s = String(spec || "");
56
+ const key = typeof a === "string" ? a : String(a?.key || "");
57
+ const prefix = typeof a === "string" ? a.replace(/\*.*$/, "") : String(a?.prefix || "");
58
+ const suffix = key.includes("*") ? key.slice(key.indexOf("*") + 1) : String(a?.suffix || "");
59
+ return key.includes("*")
60
+ ? !!prefix && s.startsWith(prefix) && (!suffix || s.endsWith(suffix))
61
+ : !!key && s === key;
62
+ });
40
63
  const sections = {
41
64
  dependencies: pkg.dependencies || {},
42
65
  devDependencies: pkg.devDependencies || {},
@@ -45,12 +68,20 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
45
68
  };
46
69
  const allDeclared = new Set(Object.values(sections).flatMap((s) => Object.keys(s)));
47
70
  const selfName = String(pkg.name || "");
71
+ // Some frameworks consume required peers internally, so application source legitimately never imports
72
+ // them. Keep this deliberately narrow: react-dom is a required Next.js runtime peer in the same package
73
+ // scope, not a general React exemption and not a repo-wide whitelist.
74
+ const frameworkRuntime = new Set();
75
+ for (const [provider, peers] of FRAMEWORK_RUNTIME_PEERS) {
76
+ if (allDeclared.has(provider)) for (const peer of peers) frameworkRuntime.add(peer);
77
+ }
48
78
 
49
79
  // ---- usage index from the graph's recorded imports ----
50
80
  const usedPackages = new Map(); // pkgName -> { files:Set, lines:Map(file→first line), kinds:Set }
51
81
  let builtinUsed = false;
52
82
  for (const e of externalImports) {
53
83
  if (e.ecosystem && e.ecosystem !== "npm") continue; // go/python imports have their own checkers
84
+ if (isAliasSpec(e.spec)) continue;
54
85
  if (e.unresolved) continue;
55
86
  if (e.builtin) { builtinUsed = true; continue; }
56
87
  if (!e.pkg) continue;
@@ -75,10 +106,10 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
75
106
  for (const [section, deps] of Object.entries(sections)) {
76
107
  if (section === "peerDependencies") continue; // peers are consumer-facing contracts, not usage
77
108
  for (const name of Object.keys(deps)) {
78
- if (name === selfName || usedPackages.has(name)) continue;
109
+ if (name === selfName || usedPackages.has(name) || frameworkRuntime.has(name)) continue;
79
110
  const tb = typesBase(name);
80
111
  if (tb) { // @types/x is used iff x is used (or it types the Node builtins)
81
- if (tb === "node" ? builtinUsed : usedPackages.has(tb) || mentioned(configBlob, tb)) continue;
112
+ if (tb === "node" ? builtinUsed : usedPackages.has(tb) || frameworkRuntime.has(tb) || mentioned(configBlob, tb)) continue;
82
113
  }
83
114
  if (binReferenced(name) || mentioned(configBlob, name)) continue; // scripts/config keep it alive
84
115
  const ecosystem = CONFIG_ECOSYSTEM_RE.test(name);
@@ -94,6 +125,7 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
94
125
  package: name,
95
126
  source: "internal",
96
127
  fixHint: `npm uninstall ${name} (after confirming no config/CLI usage)`,
128
+ ...meta,
97
129
  }));
98
130
  }
99
131
  }
@@ -109,13 +141,14 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
109
141
  severity: testOnly ? "low" : "medium",
110
142
  confidence: "high",
111
143
  title: `Missing dependency: ${name}`,
112
- detail: `"${name}" is imported by ${files.length} file(s) but not declared in package.json — it only works via a transitive install (phantom dependency) and can break on any lockfile change.`,
144
+ detail: `"${name}" is imported by ${files.length} file(s) in scope ${meta.scope} but not declared in ${manifest} — it only works via a transitive install (phantom dependency) and can break on any lockfile change.`,
113
145
  package: name,
114
146
  file: files[0],
115
147
  line: use.lines.get(files[0]) || 0,
116
148
  evidence: files.slice(0, 5).map((f) => ({ file: f, line: use.lines.get(f) || 0, snippet: "" })),
117
149
  source: "internal",
118
150
  fixHint: `npm install ${testOnly ? "--save-dev " : ""}${name}`,
151
+ ...meta,
119
152
  }));
120
153
  }
121
154
 
@@ -134,6 +167,7 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
134
167
  detail: `"${name}" is declared in ${ss.join(" + ")} — npm resolves one of them; keep a single section.`,
135
168
  package: name,
136
169
  source: "internal",
170
+ ...meta,
137
171
  }));
138
172
  }
139
173
 
@@ -156,6 +190,7 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
156
190
  file: e.file,
157
191
  line: e.line || 0,
158
192
  source: "internal",
193
+ ...meta,
159
194
  }));
160
195
  }
161
196
  if (unresolvedCount > unresolvedSeen.size) {
@@ -167,12 +202,45 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
167
202
  title: `…and ${unresolvedCount - unresolvedSeen.size} more unresolved imports`,
168
203
  detail: "Finding list capped at 100 unique unresolved imports; the count above is the true total.",
169
204
  source: "internal",
205
+ ...meta,
170
206
  }));
171
207
  }
172
208
 
173
209
  return { findings, usedPackages, declared: allDeclared };
174
210
  }
175
211
 
212
+ const normScope = (root) => String(root || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+|\/+$/g, "");
213
+ const ownsFile = (scope, file) => !scope || file === scope || String(file || "").startsWith(`${scope}/`);
214
+
215
+ // Judge every import against its nearest ancestor package.json. This is the dependency equivalent of
216
+ // Node's package scope and prevents nested Next/Vite apps from inheriting the root manifest by accident.
217
+ export function computeScopedDepFindings({
218
+ externalImports = [], packageScopes = [], workspacePkgNames = new Set(), configTexts = new Map(),
219
+ } = {}) {
220
+ const scopes = packageScopes.length
221
+ ? packageScopes.map((s) => ({ ...s, root: normScope(s.root) })).sort((a, b) => b.root.length - a.root.length)
222
+ : [{ root: "", manifest: "package.json", pkg: {}, aliases: [] }];
223
+ const importsByScope = new Map(scopes.map((s) => [s, []]));
224
+ for (const e of externalImports) {
225
+ const owner = scopes.find((s) => ownsFile(s.root, e.file)) || scopes[scopes.length - 1];
226
+ importsByScope.get(owner).push(e);
227
+ }
228
+ const configOwner = new Map();
229
+ for (const [file] of configTexts) configOwner.set(file, scopes.find((scope) => ownsFile(scope.root, file)) || scopes[scopes.length - 1]);
230
+ const findings = [], usedPackages = new Map(), declared = new Set();
231
+ for (const s of scopes) {
232
+ const scopeConfig = new Map([...configTexts].filter(([f]) => configOwner.get(f) === s));
233
+ const r = computeDepFindings({
234
+ externalImports: importsByScope.get(s), pkg: s.pkg || {}, workspacePkgNames, configTexts: scopeConfig,
235
+ aliases: s.aliases || [], scope: s.root, manifest: s.manifest || (s.root ? `${s.root}/package.json` : "package.json"),
236
+ });
237
+ findings.push(...r.findings);
238
+ for (const [name, use] of r.usedPackages) usedPackages.set(`${s.root || "."}:${name}`, use);
239
+ for (const name of r.declared) declared.add(`${s.root || "."}:${name}`);
240
+ }
241
+ return { findings, usedPackages, declared };
242
+ }
243
+
176
244
  const TEST_PATH_RE = /(^|[/\\])(test|tests|__tests__|spec|e2e|__mocks__)([/\\]|$)|[._-](test|spec)\.[a-z0-9]+$|_test\.go$/i;
177
245
 
178
246
  // ---- Go: set math over ecosystem:"Go" externalImports vs go.mod requires ----
@@ -243,11 +311,17 @@ const PY_TOOL_DISTS = new Set(("pytest tox nox black ruff flake8 pylint mypy pyr
243
311
  "pip-tools uv virtualenv pipenv gunicorn uwsgi supervisor ipython jupyter jupyterlab notebook ipykernel codecov autopep8 yapf commitizen detect-secrets safety pip-audit hatchling flit flit-core pdm").split(" "));
244
312
  const pyNorm = (n) => String(n || "").toLowerCase().replace(/[-_.]+/g, "-");
245
313
 
246
- export function computePyDepFindings({ externalImports = [], pyManifest = null, configTexts = new Map() } = {}) {
314
+ export function computePyDepFindings({
315
+ externalImports = [], pyManifest = null, configTexts = new Map(),
316
+ managedDependencies = [], ignoredDependencies = [],
317
+ } = {}) {
247
318
  const findings = [];
248
319
  const deps = (pyManifest && pyManifest.deps) || [];
249
320
  const present = !!(pyManifest && pyManifest.present);
250
321
  const declared = new Set(deps.map((d) => pyNorm(d.name)));
322
+ const managed = new Set((managedDependencies || []).map(pyNorm));
323
+ const ignored = new Set((ignoredDependencies || []).map(pyNorm));
324
+ for (const name of managed) declared.add(name);
251
325
 
252
326
  const used = new Map(); // top import → { dist, files:Set, lines:Map }
253
327
  for (const e of externalImports) {
@@ -268,6 +342,7 @@ export function computePyDepFindings({ externalImports = [], pyManifest = null,
268
342
  if (present) {
269
343
  for (const d of deps) {
270
344
  const n = pyNorm(d.name);
345
+ if (managed.has(n) || ignored.has(n)) continue;
271
346
  if (d.buildSystem || PY_TOOL_DISTS.has(n) || /^types-|-stubs$|^pytest-|^flake8-|^sphinx/.test(n)) continue;
272
347
  let hit = false;
273
348
  for (const [top, u] of used) if (covers(d.name, top, u.dist)) { hit = true; break; }
@@ -286,6 +361,7 @@ export function computePyDepFindings({ externalImports = [], pyManifest = null,
286
361
  }
287
362
  }
288
363
  for (const [top, u] of used) {
364
+ if (ignored.has(pyNorm(top)) || ignored.has(pyNorm(u.dist)) || managed.has(pyNorm(top)) || managed.has(pyNorm(u.dist))) continue;
289
365
  let hit = false;
290
366
  for (const D of declared) if (covers(D, top, u.dist)) { hit = true; break; }
291
367
  if (hit) continue;
@@ -297,14 +373,14 @@ export function computePyDepFindings({ externalImports = [], pyManifest = null,
297
373
  severity: present ? (testOnly ? "low" : "medium") : "low",
298
374
  confidence: present ? "medium" : "low",
299
375
  title: `Missing Python dependency: ${u.dist}`,
300
- detail: `"${top}" is imported by ${files.length} file(s) but ${present ? "no declared dependency provides it" : "the repo declares no Python dependencies at all (no requirements.txt / pyproject / Pipfile)"} it only works because the environment happens to have it${u.dist !== top ? ` (PyPI package is likely "${u.dist}")` : ""}.`,
376
+ detail: `"${top}" is imported by ${files.length} file(s) but ${present ? "no declared dependency provides it" : "the repo has no Python dependency manifest (requirements.txt / pyproject / Pipfile); a bundled or managed runtime may provide it"}${u.dist !== top ? ` (PyPI package is likely "${u.dist}")` : ""}.${present ? "" : " If this is intentional, declare it under python.managedDependencies in .weavatrix-deps.json."}`,
301
377
  package: u.dist,
302
378
  file: files[0],
303
379
  line: u.lines.get(files[0]) || 0,
304
380
  evidence: files.slice(0, 5).map((f) => ({ file: f, line: u.lines.get(f) || 0, snippet: "" })),
305
381
  source: "internal",
306
- fixHint: `pip install ${u.dist} (and add it to requirements.txt / pyproject)`,
382
+ fixHint: present ? `pip install ${u.dist} (and add it to requirements.txt / pyproject)` : `add ${u.dist} to a Python manifest, or declare it as a managed runtime dependency`,
307
383
  }));
308
384
  }
309
- return { findings, declared };
385
+ return { findings, declared, managed };
310
386
  }
@@ -18,21 +18,44 @@ const dirOf = (f) => (f.includes("/") ? f.slice(0, f.lastIndexOf("/")) : "");
18
18
 
19
19
  // File-level import adjacency. Go same-directory edges are excluded: a Go package IS the directory, the
20
20
  // compiler forbids real cross-package cycles, and our suffix-fallback Go resolution could fake them.
21
- export function buildFileImportGraph(graph) {
21
+ export function buildFileImportGraph(graph, { includeTypeOnly = false } = {}) {
22
22
  const fileIds = new Set();
23
23
  for (const n of graph.nodes || []) if (!String(n.id).includes("#")) fileIds.add(String(n.id));
24
- const adj = new Map();
24
+ const runtimeAdj = new Map(); // runtime/value imports only
25
+ const allAdj = new Map(); // runtime + type-only, used to describe architectural coupling
25
26
  const edges = [];
27
+ const allEdges = [];
28
+ const typeOnlyEdges = [];
29
+ const runtimeSeen = new Set(), allSeen = new Set(), typeSeen = new Set();
30
+ const add = (map, a, b) => {
31
+ let set = map.get(a);
32
+ if (!set) map.set(a, (set = new Set()));
33
+ set.add(b);
34
+ };
26
35
  for (const l of graph.links || []) {
27
36
  if (l.relation !== "imports" && l.relation !== "re_exports") continue;
28
37
  const a = ep(l.source), b = ep(l.target);
29
38
  if (!fileIds.has(a) || !fileIds.has(b) || a === b) continue;
30
39
  if (a.endsWith(".go") && b.endsWith(".go") && dirOf(a) === dirOf(b)) continue;
31
- let set = adj.get(a);
32
- if (!set) adj.set(a, (set = new Set()));
33
- if (!set.has(b)) { set.add(b); edges.push([a, b]); }
40
+ const key = `${a}\0${b}`;
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]); }
44
+ continue;
45
+ }
46
+ if (!runtimeSeen.has(key)) { runtimeSeen.add(key); add(runtimeAdj, a, b); edges.push([a, b]); }
34
47
  }
35
- return { fileIds, adj, edges };
48
+ const pureTypeOnlyEdges = typeOnlyEdges.filter(([a, b]) => !runtimeSeen.has(`${a}\0${b}`));
49
+ return {
50
+ fileIds,
51
+ adj: includeTypeOnly ? allAdj : runtimeAdj,
52
+ edges: includeTypeOnly ? allEdges : edges,
53
+ runtimeAdj,
54
+ runtimeEdges: edges,
55
+ allAdj,
56
+ allEdges,
57
+ typeOnlyEdges: pureTypeOnlyEdges,
58
+ };
36
59
  }
37
60
 
38
61
  // Iterative Tarjan (deep graphs must not blow the JS stack). Returns only non-trivial SCCs (size > 1).
@@ -147,7 +170,7 @@ const MAX_BOUNDARY_FINDINGS = 100;
147
170
 
148
171
  // Assemble everything into unified Findings. rules comes from the repo's .weavatrix-deps.json (optional).
149
172
  export function computeStructureFindings(graph, { rules = {}, entrySet = new Set(), externalImportFiles = new Set() } = {}) {
150
- const { adj, edges } = buildFileImportGraph(graph);
173
+ const { adj, edges, allAdj, allEdges, typeOnlyEdges } = buildFileImportGraph(graph);
151
174
  const findings = [];
152
175
 
153
176
  const sccs = findSccs(adj).sort((a, b) => b.length - a.length);
@@ -167,6 +190,36 @@ export function computeStructureFindings(graph, { rules = {}, entrySet = new Set
167
190
  fixHint: "extract the shared code into a module both sides import, or invert the weaker dependency",
168
191
  }));
169
192
  }
193
+
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.
197
+ const runtimeKeys = new Set(sccs.map((s) => [...s].sort().join("\0")));
198
+ const allSccs = findSccs(allAdj).sort((a, b) => b.length - a.length);
199
+ const typeCouplings = allSccs.filter((s) => !runtimeKeys.has([...s].sort().join("\0")));
200
+ const edgeCountIn = (scc, list) => {
201
+ const inside = new Set(scc);
202
+ return list.reduce((n, [a, b]) => n + (inside.has(a) && inside.has(b) ? 1 : 0), 0);
203
+ };
204
+ for (const scc of typeCouplings.slice(0, MAX_CYCLE_FINDINGS)) {
205
+ const cycle = representativeCycle(allAdj, scc);
206
+ const runtimeInside = edgeCountIn(scc, edges);
207
+ const typeInside = edgeCountIn(scc, typeOnlyEdges);
208
+ const containsRuntimeCycle = sccs.some((runtime) => runtime.every((f) => scc.includes(f)));
209
+ findings.push(makeFinding({
210
+ category: "structure",
211
+ rule: "type-coupling",
212
+ severity: "info",
213
+ 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.`,
216
+ file: cycle[0],
217
+ graphNodeId: cycle[0],
218
+ evidence: cycle.map((f) => ({ file: f, line: 0, snippet: "" })),
219
+ source: "internal",
220
+ fixHint: "review the shared type ownership only if the coupling impedes changes; no runtime-cycle fix is required",
221
+ }));
222
+ }
170
223
  if (sccs.length > MAX_CYCLE_FINDINGS) {
171
224
  findings.push(makeFinding({
172
225
  category: "structure", rule: "circular-dep", severity: "info", confidence: "high",
@@ -190,6 +243,8 @@ export function computeStructureFindings(graph, { rules = {}, entrySet = new Set
190
243
  }));
191
244
  }
192
245
 
246
+ // Architecture boundaries describe executable dependencies by default. Type-only contract sharing is
247
+ // visible in typeCouplings above, but must not be presented as a runtime layer violation.
193
248
  const violations = checkBoundaries(edges, rules);
194
249
  for (const v of violations.slice(0, MAX_BOUNDARY_FINDINGS)) {
195
250
  findings.push(makeFinding({
@@ -216,6 +271,17 @@ export function computeStructureFindings(graph, { rules = {}, entrySet = new Set
216
271
 
217
272
  return {
218
273
  findings,
219
- stats: { importEdges: edges.length, cycles: sccs.length, largestCycle: sccs[0]?.length || 0, orphans: findings.filter((f) => f.rule === "orphan-file").length, boundaryViolations: violations.length },
274
+ stats: {
275
+ importEdges: allEdges.length,
276
+ runtimeImportEdges: edges.length,
277
+ typeOnlyImportEdges: typeOnlyEdges.length,
278
+ cycles: sccs.length,
279
+ runtimeCycles: sccs.length,
280
+ largestCycle: sccs[0]?.length || 0,
281
+ typeCouplings: typeCouplings.length,
282
+ largestTypeCoupling: typeCouplings[0]?.length || 0,
283
+ orphans: findings.filter((f) => f.rule === "orphan-file").length,
284
+ boundaryViolations: violations.length,
285
+ },
220
286
  };
221
287
  }