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.
- package/README.md +92 -17
- package/package.json +1 -1
- package/skill/SKILL.md +62 -4
- package/src/analysis/dead-check.js +87 -6
- package/src/analysis/dep-check-ecosystems.js +157 -0
- package/src/analysis/dep-check.js +97 -133
- package/src/analysis/dep-rules.js +91 -12
- package/src/analysis/duplicates.compute.js +12 -18
- package/src/analysis/endpoints-rust.js +124 -0
- package/src/analysis/endpoints.js +56 -6
- package/src/analysis/graph-analysis.aggregate.js +34 -25
- package/src/analysis/graph-analysis.edges.js +21 -0
- package/src/analysis/graph-analysis.js +1 -1
- package/src/analysis/graph-analysis.summaries.js +4 -1
- package/src/analysis/internal-audit.collect.js +162 -25
- package/src/analysis/internal-audit.reach.js +52 -11
- package/src/analysis/internal-audit.run.js +71 -18
- package/src/graph/builder/lang-java.js +177 -14
- package/src/graph/builder/lang-js.js +66 -23
- package/src/graph/builder/lang-rust.js +129 -6
- package/src/graph/community.js +17 -0
- package/src/graph/internal-builder.build.js +79 -17
- package/src/graph/internal-builder.java.js +33 -0
- package/src/graph/internal-builder.langs.js +43 -2
- package/src/graph/internal-builder.resolvers.js +197 -21
- package/src/graph/relations.js +4 -0
- package/src/mcp/catalog.mjs +4 -4
- package/src/mcp/graph-context.mjs +22 -94
- package/src/mcp/graph-diff.mjs +163 -0
- package/src/mcp/sync-payload.mjs +36 -6
- package/src/mcp/tools-actions.mjs +22 -7
- package/src/mcp/tools-graph-hubs.mjs +58 -0
- package/src/mcp/tools-graph.mjs +13 -22
- package/src/mcp/tools-health.mjs +54 -18
- package/src/mcp/tools-impact.mjs +61 -45
- package/src/mcp-server.mjs +3 -0
- 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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
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 });
|
|
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
|
|
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 =
|
|
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
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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())
|
|
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 };
|