weavatrix 0.1.4 → 0.2.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/README.md +138 -53
- package/SECURITY.md +25 -8
- package/package.json +2 -2
- package/skill/SKILL.md +92 -30
- package/src/analysis/architecture-contract.js +343 -0
- package/src/analysis/audit-debt.js +94 -0
- package/src/analysis/change-classification.js +509 -0
- package/src/analysis/cycle-route.js +14 -0
- package/src/analysis/dead-check.js +5 -3
- package/src/analysis/dead-code-review.js +245 -0
- package/src/analysis/dep-check-ecosystems.js +6 -0
- package/src/analysis/dep-check.js +40 -6
- package/src/analysis/dep-rules.js +12 -9
- package/src/analysis/duplicates.compute.js +47 -7
- package/src/analysis/endpoints-java.js +186 -0
- package/src/analysis/endpoints.js +8 -2
- package/src/analysis/findings.js +8 -1
- package/src/analysis/git-history.js +549 -0
- package/src/analysis/git-ref-graph.js +74 -0
- package/src/analysis/graph-analysis.aggregate.js +2 -2
- package/src/analysis/graph-analysis.edges.js +3 -0
- package/src/analysis/graph-analysis.summaries.js +1 -1
- package/src/analysis/http-contracts.js +581 -0
- package/src/analysis/internal-audit.collect.js +3 -2
- package/src/analysis/internal-audit.reach.js +52 -1
- package/src/analysis/internal-audit.run.js +58 -10
- package/src/analysis/java-source.js +36 -0
- package/src/analysis/package-reachability.js +39 -0
- package/src/analysis/static-test-reachability.js +133 -0
- package/src/build-graph.js +72 -13
- package/src/graph/build-worker.js +3 -4
- package/src/graph/builder/lang-js.js +71 -12
- package/src/graph/community.js +10 -0
- package/src/graph/file-lock.js +69 -0
- package/src/graph/freshness-probe.js +141 -0
- package/src/graph/graph-filter.js +28 -11
- package/src/graph/incremental-refresh.js +232 -0
- package/src/graph/internal-builder.barrels.js +169 -0
- package/src/graph/internal-builder.build.js +82 -11
- package/src/graph/internal-builder.langs.js +2 -1
- package/src/graph/layout.js +21 -5
- package/src/graph/repo-registry.js +124 -0
- package/src/mcp/catalog.mjs +62 -19
- package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
- package/src/mcp/evidence-snapshot.common.mjs +160 -0
- package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
- package/src/mcp/evidence-snapshot.health.mjs +95 -0
- package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
- package/src/mcp/evidence-snapshot.mjs +50 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
- package/src/mcp/evidence-snapshot.structure.mjs +81 -0
- package/src/mcp/graph-context.mjs +100 -16
- package/src/mcp/graph-diff.mjs +74 -20
- package/src/mcp/staleness-notice.mjs +20 -0
- package/src/mcp/sync-evidence.mjs +418 -0
- package/src/mcp/sync-payload.mjs +164 -0
- package/src/mcp/tool-result.mjs +51 -0
- package/src/mcp/tools-actions.mjs +111 -30
- package/src/mcp/tools-architecture.mjs +144 -0
- package/src/mcp/tools-company.mjs +273 -0
- package/src/mcp/tools-graph.mjs +25 -14
- package/src/mcp/tools-health.mjs +336 -14
- package/src/mcp/tools-history.mjs +22 -0
- package/src/mcp/tools-impact-change.mjs +261 -0
- package/src/mcp/tools-impact.mjs +25 -6
- package/src/mcp-server.mjs +100 -17
- package/src/mcp-source-tools.mjs +11 -3
- package/src/path-classification.js +201 -0
- package/src/path-ignore.js +69 -0
- package/src/security/typosquat.js +1 -1
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Conservative incremental graph refresh. This module never writes graph.json: it either returns the
|
|
2
|
+
// untouched complete graph, a fully rebuilt graph, or a complete merge of a bounded scoped parse into
|
|
3
|
+
// the previous graph. Callers own serialization/locking.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { extname, relative } from "node:path";
|
|
7
|
+
import { walk } from "./internal-builder.langs.js";
|
|
8
|
+
import { createRepoBoundary } from "../repo-path.js";
|
|
9
|
+
import { assignDeterministicCommunities } from "./community.js";
|
|
10
|
+
|
|
11
|
+
const JS_EXT = new Set([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"]);
|
|
12
|
+
const CONFIG_RISK = /(^|\/)(package(?:-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|tsconfig(?:\.[^/]*)?\.json|jsconfig(?:\.[^/]*)?\.json|go\.(?:mod|sum)|Cargo\.(?:toml|lock)|pom\.xml|build\.gradle(?:\.kts)?|vite\.config\.[^/]+|webpack\.config\.[^/]+|babel\.config\.[^/]+)$/i;
|
|
13
|
+
const CONTROL_FILES = [".gitignore", ".weavatrixignore", ".weavatrix.json"];
|
|
14
|
+
const MAX_CONTROL_BYTES = 1_000_000;
|
|
15
|
+
|
|
16
|
+
const norm = (value) => String(value || "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
17
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
18
|
+
const endpoint = (value) => String(value && typeof value === "object" ? value.id : value);
|
|
19
|
+
const fileOfEndpoint = (value, nodesById) => {
|
|
20
|
+
const id = endpoint(value);
|
|
21
|
+
const source = nodesById.get(id)?.source_file;
|
|
22
|
+
if (source) return norm(source);
|
|
23
|
+
const marker = id.indexOf("#");
|
|
24
|
+
return norm(marker < 0 ? id : id.slice(0, marker));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// We only need to know whether a module's public surface changed, not understand its implementation.
|
|
28
|
+
// The full tree-sitter pass remains authoritative; this signature is deliberately conservative and
|
|
29
|
+
// whitespace-insensitive. Any changed export statement forces a full rebuild.
|
|
30
|
+
export function jsExportSignature(text, file = "") {
|
|
31
|
+
if (!JS_EXT.has(extname(file))) return "";
|
|
32
|
+
const source = String(text || "");
|
|
33
|
+
const exports = [];
|
|
34
|
+
const pattern = /\bexport\s+(?:declare\s+)?(?:type\s+)?(?:\*\s+(?:as\s+[A-Za-z_$][\w$]*\s+)?from\s*["'][^"']+["']|\{[\s\S]*?\}(?:\s+from\s*["'][^"']+["'])?|default\s+(?:(?:async\s+)?function\s*\*?|class)?\s*[A-Za-z_$]?[\w$]*|(?:async\s+)?function\s*\*?\s+[A-Za-z_$][\w$]*|class\s+[A-Za-z_$][\w$]*|(?:const|let|var|interface|type|enum)\s+[A-Za-z_$][\w$]*)/g;
|
|
35
|
+
for (const match of source.matchAll(pattern)) exports.push(match[0].replace(/\s+/g, " ").trim());
|
|
36
|
+
// The general matcher intentionally stops before implementation bodies. Preserve every binding name
|
|
37
|
+
// in multi-declarator exports (`export const a = 1, b = 2`) so adding/removing one cannot slip through.
|
|
38
|
+
for (const match of source.matchAll(/\bexport\s+(?:declare\s+)?(?:const|let|var)\s+([^;\n]+)/g)) {
|
|
39
|
+
const names = [];
|
|
40
|
+
for (const part of match[1].split(",")) {
|
|
41
|
+
const declared = part.trim().match(/^([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=/);
|
|
42
|
+
if (declared) names.push(declared[1]);
|
|
43
|
+
}
|
|
44
|
+
exports.push(`bindings:${names.join(",")}`);
|
|
45
|
+
}
|
|
46
|
+
return hash(exports.join("\n"));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function snapshotRepository(repoDir, files = walk(repoDir)) {
|
|
50
|
+
const root = String(repoDir);
|
|
51
|
+
const fileHashes = {};
|
|
52
|
+
const fileExportSignatures = {};
|
|
53
|
+
const relativeFiles = [];
|
|
54
|
+
for (const abs of files) {
|
|
55
|
+
const rel = norm(relative(root, abs));
|
|
56
|
+
let body;
|
|
57
|
+
try { body = readFileSync(abs); } catch { continue; }
|
|
58
|
+
relativeFiles.push(rel);
|
|
59
|
+
fileHashes[rel] = hash(body);
|
|
60
|
+
if (JS_EXT.has(extname(rel))) fileExportSignatures[rel] = jsExportSignature(body.toString("utf8"), rel);
|
|
61
|
+
}
|
|
62
|
+
relativeFiles.sort();
|
|
63
|
+
const controlHashes = {};
|
|
64
|
+
const boundary = createRepoBoundary(root);
|
|
65
|
+
for (const rel of CONTROL_FILES) {
|
|
66
|
+
const resolved = boundary.resolve(rel);
|
|
67
|
+
if (!resolved.ok) {
|
|
68
|
+
controlHashes[rel] = resolved.reason === "not-found" ? null : `UNREADABLE:${resolved.reason}`;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const stats = statSync(resolved.path);
|
|
73
|
+
controlHashes[rel] = stats.isFile() && stats.size <= MAX_CONTROL_BYTES
|
|
74
|
+
? hash(readFileSync(resolved.path))
|
|
75
|
+
: stats.isFile() ? "UNREADABLE:oversized" : "UNREADABLE:not-file";
|
|
76
|
+
} catch {
|
|
77
|
+
controlHashes[rel] = "UNREADABLE:unreadable";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const revision = hash([
|
|
81
|
+
...relativeFiles.map((file) => `${file}:${fileHashes[file]}`),
|
|
82
|
+
...Object.keys(controlHashes).sort().map((file) => `${file}:${controlHashes[file] ?? "missing"}`),
|
|
83
|
+
].join("\n"));
|
|
84
|
+
return { files, relativeFiles, fileHashes, fileExportSignatures, controlHashes, revision };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sameRecord(left, right) {
|
|
88
|
+
const a = left && typeof left === "object" ? left : {};
|
|
89
|
+
const b = right && typeof right === "object" ? right : {};
|
|
90
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
91
|
+
for (const key of keys) if (a[key] !== b[key]) return false;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function changedPaths(previous, current) {
|
|
96
|
+
const keys = new Set([...Object.keys(previous || {}), ...Object.keys(current || {})]);
|
|
97
|
+
return [...keys].filter((file) => previous?.[file] !== current?.[file]).sort();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isBarrelFile(graph, file) {
|
|
101
|
+
const nodesById = new Map((graph.nodes || []).map((node) => [String(node.id), node]));
|
|
102
|
+
return (graph.links || []).some((link) => {
|
|
103
|
+
const sourceFile = fileOfEndpoint(link.source, nodesById);
|
|
104
|
+
return sourceFile === file && (link.relation === "re_exports" || link.barrelProxy === true);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function reverseImporters(graph, changed) {
|
|
109
|
+
const changedSet = new Set(changed);
|
|
110
|
+
const nodesById = new Map((graph.nodes || []).map((node) => [String(node.id), node]));
|
|
111
|
+
const result = new Set();
|
|
112
|
+
for (const link of graph.links || []) {
|
|
113
|
+
const targetFile = fileOfEndpoint(link.target, nodesById);
|
|
114
|
+
if (!changedSet.has(targetFile)) continue;
|
|
115
|
+
const sourceFile = fileOfEndpoint(link.source, nodesById);
|
|
116
|
+
if (sourceFile && sourceFile !== targetFile) result.add(sourceFile);
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function mergeScopedGraph(base, scoped, affected, snapshot) {
|
|
122
|
+
const affectedSet = new Set(affected);
|
|
123
|
+
const baseNodes = Array.isArray(base.nodes) ? base.nodes : [];
|
|
124
|
+
const scopedNodes = Array.isArray(scoped.nodes) ? scoped.nodes : [];
|
|
125
|
+
const baseNodesById = new Map(baseNodes.map((node) => [String(node.id), node]));
|
|
126
|
+
const nodeFile = (node) => norm(node?.source_file || fileOfEndpoint(node?.id, baseNodesById));
|
|
127
|
+
const nodes = [...baseNodes.filter((node) => !affectedSet.has(nodeFile(node))), ...scopedNodes];
|
|
128
|
+
assignDeterministicCommunities(nodes);
|
|
129
|
+
const nodesById = new Map(nodes.map((node) => [String(node.id), node]));
|
|
130
|
+
if (nodesById.size !== nodes.length) throw new Error("incremental merge produced duplicate node ids");
|
|
131
|
+
|
|
132
|
+
const baseLinkKept = (link) => {
|
|
133
|
+
const sourceFile = fileOfEndpoint(link.source, baseNodesById);
|
|
134
|
+
// A scoped rebuild owns every outgoing edge of an affected source. Incoming edges from an
|
|
135
|
+
// unaffected source still describe that source and must survive (A -> B -> changed C reparses
|
|
136
|
+
// B+C, but A -> B is not regenerated by the scoped builder). The node-id filter below safely
|
|
137
|
+
// removes an incoming edge when its affected target symbol no longer exists.
|
|
138
|
+
return !affectedSet.has(sourceFile);
|
|
139
|
+
};
|
|
140
|
+
// Base and scoped links are disjoint by source file: every scoped source was removed above. Keep
|
|
141
|
+
// repeated call/reference occurrences intact because occurrence hotspots intentionally use them.
|
|
142
|
+
const links = [...(base.links || []).filter(baseLinkKept), ...(scoped.links || [])]
|
|
143
|
+
.filter((link) => nodesById.has(endpoint(link.source)) && nodesById.has(endpoint(link.target)));
|
|
144
|
+
|
|
145
|
+
const externalImports = [
|
|
146
|
+
...(base.externalImports || []).filter((record) => !affectedSet.has(norm(record?.file))),
|
|
147
|
+
...(scoped.externalImports || []),
|
|
148
|
+
];
|
|
149
|
+
const fileNodes = new Set(nodes.filter((node) => !String(node.id).includes("#")).map((node) => norm(node.source_file || node.id)));
|
|
150
|
+
if (fileNodes.size !== snapshot.relativeFiles.length || snapshot.relativeFiles.some((file) => !fileNodes.has(file))) {
|
|
151
|
+
throw new Error("incremental merge did not preserve the complete file universe");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const merged = {
|
|
155
|
+
...base,
|
|
156
|
+
nodes,
|
|
157
|
+
links,
|
|
158
|
+
externalImports,
|
|
159
|
+
jsExportRecords: scoped.jsExportRecords || base.jsExportRecords || {},
|
|
160
|
+
fileHashes: snapshot.fileHashes,
|
|
161
|
+
fileExportSignatures: snapshot.fileExportSignatures,
|
|
162
|
+
controlHashes: snapshot.controlHashes,
|
|
163
|
+
graphRevision: snapshot.revision,
|
|
164
|
+
};
|
|
165
|
+
for (const key of ["extImportsV", "edgeTypesV", "complexityV", "repoBoundaryV", "barrelResolutionV", "extractorSchemaV"]) {
|
|
166
|
+
merged[key] = Math.max(Number(base[key]) || 0, Number(scoped[key]) || 0);
|
|
167
|
+
}
|
|
168
|
+
return merged;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function refreshGraphIncrementally(repoDir, existingGraph, {
|
|
172
|
+
buildGraph,
|
|
173
|
+
maxChangedFiles = 24,
|
|
174
|
+
maxParsedFiles = 80,
|
|
175
|
+
builderOptions = {},
|
|
176
|
+
} = {}) {
|
|
177
|
+
const builder = buildGraph || (await import("./internal-builder.js")).buildInternalGraph;
|
|
178
|
+
const snapshot = snapshotRepository(repoDir);
|
|
179
|
+
const full = async (reason, changedFiles = []) => {
|
|
180
|
+
const graph = await builder(repoDir, builderOptions);
|
|
181
|
+
return { graph, kind: "full", changedFiles, reason, revision: graph.graphRevision || snapshot.revision, parsedFiles: snapshot.relativeFiles };
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
if (!existingGraph || !existingGraph.fileHashes || !existingGraph.fileExportSignatures
|
|
185
|
+
|| !existingGraph.controlHashes || !existingGraph.jsExportRecords || Number(existingGraph.barrelResolutionV) < 1
|
|
186
|
+
|| Number(existingGraph.extractorSchemaV) < 1) {
|
|
187
|
+
return full("incremental-baseline-unavailable");
|
|
188
|
+
}
|
|
189
|
+
if (!sameRecord(existingGraph.controlHashes, snapshot.controlHashes)) return full("ignore-or-control-config-changed");
|
|
190
|
+
|
|
191
|
+
const oldFiles = Object.keys(existingGraph.fileHashes).sort();
|
|
192
|
+
if (oldFiles.length !== snapshot.relativeFiles.length || oldFiles.some((file, index) => file !== snapshot.relativeFiles[index])) {
|
|
193
|
+
const changedFiles = changedPaths(existingGraph.fileHashes, snapshot.fileHashes);
|
|
194
|
+
return full("file-universe-changed", changedFiles);
|
|
195
|
+
}
|
|
196
|
+
const changedFiles = changedPaths(existingGraph.fileHashes, snapshot.fileHashes);
|
|
197
|
+
if (!changedFiles.length) {
|
|
198
|
+
return { graph: existingGraph, kind: "none", changedFiles: [], reason: "content-unchanged", revision: snapshot.revision, parsedFiles: [] };
|
|
199
|
+
}
|
|
200
|
+
if (changedFiles.length > maxChangedFiles) return full("changed-set-too-large", changedFiles);
|
|
201
|
+
if (changedFiles.some((file) => CONFIG_RISK.test(file))) return full("config-manifest-or-alias-changed", changedFiles);
|
|
202
|
+
if (changedFiles.some((file) => !JS_EXT.has(extname(file)))) return full("language-requires-full-context", changedFiles);
|
|
203
|
+
for (const file of changedFiles) {
|
|
204
|
+
if (isBarrelFile(existingGraph, file)) return full(`barrel-file-changed:${file}`, changedFiles);
|
|
205
|
+
if (existingGraph.fileExportSignatures[file] !== snapshot.fileExportSignatures[file]) {
|
|
206
|
+
return full(`export-surface-changed:${file}`, changedFiles);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const importers = reverseImporters(existingGraph, changedFiles);
|
|
211
|
+
const affected = [...new Set([...changedFiles, ...importers])].filter((file) => snapshot.fileHashes[file]).sort();
|
|
212
|
+
if (affected.length > maxParsedFiles) return full("reverse-importer-set-too-large", changedFiles);
|
|
213
|
+
try {
|
|
214
|
+
const scoped = await builder(repoDir, {
|
|
215
|
+
...builderOptions,
|
|
216
|
+
includeFiles: affected,
|
|
217
|
+
baseGraph: existingGraph,
|
|
218
|
+
});
|
|
219
|
+
if (scoped.incrementalScope !== true) return full("builder-did-not-honor-incremental-scope", changedFiles);
|
|
220
|
+
const graph = mergeScopedGraph(existingGraph, scoped, affected, snapshot);
|
|
221
|
+
return {
|
|
222
|
+
graph,
|
|
223
|
+
kind: "incremental",
|
|
224
|
+
changedFiles,
|
|
225
|
+
reason: importers.size ? "changed-files-and-reverse-importers" : "changed-files-only",
|
|
226
|
+
revision: snapshot.revision,
|
|
227
|
+
parsedFiles: affected,
|
|
228
|
+
};
|
|
229
|
+
} catch {
|
|
230
|
+
return full("incremental-merge-safety-fallback", changedFiles);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// TypeScript/JavaScript barrel transparency. The parser keeps physical import/re-export edges for
|
|
2
|
+
// execution-order and cycle analysis; this post-pass resolves public export names to their declaring
|
|
3
|
+
// files, marks the physical facade hops as proxies, and adds direct semantic importer -> origin edges.
|
|
4
|
+
// Resolution follows ESM rules that matter for a static graph: explicit exports shadow `export *`,
|
|
5
|
+
// `default` never flows through a star, cycles terminate, and conflicting star origins stay ambiguous.
|
|
6
|
+
|
|
7
|
+
const resolved = (file, name, typeOnly = false) => ({ status: "resolved", origin: { file, name, typeOnly } });
|
|
8
|
+
const MISSING = Object.freeze({ status: "missing" });
|
|
9
|
+
const AMBIGUOUS = Object.freeze({ status: "ambiguous" });
|
|
10
|
+
|
|
11
|
+
function mergeCandidates(candidates) {
|
|
12
|
+
if (candidates.some((candidate) => candidate.status === "ambiguous")) return AMBIGUOUS;
|
|
13
|
+
const origins = new Map();
|
|
14
|
+
for (const candidate of candidates) {
|
|
15
|
+
if (candidate.status !== "resolved") continue;
|
|
16
|
+
const key = `${candidate.origin.file}\0${candidate.origin.name}`;
|
|
17
|
+
const current = origins.get(key);
|
|
18
|
+
if (!current) origins.set(key, { ...candidate.origin });
|
|
19
|
+
else current.typeOnly = current.typeOnly && candidate.origin.typeOnly;
|
|
20
|
+
}
|
|
21
|
+
if (!origins.size) return MISSING;
|
|
22
|
+
if (origins.size > 1) return AMBIGUOUS;
|
|
23
|
+
return { status: "resolved", origin: [...origins.values()][0] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function withTypeOnly(result, typeOnly) {
|
|
27
|
+
if (result.status !== "resolved" || !typeOnly) return result;
|
|
28
|
+
return resolved(result.origin.file, result.origin.name, true);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveJsBarrels({ jsExports, importedLocals, links }) {
|
|
32
|
+
const tables = new Map();
|
|
33
|
+
for (const [file, records] of jsExports) {
|
|
34
|
+
const table = { explicit: new Map(), stars: [] };
|
|
35
|
+
for (const record of records || []) {
|
|
36
|
+
if (record.kind === "star") {
|
|
37
|
+
table.stars.push(record);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (!record.exported) continue;
|
|
41
|
+
const list = table.explicit.get(record.exported) || [];
|
|
42
|
+
list.push(record);
|
|
43
|
+
table.explicit.set(record.exported, list);
|
|
44
|
+
}
|
|
45
|
+
tables.set(file, table);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const cache = new Map();
|
|
49
|
+
const resolveExport = (file, name, trail = new Set()) => {
|
|
50
|
+
const key = `${file}\0${name}`;
|
|
51
|
+
const rootResolution = trail.size === 0;
|
|
52
|
+
if (cache.has(key)) return cache.get(key);
|
|
53
|
+
if (trail.has(key)) return MISSING;
|
|
54
|
+
const table = tables.get(file);
|
|
55
|
+
if (!table) return MISSING;
|
|
56
|
+
const nextTrail = new Set(trail);
|
|
57
|
+
nextTrail.add(key);
|
|
58
|
+
|
|
59
|
+
const explicit = table.explicit.get(name) || [];
|
|
60
|
+
if (explicit.length) {
|
|
61
|
+
const candidates = explicit.map((record) => {
|
|
62
|
+
if (record.kind === "named") {
|
|
63
|
+
return withTypeOnly(resolveExport(record.targetFile, record.imported, nextTrail), record.typeOnly);
|
|
64
|
+
}
|
|
65
|
+
if (record.kind === "namespace") return resolved(record.targetFile, "*", record.typeOnly);
|
|
66
|
+
const local = record.local || name;
|
|
67
|
+
const binding = importedLocals.get(file)?.get(local);
|
|
68
|
+
if (binding?.targetFile && binding.imported && binding.imported !== "*") {
|
|
69
|
+
return withTypeOnly(resolveExport(binding.targetFile, binding.imported, nextTrail), record.typeOnly || binding.typeOnly);
|
|
70
|
+
}
|
|
71
|
+
// Type/interface declarations are intentionally not symbol nodes yet, but their declaring file is
|
|
72
|
+
// still the correct semantic origin for file/module dependency analysis.
|
|
73
|
+
return resolved(file, local, record.typeOnly);
|
|
74
|
+
});
|
|
75
|
+
const result = mergeCandidates(candidates);
|
|
76
|
+
if (rootResolution) cache.set(key, result);
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (name === "default") {
|
|
81
|
+
if (rootResolution) cache.set(key, MISSING);
|
|
82
|
+
return MISSING;
|
|
83
|
+
}
|
|
84
|
+
const candidates = table.stars.map((star) => withTypeOnly(resolveExport(star.targetFile, name, nextTrail), star.typeOnly));
|
|
85
|
+
const result = mergeCandidates(candidates);
|
|
86
|
+
if (rootResolution) cache.set(key, result);
|
|
87
|
+
return result;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Every internal re-export is a physical facade hop. It remains in the graph for runtime cycle truth,
|
|
91
|
+
// but semantic consumers ignore it in favor of importer -> declaration edges below.
|
|
92
|
+
for (const link of links) {
|
|
93
|
+
if (link.relation !== "re_exports") continue;
|
|
94
|
+
const records = jsExports.get(String(link.source)) || [];
|
|
95
|
+
if (records.some((record) => record.targetFile === String(link.target))) link.barrelProxy = true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const semanticEdges = new Map();
|
|
99
|
+
const addSemanticEdge = (source, imp, origin, usage = null, force = false) => {
|
|
100
|
+
if (!origin?.file || (!force && origin.file === imp.targetFile)) return;
|
|
101
|
+
const key = `${source}\0${origin.file}\0${imp.line || 0}`;
|
|
102
|
+
const effectiveTypeOnly = imp.typeOnly === true || origin.typeOnly === true;
|
|
103
|
+
const current = semanticEdges.get(key);
|
|
104
|
+
if (current) {
|
|
105
|
+
if (!effectiveTypeOnly) delete current.typeOnly;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
semanticEdges.set(key, {
|
|
109
|
+
source,
|
|
110
|
+
target: origin.file,
|
|
111
|
+
relation: "imports",
|
|
112
|
+
confidence: "EXTRACTED",
|
|
113
|
+
semanticOrigin: true,
|
|
114
|
+
viaBarrel: imp.targetFile,
|
|
115
|
+
...(effectiveTypeOnly ? { typeOnly: true } : {}),
|
|
116
|
+
...(imp.line ? { line: imp.line } : {}),
|
|
117
|
+
...(imp.specifier ? { specifier: imp.specifier } : {}),
|
|
118
|
+
...(usage ? { usage } : {}),
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const proxiedImportGroups = new Set();
|
|
123
|
+
for (const [source, imports] of importedLocals) {
|
|
124
|
+
for (const imp of imports.values()) {
|
|
125
|
+
if (!imp?.targetFile || imp.imported === "*") continue;
|
|
126
|
+
const result = resolveExport(imp.targetFile, imp.imported);
|
|
127
|
+
if (result.status !== "resolved") continue;
|
|
128
|
+
imp.originFile = result.origin.file;
|
|
129
|
+
imp.originName = result.origin.name;
|
|
130
|
+
imp.originTypeOnly = result.origin.typeOnly === true;
|
|
131
|
+
if (result.origin.file === imp.targetFile) continue;
|
|
132
|
+
proxiedImportGroups.add(`${source}\0${imp.targetFile}\0${imp.line || 0}`);
|
|
133
|
+
for (const link of links) {
|
|
134
|
+
if (String(link.source) === source && String(link.target) === imp.targetFile && link.relation === "imports"
|
|
135
|
+
&& (!imp.line || !link.line || link.line === imp.line)) link.barrelProxy = true;
|
|
136
|
+
}
|
|
137
|
+
addSemanticEdge(source, imp, result.origin);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// One import statement can mix a barrel-local export with re-exported bindings. Once its physical
|
|
141
|
+
// file edge becomes a proxy, preserve the local binding as its own semantic edge to the barrel file.
|
|
142
|
+
for (const [source, imports] of importedLocals) for (const imp of imports.values()) {
|
|
143
|
+
if (!imp?.originFile || imp.originFile !== imp.targetFile) continue;
|
|
144
|
+
if (!proxiedImportGroups.has(`${source}\0${imp.targetFile}\0${imp.line || 0}`)) continue;
|
|
145
|
+
addSemanticEdge(source, imp, { file: imp.originFile, name: imp.originName, typeOnly: imp.originTypeOnly }, null, true);
|
|
146
|
+
}
|
|
147
|
+
links.push(...semanticEdges.values());
|
|
148
|
+
|
|
149
|
+
// Namespace member usage is only knowable in pass 2 (`ui.Button`, `ui.run`). Expose a bounded helper
|
|
150
|
+
// that uses the same cache and emits the corresponding semantic file edge exactly once.
|
|
151
|
+
const resolveNamespaceMember = (source, imp, member, usage = null) => {
|
|
152
|
+
if (!imp?.targetFile || imp.imported !== "*") return MISSING;
|
|
153
|
+
const result = resolveExport(imp.targetFile, member);
|
|
154
|
+
if (result.status !== "resolved") return result;
|
|
155
|
+
if (result.origin.file !== imp.targetFile) {
|
|
156
|
+
for (const link of links) {
|
|
157
|
+
if (String(link.source) === source && String(link.target) === imp.targetFile && link.relation === "imports"
|
|
158
|
+
&& (!imp.line || !link.line || link.line === imp.line)) link.barrelProxy = true;
|
|
159
|
+
}
|
|
160
|
+
addSemanticEdge(source, imp, result.origin, usage);
|
|
161
|
+
// addSemanticEdge may have created a new entry after the initial push.
|
|
162
|
+
const edge = semanticEdges.get(`${source}\0${result.origin.file}\0${imp.line || 0}`);
|
|
163
|
+
if (edge && !links.includes(edge)) links.push(edge);
|
|
164
|
+
}
|
|
165
|
+
return result;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
return { resolveExport, resolveNamespaceMember };
|
|
169
|
+
}
|
|
@@ -9,11 +9,19 @@ import { analyzeSyntaxComplexity } from "../analysis/source-complexity.js";
|
|
|
9
9
|
import { Parser, Query, GRAMMARS, LANGS, EXT_LANG, FAMILY, isDataFile, isDocFile, MAX_PARSE_BYTES, ensureParser, walk } from "./internal-builder.langs.js";
|
|
10
10
|
import { buildResolvers } from "./internal-builder.resolvers.js";
|
|
11
11
|
import { addJavaReferences } from "./internal-builder.java.js";
|
|
12
|
-
import {
|
|
12
|
+
import { assignDeterministicCommunities } from "./community.js";
|
|
13
|
+
import { resolveJsBarrels } from "./internal-builder.barrels.js";
|
|
14
|
+
import { snapshotRepository } from "./incremental-refresh.js";
|
|
13
15
|
|
|
14
16
|
// Parse a repo directory into a graph-builder-compatible { nodes, links } graph.
|
|
15
17
|
export async function buildInternalGraph(repoDir, opts = {}) {
|
|
16
|
-
const
|
|
18
|
+
const rel = (p) => relative(repoDir, p).replace(/\\/g, "/");
|
|
19
|
+
const allFiles = walk(repoDir);
|
|
20
|
+
const snapshot = snapshotRepository(repoDir, allFiles);
|
|
21
|
+
const requestedFiles = Array.isArray(opts.includeFiles)
|
|
22
|
+
? new Set(opts.includeFiles.map((file) => String(file).replace(/\\/g, "/").replace(/^\.\//, "")))
|
|
23
|
+
: null;
|
|
24
|
+
const files = requestedFiles ? allFiles.filter((file) => requestedFiles.has(rel(file))) : allFiles;
|
|
17
25
|
// Lazy grammar loading: compile only the WASMs for languages this repo actually contains.
|
|
18
26
|
const wanted = new Set();
|
|
19
27
|
for (const f of files) { const g = EXT_LANG[extname(f)]; if (g) wanted.add(g); }
|
|
@@ -23,13 +31,31 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
23
31
|
const caps = (grammar, src, root) => { const query = src && q(grammar, src); return query ? query.captures(root) : []; };
|
|
24
32
|
const field = (n, f) => (n && n.childForFieldName ? n.childForFieldName(f) : null);
|
|
25
33
|
|
|
26
|
-
const
|
|
27
|
-
const fileSet = new Set(files.map(rel));
|
|
34
|
+
const fileSet = new Set(allFiles.map(rel));
|
|
28
35
|
const nodes = []; const links = []; const nodeIds = new Set(); const nodeById = new Map();
|
|
29
36
|
const addNode = (n) => { if (!nodeIds.has(n.id)) { nodeIds.add(n.id); nodes.push(n); nodeById.set(n.id, n); } };
|
|
30
37
|
const perFileSymbols = new Map();
|
|
31
38
|
const symByFileName = new Map();
|
|
32
39
|
const importedLocals = new Map();
|
|
40
|
+
const jsExports = new Map();
|
|
41
|
+
if (requestedFiles && opts.baseGraph?.jsExportRecords) {
|
|
42
|
+
for (const [file, records] of Object.entries(opts.baseGraph.jsExportRecords)) {
|
|
43
|
+
if (!requestedFiles.has(file) && Array.isArray(records)) jsExports.set(file, records.map((record) => ({ ...record })));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (requestedFiles && Array.isArray(opts.baseGraph?.nodes)) {
|
|
47
|
+
for (const node of opts.baseGraph.nodes) {
|
|
48
|
+
const id = String(node?.id || "");
|
|
49
|
+
const file = String(node?.source_file || (id.includes("#") ? id.slice(0, id.indexOf("#")) : id)).replace(/\\/g, "/");
|
|
50
|
+
if (!id.includes("#") || requestedFiles.has(file) || !fileSet.has(file)) continue;
|
|
51
|
+
const match = id.match(/#([A-Za-z_$][\w$]*)@\d+/);
|
|
52
|
+
if (!match) continue;
|
|
53
|
+
let names = symByFileName.get(file);
|
|
54
|
+
if (!names) symByFileName.set(file, (names = new Map()));
|
|
55
|
+
if (!names.has(match[1])) names.set(match[1], id);
|
|
56
|
+
nodeById.set(id, node);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
33
59
|
// Bare-package imports (axios, node:fs, @scope/x) — the graph can't resolve them to a repo file, but
|
|
34
60
|
// dependency analysis NEEDS them (unused/missing deps). Additive top-level array; nodes/links untouched.
|
|
35
61
|
const externalImports = [];
|
|
@@ -52,6 +78,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
52
78
|
// giant / generated / minified file → keep the file NODE but don't parse symbols (avoids a wedged parse)
|
|
53
79
|
if (code.length > MAX_PARSE_BYTES) { perFileSymbols.set(fileRel, []); symByFileName.set(fileRel, new Map()); continue; }
|
|
54
80
|
if ((++_parsed % 24) === 0) await new Promise((r) => setImmediate(r)); // breathe: let the UI paint between chunks
|
|
81
|
+
if (typeof opts.onParseFile === "function") opts.onParseFile(fileRel);
|
|
55
82
|
const parser = new Parser(); parser.setLanguage(langs[grammar]);
|
|
56
83
|
let tree; try { tree = parser.parse(code); } catch { continue; }
|
|
57
84
|
|
|
@@ -88,6 +115,12 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
88
115
|
return id;
|
|
89
116
|
};
|
|
90
117
|
const imports = new Map(); importedLocals.set(fileRel, imports);
|
|
118
|
+
const recordJsExport = (record) => {
|
|
119
|
+
if (!record) return;
|
|
120
|
+
const records = jsExports.get(fileRel) || [];
|
|
121
|
+
records.push(record);
|
|
122
|
+
jsExports.set(fileRel, records);
|
|
123
|
+
};
|
|
91
124
|
const addImportEdge = (tgt, meta = {}) => {
|
|
92
125
|
if (!tgt || tgt === fileRel) return;
|
|
93
126
|
links.push({
|
|
@@ -117,7 +150,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
117
150
|
// Post-hoc export flag (export {a}, export default X, CJS module.exports) — declarations are flagged at addSym time.
|
|
118
151
|
const markExported = (name) => { const id = moduleNameToId.get(name); const n = id && nodeById.get(id); if (n) n.exported = true; };
|
|
119
152
|
|
|
120
|
-
try { lang.pass1({ grammar, tree, fileRel, code, caps, field, addSym, addNode, links, nodeIds, syms, nameToId, imports, addImportEdge, addExternalImport, markExported, fileSet, ...resolvers }); }
|
|
153
|
+
try { lang.pass1({ grammar, tree, fileRel, code, caps, field, addSym, addNode, links, nodeIds, syms, nameToId, imports, addImportEdge, addExternalImport, markExported, recordJsExport, fileSet, ...resolvers }); }
|
|
121
154
|
catch (e) { /* one bad file never sinks the whole build */ void e; }
|
|
122
155
|
|
|
123
156
|
syms.sort((a, b) => a.start - b.start);
|
|
@@ -140,6 +173,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
140
173
|
let dm = goDirSymbols.get(d); if (!dm) goDirSymbols.set(d, (dm = new Map()));
|
|
141
174
|
for (const [n, id] of m) if (!dm.has(n)) dm.set(n, id);
|
|
142
175
|
}
|
|
176
|
+
const { resolveNamespaceMember } = resolveJsBarrels({ jsExports, importedLocals, links });
|
|
143
177
|
// Exact source ranges can overlap (a named function nested inside another function). Attribute a call
|
|
144
178
|
// to the innermost matching symbol, not whichever outer declaration happened to be added first.
|
|
145
179
|
const enclosing = (fileRel, line) => {
|
|
@@ -154,7 +188,11 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
154
188
|
const local = symByFileName.get(fileRel); if (local && local.has(name)) return local.get(name);
|
|
155
189
|
if (sharesDirScope(fileRel)) { const d = fileRel.includes("/") ? fileRel.slice(0, fileRel.lastIndexOf("/")) : ""; const dm = goDirSymbols.get(d); if (dm && dm.has(name)) return dm.get(name); }
|
|
156
190
|
const imp = importedLocals.get(fileRel) && importedLocals.get(fileRel).get(name);
|
|
157
|
-
if (imp && imp.targetFile) {
|
|
191
|
+
if (imp && imp.targetFile) {
|
|
192
|
+
const targetFile = imp.originFile || imp.targetFile;
|
|
193
|
+
const importedName = imp.originName || imp.imported;
|
|
194
|
+
const tf = symByFileName.get(targetFile); if (tf && tf.has(importedName)) return tf.get(importedName);
|
|
195
|
+
}
|
|
158
196
|
return null;
|
|
159
197
|
};
|
|
160
198
|
const javaTypeKinds = new Set(["class", "interface", "enum", "record", "annotation"]);
|
|
@@ -203,6 +241,18 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
203
241
|
// JSX is a real symbol use even though it is not a call_expression. Resolve imported components to their
|
|
204
242
|
// declaration so component fan-in and unused-export checks do not claim `<SettingsView />` is unreferenced.
|
|
205
243
|
if (FAMILY[grammar] === "js") {
|
|
244
|
+
// Namespace calls (`ui.run()`) need the member name before an export-star facade can be resolved.
|
|
245
|
+
for (const cap of caps(grammar, `(call_expression function: (member_expression) @memberCall)`, tree.rootNode)) {
|
|
246
|
+
const object = field(cap.node, "object"), property = field(cap.node, "property");
|
|
247
|
+
if (!object || object.type !== "identifier" || !property) continue;
|
|
248
|
+
const imp = importedLocals.get(fileRel)?.get(object.text);
|
|
249
|
+
if (!imp || imp.imported !== "*" || imp.typeOnly) continue;
|
|
250
|
+
const origin = resolveNamespaceMember(fileRel, imp, property.text, "call");
|
|
251
|
+
if (origin.status !== "resolved") continue;
|
|
252
|
+
const target = symByFileName.get(origin.origin.file)?.get(origin.origin.name);
|
|
253
|
+
const caller = enclosing(fileRel, cap.node.startPosition.row + 1);
|
|
254
|
+
if (target && caller && target !== caller.id) links.push({ source: caller.id, target, relation: "calls", confidence: "INFERRED" });
|
|
255
|
+
}
|
|
206
256
|
for (const cap of caps(grammar, `[
|
|
207
257
|
(jsx_opening_element name: (_) @jsx)
|
|
208
258
|
(jsx_self_closing_element name: (_) @jsx)
|
|
@@ -213,9 +263,14 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
213
263
|
if (parts.length === 1 && !/^[A-Z_$]/.test(localName)) continue; // undotted lowercase tags are platform/intrinsic elements
|
|
214
264
|
const imp = importedLocals.get(fileRel)?.get(localName);
|
|
215
265
|
if (!imp || !imp.targetFile || imp.typeOnly) continue;
|
|
216
|
-
|
|
266
|
+
let targetFile = imp.originFile || imp.targetFile;
|
|
267
|
+
let importedName = imp.originName || imp.imported;
|
|
268
|
+
if (imp.imported === "*" && parts.length > 1) {
|
|
269
|
+
const origin = resolveNamespaceMember(fileRel, imp, parts[parts.length - 1], "jsx");
|
|
270
|
+
if (origin.status === "resolved") { targetFile = origin.origin.file; importedName = origin.origin.name; }
|
|
271
|
+
}
|
|
272
|
+
const targetSymbols = symByFileName.get(targetFile);
|
|
217
273
|
if (!targetSymbols) continue;
|
|
218
|
-
const importedName = imp.imported === "*" && parts.length > 1 ? parts[parts.length - 1] : imp.imported;
|
|
219
274
|
const target = targetSymbols.get(importedName) || targetSymbols.get(localName);
|
|
220
275
|
if (!target) continue;
|
|
221
276
|
const owner = enclosing(fileRel, cap.node.startPosition.row + 1);
|
|
@@ -261,18 +316,34 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
261
316
|
|
|
262
317
|
// community = folder bucket (top 2 path parts) — deterministic, mirrors the folder-based module grouping the
|
|
263
318
|
// app already uses (graph-builder-analysis.js). Populates Modules/community cards without a heavy clustering pass.
|
|
264
|
-
|
|
265
|
-
for (const n of nodes) { const territory = communityTerritoryOf(n.source_file); if (!commOf.has(territory)) commOf.set(territory, commSeq++); n.community = commOf.get(territory); }
|
|
319
|
+
assignDeterministicCommunities(nodes);
|
|
266
320
|
|
|
267
321
|
// extImportsV: bump when the externalImports schema/coverage changes (v2 = go/python ecosystems) —
|
|
268
322
|
// deps-engine rebuilds in memory when a saved graph is older than this.
|
|
269
323
|
// edgeTypesV 2 adds language-neutral compile-only edges (currently Rust mod/use/re-export) on top
|
|
270
324
|
// of v1's TypeScript typeOnly classification.
|
|
271
|
-
return {
|
|
325
|
+
return {
|
|
326
|
+
nodes,
|
|
327
|
+
links,
|
|
328
|
+
externalImports,
|
|
329
|
+
extImportsV: 2,
|
|
330
|
+
edgeTypesV: 2,
|
|
331
|
+
complexityV: 1,
|
|
332
|
+
repoBoundaryV: 1,
|
|
333
|
+
barrelResolutionV: 1,
|
|
334
|
+
extractorSchemaV: 1,
|
|
335
|
+
jsExportRecords: Object.fromEntries([...jsExports.entries()].sort(([a], [b]) => a.localeCompare(b))),
|
|
336
|
+
fileHashes: snapshot.fileHashes,
|
|
337
|
+
fileExportSignatures: snapshot.fileExportSignatures,
|
|
338
|
+
controlHashes: snapshot.controlHashes,
|
|
339
|
+
graphRevision: snapshot.revision,
|
|
340
|
+
...(requestedFiles ? { incrementalScope: true } : {}),
|
|
341
|
+
};
|
|
272
342
|
}
|
|
273
343
|
|
|
274
344
|
// Build + write graph.json to outPath (creating the dir). Returns { ok, nodes, links, graphJson }.
|
|
275
345
|
export async function writeInternalGraph(repoDir, outPath, opts = {}) {
|
|
346
|
+
if (Array.isArray(opts.includeFiles)) throw new Error("refusing to write a scoped incremental graph as a complete graph");
|
|
276
347
|
const graph = await buildInternalGraph(repoDir, opts);
|
|
277
348
|
mkdirSync(dirname(outPath), { recursive: true });
|
|
278
349
|
writeFileSync(outPath, JSON.stringify(graph), "utf8");
|
|
@@ -9,6 +9,7 @@ import { execFileSync } from "node:child_process";
|
|
|
9
9
|
import { createRequire } from "node:module";
|
|
10
10
|
import { isPathInside } from "../repo-path.js";
|
|
11
11
|
import { childProcessEnv } from "../child-env.js";
|
|
12
|
+
import { filterWeavatrixIgnored } from "../path-ignore.js";
|
|
12
13
|
import LANG_JS from "./builder/lang-js.js";
|
|
13
14
|
import LANG_PY from "./builder/lang-python.js";
|
|
14
15
|
import LANG_GO from "./builder/lang-go.js";
|
|
@@ -132,7 +133,7 @@ function gitFileUniverse(dir) {
|
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
function walk(dir) {
|
|
135
|
-
return gitFileUniverse(dir) ?? walkFallback(dir);
|
|
136
|
+
return filterWeavatrixIgnored(dir, gitFileUniverse(dir) ?? walkFallback(dir));
|
|
136
137
|
}
|
|
137
138
|
|
|
138
139
|
export { Parser, Query, GRAMMARS, LANGS, EXT_LANG, FAMILY, isDataFile, isDocFile, MAX_PARSE_BYTES, ensureParser, walk };
|
package/src/graph/layout.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
// pure filter/analysis helpers so callers get everything from one import:
|
|
3
3
|
// graph-filter.js — pure graph filters (test-mode, path-scope)
|
|
4
4
|
// graph-analysis.js — parse graph.json → file/module/symbol view, hotspots, communities
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { basename, join, resolve } from "node:path";
|
|
8
|
+
import process from "node:process";
|
|
9
|
+
import { readdirSync, realpathSync } from "node:fs";
|
|
8
10
|
|
|
9
11
|
export * from "./graph-filter.js";
|
|
10
12
|
export * from "../analysis/graph-analysis.js";
|
|
@@ -12,14 +14,28 @@ export * from "../analysis/graph-analysis.js";
|
|
|
12
14
|
// Graphs live in a `weavatrix-graphs/` folder NEXT to the repo (inside the repo's parent folder),
|
|
13
15
|
// never inside the repo itself — the graph is derived data, not source. One folder per repo holds
|
|
14
16
|
// graph.json plus graph.prev.json (saved by rebuild_graph for graph_diff).
|
|
17
|
+
export function graphHomeDir() {
|
|
18
|
+
return resolve(process.env.WEAVATRIX_GRAPH_HOME || join(homedir(), ".weavatrix", "graphs"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function graphStorageKey(repoPath) {
|
|
22
|
+
let absolute;
|
|
23
|
+
try { absolute = realpathSync.native(repoPath); }
|
|
24
|
+
catch { absolute = resolve(repoPath); }
|
|
25
|
+
const normalized = process.platform === "win32" ? absolute.toLowerCase() : absolute;
|
|
26
|
+
const slug = (basename(absolute) || "repo").replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
27
|
+
const digest = createHash("sha256").update(normalized).digest("hex").slice(0, 12);
|
|
28
|
+
return `${slug}-${digest}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
export function graphOutDirForRepo(repoPath) {
|
|
16
|
-
return join(
|
|
32
|
+
return join(graphHomeDir(), graphStorageKey(repoPath));
|
|
17
33
|
}
|
|
18
34
|
|
|
19
35
|
// A separate dir for a single module's scoped graph, so it never clobbers the repo's graph.
|
|
20
36
|
export function graphOutDirForModule(repoPath, moduleName) {
|
|
21
37
|
const safe = String(moduleName).replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
22
|
-
return join(
|
|
38
|
+
return join(graphOutDirForRepo(repoPath), "modules", safe);
|
|
23
39
|
}
|
|
24
40
|
|
|
25
41
|
// Top-level source folders of a repo (for path-scoped builds).
|