weavatrix 0.1.3 → 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 (78) hide show
  1. package/README.md +168 -63
  2. package/SECURITY.md +25 -8
  3. package/package.json +2 -2
  4. package/skill/SKILL.md +108 -39
  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 +13 -6
  10. package/src/analysis/dead-code-review.js +245 -0
  11. package/src/analysis/dep-check-ecosystems.js +163 -0
  12. package/src/analysis/dep-check.js +69 -147
  13. package/src/analysis/dep-rules.js +47 -31
  14. package/src/analysis/duplicates.compute.js +47 -7
  15. package/src/analysis/endpoints-java.js +186 -0
  16. package/src/analysis/endpoints-rust.js +124 -0
  17. package/src/analysis/endpoints.js +18 -5
  18. package/src/analysis/findings.js +8 -1
  19. package/src/analysis/git-history.js +549 -0
  20. package/src/analysis/git-ref-graph.js +74 -0
  21. package/src/analysis/graph-analysis.aggregate.js +29 -28
  22. package/src/analysis/graph-analysis.edges.js +24 -0
  23. package/src/analysis/graph-analysis.js +1 -1
  24. package/src/analysis/graph-analysis.summaries.js +4 -1
  25. package/src/analysis/http-contracts.js +581 -0
  26. package/src/analysis/internal-audit.collect.js +43 -2
  27. package/src/analysis/internal-audit.reach.js +52 -1
  28. package/src/analysis/internal-audit.run.js +66 -13
  29. package/src/analysis/java-source.js +36 -0
  30. package/src/analysis/package-reachability.js +39 -0
  31. package/src/analysis/static-test-reachability.js +133 -0
  32. package/src/build-graph.js +72 -13
  33. package/src/graph/build-worker.js +3 -4
  34. package/src/graph/builder/lang-java.js +177 -14
  35. package/src/graph/builder/lang-js.js +71 -12
  36. package/src/graph/builder/lang-rust.js +129 -6
  37. package/src/graph/community.js +27 -0
  38. package/src/graph/file-lock.js +69 -0
  39. package/src/graph/freshness-probe.js +141 -0
  40. package/src/graph/graph-filter.js +28 -11
  41. package/src/graph/incremental-refresh.js +232 -0
  42. package/src/graph/internal-builder.barrels.js +169 -0
  43. package/src/graph/internal-builder.build.js +111 -16
  44. package/src/graph/internal-builder.java.js +33 -0
  45. package/src/graph/internal-builder.langs.js +2 -1
  46. package/src/graph/internal-builder.resolvers.js +109 -2
  47. package/src/graph/layout.js +21 -5
  48. package/src/graph/relations.js +4 -0
  49. package/src/graph/repo-registry.js +124 -0
  50. package/src/mcp/catalog.mjs +64 -21
  51. package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
  52. package/src/mcp/evidence-snapshot.common.mjs +160 -0
  53. package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
  54. package/src/mcp/evidence-snapshot.health.mjs +95 -0
  55. package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
  56. package/src/mcp/evidence-snapshot.mjs +50 -0
  57. package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
  58. package/src/mcp/evidence-snapshot.structure.mjs +81 -0
  59. package/src/mcp/graph-context.mjs +106 -181
  60. package/src/mcp/graph-diff.mjs +217 -0
  61. package/src/mcp/staleness-notice.mjs +20 -0
  62. package/src/mcp/sync-evidence.mjs +418 -0
  63. package/src/mcp/sync-payload.mjs +194 -8
  64. package/src/mcp/tool-result.mjs +51 -0
  65. package/src/mcp/tools-actions.mjs +114 -33
  66. package/src/mcp/tools-architecture.mjs +144 -0
  67. package/src/mcp/tools-company.mjs +273 -0
  68. package/src/mcp/tools-graph-hubs.mjs +58 -0
  69. package/src/mcp/tools-graph.mjs +36 -68
  70. package/src/mcp/tools-health.mjs +354 -20
  71. package/src/mcp/tools-history.mjs +22 -0
  72. package/src/mcp/tools-impact-change.mjs +261 -0
  73. package/src/mcp/tools-impact.mjs +35 -11
  74. package/src/mcp-server.mjs +100 -17
  75. package/src/mcp-source-tools.mjs +11 -3
  76. package/src/path-classification.js +201 -0
  77. package/src/path-ignore.js +69 -0
  78. package/src/security/typosquat.js +1 -1
@@ -8,10 +8,20 @@ import { specToPkg } from "./builder/spec-pkg.js";
8
8
  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
+ import { addJavaReferences } from "./internal-builder.java.js";
12
+ import { assignDeterministicCommunities } from "./community.js";
13
+ import { resolveJsBarrels } from "./internal-builder.barrels.js";
14
+ import { snapshotRepository } from "./incremental-refresh.js";
11
15
 
12
16
  // Parse a repo directory into a graph-builder-compatible { nodes, links } graph.
13
17
  export async function buildInternalGraph(repoDir, opts = {}) {
14
- 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;
15
25
  // Lazy grammar loading: compile only the WASMs for languages this repo actually contains.
16
26
  const wanted = new Set();
17
27
  for (const f of files) { const g = EXT_LANG[extname(f)]; if (g) wanted.add(g); }
@@ -21,13 +31,31 @@ export async function buildInternalGraph(repoDir, opts = {}) {
21
31
  const caps = (grammar, src, root) => { const query = src && q(grammar, src); return query ? query.captures(root) : []; };
22
32
  const field = (n, f) => (n && n.childForFieldName ? n.childForFieldName(f) : null);
23
33
 
24
- const rel = (p) => relative(repoDir, p).replace(/\\/g, "/");
25
- const fileSet = new Set(files.map(rel));
34
+ const fileSet = new Set(allFiles.map(rel));
26
35
  const nodes = []; const links = []; const nodeIds = new Set(); const nodeById = new Map();
27
36
  const addNode = (n) => { if (!nodeIds.has(n.id)) { nodeIds.add(n.id); nodes.push(n); nodeById.set(n.id, n); } };
28
37
  const perFileSymbols = new Map();
29
38
  const symByFileName = new Map();
30
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
+ }
31
59
  // Bare-package imports (axios, node:fs, @scope/x) — the graph can't resolve them to a repo file, but
32
60
  // dependency analysis NEEDS them (unused/missing deps). Additive top-level array; nodes/links untouched.
33
61
  const externalImports = [];
@@ -50,13 +78,15 @@ export async function buildInternalGraph(repoDir, opts = {}) {
50
78
  // giant / generated / minified file → keep the file NODE but don't parse symbols (avoids a wedged parse)
51
79
  if (code.length > MAX_PARSE_BYTES) { perFileSymbols.set(fileRel, []); symByFileName.set(fileRel, new Map()); continue; }
52
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);
53
82
  const parser = new Parser(); parser.setLanguage(langs[grammar]);
54
83
  let tree; try { tree = parser.parse(code); } catch { continue; }
55
84
 
56
85
  const syms = []; const nameToId = new Map(); const moduleNameToId = new Map();
57
86
  const addSym = (name, line, callable, extra) => {
58
87
  if (!name || !/^[A-Za-z_$][\w$]*$/.test(name)) return;
59
- const id = `${fileRel}#${name}@${line}`; if (nodeIds.has(id)) return;
88
+ const suffix = /^:[A-Za-z0-9_-]+$/.test(extra?.idSuffix || "") ? extra.idSuffix : "";
89
+ const id = `${fileRel}#${name}@${line}${suffix}`; if (nodeIds.has(id)) return;
60
90
  const sourceNode = extra && extra.sourceNode;
61
91
  const endLine = sourceNode?.endPosition ? sourceNode.endPosition.row + 1 : 0;
62
92
  let complexity = null;
@@ -82,8 +112,15 @@ export async function buildInternalGraph(repoDir, opts = {}) {
82
112
  syms.push({ id, name, start: line, end: endLine >= line ? endLine : 0 });
83
113
  if (!nameToId.has(name)) nameToId.set(name, id);
84
114
  if (extra?.moduleDeclaration && !moduleNameToId.has(name)) moduleNameToId.set(name, id);
115
+ return id;
85
116
  };
86
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
+ };
87
124
  const addImportEdge = (tgt, meta = {}) => {
88
125
  if (!tgt || tgt === fileRel) return;
89
126
  links.push({
@@ -92,6 +129,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
92
129
  relation: meta.relation || "imports",
93
130
  confidence: "EXTRACTED",
94
131
  ...(typeof meta.typeOnly === "boolean" ? { typeOnly: meta.typeOnly } : {}),
132
+ ...(meta.compileOnly === true ? { compileOnly: true } : {}),
95
133
  ...(meta.line ? { line: meta.line } : {}),
96
134
  ...(meta.specifier ? { specifier: meta.specifier } : {}),
97
135
  });
@@ -112,7 +150,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
112
150
  // Post-hoc export flag (export {a}, export default X, CJS module.exports) — declarations are flagged at addSym time.
113
151
  const markExported = (name) => { const id = moduleNameToId.get(name); const n = id && nodeById.get(id); if (n) n.exported = true; };
114
152
 
115
- 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 }); }
116
154
  catch (e) { /* one bad file never sinks the whole build */ void e; }
117
155
 
118
156
  syms.sort((a, b) => a.start - b.start);
@@ -135,6 +173,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
135
173
  let dm = goDirSymbols.get(d); if (!dm) goDirSymbols.set(d, (dm = new Map()));
136
174
  for (const [n, id] of m) if (!dm.has(n)) dm.set(n, id);
137
175
  }
176
+ const { resolveNamespaceMember } = resolveJsBarrels({ jsExports, importedLocals, links });
138
177
  // Exact source ranges can overlap (a named function nested inside another function). Attribute a call
139
178
  // to the innermost matching symbol, not whichever outer declaration happened to be added first.
140
179
  const enclosing = (fileRel, line) => {
@@ -149,9 +188,24 @@ export async function buildInternalGraph(repoDir, opts = {}) {
149
188
  const local = symByFileName.get(fileRel); if (local && local.has(name)) return local.get(name);
150
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); }
151
190
  const imp = importedLocals.get(fileRel) && importedLocals.get(fileRel).get(name);
152
- 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
+ }
153
196
  return null;
154
197
  };
198
+ const javaTypeKinds = new Set(["class", "interface", "enum", "record", "annotation"]);
199
+ const resolveJavaType = (name, fileRel) => {
200
+ const imp = importedLocals.get(fileRel)?.get(name);
201
+ if (imp?.targetFile) {
202
+ const symbols = symByFileName.get(imp.targetFile);
203
+ const target = symbols?.get(imp.imported) || symbols?.get(name);
204
+ if (target && javaTypeKinds.has(nodeById.get(target)?.symbol_kind)) return target;
205
+ }
206
+ const target = symByFileName.get(fileRel)?.get(name);
207
+ return target && javaTypeKinds.has(nodeById.get(target)?.symbol_kind) ? target : null;
208
+ };
155
209
  for (const abs of files) {
156
210
  const fileRel = rel(abs); const grammar = EXT_LANG[extname(abs)]; if (!grammar) continue;
157
211
  const lang = LANGS[FAMILY[grammar]]; if (!lang || lang.isWeb || !langs[grammar]) continue;
@@ -175,14 +229,30 @@ export async function buildInternalGraph(repoDir, opts = {}) {
175
229
  const target = dm && dm.get(fld.text);
176
230
  if (target && target !== caller.id) links.push({ source: caller.id, target, relation: "calls", confidence: "INFERRED" });
177
231
  }
178
- for (const heritageSrc of lang.heritage || []) for (const cap of caps(grammar, heritageSrc, tree.rootNode)) {
179
- const cls = enclosing(fileRel, cap.node.startPosition.row + 1); if (!cls) continue;
180
- const target = resolveCall(cap.node.text, fileRel);
181
- if (target && target !== cls.id) links.push({ source: cls.id, target, relation: "inherits", confidence: "INFERRED" });
232
+ for (const heritageSpec of lang.heritage || []) {
233
+ const query = typeof heritageSpec === "string" ? heritageSpec : heritageSpec.query;
234
+ const relation = typeof heritageSpec === "string" ? "inherits" : (heritageSpec.relation || "inherits");
235
+ for (const cap of caps(grammar, query, tree.rootNode)) {
236
+ const cls = enclosing(fileRel, cap.node.startPosition.row + 1); if (!cls) continue;
237
+ const target = FAMILY[grammar] === "java" ? resolveJavaType(cap.node.text, fileRel) : resolveCall(cap.node.text, fileRel);
238
+ if (target && target !== cls.id) links.push({ source: cls.id, target, relation, confidence: "INFERRED" });
239
+ }
182
240
  }
183
241
  // JSX is a real symbol use even though it is not a call_expression. Resolve imported components to their
184
242
  // declaration so component fan-in and unused-export checks do not claim `<SettingsView />` is unreferenced.
185
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
+ }
186
256
  for (const cap of caps(grammar, `[
187
257
  (jsx_opening_element name: (_) @jsx)
188
258
  (jsx_self_closing_element name: (_) @jsx)
@@ -193,9 +263,14 @@ export async function buildInternalGraph(repoDir, opts = {}) {
193
263
  if (parts.length === 1 && !/^[A-Z_$]/.test(localName)) continue; // undotted lowercase tags are platform/intrinsic elements
194
264
  const imp = importedLocals.get(fileRel)?.get(localName);
195
265
  if (!imp || !imp.targetFile || imp.typeOnly) continue;
196
- 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);
197
273
  if (!targetSymbols) continue;
198
- const importedName = imp.imported === "*" && parts.length > 1 ? parts[parts.length - 1] : imp.imported;
199
274
  const target = targetSymbols.get(importedName) || targetSymbols.get(localName);
200
275
  if (!target) continue;
201
276
  const owner = enclosing(fileRel, cap.node.startPosition.row + 1);
@@ -222,6 +297,9 @@ export async function buildInternalGraph(repoDir, opts = {}) {
222
297
  const caller = enclosing(fileRel, sel.startPosition.row + 1); emitRef(caller ? caller.id : fileRel, target);
223
298
  }
224
299
  }
300
+ if (FAMILY[grammar] === "java") {
301
+ addJavaReferences({ grammar, tree, fileRel, caps, resolveJavaType, enclosing, links });
302
+ }
225
303
  tree.delete();
226
304
  }
227
305
 
@@ -238,17 +316,34 @@ export async function buildInternalGraph(repoDir, opts = {}) {
238
316
 
239
317
  // community = folder bucket (top 2 path parts) — deterministic, mirrors the folder-based module grouping the
240
318
  // app already uses (graph-builder-analysis.js). Populates Modules/community cards without a heavy clustering pass.
241
- const folderOf = (f) => { const d = String(f || "").split("/").filter(Boolean).slice(0, -1); return d.length ? d.slice(0, 2).join("/") : "(root)"; };
242
- const commOf = new Map(); let commSeq = 0;
243
- for (const n of nodes) { const fo = folderOf(n.source_file); if (!commOf.has(fo)) commOf.set(fo, commSeq++); n.community = commOf.get(fo); }
319
+ assignDeterministicCommunities(nodes);
244
320
 
245
321
  // extImportsV: bump when the externalImports schema/coverage changes (v2 = go/python ecosystems) —
246
322
  // deps-engine rebuilds in memory when a saved graph is older than this.
247
- return { nodes, links, externalImports, extImportsV: 2, edgeTypesV: 1, complexityV: 1, repoBoundaryV: 1 };
323
+ // edgeTypesV 2 adds language-neutral compile-only edges (currently Rust mod/use/re-export) on top
324
+ // of v1's TypeScript typeOnly classification.
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
+ };
248
342
  }
249
343
 
250
344
  // Build + write graph.json to outPath (creating the dir). Returns { ok, nodes, links, graphJson }.
251
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");
252
347
  const graph = await buildInternalGraph(repoDir, opts);
253
348
  mkdirSync(dirname(outPath), { recursive: true });
254
349
  writeFileSync(outPath, JSON.stringify(graph), "utf8");
@@ -0,0 +1,33 @@
1
+ // Resolve Java type usages only when they land on declarations that exist in the graph. Synthetic nodes for
2
+ // String/List/annotations inflate metrics without adding navigation value, so unresolved/external types vanish.
3
+ export function addJavaReferences({ grammar, tree, fileRel, caps, resolveJavaType, enclosing, links }) {
4
+ const refSeen = new Set();
5
+ const isHeritage = (node) => {
6
+ let current = node?.parent;
7
+ for (let hops = 0; current && hops < 8; hops++, current = current.parent) {
8
+ if (["superclass", "super_interfaces", "extends_interfaces"].includes(current.type)) return true;
9
+ if (["class_declaration", "interface_declaration", "enum_declaration", "record_declaration"].includes(current.type)) return false;
10
+ }
11
+ return false;
12
+ };
13
+ const emitTypeRef = (name, node) => {
14
+ if (!name || isHeritage(node)) return;
15
+ const target = resolveJavaType(name, fileRel);
16
+ if (!target) return;
17
+ const owner = enclosing(fileRel, node.startPosition.row + 1);
18
+ const source = owner?.id || fileRel;
19
+ if (source === target) return;
20
+ const key = `${source}>${target}`;
21
+ if (refSeen.has(key)) return;
22
+ refSeen.add(key);
23
+ links.push({ source, target, relation: "references", confidence: "INFERRED", usage: "type", line: node.startPosition.row + 1 });
24
+ };
25
+ for (const cap of caps(grammar, `(type_identifier) @type`, tree.rootNode)) {
26
+ // A qualified type is handled once by its outer scoped_type_identifier. Inner package segments could
27
+ // otherwise bind to unrelated same-named project types.
28
+ if (cap.node.parent?.type !== "scoped_type_identifier") emitTypeRef(cap.node.text, cap.node);
29
+ }
30
+ for (const cap of caps(grammar, `(scoped_type_identifier) @type`, tree.rootNode)) {
31
+ if (cap.node.parent?.type !== "scoped_type_identifier") emitTypeRef(cap.node.text.split(".").pop(), cap.node);
32
+ }
33
+ }
@@ -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 };
@@ -1,5 +1,6 @@
1
1
  // Per-repo resolution context shared by the language modules: JS/TS path-aliases + relative imports, Python
2
- // dotted/relative modules, Go package dirs, Java class files, and web hrefs / the CSS selector index.
2
+ // dotted/relative modules, Go package dirs, Rust crate/module paths, Java class files, and web hrefs /
3
+ // the CSS selector index.
3
4
  // (Split from internal-builder.js — see its doc comment for the overall architecture.)
4
5
  import { readFileSync } from "node:fs";
5
6
  import { join, dirname } from "node:path";
@@ -47,6 +48,112 @@ export function buildResolvers(repoDir, fileSet) {
47
48
  return cands.find((f) => f === full || f.endsWith("/" + full)) || cands[0] || null;
48
49
  };
49
50
 
51
+ // Rust modules are files, but their paths are logical rather than simple source-relative imports:
52
+ // `foo.rs` owns children below `foo/`, while lib.rs/main.rs/mod.rs own siblings. Keep the resolver
53
+ // filesystem-only and crate-local: Cargo/external dependencies belong to dependency analysis, not to
54
+ // the internal module graph.
55
+ const cleanRustRel = (p) => String(p || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+/g, "/").replace(/\/$/, "").replace(/^\.$/, "");
56
+ const rustDir = (p) => { p = cleanRustRel(p); const i = p.lastIndexOf("/"); return i < 0 ? "" : p.slice(0, i); };
57
+ const rustBase = (p) => { p = cleanRustRel(p); const i = p.lastIndexOf("/"); return i < 0 ? p : p.slice(i + 1); };
58
+ const rustJoin = (...parts) => cleanRustRel(join(...parts.filter((x) => x != null && x !== "")));
59
+ const rustFiles = new Set([...fileSet].filter((fr) => fr.endsWith(".rs")));
60
+ const rustRoots = new Map();
61
+ for (const fr of rustFiles) {
62
+ const base = rustBase(fr);
63
+ if (base !== "lib.rs" && base !== "main.rs") continue;
64
+ const dir = rustDir(fr);
65
+ let root = rustRoots.get(dir);
66
+ if (!root) rustRoots.set(dir, (root = { base: dir, lib: null, main: null }));
67
+ root[base === "lib.rs" ? "lib" : "main"] = fr;
68
+ }
69
+ const rustRootList = [...rustRoots.values()].sort((a, b) => b.base.length - a.base.length);
70
+ const rustContext = (fromRel) => {
71
+ fromRel = cleanRustRel(fromRel);
72
+ const base = rustBase(fromRel);
73
+ const dir = rustDir(fromRel);
74
+ if (base === "lib.rs" || base === "main.rs") return { base: dir, rootFile: fromRel };
75
+
76
+ // Cargo treats direct files in these conventional folders as independent crate roots. This only
77
+ // affects paths originating in the root file itself; nested module ownership still comes from mod/use.
78
+ if (/^(?:bin|examples|tests|benches)$/.test(rustBase(dir))) return { base: dir, rootFile: fromRel };
79
+ for (const root of rustRootList) {
80
+ if (!root.base || fromRel.startsWith(root.base + "/")) return { base: root.base, rootFile: root.lib || root.main };
81
+ }
82
+ return { base: dir, rootFile: fromRel };
83
+ };
84
+ const rustModuleBase = (fromRel) => {
85
+ const ctx = rustContext(fromRel);
86
+ if (ctx.rootFile === cleanRustRel(fromRel)) return rustDir(fromRel);
87
+ const name = rustBase(fromRel);
88
+ if (name === "lib.rs" || name === "main.rs" || name === "mod.rs") return rustDir(fromRel);
89
+ return rustJoin(rustDir(fromRel), name.replace(/\.rs$/, ""));
90
+ };
91
+ const rustInlineBase = (fromRel, inlineModules = []) => {
92
+ let base = rustModuleBase(fromRel);
93
+ for (let i = 0; i < inlineModules.length; i++) {
94
+ const mod = inlineModules[i] || {};
95
+ if (mod.path) {
96
+ // Rust Reference: a path attribute on the first inline module is relative to the source file's
97
+ // directory; nested attributes are relative to their containing module's search directory.
98
+ const parent = i === 0 ? rustDir(fromRel) : base;
99
+ base = rustJoin(parent, mod.path);
100
+ } else base = rustJoin(base, mod.name);
101
+ }
102
+ return base;
103
+ };
104
+ const rustModuleFile = (moduleBase, ctx) => {
105
+ moduleBase = cleanRustRel(moduleBase);
106
+ if (moduleBase === cleanRustRel(ctx.base) && ctx.rootFile && rustFiles.has(ctx.rootFile)) return ctx.rootFile;
107
+ const flat = moduleBase + ".rs";
108
+ if (rustFiles.has(flat)) return flat;
109
+ const legacy = rustJoin(moduleBase, "mod.rs");
110
+ return rustFiles.has(legacy) ? legacy : null;
111
+ };
112
+ const resolveRustMod = (fromRel, name, { inlineModules = [], explicitPath = "" } = {}) => {
113
+ fromRel = cleanRustRel(fromRel);
114
+ if (explicitPath) {
115
+ const parent = inlineModules.length ? rustInlineBase(fromRel, inlineModules) : rustDir(fromRel);
116
+ const target = rustJoin(parent, explicitPath);
117
+ return rustFiles.has(target) ? target : null;
118
+ }
119
+ const targetBase = rustJoin(rustInlineBase(fromRel, inlineModules), String(name || "").replace(/^r#/, ""));
120
+ return rustModuleFile(targetBase, rustContext(fromRel));
121
+ };
122
+ const resolveRustPath = (fromRel, rawSegments, { inlineModules = [], unqualified = true } = {}) => {
123
+ fromRel = cleanRustRel(fromRel);
124
+ const segments = (Array.isArray(rawSegments) ? rawSegments : String(rawSegments || "").split("::"))
125
+ .map((s) => String(s).trim().replace(/^r#/, "")).filter(Boolean);
126
+ if (!segments.length) return null;
127
+ const ctx = rustContext(fromRel);
128
+ const current = rustInlineBase(fromRel, inlineModules);
129
+ let rest = [...segments];
130
+ const starts = [];
131
+ let anchored = false;
132
+ if (rest[0] === "crate") { anchored = true; rest.shift(); starts.push(ctx.base); }
133
+ else if (rest[0] === "self") { anchored = true; rest.shift(); starts.push(current); }
134
+ else if (rest[0] === "super") {
135
+ anchored = true;
136
+ let base = current;
137
+ while (rest[0] === "super") { rest.shift(); base = rustDir(base); }
138
+ if (ctx.base && base !== ctx.base && !base.startsWith(ctx.base + "/")) return null;
139
+ starts.push(base);
140
+ } else if (unqualified) {
141
+ // Rust 2018 resolves a bare use path from the crate root/external prelude. Prefer an internal root
142
+ // module, then allow a lexically-local module for qualified expressions in nested modules.
143
+ starts.push(ctx.base);
144
+ if (current !== ctx.base) starts.push(current);
145
+ } else return null;
146
+
147
+ for (const start of starts) {
148
+ const min = anchored ? 0 : 1; // never reinterpret an unresolved external `serde::X` as the crate root
149
+ for (let used = rest.length; used >= min; used--) {
150
+ const target = rustModuleFile(rustJoin(start, ...rest.slice(0, used)), ctx);
151
+ if (target) return { targetFile: target, consumed: segments.length - rest.length + used, remaining: rest.slice(used), anchored };
152
+ }
153
+ }
154
+ return null;
155
+ };
156
+
50
157
  // Path aliases (tsconfig compilerOptions.paths + vite/webpack alias) are scoped to their config folder.
51
158
  // Without nearest-config resolution, a monorepo's root `@/*` can hijack the same alias in web/.
52
159
  const aliasContexts = new Map();
@@ -188,5 +295,5 @@ export function buildResolvers(repoDir, fileSet) {
188
295
  return fileSet.has(cand) ? cand : null;
189
296
  };
190
297
 
191
- return { resolveJsImport, resolveAlias, resolvePyPath, pyBaseDir, pyTopDirs, resolveGoImport, dirFiles, resolveJavaImport, resolveHref, selectorIndex, htmlUsages, goModule, goRequires };
298
+ return { resolveJsImport, resolveAlias, resolvePyPath, pyBaseDir, pyTopDirs, resolveGoImport, dirFiles, resolveRustMod, resolveRustPath, resolveJavaImport, resolveHref, selectorIndex, htmlUsages, goModule, goRequires };
192
299
  }
@@ -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).
@@ -0,0 +1,4 @@
1
+ // Ownership/nesting edges are useful for navigation, but are not evidence of code usage.
2
+ const STRUCTURAL_RELATIONS = new Set(["contains", "method"]);
3
+
4
+ export const isStructuralRelation = (relation) => STRUCTURAL_RELATIONS.has(String(relation || ""));
@@ -0,0 +1,124 @@
1
+ // Global local registry for repository graphs. Absolute paths never leave the machine; hosted sync
2
+ // uses the opaque UUID. Identity is anchored in each canonical graph folder so simultaneous MCP
3
+ // processes cannot mint different IDs for the same repository.
4
+ import {randomUUID} from 'node:crypto'
5
+ import {existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync} from 'node:fs'
6
+ import {basename, join, relative, resolve} from 'node:path'
7
+ import process from 'node:process'
8
+ import {graphStorageKey} from './layout.js'
9
+ import {atomicWriteFileSync, withFileLockSync} from './file-lock.js'
10
+
11
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
12
+ const MARKER = '.repository-id'
13
+
14
+ const normalized = (value) => {
15
+ const path = String(value || '').replace(/\\/g, '/').replace(/\/+$/, '')
16
+ return process.platform === 'win32' ? path.toLowerCase() : path
17
+ }
18
+
19
+ const realOrResolved = (value) => {
20
+ try { return realpathSync.native(value) } catch { return resolve(value) }
21
+ }
22
+
23
+ const canonicalGraphDir = (repoPath, graphHome) => join(resolve(graphHome), graphStorageKey(repoPath))
24
+ const markerPath = (graphDir) => join(graphDir, MARKER)
25
+
26
+ export function registryPath(graphHome) { return join(graphHome, 'repositories.json') }
27
+
28
+ export function readRepositoryRegistry(graphHome) {
29
+ try {
30
+ const parsed = JSON.parse(readFileSync(registryPath(graphHome), 'utf8'))
31
+ return parsed?.repositoryRegistryV === 1 && Array.isArray(parsed.repositories)
32
+ ? parsed.repositories.filter((item) => item && typeof item === 'object')
33
+ : []
34
+ } catch { return [] }
35
+ }
36
+
37
+ function writeRegistry(graphHome, repositories) {
38
+ atomicWriteFileSync(registryPath(graphHome), JSON.stringify({repositoryRegistryV: 1, repositories}, null, 2), 'utf8')
39
+ }
40
+
41
+ function readMarker(graphDir) {
42
+ try {
43
+ const id = readFileSync(markerPath(graphDir), 'utf8').trim()
44
+ return UUID.test(id) ? id : null
45
+ } catch { return null }
46
+ }
47
+
48
+ function createOrReadMarker(graphDir, preferredId) {
49
+ mkdirSync(graphDir, {recursive: true})
50
+ const path = markerPath(graphDir)
51
+ const existing = readMarker(graphDir)
52
+ if (existing) return existing
53
+ if (existsSync(path)) throw new Error(`invalid repository identity marker: ${path}`)
54
+ const candidate = UUID.test(String(preferredId || '')) ? preferredId : randomUUID()
55
+ try { writeFileSync(path, `${candidate}\n`, {encoding: 'utf8', flag: 'wx'}) }
56
+ catch (error) {
57
+ if (error?.code !== 'EEXIST') throw error
58
+ const raced = readMarker(graphDir)
59
+ if (!raced) throw new Error(`invalid repository identity marker after concurrent registration: ${path}`)
60
+ return raced
61
+ }
62
+ return candidate
63
+ }
64
+
65
+ export function registerRepository({repoPath, graphDir, graphHome}) {
66
+ mkdirSync(graphHome, {recursive: true})
67
+ const real = realOrResolved(repoPath)
68
+ const canonical = canonicalGraphDir(real, graphHome)
69
+ if (normalized(realOrResolved(graphDir)) !== normalized(realOrResolved(canonical))) {
70
+ throw new Error(`repository graphs must be registered from the canonical graph directory: ${canonical}`)
71
+ }
72
+ return withFileLockSync(join(graphHome, '.repositories.lock'), () => {
73
+ const key = normalized(real)
74
+ const repositories = readRepositoryRegistry(graphHome)
75
+ let record = repositories.find((item) => normalized(item.repoPath) === key)
76
+ const now = new Date().toISOString()
77
+ const repositoryId = createOrReadMarker(canonical, record?.repositoryId)
78
+ if (!record) {
79
+ record = {repositoryId, repoPath: real, label: basename(real) || 'repo', graphDir: canonical, firstSeenAt: now, lastSeenAt: now}
80
+ repositories.push(record)
81
+ } else {
82
+ record.repositoryId = repositoryId
83
+ record.repoPath = real
84
+ record.graphDir = canonical
85
+ record.label = basename(real) || record.label || 'repo'
86
+ record.lastSeenAt = now
87
+ }
88
+ const deduped = repositories.filter((item, index, all) =>
89
+ normalized(item.repoPath) !== key || all.findIndex((candidate) => normalized(candidate.repoPath) === key) === index)
90
+ deduped.sort((a, b) => String(a.label).localeCompare(String(b.label)) || String(a.repositoryId).localeCompare(String(b.repositoryId)))
91
+ writeRegistry(graphHome, deduped)
92
+ return {...record}
93
+ })
94
+ }
95
+
96
+ export function repositoryRecord(repoPath, graphHome) {
97
+ const real = realOrResolved(repoPath)
98
+ const key = normalized(real)
99
+ return liveRepositoryRecords(graphHome).find((item) => normalized(item.repoPath) === key) || null
100
+ }
101
+
102
+ function validLiveRecord(item, graphHome) {
103
+ if (!UUID.test(String(item?.repositoryId || '')) || typeof item?.repoPath !== 'string' || typeof item?.graphDir !== 'string') return null
104
+ let repoReal
105
+ let graphHomeReal
106
+ let graphReal
107
+ try {
108
+ repoReal = realpathSync.native(item.repoPath)
109
+ graphHomeReal = realpathSync.native(graphHome)
110
+ graphReal = realpathSync.native(item.graphDir)
111
+ if (!statSync(repoReal).isDirectory() || !existsSync(join(repoReal, '.git'))) return null
112
+ if (!statSync(graphReal).isDirectory() || !statSync(join(graphReal, 'graph.json')).isFile()) return null
113
+ } catch { return null }
114
+ if (normalized(graphReal) !== normalized(realOrResolved(canonicalGraphDir(repoReal, graphHomeReal)))) return null
115
+ const rel = relative(graphHomeReal, graphReal)
116
+ if (!rel || rel.startsWith('..') || resolve(graphHomeReal, rel) !== resolve(graphReal)) return null
117
+ if (readMarker(graphReal) !== item.repositoryId) return null
118
+ return {...item, repoPath: repoReal, graphDir: graphReal}
119
+ }
120
+
121
+ export function liveRepositoryRecords(graphHome) {
122
+ if (!existsSync(graphHome)) return []
123
+ return readRepositoryRegistry(graphHome).map((item) => validLiveRecord(item, graphHome)).filter(Boolean)
124
+ }