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.
- package/README.md +117 -21
- package/SECURITY.md +31 -0
- package/package.json +16 -4
- package/skill/SKILL.md +69 -12
- package/src/analysis/coverage-reports.js +14 -11
- package/src/analysis/dead-check.js +87 -6
- package/src/analysis/dep-check.js +84 -8
- package/src/analysis/dep-rules.js +74 -8
- package/src/analysis/duplicates.compute.js +215 -215
- package/src/analysis/duplicates.js +15 -15
- package/src/analysis/duplicates.run.js +52 -51
- package/src/analysis/duplicates.tokenize.js +182 -182
- package/src/analysis/endpoints.js +50 -5
- package/src/analysis/graph-analysis.aggregate.js +16 -4
- package/src/analysis/internal-audit.collect.js +154 -31
- package/src/analysis/internal-audit.reach.js +52 -11
- package/src/analysis/internal-audit.run.js +72 -21
- package/src/build-graph.js +2 -1
- package/src/child-env.js +7 -0
- package/src/graph/builder/lang-js.js +66 -23
- package/src/graph/internal-builder.build.js +48 -10
- package/src/graph/internal-builder.langs.js +49 -3
- package/src/graph/internal-builder.resolvers.js +96 -20
- package/src/mcp/catalog.mjs +10 -8
- package/src/mcp/graph-context.mjs +107 -17
- package/src/mcp/sync-payload.mjs +110 -0
- package/src/mcp/tools-actions.mjs +42 -18
- package/src/mcp/tools-graph.mjs +46 -12
- package/src/mcp/tools-health.mjs +42 -18
- package/src/mcp/tools-impact.mjs +55 -43
- package/src/mcp-rg.mjs +2 -1
- package/src/mcp-server.mjs +25 -16
- package/src/mcp-source-tools.mjs +16 -5
- package/src/process.js +4 -3
- package/src/repo-path.js +53 -0
- package/src/security/advisory-store.js +51 -7
- package/src/security/installed.js +44 -31
- package/src/security/malware-heuristics.exclusions.js +134 -134
- package/src/security/malware-heuristics.js +15 -15
- package/src/security/malware-heuristics.roots.js +142 -142
- package/src/security/malware-heuristics.scan.js +85 -85
- 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({
|
|
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
|
|
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({
|
|
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
|
|
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
|
|
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
|
-
|
|
32
|
-
if (!
|
|
33
|
-
if (
|
|
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
|
-
|
|
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: {
|
|
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
|
}
|