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,52 @@
|
|
|
1
|
+
// Unified Finding factory + rollups for the internal dependency/security engine (DEPS_SECURITY_PLAN.md §2.3).
|
|
2
|
+
// Every analyzer (unused / structure / vulnerability / malware) and the external-tool adapter emit THIS shape,
|
|
3
|
+
// so the renderer and the AI summarizer consume one contract regardless of engine.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
export const SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"];
|
|
7
|
+
export const FINDING_CATEGORIES = ["unused", "structure", "vulnerability", "malware"];
|
|
8
|
+
|
|
9
|
+
// Stable id: survives re-runs so the UI can persist expand/dismiss state per finding.
|
|
10
|
+
export function makeFinding(f) {
|
|
11
|
+
const id = createHash("sha1")
|
|
12
|
+
.update([f.category, f.rule, f.file || "", f.package || "", f.symbol || "", f.title || ""].join("|"))
|
|
13
|
+
.digest("hex")
|
|
14
|
+
.slice(0, 16);
|
|
15
|
+
return {
|
|
16
|
+
id,
|
|
17
|
+
severity: "info",
|
|
18
|
+
confidence: "medium",
|
|
19
|
+
title: "",
|
|
20
|
+
detail: "",
|
|
21
|
+
file: "",
|
|
22
|
+
line: 0,
|
|
23
|
+
symbol: "",
|
|
24
|
+
package: "",
|
|
25
|
+
version: "",
|
|
26
|
+
graphNodeId: f.graphNodeId || f.file || "",
|
|
27
|
+
evidence: [],
|
|
28
|
+
source: "internal",
|
|
29
|
+
fixHint: "",
|
|
30
|
+
...f,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function summarizeFindings(findings) {
|
|
35
|
+
const bySeverity = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
36
|
+
const byCategory = { unused: 0, structure: 0, vulnerability: 0, malware: 0 };
|
|
37
|
+
for (const f of findings || []) {
|
|
38
|
+
if (f.severity in bySeverity) bySeverity[f.severity]++;
|
|
39
|
+
if (f.category in byCategory) byCategory[f.category]++;
|
|
40
|
+
}
|
|
41
|
+
return { bySeverity, byCategory };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function sortFindings(findings) {
|
|
45
|
+
const sev = new Map(SEVERITY_ORDER.map((s, i) => [s, i]));
|
|
46
|
+
return [...(findings || [])].sort(
|
|
47
|
+
(a, b) =>
|
|
48
|
+
(sev.get(a.severity) ?? 9) - (sev.get(b.severity) ?? 9) ||
|
|
49
|
+
String(a.category).localeCompare(String(b.category)) ||
|
|
50
|
+
String(a.file || a.package || "").localeCompare(String(b.file || b.package || ""))
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Aggregating a built graph.json into the file/module/symbol rollup the UI needs. `aggregateGraph`
|
|
2
|
+
// is pure over the parsed graph (covered by tests) apart from optional repoRoot file reads;
|
|
3
|
+
// `analyzeGraph` reads the graph file from disk first.
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { normalizeRepoParts, readCoverageForRepo, pctFromCounts } from "./coverage-reports.js";
|
|
7
|
+
import { bareSymbolName, countLocalRefsOutsideOwnRange, computeSymbolExternalRefs } from "./graph-analysis.refs.js";
|
|
8
|
+
|
|
9
|
+
// Aggregate a built graph.json into the file- and module-level view the UI needs:
|
|
10
|
+
// - graph-builder nodes are FILES *and* their symbols (functions/methods), linked file→symbol by the
|
|
11
|
+
// "contains" relation. So a community's raw node count (what the cards show) is NOT a file count.
|
|
12
|
+
// - Each file is assigned to ONE module via its file-node's community (symbols may cluster apart,
|
|
13
|
+
// but the file itself belongs where its file-node sits) → exact, non-overlapping file counts.
|
|
14
|
+
// - Real edges (everything except "contains") roll up to file→file and module→module relations.
|
|
15
|
+
// Module names match summarizeCommunities (dominant first-two-path-parts), so they line up with cards.
|
|
16
|
+
export function analyzeGraph(graphJsonPath, repoRoot) {
|
|
17
|
+
let graph;
|
|
18
|
+
try {
|
|
19
|
+
graph = JSON.parse(readFileSync(graphJsonPath, "utf8"));
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return aggregateGraph(graph, repoRoot);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Pure aggregation over an already-parsed graph object — split out of analyzeGraph so it can be
|
|
27
|
+
// unit-tested without a graph.json on disk. Touches the filesystem only when repoRoot is provided
|
|
28
|
+
// (to read each source file's line count for the size colouring).
|
|
29
|
+
export function aggregateGraph(graph, repoRoot) {
|
|
30
|
+
const nodes = graph.nodes || [];
|
|
31
|
+
const links = graph.links || [];
|
|
32
|
+
const endpoint = (value) => (value && typeof value === "object" ? value.id : value);
|
|
33
|
+
|
|
34
|
+
// symbol nodes = targets of a "contains" edge (a file "contains" its functions/methods)
|
|
35
|
+
const symbolIds = new Set();
|
|
36
|
+
for (const link of links) if (link.relation === "contains") symbolIds.add(endpoint(link.target));
|
|
37
|
+
|
|
38
|
+
// community → dominant folder name (over all code nodes — same rule as summarizeCommunities)
|
|
39
|
+
// MODULE = the file's own top FOLDER (its "territory"), up to 2 path levels — NOT a dependency
|
|
40
|
+
// cluster. e.g. src/widget/foo.js → "src/widget"; benchmarks/cleanup.py → "benchmarks" (so all
|
|
41
|
+
// benchmarks files are one module); a top-level file like index.js → "(root)".
|
|
42
|
+
const folderOf = (file) => {
|
|
43
|
+
const dirs = String(file || "").split(/[\\/]/).filter(Boolean).slice(0, -1);
|
|
44
|
+
return dirs.length ? dirs.slice(0, 2).join("/") : "(root)";
|
|
45
|
+
};
|
|
46
|
+
// In a merged (cross-repo) graph every node carries a `repo`; qualify file & module identity by it
|
|
47
|
+
// so same-named folders/files in different repos never merge. Single-repo graphs have no `repo`,
|
|
48
|
+
// so identity stays the bare path (unchanged behavior).
|
|
49
|
+
const fileIdOf = (node) => (node.repo ? `${node.repo}::${node.source_file}` : node.source_file);
|
|
50
|
+
const moduleOfNode = (node) => (node.repo ? `${node.repo}/` : "") + folderOf(node.source_file);
|
|
51
|
+
|
|
52
|
+
// file → module (folder) + symbols + display path, all keyed by repo-qualified file id
|
|
53
|
+
const fileModule = new Map();
|
|
54
|
+
const fileSymbols = new Map();
|
|
55
|
+
const filePath = new Map();
|
|
56
|
+
const moduleRepo = new Map();
|
|
57
|
+
for (const node of nodes) {
|
|
58
|
+
if (node.file_type !== "code" || !node.source_file) continue;
|
|
59
|
+
const fid = fileIdOf(node);
|
|
60
|
+
if (symbolIds.has(node.id)) {
|
|
61
|
+
const list = fileSymbols.get(fid) || [];
|
|
62
|
+
list.push({
|
|
63
|
+
id: node.id,
|
|
64
|
+
label: node.label || node.norm_label || node.id,
|
|
65
|
+
line: String(node.source_location || "").replace(/^L/, ""),
|
|
66
|
+
endLine: String(node.source_end || "").replace(/^L/, ""),
|
|
67
|
+
...(node.complexity ? { complexity: node.complexity } : {}),
|
|
68
|
+
...(node.decorated ? { decorated: true } : {})
|
|
69
|
+
});
|
|
70
|
+
fileSymbols.set(fid, list);
|
|
71
|
+
}
|
|
72
|
+
if (!fileModule.has(fid)) {
|
|
73
|
+
const mod = moduleOfNode(node);
|
|
74
|
+
fileModule.set(fid, mod);
|
|
75
|
+
filePath.set(fid, node.source_file);
|
|
76
|
+
if (node.repo) moduleRepo.set(mod, node.repo);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// raw node count per module (files + their symbols), for the honest "X files · Y nodes"
|
|
81
|
+
const moduleNodeCount = new Map();
|
|
82
|
+
for (const node of nodes) {
|
|
83
|
+
if (node.file_type !== "code" || !node.source_file) continue;
|
|
84
|
+
const name = moduleOfNode(node);
|
|
85
|
+
moduleNodeCount.set(name, (moduleNodeCount.get(name) || 0) + 1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// modules: folder → its files (+ symbol breakdown). `repo` is set for cross-repo graphs.
|
|
89
|
+
const modules = new Map();
|
|
90
|
+
for (const [fid, name] of fileModule) {
|
|
91
|
+
const mod = modules.get(name) || { name, repo: moduleRepo.get(name) || null, files: [] };
|
|
92
|
+
const symbols = fileSymbols.get(fid) || [];
|
|
93
|
+
mod.files.push({ file: fid, path: filePath.get(fid) || fid, symbolCount: symbols.length, symbols: symbols.slice(0, 300) });
|
|
94
|
+
modules.set(name, mod);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// file→file and module→module edges (skip "contains"; map endpoints by repo-qualified file / module)
|
|
98
|
+
const id2file = new Map();
|
|
99
|
+
for (const node of nodes) if (node.source_file) id2file.set(node.id, fileIdOf(node));
|
|
100
|
+
const fileEdges = new Map(); // key → { count, rels:{relation→n} } so we can emit the DOMINANT relation (call/import/inherit)
|
|
101
|
+
const moduleEdges = new Map();
|
|
102
|
+
for (const link of links) {
|
|
103
|
+
if (link.relation === "contains") continue;
|
|
104
|
+
const fromFile = id2file.get(endpoint(link.source));
|
|
105
|
+
const toFile = id2file.get(endpoint(link.target));
|
|
106
|
+
if (fromFile && toFile && fromFile !== toFile) {
|
|
107
|
+
const key = `${fromFile} ${toFile}`;
|
|
108
|
+
let fe = fileEdges.get(key);
|
|
109
|
+
if (!fe) fileEdges.set(key, (fe = { count: 0, rels: {} }));
|
|
110
|
+
fe.count++;
|
|
111
|
+
if (link.relation) fe.rels[link.relation] = (fe.rels[link.relation] || 0) + 1;
|
|
112
|
+
const fromMod = fileModule.get(fromFile);
|
|
113
|
+
const toMod = fileModule.get(toFile);
|
|
114
|
+
if (fromMod && toMod && fromMod !== toMod) {
|
|
115
|
+
const mkey = `${fromMod} ${toMod}`;
|
|
116
|
+
moduleEdges.set(mkey, (moduleEdges.get(mkey) || 0) + 1);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const split = (key) => {
|
|
121
|
+
const i = key.indexOf(" ");
|
|
122
|
+
return [key.slice(0, i), key.slice(i + 1)];
|
|
123
|
+
};
|
|
124
|
+
const edgeList = (map) =>
|
|
125
|
+
[...map.entries()]
|
|
126
|
+
.map(([key, v]) => {
|
|
127
|
+
const [from, to] = split(key);
|
|
128
|
+
if (typeof v === "number") return { from, to, count: v }; // moduleEdges (no relation breakdown)
|
|
129
|
+
const dom = Object.entries(v.rels).sort((a, b) => b[1] - a[1])[0];
|
|
130
|
+
return { from, to, count: v.count, relation: dom ? dom[0] : null };
|
|
131
|
+
})
|
|
132
|
+
.sort((a, b) => b.count - a.count);
|
|
133
|
+
|
|
134
|
+
// symbol-level (function/method) call graph: edges between symbol nodes (calls/method), each symbol
|
|
135
|
+
// tagged with its file's module so the Symbols view can still cluster into module regions.
|
|
136
|
+
const symEdges = new Map();
|
|
137
|
+
for (const link of links) {
|
|
138
|
+
if (link.relation === "contains") continue;
|
|
139
|
+
const s = endpoint(link.source);
|
|
140
|
+
const t = endpoint(link.target);
|
|
141
|
+
if (s === t || !symbolIds.has(s) || !symbolIds.has(t)) continue;
|
|
142
|
+
const key = `${s}\t${t}`;
|
|
143
|
+
symEdges.set(key, (symEdges.get(key) || 0) + 1);
|
|
144
|
+
}
|
|
145
|
+
const connectedSyms = new Set();
|
|
146
|
+
for (const key of symEdges.keys()) {
|
|
147
|
+
const tab = key.indexOf("\t");
|
|
148
|
+
connectedSyms.add(key.slice(0, tab));
|
|
149
|
+
connectedSyms.add(key.slice(tab + 1));
|
|
150
|
+
}
|
|
151
|
+
const symbols = [];
|
|
152
|
+
for (const node of nodes) {
|
|
153
|
+
if (!symbolIds.has(node.id) || !connectedSyms.has(node.id)) continue;
|
|
154
|
+
const fid = fileIdOf(node);
|
|
155
|
+
symbols.push({
|
|
156
|
+
id: node.id,
|
|
157
|
+
label: node.label || node.norm_label || node.id,
|
|
158
|
+
file: node.source_file || "",
|
|
159
|
+
module: fileModule.get(fid) || moduleOfNode(node),
|
|
160
|
+
line: String(node.source_location || "").replace(/^L/, ""),
|
|
161
|
+
endLine: String(node.source_end || "").replace(/^L/, ""),
|
|
162
|
+
...(node.complexity ? { complexity: node.complexity } : {})
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const symbolEdges = [...symEdges.entries()]
|
|
166
|
+
.map(([key, count]) => {
|
|
167
|
+
const tab = key.indexOf("\t");
|
|
168
|
+
return { from: key.slice(0, tab), to: key.slice(tab + 1), count };
|
|
169
|
+
})
|
|
170
|
+
.sort((a, b) => b.count - a.count);
|
|
171
|
+
|
|
172
|
+
// lines of code per folder (best-effort — needs the repo on disk; absent for merged combos) so the
|
|
173
|
+
// UI can color folders blue→red by size.
|
|
174
|
+
let folderLoc = null;
|
|
175
|
+
const fileLoc = new Map();
|
|
176
|
+
const fileText = new Map();
|
|
177
|
+
if (repoRoot) {
|
|
178
|
+
folderLoc = {};
|
|
179
|
+
for (const [sourceFile, mod] of fileModule) {
|
|
180
|
+
let loc = 0;
|
|
181
|
+
try {
|
|
182
|
+
const txt = readFileSync(join(repoRoot, sourceFile), "utf8");
|
|
183
|
+
loc = txt ? txt.split("\n").length : 0;
|
|
184
|
+
fileText.set(sourceFile, txt || "");
|
|
185
|
+
} catch {
|
|
186
|
+
/* file may be gone — skip */
|
|
187
|
+
}
|
|
188
|
+
fileLoc.set(sourceFile, loc);
|
|
189
|
+
folderLoc[mod] = (folderLoc[mod] || 0) + loc;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const coverageByFile = repoRoot ? readCoverageForRepo(repoRoot, [...filePath.values()]) : new Map();
|
|
193
|
+
const coverageForFile = (fid) => coverageByFile.get(normalizeRepoParts(filePath.get(fid) || fid)) || null;
|
|
194
|
+
const coverageForRange = (fid, startLine, endLine) => {
|
|
195
|
+
const cov = coverageForFile(fid);
|
|
196
|
+
if (!cov) return null;
|
|
197
|
+
if (!(cov.lines instanceof Map) || !cov.lines.size || !Number.isFinite(startLine) || !Number.isFinite(endLine)) return cov.pct ?? null;
|
|
198
|
+
let total = 0;
|
|
199
|
+
let covered = 0;
|
|
200
|
+
for (let line = Math.max(1, startLine); line <= Math.max(startLine, endLine); line++) {
|
|
201
|
+
if (!cov.lines.has(line)) continue;
|
|
202
|
+
total++;
|
|
203
|
+
if (Number(cov.lines.get(line)) > 0) covered++;
|
|
204
|
+
}
|
|
205
|
+
return total ? pctFromCounts(covered, total) : cov.pct ?? null;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const symbolExternalRefs = computeSymbolExternalRefs(filePath, fileSymbols, fileText);
|
|
209
|
+
|
|
210
|
+
// Prefer the exact AST end line emitted by the internal builder. Older graph.json files only carry a
|
|
211
|
+
// start line, so keep the historical next-declaration/EOF approximation as a compatibility fallback.
|
|
212
|
+
const symbolLoc = new Map();
|
|
213
|
+
const symbolCoverage = new Map();
|
|
214
|
+
// Source-level refs catch local value/constants usage that graph-builder's call edges can miss.
|
|
215
|
+
const symbolLocalRefs = new Map();
|
|
216
|
+
for (const [fid, list] of fileSymbols) {
|
|
217
|
+
const total = fileLoc.get(fid) || 0;
|
|
218
|
+
const txt = fileText.get(fid) || "";
|
|
219
|
+
const sorted = list
|
|
220
|
+
.map((s) => ({ id: s.id, label: s.label, start: parseInt(s.line, 10), end: parseInt(s.endLine, 10), ref: s }))
|
|
221
|
+
.filter((s) => Number.isFinite(s.start) && s.start > 0)
|
|
222
|
+
.sort((a, b) => a.start - b.start);
|
|
223
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
224
|
+
const start = sorted[i].start;
|
|
225
|
+
const next = i + 1 < sorted.length ? sorted[i + 1].start : total > start ? total + 1 : start + 1;
|
|
226
|
+
const exactEnd = Number.isFinite(sorted[i].end) && sorted[i].end >= start ? sorted[i].end : 0;
|
|
227
|
+
const end = exactEnd || Math.max(start, next - 1);
|
|
228
|
+
const loc = Math.max(1, end - start + 1);
|
|
229
|
+
symbolLoc.set(sorted[i].id, loc);
|
|
230
|
+
sorted[i].ref.loc = loc;
|
|
231
|
+
sorted[i].ref.endLine = end;
|
|
232
|
+
const cov = coverageForRange(fid, start, end);
|
|
233
|
+
if (cov != null) symbolCoverage.set(sorted[i].id, cov);
|
|
234
|
+
const refs = countLocalRefsOutsideOwnRange(txt, bareSymbolName(sorted[i].label), start, end);
|
|
235
|
+
if (refs > 0) symbolLocalRefs.set(sorted[i].id, refs);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
for (const s of symbols) {
|
|
239
|
+
const fid = id2file.get(s.id);
|
|
240
|
+
s.loc = symbolLoc.get(s.id) || 0;
|
|
241
|
+
s.coverage = symbolCoverage.get(s.id) ?? coverageForFile(fid)?.pct ?? null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
complexityV: Number(graph.complexityV) || 0,
|
|
246
|
+
modules: [...modules.values()]
|
|
247
|
+
.map((mod) => ({
|
|
248
|
+
name: mod.name,
|
|
249
|
+
repo: mod.repo || null,
|
|
250
|
+
fileCount: mod.files.length,
|
|
251
|
+
nodeCount: moduleNodeCount.get(mod.name) || 0,
|
|
252
|
+
symbolCount: mod.files.reduce((sum, f) => sum + f.symbolCount, 0),
|
|
253
|
+
files: mod.files
|
|
254
|
+
.map((f) => {
|
|
255
|
+
const cov = coverageForFile(f.file);
|
|
256
|
+
return { ...f, loc: fileLoc.get(f.file) || 0, coverage: cov?.pct ?? null, coverageSource: cov?.source || "" };
|
|
257
|
+
})
|
|
258
|
+
.sort((a, b) => b.symbolCount - a.symbolCount)
|
|
259
|
+
}))
|
|
260
|
+
.sort((a, b) => b.fileCount - a.fileCount),
|
|
261
|
+
moduleEdges: edgeList(moduleEdges),
|
|
262
|
+
fileEdges: edgeList(fileEdges),
|
|
263
|
+
symbols,
|
|
264
|
+
symbolEdges,
|
|
265
|
+
symbolRefs: [...new Set([...symbolLocalRefs.keys(), ...symbolExternalRefs.keys()])].map((id) => {
|
|
266
|
+
const sourceRefs = symbolLocalRefs.get(id) || 0;
|
|
267
|
+
const externalRefs = symbolExternalRefs.get(id) || 0;
|
|
268
|
+
return { id, localRefs: sourceRefs + externalRefs, sourceRefs, externalRefs };
|
|
269
|
+
}),
|
|
270
|
+
folderLoc,
|
|
271
|
+
totals: {
|
|
272
|
+
files: fileModule.size,
|
|
273
|
+
nodes: nodes.filter((n) => n.file_type === "code").length,
|
|
274
|
+
fileEdges: fileEdges.size,
|
|
275
|
+
moduleEdges: moduleEdges.size,
|
|
276
|
+
symbols: symbols.length,
|
|
277
|
+
symbolEdges: symbolEdges.length
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Reading a built graph.json and aggregating it into the views the UI needs: named communities,
|
|
2
|
+
// degree hotspots, and the file/module/symbol rollup. `aggregateGraph` is pure (covered by tests);
|
|
3
|
+
// the rest read the graph file from disk.
|
|
4
|
+
// Facade: implementation lives in graph-analysis.summaries.js and graph-analysis.aggregate.js
|
|
5
|
+
// (with internal helpers in graph-analysis.refs.js).
|
|
6
|
+
export { summarizeCommunities, summarizeHotspots } from "./graph-analysis.summaries.js";
|
|
7
|
+
export { analyzeGraph, aggregateGraph } from "./graph-analysis.aggregate.js";
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Internal helpers for graph-analysis: identifier reference counting inside source text,
|
|
2
|
+
// relative-import candidate resolution, and the import-driven external-reference scan that feeds
|
|
3
|
+
// aggregateGraph's symbolRefs output. Not part of the public graph-analysis facade.
|
|
4
|
+
import { normRepoPath, normalizeRepoParts, dirOfRepoPath } from "./coverage-reports.js";
|
|
5
|
+
|
|
6
|
+
const escapeRegExp = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7
|
+
export const bareSymbolName = (label) => String(label || "").replace(/\s*\(.*$/, "").trim();
|
|
8
|
+
const isIdentifierName = (name) => /^[A-Za-z_$][\w$]*$/.test(name);
|
|
9
|
+
|
|
10
|
+
function countIdentifierInLine(line, name) {
|
|
11
|
+
const re = new RegExp(`(^|[^A-Za-z0-9_$])${escapeRegExp(name)}(?![A-Za-z0-9_$])`, "g");
|
|
12
|
+
let count = 0;
|
|
13
|
+
while (re.exec(line)) count++;
|
|
14
|
+
return count;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function countIdentifierInText(text, name) {
|
|
18
|
+
if (!text || !isIdentifierName(name)) return 0;
|
|
19
|
+
return String(text).split(/\r?\n/).reduce((sum, line) => sum + countIdentifierInLine(line, name), 0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function countMemberAccess(text, objectName, memberName) {
|
|
23
|
+
if (!text || !isIdentifierName(objectName) || !isIdentifierName(memberName)) return 0;
|
|
24
|
+
const re = new RegExp(`(^|[^A-Za-z0-9_$])${escapeRegExp(objectName)}\\s*\\.\\s*${escapeRegExp(memberName)}(?![A-Za-z0-9_$])`, "g");
|
|
25
|
+
let count = 0;
|
|
26
|
+
while (re.exec(String(text))) count++;
|
|
27
|
+
return count;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function countLocalRefsOutsideOwnRange(text, name, startLine, endLine) {
|
|
31
|
+
if (!text || !isIdentifierName(name)) return 0;
|
|
32
|
+
const start = Number.isFinite(startLine) && startLine > 0 ? startLine : 0;
|
|
33
|
+
const end = Number.isFinite(endLine) && endLine >= start ? endLine : start;
|
|
34
|
+
let refs = 0;
|
|
35
|
+
const lines = String(text).split(/\r?\n/);
|
|
36
|
+
for (let i = 0; i < lines.length; i++) {
|
|
37
|
+
const lineNo = i + 1;
|
|
38
|
+
if (start && lineNo >= start && lineNo <= end) continue;
|
|
39
|
+
refs += countIdentifierInLine(lines[i], name);
|
|
40
|
+
}
|
|
41
|
+
return refs;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function importCandidates(fromFile, spec) {
|
|
45
|
+
const raw = String(spec || "");
|
|
46
|
+
if (!raw.startsWith(".")) return [];
|
|
47
|
+
const base = normalizeRepoParts(`${dirOfRepoPath(fromFile)}/${raw}`);
|
|
48
|
+
return [
|
|
49
|
+
base,
|
|
50
|
+
`${base}.js`,
|
|
51
|
+
`${base}.ts`,
|
|
52
|
+
`${base}.jsx`,
|
|
53
|
+
`${base}.tsx`,
|
|
54
|
+
`${base}.mjs`,
|
|
55
|
+
`${base}.cjs`,
|
|
56
|
+
`${base}/index.js`,
|
|
57
|
+
`${base}/index.ts`,
|
|
58
|
+
`${base}/index.jsx`,
|
|
59
|
+
`${base}/index.tsx`
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stripModuleStatements(text) {
|
|
64
|
+
return String(text || "")
|
|
65
|
+
.replace(/\bimport\s+[\s\S]*?\s+from\s*['"][^'"]+['"]\s*;?/g, "")
|
|
66
|
+
.replace(/\bexport\s+\{[\s\S]*?\}\s+from\s*['"][^'"]+['"]\s*;?/g, "")
|
|
67
|
+
.replace(/\b(?:const|let|var)\s+\{[\s\S]*?\}\s*=\s*require\(\s*['"][^'"]+['"]\s*\)\s*;?/g, "");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseNamedSpecifiers(raw) {
|
|
71
|
+
return String(raw || "")
|
|
72
|
+
.split(",")
|
|
73
|
+
.map((part) => part.trim())
|
|
74
|
+
.filter(Boolean)
|
|
75
|
+
.map((part) => {
|
|
76
|
+
const m = part.match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
|
|
77
|
+
return m ? { imported: m[1], local: m[2] || m[1] } : null;
|
|
78
|
+
})
|
|
79
|
+
.filter(Boolean);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Import-driven external references: scan each file's import/export/require statements and count how
|
|
83
|
+
// often the imported names are used in the importer, attributing counts to the target file's symbol
|
|
84
|
+
// ids. Moved whole out of aggregateGraph; parameters are its `filePath` (fid → repo path),
|
|
85
|
+
// `fileSymbols` (fid → symbol list) and `fileText` (fid → source text) maps.
|
|
86
|
+
export function computeSymbolExternalRefs(filePath, fileSymbols, fileText) {
|
|
87
|
+
const fidByPath = new Map();
|
|
88
|
+
for (const [fid, p] of filePath) fidByPath.set(normRepoPath(p), fid);
|
|
89
|
+
const symbolIdsByFileAndName = new Map();
|
|
90
|
+
for (const [fid, list] of fileSymbols) {
|
|
91
|
+
const byName = symbolIdsByFileAndName.get(fid) || new Map();
|
|
92
|
+
for (const sym of list || []) {
|
|
93
|
+
const name = bareSymbolName(sym.label);
|
|
94
|
+
if (!isIdentifierName(name)) continue;
|
|
95
|
+
const ids = byName.get(name) || [];
|
|
96
|
+
ids.push(sym.id);
|
|
97
|
+
byName.set(name, ids);
|
|
98
|
+
}
|
|
99
|
+
symbolIdsByFileAndName.set(fid, byName);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const symbolExternalRefs = new Map();
|
|
103
|
+
const addExternalRefs = (targetFid, importedName, refs) => {
|
|
104
|
+
if (!targetFid || refs <= 0 || !isIdentifierName(importedName)) return;
|
|
105
|
+
const ids = symbolIdsByFileAndName.get(targetFid)?.get(importedName) || [];
|
|
106
|
+
for (const id of ids) symbolExternalRefs.set(id, (symbolExternalRefs.get(id) || 0) + refs);
|
|
107
|
+
};
|
|
108
|
+
const resolveImportedFid = (fromPath, spec) => {
|
|
109
|
+
for (const candidate of importCandidates(fromPath, spec)) {
|
|
110
|
+
const fid = fidByPath.get(candidate);
|
|
111
|
+
if (fid) return fid;
|
|
112
|
+
}
|
|
113
|
+
return "";
|
|
114
|
+
};
|
|
115
|
+
for (const [importerFid, txt] of fileText) {
|
|
116
|
+
const importerPath = filePath.get(importerFid) || importerFid;
|
|
117
|
+
const scrubbed = stripModuleStatements(txt);
|
|
118
|
+
const seenStatements = [
|
|
119
|
+
...String(txt).matchAll(/\bimport\s+([\s\S]*?)\s+from\s*['"]([^'"]+)['"]\s*;?/g)
|
|
120
|
+
];
|
|
121
|
+
for (const m of seenStatements) {
|
|
122
|
+
const targetFid = resolveImportedFid(importerPath, m[2]);
|
|
123
|
+
if (!targetFid) continue;
|
|
124
|
+
const named = String(m[1] || "").match(/\{([\s\S]*?)\}/);
|
|
125
|
+
if (named) {
|
|
126
|
+
for (const spec of parseNamedSpecifiers(named[1])) addExternalRefs(targetFid, spec.imported, countIdentifierInText(scrubbed, spec.local));
|
|
127
|
+
}
|
|
128
|
+
const ns = String(m[1] || "").match(/\*\s+as\s+([A-Za-z_$][\w$]*)/);
|
|
129
|
+
if (ns) {
|
|
130
|
+
const byName = symbolIdsByFileAndName.get(targetFid) || new Map();
|
|
131
|
+
for (const name of byName.keys()) addExternalRefs(targetFid, name, countMemberAccess(scrubbed, ns[1], name));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (const m of String(txt).matchAll(/\bexport\s+\{([\s\S]*?)\}\s+from\s*['"]([^'"]+)['"]\s*;?/g)) {
|
|
135
|
+
const targetFid = resolveImportedFid(importerPath, m[2]);
|
|
136
|
+
if (!targetFid) continue;
|
|
137
|
+
for (const spec of parseNamedSpecifiers(m[1])) addExternalRefs(targetFid, spec.imported, 1);
|
|
138
|
+
}
|
|
139
|
+
for (const m of String(txt).matchAll(/\b(?:const|let|var)\s+\{([\s\S]*?)\}\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)\s*;?/g)) {
|
|
140
|
+
const targetFid = resolveImportedFid(importerPath, m[2]);
|
|
141
|
+
if (!targetFid) continue;
|
|
142
|
+
for (const spec of parseNamedSpecifiers(m[1])) addExternalRefs(targetFid, spec.imported, countIdentifierInText(scrubbed, spec.local));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return symbolExternalRefs;
|
|
146
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Reading a built graph.json and summarizing it for the UI cards: named communities and degree
|
|
2
|
+
// hotspots. Both read the graph file from disk and return [] on any failure.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
// graph-builder labels communities "Community N" without an LLM. Derive a real name from each
|
|
6
|
+
// community's dominant folder + sample files so the UI shows modules, not bare numbers.
|
|
7
|
+
export function summarizeCommunities(graphJsonPath, max = 40) {
|
|
8
|
+
try {
|
|
9
|
+
const graph = JSON.parse(readFileSync(graphJsonPath, "utf8"));
|
|
10
|
+
const byCommunity = new Map();
|
|
11
|
+
for (const node of graph.nodes || []) {
|
|
12
|
+
if (node.file_type !== "code") continue;
|
|
13
|
+
const community = node.community;
|
|
14
|
+
if (community === undefined || community === null) continue;
|
|
15
|
+
if (!byCommunity.has(community)) byCommunity.set(community, { id: community, size: 0, dirs: new Map(), files: [] });
|
|
16
|
+
const entry = byCommunity.get(community);
|
|
17
|
+
entry.size += 1;
|
|
18
|
+
const parts = String(node.source_file || "").split(/[\\/]/).filter(Boolean);
|
|
19
|
+
const dir = parts.length > 1 ? parts.slice(0, 2).join("/") : "(root)";
|
|
20
|
+
entry.dirs.set(dir, (entry.dirs.get(dir) || 0) + 1);
|
|
21
|
+
if (entry.files.length < 4) entry.files.push(parts[parts.length - 1] || node.source_file || "");
|
|
22
|
+
}
|
|
23
|
+
return [...byCommunity.values()]
|
|
24
|
+
.map((entry) => {
|
|
25
|
+
const dominant = [...entry.dirs.entries()].sort((left, right) => right[1] - left[1])[0];
|
|
26
|
+
return { id: entry.id, size: entry.size, name: dominant ? dominant[0] : "(mixed)", files: entry.files };
|
|
27
|
+
})
|
|
28
|
+
.sort((left, right) => right.size - left.size)
|
|
29
|
+
.slice(0, max);
|
|
30
|
+
} catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Top nodes by total degree (in+out) — the "load-bearing" / refactor-candidate hotspots.
|
|
36
|
+
export function summarizeHotspots(graphJsonPath, max = 15) {
|
|
37
|
+
try {
|
|
38
|
+
const graph = JSON.parse(readFileSync(graphJsonPath, "utf8"));
|
|
39
|
+
const nodes = graph.nodes || [];
|
|
40
|
+
const endpoint = (value) => (value && typeof value === "object" ? value.id : value);
|
|
41
|
+
const inDeg = new Map();
|
|
42
|
+
const outDeg = new Map();
|
|
43
|
+
for (const link of graph.links || []) {
|
|
44
|
+
const s = endpoint(link.source);
|
|
45
|
+
const t = endpoint(link.target);
|
|
46
|
+
outDeg.set(s, (outDeg.get(s) || 0) + 1);
|
|
47
|
+
inDeg.set(t, (inDeg.get(t) || 0) + 1);
|
|
48
|
+
}
|
|
49
|
+
return nodes
|
|
50
|
+
.filter((node) => node.file_type === "code")
|
|
51
|
+
.map((node) => {
|
|
52
|
+
const inbound = inDeg.get(node.id) || 0;
|
|
53
|
+
const outbound = outDeg.get(node.id) || 0;
|
|
54
|
+
return { label: node.label || node.norm_label || node.id, file: node.source_file || "", in: inbound, out: outbound, degree: inbound + outbound };
|
|
55
|
+
})
|
|
56
|
+
.filter((node) => node.degree > 0)
|
|
57
|
+
.sort((left, right) => right.degree - left.degree)
|
|
58
|
+
.slice(0, max);
|
|
59
|
+
} catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// internal-audit.collect.js — filesystem collection helpers for the internal audit: source/config
|
|
2
|
+
// text gathering, workspace package names, and the Python manifest reader. Split from internal-audit.js.
|
|
3
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { parseRequirementsNames, parsePyprojectDeps, parsePipfileDeps } from "./manifests.js";
|
|
6
|
+
|
|
7
|
+
export const readText = (p) => { try { return readFileSync(p, "utf8"); } catch { return null; } };
|
|
8
|
+
export const readJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
|
|
9
|
+
const SOURCE_EXT_RE = /\.(?:[cm]?[jt]sx?|py|go|vue|svelte)$/i;
|
|
10
|
+
const SOURCE_SKIP_DIRS = new Set([
|
|
11
|
+
".git", ".hg", ".svn", "node_modules", "vendor", "dist", "build", "coverage", ".next", "out",
|
|
12
|
+
"weavatrix-graphs", "weavatrix-graphs", "__pycache__", ".venv", "venv", "env", ".tox", "site-packages",
|
|
13
|
+
".mypy_cache", ".pytest_cache",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export function collectSourceTexts(repoRoot, graph) {
|
|
17
|
+
const sources = new Map();
|
|
18
|
+
const add = (rel) => {
|
|
19
|
+
const file = String(rel || "").replace(/\\/g, "/");
|
|
20
|
+
if (!file || sources.has(file)) return;
|
|
21
|
+
const text = readText(join(repoRoot, file));
|
|
22
|
+
if (text != null) sources.set(file, text);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
for (const n of graph.nodes || []) add(n.source_file);
|
|
26
|
+
|
|
27
|
+
const walk = (abs, parts = []) => {
|
|
28
|
+
let entries = [];
|
|
29
|
+
try { entries = readdirSync(abs, { withFileTypes: true }); } catch { return; }
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
if (entry.isDirectory()) {
|
|
32
|
+
if (!SOURCE_SKIP_DIRS.has(entry.name)) walk(join(abs, entry.name), [...parts, entry.name]);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (!entry.isFile() || !SOURCE_EXT_RE.test(entry.name)) continue;
|
|
36
|
+
add([...parts, entry.name].join("/"));
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
walk(repoRoot);
|
|
40
|
+
return sources;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Root config files whose TEXT keeps a dependency "mentioned" (dep-check downgrades/skips those).
|
|
44
|
+
// package.json is deliberately ABSENT — every declared dep appears there, it would blank all findings;
|
|
45
|
+
// its scripts are passed to dep-check separately via pkg.
|
|
46
|
+
const CONFIG_FILES = [
|
|
47
|
+
"tsconfig.json", "jsconfig.json",
|
|
48
|
+
".eslintrc", ".eslintrc.json", ".eslintrc.js", ".eslintrc.cjs", "eslint.config.js", "eslint.config.mjs",
|
|
49
|
+
".babelrc", "babel.config.js", "babel.config.cjs",
|
|
50
|
+
"jest.config.js", "jest.config.ts", "jest.config.cjs", "jest.config.mjs",
|
|
51
|
+
"vite.config.js", "vite.config.ts", "vite.config.mjs", "vitest.config.js", "vitest.config.ts",
|
|
52
|
+
"webpack.config.js", "rollup.config.js", "esbuild.config.js",
|
|
53
|
+
"postcss.config.js", "postcss.config.cjs", "tailwind.config.js", "tailwind.config.ts",
|
|
54
|
+
".prettierrc", ".prettierrc.json", "prettier.config.js",
|
|
55
|
+
"playwright.config.js", "playwright.config.ts", "cypress.config.js", "cypress.config.ts",
|
|
56
|
+
"next.config.js", "next.config.mjs", "nuxt.config.ts", "svelte.config.js", "astro.config.mjs",
|
|
57
|
+
"angular.json", "nest-cli.json", ".mocharc.json", ".mocharc.yml",
|
|
58
|
+
"commitlint.config.js", ".lintstagedrc", ".lintstagedrc.json", "knip.json", ".releaserc",
|
|
59
|
+
"electron-builder.yml", "electron-builder.json", "serverless.yml", "Dockerfile", "docker-compose.yml",
|
|
60
|
+
// python tool configs (pyproject.toml is deliberately absent — it DECLARES deps, scanning it would blank findings)
|
|
61
|
+
"tox.ini", "pytest.ini", "setup.cfg", ".pre-commit-config.yaml", "Makefile", "Procfile",
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
export function collectConfigTexts(repoRoot) {
|
|
65
|
+
const map = new Map();
|
|
66
|
+
for (const f of CONFIG_FILES) { const t = readText(join(repoRoot, f)); if (t != null) map.set(f, t); }
|
|
67
|
+
try {
|
|
68
|
+
for (const wf of readdirSync(join(repoRoot, ".github", "workflows"))) {
|
|
69
|
+
if (!/\.ya?ml$/i.test(wf)) continue;
|
|
70
|
+
const t = readText(join(repoRoot, ".github", "workflows", wf));
|
|
71
|
+
if (t != null) map.set(`.github/workflows/${wf}`, t);
|
|
72
|
+
}
|
|
73
|
+
} catch { /* no workflows */ }
|
|
74
|
+
return map;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Monorepo-local package names: "packages/*"-style workspace globs → each child's package.json name.
|
|
78
|
+
// Those are importable without being declared — never "missing" deps.
|
|
79
|
+
export function workspacePkgNames(repoRoot, pkg) {
|
|
80
|
+
const names = new Set();
|
|
81
|
+
const globs = Array.isArray(pkg.workspaces) ? pkg.workspaces : (pkg.workspaces && pkg.workspaces.packages) || [];
|
|
82
|
+
for (const g of globs) {
|
|
83
|
+
const base = String(g).replace(/\/?\*+.*$/, "");
|
|
84
|
+
let dirs = [];
|
|
85
|
+
if (/\*/.test(String(g))) { try { dirs = readdirSync(join(repoRoot, base)).map((d) => join(base, d)); } catch { continue; } }
|
|
86
|
+
else dirs = [String(g)];
|
|
87
|
+
for (const d of dirs) {
|
|
88
|
+
const p = readJson(join(repoRoot, d, "package.json"));
|
|
89
|
+
if (p && p.name) names.add(p.name);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return names;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const TEST_FILE_RE = /(^|[/])(test|tests|__tests__|spec|e2e|__mocks__)([/]|$)|[._-](test|spec)\.[a-z0-9]+$/i;
|
|
96
|
+
|
|
97
|
+
// Python declared deps: root requirements*.txt/.in + requirements/ dir + pyproject.toml + Pipfile.
|
|
98
|
+
// present=false (no manifest at all) softens missing-dep findings instead of suppressing them.
|
|
99
|
+
export function collectPyManifest(repoRoot) {
|
|
100
|
+
const deps = [];
|
|
101
|
+
let present = false;
|
|
102
|
+
let names = [];
|
|
103
|
+
try { names = readdirSync(repoRoot).filter((n) => /^requirements[\w.-]*\.(txt|in)$/i.test(n)); } catch { /* unreadable root */ }
|
|
104
|
+
try { names.push(...readdirSync(join(repoRoot, "requirements")).filter((n) => /\.(txt|in)$/i.test(n)).map((n) => `requirements/${n}`)); } catch { /* no requirements dir */ }
|
|
105
|
+
for (const n of names) {
|
|
106
|
+
const t = readText(join(repoRoot, n));
|
|
107
|
+
if (t == null) continue;
|
|
108
|
+
present = true;
|
|
109
|
+
const dev = /dev|test|lint|doc|ci/i.test(n.replace(/^requirements[/\\]?/i, ""));
|
|
110
|
+
for (const d of parseRequirementsNames(t)) deps.push({ ...d, dev });
|
|
111
|
+
}
|
|
112
|
+
const pp = readText(join(repoRoot, "pyproject.toml"));
|
|
113
|
+
if (pp != null) { const r = parsePyprojectDeps(pp); if (r.present) { present = true; deps.push(...r.deps); } }
|
|
114
|
+
const pf = readText(join(repoRoot, "Pipfile"));
|
|
115
|
+
if (pf != null) { const r = parsePipfileDeps(pf); if (r.present) { present = true; deps.push(...r.deps); } }
|
|
116
|
+
return { present, deps };
|
|
117
|
+
}
|