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
|
@@ -5,6 +5,7 @@ import { readFileSync } from "node:fs";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { normalizeRepoParts, readCoverageForRepo, pctFromCounts } from "./coverage-reports.js";
|
|
7
7
|
import { bareSymbolName, countLocalRefsOutsideOwnRange, computeSymbolExternalRefs } from "./graph-analysis.refs.js";
|
|
8
|
+
import { createRepoBoundary } from "../repo-path.js";
|
|
8
9
|
|
|
9
10
|
// Aggregate a built graph.json into the file- and module-level view the UI needs:
|
|
10
11
|
// - graph-builder nodes are FILES *and* their symbols (functions/methods), linked file→symbol by the
|
|
@@ -99,21 +100,25 @@ export function aggregateGraph(graph, repoRoot) {
|
|
|
99
100
|
for (const node of nodes) if (node.source_file) id2file.set(node.id, fileIdOf(node));
|
|
100
101
|
const fileEdges = new Map(); // key → { count, rels:{relation→n} } so we can emit the DOMINANT relation (call/import/inherit)
|
|
101
102
|
const moduleEdges = new Map();
|
|
103
|
+
const typeOnlyFileEdges = new Map();
|
|
104
|
+
const typeOnlyModuleEdges = new Map();
|
|
102
105
|
for (const link of links) {
|
|
103
106
|
if (link.relation === "contains") continue;
|
|
104
107
|
const fromFile = id2file.get(endpoint(link.source));
|
|
105
108
|
const toFile = id2file.get(endpoint(link.target));
|
|
106
109
|
if (fromFile && toFile && fromFile !== toFile) {
|
|
107
110
|
const key = `${fromFile} ${toFile}`;
|
|
108
|
-
|
|
109
|
-
|
|
111
|
+
const targetFileEdges = link.typeOnly === true ? typeOnlyFileEdges : fileEdges;
|
|
112
|
+
let fe = targetFileEdges.get(key);
|
|
113
|
+
if (!fe) targetFileEdges.set(key, (fe = { count: 0, rels: {} }));
|
|
110
114
|
fe.count++;
|
|
111
115
|
if (link.relation) fe.rels[link.relation] = (fe.rels[link.relation] || 0) + 1;
|
|
112
116
|
const fromMod = fileModule.get(fromFile);
|
|
113
117
|
const toMod = fileModule.get(toFile);
|
|
114
118
|
if (fromMod && toMod && fromMod !== toMod) {
|
|
115
119
|
const mkey = `${fromMod} ${toMod}`;
|
|
116
|
-
|
|
120
|
+
const targetModuleEdges = link.typeOnly === true ? typeOnlyModuleEdges : moduleEdges;
|
|
121
|
+
targetModuleEdges.set(mkey, (targetModuleEdges.get(mkey) || 0) + 1);
|
|
117
122
|
}
|
|
118
123
|
}
|
|
119
124
|
}
|
|
@@ -175,11 +180,14 @@ export function aggregateGraph(graph, repoRoot) {
|
|
|
175
180
|
const fileLoc = new Map();
|
|
176
181
|
const fileText = new Map();
|
|
177
182
|
if (repoRoot) {
|
|
183
|
+
const boundary = createRepoBoundary(repoRoot);
|
|
178
184
|
folderLoc = {};
|
|
179
185
|
for (const [sourceFile, mod] of fileModule) {
|
|
180
186
|
let loc = 0;
|
|
181
187
|
try {
|
|
182
|
-
const
|
|
188
|
+
const resolved = boundary.resolve(sourceFile);
|
|
189
|
+
if (!resolved.ok) throw new Error("source path is outside the repository");
|
|
190
|
+
const txt = readFileSync(resolved.path, "utf8");
|
|
183
191
|
loc = txt ? txt.split("\n").length : 0;
|
|
184
192
|
fileText.set(sourceFile, txt || "");
|
|
185
193
|
} catch {
|
|
@@ -259,7 +267,9 @@ export function aggregateGraph(graph, repoRoot) {
|
|
|
259
267
|
}))
|
|
260
268
|
.sort((a, b) => b.fileCount - a.fileCount),
|
|
261
269
|
moduleEdges: edgeList(moduleEdges),
|
|
270
|
+
typeOnlyModuleEdges: edgeList(typeOnlyModuleEdges),
|
|
262
271
|
fileEdges: edgeList(fileEdges),
|
|
272
|
+
typeOnlyFileEdges: edgeList(typeOnlyFileEdges),
|
|
263
273
|
symbols,
|
|
264
274
|
symbolEdges,
|
|
265
275
|
symbolRefs: [...new Set([...symbolLocalRefs.keys(), ...symbolExternalRefs.keys()])].map((id) => {
|
|
@@ -272,7 +282,9 @@ export function aggregateGraph(graph, repoRoot) {
|
|
|
272
282
|
files: fileModule.size,
|
|
273
283
|
nodes: nodes.filter((n) => n.file_type === "code").length,
|
|
274
284
|
fileEdges: fileEdges.size,
|
|
285
|
+
typeOnlyFileEdges: typeOnlyFileEdges.size,
|
|
275
286
|
moduleEdges: moduleEdges.size,
|
|
287
|
+
typeOnlyModuleEdges: typeOnlyModuleEdges.size,
|
|
276
288
|
symbols: symbols.length,
|
|
277
289
|
symbolEdges: symbolEdges.length
|
|
278
290
|
}
|
|
@@ -1,42 +1,70 @@
|
|
|
1
1
|
// internal-audit.collect.js — filesystem collection helpers for the internal audit: source/config
|
|
2
2
|
// text gathering, workspace package names, and the Python manifest reader. Split from internal-audit.js.
|
|
3
3
|
import { readFileSync, readdirSync } from "node:fs";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
5
6
|
import { parseRequirementsNames, parsePyprojectDeps, parsePipfileDeps } from "./manifests.js";
|
|
7
|
+
import { createRepoBoundary } from "../repo-path.js";
|
|
8
|
+
import { childProcessEnv } from "../child-env.js";
|
|
6
9
|
|
|
7
10
|
export const readText = (p) => { try { return readFileSync(p, "utf8"); } catch { return null; } };
|
|
8
11
|
export const readJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
|
|
12
|
+
export const readRepoText = (boundary, relativePath) => {
|
|
13
|
+
const resolved = boundary.resolve(relativePath);
|
|
14
|
+
return resolved.ok ? readText(resolved.path) : null;
|
|
15
|
+
};
|
|
16
|
+
export const readRepoJson = (boundary, relativePath) => {
|
|
17
|
+
const resolved = boundary.resolve(relativePath);
|
|
18
|
+
return resolved.ok ? readJson(resolved.path) : null;
|
|
19
|
+
};
|
|
9
20
|
const SOURCE_EXT_RE = /\.(?:[cm]?[jt]sx?|py|go|vue|svelte)$/i;
|
|
10
21
|
const SOURCE_SKIP_DIRS = new Set([
|
|
11
22
|
".git", ".hg", ".svn", "node_modules", "vendor", "dist", "build", "coverage", ".next", "out",
|
|
12
|
-
"
|
|
23
|
+
"release", "weavatrix-graphs", "__pycache__", ".venv", "venv", "env", ".tox", "site-packages",
|
|
13
24
|
".mypy_cache", ".pytest_cache",
|
|
14
25
|
]);
|
|
15
26
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
// One file universe for every filesystem-backed audit. In Git repos this exactly matches tracked files
|
|
28
|
+
// plus untracked/non-ignored work, so release bundles and other .gitignore outputs cannot re-enter through
|
|
29
|
+
// text/config/manifest fallback collectors after the graph builder correctly omitted them.
|
|
30
|
+
export function listRepoFiles(repoRoot) {
|
|
31
|
+
try {
|
|
32
|
+
const r = spawnSync("git", ["-C", repoRoot, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
|
|
33
|
+
encoding: "utf8", windowsHide: true, timeout: 15_000, maxBuffer: 32 * 1024 * 1024,
|
|
34
|
+
env: childProcessEnv(),
|
|
35
|
+
});
|
|
36
|
+
if (r.status === 0) return String(r.stdout || "").split("\0").filter(Boolean).map((f) => f.replace(/\\/g, "/"));
|
|
37
|
+
} catch { /* non-Git repo or git unavailable: use the bounded walker below */ }
|
|
26
38
|
|
|
39
|
+
const files = [];
|
|
27
40
|
const walk = (abs, parts = []) => {
|
|
28
41
|
let entries = [];
|
|
29
42
|
try { entries = readdirSync(abs, { withFileTypes: true }); } catch { return; }
|
|
30
43
|
for (const entry of entries) {
|
|
31
44
|
if (entry.isDirectory()) {
|
|
32
45
|
if (!SOURCE_SKIP_DIRS.has(entry.name)) walk(join(abs, entry.name), [...parts, entry.name]);
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
if (!entry.isFile() || !SOURCE_EXT_RE.test(entry.name)) continue;
|
|
36
|
-
add([...parts, entry.name].join("/"));
|
|
46
|
+
} else if (entry.isFile()) files.push([...parts, entry.name].join("/"));
|
|
37
47
|
}
|
|
38
48
|
};
|
|
39
49
|
walk(repoRoot);
|
|
50
|
+
return files;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function collectSourceTexts(repoRoot, graph) {
|
|
54
|
+
const sources = new Map();
|
|
55
|
+
const boundary = createRepoBoundary(repoRoot);
|
|
56
|
+
const add = (rel) => {
|
|
57
|
+
const file = String(rel || "").replace(/\\/g, "/");
|
|
58
|
+
if (!file || sources.has(file)) return;
|
|
59
|
+
const resolved = boundary.resolve(file);
|
|
60
|
+
if (!resolved.ok) return;
|
|
61
|
+
const text = readText(resolved.path);
|
|
62
|
+
if (text != null) sources.set(file, text);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
for (const n of graph.nodes || []) add(n.source_file);
|
|
66
|
+
|
|
67
|
+
for (const file of listRepoFiles(repoRoot)) if (SOURCE_EXT_RE.test(file)) add(file);
|
|
40
68
|
return sources;
|
|
41
69
|
}
|
|
42
70
|
|
|
@@ -50,7 +78,8 @@ const CONFIG_FILES = [
|
|
|
50
78
|
"jest.config.js", "jest.config.ts", "jest.config.cjs", "jest.config.mjs",
|
|
51
79
|
"vite.config.js", "vite.config.ts", "vite.config.mjs", "vitest.config.js", "vitest.config.ts",
|
|
52
80
|
"webpack.config.js", "rollup.config.js", "esbuild.config.js",
|
|
53
|
-
"postcss.config.js", "postcss.config.cjs", "
|
|
81
|
+
"postcss.config.js", "postcss.config.cjs", "postcss.config.mjs", "postcss.config.ts",
|
|
82
|
+
"tailwind.config.js", "tailwind.config.cjs", "tailwind.config.mjs", "tailwind.config.ts",
|
|
54
83
|
".prettierrc", ".prettierrc.json", "prettier.config.js",
|
|
55
84
|
"playwright.config.js", "playwright.config.ts", "cypress.config.js", "cypress.config.ts",
|
|
56
85
|
"next.config.js", "next.config.mjs", "nuxt.config.ts", "svelte.config.js", "astro.config.mjs",
|
|
@@ -63,32 +92,122 @@ const CONFIG_FILES = [
|
|
|
63
92
|
|
|
64
93
|
export function collectConfigTexts(repoRoot) {
|
|
65
94
|
const map = new Map();
|
|
66
|
-
|
|
95
|
+
const boundary = createRepoBoundary(repoRoot);
|
|
96
|
+
const names = new Set(CONFIG_FILES.map((f) => f.toLowerCase()));
|
|
97
|
+
for (const f of listRepoFiles(repoRoot)) {
|
|
98
|
+
const base = f.slice(f.lastIndexOf("/") + 1).toLowerCase();
|
|
99
|
+
if (!names.has(base) && !/^\.github\/workflows\/.*\.ya?ml$/i.test(f)) continue;
|
|
100
|
+
const t = readRepoText(boundary, f);
|
|
101
|
+
if (t != null) map.set(f, t);
|
|
102
|
+
}
|
|
103
|
+
return map;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readJsonc(text) {
|
|
107
|
+
if (text == null) return null;
|
|
67
108
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
109
|
+
const input = String(text).replace(/^\uFEFF/, "");
|
|
110
|
+
let clean = "", inString = false, escaped = false;
|
|
111
|
+
for (let i = 0; i < input.length; i++) {
|
|
112
|
+
const ch = input[i], next = input[i + 1];
|
|
113
|
+
if (inString) {
|
|
114
|
+
clean += ch;
|
|
115
|
+
if (escaped) escaped = false;
|
|
116
|
+
else if (ch === "\\") escaped = true;
|
|
117
|
+
else if (ch === '"') inString = false;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (ch === '"') { inString = true; clean += ch; continue; }
|
|
121
|
+
if (ch === "/" && next === "/") {
|
|
122
|
+
while (i < input.length && input[i] !== "\n") i++;
|
|
123
|
+
clean += "\n";
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (ch === "/" && next === "*") {
|
|
127
|
+
i += 2;
|
|
128
|
+
while (i < input.length && !(input[i] === "*" && input[i + 1] === "/")) i++;
|
|
129
|
+
i++;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
clean += ch;
|
|
72
133
|
}
|
|
73
|
-
|
|
74
|
-
|
|
134
|
+
clean = clean.replace(/,\s*([}\]])/g, "$1");
|
|
135
|
+
return JSON.parse(clean);
|
|
136
|
+
} catch { return null; }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const normRoot = (root) => {
|
|
140
|
+
const value = String(root || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+|\/+$/g, "");
|
|
141
|
+
return value === "." ? "" : value;
|
|
142
|
+
};
|
|
143
|
+
const inScope = (file, root) => !root || file === root || file.startsWith(`${root}/`);
|
|
144
|
+
|
|
145
|
+
function aliasesForScope(repoRoot, root, files, boundary, scopeRoots) {
|
|
146
|
+
const ownerOf = (file) => scopeRoots.find((candidate) => inScope(file, candidate)) ?? "";
|
|
147
|
+
const configs = files
|
|
148
|
+
.filter((f) => ownerOf(f) === root && /(^|\/)(?:tsconfig|jsconfig)(?:\.[^/]+)?\.json$/i.test(f))
|
|
149
|
+
.filter((f) => normRoot(dirname(f)) === root)
|
|
150
|
+
.sort((a, b) => Number(!/(^|\/)(?:tsconfig|jsconfig)\.json$/i.test(a)) - Number(!/(^|\/)(?:tsconfig|jsconfig)\.json$/i.test(b)) || a.localeCompare(b));
|
|
151
|
+
const aliases = new Map();
|
|
152
|
+
for (const config of configs) {
|
|
153
|
+
const cfg = readJsonc(readRepoText(boundary, config));
|
|
154
|
+
const paths = cfg?.compilerOptions?.paths || {};
|
|
155
|
+
for (const key of Object.keys(paths)) if (!aliases.has(key)) aliases.set(key, {
|
|
156
|
+
key,
|
|
157
|
+
prefix: String(key).replace(/\*.*$/, ""),
|
|
158
|
+
suffix: String(key).includes("*") ? String(key).slice(String(key).indexOf("*") + 1) : "",
|
|
159
|
+
config,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return [...aliases.values()];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Every package.json defines a dependency scope. The nearest ancestor manifest owns an import, matching
|
|
166
|
+
// npm workspace/package semantics. Aliases are collected from that scope's tsconfig/jsconfig so `@/*`
|
|
167
|
+
// remains local rather than becoming a phantom npm package.
|
|
168
|
+
export function collectPackageScopes(repoRoot, rootPkg = null) {
|
|
169
|
+
const boundary = createRepoBoundary(repoRoot);
|
|
170
|
+
const files = listRepoFiles(repoRoot);
|
|
171
|
+
const manifests = files.filter((f) => /(^|\/)package\.json$/i.test(f));
|
|
172
|
+
if (!manifests.includes("package.json") && rootPkg) manifests.unshift("package.json");
|
|
173
|
+
const uniqueManifests = [...new Set(manifests)];
|
|
174
|
+
const scopeRoots = uniqueManifests
|
|
175
|
+
.map((manifest) => manifest === "package.json" ? "" : normRoot(dirname(manifest)))
|
|
176
|
+
.sort((a, b) => b.length - a.length);
|
|
177
|
+
const scopes = [];
|
|
178
|
+
for (const manifest of uniqueManifests) {
|
|
179
|
+
const root = manifest === "package.json" ? "" : normRoot(dirname(manifest));
|
|
180
|
+
const pkg = manifest === "package.json" && rootPkg ? rootPkg : readRepoJson(boundary, manifest);
|
|
181
|
+
if (!pkg || typeof pkg !== "object") continue;
|
|
182
|
+
scopes.push({ root, manifest, pkg, aliases: aliasesForScope(repoRoot, root, files, boundary, scopeRoots) });
|
|
183
|
+
}
|
|
184
|
+
if (!scopes.some((s) => !s.root)) scopes.push({ root: "", manifest: "package.json", pkg: rootPkg || {}, aliases: aliasesForScope(repoRoot, "", files, boundary, [...scopeRoots, ""]) });
|
|
185
|
+
return scopes.sort((a, b) => b.root.length - a.root.length);
|
|
75
186
|
}
|
|
76
187
|
|
|
77
188
|
// Monorepo-local package names: "packages/*"-style workspace globs → each child's package.json name.
|
|
78
189
|
// Those are importable without being declared — never "missing" deps.
|
|
79
190
|
export function workspacePkgNames(repoRoot, pkg) {
|
|
80
191
|
const names = new Set();
|
|
192
|
+
const boundary = createRepoBoundary(repoRoot);
|
|
81
193
|
const globs = Array.isArray(pkg.workspaces) ? pkg.workspaces : (pkg.workspaces && pkg.workspaces.packages) || [];
|
|
82
194
|
for (const g of globs) {
|
|
83
195
|
const base = String(g).replace(/\/?\*+.*$/, "");
|
|
84
196
|
let dirs = [];
|
|
85
|
-
if (/\*/.test(String(g))) {
|
|
197
|
+
if (/\*/.test(String(g))) {
|
|
198
|
+
try {
|
|
199
|
+
const resolvedBase = boundary.resolve(base || ".");
|
|
200
|
+
if (!resolvedBase.ok) continue;
|
|
201
|
+
dirs = readdirSync(resolvedBase.path).map((d) => join(base, d));
|
|
202
|
+
} catch { continue; }
|
|
203
|
+
}
|
|
86
204
|
else dirs = [String(g)];
|
|
87
205
|
for (const d of dirs) {
|
|
88
|
-
const p =
|
|
206
|
+
const p = readRepoJson(boundary, join(d, "package.json"));
|
|
89
207
|
if (p && p.name) names.add(p.name);
|
|
90
208
|
}
|
|
91
209
|
}
|
|
210
|
+
for (const scope of collectPackageScopes(repoRoot, pkg)) if (scope.pkg?.name) names.add(scope.pkg.name);
|
|
92
211
|
return names;
|
|
93
212
|
}
|
|
94
213
|
|
|
@@ -100,18 +219,22 @@ export function collectPyManifest(repoRoot) {
|
|
|
100
219
|
const deps = [];
|
|
101
220
|
let present = false;
|
|
102
221
|
let names = [];
|
|
103
|
-
|
|
104
|
-
try { names
|
|
222
|
+
const boundary = createRepoBoundary(repoRoot);
|
|
223
|
+
try { names = readdirSync(boundary.root).filter((n) => /^requirements[\w.-]*\.(txt|in)$/i.test(n)); } catch { /* unreadable root */ }
|
|
224
|
+
try {
|
|
225
|
+
const requirements = boundary.resolve("requirements");
|
|
226
|
+
if (requirements.ok) names.push(...readdirSync(requirements.path).filter((n) => /\.(txt|in)$/i.test(n)).map((n) => `requirements/${n}`));
|
|
227
|
+
} catch { /* no requirements dir */ }
|
|
105
228
|
for (const n of names) {
|
|
106
|
-
const t =
|
|
229
|
+
const t = readRepoText(boundary, n);
|
|
107
230
|
if (t == null) continue;
|
|
108
231
|
present = true;
|
|
109
232
|
const dev = /dev|test|lint|doc|ci/i.test(n.replace(/^requirements[/\\]?/i, ""));
|
|
110
233
|
for (const d of parseRequirementsNames(t)) deps.push({ ...d, dev });
|
|
111
234
|
}
|
|
112
|
-
const pp =
|
|
235
|
+
const pp = readRepoText(boundary, "pyproject.toml");
|
|
113
236
|
if (pp != null) { const r = parsePyprojectDeps(pp); if (r.present) { present = true; deps.push(...r.deps); } }
|
|
114
|
-
const pf =
|
|
237
|
+
const pf = readRepoText(boundary, "Pipfile");
|
|
115
238
|
if (pf != null) { const r = parsePipfileDeps(pf); if (r.present) { present = true; deps.push(...r.deps); } }
|
|
116
239
|
return { present, deps };
|
|
117
240
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// internal-audit.reach.js — entry-point discovery + file-level reachability BFS for the internal
|
|
2
2
|
// audit. Split from internal-audit.js.
|
|
3
|
-
import {
|
|
3
|
+
import { posix } from "node:path";
|
|
4
|
+
import { ENTRY_FILE, isFrameworkEntryFile } from "./dead-check.js";
|
|
4
5
|
import { TEST_FILE_RE } from "./internal-audit.collect.js";
|
|
5
6
|
|
|
6
7
|
const isFileNode = (n) => !String(n.id).includes("#");
|
|
@@ -8,20 +9,60 @@ const isFileNode = (n) => !String(n.id).includes("#");
|
|
|
8
9
|
// Entry set for reachability: conventional entry names + package.json main/module/browser/bin/exports +
|
|
9
10
|
// html pages (they root classic-script apps) + test files (the runner enters them) + root config files +
|
|
10
11
|
// dynamic-import targets. Anything reachable from here is "used"; the rest corroborates unused-file.
|
|
11
|
-
export function entryFiles(graph,
|
|
12
|
+
export function entryFiles(graph, pkgOrScopes, dynamicTargets = new Set(), { declaredEntries = [], sources = new Map() } = {}) {
|
|
12
13
|
const entries = new Set();
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
const scopes = Array.isArray(pkgOrScopes)
|
|
15
|
+
? pkgOrScopes
|
|
16
|
+
: [{ root: "", manifest: "package.json", pkg: pkgOrScopes || {} }];
|
|
17
|
+
const fileSet = new Set((graph.nodes || []).filter(isFileNode).map((n) => String(n.source_file || n.id).replace(/\\/g, "/")));
|
|
18
|
+
const pe = new Set();
|
|
19
|
+
const resolveEntry = (root, raw) => {
|
|
20
|
+
let p = String(raw || "").trim().replace(/^['"]|['"]$/g, "").replace(/^file:/, "").replace(/\\/g, "/");
|
|
21
|
+
if (!p || p.startsWith("-") || /^https?:/.test(p)) return "";
|
|
22
|
+
p = p.replace(/^\.\//, "");
|
|
23
|
+
const joined = posix.normalize(root ? posix.join(root, p) : p).replace(/^\.\//, "");
|
|
24
|
+
if (fileSet.has(joined)) return joined;
|
|
25
|
+
for (const ext of [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py"]) if (fileSet.has(joined + ext)) return joined + ext;
|
|
26
|
+
return joined;
|
|
27
|
+
};
|
|
28
|
+
for (const scope of scopes) {
|
|
29
|
+
const pkg = scope.pkg || {};
|
|
30
|
+
const root = String(scope.root || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
31
|
+
const pkgEntries = [];
|
|
32
|
+
for (const k of ["main", "module", "browser"]) if (typeof pkg[k] === "string") pkgEntries.push(pkg[k]);
|
|
33
|
+
if (pkg.bin) pkgEntries.push(...(typeof pkg.bin === "string" ? [pkg.bin] : Object.values(pkg.bin)));
|
|
34
|
+
(function walkExports(e) {
|
|
35
|
+
if (typeof e === "string") pkgEntries.push(e);
|
|
36
|
+
else if (e && typeof e === "object") Object.values(e).forEach(walkExports);
|
|
37
|
+
})(pkg.exports);
|
|
38
|
+
// Script commands are external entry surfaces. Extract only path-shaped source tokens; package names,
|
|
39
|
+
// flags and shell operators are intentionally ignored.
|
|
40
|
+
for (const script of Object.values(pkg.scripts || {})) {
|
|
41
|
+
const re = /(?:^|[\s'"=])((?:\.?\.?\/)?[\w@./-]+\.(?:[cm]?[jt]sx?|py|go))(?=$|[\s'";,)&|])/gi;
|
|
42
|
+
let m;
|
|
43
|
+
while ((m = re.exec(String(script)))) pkgEntries.push(m[1]);
|
|
44
|
+
const runner = /\b(?:node|bun|deno|tsx|ts-node|python\d*|electron)\s+(?!-)([\w@./\\-]+)/gi;
|
|
45
|
+
while ((m = runner.exec(String(script)))) if (/[./\\]/.test(m[1])) pkgEntries.push(m[1]);
|
|
46
|
+
}
|
|
47
|
+
for (const p of pkgEntries) pe.add(resolveEntry(root, p));
|
|
48
|
+
}
|
|
21
49
|
for (const n of graph.nodes || []) {
|
|
22
50
|
if (!isFileNode(n)) continue;
|
|
23
51
|
const f = n.source_file;
|
|
24
|
-
if (ENTRY_FILE.test(f) || TEST_FILE_RE.test(f) || /\.html?$/i.test(f) || pe.has(f) || /(^|\/)[^/]*\.config\.[a-z]+$/i.test(f)) entries.add(f);
|
|
52
|
+
if (ENTRY_FILE.test(f) || isFrameworkEntryFile(f) || /\.d\.[cm]?ts$/i.test(f) || TEST_FILE_RE.test(f) || /\.html?$/i.test(f) || pe.has(f) || /(^|\/)[^/]*\.config\.[a-z]+$/i.test(f)) entries.add(f);
|
|
53
|
+
}
|
|
54
|
+
for (const raw of Array.isArray(declaredEntries) ? declaredEntries : [declaredEntries]) {
|
|
55
|
+
const resolved = resolveEntry("", raw);
|
|
56
|
+
if (resolved && fileSet.has(resolved)) entries.add(resolved);
|
|
57
|
+
}
|
|
58
|
+
// Bundled helper programs are often launched by filename (`resolveResource("worker.py")`) rather than
|
|
59
|
+
// imported. A quoted basename reference from another source file is enough to establish that a code file
|
|
60
|
+
// under resources/ is a runtime entry, without treating arbitrary prose/config files as roots.
|
|
61
|
+
for (const file of fileSet) {
|
|
62
|
+
if (!/(^|\/)resources\/.*\.(?:py|[cm]?[jt]s)$/i.test(file)) continue;
|
|
63
|
+
const base = posix.basename(file).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
64
|
+
const quoted = new RegExp(`["'\x60]${base}["'\x60]`);
|
|
65
|
+
if ([...sources].some(([other, text]) => other !== file && quoted.test(String(text || "")))) entries.add(file);
|
|
25
66
|
}
|
|
26
67
|
for (const t of dynamicTargets) entries.add(t);
|
|
27
68
|
return entries;
|
|
@@ -4,52 +4,73 @@
|
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { join, basename } from "node:path";
|
|
6
6
|
import { computeDead, computeUnusedExports } from "./dead-check.js";
|
|
7
|
-
import {
|
|
7
|
+
import { computeScopedDepFindings, computeGoDepFindings, computePyDepFindings } from "./dep-check.js";
|
|
8
8
|
import { parseGoMod } from "./manifests.js";
|
|
9
9
|
import { computeStructureFindings } from "./dep-rules.js";
|
|
10
10
|
import { makeFinding, summarizeFindings, sortFindings } from "./findings.js";
|
|
11
11
|
import { graphOutDirForRepo } from "../graph/layout.js";
|
|
12
12
|
import { collectInstalled } from "../security/installed.js";
|
|
13
|
-
import { loadStore, queryStore } from "../security/advisory-store.js";
|
|
13
|
+
import { loadStore, queryStore, advisoryQueryFingerprint } from "../security/advisory-store.js";
|
|
14
14
|
import { matchAdvisories } from "../security/match.js";
|
|
15
15
|
import { scanMalware } from "../security/malware-heuristics.js";
|
|
16
16
|
import { classifyTyposquat } from "../security/typosquat.js";
|
|
17
17
|
import {
|
|
18
|
-
|
|
19
|
-
collectPyManifest, TEST_FILE_RE,
|
|
18
|
+
readJson, readRepoText, readRepoJson, collectSourceTexts, collectConfigTexts, workspacePkgNames,
|
|
19
|
+
collectPackageScopes, collectPyManifest, TEST_FILE_RE,
|
|
20
20
|
} from "./internal-audit.collect.js";
|
|
21
21
|
import { entryFiles, computeReachability } from "./internal-audit.reach.js";
|
|
22
|
+
import { createRepoBoundary } from "../repo-path.js";
|
|
22
23
|
|
|
23
24
|
// Run the internal audit. graph is optional (loaded from the repo's central graph.json when absent);
|
|
24
25
|
// advisoryStorePath overrides the default ~/.weavatrix/advisories.json (tests use a scratch path).
|
|
25
26
|
// async because the malware sweep shells out to ripgrep.
|
|
26
27
|
export async function runInternalAudit(repoPath, { graph, advisoryStorePath, skipMalwareScan = false, malwareExclusions = {}, rgPath = "" } = {}) {
|
|
27
28
|
if (!existsSync(repoPath)) return { ok: false, error: "Repo path not found" };
|
|
29
|
+
const boundary = createRepoBoundary(repoPath);
|
|
30
|
+
if (!boundary.root) return { ok: false, error: "Repository path is unreadable" };
|
|
28
31
|
if (!graph) {
|
|
29
32
|
graph = readJson(join(graphOutDirForRepo(repoPath), "graph.json"));
|
|
30
33
|
if (!graph) return { ok: false, error: "Build the graph first (no graph.json)" };
|
|
31
34
|
}
|
|
32
|
-
const pkg =
|
|
35
|
+
const pkg = readRepoJson(boundary, "package.json") || {};
|
|
36
|
+
const packageScopes = collectPackageScopes(repoPath, pkg);
|
|
33
37
|
const externalImports = graph.externalImports || [];
|
|
34
38
|
const dynamicTargets = new Set(externalImports.filter((e) => e.dynamic && e.target).map((e) => e.target));
|
|
39
|
+
const rules = readRepoJson(boundary, ".weavatrix-deps.json") || {};
|
|
35
40
|
|
|
36
41
|
// Graphs can be stale or miss a helper file; text fallbacks must scan the real repo tree too.
|
|
37
42
|
const sources = collectSourceTexts(repoPath, graph);
|
|
38
43
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
const entries = entryFiles(graph, packageScopes, dynamicTargets, {
|
|
45
|
+
declaredEntries: rules.entrypoints || rules.entries || [],
|
|
46
|
+
sources,
|
|
47
|
+
});
|
|
48
|
+
const dead = computeDead(graph, sources, { entrySet: entries });
|
|
49
|
+
const unusedExports = computeUnusedExports(graph, sources, { dynamicTargets, entrySet: entries });
|
|
42
50
|
const reachable = computeReachability(graph, entries);
|
|
43
51
|
const configTexts = collectConfigTexts(repoPath);
|
|
44
|
-
const dep =
|
|
52
|
+
const dep = computeScopedDepFindings({ externalImports, packageScopes, workspacePkgNames: workspacePkgNames(repoPath, pkg), configTexts });
|
|
45
53
|
// non-npm ecosystems: Go (go.mod) + Python (requirements/pyproject/Pipfile) — same findings shape
|
|
46
|
-
const goModText =
|
|
54
|
+
const goModText = readRepoText(boundary, "go.mod");
|
|
47
55
|
const goDep = computeGoDepFindings({ externalImports, goMod: goModText != null ? parseGoMod(goModText) : null });
|
|
48
|
-
const
|
|
56
|
+
const asList = (v) => Array.isArray(v) ? v : typeof v === "string" ? [v] : [];
|
|
57
|
+
const pyRules = rules.python || {};
|
|
58
|
+
const depRules = rules.dependencies || {};
|
|
59
|
+
const managedPython = [...new Set([
|
|
60
|
+
...asList(rules.managedPythonDependencies), ...asList(pyRules.managed), ...asList(pyRules.managedDependencies),
|
|
61
|
+
...asList(depRules.managedPython),
|
|
62
|
+
])];
|
|
63
|
+
const ignoredPython = [...new Set([
|
|
64
|
+
...asList(rules.ignorePythonDependencies), ...asList(pyRules.ignore), ...asList(pyRules.ignoreDependencies),
|
|
65
|
+
...asList(depRules.ignorePython),
|
|
66
|
+
])];
|
|
67
|
+
const pyDep = computePyDepFindings({
|
|
68
|
+
externalImports, pyManifest: collectPyManifest(repoPath), configTexts,
|
|
69
|
+
managedDependencies: managedPython, ignoredDependencies: ignoredPython,
|
|
70
|
+
});
|
|
49
71
|
|
|
50
72
|
// structure: cycles / orphans / boundary rules. Rules come from the repo's optional .weavatrix-deps.json
|
|
51
73
|
// (the depcruise-config analogue); no bundled default rules — cycles+orphans are always on.
|
|
52
|
-
const rules = readJson(join(repoPath, ".weavatrix-deps.json")) || {};
|
|
53
74
|
const externalImportFiles = new Set(externalImports.filter((e) => e.pkg && !e.builtin).map((e) => e.file));
|
|
54
75
|
const structure = computeStructureFindings(graph, { rules, entrySet: entries, externalImportFiles });
|
|
55
76
|
|
|
@@ -58,7 +79,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
58
79
|
const deadFileSet = new Set(dead.deadFiles.map((f) => f.file));
|
|
59
80
|
for (const f of structure.findings) if (!(f.rule === "orphan-file" && deadFileSet.has(f.file))) findings.push(f);
|
|
60
81
|
for (const f of dead.deadFiles) {
|
|
61
|
-
if (dynamicTargets.has(f.file) || TEST_FILE_RE.test(f.file)) continue;
|
|
82
|
+
if (entries.has(f.file) || dynamicTargets.has(f.file) || TEST_FILE_RE.test(f.file)) continue;
|
|
62
83
|
findings.push(makeFinding({
|
|
63
84
|
category: "unused",
|
|
64
85
|
rule: "unused-file",
|
|
@@ -72,6 +93,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
72
93
|
fixHint: "review, then delete the file",
|
|
73
94
|
}));
|
|
74
95
|
}
|
|
96
|
+
let unusedExportCount = 0;
|
|
75
97
|
for (const s of unusedExports) {
|
|
76
98
|
if (s.test) continue; // exports from test files are runner-visible noise
|
|
77
99
|
if (/(^|\/)[^/]*\.config\.[a-z0-9]+$|(^|\/)\.[^/]+rc(\.[a-z]+)?$/i.test(s.file)) continue; // config exports are consumed by their tool
|
|
@@ -87,6 +109,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
87
109
|
graphNodeId: s.id,
|
|
88
110
|
source: "internal",
|
|
89
111
|
}));
|
|
112
|
+
unusedExportCount++;
|
|
90
113
|
}
|
|
91
114
|
|
|
92
115
|
// ---- supply-chain: installed packages × cached OSV advisories. 100% OFFLINE here — the cache is
|
|
@@ -94,14 +117,31 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
94
117
|
let advisoryDbDate = null;
|
|
95
118
|
let installedCount = 0;
|
|
96
119
|
let inst = { installed: [], drift: [] };
|
|
120
|
+
const checks = {
|
|
121
|
+
osv: { status: "NOT_CHECKED", detail: "Advisory cache was never refreshed for this repository. The refresh_advisories tool belongs to the optional online capability group: enable that group in the MCP registration, then call the tool explicitly to opt in to sending pinned package names and versions to OSV.dev." },
|
|
122
|
+
malware: { status: skipMalwareScan ? "NOT_CHECKED" : "PENDING", detail: skipMalwareScan ? "Installed-package malware scan is opt-in and was not requested." : "" },
|
|
123
|
+
};
|
|
97
124
|
try {
|
|
98
125
|
inst = collectInstalled(repoPath);
|
|
99
126
|
installedCount = inst.installed.length;
|
|
100
127
|
const store = advisoryStorePath ? loadStore(advisoryStorePath) : loadStore();
|
|
101
|
-
// per-repo
|
|
102
|
-
//
|
|
103
|
-
|
|
128
|
+
// Only a per-repo stamp proves that this repository's installed versions were queried. A legacy
|
|
129
|
+
// global fetched_at may belong to another repo and must never certify this one as clean.
|
|
130
|
+
const repoStamp = store.meta?.repos?.[repoPath] || null;
|
|
131
|
+
advisoryDbDate = typeof repoStamp === "string" ? repoStamp : repoStamp?.fetched_at || null;
|
|
104
132
|
if (advisoryDbDate) {
|
|
133
|
+
let status = typeof repoStamp === "object" && ["OK", "PARTIAL", "ERROR"].includes(repoStamp.status) ? repoStamp.status : "PARTIAL";
|
|
134
|
+
const fingerprintMatches = typeof repoStamp === "object" && repoStamp.query_fingerprint === advisoryQueryFingerprint(inst.installed);
|
|
135
|
+
if (!fingerprintMatches && status === "OK") status = "PARTIAL";
|
|
136
|
+
const coverage = typeof repoStamp === "object" && Number.isFinite(repoStamp.queried)
|
|
137
|
+
? ` (${repoStamp.queried_ok ?? repoStamp.queried}/${repoStamp.queried} package versions queried successfully)`
|
|
138
|
+
: "";
|
|
139
|
+
const drift = fingerprintMatches ? "" : " Dependency versions changed, or this is a legacy stamp without a package fingerprint; enable the optional online capability group and call refresh_advisories for complete coverage.";
|
|
140
|
+
checks.osv = {
|
|
141
|
+
status,
|
|
142
|
+
detail: `${status === "PARTIAL" ? "Partially matched" : "Matched"} installed packages against the cached OSV snapshot from ${advisoryDbDate}${coverage}.${drift}`,
|
|
143
|
+
checkedAt: advisoryDbDate,
|
|
144
|
+
};
|
|
105
145
|
for (const h of matchAdvisories(inst.installed, (eco, name) => queryStore(store, eco, name))) {
|
|
106
146
|
const mal = h.adv.kind === "malicious";
|
|
107
147
|
findings.push(makeFinding({
|
|
@@ -120,7 +160,8 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
120
160
|
}
|
|
121
161
|
}
|
|
122
162
|
// direct-dependency typosquat (dev-chosen names, small set → low FP): surface quietly even alone.
|
|
123
|
-
|
|
163
|
+
const directDependencyNames = new Set(packageScopes.flatMap((s) => Object.keys({ ...(s.pkg?.dependencies || {}), ...(s.pkg?.devDependencies || {}) })));
|
|
164
|
+
for (const name of directDependencyNames) {
|
|
124
165
|
const sq = classifyTyposquat(name);
|
|
125
166
|
if (!sq) continue;
|
|
126
167
|
findings.push(makeFinding({
|
|
@@ -149,7 +190,9 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
149
190
|
fixHint: "npm ci (clean install from the lockfile)",
|
|
150
191
|
}));
|
|
151
192
|
}
|
|
152
|
-
} catch {
|
|
193
|
+
} catch (error) {
|
|
194
|
+
checks.osv = { status: "ERROR", detail: `Offline advisory matching failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
195
|
+
}
|
|
153
196
|
|
|
154
197
|
// ---- malware heuristics: install-script beacons / miners / exfil / obfuscation across installed libs.
|
|
155
198
|
// Local + offline (ripgrep or a bounded Node fallback). Scans node_modules, Python venvs, Go vendor/cache.
|
|
@@ -160,7 +203,10 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
160
203
|
const scan = await scanMalware(repoPath, { installed: inst.installed, importedPkgs, malwareExclusions, rgPath });
|
|
161
204
|
findings.push(...scan.findings);
|
|
162
205
|
malwareScan = { scanMode: scan.scanMode, packagesScanned: scan.packagesScanned, findings: scan.findings.length, excludedSignals: scan.excludedSignals || 0 };
|
|
163
|
-
|
|
206
|
+
checks.malware = { status: "OK", detail: `Scanned ${scan.packagesScanned} installed package(s) using ${scan.scanMode}.` };
|
|
207
|
+
} catch (error) {
|
|
208
|
+
checks.malware = { status: "ERROR", detail: `Installed-package malware scan failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
209
|
+
}
|
|
164
210
|
}
|
|
165
211
|
|
|
166
212
|
const sorted = sortFindings(findings);
|
|
@@ -175,15 +221,20 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
175
221
|
symbols: dead.stats.symbols,
|
|
176
222
|
manifestDeps: dep.declared.size + goDep.declared.size + pyDep.declared.size,
|
|
177
223
|
externalImports: externalImports.length,
|
|
178
|
-
nodeModulesPresent:
|
|
224
|
+
nodeModulesPresent: boundary.resolve("node_modules").ok,
|
|
179
225
|
installedPackages: installedCount,
|
|
180
226
|
advisoryDbDate,
|
|
227
|
+
advisoryStatus: checks.osv.status,
|
|
181
228
|
malwareScanMode: malwareScan?.scanMode || "skipped",
|
|
229
|
+
malwareStatus: checks.malware.status,
|
|
230
|
+
packageScopes: packageScopes.length,
|
|
231
|
+
managedPythonDependencies: managedPython.length,
|
|
182
232
|
},
|
|
183
233
|
summary: summarizeFindings(sorted),
|
|
184
234
|
findings: sorted,
|
|
185
|
-
deadReport: { deadSymbols: dead.deadSymbols.length, deadFiles: dead.deadFiles.length, unusedExports:
|
|
235
|
+
deadReport: { deadSymbols: dead.deadSymbols.length, deadFiles: dead.deadFiles.length, unusedExports: unusedExportCount },
|
|
186
236
|
structureReport: structure.stats,
|
|
237
|
+
checks,
|
|
187
238
|
malwareScan,
|
|
188
239
|
};
|
|
189
240
|
}
|
package/src/build-graph.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { existsSync, writeFileSync, mkdirSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { Worker } from "node:worker_threads";
|
|
12
|
+
import { childProcessEnv } from "./child-env.js";
|
|
12
13
|
import { graphOutDirForRepo, repoTopFolders, summarizeCommunities, summarizeHotspots, filterGraphForMode, filterGraphByScope } from "./graph/layout.js";
|
|
13
14
|
|
|
14
15
|
// The worker path deadlocks web-tree-sitter's WASM in Electron's worker threads (fine in plain Node) — off
|
|
@@ -25,7 +26,7 @@ function buildGraphInWorker(payload) {
|
|
|
25
26
|
return new Promise((resolve, reject) => {
|
|
26
27
|
let worker;
|
|
27
28
|
try {
|
|
28
|
-
worker = new Worker(new URL("./graph/build-worker.js", import.meta.url), { workerData: payload });
|
|
29
|
+
worker = new Worker(new URL("./graph/build-worker.js", import.meta.url), { workerData: payload, env: childProcessEnv() });
|
|
29
30
|
} catch (e) {
|
|
30
31
|
reject(Object.assign(e, { workerStartFailed: true }));
|
|
31
32
|
return;
|
package/src/child-env.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Child processes and worker threads do not need the hosted-sync bearer token.
|
|
2
|
+
// Keep it in the MCP process so only sync_graph can attach it to an HTTP request.
|
|
3
|
+
export function childProcessEnv(overrides = {}) {
|
|
4
|
+
const env = { ...process.env, ...overrides };
|
|
5
|
+
delete env.WEAVATRIX_SYNC_TOKEN;
|
|
6
|
+
return env;
|
|
7
|
+
}
|