weavatrix 0.1.2 → 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 +66 -11
- package/package.json +1 -1
- package/skill/SKILL.md +54 -3
- package/src/analysis/dead-check.js +80 -4
- package/src/analysis/dep-check.js +84 -8
- package/src/analysis/dep-rules.js +74 -8
- package/src/analysis/duplicates.compute.js +12 -18
- package/src/analysis/endpoints.js +45 -2
- package/src/analysis/graph-analysis.aggregate.js +11 -3
- package/src/analysis/internal-audit.collect.js +122 -25
- package/src/analysis/internal-audit.reach.js +52 -11
- package/src/analysis/internal-audit.run.js +65 -17
- 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 +43 -2
- package/src/graph/internal-builder.resolvers.js +88 -19
- package/src/mcp/catalog.mjs +2 -2
- package/src/mcp/graph-context.mjs +100 -13
- package/src/mcp/sync-payload.mjs +9 -1
- package/src/mcp/tools-actions.mjs +22 -7
- package/src/mcp/tools-graph.mjs +46 -12
- package/src/mcp/tools-health.mjs +42 -18
- package/src/mcp/tools-impact.mjs +52 -41
- package/src/mcp-server.mjs +3 -0
- package/src/security/advisory-store.js +51 -7
|
@@ -18,21 +18,44 @@ const dirOf = (f) => (f.includes("/") ? f.slice(0, f.lastIndexOf("/")) : "");
|
|
|
18
18
|
|
|
19
19
|
// File-level import adjacency. Go same-directory edges are excluded: a Go package IS the directory, the
|
|
20
20
|
// compiler forbids real cross-package cycles, and our suffix-fallback Go resolution could fake them.
|
|
21
|
-
export function buildFileImportGraph(graph) {
|
|
21
|
+
export function buildFileImportGraph(graph, { includeTypeOnly = false } = {}) {
|
|
22
22
|
const fileIds = new Set();
|
|
23
23
|
for (const n of graph.nodes || []) if (!String(n.id).includes("#")) fileIds.add(String(n.id));
|
|
24
|
-
const
|
|
24
|
+
const runtimeAdj = new Map(); // runtime/value imports only
|
|
25
|
+
const allAdj = new Map(); // runtime + type-only, used to describe architectural coupling
|
|
25
26
|
const edges = [];
|
|
27
|
+
const allEdges = [];
|
|
28
|
+
const typeOnlyEdges = [];
|
|
29
|
+
const runtimeSeen = new Set(), allSeen = new Set(), typeSeen = new Set();
|
|
30
|
+
const add = (map, a, b) => {
|
|
31
|
+
let set = map.get(a);
|
|
32
|
+
if (!set) map.set(a, (set = new Set()));
|
|
33
|
+
set.add(b);
|
|
34
|
+
};
|
|
26
35
|
for (const l of graph.links || []) {
|
|
27
36
|
if (l.relation !== "imports" && l.relation !== "re_exports") continue;
|
|
28
37
|
const a = ep(l.source), b = ep(l.target);
|
|
29
38
|
if (!fileIds.has(a) || !fileIds.has(b) || a === b) continue;
|
|
30
39
|
if (a.endsWith(".go") && b.endsWith(".go") && dirOf(a) === dirOf(b)) continue;
|
|
31
|
-
|
|
32
|
-
if (!
|
|
33
|
-
if (
|
|
40
|
+
const key = `${a}\0${b}`;
|
|
41
|
+
if (!allSeen.has(key)) { allSeen.add(key); add(allAdj, a, b); allEdges.push([a, b]); }
|
|
42
|
+
if (l.typeOnly === true) {
|
|
43
|
+
if (!typeSeen.has(key)) { typeSeen.add(key); typeOnlyEdges.push([a, b]); }
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (!runtimeSeen.has(key)) { runtimeSeen.add(key); add(runtimeAdj, a, b); edges.push([a, b]); }
|
|
34
47
|
}
|
|
35
|
-
|
|
48
|
+
const pureTypeOnlyEdges = typeOnlyEdges.filter(([a, b]) => !runtimeSeen.has(`${a}\0${b}`));
|
|
49
|
+
return {
|
|
50
|
+
fileIds,
|
|
51
|
+
adj: includeTypeOnly ? allAdj : runtimeAdj,
|
|
52
|
+
edges: includeTypeOnly ? allEdges : edges,
|
|
53
|
+
runtimeAdj,
|
|
54
|
+
runtimeEdges: edges,
|
|
55
|
+
allAdj,
|
|
56
|
+
allEdges,
|
|
57
|
+
typeOnlyEdges: pureTypeOnlyEdges,
|
|
58
|
+
};
|
|
36
59
|
}
|
|
37
60
|
|
|
38
61
|
// Iterative Tarjan (deep graphs must not blow the JS stack). Returns only non-trivial SCCs (size > 1).
|
|
@@ -147,7 +170,7 @@ const MAX_BOUNDARY_FINDINGS = 100;
|
|
|
147
170
|
|
|
148
171
|
// Assemble everything into unified Findings. rules comes from the repo's .weavatrix-deps.json (optional).
|
|
149
172
|
export function computeStructureFindings(graph, { rules = {}, entrySet = new Set(), externalImportFiles = new Set() } = {}) {
|
|
150
|
-
const { adj, edges } = buildFileImportGraph(graph);
|
|
173
|
+
const { adj, edges, allAdj, allEdges, typeOnlyEdges } = buildFileImportGraph(graph);
|
|
151
174
|
const findings = [];
|
|
152
175
|
|
|
153
176
|
const sccs = findSccs(adj).sort((a, b) => b.length - a.length);
|
|
@@ -167,6 +190,36 @@ export function computeStructureFindings(graph, { rules = {}, entrySet = new Set
|
|
|
167
190
|
fixHint: "extract the shared code into a module both sides import, or invert the weaker dependency",
|
|
168
191
|
}));
|
|
169
192
|
}
|
|
193
|
+
|
|
194
|
+
// A TypeScript `import type` is erased before runtime. It can still reveal architectural coupling, but
|
|
195
|
+
// it cannot create an initialization-order/runtime cycle. Report SCCs that grow only because of type
|
|
196
|
+
// edges separately and at info severity so agents do not churn working code to fix a phantom hazard.
|
|
197
|
+
const runtimeKeys = new Set(sccs.map((s) => [...s].sort().join("\0")));
|
|
198
|
+
const allSccs = findSccs(allAdj).sort((a, b) => b.length - a.length);
|
|
199
|
+
const typeCouplings = allSccs.filter((s) => !runtimeKeys.has([...s].sort().join("\0")));
|
|
200
|
+
const edgeCountIn = (scc, list) => {
|
|
201
|
+
const inside = new Set(scc);
|
|
202
|
+
return list.reduce((n, [a, b]) => n + (inside.has(a) && inside.has(b) ? 1 : 0), 0);
|
|
203
|
+
};
|
|
204
|
+
for (const scc of typeCouplings.slice(0, MAX_CYCLE_FINDINGS)) {
|
|
205
|
+
const cycle = representativeCycle(allAdj, scc);
|
|
206
|
+
const runtimeInside = edgeCountIn(scc, edges);
|
|
207
|
+
const typeInside = edgeCountIn(scc, typeOnlyEdges);
|
|
208
|
+
const containsRuntimeCycle = sccs.some((runtime) => runtime.every((f) => scc.includes(f)));
|
|
209
|
+
findings.push(makeFinding({
|
|
210
|
+
category: "structure",
|
|
211
|
+
rule: "type-coupling",
|
|
212
|
+
severity: "info",
|
|
213
|
+
confidence: "high",
|
|
214
|
+
title: `${containsRuntimeCycle ? "Type imports expand dependency coupling" : "Type-induced dependency cycle (no runtime cycle)"}: ${scc.length} files`,
|
|
215
|
+
detail: `${cycle.join(" → ")}. This strongly-connected group needs type-only edges to close; it contains ${runtimeInside} runtime edge(s) and ${typeInside} type-only edge(s)${containsRuntimeCycle ? ", with a smaller runtime cycle reported separately" : ", while its runtime import graph is acyclic"}. Treat this as design coupling, not an initialization-order failure.`,
|
|
216
|
+
file: cycle[0],
|
|
217
|
+
graphNodeId: cycle[0],
|
|
218
|
+
evidence: cycle.map((f) => ({ file: f, line: 0, snippet: "" })),
|
|
219
|
+
source: "internal",
|
|
220
|
+
fixHint: "review the shared type ownership only if the coupling impedes changes; no runtime-cycle fix is required",
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
170
223
|
if (sccs.length > MAX_CYCLE_FINDINGS) {
|
|
171
224
|
findings.push(makeFinding({
|
|
172
225
|
category: "structure", rule: "circular-dep", severity: "info", confidence: "high",
|
|
@@ -190,6 +243,8 @@ export function computeStructureFindings(graph, { rules = {}, entrySet = new Set
|
|
|
190
243
|
}));
|
|
191
244
|
}
|
|
192
245
|
|
|
246
|
+
// Architecture boundaries describe executable dependencies by default. Type-only contract sharing is
|
|
247
|
+
// visible in typeCouplings above, but must not be presented as a runtime layer violation.
|
|
193
248
|
const violations = checkBoundaries(edges, rules);
|
|
194
249
|
for (const v of violations.slice(0, MAX_BOUNDARY_FINDINGS)) {
|
|
195
250
|
findings.push(makeFinding({
|
|
@@ -216,6 +271,17 @@ export function computeStructureFindings(graph, { rules = {}, entrySet = new Set
|
|
|
216
271
|
|
|
217
272
|
return {
|
|
218
273
|
findings,
|
|
219
|
-
stats: {
|
|
274
|
+
stats: {
|
|
275
|
+
importEdges: allEdges.length,
|
|
276
|
+
runtimeImportEdges: edges.length,
|
|
277
|
+
typeOnlyImportEdges: typeOnlyEdges.length,
|
|
278
|
+
cycles: sccs.length,
|
|
279
|
+
runtimeCycles: sccs.length,
|
|
280
|
+
largestCycle: sccs[0]?.length || 0,
|
|
281
|
+
typeCouplings: typeCouplings.length,
|
|
282
|
+
largestTypeCoupling: typeCouplings[0]?.length || 0,
|
|
283
|
+
orphans: findings.filter((f) => f.rule === "orphan-file").length,
|
|
284
|
+
boundaryViolations: violations.length,
|
|
285
|
+
},
|
|
220
286
|
};
|
|
221
287
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// duplicates.compute.js — fragment extraction, inverted-index pairing, and the computeDuplicates
|
|
2
2
|
// pipeline (split from duplicates.js; see the facade there for the full algorithm overview).
|
|
3
|
-
import { readFileSync
|
|
4
|
-
import { join, extname } from "node:path";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
5
4
|
import { isTestPath } from "../graph/graph-filter.js";
|
|
6
5
|
import { createRepoBoundary } from "../repo-path.js";
|
|
6
|
+
import { listRepoFiles } from "./internal-audit.collect.js";
|
|
7
7
|
import { stripNonCode, bodyEndLineCount, tokenize, fingerprints, extractLargeStrings } from "./duplicates.tokenize.js";
|
|
8
8
|
|
|
9
9
|
const FLOOR_TOKENS = 30; // fragments below this never enter the index (UI slider min)
|
|
@@ -17,20 +17,7 @@ const WINDOW_EXTS = /\.(css|scss|sass|less|styl|html?|md|markdown|mdx|vue|svelte
|
|
|
17
17
|
const WINDOW_LINES = 24; // non-overlapping block size for windowed files
|
|
18
18
|
// Asset files are DEDUP-ONLY — they are NOT put in the code graph (that would score md/css as fake
|
|
19
19
|
// "methods" in Health and clutter the GUI board), so the clone scanner finds them by walking the repo.
|
|
20
|
-
const WALK_SKIP = new Set(["node_modules", ".git", "dist", "build", "coverage", "vendor", "weavatrix-graphs", ".next", "out", "__pycache__", ".venv", "venv", "site-packages"]);
|
|
21
20
|
const MAX_ASSET_FILES = 4000;
|
|
22
|
-
function walkAssets(root, dir, acc, depth) {
|
|
23
|
-
if (depth > 40 || acc.length >= MAX_ASSET_FILES) return acc;
|
|
24
|
-
let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return acc; }
|
|
25
|
-
for (const e of entries) {
|
|
26
|
-
if (acc.length >= MAX_ASSET_FILES) break;
|
|
27
|
-
if (e.name.startsWith(".") || WALK_SKIP.has(e.name)) continue;
|
|
28
|
-
const full = join(dir, e.name);
|
|
29
|
-
if (e.isDirectory()) walkAssets(root, full, acc, depth + 1);
|
|
30
|
-
else if (WINDOW_EXTS.test(e.name)) acc.push(full.slice(root.length + 1).replace(/\\/g, "/"));
|
|
31
|
-
}
|
|
32
|
-
return acc;
|
|
33
|
-
}
|
|
34
21
|
// Fingerprints shared by MORE than this many fragments are ubiquitous boilerplate and are excluded
|
|
35
22
|
// from BOTH the shared count AND the union (so jaccard stays honest — see pairsForMode). Set well
|
|
36
23
|
// above realistic clone multiplicity: N byte-identical copies each put all their fingerprints in
|
|
@@ -55,7 +42,7 @@ function computeNameTwins(frags) {
|
|
|
55
42
|
frags.forEach((f, i) => {
|
|
56
43
|
if (f.kind === "string") return;
|
|
57
44
|
const name = String(f.label || "").trim().replace(/\(\)$/, "");
|
|
58
|
-
if (name.length < 5 || !/^[A-Za-z_$][\w$]*$/.test(name) || TWIN_STOP.has(name.toLowerCase())) return;
|
|
45
|
+
if (name.length < 5 || !/^[A-Za-z_$][\w$]*$/.test(name) || TWIN_STOP.has(name.toLowerCase()) || /^do_(?:get|post|put|patch|delete|head|options)$/i.test(name)) return;
|
|
59
46
|
const key = name.toLowerCase();
|
|
60
47
|
let a = byName.get(key); if (!a) byName.set(key, (a = []));
|
|
61
48
|
a.push(i);
|
|
@@ -66,15 +53,18 @@ function computeNameTwins(frags) {
|
|
|
66
53
|
const files = new Set(idxs.map((i) => frags[i].file));
|
|
67
54
|
if (files.size < 2 || idxs.length > 12) continue; // single-file overloads / framework-name explosions
|
|
68
55
|
let simMin = 1, simMax = 0;
|
|
56
|
+
const pairs = [];
|
|
69
57
|
for (let a = 0; a < idxs.length; a++) for (let b = a + 1; b < idxs.length; b++) {
|
|
70
58
|
if (frags[idxs[a]].file === frags[idxs[b]].file) continue;
|
|
71
59
|
const s = jac(frags[idxs[a]].fp.renamed, frags[idxs[b]].fp.renamed);
|
|
72
60
|
if (s < simMin) simMin = s;
|
|
73
61
|
if (s > simMax) simMax = s;
|
|
62
|
+
pairs.push({ a: idxs[a], b: idxs[b], similarity: Math.round(s * 100) });
|
|
74
63
|
}
|
|
75
64
|
out.push({
|
|
76
65
|
label: String(frags[idxs[0]].label || "").replace(/\(\)$/, ""), members: idxs, files: files.size,
|
|
77
66
|
simMin: Math.round(simMin * 100), simMax: Math.round(simMax * 100),
|
|
67
|
+
pairs,
|
|
78
68
|
tokens: idxs.reduce((n, i) => n + frags[i].n, 0),
|
|
79
69
|
});
|
|
80
70
|
}
|
|
@@ -130,11 +120,14 @@ export function computeDuplicates(repoPath, graphJsonPath, opts = {}) {
|
|
|
130
120
|
// total symbol nodes in the graph — 0 means a file-only graph (built by an older builder before
|
|
131
121
|
// symbol extraction, or a stale graph): clone detection has nothing to work with, and the UI must
|
|
132
122
|
// say "rebuild the graph" instead of the misleading "no clones at these thresholds".
|
|
123
|
+
const repoFiles = listRepoFiles(repoPath);
|
|
124
|
+
const allowedFiles = new Set(repoFiles);
|
|
133
125
|
let graphSymbols = 0;
|
|
134
|
-
for (const arr of byFile.
|
|
126
|
+
for (const [file, arr] of byFile) if (allowedFiles.has(file)) graphSymbols += arr.length;
|
|
135
127
|
const frags = [];
|
|
136
128
|
const boundary = createRepoBoundary(repoPath);
|
|
137
129
|
for (const [file, syms] of byFile) {
|
|
130
|
+
if (!allowedFiles.has(file)) continue;
|
|
138
131
|
const resolved = boundary.resolve(file);
|
|
139
132
|
if (!resolved.ok) continue;
|
|
140
133
|
const full = resolved.path;
|
|
@@ -195,7 +188,8 @@ export function computeDuplicates(repoPath, graphJsonPath, opts = {}) {
|
|
|
195
188
|
// ---- windowed fragments for symbol-less file types (CSS/HTML/MD/…), found by walking the repo (NOT the
|
|
196
189
|
// graph — assets are dedup-only). Each file is sliced into WINDOW_LINES blocks and fingerprinted.
|
|
197
190
|
const symFiles = new Set(byFile.keys());
|
|
198
|
-
|
|
191
|
+
const assetFiles = repoFiles.filter((file) => WINDOW_EXTS.test(file)).slice(0, MAX_ASSET_FILES);
|
|
192
|
+
for (const file of assetFiles) {
|
|
199
193
|
if (symFiles.has(file)) continue;
|
|
200
194
|
const resolved = boundary.resolve(file);
|
|
201
195
|
if (!resolved.ok) continue;
|
|
@@ -44,9 +44,27 @@ function handlerName(expr) {
|
|
|
44
44
|
// endpoints (handler "operation", {id} params). These keys never appear in a real route/handler table.
|
|
45
45
|
const OPENAPI_BLOCK = /\boperationId\b|\bresponses\s*:|\brequestBody\b|\bschemaRef\b|\boperation\s*\(|\bsummary\s*:/;
|
|
46
46
|
|
|
47
|
-
const looksLikePath = (p) => typeof p === "string" && /^\/[\w\-./:{}
|
|
47
|
+
const looksLikePath = (p) => typeof p === "string" && /^\/[\w\-./:{}*$?]*$/.test(p) && !p.includes("://");
|
|
48
48
|
const cleanPath = (p) => String(p || "").replace(/\/+$/, "") || "/";
|
|
49
49
|
|
|
50
|
+
export function nextRoutePath(file) {
|
|
51
|
+
const parts = String(file || "").replace(/\\/g, "/").split("/").filter(Boolean);
|
|
52
|
+
if (!/^route\.[cm]?[jt]s$/i.test(parts.at(-1) || "")) return "";
|
|
53
|
+
const appAt = parts.lastIndexOf("app");
|
|
54
|
+
if (appAt < 0) return "";
|
|
55
|
+
const route = [];
|
|
56
|
+
for (let segment of parts.slice(appAt + 1, -1)) {
|
|
57
|
+
if (!segment || /^\([^)]*\)$/.test(segment) || segment.startsWith("@")) continue; // route groups / parallel slots
|
|
58
|
+
segment = segment.replace(/^\((?:\.{1,3})\)/, ""); // intercepting-route marker
|
|
59
|
+
let m;
|
|
60
|
+
if ((m = /^\[\[\.\.\.([^\]]+)\]\]$/.exec(segment))) segment = `*${m[1]}?`;
|
|
61
|
+
else if ((m = /^\[\.\.\.([^\]]+)\]$/.exec(segment))) segment = `*${m[1]}`;
|
|
62
|
+
else if ((m = /^\[([^\]]+)\]$/.exec(segment))) segment = `:${m[1]}`;
|
|
63
|
+
if (segment) route.push(segment);
|
|
64
|
+
}
|
|
65
|
+
return `/${route.join("/")}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
export function extractEndpointsFromText(text, file) {
|
|
51
69
|
const out = [];
|
|
52
70
|
const py = /\.py$/i.test(file);
|
|
@@ -58,6 +76,31 @@ export function extractEndpointsFromText(text, file) {
|
|
|
58
76
|
out.push({ method: m, path: p, handler: handlerName(expr), file, line: lineAt(text, idx) });
|
|
59
77
|
};
|
|
60
78
|
|
|
79
|
+
// Next.js App Router: the filesystem provides the path and exported HTTP-method functions provide the
|
|
80
|
+
// verbs. No literal route string exists in route.ts, so generic Express/FastAPI regexes cannot see it.
|
|
81
|
+
const nextPath = nextRoutePath(file);
|
|
82
|
+
if (nextPath) {
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
const direct = /\bexport\s+(?:(?:async|declare)\s+)*(?:function\s+|(?:const|let|var)\s+)(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/g;
|
|
85
|
+
let nm;
|
|
86
|
+
while ((nm = direct.exec(text))) {
|
|
87
|
+
const method = nm[1].toUpperCase();
|
|
88
|
+
if (!seen.has(method)) { seen.add(method); add(method, nextPath, method, nm.index); }
|
|
89
|
+
}
|
|
90
|
+
const lists = /\bexport\s*\{([^}]+)\}/g;
|
|
91
|
+
let lm;
|
|
92
|
+
while ((lm = lists.exec(text))) {
|
|
93
|
+
for (const item of lm[1].split(",")) {
|
|
94
|
+
const mm = /^\s*([A-Za-z_$][\w$]*)(?:\s+as\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS))?\s*$/.exec(item);
|
|
95
|
+
if (!mm) continue;
|
|
96
|
+
if (!mm[2] && !HTTP_METHODS.has(mm[1])) continue;
|
|
97
|
+
const method = String(mm[2] || mm[1]).toUpperCase();
|
|
98
|
+
if (!HTTP_METHODS.has(method)) continue;
|
|
99
|
+
if (!seen.has(method)) { seen.add(method); add(method, nextPath, mm[1], lm.index); }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
61
104
|
// ---- object routes: "/path": { GET: fn, POST: fn2 } or "/path": handler --------------------
|
|
62
105
|
// find each "…": { or "…": expr, where the key looks like a path
|
|
63
106
|
const objKeyRe = /(["'`])(\/[^"'`]*)\1\s*:\s*(\{)?/g;
|
|
@@ -132,7 +175,7 @@ export function detectEndpoints(repoPath, codeFiles) {
|
|
|
132
175
|
const resolved = boundary.resolve(rel);
|
|
133
176
|
if (!resolved.ok) continue;
|
|
134
177
|
const text = safeRead(resolved.path);
|
|
135
|
-
if (!text || !/["'`]\/|\.(get|post|put|patch|delete)\s*\(|HandleFunc|@\w*\.?(get|post|put|patch|delete)/i.test(text)) continue;
|
|
178
|
+
if (!text || (!nextRoutePath(rel) && !/["'`]\/|\.(get|post|put|patch|delete)\s*\(|HandleFunc|@\w*\.?(get|post|put|patch|delete)/i.test(text))) continue;
|
|
136
179
|
for (const e of extractEndpointsFromText(text, rel.replace(/\\/g, "/"))) {
|
|
137
180
|
const key = `${e.method} ${normParamKey(e.path)}`;
|
|
138
181
|
const prev = byKey.get(key);
|
|
@@ -100,21 +100,25 @@ export function aggregateGraph(graph, repoRoot) {
|
|
|
100
100
|
for (const node of nodes) if (node.source_file) id2file.set(node.id, fileIdOf(node));
|
|
101
101
|
const fileEdges = new Map(); // key → { count, rels:{relation→n} } so we can emit the DOMINANT relation (call/import/inherit)
|
|
102
102
|
const moduleEdges = new Map();
|
|
103
|
+
const typeOnlyFileEdges = new Map();
|
|
104
|
+
const typeOnlyModuleEdges = new Map();
|
|
103
105
|
for (const link of links) {
|
|
104
106
|
if (link.relation === "contains") continue;
|
|
105
107
|
const fromFile = id2file.get(endpoint(link.source));
|
|
106
108
|
const toFile = id2file.get(endpoint(link.target));
|
|
107
109
|
if (fromFile && toFile && fromFile !== toFile) {
|
|
108
110
|
const key = `${fromFile} ${toFile}`;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
const targetFileEdges = link.typeOnly === true ? typeOnlyFileEdges : fileEdges;
|
|
112
|
+
let fe = targetFileEdges.get(key);
|
|
113
|
+
if (!fe) targetFileEdges.set(key, (fe = { count: 0, rels: {} }));
|
|
111
114
|
fe.count++;
|
|
112
115
|
if (link.relation) fe.rels[link.relation] = (fe.rels[link.relation] || 0) + 1;
|
|
113
116
|
const fromMod = fileModule.get(fromFile);
|
|
114
117
|
const toMod = fileModule.get(toFile);
|
|
115
118
|
if (fromMod && toMod && fromMod !== toMod) {
|
|
116
119
|
const mkey = `${fromMod} ${toMod}`;
|
|
117
|
-
|
|
120
|
+
const targetModuleEdges = link.typeOnly === true ? typeOnlyModuleEdges : moduleEdges;
|
|
121
|
+
targetModuleEdges.set(mkey, (targetModuleEdges.get(mkey) || 0) + 1);
|
|
118
122
|
}
|
|
119
123
|
}
|
|
120
124
|
}
|
|
@@ -263,7 +267,9 @@ export function aggregateGraph(graph, repoRoot) {
|
|
|
263
267
|
}))
|
|
264
268
|
.sort((a, b) => b.fileCount - a.fileCount),
|
|
265
269
|
moduleEdges: edgeList(moduleEdges),
|
|
270
|
+
typeOnlyModuleEdges: edgeList(typeOnlyModuleEdges),
|
|
266
271
|
fileEdges: edgeList(fileEdges),
|
|
272
|
+
typeOnlyFileEdges: edgeList(typeOnlyFileEdges),
|
|
267
273
|
symbols,
|
|
268
274
|
symbolEdges,
|
|
269
275
|
symbolRefs: [...new Set([...symbolLocalRefs.keys(), ...symbolExternalRefs.keys()])].map((id) => {
|
|
@@ -276,7 +282,9 @@ export function aggregateGraph(graph, repoRoot) {
|
|
|
276
282
|
files: fileModule.size,
|
|
277
283
|
nodes: nodes.filter((n) => n.file_type === "code").length,
|
|
278
284
|
fileEdges: fileEdges.size,
|
|
285
|
+
typeOnlyFileEdges: typeOnlyFileEdges.size,
|
|
279
286
|
moduleEdges: moduleEdges.size,
|
|
287
|
+
typeOnlyModuleEdges: typeOnlyModuleEdges.size,
|
|
280
288
|
symbols: symbols.length,
|
|
281
289
|
symbolEdges: symbolEdges.length
|
|
282
290
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
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";
|
|
6
7
|
import { createRepoBoundary } from "../repo-path.js";
|
|
8
|
+
import { childProcessEnv } from "../child-env.js";
|
|
7
9
|
|
|
8
10
|
export const readText = (p) => { try { return readFileSync(p, "utf8"); } catch { return null; } };
|
|
9
11
|
export const readJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
|
|
@@ -18,10 +20,36 @@ export const readRepoJson = (boundary, relativePath) => {
|
|
|
18
20
|
const SOURCE_EXT_RE = /\.(?:[cm]?[jt]sx?|py|go|vue|svelte)$/i;
|
|
19
21
|
const SOURCE_SKIP_DIRS = new Set([
|
|
20
22
|
".git", ".hg", ".svn", "node_modules", "vendor", "dist", "build", "coverage", ".next", "out",
|
|
21
|
-
"
|
|
23
|
+
"release", "weavatrix-graphs", "__pycache__", ".venv", "venv", "env", ".tox", "site-packages",
|
|
22
24
|
".mypy_cache", ".pytest_cache",
|
|
23
25
|
]);
|
|
24
26
|
|
|
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 */ }
|
|
38
|
+
|
|
39
|
+
const files = [];
|
|
40
|
+
const walk = (abs, parts = []) => {
|
|
41
|
+
let entries = [];
|
|
42
|
+
try { entries = readdirSync(abs, { withFileTypes: true }); } catch { return; }
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
if (!SOURCE_SKIP_DIRS.has(entry.name)) walk(join(abs, entry.name), [...parts, entry.name]);
|
|
46
|
+
} else if (entry.isFile()) files.push([...parts, entry.name].join("/"));
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
walk(repoRoot);
|
|
50
|
+
return files;
|
|
51
|
+
}
|
|
52
|
+
|
|
25
53
|
export function collectSourceTexts(repoRoot, graph) {
|
|
26
54
|
const sources = new Map();
|
|
27
55
|
const boundary = createRepoBoundary(repoRoot);
|
|
@@ -36,19 +64,7 @@ export function collectSourceTexts(repoRoot, graph) {
|
|
|
36
64
|
|
|
37
65
|
for (const n of graph.nodes || []) add(n.source_file);
|
|
38
66
|
|
|
39
|
-
const
|
|
40
|
-
let entries = [];
|
|
41
|
-
try { entries = readdirSync(abs, { withFileTypes: true }); } catch { return; }
|
|
42
|
-
for (const entry of entries) {
|
|
43
|
-
if (entry.isDirectory()) {
|
|
44
|
-
if (!SOURCE_SKIP_DIRS.has(entry.name)) walk(join(abs, entry.name), [...parts, entry.name]);
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
if (!entry.isFile() || !SOURCE_EXT_RE.test(entry.name)) continue;
|
|
48
|
-
add([...parts, entry.name].join("/"));
|
|
49
|
-
}
|
|
50
|
-
};
|
|
51
|
-
walk(repoRoot);
|
|
67
|
+
for (const file of listRepoFiles(repoRoot)) if (SOURCE_EXT_RE.test(file)) add(file);
|
|
52
68
|
return sources;
|
|
53
69
|
}
|
|
54
70
|
|
|
@@ -62,7 +78,8 @@ const CONFIG_FILES = [
|
|
|
62
78
|
"jest.config.js", "jest.config.ts", "jest.config.cjs", "jest.config.mjs",
|
|
63
79
|
"vite.config.js", "vite.config.ts", "vite.config.mjs", "vitest.config.js", "vitest.config.ts",
|
|
64
80
|
"webpack.config.js", "rollup.config.js", "esbuild.config.js",
|
|
65
|
-
"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",
|
|
66
83
|
".prettierrc", ".prettierrc.json", "prettier.config.js",
|
|
67
84
|
"playwright.config.js", "playwright.config.ts", "cypress.config.js", "cypress.config.ts",
|
|
68
85
|
"next.config.js", "next.config.mjs", "nuxt.config.ts", "svelte.config.js", "astro.config.mjs",
|
|
@@ -76,17 +93,96 @@ const CONFIG_FILES = [
|
|
|
76
93
|
export function collectConfigTexts(repoRoot) {
|
|
77
94
|
const map = new Map();
|
|
78
95
|
const boundary = createRepoBoundary(repoRoot);
|
|
79
|
-
|
|
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;
|
|
80
108
|
try {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
for (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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;
|
|
87
133
|
}
|
|
88
|
-
|
|
89
|
-
|
|
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);
|
|
90
186
|
}
|
|
91
187
|
|
|
92
188
|
// Monorepo-local package names: "packages/*"-style workspace globs → each child's package.json name.
|
|
@@ -111,6 +207,7 @@ export function workspacePkgNames(repoRoot, pkg) {
|
|
|
111
207
|
if (p && p.name) names.add(p.name);
|
|
112
208
|
}
|
|
113
209
|
}
|
|
210
|
+
for (const scope of collectPackageScopes(repoRoot, pkg)) if (scope.pkg?.name) names.add(scope.pkg.name);
|
|
114
211
|
return names;
|
|
115
212
|
}
|
|
116
213
|
|
|
@@ -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;
|