weavatrix 0.1.2 → 0.1.4

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 (37) hide show
  1. package/README.md +92 -17
  2. package/package.json +1 -1
  3. package/skill/SKILL.md +62 -4
  4. package/src/analysis/dead-check.js +87 -6
  5. package/src/analysis/dep-check-ecosystems.js +157 -0
  6. package/src/analysis/dep-check.js +97 -133
  7. package/src/analysis/dep-rules.js +91 -12
  8. package/src/analysis/duplicates.compute.js +12 -18
  9. package/src/analysis/endpoints-rust.js +124 -0
  10. package/src/analysis/endpoints.js +56 -6
  11. package/src/analysis/graph-analysis.aggregate.js +34 -25
  12. package/src/analysis/graph-analysis.edges.js +21 -0
  13. package/src/analysis/graph-analysis.js +1 -1
  14. package/src/analysis/graph-analysis.summaries.js +4 -1
  15. package/src/analysis/internal-audit.collect.js +162 -25
  16. package/src/analysis/internal-audit.reach.js +52 -11
  17. package/src/analysis/internal-audit.run.js +71 -18
  18. package/src/graph/builder/lang-java.js +177 -14
  19. package/src/graph/builder/lang-js.js +66 -23
  20. package/src/graph/builder/lang-rust.js +129 -6
  21. package/src/graph/community.js +17 -0
  22. package/src/graph/internal-builder.build.js +79 -17
  23. package/src/graph/internal-builder.java.js +33 -0
  24. package/src/graph/internal-builder.langs.js +43 -2
  25. package/src/graph/internal-builder.resolvers.js +197 -21
  26. package/src/graph/relations.js +4 -0
  27. package/src/mcp/catalog.mjs +4 -4
  28. package/src/mcp/graph-context.mjs +22 -94
  29. package/src/mcp/graph-diff.mjs +163 -0
  30. package/src/mcp/sync-payload.mjs +36 -6
  31. package/src/mcp/tools-actions.mjs +22 -7
  32. package/src/mcp/tools-graph-hubs.mjs +58 -0
  33. package/src/mcp/tools-graph.mjs +13 -22
  34. package/src/mcp/tools-health.mjs +54 -18
  35. package/src/mcp/tools-impact.mjs +61 -45
  36. package/src/mcp-server.mjs +3 -0
  37. package/src/security/advisory-store.js +51 -7
@@ -1,10 +1,8 @@
1
1
  // Rust extractor. Symbols: fn (incl. impl methods) + struct/enum/trait/type/mod + const/static
2
2
  // (+ macro_rules!/union via optional queries). Calls: `foo()` / `x.foo()` / `path::foo()` (name only).
3
- // Heritage: `impl Trait for Type` resolves only when the impl block sits inside a tracked symbol
4
- // (e.g. a mod) a top-level impl has no enclosing symbol to anchor the edge, so v1 usually skips it.
5
- // `use` paths map to files only through the crate's module tree, which needs Cargo layout resolution —
6
- // no import edges in v1; cross-file connectivity comes from the name-based calls pass plus the
7
- // same-folder scope (mod.rs convention).
3
+ // File dependencies follow Rust's module tree: outlined `mod`, `use` trees, public re-exports, and anchored
4
+ // `crate/self/super` paths resolve to repo-local .rs or */mod.rs files. External crates deliberately stay out
5
+ // of this adapter; Cargo dependency analysis owns them.
8
6
  const SYMS_CORE = `
9
7
  (function_item name: (identifier) @method)
10
8
  (struct_item name: (type_identifier) @class)
@@ -20,6 +18,66 @@ const SYMS_OPTIONAL = [
20
18
  `(union_item name: (type_identifier) @class)`,
21
19
  ];
22
20
 
21
+ const cleanSegment = (part) => String(part || "").trim().replace(/^r#/, "");
22
+ const pathParts = (node) => String(node?.text || "").split("::").map(cleanSegment).filter(Boolean);
23
+ const under = (node, type) => { for (let p = node?.parent; p; p = p.parent) if (p.type === type) return true; return false; };
24
+
25
+ function pathAttribute(modNode) {
26
+ for (let prev = modNode?.previousNamedSibling; prev?.type === "attribute_item"; prev = prev.previousNamedSibling) {
27
+ const hashedRaw = prev.text.match(/\bpath\s*=\s*r(#+)"([^"]+)"\1/);
28
+ if (hashedRaw) return hashedRaw[2] || "";
29
+ const plain = prev.text.match(/\bpath\s*=\s*(?:r)?"([^"]+)"/);
30
+ if (plain) return plain[1] || "";
31
+ }
32
+ return "";
33
+ }
34
+
35
+ function inlineAncestors(node) {
36
+ const result = [];
37
+ for (let p = node?.parent; p; p = p.parent) {
38
+ if (p.type !== "declaration_list" || p.parent?.type !== "mod_item") continue;
39
+ const mod = p.parent;
40
+ const name = mod.namedChildren.find((child) => child.type === "identifier")?.text;
41
+ if (name) result.unshift({ name: cleanSegment(name), path: pathAttribute(mod) });
42
+ }
43
+ return result;
44
+ }
45
+
46
+ // Expand a Rust use tree into its leaf paths while retaining aliases. Examples:
47
+ // `crate::api::{self, Client as C, types::*}` -> api, api::Client, api::types.
48
+ function useLeaves(node, prefix = []) {
49
+ if (!node) return [];
50
+ if (node.type === "use_declaration") {
51
+ const body = node.namedChildren.find((child) => child.type !== "visibility_modifier");
52
+ return useLeaves(body, prefix);
53
+ }
54
+ if (node.type === "use_list") return node.namedChildren.flatMap((child) => useLeaves(child, prefix));
55
+ if (node.type === "scoped_use_list") {
56
+ const list = node.namedChildren.find((child) => child.type === "use_list");
57
+ const head = node.namedChildren.find((child) => child !== list);
58
+ return useLeaves(list, [...prefix, ...pathParts(head)]);
59
+ }
60
+ if (node.type === "use_as_clause") {
61
+ const named = node.namedChildren;
62
+ const alias = cleanSegment(named[named.length - 1]?.text);
63
+ return useLeaves(named[0], prefix).map((leaf) => ({ ...leaf, local: alias }));
64
+ }
65
+ if (node.type === "use_wildcard") {
66
+ return [{ segments: [...prefix, ...pathParts(node.namedChildren[0])], wildcard: true, local: null }];
67
+ }
68
+ if (node.type === "self") {
69
+ const local = [...prefix].reverse().find((part) => !["crate", "self", "super"].includes(part)) || null;
70
+ return [{ segments: prefix.length ? [...prefix] : ["self"], moduleSelf: true, local }];
71
+ }
72
+ if (["identifier", "scoped_identifier", "scoped_type_identifier", "crate", "super"].includes(node.type)) {
73
+ const own = pathParts(node);
74
+ const segments = [...prefix, ...own];
75
+ const local = [...own].reverse().find((part) => !["crate", "self", "super"].includes(part)) || null;
76
+ return [{ segments, local }];
77
+ }
78
+ return [];
79
+ }
80
+
23
81
  export default {
24
82
  family: "rust",
25
83
  grammars: ["rust"],
@@ -35,11 +93,76 @@ export default {
35
93
  ],
36
94
 
37
95
  pass1(ctx) {
38
- const { grammar, tree, caps, addSym } = ctx;
96
+ const { grammar, tree, fileRel, caps, addSym, addImportEdge, imports, resolveRustMod, resolveRustPath } = ctx;
39
97
  for (const src of [SYMS_CORE, ...SYMS_OPTIONAL]) {
40
98
  for (const cap of caps(grammar, src, tree.rootNode)) {
41
99
  addSym(cap.node.text, cap.node.startPosition.row + 1, cap.name === "method", { sourceNode: cap.node.parent });
42
100
  }
43
101
  }
102
+
103
+ // File dependency edges are intentionally unique per source/target/relation. A qualified path can be
104
+ // nested in another qualified path and often repeats an existing `mod`/`use`; counting occurrences would
105
+ // inflate module coupling without adding structure.
106
+ const emitted = new Set();
107
+ const emit = (target, meta = {}) => {
108
+ if (!target || target === fileRel) return;
109
+ const relation = meta.relation || "imports";
110
+ const key = relation + ">" + target;
111
+ if (emitted.has(key)) return;
112
+ emitted.add(key);
113
+ // Rust `mod`/`use`/`pub use` relationships are resolved by the compiler. They describe real
114
+ // architectural coupling, but they are not runtime module loading and therefore must not create
115
+ // JavaScript-style initialization cycles or runtime boundary/blast-radius findings.
116
+ addImportEdge(target, { ...meta, relation, compileOnly: true });
117
+ };
118
+
119
+ // `mod foo;` loads foo.rs or foo/mod.rs. Inline `mod foo { ... }` stays in the current file, but any
120
+ // outlined modules declared inside it are captured separately with their inline ancestor path.
121
+ for (const cap of caps(grammar, `(mod_item) @mod`, tree.rootNode)) {
122
+ const mod = cap.node;
123
+ if (mod.namedChildren.some((child) => child.type === "declaration_list")) continue;
124
+ const name = mod.namedChildren.find((child) => child.type === "identifier")?.text;
125
+ if (!name) continue;
126
+ const target = resolveRustMod(fileRel, cleanSegment(name), {
127
+ inlineModules: inlineAncestors(mod),
128
+ explicitPath: pathAttribute(mod),
129
+ });
130
+ emit(target, { line: mod.startPosition.row + 1, specifier: `mod ${name}` });
131
+ }
132
+
133
+ // Imports/re-exports may be nested trees and aliases. Besides the file edge, retain direct item aliases
134
+ // so the existing pass-2 call resolver can connect `use crate::worker::run; run()` across folders.
135
+ for (const cap of caps(grammar, `(use_declaration) @use`, tree.rootNode)) {
136
+ const use = cap.node;
137
+ const relation = use.namedChildren.some((child) => child.type === "visibility_modifier") ? "re_exports" : "imports";
138
+ const ancestors = inlineAncestors(use);
139
+ for (const leaf of useLeaves(use)) {
140
+ const resolved = resolveRustPath(fileRel, leaf.segments, { inlineModules: ancestors, unqualified: true });
141
+ if (!resolved) continue;
142
+ emit(resolved.targetFile, { relation, line: use.startPosition.row + 1, specifier: use.text.replace(/;\s*$/, "") });
143
+ if (!leaf.wildcard && leaf.local && leaf.local !== "_") {
144
+ const imported = resolved.remaining.length ? cleanSegment(resolved.remaining.at(-1)) : "*";
145
+ imports.set(cleanSegment(leaf.local), { imported, targetFile: resolved.targetFile, rustModule: imported === "*" });
146
+ }
147
+ }
148
+ }
149
+
150
+ // Fully-qualified paths need no `use` declaration but still prove a dependency. Restrict this pass to
151
+ // explicit crate/self/super anchors so associated paths such as `Type::method` cannot be mistaken for a
152
+ // same-named module file. Inner prefixes are skipped; only the maximal path emits an edge.
153
+ for (const cap of caps(grammar, `[(scoped_identifier) (scoped_type_identifier)] @path`, tree.rootNode)) {
154
+ const node = cap.node;
155
+ if (under(node, "use_declaration")) continue;
156
+ if (["scoped_identifier", "scoped_type_identifier"].includes(node.parent?.type)) continue;
157
+ const segments = pathParts(node);
158
+ if (!["crate", "self", "super"].includes(segments[0])) continue;
159
+ const resolved = resolveRustPath(fileRel, segments, { inlineModules: inlineAncestors(node), unqualified: false });
160
+ if (!resolved) continue;
161
+ emit(resolved.targetFile, { line: node.startPosition.row + 1, specifier: node.text });
162
+ const finalName = cleanSegment(segments.at(-1));
163
+ if (resolved.remaining.length && finalName && !imports.has(finalName)) {
164
+ imports.set(finalName, { imported: finalName, targetFile: resolved.targetFile, rustQualified: true });
165
+ }
166
+ }
44
167
  },
45
168
  };
@@ -0,0 +1,17 @@
1
+ // Deterministic community territories. Most languages keep the historical top-two-folder bucket. Java
2
+ // Maven/Gradle trees need package depth: otherwise every class under application/src becomes one giant blob.
3
+ export function communityTerritoryOf(file) {
4
+ const normalized = String(file || "").replace(/\\/g, "/");
5
+ if (/\.java$/i.test(normalized)) {
6
+ const marker = /(?:^|\/)src\/(?:main|test)\/java\//.exec(normalized);
7
+ if (marker) {
8
+ const end = marker.index + marker[0].length;
9
+ const prefix = normalized.slice(0, end).replace(/\/$/, "");
10
+ const packageDirs = normalized.slice(end).split("/").filter(Boolean).slice(0, -1);
11
+ if (packageDirs.length) return `${prefix}/${packageDirs.slice(0, 5).join("/")}`;
12
+ return prefix;
13
+ }
14
+ }
15
+ const dirs = normalized.split("/").filter(Boolean).slice(0, -1);
16
+ return dirs.length ? dirs.slice(0, 2).join("/") : "(root)";
17
+ }
@@ -8,6 +8,8 @@ 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 { communityTerritoryOf } from "./community.js";
11
13
 
12
14
  // Parse a repo directory into a graph-builder-compatible { nodes, links } graph.
13
15
  export async function buildInternalGraph(repoDir, opts = {}) {
@@ -53,10 +55,11 @@ export async function buildInternalGraph(repoDir, opts = {}) {
53
55
  const parser = new Parser(); parser.setLanguage(langs[grammar]);
54
56
  let tree; try { tree = parser.parse(code); } catch { continue; }
55
57
 
56
- const syms = []; const nameToId = new Map();
58
+ const syms = []; const nameToId = new Map(); const moduleNameToId = new Map();
57
59
  const addSym = (name, line, callable, extra) => {
58
60
  if (!name || !/^[A-Za-z_$][\w$]*$/.test(name)) return;
59
- const id = `${fileRel}#${name}@${line}`; if (nodeIds.has(id)) return;
61
+ const suffix = /^:[A-Za-z0-9_-]+$/.test(extra?.idSuffix || "") ? extra.idSuffix : "";
62
+ const id = `${fileRel}#${name}@${line}${suffix}`; if (nodeIds.has(id)) return;
60
63
  const sourceNode = extra && extra.sourceNode;
61
64
  const endLine = sourceNode?.endPosition ? sourceNode.endPosition.row + 1 : 0;
62
65
  let complexity = null;
@@ -73,28 +76,46 @@ export async function buildInternalGraph(repoDir, opts = {}) {
73
76
  ...(endLine >= line ? { source_end: `L${endLine}` } : {}),
74
77
  ...(complexity ? { complexity } : {}),
75
78
  ...(extra && extra.exported ? { exported: true } : {}),
76
- ...(extra && extra.decorated ? { decorated: true } : {})
79
+ ...(extra && extra.decorated ? { decorated: true } : {}),
80
+ ...(extra && extra.symbolKind ? { symbol_kind: extra.symbolKind } : {}),
81
+ ...(extra && extra.memberOf ? { member_of: extra.memberOf } : {}),
82
+ ...(extra && extra.visibility ? { visibility: extra.visibility } : {})
77
83
  });
78
84
  links.push({ source: fileRel, target: id, relation: "contains", confidence: "EXTRACTED" });
79
- syms.push({ id, name, start: line, end: endLine >= line ? endLine : 0 }); if (!nameToId.has(name)) nameToId.set(name, id);
85
+ syms.push({ id, name, start: line, end: endLine >= line ? endLine : 0 });
86
+ if (!nameToId.has(name)) nameToId.set(name, id);
87
+ if (extra?.moduleDeclaration && !moduleNameToId.has(name)) moduleNameToId.set(name, id);
88
+ return id;
80
89
  };
81
90
  const imports = new Map(); importedLocals.set(fileRel, imports);
82
- const addImportEdge = (tgt) => { if (tgt && tgt !== fileRel) links.push({ source: fileRel, target: tgt, relation: "imports", confidence: "EXTRACTED" }); };
91
+ const addImportEdge = (tgt, meta = {}) => {
92
+ if (!tgt || tgt === fileRel) return;
93
+ links.push({
94
+ source: fileRel,
95
+ target: tgt,
96
+ relation: meta.relation || "imports",
97
+ confidence: "EXTRACTED",
98
+ ...(typeof meta.typeOnly === "boolean" ? { typeOnly: meta.typeOnly } : {}),
99
+ ...(meta.compileOnly === true ? { compileOnly: true } : {}),
100
+ ...(meta.line ? { line: meta.line } : {}),
101
+ ...(meta.specifier ? { specifier: meta.specifier } : {}),
102
+ });
103
+ };
83
104
  // rec: {spec, kind, line} bare-pkg import · {dynamic:true, spec?, target?} dynamic import marker
84
105
  // (target = internally-resolved dynamic import; suppresses false "unused file" in dep analysis) ·
85
106
  // {unresolved:true, spec} broken local import (relative or alias path that resolves to no file).
86
107
  const addExternalImport = (rec) => {
87
108
  if (!rec) return;
88
- if (rec.dynamic) { externalImports.push({ file: fileRel, spec: rec.spec || null, kind: rec.kind || "dynamic", dynamic: true, line: rec.line || 0, ...(rec.target ? { target: rec.target } : {}) }); return; }
89
- if (rec.unresolved) { externalImports.push({ file: fileRel, spec: rec.spec, kind: rec.kind || "esm", unresolved: true, line: rec.line || 0 }); return; }
109
+ if (rec.dynamic) { externalImports.push({ file: fileRel, spec: rec.spec || null, kind: rec.kind || "dynamic", dynamic: true, line: rec.line || 0, ...(rec.target ? { target: rec.target } : {}), ...(rec.typeOnly ? { typeOnly: true } : {}) }); return; }
110
+ if (rec.unresolved) { externalImports.push({ file: fileRel, spec: rec.spec, kind: rec.kind || "esm", unresolved: true, line: rec.line || 0, ...(rec.typeOnly ? { typeOnly: true } : {}) }); return; }
90
111
  // non-npm extractors (go/python) classify their own specs and pass pkg/builtin/ecosystem precomputed
91
- if (rec.pkg) { externalImports.push({ file: fileRel, spec: rec.spec, pkg: rec.pkg, builtin: !!rec.builtin, kind: rec.kind || "import", line: rec.line || 0, ...(rec.ecosystem ? { ecosystem: rec.ecosystem } : {}) }); return; }
112
+ if (rec.pkg) { externalImports.push({ file: fileRel, spec: rec.spec, pkg: rec.pkg, builtin: !!rec.builtin, kind: rec.kind || "import", line: rec.line || 0, ...(rec.ecosystem ? { ecosystem: rec.ecosystem } : {}), ...(rec.typeOnly ? { typeOnly: true } : {}) }); return; }
92
113
  const r = specToPkg(rec.spec);
93
114
  if (!r) return;
94
- externalImports.push({ file: fileRel, spec: rec.spec, pkg: r.pkg, builtin: !!r.builtin, kind: rec.kind || "esm", line: rec.line || 0 });
115
+ externalImports.push({ file: fileRel, spec: rec.spec, pkg: r.pkg, builtin: !!r.builtin, kind: rec.kind || "esm", line: rec.line || 0, ...(rec.typeOnly ? { typeOnly: true } : {}) });
95
116
  };
96
117
  // Post-hoc export flag (export {a}, export default X, CJS module.exports) — declarations are flagged at addSym time.
97
- const markExported = (name) => { const id = nameToId.get(name); const n = id && nodeById.get(id); if (n) n.exported = true; };
118
+ const markExported = (name) => { const id = moduleNameToId.get(name); const n = id && nodeById.get(id); if (n) n.exported = true; };
98
119
 
99
120
  try { lang.pass1({ grammar, tree, fileRel, code, caps, field, addSym, addNode, links, nodeIds, syms, nameToId, imports, addImportEdge, addExternalImport, markExported, fileSet, ...resolvers }); }
100
121
  catch (e) { /* one bad file never sinks the whole build */ void e; }
@@ -136,6 +157,17 @@ export async function buildInternalGraph(repoDir, opts = {}) {
136
157
  if (imp && imp.targetFile) { const tf = symByFileName.get(imp.targetFile); if (tf && tf.has(imp.imported)) return tf.get(imp.imported); }
137
158
  return null;
138
159
  };
160
+ const javaTypeKinds = new Set(["class", "interface", "enum", "record", "annotation"]);
161
+ const resolveJavaType = (name, fileRel) => {
162
+ const imp = importedLocals.get(fileRel)?.get(name);
163
+ if (imp?.targetFile) {
164
+ const symbols = symByFileName.get(imp.targetFile);
165
+ const target = symbols?.get(imp.imported) || symbols?.get(name);
166
+ if (target && javaTypeKinds.has(nodeById.get(target)?.symbol_kind)) return target;
167
+ }
168
+ const target = symByFileName.get(fileRel)?.get(name);
169
+ return target && javaTypeKinds.has(nodeById.get(target)?.symbol_kind) ? target : null;
170
+ };
139
171
  for (const abs of files) {
140
172
  const fileRel = rel(abs); const grammar = EXT_LANG[extname(abs)]; if (!grammar) continue;
141
173
  const lang = LANGS[FAMILY[grammar]]; if (!lang || lang.isWeb || !langs[grammar]) continue;
@@ -159,10 +191,36 @@ export async function buildInternalGraph(repoDir, opts = {}) {
159
191
  const target = dm && dm.get(fld.text);
160
192
  if (target && target !== caller.id) links.push({ source: caller.id, target, relation: "calls", confidence: "INFERRED" });
161
193
  }
162
- for (const heritageSrc of lang.heritage || []) for (const cap of caps(grammar, heritageSrc, tree.rootNode)) {
163
- const cls = enclosing(fileRel, cap.node.startPosition.row + 1); if (!cls) continue;
164
- const target = resolveCall(cap.node.text, fileRel);
165
- if (target && target !== cls.id) links.push({ source: cls.id, target, relation: "inherits", confidence: "INFERRED" });
194
+ for (const heritageSpec of lang.heritage || []) {
195
+ const query = typeof heritageSpec === "string" ? heritageSpec : heritageSpec.query;
196
+ const relation = typeof heritageSpec === "string" ? "inherits" : (heritageSpec.relation || "inherits");
197
+ for (const cap of caps(grammar, query, tree.rootNode)) {
198
+ const cls = enclosing(fileRel, cap.node.startPosition.row + 1); if (!cls) continue;
199
+ const target = FAMILY[grammar] === "java" ? resolveJavaType(cap.node.text, fileRel) : resolveCall(cap.node.text, fileRel);
200
+ if (target && target !== cls.id) links.push({ source: cls.id, target, relation, confidence: "INFERRED" });
201
+ }
202
+ }
203
+ // JSX is a real symbol use even though it is not a call_expression. Resolve imported components to their
204
+ // declaration so component fan-in and unused-export checks do not claim `<SettingsView />` is unreferenced.
205
+ if (FAMILY[grammar] === "js") {
206
+ for (const cap of caps(grammar, `[
207
+ (jsx_opening_element name: (_) @jsx)
208
+ (jsx_self_closing_element name: (_) @jsx)
209
+ ]`, tree.rootNode)) {
210
+ const jsxName = cap.node.text;
211
+ const parts = jsxName.split(".");
212
+ const localName = parts[0];
213
+ if (parts.length === 1 && !/^[A-Z_$]/.test(localName)) continue; // undotted lowercase tags are platform/intrinsic elements
214
+ const imp = importedLocals.get(fileRel)?.get(localName);
215
+ if (!imp || !imp.targetFile || imp.typeOnly) continue;
216
+ const targetSymbols = symByFileName.get(imp.targetFile);
217
+ if (!targetSymbols) continue;
218
+ const importedName = imp.imported === "*" && parts.length > 1 ? parts[parts.length - 1] : imp.imported;
219
+ const target = targetSymbols.get(importedName) || targetSymbols.get(localName);
220
+ if (!target) continue;
221
+ const owner = enclosing(fileRel, cap.node.startPosition.row + 1);
222
+ links.push({ source: owner?.id || fileRel, target, relation: "references", confidence: "INFERRED", usage: "jsx", line: cap.node.startPosition.row + 1 });
223
+ }
166
224
  }
167
225
  // Go value references: a top-level const/var/type/func used BY NAME (bare `X`, or cross-package `pkg.X`) in
168
226
  // another file/scope → a `references` edge, so used-but-never-called symbols (message-type consts, etc.) are
@@ -184,6 +242,9 @@ export async function buildInternalGraph(repoDir, opts = {}) {
184
242
  const caller = enclosing(fileRel, sel.startPosition.row + 1); emitRef(caller ? caller.id : fileRel, target);
185
243
  }
186
244
  }
245
+ if (FAMILY[grammar] === "java") {
246
+ addJavaReferences({ grammar, tree, fileRel, caps, resolveJavaType, enclosing, links });
247
+ }
187
248
  tree.delete();
188
249
  }
189
250
 
@@ -200,13 +261,14 @@ export async function buildInternalGraph(repoDir, opts = {}) {
200
261
 
201
262
  // community = folder bucket (top 2 path parts) — deterministic, mirrors the folder-based module grouping the
202
263
  // app already uses (graph-builder-analysis.js). Populates Modules/community cards without a heavy clustering pass.
203
- const folderOf = (f) => { const d = String(f || "").split("/").filter(Boolean).slice(0, -1); return d.length ? d.slice(0, 2).join("/") : "(root)"; };
204
264
  const commOf = new Map(); let commSeq = 0;
205
- for (const n of nodes) { const fo = folderOf(n.source_file); if (!commOf.has(fo)) commOf.set(fo, commSeq++); n.community = commOf.get(fo); }
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); }
206
266
 
207
267
  // extImportsV: bump when the externalImports schema/coverage changes (v2 = go/python ecosystems) —
208
268
  // deps-engine rebuilds in memory when a saved graph is older than this.
209
- return { nodes, links, externalImports, extImportsV: 2, complexityV: 1, repoBoundaryV: 1 };
269
+ // edgeTypesV 2 adds language-neutral compile-only edges (currently Rust mod/use/re-export) on top
270
+ // of v1's TypeScript typeOnly classification.
271
+ return { nodes, links, externalImports, extImportsV: 2, edgeTypesV: 2, complexityV: 1, repoBoundaryV: 1 };
210
272
  }
211
273
 
212
274
  // Build + write graph.json to outPath (creating the dir). Returns { ok, nodes, links, graphJson }.
@@ -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
+ }
@@ -5,8 +5,10 @@
5
5
  // build works — and Electron main runs Node, so this needs no external runtime.
6
6
  import { readdirSync, statSync, realpathSync } from "node:fs";
7
7
  import { join, extname, dirname } from "node:path";
8
+ import { execFileSync } from "node:child_process";
8
9
  import { createRequire } from "node:module";
9
10
  import { isPathInside } from "../repo-path.js";
11
+ import { childProcessEnv } from "../child-env.js";
10
12
  import LANG_JS from "./builder/lang-js.js";
11
13
  import LANG_PY from "./builder/lang-python.js";
12
14
  import LANG_GO from "./builder/lang-go.js";
@@ -68,7 +70,7 @@ async function ensureParser(opts = {}, wanted = null) {
68
70
  // Cycle-safe directory walk. statSync FOLLOWS symlinks/junctions, so a link pointing at an ancestor would
69
71
  // otherwise recurse forever (a/b/link/b/link/…). We dedupe by REAL path (a visited dir is never re-entered)
70
72
  // and cap depth as a backstop, so a symlink loop can't wedge the build.
71
- function walk(dir, acc = [], seen = new Set(), depth = 0, rootReal = null) {
73
+ function walkFallback(dir, acc = [], seen = new Set(), depth = 0, rootReal = null) {
72
74
  if (depth > 40) return acc;
73
75
  let real; try { real = realpathSync.native(dir); } catch { return acc; }
74
76
  if (rootReal == null) rootReal = real;
@@ -86,7 +88,7 @@ function walk(dir, acc = [], seen = new Set(), depth = 0, rootReal = null) {
86
88
  let entryReal; try { entryReal = realpathSync.native(full); } catch { continue; }
87
89
  if (!isPathInside(rootReal, entryReal)) continue;
88
90
  let st; try { st = statSync(full); } catch { continue; }
89
- if (st.isDirectory()) walk(full, acc, seen, depth + 1, rootReal);
91
+ if (st.isDirectory()) walkFallback(full, acc, seen, depth + 1, rootReal);
90
92
  // include by KNOWN extension, not by loaded grammar — grammars now load lazily AFTER the walk
91
93
  // (the parse passes skip files whose grammar failed to load, so the guarantee is unchanged)
92
94
  else { const e = extname(name); if (EXT_LANG[e] || isDataFile(name) || isDocFile(name)) acc.push(full); }
@@ -94,4 +96,43 @@ function walk(dir, acc = [], seen = new Set(), depth = 0, rootReal = null) {
94
96
  return acc;
95
97
  }
96
98
 
99
+ // Git already owns the repository's file-universe rules. Asking it for tracked files plus untracked,
100
+ // non-ignored files prevents generated outputs (Electron release/, custom cache dirs, ignored agent files,
101
+ // etc.) from contaminating graph/duplicate/audit results while still indexing new source before it is staged.
102
+ // A repository may be opened without Git (or Git may be unavailable), so failure is deliberately a signal to
103
+ // use the boundary-safe walker above rather than a build failure.
104
+ function gitFileUniverse(dir) {
105
+ let raw;
106
+ try {
107
+ raw = execFileSync("git", ["-C", dir, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
108
+ encoding: "utf8",
109
+ windowsHide: true,
110
+ stdio: ["ignore", "pipe", "ignore"],
111
+ timeout: 15_000,
112
+ maxBuffer: 64 * 1024 * 1024,
113
+ env: childProcessEnv(),
114
+ });
115
+ } catch { return null; }
116
+
117
+ let rootReal;
118
+ try { rootReal = realpathSync.native(dir); } catch { return null; }
119
+ const files = [];
120
+ for (const rel of raw.split("\0")) {
121
+ if (!rel) continue;
122
+ const full = join(dir, rel);
123
+ let real; try { real = realpathSync.native(full); } catch { continue; } // deleted index entry
124
+ if (!isPathInside(rootReal, real)) continue; // tracked symlink/junction escaping the repo
125
+ let st; try { st = statSync(full); } catch { continue; }
126
+ if (!st.isFile()) continue; // includes neither submodule dirs nor directory-like junctions
127
+ const name = rel.split(/[\\/]/).pop() || "";
128
+ const ext = extname(name);
129
+ if (EXT_LANG[ext] || isDataFile(name) || isDocFile(name)) files.push(full);
130
+ }
131
+ return files;
132
+ }
133
+
134
+ function walk(dir) {
135
+ return gitFileUniverse(dir) ?? walkFallback(dir);
136
+ }
137
+
97
138
  export { Parser, Query, GRAMMARS, LANGS, EXT_LANG, FAMILY, isDataFile, isDocFile, MAX_PARSE_BYTES, ensureParser, walk };