weavatrix 0.1.2 → 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 +92 -17
- package/package.json +1 -1
- package/skill/SKILL.md +62 -4
- package/src/analysis/dead-check.js +87 -6
- package/src/analysis/dep-check-ecosystems.js +157 -0
- package/src/analysis/dep-check.js +97 -133
- package/src/analysis/dep-rules.js +91 -12
- package/src/analysis/duplicates.compute.js +12 -18
- package/src/analysis/endpoints-rust.js +124 -0
- package/src/analysis/endpoints.js +56 -6
- package/src/analysis/graph-analysis.aggregate.js +34 -25
- 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 +162 -25
- package/src/analysis/internal-audit.reach.js +52 -11
- package/src/analysis/internal-audit.run.js +71 -18
- package/src/graph/builder/lang-java.js +177 -14
- package/src/graph/builder/lang-js.js +66 -23
- package/src/graph/builder/lang-rust.js +129 -6
- package/src/graph/community.js +17 -0
- package/src/graph/internal-builder.build.js +79 -17
- package/src/graph/internal-builder.java.js +33 -0
- package/src/graph/internal-builder.langs.js +43 -2
- package/src/graph/internal-builder.resolvers.js +197 -21
- package/src/graph/relations.js +4 -0
- package/src/mcp/catalog.mjs +4 -4
- package/src/mcp/graph-context.mjs +22 -94
- package/src/mcp/graph-diff.mjs +163 -0
- package/src/mcp/sync-payload.mjs +36 -6
- package/src/mcp/tools-actions.mjs +22 -7
- package/src/mcp/tools-graph-hubs.mjs +58 -0
- package/src/mcp/tools-graph.mjs +13 -22
- package/src/mcp/tools-health.mjs +54 -18
- package/src/mcp/tools-impact.mjs +61 -45
- package/src/mcp-server.mjs +3 -0
- package/src/security/advisory-store.js +51 -7
|
@@ -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", nonRuntimeRoots = [],
|
|
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,46 @@ 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
|
+
const normRoots = (nonRuntimeRoots || []).map((root) => String(root || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+|\/+$/g, "")).filter(Boolean);
|
|
72
|
+
const isNonRuntimeFile = (file) => {
|
|
73
|
+
const normalized = String(file || "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
74
|
+
return normRoots.some((root) => normalized === root || normalized.startsWith(`${root}/`));
|
|
75
|
+
};
|
|
76
|
+
const npmNameParts = (name) => {
|
|
77
|
+
const value = String(name || "");
|
|
78
|
+
const slash = value.startsWith("@") ? value.indexOf("/") : -1;
|
|
79
|
+
return slash > 0 ? { scope: value.slice(0, slash), base: value.slice(slash + 1) } : { scope: "", base: value };
|
|
80
|
+
};
|
|
81
|
+
const selfParts = npmNameParts(selfName);
|
|
82
|
+
const configuredNapi = npmNameParts(pkg.napi?.name || selfName);
|
|
83
|
+
const napiBinding = {
|
|
84
|
+
scope: configuredNapi.scope || (configuredNapi.base === selfParts.base ? selfParts.scope : ""),
|
|
85
|
+
base: configuredNapi.base,
|
|
86
|
+
};
|
|
87
|
+
const NAPI_PLATFORM_SUFFIX = /^(?:android-(?:arm64|arm-eabi)|win32-(?:x64|ia32|arm64)-msvc|darwin-(?:universal|x64|arm64)|freebsd-(?:x64|arm64)|linux-(?:x64|arm64|arm|riscv64|s390x|ppc64|loong64)-(?:gnu|musl|gnueabihf|musleabihf)|aix-ppc64|sunos-x64|wasm32-wasi(?:-preview1-threads)?)$/;
|
|
88
|
+
const isGeneratedNapiPackage = (name, files) => {
|
|
89
|
+
if (!pkg.napi || !napiBinding.base || !files.every((file) => /(^|\/)index\.[cm]?js$/i.test(String(file || "").replace(/\\/g, "/")))) return false;
|
|
90
|
+
const candidate = npmNameParts(name);
|
|
91
|
+
if (candidate.scope !== napiBinding.scope || !candidate.base.startsWith(`${napiBinding.base}-`)) return false;
|
|
92
|
+
return NAPI_PLATFORM_SUFFIX.test(candidate.base.slice(napiBinding.base.length + 1));
|
|
93
|
+
};
|
|
94
|
+
const isGeneratedNapiBinary = (entry) => !!pkg.napi
|
|
95
|
+
&& /(^|\/)index\.[cm]?js$/i.test(String(entry.file || "").replace(/\\/g, "/"))
|
|
96
|
+
&& /^\.\/[^/]+\.node$/i.test(String(entry.spec || ""));
|
|
97
|
+
// Some frameworks consume required peers internally, so application source legitimately never imports
|
|
98
|
+
// them. Keep this deliberately narrow: react-dom is a required Next.js runtime peer in the same package
|
|
99
|
+
// scope, not a general React exemption and not a repo-wide whitelist.
|
|
100
|
+
const frameworkRuntime = new Set();
|
|
101
|
+
for (const [provider, peers] of FRAMEWORK_RUNTIME_PEERS) {
|
|
102
|
+
if (allDeclared.has(provider)) for (const peer of peers) frameworkRuntime.add(peer);
|
|
103
|
+
}
|
|
48
104
|
|
|
49
105
|
// ---- usage index from the graph's recorded imports ----
|
|
50
106
|
const usedPackages = new Map(); // pkgName -> { files:Set, lines:Map(file→first line), kinds:Set }
|
|
51
107
|
let builtinUsed = false;
|
|
52
108
|
for (const e of externalImports) {
|
|
53
109
|
if (e.ecosystem && e.ecosystem !== "npm") continue; // go/python imports have their own checkers
|
|
110
|
+
if (isAliasSpec(e.spec)) continue;
|
|
54
111
|
if (e.unresolved) continue;
|
|
55
112
|
if (e.builtin) { builtinUsed = true; continue; }
|
|
56
113
|
if (!e.pkg) continue;
|
|
@@ -75,10 +132,10 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
|
|
|
75
132
|
for (const [section, deps] of Object.entries(sections)) {
|
|
76
133
|
if (section === "peerDependencies") continue; // peers are consumer-facing contracts, not usage
|
|
77
134
|
for (const name of Object.keys(deps)) {
|
|
78
|
-
if (name === selfName || usedPackages.has(name)) continue;
|
|
135
|
+
if (name === selfName || usedPackages.has(name) || frameworkRuntime.has(name)) continue;
|
|
79
136
|
const tb = typesBase(name);
|
|
80
137
|
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;
|
|
138
|
+
if (tb === "node" ? builtinUsed : usedPackages.has(tb) || frameworkRuntime.has(tb) || mentioned(configBlob, tb)) continue;
|
|
82
139
|
}
|
|
83
140
|
if (binReferenced(name) || mentioned(configBlob, name)) continue; // scripts/config keep it alive
|
|
84
141
|
const ecosystem = CONFIG_ECOSYSTEM_RE.test(name);
|
|
@@ -94,6 +151,7 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
|
|
|
94
151
|
package: name,
|
|
95
152
|
source: "internal",
|
|
96
153
|
fixHint: `npm uninstall ${name} (after confirming no config/CLI usage)`,
|
|
154
|
+
...meta,
|
|
97
155
|
}));
|
|
98
156
|
}
|
|
99
157
|
}
|
|
@@ -102,6 +160,7 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
|
|
|
102
160
|
for (const [name, use] of usedPackages) {
|
|
103
161
|
if (allDeclared.has(name) || name === selfName || workspacePkgNames.has(name)) continue;
|
|
104
162
|
const files = [...use.files];
|
|
163
|
+
if (files.every(isNonRuntimeFile) || isGeneratedNapiPackage(name, files)) continue;
|
|
105
164
|
const testOnly = files.every((f) => /(^|[/\\])(test|tests|__tests__|spec|e2e|__mocks__)([/\\]|$)|[._-](test|spec)\.[a-z]+$/i.test(f));
|
|
106
165
|
findings.push(makeFinding({
|
|
107
166
|
category: "unused",
|
|
@@ -109,13 +168,14 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
|
|
|
109
168
|
severity: testOnly ? "low" : "medium",
|
|
110
169
|
confidence: "high",
|
|
111
170
|
title: `Missing dependency: ${name}`,
|
|
112
|
-
detail: `"${name}" is imported by ${files.length} file(s) but not declared in
|
|
171
|
+
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
172
|
package: name,
|
|
114
173
|
file: files[0],
|
|
115
174
|
line: use.lines.get(files[0]) || 0,
|
|
116
175
|
evidence: files.slice(0, 5).map((f) => ({ file: f, line: use.lines.get(f) || 0, snippet: "" })),
|
|
117
176
|
source: "internal",
|
|
118
177
|
fixHint: `npm install ${testOnly ? "--save-dev " : ""}${name}`,
|
|
178
|
+
...meta,
|
|
119
179
|
}));
|
|
120
180
|
}
|
|
121
181
|
|
|
@@ -134,6 +194,7 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
|
|
|
134
194
|
detail: `"${name}" is declared in ${ss.join(" + ")} — npm resolves one of them; keep a single section.`,
|
|
135
195
|
package: name,
|
|
136
196
|
source: "internal",
|
|
197
|
+
...meta,
|
|
137
198
|
}));
|
|
138
199
|
}
|
|
139
200
|
|
|
@@ -142,6 +203,7 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
|
|
|
142
203
|
let unresolvedCount = 0;
|
|
143
204
|
for (const e of externalImports) {
|
|
144
205
|
if (!e.unresolved) continue;
|
|
206
|
+
if (isNonRuntimeFile(e.file) || isGeneratedNapiBinary(e)) continue;
|
|
145
207
|
unresolvedCount++;
|
|
146
208
|
const key = e.file + "|" + e.spec;
|
|
147
209
|
if (unresolvedSeen.has(key) || unresolvedSeen.size >= 100) continue; // cap the findings, keep the count honest
|
|
@@ -156,6 +218,7 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
|
|
|
156
218
|
file: e.file,
|
|
157
219
|
line: e.line || 0,
|
|
158
220
|
source: "internal",
|
|
221
|
+
...meta,
|
|
159
222
|
}));
|
|
160
223
|
}
|
|
161
224
|
if (unresolvedCount > unresolvedSeen.size) {
|
|
@@ -167,144 +230,45 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
|
|
|
167
230
|
title: `…and ${unresolvedCount - unresolvedSeen.size} more unresolved imports`,
|
|
168
231
|
detail: "Finding list capped at 100 unique unresolved imports; the count above is the true total.",
|
|
169
232
|
source: "internal",
|
|
233
|
+
...meta,
|
|
170
234
|
}));
|
|
171
235
|
}
|
|
172
236
|
|
|
173
237
|
return { findings, usedPackages, declared: allDeclared };
|
|
174
238
|
}
|
|
175
239
|
|
|
176
|
-
const
|
|
240
|
+
const normScope = (root) => String(root || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+|\/+$/g, "");
|
|
241
|
+
const ownsFile = (scope, file) => !scope || file === scope || String(file || "").startsWith(`${scope}/`);
|
|
177
242
|
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
const moduleOf = (spec) => { let best = ""; for (const p of declared) if ((spec === p || spec.startsWith(p + "/")) && p.length > best.length) best = p; return best; };
|
|
189
|
-
|
|
190
|
-
const usedModules = new Set();
|
|
191
|
-
const missing = new Map(); // pkg → { files:Set, lines:Map }
|
|
243
|
+
// Judge every import against its nearest ancestor package.json. This is the dependency equivalent of
|
|
244
|
+
// Node's package scope and prevents nested Next/Vite apps from inheriting the root manifest by accident.
|
|
245
|
+
export function computeScopedDepFindings({
|
|
246
|
+
externalImports = [], packageScopes = [], workspacePkgNames = new Set(), configTexts = new Map(),
|
|
247
|
+
nonRuntimeRoots = [],
|
|
248
|
+
} = {}) {
|
|
249
|
+
const scopes = packageScopes.length
|
|
250
|
+
? packageScopes.map((s) => ({ ...s, root: normScope(s.root) })).sort((a, b) => b.root.length - a.root.length)
|
|
251
|
+
: [{ root: "", manifest: "package.json", pkg: {}, aliases: [] }];
|
|
252
|
+
const importsByScope = new Map(scopes.map((s) => [s, []]));
|
|
192
253
|
for (const e of externalImports) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (mod) { usedModules.add(mod); continue; }
|
|
196
|
-
let m = missing.get(e.pkg);
|
|
197
|
-
if (!m) missing.set(e.pkg, (m = { files: new Set(), lines: new Map() }));
|
|
198
|
-
m.files.add(e.file);
|
|
199
|
-
if (!m.lines.has(e.file)) m.lines.set(e.file, e.line || 0);
|
|
254
|
+
const owner = scopes.find((s) => ownsFile(s.root, e.file)) || scopes[scopes.length - 1];
|
|
255
|
+
importsByScope.get(owner).push(e);
|
|
200
256
|
}
|
|
201
|
-
|
|
202
|
-
for (const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
fixHint: "go mod tidy (drops requires nothing imports)",
|
|
215
|
-
}));
|
|
216
|
-
}
|
|
217
|
-
for (const [pkg, use] of missing) {
|
|
218
|
-
if (replaced.has(pkg)) continue;
|
|
219
|
-
const files = [...use.files];
|
|
220
|
-
findings.push(makeFinding({
|
|
221
|
-
category: "unused",
|
|
222
|
-
rule: "missing-dep",
|
|
223
|
-
severity: "medium",
|
|
224
|
-
confidence: "medium",
|
|
225
|
-
title: `Missing Go module: ${pkg}`,
|
|
226
|
-
detail: `"${pkg}" is imported by ${files.length} file(s) but go.mod has no matching require — a replace/workspace/vendor setup, or the module was never added.`,
|
|
227
|
-
package: pkg,
|
|
228
|
-
file: files[0],
|
|
229
|
-
line: use.lines.get(files[0]) || 0,
|
|
230
|
-
evidence: files.slice(0, 5).map((f) => ({ file: f, line: use.lines.get(f) || 0, snippet: "" })),
|
|
231
|
-
source: "internal",
|
|
232
|
-
fixHint: `go get ${pkg}`,
|
|
233
|
-
}));
|
|
257
|
+
const configOwner = new Map();
|
|
258
|
+
for (const [file] of configTexts) configOwner.set(file, scopes.find((scope) => ownsFile(scope.root, file)) || scopes[scopes.length - 1]);
|
|
259
|
+
const findings = [], usedPackages = new Map(), declared = new Set();
|
|
260
|
+
for (const s of scopes) {
|
|
261
|
+
const scopeConfig = new Map([...configTexts].filter(([f]) => configOwner.get(f) === s));
|
|
262
|
+
const r = computeDepFindings({
|
|
263
|
+
externalImports: importsByScope.get(s), pkg: s.pkg || {}, workspacePkgNames, configTexts: scopeConfig,
|
|
264
|
+
aliases: s.aliases || [], scope: s.root, manifest: s.manifest || (s.root ? `${s.root}/package.json` : "package.json"),
|
|
265
|
+
nonRuntimeRoots,
|
|
266
|
+
});
|
|
267
|
+
findings.push(...r.findings);
|
|
268
|
+
for (const [name, use] of r.usedPackages) usedPackages.set(`${s.root || "."}:${name}`, use);
|
|
269
|
+
for (const name of r.declared) declared.add(`${s.root || "."}:${name}`);
|
|
234
270
|
}
|
|
235
|
-
return { findings, declared };
|
|
271
|
+
return { findings, usedPackages, declared };
|
|
236
272
|
}
|
|
237
273
|
|
|
238
|
-
|
|
239
|
-
// Import→dist naming is heuristic (yaml→PyYAML, python-X/X-python variants), so matching is GENEROUS for
|
|
240
|
-
// suppression and every unused finding stays low-confidence. CLI-only tools (pytest, black, gunicorn …)
|
|
241
|
-
// and stub/plugin conventions (types-*, *-stubs, pytest-*, flake8-*) are never flagged unused.
|
|
242
|
-
const PY_TOOL_DISTS = new Set(("pytest tox nox black ruff flake8 pylint mypy pyright isort bandit coverage pre-commit pip setuptools wheel build twine poetry poetry-core " +
|
|
243
|
-
"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
|
-
const pyNorm = (n) => String(n || "").toLowerCase().replace(/[-_.]+/g, "-");
|
|
245
|
-
|
|
246
|
-
export function computePyDepFindings({ externalImports = [], pyManifest = null, configTexts = new Map() } = {}) {
|
|
247
|
-
const findings = [];
|
|
248
|
-
const deps = (pyManifest && pyManifest.deps) || [];
|
|
249
|
-
const present = !!(pyManifest && pyManifest.present);
|
|
250
|
-
const declared = new Set(deps.map((d) => pyNorm(d.name)));
|
|
251
|
-
|
|
252
|
-
const used = new Map(); // top import → { dist, files:Set, lines:Map }
|
|
253
|
-
for (const e of externalImports) {
|
|
254
|
-
if (e.ecosystem !== "PyPI" || e.builtin || e.unresolved || !e.pkg) continue;
|
|
255
|
-
const top = String(e.spec || e.pkg).split(".")[0];
|
|
256
|
-
let u = used.get(top);
|
|
257
|
-
if (!u) used.set(top, (u = { dist: e.pkg, files: new Set(), lines: new Map() }));
|
|
258
|
-
u.files.add(e.file);
|
|
259
|
-
if (!u.lines.has(e.file)) u.lines.set(e.file, e.line || 0);
|
|
260
|
-
}
|
|
261
|
-
// generous match: does declared dist D cover import top t (dist guess g)?
|
|
262
|
-
const covers = (D, t, g) => {
|
|
263
|
-
const d = pyNorm(D), nt = pyNorm(t), ng = pyNorm(g);
|
|
264
|
-
return d === nt || d === ng || d === `python-${nt}` || d === `${nt}-python` || d === `${nt}-binary` || d.replace(/\d+$/, "") === nt.replace(/\d+$/, "");
|
|
265
|
-
};
|
|
266
|
-
|
|
267
|
-
const configBlob = [...configTexts.values()].join("\n");
|
|
268
|
-
if (present) {
|
|
269
|
-
for (const d of deps) {
|
|
270
|
-
const n = pyNorm(d.name);
|
|
271
|
-
if (d.buildSystem || PY_TOOL_DISTS.has(n) || /^types-|-stubs$|^pytest-|^flake8-|^sphinx/.test(n)) continue;
|
|
272
|
-
let hit = false;
|
|
273
|
-
for (const [top, u] of used) if (covers(d.name, top, u.dist)) { hit = true; break; }
|
|
274
|
-
if (hit || mentioned(configBlob, d.name)) continue;
|
|
275
|
-
findings.push(makeFinding({
|
|
276
|
-
category: "unused",
|
|
277
|
-
rule: "unused-dep",
|
|
278
|
-
severity: d.dev ? "info" : "low",
|
|
279
|
-
confidence: "low",
|
|
280
|
-
title: `Unused Python dependency: ${d.name}`,
|
|
281
|
-
detail: `"${d.name}" is declared but no .py file imports a module that maps to it. Import-name↔package-name mapping is heuristic and plugins/CLI tools load dynamically — review before removing.`,
|
|
282
|
-
package: d.name,
|
|
283
|
-
source: "internal",
|
|
284
|
-
fixHint: `remove "${d.name}" from the manifest after confirming nothing imports or shells out to it`,
|
|
285
|
-
}));
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
for (const [top, u] of used) {
|
|
289
|
-
let hit = false;
|
|
290
|
-
for (const D of declared) if (covers(D, top, u.dist)) { hit = true; break; }
|
|
291
|
-
if (hit) continue;
|
|
292
|
-
const files = [...u.files];
|
|
293
|
-
const testOnly = files.every((f) => TEST_PATH_RE.test(f));
|
|
294
|
-
findings.push(makeFinding({
|
|
295
|
-
category: "unused",
|
|
296
|
-
rule: "missing-dep",
|
|
297
|
-
severity: present ? (testOnly ? "low" : "medium") : "low",
|
|
298
|
-
confidence: present ? "medium" : "low",
|
|
299
|
-
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}")` : ""}.`,
|
|
301
|
-
package: u.dist,
|
|
302
|
-
file: files[0],
|
|
303
|
-
line: u.lines.get(files[0]) || 0,
|
|
304
|
-
evidence: files.slice(0, 5).map((f) => ({ file: f, line: u.lines.get(f) || 0, snippet: "" })),
|
|
305
|
-
source: "internal",
|
|
306
|
-
fixHint: `pip install ${u.dist} (and add it to requirements.txt / pyproject)`,
|
|
307
|
-
}));
|
|
308
|
-
}
|
|
309
|
-
return { findings, declared };
|
|
310
|
-
}
|
|
274
|
+
export { computeGoDepFindings, computePyDepFindings } from "./dep-check-ecosystems.js";
|
|
@@ -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,24 +14,54 @@ 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) {
|
|
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
|
-
const
|
|
22
|
+
const runtimeAdj = new Map(); // runtime/value imports only
|
|
23
|
+
const allAdj = new Map(); // runtime + compile-time-only, used to describe architectural coupling
|
|
25
24
|
const edges = [];
|
|
25
|
+
const allEdges = [];
|
|
26
|
+
const typeOnlyEdges = [];
|
|
27
|
+
const compileOnlyEdges = [];
|
|
28
|
+
const compileTimeEdges = [];
|
|
29
|
+
const runtimeSeen = new Set(), allSeen = new Set(), typeSeen = new Set(), compileSeen = new Set(), compileTimeSeen = 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 || 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]); }
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!runtimeSeen.has(key)) { runtimeSeen.add(key); add(runtimeAdj, a, b); edges.push([a, b]); }
|
|
34
49
|
}
|
|
35
|
-
|
|
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}`));
|
|
53
|
+
return {
|
|
54
|
+
fileIds,
|
|
55
|
+
adj: includeTypeOnly || includeCompileOnly ? allAdj : runtimeAdj,
|
|
56
|
+
edges: includeTypeOnly || includeCompileOnly ? allEdges : edges,
|
|
57
|
+
runtimeAdj,
|
|
58
|
+
runtimeEdges: edges,
|
|
59
|
+
allAdj,
|
|
60
|
+
allEdges,
|
|
61
|
+
typeOnlyEdges: pureTypeOnlyEdges,
|
|
62
|
+
compileOnlyEdges: pureCompileOnlyEdges,
|
|
63
|
+
compileTimeEdges: pureCompileTimeEdges,
|
|
64
|
+
};
|
|
36
65
|
}
|
|
37
66
|
|
|
38
67
|
// Iterative Tarjan (deep graphs must not blow the JS stack). Returns only non-trivial SCCs (size > 1).
|
|
@@ -144,12 +173,10 @@ export function checkBoundaries(edges, rules = {}) {
|
|
|
144
173
|
|
|
145
174
|
const MAX_CYCLE_FINDINGS = 50;
|
|
146
175
|
const MAX_BOUNDARY_FINDINGS = 100;
|
|
147
|
-
|
|
148
176
|
// Assemble everything into unified Findings. rules comes from the repo's .weavatrix-deps.json (optional).
|
|
149
177
|
export function computeStructureFindings(graph, { rules = {}, entrySet = new Set(), externalImportFiles = new Set() } = {}) {
|
|
150
|
-
const { adj, edges } = buildFileImportGraph(graph);
|
|
178
|
+
const { adj, edges, allAdj, allEdges, typeOnlyEdges, compileOnlyEdges, compileTimeEdges } = buildFileImportGraph(graph);
|
|
151
179
|
const findings = [];
|
|
152
|
-
|
|
153
180
|
const sccs = findSccs(adj).sort((a, b) => b.length - a.length);
|
|
154
181
|
for (const scc of sccs.slice(0, MAX_CYCLE_FINDINGS)) {
|
|
155
182
|
const cycle = representativeCycle(adj, scc);
|
|
@@ -167,6 +194,40 @@ export function computeStructureFindings(graph, { rules = {}, entrySet = new Set
|
|
|
167
194
|
fixHint: "extract the shared code into a module both sides import, or invert the weaker dependency",
|
|
168
195
|
}));
|
|
169
196
|
}
|
|
197
|
+
|
|
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.
|
|
201
|
+
const runtimeKeys = new Set(sccs.map((s) => [...s].sort().join("\0")));
|
|
202
|
+
const allSccs = findSccs(allAdj).sort((a, b) => b.length - a.length);
|
|
203
|
+
const compileTimeCouplings = allSccs.filter((s) => !runtimeKeys.has([...s].sort().join("\0")));
|
|
204
|
+
const edgeCountIn = (scc, list) => {
|
|
205
|
+
const inside = new Set(scc);
|
|
206
|
+
return list.reduce((n, [a, b]) => n + (inside.has(a) && inside.has(b) ? 1 : 0), 0);
|
|
207
|
+
};
|
|
208
|
+
for (const scc of compileTimeCouplings.slice(0, MAX_CYCLE_FINDINGS)) {
|
|
209
|
+
const cycle = representativeCycle(allAdj, scc);
|
|
210
|
+
const runtimeInside = edgeCountIn(scc, edges);
|
|
211
|
+
const typeInside = edgeCountIn(scc, typeOnlyEdges);
|
|
212
|
+
const compileInside = edgeCountIn(scc, compileOnlyEdges);
|
|
213
|
+
const containsRuntimeCycle = sccs.some((runtime) => runtime.every((f) => scc.includes(f)));
|
|
214
|
+
const typeSpecific = compileInside === 0;
|
|
215
|
+
findings.push(makeFinding({
|
|
216
|
+
category: "structure",
|
|
217
|
+
rule: typeSpecific ? "type-coupling" : "compile-time-coupling",
|
|
218
|
+
severity: "info",
|
|
219
|
+
confidence: "high",
|
|
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.`,
|
|
224
|
+
file: cycle[0],
|
|
225
|
+
graphNodeId: cycle[0],
|
|
226
|
+
evidence: cycle.map((f) => ({ file: f, line: 0, snippet: "" })),
|
|
227
|
+
source: "internal",
|
|
228
|
+
fixHint: "review the compile-time ownership only if the coupling impedes changes; no runtime-cycle fix is required",
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
170
231
|
if (sccs.length > MAX_CYCLE_FINDINGS) {
|
|
171
232
|
findings.push(makeFinding({
|
|
172
233
|
category: "structure", rule: "circular-dep", severity: "info", confidence: "high",
|
|
@@ -190,6 +251,8 @@ export function computeStructureFindings(graph, { rules = {}, entrySet = new Set
|
|
|
190
251
|
}));
|
|
191
252
|
}
|
|
192
253
|
|
|
254
|
+
// Architecture boundaries describe executable dependencies by default. Type-only contract sharing is
|
|
255
|
+
// visible in typeCouplings above, but must not be presented as a runtime layer violation.
|
|
193
256
|
const violations = checkBoundaries(edges, rules);
|
|
194
257
|
for (const v of violations.slice(0, MAX_BOUNDARY_FINDINGS)) {
|
|
195
258
|
findings.push(makeFinding({
|
|
@@ -216,6 +279,22 @@ export function computeStructureFindings(graph, { rules = {}, entrySet = new Set
|
|
|
216
279
|
|
|
217
280
|
return {
|
|
218
281
|
findings,
|
|
219
|
-
stats: {
|
|
282
|
+
stats: {
|
|
283
|
+
importEdges: allEdges.length,
|
|
284
|
+
runtimeImportEdges: edges.length,
|
|
285
|
+
typeOnlyImportEdges: typeOnlyEdges.length,
|
|
286
|
+
compileOnlyImportEdges: compileOnlyEdges.length,
|
|
287
|
+
compileTimeImportEdges: compileTimeEdges.length,
|
|
288
|
+
cycles: sccs.length,
|
|
289
|
+
runtimeCycles: sccs.length,
|
|
290
|
+
largestCycle: sccs[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,
|
|
296
|
+
orphans: findings.filter((f) => f.rule === "orphan-file").length,
|
|
297
|
+
boundaryViolations: violations.length,
|
|
298
|
+
},
|
|
220
299
|
};
|
|
221
300
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// duplicates.compute.js — fragment extraction, inverted-index pairing, and the computeDuplicates
|
|
2
2
|
// pipeline (split from duplicates.js; see the facade there for the full algorithm overview).
|
|
3
|
-
import { readFileSync
|
|
4
|
-
import { join, extname } from "node:path";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
5
4
|
import { isTestPath } from "../graph/graph-filter.js";
|
|
6
5
|
import { createRepoBoundary } from "../repo-path.js";
|
|
6
|
+
import { listRepoFiles } from "./internal-audit.collect.js";
|
|
7
7
|
import { stripNonCode, bodyEndLineCount, tokenize, fingerprints, extractLargeStrings } from "./duplicates.tokenize.js";
|
|
8
8
|
|
|
9
9
|
const FLOOR_TOKENS = 30; // fragments below this never enter the index (UI slider min)
|
|
@@ -17,20 +17,7 @@ const WINDOW_EXTS = /\.(css|scss|sass|less|styl|html?|md|markdown|mdx|vue|svelte
|
|
|
17
17
|
const WINDOW_LINES = 24; // non-overlapping block size for windowed files
|
|
18
18
|
// Asset files are DEDUP-ONLY — they are NOT put in the code graph (that would score md/css as fake
|
|
19
19
|
// "methods" in Health and clutter the GUI board), so the clone scanner finds them by walking the repo.
|
|
20
|
-
const WALK_SKIP = new Set(["node_modules", ".git", "dist", "build", "coverage", "vendor", "weavatrix-graphs", ".next", "out", "__pycache__", ".venv", "venv", "site-packages"]);
|
|
21
20
|
const MAX_ASSET_FILES = 4000;
|
|
22
|
-
function walkAssets(root, dir, acc, depth) {
|
|
23
|
-
if (depth > 40 || acc.length >= MAX_ASSET_FILES) return acc;
|
|
24
|
-
let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return acc; }
|
|
25
|
-
for (const e of entries) {
|
|
26
|
-
if (acc.length >= MAX_ASSET_FILES) break;
|
|
27
|
-
if (e.name.startsWith(".") || WALK_SKIP.has(e.name)) continue;
|
|
28
|
-
const full = join(dir, e.name);
|
|
29
|
-
if (e.isDirectory()) walkAssets(root, full, acc, depth + 1);
|
|
30
|
-
else if (WINDOW_EXTS.test(e.name)) acc.push(full.slice(root.length + 1).replace(/\\/g, "/"));
|
|
31
|
-
}
|
|
32
|
-
return acc;
|
|
33
|
-
}
|
|
34
21
|
// Fingerprints shared by MORE than this many fragments are ubiquitous boilerplate and are excluded
|
|
35
22
|
// from BOTH the shared count AND the union (so jaccard stays honest — see pairsForMode). Set well
|
|
36
23
|
// above realistic clone multiplicity: N byte-identical copies each put all their fingerprints in
|
|
@@ -55,7 +42,7 @@ function computeNameTwins(frags) {
|
|
|
55
42
|
frags.forEach((f, i) => {
|
|
56
43
|
if (f.kind === "string") return;
|
|
57
44
|
const name = String(f.label || "").trim().replace(/\(\)$/, "");
|
|
58
|
-
if (name.length < 5 || !/^[A-Za-z_$][\w$]*$/.test(name) || TWIN_STOP.has(name.toLowerCase())) return;
|
|
45
|
+
if (name.length < 5 || !/^[A-Za-z_$][\w$]*$/.test(name) || TWIN_STOP.has(name.toLowerCase()) || /^do_(?:get|post|put|patch|delete|head|options)$/i.test(name)) return;
|
|
59
46
|
const key = name.toLowerCase();
|
|
60
47
|
let a = byName.get(key); if (!a) byName.set(key, (a = []));
|
|
61
48
|
a.push(i);
|
|
@@ -66,15 +53,18 @@ function computeNameTwins(frags) {
|
|
|
66
53
|
const files = new Set(idxs.map((i) => frags[i].file));
|
|
67
54
|
if (files.size < 2 || idxs.length > 12) continue; // single-file overloads / framework-name explosions
|
|
68
55
|
let simMin = 1, simMax = 0;
|
|
56
|
+
const pairs = [];
|
|
69
57
|
for (let a = 0; a < idxs.length; a++) for (let b = a + 1; b < idxs.length; b++) {
|
|
70
58
|
if (frags[idxs[a]].file === frags[idxs[b]].file) continue;
|
|
71
59
|
const s = jac(frags[idxs[a]].fp.renamed, frags[idxs[b]].fp.renamed);
|
|
72
60
|
if (s < simMin) simMin = s;
|
|
73
61
|
if (s > simMax) simMax = s;
|
|
62
|
+
pairs.push({ a: idxs[a], b: idxs[b], similarity: Math.round(s * 100) });
|
|
74
63
|
}
|
|
75
64
|
out.push({
|
|
76
65
|
label: String(frags[idxs[0]].label || "").replace(/\(\)$/, ""), members: idxs, files: files.size,
|
|
77
66
|
simMin: Math.round(simMin * 100), simMax: Math.round(simMax * 100),
|
|
67
|
+
pairs,
|
|
78
68
|
tokens: idxs.reduce((n, i) => n + frags[i].n, 0),
|
|
79
69
|
});
|
|
80
70
|
}
|
|
@@ -130,11 +120,14 @@ export function computeDuplicates(repoPath, graphJsonPath, opts = {}) {
|
|
|
130
120
|
// total symbol nodes in the graph — 0 means a file-only graph (built by an older builder before
|
|
131
121
|
// symbol extraction, or a stale graph): clone detection has nothing to work with, and the UI must
|
|
132
122
|
// say "rebuild the graph" instead of the misleading "no clones at these thresholds".
|
|
123
|
+
const repoFiles = listRepoFiles(repoPath);
|
|
124
|
+
const allowedFiles = new Set(repoFiles);
|
|
133
125
|
let graphSymbols = 0;
|
|
134
|
-
for (const arr of byFile.
|
|
126
|
+
for (const [file, arr] of byFile) if (allowedFiles.has(file)) graphSymbols += arr.length;
|
|
135
127
|
const frags = [];
|
|
136
128
|
const boundary = createRepoBoundary(repoPath);
|
|
137
129
|
for (const [file, syms] of byFile) {
|
|
130
|
+
if (!allowedFiles.has(file)) continue;
|
|
138
131
|
const resolved = boundary.resolve(file);
|
|
139
132
|
if (!resolved.ok) continue;
|
|
140
133
|
const full = resolved.path;
|
|
@@ -195,7 +188,8 @@ export function computeDuplicates(repoPath, graphJsonPath, opts = {}) {
|
|
|
195
188
|
// ---- windowed fragments for symbol-less file types (CSS/HTML/MD/…), found by walking the repo (NOT the
|
|
196
189
|
// graph — assets are dedup-only). Each file is sliced into WINDOW_LINES blocks and fingerprinted.
|
|
197
190
|
const symFiles = new Set(byFile.keys());
|
|
198
|
-
|
|
191
|
+
const assetFiles = repoFiles.filter((file) => WINDOW_EXTS.test(file)).slice(0, MAX_ASSET_FILES);
|
|
192
|
+
for (const file of assetFiles) {
|
|
199
193
|
if (symFiles.has(file)) continue;
|
|
200
194
|
const resolved = boundary.resolve(file);
|
|
201
195
|
if (!resolved.ok) continue;
|