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.
Files changed (70) hide show
  1. package/README.md +138 -53
  2. package/SECURITY.md +25 -8
  3. package/package.json +2 -2
  4. package/skill/SKILL.md +92 -30
  5. package/src/analysis/architecture-contract.js +343 -0
  6. package/src/analysis/audit-debt.js +94 -0
  7. package/src/analysis/change-classification.js +509 -0
  8. package/src/analysis/cycle-route.js +14 -0
  9. package/src/analysis/dead-check.js +5 -3
  10. package/src/analysis/dead-code-review.js +245 -0
  11. package/src/analysis/dep-check-ecosystems.js +6 -0
  12. package/src/analysis/dep-check.js +40 -6
  13. package/src/analysis/dep-rules.js +12 -9
  14. package/src/analysis/duplicates.compute.js +47 -7
  15. package/src/analysis/endpoints-java.js +186 -0
  16. package/src/analysis/endpoints.js +8 -2
  17. package/src/analysis/findings.js +8 -1
  18. package/src/analysis/git-history.js +549 -0
  19. package/src/analysis/git-ref-graph.js +74 -0
  20. package/src/analysis/graph-analysis.aggregate.js +2 -2
  21. package/src/analysis/graph-analysis.edges.js +3 -0
  22. package/src/analysis/graph-analysis.summaries.js +1 -1
  23. package/src/analysis/http-contracts.js +581 -0
  24. package/src/analysis/internal-audit.collect.js +3 -2
  25. package/src/analysis/internal-audit.reach.js +52 -1
  26. package/src/analysis/internal-audit.run.js +58 -10
  27. package/src/analysis/java-source.js +36 -0
  28. package/src/analysis/package-reachability.js +39 -0
  29. package/src/analysis/static-test-reachability.js +133 -0
  30. package/src/build-graph.js +72 -13
  31. package/src/graph/build-worker.js +3 -4
  32. package/src/graph/builder/lang-js.js +71 -12
  33. package/src/graph/community.js +10 -0
  34. package/src/graph/file-lock.js +69 -0
  35. package/src/graph/freshness-probe.js +141 -0
  36. package/src/graph/graph-filter.js +28 -11
  37. package/src/graph/incremental-refresh.js +232 -0
  38. package/src/graph/internal-builder.barrels.js +169 -0
  39. package/src/graph/internal-builder.build.js +82 -11
  40. package/src/graph/internal-builder.langs.js +2 -1
  41. package/src/graph/layout.js +21 -5
  42. package/src/graph/repo-registry.js +124 -0
  43. package/src/mcp/catalog.mjs +62 -19
  44. package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
  45. package/src/mcp/evidence-snapshot.common.mjs +160 -0
  46. package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
  47. package/src/mcp/evidence-snapshot.health.mjs +95 -0
  48. package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
  49. package/src/mcp/evidence-snapshot.mjs +50 -0
  50. package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
  51. package/src/mcp/evidence-snapshot.structure.mjs +81 -0
  52. package/src/mcp/graph-context.mjs +100 -16
  53. package/src/mcp/graph-diff.mjs +74 -20
  54. package/src/mcp/staleness-notice.mjs +20 -0
  55. package/src/mcp/sync-evidence.mjs +418 -0
  56. package/src/mcp/sync-payload.mjs +164 -0
  57. package/src/mcp/tool-result.mjs +51 -0
  58. package/src/mcp/tools-actions.mjs +111 -30
  59. package/src/mcp/tools-architecture.mjs +144 -0
  60. package/src/mcp/tools-company.mjs +273 -0
  61. package/src/mcp/tools-graph.mjs +25 -14
  62. package/src/mcp/tools-health.mjs +336 -14
  63. package/src/mcp/tools-history.mjs +22 -0
  64. package/src/mcp/tools-impact-change.mjs +261 -0
  65. package/src/mcp/tools-impact.mjs +25 -6
  66. package/src/mcp-server.mjs +100 -17
  67. package/src/mcp-source-tools.mjs +11 -3
  68. package/src/path-classification.js +201 -0
  69. package/src/path-ignore.js +69 -0
  70. 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 { communityTerritoryOf } from "./community.js";
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 files = walk(repoDir);
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 rel = (p) => relative(repoDir, p).replace(/\\/g, "/");
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) { const tf = symByFileName.get(imp.targetFile); if (tf && tf.has(imp.imported)) return tf.get(imp.imported); }
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
- const targetSymbols = symByFileName.get(imp.targetFile);
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
- const commOf = new Map(); let commSeq = 0;
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 { nodes, links, externalImports, extImportsV: 2, edgeTypesV: 2, complexityV: 1, repoBoundaryV: 1 };
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 };
@@ -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 { readdirSync } from "node:fs";
6
- import { join, dirname } from "node:path";
7
- import { repoBaseName } from "../scan/discover.js";
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(dirname(repoPath), "weavatrix-graphs", repoBaseName(repoPath));
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(dirname(repoPath), "weavatrix-graphs", repoBaseName(repoPath), "modules", safe);
38
+ return join(graphOutDirForRepo(repoPath), "modules", safe);
23
39
  }
24
40
 
25
41
  // Top-level source folders of a repo (for path-scoped builds).