weavatrix 0.1.0
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/LICENSE +21 -0
- package/README.md +146 -0
- package/bin/weavatrix-mcp.mjs +6 -0
- package/package.json +50 -0
- package/skill/SKILL.md +56 -0
- package/src/analysis/coverage-reports.js +232 -0
- package/src/analysis/dead-check.js +136 -0
- package/src/analysis/dep-check.js +310 -0
- package/src/analysis/dep-rules.js +221 -0
- package/src/analysis/duplicates-worker.js +11 -0
- package/src/analysis/duplicates.compute.js +215 -0
- package/src/analysis/duplicates.js +15 -0
- package/src/analysis/duplicates.run.js +51 -0
- package/src/analysis/duplicates.tokenize.js +182 -0
- package/src/analysis/endpoints.js +156 -0
- package/src/analysis/findings.js +52 -0
- package/src/analysis/graph-analysis.aggregate.js +280 -0
- package/src/analysis/graph-analysis.js +7 -0
- package/src/analysis/graph-analysis.refs.js +146 -0
- package/src/analysis/graph-analysis.summaries.js +62 -0
- package/src/analysis/internal-audit.collect.js +117 -0
- package/src/analysis/internal-audit.js +9 -0
- package/src/analysis/internal-audit.reach.js +47 -0
- package/src/analysis/internal-audit.run.js +189 -0
- package/src/analysis/manifests.js +169 -0
- package/src/analysis/source-complexity.ast.js +97 -0
- package/src/analysis/source-complexity.constants.js +55 -0
- package/src/analysis/source-complexity.js +14 -0
- package/src/analysis/source-complexity.report.js +76 -0
- package/src/analysis/source-complexity.walk.js +174 -0
- package/src/build-graph.js +102 -0
- package/src/config.js +5 -0
- package/src/graph/build-worker.js +30 -0
- package/src/graph/builder/lang-csharp.js +40 -0
- package/src/graph/builder/lang-css.js +29 -0
- package/src/graph/builder/lang-go.js +53 -0
- package/src/graph/builder/lang-html.js +29 -0
- package/src/graph/builder/lang-java.js +29 -0
- package/src/graph/builder/lang-js.js +168 -0
- package/src/graph/builder/lang-python.js +78 -0
- package/src/graph/builder/lang-rust.js +45 -0
- package/src/graph/builder/spec-pkg.js +87 -0
- package/src/graph/graph-filter.js +44 -0
- package/src/graph/internal-builder.build.js +217 -0
- package/src/graph/internal-builder.js +12 -0
- package/src/graph/internal-builder.langs.js +86 -0
- package/src/graph/internal-builder.resolvers.js +116 -0
- package/src/graph/layout.js +35 -0
- package/src/infra/infra-items.js +255 -0
- package/src/infra/infra-registry.js +184 -0
- package/src/infra/infra.detect.js +119 -0
- package/src/infra/infra.js +38 -0
- package/src/infra/infra.match.js +102 -0
- package/src/infra/infra.scan.js +93 -0
- package/src/mcp/catalog.mjs +68 -0
- package/src/mcp/graph-context.mjs +276 -0
- package/src/mcp/tools-actions.mjs +132 -0
- package/src/mcp/tools-graph.mjs +274 -0
- package/src/mcp/tools-health.mjs +209 -0
- package/src/mcp/tools-impact.mjs +189 -0
- package/src/mcp-rg.mjs +51 -0
- package/src/mcp-server.mjs +171 -0
- package/src/mcp-source-tools.mjs +139 -0
- package/src/process.js +77 -0
- package/src/scan/discover.inventory.js +78 -0
- package/src/scan/discover.js +5 -0
- package/src/scan/discover.list.js +79 -0
- package/src/scan/discover.stack.js +227 -0
- package/src/scan/search.core.js +24 -0
- package/src/scan/search.git.js +102 -0
- package/src/scan/search.js +9 -0
- package/src/scan/search.node.js +83 -0
- package/src/scan/search.preview.js +49 -0
- package/src/scan/search.rg.js +145 -0
- package/src/security/advisory-store.js +177 -0
- package/src/security/installed.js +247 -0
- package/src/security/malware-file-heuristics.js +121 -0
- package/src/security/malware-heuristics.exclusions.js +134 -0
- package/src/security/malware-heuristics.js +15 -0
- package/src/security/malware-heuristics.roots.js +142 -0
- package/src/security/malware-heuristics.scan.js +87 -0
- package/src/security/malware-heuristics.sweep.js +122 -0
- package/src/security/malware-scoring.js +84 -0
- package/src/security/match.js +85 -0
- package/src/security/registry-sig.classify.js +136 -0
- package/src/security/registry-sig.js +18 -0
- package/src/security/registry-sig.rules.js +188 -0
- package/src/security/typosquat.js +72 -0
- package/src/util.js +27 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// Pure dependency checker (replaces depcheck + knip's dependency output) — set math over the graph's
|
|
2
|
+
// externalImports vs package.json. NO filesystem here: the fs wrapper is internal-audit.js, so this is
|
|
3
|
+
// fully unit-testable (same pattern as dead-check.js computeDead). See DEPS_SECURITY_PLAN.md (P1).
|
|
4
|
+
//
|
|
5
|
+
// Philosophy: bias to FALSE-NEGATIVES. A dep we can't prove unused stays unflagged (or drops to low
|
|
6
|
+
// confidence) — knip's ~100 framework plugins know config conventions we don't, so we compensate with
|
|
7
|
+
// script/config-text mention scanning + a config-ecosystem prefix rule, and we NEVER say "safe to
|
|
8
|
+
// auto-remove", only "review".
|
|
9
|
+
import { makeFinding } from "./findings.js";
|
|
10
|
+
|
|
11
|
+
// Packages referenced by config CONVENTION, not imports (eslint extends "airbnb" → eslint-config-airbnb).
|
|
12
|
+
// Flagged only at low confidence when nothing mentions them anywhere.
|
|
13
|
+
const CONFIG_ECOSYSTEM_RE =
|
|
14
|
+
/^(eslint-(config|plugin)-|@typescript-eslint\/|@eslint\/|prettier-plugin-|postcss-|autoprefixer$|tailwindcss$|babel-(plugin|preset)-|@babel\/(plugin|preset)-|stylelint-|@commitlint\/|commitlint-|remark-|rehype-|@semantic-release\/|karma-|grunt-|gulp-)/;
|
|
15
|
+
|
|
16
|
+
// CLI name → package name, for script commands whose binary doesn't equal the package
|
|
17
|
+
// (`tsc` comes from typescript, `depcruise` from dependency-cruiser, …).
|
|
18
|
+
const BIN_PKG = {
|
|
19
|
+
tsc: "typescript",
|
|
20
|
+
depcruise: "dependency-cruiser",
|
|
21
|
+
"vue-cli-service": "@vue/cli-service",
|
|
22
|
+
ng: "@angular/cli",
|
|
23
|
+
nest: "@nestjs/cli",
|
|
24
|
+
sb: "storybook",
|
|
25
|
+
"electron-rebuild": "@electron/rebuild",
|
|
26
|
+
playwright: "@playwright/test",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const escRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
30
|
+
// word-ish mention: the name not embedded inside a longer identifier/path segment
|
|
31
|
+
const mentioned = (blob, name) => new RegExp(`(^|[^\\w@.-])${escRe(name)}($|[^\\w.-])`).test(blob);
|
|
32
|
+
|
|
33
|
+
// computeDepFindings({ externalImports, pkg, workspacePkgNames, configTexts }) → { findings, usedPackages }
|
|
34
|
+
// externalImports — graph.json's array (P0): {file, spec, pkg, builtin, kind, line, dynamic?, unresolved?}
|
|
35
|
+
// pkg — parsed package.json ({} for non-JS repos → no dep findings)
|
|
36
|
+
// workspacePkgNames — Set of monorepo-local package names (never "missing")
|
|
37
|
+
// configTexts — Map<fileName, text> of root config files + CI workflows (mention scanning)
|
|
38
|
+
export function computeDepFindings({ externalImports = [], pkg = {}, workspacePkgNames = new Set(), configTexts = new Map() } = {}) {
|
|
39
|
+
const findings = [];
|
|
40
|
+
const sections = {
|
|
41
|
+
dependencies: pkg.dependencies || {},
|
|
42
|
+
devDependencies: pkg.devDependencies || {},
|
|
43
|
+
peerDependencies: pkg.peerDependencies || {},
|
|
44
|
+
optionalDependencies: pkg.optionalDependencies || {},
|
|
45
|
+
};
|
|
46
|
+
const allDeclared = new Set(Object.values(sections).flatMap((s) => Object.keys(s)));
|
|
47
|
+
const selfName = String(pkg.name || "");
|
|
48
|
+
|
|
49
|
+
// ---- usage index from the graph's recorded imports ----
|
|
50
|
+
const usedPackages = new Map(); // pkgName -> { files:Set, lines:Map(file→first line), kinds:Set }
|
|
51
|
+
let builtinUsed = false;
|
|
52
|
+
for (const e of externalImports) {
|
|
53
|
+
if (e.ecosystem && e.ecosystem !== "npm") continue; // go/python imports have their own checkers
|
|
54
|
+
if (e.unresolved) continue;
|
|
55
|
+
if (e.builtin) { builtinUsed = true; continue; }
|
|
56
|
+
if (!e.pkg) continue;
|
|
57
|
+
let u = usedPackages.get(e.pkg);
|
|
58
|
+
if (!u) usedPackages.set(e.pkg, (u = { files: new Set(), lines: new Map(), kinds: new Set() }));
|
|
59
|
+
u.files.add(e.file);
|
|
60
|
+
if (!u.lines.has(e.file)) u.lines.set(e.file, e.line || 0);
|
|
61
|
+
u.kinds.add(e.kind);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const scriptBlob = Object.values(pkg.scripts || {}).join("\n");
|
|
65
|
+
const configBlob = scriptBlob + "\n" + [...configTexts.values()].join("\n");
|
|
66
|
+
const scriptTokens = new Set(scriptBlob.split(/[^\w@/.:-]+/).filter(Boolean));
|
|
67
|
+
const binReferenced = (name) => {
|
|
68
|
+
if (scriptTokens.has(name)) return true;
|
|
69
|
+
for (const [bin, p] of Object.entries(BIN_PKG)) if (p === name && scriptTokens.has(bin)) return true;
|
|
70
|
+
return false;
|
|
71
|
+
};
|
|
72
|
+
const typesBase = (name) => (name.startsWith("@types/") ? name.slice(7).replace(/^(.+?)__(.+)$/, "@$1/$2") : null); // @types/babel__core → @babel/core
|
|
73
|
+
|
|
74
|
+
// ---- unused dependencies (per section; prod vs dev differ in severity/confidence) ----
|
|
75
|
+
for (const [section, deps] of Object.entries(sections)) {
|
|
76
|
+
if (section === "peerDependencies") continue; // peers are consumer-facing contracts, not usage
|
|
77
|
+
for (const name of Object.keys(deps)) {
|
|
78
|
+
if (name === selfName || usedPackages.has(name)) continue;
|
|
79
|
+
const tb = typesBase(name);
|
|
80
|
+
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;
|
|
82
|
+
}
|
|
83
|
+
if (binReferenced(name) || mentioned(configBlob, name)) continue; // scripts/config keep it alive
|
|
84
|
+
const ecosystem = CONFIG_ECOSYSTEM_RE.test(name);
|
|
85
|
+
const dev = section !== "dependencies";
|
|
86
|
+
if (ecosystem && dev) continue; // config-convention devDeps: too FP-prone to flag at all
|
|
87
|
+
findings.push(makeFinding({
|
|
88
|
+
category: "unused",
|
|
89
|
+
rule: "unused-dep",
|
|
90
|
+
severity: dev ? "info" : "low",
|
|
91
|
+
confidence: dev || ecosystem ? "low" : "medium",
|
|
92
|
+
title: `Unused ${section === "dependencies" ? "dependency" : section.replace(/ies$/, "y")}: ${name}`,
|
|
93
|
+
detail: `"${name}" is declared in ${section} but never imported in source, never referenced by a script, and not mentioned in any known config file. Dynamic/config-convention usage can't be fully ruled out — review before removing.`,
|
|
94
|
+
package: name,
|
|
95
|
+
source: "internal",
|
|
96
|
+
fixHint: `npm uninstall ${name} (after confirming no config/CLI usage)`,
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- missing (phantom) dependencies: imported but declared nowhere ----
|
|
102
|
+
for (const [name, use] of usedPackages) {
|
|
103
|
+
if (allDeclared.has(name) || name === selfName || workspacePkgNames.has(name)) continue;
|
|
104
|
+
const files = [...use.files];
|
|
105
|
+
const testOnly = files.every((f) => /(^|[/\\])(test|tests|__tests__|spec|e2e|__mocks__)([/\\]|$)|[._-](test|spec)\.[a-z]+$/i.test(f));
|
|
106
|
+
findings.push(makeFinding({
|
|
107
|
+
category: "unused",
|
|
108
|
+
rule: "missing-dep",
|
|
109
|
+
severity: testOnly ? "low" : "medium",
|
|
110
|
+
confidence: "high",
|
|
111
|
+
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.`,
|
|
113
|
+
package: name,
|
|
114
|
+
file: files[0],
|
|
115
|
+
line: use.lines.get(files[0]) || 0,
|
|
116
|
+
evidence: files.slice(0, 5).map((f) => ({ file: f, line: use.lines.get(f) || 0, snippet: "" })),
|
|
117
|
+
source: "internal",
|
|
118
|
+
fixHint: `npm install ${testOnly ? "--save-dev " : ""}${name}`,
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---- duplicate declarations (same package in several sections) ----
|
|
123
|
+
const seenIn = new Map();
|
|
124
|
+
for (const [section, deps] of Object.entries(sections)) for (const name of Object.keys(deps)) (seenIn.get(name) || seenIn.set(name, []).get(name)).push(section);
|
|
125
|
+
for (const [name, ss] of seenIn) {
|
|
126
|
+
if (ss.length < 2) continue;
|
|
127
|
+
if (ss.includes("peerDependencies") && ss.includes("devDependencies") && ss.length === 2) continue; // standard lib-author pattern
|
|
128
|
+
findings.push(makeFinding({
|
|
129
|
+
category: "unused",
|
|
130
|
+
rule: "duplicate-dep",
|
|
131
|
+
severity: "info",
|
|
132
|
+
confidence: "high",
|
|
133
|
+
title: `Duplicate declaration: ${name}`,
|
|
134
|
+
detail: `"${name}" is declared in ${ss.join(" + ")} — npm resolves one of them; keep a single section.`,
|
|
135
|
+
package: name,
|
|
136
|
+
source: "internal",
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---- unresolved local imports (broken relative/alias paths) ----
|
|
141
|
+
const unresolvedSeen = new Set();
|
|
142
|
+
let unresolvedCount = 0;
|
|
143
|
+
for (const e of externalImports) {
|
|
144
|
+
if (!e.unresolved) continue;
|
|
145
|
+
unresolvedCount++;
|
|
146
|
+
const key = e.file + "|" + e.spec;
|
|
147
|
+
if (unresolvedSeen.has(key) || unresolvedSeen.size >= 100) continue; // cap the findings, keep the count honest
|
|
148
|
+
unresolvedSeen.add(key);
|
|
149
|
+
findings.push(makeFinding({
|
|
150
|
+
category: "structure",
|
|
151
|
+
rule: "unresolved-import",
|
|
152
|
+
severity: "low",
|
|
153
|
+
confidence: "medium",
|
|
154
|
+
title: `Unresolved import: ${e.spec}`,
|
|
155
|
+
detail: `${e.file}:${e.line} imports "${e.spec}", which resolves to no file in the repo (broken path, missing alias target, or a file type the graph doesn't index).`,
|
|
156
|
+
file: e.file,
|
|
157
|
+
line: e.line || 0,
|
|
158
|
+
source: "internal",
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
if (unresolvedCount > unresolvedSeen.size) {
|
|
162
|
+
findings.push(makeFinding({
|
|
163
|
+
category: "structure",
|
|
164
|
+
rule: "unresolved-import",
|
|
165
|
+
severity: "info",
|
|
166
|
+
confidence: "high",
|
|
167
|
+
title: `…and ${unresolvedCount - unresolvedSeen.size} more unresolved imports`,
|
|
168
|
+
detail: "Finding list capped at 100 unique unresolved imports; the count above is the true total.",
|
|
169
|
+
source: "internal",
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { findings, usedPackages, declared: allDeclared };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const TEST_PATH_RE = /(^|[/\\])(test|tests|__tests__|spec|e2e|__mocks__)([/\\]|$)|[._-](test|spec)\.[a-z0-9]+$|_test\.go$/i;
|
|
177
|
+
|
|
178
|
+
// ---- Go: set math over ecosystem:"Go" externalImports vs go.mod requires ----
|
|
179
|
+
// goMod = parseGoMod() output. Only DIRECT requires can be "unused" (indirect ones belong to `go mod
|
|
180
|
+
// tidy`); replace targets and the own module never count. Missing = imported module with no require
|
|
181
|
+
// prefix — rare in Go (builds fail), so it usually flags vendored/replaced setups: keep it medium.
|
|
182
|
+
export function computeGoDepFindings({ externalImports = [], goMod = null } = {}) {
|
|
183
|
+
const findings = [];
|
|
184
|
+
if (!goMod || !goMod.module) return { findings, declared: new Set() };
|
|
185
|
+
const requires = goMod.requires || [];
|
|
186
|
+
const declared = new Set(requires.map((r) => r.path));
|
|
187
|
+
const replaced = new Set((goMod.replaces || []).map((r) => r.from));
|
|
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 }
|
|
192
|
+
for (const e of externalImports) {
|
|
193
|
+
if (e.ecosystem !== "Go" || e.builtin || e.unresolved || !e.pkg) continue;
|
|
194
|
+
const mod = moduleOf(e.spec || e.pkg) || (declared.has(e.pkg) ? e.pkg : "");
|
|
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);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const r of requires) {
|
|
203
|
+
if (r.indirect || usedModules.has(r.path)) continue;
|
|
204
|
+
findings.push(makeFinding({
|
|
205
|
+
category: "unused",
|
|
206
|
+
rule: "unused-dep",
|
|
207
|
+
severity: "low",
|
|
208
|
+
confidence: "medium",
|
|
209
|
+
title: `Unused Go module: ${r.path}`,
|
|
210
|
+
detail: `"${r.path}" is required (direct) in go.mod but no .go file imports it or any of its packages. Build-tag-guarded files can hide usage — confirm with \`go mod tidy\` before removing.`,
|
|
211
|
+
package: r.path,
|
|
212
|
+
version: r.version,
|
|
213
|
+
source: "internal",
|
|
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
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
return { findings, declared };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---- Python: ecosystem:"PyPI" externalImports vs requirements/pyproject/Pipfile ----
|
|
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
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Pure structure checker (replaces dependency-cruiser's core): circular dependencies (iterative Tarjan
|
|
2
|
+
// SCC + one representative cycle each), orphan files, and a small glob boundary-rule DSL evaluated over
|
|
3
|
+
// the graph's file-level import edges. NO filesystem — internal-audit.js feeds it. DEPS_SECURITY_PLAN P2.
|
|
4
|
+
//
|
|
5
|
+
// Honest gaps vs depcruise: we don't follow imports INTO node_modules and don't run enhanced-resolve
|
|
6
|
+
// (package.json#exports / webpack resolution), so some cycles it sees we can't. Cycles we DO report are
|
|
7
|
+
// built from EXTRACTED import edges → high confidence.
|
|
8
|
+
import { makeFinding } from "./findings.js";
|
|
9
|
+
import { ENTRY_FILE } from "./dead-check.js";
|
|
10
|
+
|
|
11
|
+
const TEST_FILE_RE = /(^|[/])(test|tests|__tests__|spec|e2e|__mocks__)([/]|$)|[._-](test|spec)\.[a-z0-9]+$/i;
|
|
12
|
+
// config/data/docs: never "orphans" — nothing imports them by design
|
|
13
|
+
const NON_CODE_RE = /\.(json|ya?ml|sh|ps1|md|txt|html?|css|scss|less)$|(^|[/])(dockerfile|containerfile)/i;
|
|
14
|
+
|
|
15
|
+
const ep = (v) => String(v && typeof v === "object" ? v.id : v);
|
|
16
|
+
const fileOf = (v) => { const s = ep(v); const h = s.indexOf("#"); return h < 0 ? s : s.slice(0, h); };
|
|
17
|
+
const dirOf = (f) => (f.includes("/") ? f.slice(0, f.lastIndexOf("/")) : "");
|
|
18
|
+
|
|
19
|
+
// File-level import adjacency. Go same-directory edges are excluded: a Go package IS the directory, the
|
|
20
|
+
// compiler forbids real cross-package cycles, and our suffix-fallback Go resolution could fake them.
|
|
21
|
+
export function buildFileImportGraph(graph) {
|
|
22
|
+
const fileIds = new Set();
|
|
23
|
+
for (const n of graph.nodes || []) if (!String(n.id).includes("#")) fileIds.add(String(n.id));
|
|
24
|
+
const adj = new Map();
|
|
25
|
+
const edges = [];
|
|
26
|
+
for (const l of graph.links || []) {
|
|
27
|
+
if (l.relation !== "imports" && l.relation !== "re_exports") continue;
|
|
28
|
+
const a = ep(l.source), b = ep(l.target);
|
|
29
|
+
if (!fileIds.has(a) || !fileIds.has(b) || a === b) continue;
|
|
30
|
+
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]); }
|
|
34
|
+
}
|
|
35
|
+
return { fileIds, adj, edges };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Iterative Tarjan (deep graphs must not blow the JS stack). Returns only non-trivial SCCs (size > 1).
|
|
39
|
+
export function findSccs(adj) {
|
|
40
|
+
const index = new Map(), low = new Map(), onStack = new Set(), S = [];
|
|
41
|
+
let counter = 0;
|
|
42
|
+
const sccs = [];
|
|
43
|
+
for (const root of adj.keys()) {
|
|
44
|
+
if (index.has(root)) continue;
|
|
45
|
+
index.set(root, counter); low.set(root, counter); counter++;
|
|
46
|
+
S.push(root); onStack.add(root);
|
|
47
|
+
const stack = [{ v: root, ci: 0, neigh: [...(adj.get(root) || [])] }];
|
|
48
|
+
while (stack.length) {
|
|
49
|
+
const fr = stack[stack.length - 1];
|
|
50
|
+
if (fr.ci < fr.neigh.length) {
|
|
51
|
+
const w = fr.neigh[fr.ci++];
|
|
52
|
+
if (!index.has(w)) {
|
|
53
|
+
index.set(w, counter); low.set(w, counter); counter++;
|
|
54
|
+
S.push(w); onStack.add(w);
|
|
55
|
+
stack.push({ v: w, ci: 0, neigh: [...(adj.get(w) || [])] });
|
|
56
|
+
} else if (onStack.has(w)) {
|
|
57
|
+
low.set(fr.v, Math.min(low.get(fr.v), index.get(w)));
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
stack.pop();
|
|
61
|
+
if (stack.length) { const p = stack[stack.length - 1]; low.set(p.v, Math.min(low.get(p.v), low.get(fr.v))); }
|
|
62
|
+
if (low.get(fr.v) === index.get(fr.v)) {
|
|
63
|
+
const comp = [];
|
|
64
|
+
let w;
|
|
65
|
+
do { w = S.pop(); onStack.delete(w); comp.push(w); } while (w !== fr.v);
|
|
66
|
+
if (comp.length > 1) sccs.push(comp);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return sccs;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// One representative (shortest) cycle through an SCC's lexicographically-first file — a readable path,
|
|
75
|
+
// not the full Johnson enumeration (which explodes combinatorially on big tangles).
|
|
76
|
+
export function representativeCycle(adj, scc) {
|
|
77
|
+
const inScc = new Set(scc);
|
|
78
|
+
const start = [...scc].sort()[0];
|
|
79
|
+
const prev = new Map([[start, null]]);
|
|
80
|
+
const q = [start];
|
|
81
|
+
while (q.length) {
|
|
82
|
+
const cur = q.shift();
|
|
83
|
+
for (const nxt of adj.get(cur) || []) {
|
|
84
|
+
if (nxt === start) {
|
|
85
|
+
const path = [];
|
|
86
|
+
for (let c = cur; c != null; c = prev.get(c)) path.push(c);
|
|
87
|
+
return [...path.reverse(), start];
|
|
88
|
+
}
|
|
89
|
+
if (!inScc.has(nxt) || prev.has(nxt)) continue;
|
|
90
|
+
prev.set(nxt, cur);
|
|
91
|
+
q.push(nxt);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [...scc, scc[0]]; // unreachable in a true SCC; safe fallback
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Orphans: file nodes with ZERO non-contains graph degree (nothing in, nothing out — imports, calls,
|
|
98
|
+
// references all collapsed to files). Entries/tests/config-data are exempt. A file that DOES import
|
|
99
|
+
// npm packages (externalImports) is a working script, not an island — confidence drops, not the verdict.
|
|
100
|
+
export function findOrphans(graph, { entrySet = new Set(), externalImportFiles = new Set() } = {}) {
|
|
101
|
+
const deg = new Map();
|
|
102
|
+
for (const l of graph.links || []) {
|
|
103
|
+
if (l.relation === "contains") continue;
|
|
104
|
+
const a = fileOf(l.source), b = fileOf(l.target);
|
|
105
|
+
if (a === b) continue;
|
|
106
|
+
deg.set(a, (deg.get(a) || 0) + 1);
|
|
107
|
+
deg.set(b, (deg.get(b) || 0) + 1);
|
|
108
|
+
}
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const n of graph.nodes || []) {
|
|
111
|
+
const id = String(n.id);
|
|
112
|
+
if (id.includes("#")) continue;
|
|
113
|
+
if ((deg.get(id) || 0) > 0) continue;
|
|
114
|
+
const f = n.source_file;
|
|
115
|
+
if (entrySet.has(f) || ENTRY_FILE.test(f) || TEST_FILE_RE.test(f) || NON_CODE_RE.test(f)) continue;
|
|
116
|
+
out.push({ file: f, importsExternals: externalImportFiles.has(f) });
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Boundary DSL — the useful subset of depcruise's forbidden rules, as plain JSON globs:
|
|
122
|
+
// { "forbidden": [{ "name", "comment"?, "severity"?, "from": "main/**", "to": "renderer/**" }],
|
|
123
|
+
// "allowedOnly":[{ "name", "comment"?, "severity"?, "from": "src/ui/**", "to": ["src/ui/**", "src/shared/**"] }] }
|
|
124
|
+
// forbidden fires when an edge matches from AND to; allowedOnly fires when from matches and to matches NONE.
|
|
125
|
+
export function globToRe(glob) {
|
|
126
|
+
// split on ** first so single-* expansion cannot touch the multi-segment wildcards
|
|
127
|
+
const parts = String(glob).split("**").map((p) => p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]"));
|
|
128
|
+
let s = parts.join(".*");
|
|
129
|
+
s = s.replace(/\/\.\*\//g, "/(?:.*/)?"); // a/**/b also matches a/b (zero middle dirs)
|
|
130
|
+
s = s.replace(/^\.\*\//, "(?:.*/)?"); // **/b also matches root-level b
|
|
131
|
+
return new RegExp(`^${s}$`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function checkBoundaries(edges, rules = {}) {
|
|
135
|
+
const violations = [];
|
|
136
|
+
const forbidden = (rules.forbidden || []).map((r) => ({ ...r, fromRe: globToRe(r.from), toRe: globToRe(r.to) }));
|
|
137
|
+
const allowedOnly = (rules.allowedOnly || []).map((r) => ({ ...r, fromRe: globToRe(r.from), toRes: (Array.isArray(r.to) ? r.to : [r.to]).map(globToRe) }));
|
|
138
|
+
for (const [a, b] of edges) {
|
|
139
|
+
for (const r of forbidden) if (r.fromRe.test(a) && r.toRe.test(b)) violations.push({ name: r.name, comment: r.comment || "", severity: r.severity, from: a, to: b, kind: "forbidden" });
|
|
140
|
+
for (const r of allowedOnly) if (r.fromRe.test(a) && !r.toRes.some((re) => re.test(b))) violations.push({ name: r.name, comment: r.comment || "", severity: r.severity, from: a, to: b, kind: "allowedOnly" });
|
|
141
|
+
}
|
|
142
|
+
return violations;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const MAX_CYCLE_FINDINGS = 50;
|
|
146
|
+
const MAX_BOUNDARY_FINDINGS = 100;
|
|
147
|
+
|
|
148
|
+
// Assemble everything into unified Findings. rules comes from the repo's .weavatrix-deps.json (optional).
|
|
149
|
+
export function computeStructureFindings(graph, { rules = {}, entrySet = new Set(), externalImportFiles = new Set() } = {}) {
|
|
150
|
+
const { adj, edges } = buildFileImportGraph(graph);
|
|
151
|
+
const findings = [];
|
|
152
|
+
|
|
153
|
+
const sccs = findSccs(adj).sort((a, b) => b.length - a.length);
|
|
154
|
+
for (const scc of sccs.slice(0, MAX_CYCLE_FINDINGS)) {
|
|
155
|
+
const cycle = representativeCycle(adj, scc);
|
|
156
|
+
findings.push(makeFinding({
|
|
157
|
+
category: "structure",
|
|
158
|
+
rule: "circular-dep",
|
|
159
|
+
severity: scc.length > 4 ? "high" : "medium",
|
|
160
|
+
confidence: "high",
|
|
161
|
+
title: `Circular dependency: ${scc.length} files`,
|
|
162
|
+
detail: `${cycle.join(" → ")}${scc.length + 1 > cycle.length ? ` (representative loop; the tangle spans ${scc.length} files)` : ""}. Break the cycle by extracting the shared piece or inverting one import.`,
|
|
163
|
+
file: cycle[0],
|
|
164
|
+
graphNodeId: cycle[0],
|
|
165
|
+
evidence: cycle.map((f) => ({ file: f, line: 0, snippet: "" })),
|
|
166
|
+
source: "internal",
|
|
167
|
+
fixHint: "extract the shared code into a module both sides import, or invert the weaker dependency",
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
if (sccs.length > MAX_CYCLE_FINDINGS) {
|
|
171
|
+
findings.push(makeFinding({
|
|
172
|
+
category: "structure", rule: "circular-dep", severity: "info", confidence: "high",
|
|
173
|
+
title: `…and ${sccs.length - MAX_CYCLE_FINDINGS} more dependency cycles`,
|
|
174
|
+
detail: `Cycle findings are capped at ${MAX_CYCLE_FINDINGS}; ${sccs.length} strongly-connected groups exist in total.`,
|
|
175
|
+
source: "internal",
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const o of findOrphans(graph, { entrySet, externalImportFiles })) {
|
|
180
|
+
findings.push(makeFinding({
|
|
181
|
+
category: "structure",
|
|
182
|
+
rule: "orphan-file",
|
|
183
|
+
severity: "info",
|
|
184
|
+
confidence: o.importsExternals ? "low" : "medium",
|
|
185
|
+
title: `Orphan file: ${o.file}`,
|
|
186
|
+
detail: `No repo file imports it and it imports/calls nothing in the repo${o.importsExternals ? " (it does use npm packages — possibly a standalone script or tool)" : ""}. Possibly dead, possibly an undeclared entry point.`,
|
|
187
|
+
file: o.file,
|
|
188
|
+
graphNodeId: o.file,
|
|
189
|
+
source: "internal",
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const violations = checkBoundaries(edges, rules);
|
|
194
|
+
for (const v of violations.slice(0, MAX_BOUNDARY_FINDINGS)) {
|
|
195
|
+
findings.push(makeFinding({
|
|
196
|
+
category: "structure",
|
|
197
|
+
rule: "boundary-violation",
|
|
198
|
+
severity: ["critical", "high", "medium", "low", "info"].includes(v.severity) ? v.severity : "medium",
|
|
199
|
+
confidence: "high",
|
|
200
|
+
title: `Boundary violation (${v.name}): ${v.from} → ${v.to}`,
|
|
201
|
+
detail: `${v.kind === "allowedOnly" ? "Import leaves the allowed set" : "Forbidden import"}${v.comment ? `: ${v.comment}` : ""}.`,
|
|
202
|
+
file: v.from,
|
|
203
|
+
graphNodeId: v.from,
|
|
204
|
+
evidence: [{ file: v.from, line: 0, snippet: `imports ${v.to}` }],
|
|
205
|
+
source: "internal",
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
if (violations.length > MAX_BOUNDARY_FINDINGS) {
|
|
209
|
+
findings.push(makeFinding({
|
|
210
|
+
category: "structure", rule: "boundary-violation", severity: "info", confidence: "high",
|
|
211
|
+
title: `…and ${violations.length - MAX_BOUNDARY_FINDINGS} more boundary violations`,
|
|
212
|
+
detail: `Boundary findings are capped at ${MAX_BOUNDARY_FINDINGS}; ${violations.length} edges violate the rules in total.`,
|
|
213
|
+
source: "internal",
|
|
214
|
+
}));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
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 },
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// duplicates-worker.js — runs clone detection OFF Electron's main thread (same rationale as
|
|
2
|
+
// graph/build-worker.js: a blocked main event loop freezes every window on Windows). The fingerprint
|
|
3
|
+
// sets never leave the worker; only slim fragment metadata + similarity pairs cross back.
|
|
4
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
5
|
+
import { computeDuplicates } from "./duplicates.js";
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
parentPort.postMessage(computeDuplicates(workerData.repoPath, workerData.graphJsonPath));
|
|
9
|
+
} catch (e) {
|
|
10
|
+
parentPort.postMessage({ ok: false, error: (e && e.message) || String(e) });
|
|
11
|
+
}
|