weavatrix 0.1.1 → 0.1.3
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 +117 -21
- package/SECURITY.md +31 -0
- package/package.json +16 -4
- package/skill/SKILL.md +69 -12
- package/src/analysis/coverage-reports.js +14 -11
- package/src/analysis/dead-check.js +87 -6
- package/src/analysis/dep-check.js +84 -8
- package/src/analysis/dep-rules.js +74 -8
- package/src/analysis/duplicates.compute.js +215 -215
- package/src/analysis/duplicates.js +15 -15
- package/src/analysis/duplicates.run.js +52 -51
- package/src/analysis/duplicates.tokenize.js +182 -182
- package/src/analysis/endpoints.js +50 -5
- package/src/analysis/graph-analysis.aggregate.js +16 -4
- package/src/analysis/internal-audit.collect.js +154 -31
- package/src/analysis/internal-audit.reach.js +52 -11
- package/src/analysis/internal-audit.run.js +72 -21
- package/src/build-graph.js +2 -1
- package/src/child-env.js +7 -0
- package/src/graph/builder/lang-js.js +66 -23
- package/src/graph/internal-builder.build.js +48 -10
- package/src/graph/internal-builder.langs.js +49 -3
- package/src/graph/internal-builder.resolvers.js +96 -20
- package/src/mcp/catalog.mjs +10 -8
- package/src/mcp/graph-context.mjs +107 -17
- package/src/mcp/sync-payload.mjs +110 -0
- package/src/mcp/tools-actions.mjs +42 -18
- package/src/mcp/tools-graph.mjs +46 -12
- package/src/mcp/tools-health.mjs +42 -18
- package/src/mcp/tools-impact.mjs +55 -43
- package/src/mcp-rg.mjs +2 -1
- package/src/mcp-server.mjs +25 -16
- package/src/mcp-source-tools.mjs +16 -5
- package/src/process.js +4 -3
- package/src/repo-path.js +53 -0
- package/src/security/advisory-store.js +51 -7
- package/src/security/installed.js +44 -31
- package/src/security/malware-heuristics.exclusions.js +134 -134
- package/src/security/malware-heuristics.js +15 -15
- package/src/security/malware-heuristics.roots.js +142 -142
- package/src/security/malware-heuristics.scan.js +85 -85
- package/src/security/malware-heuristics.sweep.js +122 -122
|
@@ -32,37 +32,56 @@ export default {
|
|
|
32
32
|
// on deeply-nested / minified / bundled JS (regression). An export_statement is always a NEAR ancestor of
|
|
33
33
|
// the declaration head (≤ ~5 hops), and hitting a statement_block means we're nested inside a function →
|
|
34
34
|
// not a module-level export → bail immediately. See [[graph-builder-internalization]].
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// is only ~4 hops to its export_statement (method → class_body → class_declaration → export_statement),
|
|
38
|
-
// so we do NOT bail at class_body — that preserves the exported flag for such methods.
|
|
35
|
+
// Early-out for nested scopes keeps this O(1); class members are intentionally never module exports even
|
|
36
|
+
// when their owner class is exported. The hop cap remains a backstop for malformed/deep syntax trees.
|
|
39
37
|
const isExportedDecl = (node) => {
|
|
40
38
|
let p = node.parent;
|
|
41
39
|
for (let hops = 0; p && hops < 6; hops++) {
|
|
42
40
|
if (p.type === "export_statement") return true;
|
|
43
|
-
if (p.type === "program" || p.type === "statement_block") return false;
|
|
41
|
+
if (p.type === "program" || p.type === "statement_block" || p.type === "class_body") return false;
|
|
42
|
+
p = p.parent;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
};
|
|
46
|
+
const isModuleDeclaration = (node) => {
|
|
47
|
+
let p = node.parent;
|
|
48
|
+
for (let hops = 0; p && hops < 8; hops++) {
|
|
49
|
+
if (p.type === "program") return true;
|
|
50
|
+
if (p.type === "statement_block" || p.type === "class_body") return false;
|
|
44
51
|
p = p.parent;
|
|
45
52
|
}
|
|
46
53
|
return false;
|
|
47
54
|
};
|
|
48
55
|
// a bare (package) specifier = non-relative AND not a path alias; alias-that-missed is a broken local, not a dep
|
|
49
|
-
const isBareSpec = (spec) => !!spec && !spec.startsWith(".") && resolveAlias(spec) == null;
|
|
56
|
+
const isBareSpec = (spec) => !!spec && !spec.startsWith(".") && resolveAlias(fileRel, spec) == null;
|
|
50
57
|
// broken local import (relative or alias path resolving to no file) — recorded for the "unresolved-import"
|
|
51
58
|
// finding. Asset imports (svg/css-modules-adjacent/fonts/…) and ?query-suffixed specs that resolve once
|
|
52
59
|
// the query is stripped (Vite ?raw/?url/?worker) are NOT code-graph concerns.
|
|
53
60
|
const ASSET_RE = /\.(svg|png|jpe?g|gif|webp|avif|ico|bmp|woff2?|ttf|eot|otf|mp[34]|webm|wasm|pdf|txt|md|html?)$/i;
|
|
54
|
-
const recordUnresolved = (rawSpec, kind, line) => {
|
|
61
|
+
const recordUnresolved = (rawSpec, kind, line, typeOnly = false) => {
|
|
55
62
|
const clean = String(rawSpec || "").split("?")[0];
|
|
56
63
|
if (!clean || ASSET_RE.test(clean)) return;
|
|
57
64
|
if (clean !== rawSpec && resolveJsImport(fileRel, clean)) return; // only the ?query broke resolution
|
|
58
|
-
addExternalImport({ spec: rawSpec, kind, line, unresolved: true });
|
|
65
|
+
addExternalImport({ spec: rawSpec, kind, line, unresolved: true, typeOnly });
|
|
59
66
|
};
|
|
60
67
|
|
|
61
68
|
// ---- symbols (export flag captured at declaration time) ----
|
|
69
|
+
const methodMetadata = (nameNode) => {
|
|
70
|
+
let method = nameNode.parent;
|
|
71
|
+
while (method && method.type !== "method_definition") method = method.parent;
|
|
72
|
+
let owner = method?.parent;
|
|
73
|
+
while (owner && !["class_declaration", "class"].includes(owner.type)) owner = owner.parent;
|
|
74
|
+
const ownerName = owner && field(owner, "name")?.text;
|
|
75
|
+
const visibility = /^\s*private\b/.test(method?.text || "") ? "private"
|
|
76
|
+
: /^\s*protected\b/.test(method?.text || "") ? "protected" : "public";
|
|
77
|
+
return { symbolKind: "method", ...(ownerName ? { memberOf: ownerName } : {}), visibility };
|
|
78
|
+
};
|
|
62
79
|
for (const cap of caps(grammar, FUNCS, tree.rootNode)) {
|
|
80
|
+
const isMethod = cap.name === "method";
|
|
63
81
|
addSym(cap.node.text, cap.node.startPosition.row + 1, cap.name !== "class", {
|
|
64
82
|
sourceNode: cap.node.parent,
|
|
65
|
-
...(isExportedDecl(cap.node) ? { exported: true } : {})
|
|
83
|
+
...(!isMethod && isExportedDecl(cap.node) ? { exported: true } : {}),
|
|
84
|
+
...(isMethod ? methodMetadata(cap.node) : { symbolKind: cap.name === "class" ? "class" : "function", moduleDeclaration: isModuleDeclaration(cap.node) })
|
|
66
85
|
});
|
|
67
86
|
}
|
|
68
87
|
for (const cap of caps(grammar, TOPVARS, tree.rootNode)) {
|
|
@@ -70,26 +89,47 @@ export default {
|
|
|
70
89
|
const val = field(cap.node, "value");
|
|
71
90
|
addSym(nameNode.text, nameNode.startPosition.row + 1, !!(val && CALLABLE.test(val.type)), {
|
|
72
91
|
sourceNode: val || cap.node,
|
|
73
|
-
...(isExportedDecl(cap.node) ? { exported: true } : {})
|
|
92
|
+
...(isExportedDecl(cap.node) ? { exported: true } : {}),
|
|
93
|
+
symbolKind: "variable",
|
|
94
|
+
moduleDeclaration: true
|
|
74
95
|
});
|
|
75
96
|
}
|
|
76
97
|
|
|
98
|
+
const importTypeOnly = (node) => {
|
|
99
|
+
if (/^\s*import\s+type\b/.test(node.text)) return true;
|
|
100
|
+
const clause = node.namedChildren.find((c) => c.type === "import_clause");
|
|
101
|
+
if (!clause) return false; // side-effect import executes the target module
|
|
102
|
+
const parts = clause.namedChildren;
|
|
103
|
+
if (parts.some((c) => c.type === "identifier" || c.type === "namespace_import")) return false;
|
|
104
|
+
const named = parts.find((c) => c.type === "named_imports");
|
|
105
|
+
const specs = named?.namedChildren.filter((c) => c.type === "import_specifier") || [];
|
|
106
|
+
return specs.length > 0 && specs.every((s) => /^\s*type\b/.test(s.text));
|
|
107
|
+
};
|
|
108
|
+
const reexportTypeOnly = (node) => {
|
|
109
|
+
if (/^\s*export\s+type\b/.test(node.text)) return true;
|
|
110
|
+
const clause = node.namedChildren.find((c) => c.type === "export_clause");
|
|
111
|
+
const specs = clause?.namedChildren.filter((c) => c.type === "export_specifier") || [];
|
|
112
|
+
return specs.length > 0 && specs.every((s) => /^\s*type\b/.test(s.text));
|
|
113
|
+
};
|
|
114
|
+
|
|
77
115
|
// ---- ESM imports ----
|
|
78
116
|
for (const cap of caps(grammar, `(import_statement) @imp`, tree.rootNode)) {
|
|
79
117
|
const node = cap.node; const srcNode = field(node, "source");
|
|
80
118
|
const rawSpec = srcNode ? srcNode.text.replace(/^['"`]|['"`]$/g, "") : "";
|
|
119
|
+
const line = node.startPosition.row + 1;
|
|
120
|
+
const typeOnly = importTypeOnly(node);
|
|
81
121
|
const tgt = resolveJsImport(fileRel, rawSpec);
|
|
82
122
|
if (!tgt) {
|
|
83
|
-
if (isBareSpec(rawSpec)) addExternalImport({ spec: rawSpec, kind: "esm", line
|
|
84
|
-
else if (rawSpec) recordUnresolved(rawSpec, "esm",
|
|
123
|
+
if (isBareSpec(rawSpec)) addExternalImport({ spec: rawSpec, kind: "esm", line, typeOnly });
|
|
124
|
+
else if (rawSpec) recordUnresolved(rawSpec, "esm", line, typeOnly);
|
|
85
125
|
continue;
|
|
86
126
|
}
|
|
87
|
-
addImportEdge(tgt);
|
|
127
|
+
addImportEdge(tgt, { typeOnly, line, specifier: rawSpec });
|
|
88
128
|
const clause = node.namedChildren.find((c) => c.type === "import_clause"); if (!clause) continue;
|
|
89
129
|
for (const c of clause.namedChildren) {
|
|
90
|
-
if (c.type === "identifier") imports.set(c.text, { imported: "default", targetFile: tgt });
|
|
91
|
-
else if (c.type === "namespace_import") { const id = c.namedChildren.find((x) => x.type === "identifier"); if (id) imports.set(id.text, { imported: "*", targetFile: tgt }); }
|
|
92
|
-
else if (c.type === "named_imports") for (const s of c.namedChildren) { if (s.type !== "import_specifier") continue; const nm = field(s, "name"), al = field(s, "alias"); if (nm) imports.set((al || nm).text, { imported: nm.text, targetFile: tgt }); }
|
|
130
|
+
if (c.type === "identifier") imports.set(c.text, { imported: "default", targetFile: tgt, typeOnly });
|
|
131
|
+
else if (c.type === "namespace_import") { const id = c.namedChildren.find((x) => x.type === "identifier"); if (id) imports.set(id.text, { imported: "*", targetFile: tgt, typeOnly }); }
|
|
132
|
+
else if (c.type === "named_imports") for (const s of c.namedChildren) { if (s.type !== "import_specifier") continue; const nm = field(s, "name"), al = field(s, "alias"); if (nm) imports.set((al || nm).text, { imported: nm.text, targetFile: tgt, typeOnly: typeOnly || /^\s*type\b/.test(s.text) }); }
|
|
93
133
|
}
|
|
94
134
|
}
|
|
95
135
|
|
|
@@ -98,7 +138,7 @@ export default {
|
|
|
98
138
|
if (cap.name !== "src") continue;
|
|
99
139
|
let dv = cap.node; while (dv && dv.type !== "variable_declarator") dv = dv.parent;
|
|
100
140
|
const tgt = resolveJsImport(fileRel, cap.node.text); if (!tgt) continue;
|
|
101
|
-
addImportEdge(tgt);
|
|
141
|
+
addImportEdge(tgt, { typeOnly: false, line: cap.node.startPosition.row + 1, specifier: cap.node.text });
|
|
102
142
|
const lhs = dv && field(dv, "name");
|
|
103
143
|
if (lhs && lhs.type === "identifier") imports.set(lhs.text, { imported: "*", targetFile: tgt });
|
|
104
144
|
else if (lhs && lhs.type === "object_pattern") for (const p of lhs.namedChildren) { const key = field(p, "key") || (p.type === "shorthand_property_identifier_pattern" ? p : null); const val = field(p, "value"); const local = (val && val.type === "identifier") ? val : key; if (key && local) imports.set(local.text, { imported: key.text, targetFile: tgt }); }
|
|
@@ -139,13 +179,16 @@ export default {
|
|
|
139
179
|
}
|
|
140
180
|
|
|
141
181
|
// ---- re-exports (barrel/index files): edge so the real target isn't falsely DEAD; bare source → external ----
|
|
142
|
-
for (const cap of caps(grammar, `(export_statement
|
|
143
|
-
|
|
144
|
-
|
|
182
|
+
for (const cap of caps(grammar, `(export_statement) @exp`, tree.rootNode)) {
|
|
183
|
+
const node = cap.node; const srcNode = field(node, "source");
|
|
184
|
+
if (!srcNode) continue;
|
|
185
|
+
const rawSpec = srcNode.text.replace(/^['"`]|['"`]$/g, "");
|
|
186
|
+
const line = node.startPosition.row + 1;
|
|
187
|
+
const typeOnly = reexportTypeOnly(node);
|
|
145
188
|
const tgt = resolveJsImport(fileRel, rawSpec);
|
|
146
|
-
if (tgt) addImportEdge(tgt);
|
|
147
|
-
else if (isBareSpec(rawSpec)) addExternalImport({ spec: rawSpec, kind: "reexport", line
|
|
148
|
-
else if (rawSpec) recordUnresolved(rawSpec, "reexport",
|
|
189
|
+
if (tgt) addImportEdge(tgt, { relation: "re_exports", typeOnly, line, specifier: rawSpec });
|
|
190
|
+
else if (isBareSpec(rawSpec)) addExternalImport({ spec: rawSpec, kind: "reexport", line, typeOnly });
|
|
191
|
+
else if (rawSpec) recordUnresolved(rawSpec, "reexport", line, typeOnly);
|
|
149
192
|
}
|
|
150
193
|
|
|
151
194
|
// ---- export markers beyond declarations: `export { a, b }`, `export default X`, CJS module.exports ----
|
|
@@ -53,7 +53,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
53
53
|
const parser = new Parser(); parser.setLanguage(langs[grammar]);
|
|
54
54
|
let tree; try { tree = parser.parse(code); } catch { continue; }
|
|
55
55
|
|
|
56
|
-
const syms = []; const nameToId = new Map();
|
|
56
|
+
const syms = []; const nameToId = new Map(); const moduleNameToId = new Map();
|
|
57
57
|
const addSym = (name, line, callable, extra) => {
|
|
58
58
|
if (!name || !/^[A-Za-z_$][\w$]*$/.test(name)) return;
|
|
59
59
|
const id = `${fileRel}#${name}@${line}`; if (nodeIds.has(id)) return;
|
|
@@ -73,28 +73,44 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
73
73
|
...(endLine >= line ? { source_end: `L${endLine}` } : {}),
|
|
74
74
|
...(complexity ? { complexity } : {}),
|
|
75
75
|
...(extra && extra.exported ? { exported: true } : {}),
|
|
76
|
-
...(extra && extra.decorated ? { decorated: true } : {})
|
|
76
|
+
...(extra && extra.decorated ? { decorated: true } : {}),
|
|
77
|
+
...(extra && extra.symbolKind ? { symbol_kind: extra.symbolKind } : {}),
|
|
78
|
+
...(extra && extra.memberOf ? { member_of: extra.memberOf } : {}),
|
|
79
|
+
...(extra && extra.visibility ? { visibility: extra.visibility } : {})
|
|
77
80
|
});
|
|
78
81
|
links.push({ source: fileRel, target: id, relation: "contains", confidence: "EXTRACTED" });
|
|
79
|
-
syms.push({ id, name, start: line, end: endLine >= line ? endLine : 0 });
|
|
82
|
+
syms.push({ id, name, start: line, end: endLine >= line ? endLine : 0 });
|
|
83
|
+
if (!nameToId.has(name)) nameToId.set(name, id);
|
|
84
|
+
if (extra?.moduleDeclaration && !moduleNameToId.has(name)) moduleNameToId.set(name, id);
|
|
80
85
|
};
|
|
81
86
|
const imports = new Map(); importedLocals.set(fileRel, imports);
|
|
82
|
-
const addImportEdge = (tgt
|
|
87
|
+
const addImportEdge = (tgt, meta = {}) => {
|
|
88
|
+
if (!tgt || tgt === fileRel) return;
|
|
89
|
+
links.push({
|
|
90
|
+
source: fileRel,
|
|
91
|
+
target: tgt,
|
|
92
|
+
relation: meta.relation || "imports",
|
|
93
|
+
confidence: "EXTRACTED",
|
|
94
|
+
...(typeof meta.typeOnly === "boolean" ? { typeOnly: meta.typeOnly } : {}),
|
|
95
|
+
...(meta.line ? { line: meta.line } : {}),
|
|
96
|
+
...(meta.specifier ? { specifier: meta.specifier } : {}),
|
|
97
|
+
});
|
|
98
|
+
};
|
|
83
99
|
// rec: {spec, kind, line} bare-pkg import · {dynamic:true, spec?, target?} dynamic import marker
|
|
84
100
|
// (target = internally-resolved dynamic import; suppresses false "unused file" in dep analysis) ·
|
|
85
101
|
// {unresolved:true, spec} broken local import (relative or alias path that resolves to no file).
|
|
86
102
|
const addExternalImport = (rec) => {
|
|
87
103
|
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; }
|
|
104
|
+
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; }
|
|
105
|
+
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
106
|
// 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; }
|
|
107
|
+
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
108
|
const r = specToPkg(rec.spec);
|
|
93
109
|
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 });
|
|
110
|
+
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
111
|
};
|
|
96
112
|
// Post-hoc export flag (export {a}, export default X, CJS module.exports) — declarations are flagged at addSym time.
|
|
97
|
-
const markExported = (name) => { const id =
|
|
113
|
+
const markExported = (name) => { const id = moduleNameToId.get(name); const n = id && nodeById.get(id); if (n) n.exported = true; };
|
|
98
114
|
|
|
99
115
|
try { lang.pass1({ grammar, tree, fileRel, code, caps, field, addSym, addNode, links, nodeIds, syms, nameToId, imports, addImportEdge, addExternalImport, markExported, fileSet, ...resolvers }); }
|
|
100
116
|
catch (e) { /* one bad file never sinks the whole build */ void e; }
|
|
@@ -164,6 +180,28 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
164
180
|
const target = resolveCall(cap.node.text, fileRel);
|
|
165
181
|
if (target && target !== cls.id) links.push({ source: cls.id, target, relation: "inherits", confidence: "INFERRED" });
|
|
166
182
|
}
|
|
183
|
+
// JSX is a real symbol use even though it is not a call_expression. Resolve imported components to their
|
|
184
|
+
// declaration so component fan-in and unused-export checks do not claim `<SettingsView />` is unreferenced.
|
|
185
|
+
if (FAMILY[grammar] === "js") {
|
|
186
|
+
for (const cap of caps(grammar, `[
|
|
187
|
+
(jsx_opening_element name: (_) @jsx)
|
|
188
|
+
(jsx_self_closing_element name: (_) @jsx)
|
|
189
|
+
]`, tree.rootNode)) {
|
|
190
|
+
const jsxName = cap.node.text;
|
|
191
|
+
const parts = jsxName.split(".");
|
|
192
|
+
const localName = parts[0];
|
|
193
|
+
if (parts.length === 1 && !/^[A-Z_$]/.test(localName)) continue; // undotted lowercase tags are platform/intrinsic elements
|
|
194
|
+
const imp = importedLocals.get(fileRel)?.get(localName);
|
|
195
|
+
if (!imp || !imp.targetFile || imp.typeOnly) continue;
|
|
196
|
+
const targetSymbols = symByFileName.get(imp.targetFile);
|
|
197
|
+
if (!targetSymbols) continue;
|
|
198
|
+
const importedName = imp.imported === "*" && parts.length > 1 ? parts[parts.length - 1] : imp.imported;
|
|
199
|
+
const target = targetSymbols.get(importedName) || targetSymbols.get(localName);
|
|
200
|
+
if (!target) continue;
|
|
201
|
+
const owner = enclosing(fileRel, cap.node.startPosition.row + 1);
|
|
202
|
+
links.push({ source: owner?.id || fileRel, target, relation: "references", confidence: "INFERRED", usage: "jsx", line: cap.node.startPosition.row + 1 });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
167
205
|
// Go value references: a top-level const/var/type/func used BY NAME (bare `X`, or cross-package `pkg.X`) in
|
|
168
206
|
// another file/scope → a `references` edge, so used-but-never-called symbols (message-type consts, etc.) are
|
|
169
207
|
// not falsely DEAD. (Same-file usage is already covered by graph-builder-analysis localRefs.) Go-only for now.
|
|
@@ -206,7 +244,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
206
244
|
|
|
207
245
|
// extImportsV: bump when the externalImports schema/coverage changes (v2 = go/python ecosystems) —
|
|
208
246
|
// deps-engine rebuilds in memory when a saved graph is older than this.
|
|
209
|
-
return { nodes, links, externalImports, extImportsV: 2, complexityV: 1 };
|
|
247
|
+
return { nodes, links, externalImports, extImportsV: 2, edgeTypesV: 1, complexityV: 1, repoBoundaryV: 1 };
|
|
210
248
|
}
|
|
211
249
|
|
|
212
250
|
// Build + write graph.json to outPath (creating the dir). Returns { ok, nodes, links, graphJson }.
|
|
@@ -5,7 +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";
|
|
10
|
+
import { isPathInside } from "../repo-path.js";
|
|
11
|
+
import { childProcessEnv } from "../child-env.js";
|
|
9
12
|
import LANG_JS from "./builder/lang-js.js";
|
|
10
13
|
import LANG_PY from "./builder/lang-python.js";
|
|
11
14
|
import LANG_GO from "./builder/lang-go.js";
|
|
@@ -67,9 +70,11 @@ async function ensureParser(opts = {}, wanted = null) {
|
|
|
67
70
|
// Cycle-safe directory walk. statSync FOLLOWS symlinks/junctions, so a link pointing at an ancestor would
|
|
68
71
|
// otherwise recurse forever (a/b/link/b/link/…). We dedupe by REAL path (a visited dir is never re-entered)
|
|
69
72
|
// and cap depth as a backstop, so a symlink loop can't wedge the build.
|
|
70
|
-
function
|
|
73
|
+
function walkFallback(dir, acc = [], seen = new Set(), depth = 0, rootReal = null) {
|
|
71
74
|
if (depth > 40) return acc;
|
|
72
|
-
let real; try { real = realpathSync.native(dir); } catch {
|
|
75
|
+
let real; try { real = realpathSync.native(dir); } catch { return acc; }
|
|
76
|
+
if (rootReal == null) rootReal = real;
|
|
77
|
+
if (!isPathInside(rootReal, real)) return acc;
|
|
73
78
|
if (seen.has(real)) return acc;
|
|
74
79
|
seen.add(real);
|
|
75
80
|
let entries;
|
|
@@ -80,8 +85,10 @@ function walk(dir, acc = [], seen = new Set(), depth = 0) {
|
|
|
80
85
|
// never match AGENT_DOTFILE, so we still never recurse into them (.git/.github/.cursor stay out).
|
|
81
86
|
if (name.startsWith(".") && !AGENT_DOTFILE.test(name)) continue;
|
|
82
87
|
const full = join(dir, name);
|
|
88
|
+
let entryReal; try { entryReal = realpathSync.native(full); } catch { continue; }
|
|
89
|
+
if (!isPathInside(rootReal, entryReal)) continue;
|
|
83
90
|
let st; try { st = statSync(full); } catch { continue; }
|
|
84
|
-
if (st.isDirectory())
|
|
91
|
+
if (st.isDirectory()) walkFallback(full, acc, seen, depth + 1, rootReal);
|
|
85
92
|
// include by KNOWN extension, not by loaded grammar — grammars now load lazily AFTER the walk
|
|
86
93
|
// (the parse passes skip files whose grammar failed to load, so the guarantee is unchanged)
|
|
87
94
|
else { const e = extname(name); if (EXT_LANG[e] || isDataFile(name) || isDocFile(name)) acc.push(full); }
|
|
@@ -89,4 +96,43 @@ function walk(dir, acc = [], seen = new Set(), depth = 0) {
|
|
|
89
96
|
return acc;
|
|
90
97
|
}
|
|
91
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
|
+
|
|
92
138
|
export { Parser, Query, GRAMMARS, LANGS, EXT_LANG, FAMILY, isDataFile, isDocFile, MAX_PARSE_BYTES, ensureParser, walk };
|
|
@@ -4,14 +4,21 @@
|
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
5
|
import { join, dirname } from "node:path";
|
|
6
6
|
import { parseGoMod } from "../analysis/manifests.js";
|
|
7
|
+
import { createRepoBoundary } from "../repo-path.js";
|
|
7
8
|
|
|
8
9
|
export function buildResolvers(repoDir, fileSet) {
|
|
10
|
+
const boundary = createRepoBoundary(repoDir);
|
|
11
|
+
const readLocal = (relativePath) => {
|
|
12
|
+
const resolved = boundary.resolve(relativePath);
|
|
13
|
+
if (!resolved.ok) throw new Error("resolver input is outside the repository");
|
|
14
|
+
return readFileSync(resolved.path, "utf8");
|
|
15
|
+
};
|
|
9
16
|
// Go package = directory (resolved via go.mod module prefix); Java class = file (basename index).
|
|
10
17
|
// go.mod requires also feed goSpecToPkg so external Go imports map to their declared module.
|
|
11
18
|
let goModule = "";
|
|
12
19
|
let goRequires = [];
|
|
13
20
|
try {
|
|
14
|
-
const gomod = parseGoMod(
|
|
21
|
+
const gomod = parseGoMod(readLocal("go.mod"));
|
|
15
22
|
goModule = gomod.module;
|
|
16
23
|
goRequires = gomod.requires.map((r) => r.path);
|
|
17
24
|
} catch { /* no go.mod */ }
|
|
@@ -40,29 +47,98 @@ export function buildResolvers(repoDir, fileSet) {
|
|
|
40
47
|
return cands.find((f) => f === full || f.endsWith("/" + full)) || cands[0] || null;
|
|
41
48
|
};
|
|
42
49
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
const
|
|
46
|
-
const
|
|
50
|
+
// Path aliases (tsconfig compilerOptions.paths + vite/webpack alias) are scoped to their config folder.
|
|
51
|
+
// Without nearest-config resolution, a monorepo's root `@/*` can hijack the same alias in web/.
|
|
52
|
+
const aliasContexts = new Map();
|
|
53
|
+
const cleanRel = (p) => String(p || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+/g, "/").replace(/\/$/, "").replace(/^\.$/, "");
|
|
54
|
+
const contextFor = (dir) => {
|
|
55
|
+
dir = cleanRel(dir);
|
|
56
|
+
let ctx = aliasContexts.get(dir);
|
|
57
|
+
if (!ctx) aliasContexts.set(dir, (ctx = { dir, aliases: [], baseUrls: [] }));
|
|
58
|
+
return ctx;
|
|
59
|
+
};
|
|
60
|
+
const addAlias = (ctx, a, t) => {
|
|
47
61
|
a = String(a).replace(/\/\*$/, "").replace(/\/$/, "");
|
|
48
|
-
t = String(t).replace(/\/\*$/, "")
|
|
49
|
-
if (a && t && !
|
|
62
|
+
t = cleanRel(String(t)).replace(/\/\*$/, "");
|
|
63
|
+
if (a && t && !ctx.aliases.some((x) => x.alias === a)) ctx.aliases.push({ alias: a, target: t });
|
|
64
|
+
};
|
|
65
|
+
const parseJsonc = (raw) => {
|
|
66
|
+
// Regex comment stripping corrupts perfectly valid path strings such as `"@/*"` followed later by
|
|
67
|
+
// `"**/*.ts"`. Strip comments/trailing commas only while outside JSON strings.
|
|
68
|
+
raw = String(raw).replace(/^\uFEFF/, "");
|
|
69
|
+
let clean = ""; let inString = false; let escaped = false;
|
|
70
|
+
for (let i = 0; i < raw.length; i++) {
|
|
71
|
+
const ch = raw[i], next = raw[i + 1];
|
|
72
|
+
if (inString) {
|
|
73
|
+
clean += ch;
|
|
74
|
+
if (escaped) escaped = false;
|
|
75
|
+
else if (ch === "\\") escaped = true;
|
|
76
|
+
else if (ch === '"') inString = false;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (ch === '"') { inString = true; clean += ch; continue; }
|
|
80
|
+
if (ch === "/" && next === "/") { while (i < raw.length && raw[i] !== "\n") i++; clean += "\n"; continue; }
|
|
81
|
+
if (ch === "/" && next === "*") {
|
|
82
|
+
i += 2;
|
|
83
|
+
while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) { if (raw[i] === "\n") clean += "\n"; i++; }
|
|
84
|
+
i++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
clean += ch;
|
|
88
|
+
}
|
|
89
|
+
let withoutTrailing = ""; inString = false; escaped = false;
|
|
90
|
+
for (let i = 0; i < clean.length; i++) {
|
|
91
|
+
const ch = clean[i];
|
|
92
|
+
if (inString) {
|
|
93
|
+
withoutTrailing += ch;
|
|
94
|
+
if (escaped) escaped = false;
|
|
95
|
+
else if (ch === "\\") escaped = true;
|
|
96
|
+
else if (ch === '"') inString = false;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (ch === '"') { inString = true; withoutTrailing += ch; continue; }
|
|
100
|
+
if (ch === ",") {
|
|
101
|
+
let j = i + 1; while (/\s/.test(clean[j] || "")) j++;
|
|
102
|
+
if (clean[j] === "}" || clean[j] === "]") continue;
|
|
103
|
+
}
|
|
104
|
+
withoutTrailing += ch;
|
|
105
|
+
}
|
|
106
|
+
return JSON.parse(withoutTrailing);
|
|
50
107
|
};
|
|
51
|
-
const
|
|
52
|
-
|
|
108
|
+
const configRank = (fr) => /(^|\/)tsconfig\.json$/i.test(fr) ? 0 : /(^|\/)jsconfig\.json$/i.test(fr) ? 1 : 2;
|
|
109
|
+
const configFiles = [...fileSet]
|
|
110
|
+
.filter((fr) => /(^|\/)(?:tsconfig(?:\.[^/]+)?|jsconfig)\.json$/i.test(fr))
|
|
111
|
+
.sort((a, b) => dirname(a).localeCompare(dirname(b)) || configRank(a) - configRank(b) || a.localeCompare(b));
|
|
112
|
+
for (const cfg of configFiles) {
|
|
53
113
|
try {
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
if (co.baseUrl != null && !
|
|
58
|
-
for (const [k, v] of Object.entries(paths)) {
|
|
114
|
+
const tj = parseJsonc(readLocal(cfg)); const co = tj.compilerOptions || {}; const paths = co.paths || {};
|
|
115
|
+
const cfgDir = cleanRel(dirname(cfg)); const ctx = contextFor(cfgDir);
|
|
116
|
+
const baseRoot = cleanRel(join(cfgDir || ".", String(co.baseUrl || ".")));
|
|
117
|
+
if (co.baseUrl != null && !ctx.baseUrls.includes(baseRoot)) ctx.baseUrls.push(baseRoot);
|
|
118
|
+
for (const [k, v] of Object.entries(paths)) {
|
|
119
|
+
const t = Array.isArray(v) ? v[0] : v;
|
|
120
|
+
if (t) addAlias(ctx, k, join(baseRoot || ".", String(t)));
|
|
121
|
+
}
|
|
59
122
|
} catch { /* no/invalid tsconfig */ }
|
|
60
123
|
}
|
|
61
|
-
for (const vc of [
|
|
62
|
-
try {
|
|
124
|
+
for (const vc of [...fileSet].filter((fr) => /(^|\/)(?:vite\.config\.(?:ts|js|mjs)|webpack\.config\.js)$/.test(fr))) {
|
|
125
|
+
try {
|
|
126
|
+
const cfgDir = cleanRel(dirname(vc)); const ctx = contextFor(cfgDir); const src = readLocal(vc);
|
|
127
|
+
for (const m of src.matchAll(/['"`]([^'"`]+)['"`]\s*:\s*path\.resolve\([^,]+,\s*['"`]([^'"`]+)['"`]\s*\)/g)) addAlias(ctx, m[1], join(cfgDir || ".", m[2]));
|
|
128
|
+
} catch { /* no/invalid bundler config */ }
|
|
63
129
|
}
|
|
64
|
-
|
|
65
|
-
const
|
|
130
|
+
for (const ctx of aliasContexts.values()) ctx.aliases.sort((a, b) => b.alias.length - a.alias.length);
|
|
131
|
+
const contextsForFile = (fromRel) => [...aliasContexts.values()]
|
|
132
|
+
.filter((ctx) => !ctx.dir || fromRel === ctx.dir || fromRel.startsWith(ctx.dir + "/"))
|
|
133
|
+
.sort((a, b) => b.dir.length - a.dir.length);
|
|
134
|
+
const resolveAlias = (fromRel, spec) => {
|
|
135
|
+
if (spec === undefined) { spec = fromRel; fromRel = ""; }
|
|
136
|
+
for (const ctx of contextsForFile(fromRel)) for (const { alias, target } of ctx.aliases) {
|
|
137
|
+
if (spec === alias) return target;
|
|
138
|
+
if (spec.startsWith(alias + "/")) return target + spec.slice(alias.length);
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
};
|
|
66
142
|
|
|
67
143
|
const JS_EXTS = ["", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs", ".json", "/index.js", "/index.ts", "/index.jsx", "/index.tsx"];
|
|
68
144
|
const resolveJsImport = (fromRel, spec) => {
|
|
@@ -70,10 +146,10 @@ export function buildResolvers(repoDir, fileSet) {
|
|
|
70
146
|
let base;
|
|
71
147
|
if (spec.startsWith(".")) base = join(dirname(fromRel), spec).replace(/\\/g, "/").replace(/^\.\//, "");
|
|
72
148
|
else {
|
|
73
|
-
base = resolveAlias(spec);
|
|
149
|
+
base = resolveAlias(fromRel, spec);
|
|
74
150
|
if (base == null) {
|
|
75
151
|
// baseUrl-rooted internal import ("components/Button" with baseUrl:"src") — try before calling it an npm package
|
|
76
|
-
for (const b of
|
|
152
|
+
for (const ctx of contextsForFile(fromRel)) for (const b of ctx.baseUrls) {
|
|
77
153
|
const root = (b ? b + "/" : "") + spec;
|
|
78
154
|
for (const e of JS_EXTS) { const cand = (root + e).replace(/\/+/g, "/"); if (fileSet.has(cand)) return cand; }
|
|
79
155
|
}
|
package/src/mcp/catalog.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {readSource, searchCode} from '../mcp-source-tools.mjs'
|
|
|
11
11
|
|
|
12
12
|
const SELF_DIR = dirname(fileURLToPath(import.meta.url))
|
|
13
13
|
const resolveRg = createRgResolver(SELF_DIR)
|
|
14
|
+
export const DEFAULT_CAPS = Object.freeze(['graph', 'search', 'source', 'health', 'build', 'retarget'])
|
|
14
15
|
|
|
15
16
|
// The files whose mtime the stdio shell watches for hot reload — keep in sync with the imports in
|
|
16
17
|
// loadHotApi below (catalog.mjs itself is last: a change here re-derives the whole table).
|
|
@@ -22,7 +23,7 @@ function buildTools({tg, ti, th, ta}) {
|
|
|
22
23
|
{cap: 'graph', name: 'get_node', description: 'Get full details for a specific node by label or ID.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID to look up'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNode(g, a, ctx)},
|
|
23
24
|
{cap: 'graph', name: 'get_neighbors', description: 'Get all direct neighbors of a node with edge details (1 hop, call sites deduped). For transitive impact use get_dependents; for the impact of your current branch changes use change_impact.', inputSchema: {type: 'object', properties: {label: {type: 'string'}, relation_filter: {type: 'string', description: 'Optional: filter by relation type'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNeighbors(g, a, ctx)},
|
|
24
25
|
{cap: 'graph', name: 'query_graph', description: 'Explore the graph around a concept (BFS/DFS). Returns a focused, ranked subgraph — the closest, most-connected nodes near the matched seeds, with edges among them — not the full flood; states how many nodes were reached vs shown.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Natural language question or keyword search'}, mode: {type: 'string', enum: ['bfs', 'dfs'], default: 'bfs'}, depth: {type: 'integer', default: 3}, context_filter: {type: 'array', items: {type: 'string'}}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, required: ['question']}, run: (g, a) => tg.tQueryGraph(g, a)},
|
|
25
|
-
{cap: 'graph', name: 'god_nodes', description: '
|
|
26
|
+
{cap: 'graph', name: 'god_nodes', description: 'Rank connectivity hubs by unique call/import/reference neighbors, excluding structural containment. Repeated call sites are reported separately and do not inflate the rank.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', default: 10}}}, run: (g, a) => tg.tGodNodes(g, a)},
|
|
26
27
|
{cap: 'graph', name: 'shortest_path', description: 'Find the shortest path between two concepts in the knowledge graph.', inputSchema: {type: 'object', properties: {source: {type: 'string'}, target: {type: 'string'}, max_hops: {type: 'integer', default: 8}}, required: ['source', 'target']}, run: (g, a) => tg.tShortestPath(g, a)},
|
|
27
28
|
{cap: 'graph', name: 'get_dependents', description: 'Transitive blast-radius of ONE node: everything that calls/imports/inherits it, directly or through intermediaries (reverse edges, ranked by proximity then connectivity). For a symbol, also follows importers of its containing file. Use before refactoring; get_neighbors shows only 1 hop, change_impact covers your whole current diff at once.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID'}, depth: {type: 'integer', description: 'Max reverse hops, default 3', default: 3}, max_nodes: {type: 'integer', description: 'Max dependents to list, default 40', default: 40}}, required: ['label']}, run: (g, a) => ti.tGetDependents(g, a)},
|
|
28
29
|
{cap: 'graph', name: 'change_impact', description: 'Blast radius of a change: by default diffs branch commits + staged/unstaged/untracked work against a base ref (auto merge-base with origin/main|master), OR takes an explicit `files` list (e.g. a PR\'s changed files — assesses a NOT-checked-out PR). Maps changed files/symbols onto the graph and lists everything depending on them (reverse edges, ranked by proximity + connectivity) with test coverage attached — untested hotspots called out. Run before opening or reviewing a PR; drill down with get_dependents, coverage detail via coverage_map.', inputSchema: {type: 'object', properties: {base: {type: 'string', description: 'Base ref, e.g. origin/main or HEAD~1 (default: first existing of origin/HEAD, origin/main, origin/master, main, master)'}, files: {type: 'array', items: {type: 'string'}, description: 'Explicit repo-relative changed-file list — skips the local git diff; use for PRs that are not checked out'}, depth: {type: 'integer', description: 'Max reverse hops, default 2'}, max_nodes: {type: 'integer', description: 'Max impacted nodes to list, default 40'}}}, run: (g, a, ctx) => ti.tChangeImpact(g, a, ctx)},
|
|
@@ -37,16 +38,16 @@ function buildTools({tg, ti, th, ta}) {
|
|
|
37
38
|
{cap: 'source', name: 'list_endpoints', description: 'Inventory of HTTP endpoints defined in the repo (Express/Fastify/Nest/Flask/FastAPI/Go mux …): method, path, handler, and file:line, deduped across code and OpenAPI docs.', inputSchema: {type: 'object', properties: {max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
|
|
38
39
|
{cap: 'build', name: 'rebuild_graph', description: "Rebuild this repo's code graph from current source (weavatrix's own web-tree-sitter builder), reload it in-memory, and report the STRUCTURAL DELTA vs the previous state — new/removed module dependencies, cycle changes, newly orphaned symbols. The prior state is saved as graph.prev.json for graph_diff. Call after significant edits.", inputSchema: {type: 'object', properties: {mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], default: 'full'}, scope: {type: 'string', description: 'optional path prefix to limit the graph'}}}, run: (g, a, ctx) => ta.tRebuildGraph(g, a, ctx)},
|
|
39
40
|
{cap: 'graph', name: 'graph_diff', description: 'Structural diff of the last rebuild: previous graph state (graph.prev.json, saved by rebuild_graph) vs current — architecture drift (new module dependencies), broken or introduced import cycles, symbols that lost their last caller. The semantic complement to the textual git diff for validating a refactor.', inputSchema: {type: 'object', properties: {path: {type: 'string', description: 'Optional node-id/path prefix to scope the diff, e.g. src/query'}}}, run: (g, a, ctx) => ti.tGraphDiff(g, a, ctx)},
|
|
40
|
-
{cap: '
|
|
41
|
-
{cap: '
|
|
41
|
+
{cap: 'retarget', name: 'open_repo', description: 'OFFLINE RETARGET: switch this server to another local Git repository, building its graph when missing. This explicit tool call changes the active repository boundary; pass build:false to probe without building. Omit the retarget capability at registration to pin one repository.', inputSchema: {type: 'object', properties: {path: {type: 'string', description: 'Absolute path to a Git working tree'}, build: {type: 'boolean', description: 'Build the graph when missing (default true)', default: true}, mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], default: 'full'}}, required: ['path']}, run: (g, a, ctx) => ta.tOpenRepo(g, a, ctx)},
|
|
42
|
+
{cap: 'retarget', name: 'list_known_repos', description: 'OFFLINE RETARGET: list sibling repositories with existing graphs that can be selected through open_repo.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tListKnownRepos(g, a, ctx)},
|
|
42
43
|
{cap: 'online', name: 'refresh_advisories', description: "ONLINE, explicit opt-in: refresh the local OSV advisory store for this repo's lockfile-pinned packages (npm/PyPI/Go). Sends package names + versions to OSV.dev — never automatic, never source code. Afterwards run_audit reads the refreshed store fully offline (~/.weavatrix/advisories.json).", inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', description: 'Per-request timeout, default 20000'}}}, run: (g, a, ctx) => ta.tRefreshAdvisories(g, a, ctx)},
|
|
43
|
-
{cap: 'online', name: 'sync_graph', description: 'ONLINE, explicit opt-in, off until configured:
|
|
44
|
+
{cap: 'online', name: 'sync_graph', description: 'ONLINE, explicit opt-in, off until configured: send a versioned allowlist of graph metadata to an endpoint you configure (env WEAVATRIX_SYNC_URL, optional WEAVATRIX_SYNC_TOKEN bearer auth). Unknown graph fields are discarded and source file bodies are never read for sync. Rebuild graphs created before 0.1.3 once to add typed-edge metadata.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tSyncGraph(g, a, ctx)},
|
|
44
45
|
]
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
// Import the tool modules (cache-busted when version > 0), build the catalog, apply the caps filter.
|
|
48
|
-
// capsArg semantics: undefined/null =
|
|
49
|
-
//
|
|
49
|
+
// capsArg semantics: undefined/null = offline defaults (including explicit-call repo retargeting); a
|
|
50
|
+
// present string (even '') is an explicit selection, so "select nothing" really exposes nothing.
|
|
50
51
|
export async function loadHotApi(version, capsArg) {
|
|
51
52
|
const v = version ? `?v=${version}` : ''
|
|
52
53
|
const [tg, ti, th, ta] = await Promise.all([
|
|
@@ -56,8 +57,9 @@ export async function loadHotApi(version, capsArg) {
|
|
|
56
57
|
import(new URL(`./tools-actions.mjs${v}`, import.meta.url).href),
|
|
57
58
|
])
|
|
58
59
|
const all = buildTools({tg, ti, th, ta})
|
|
59
|
-
const
|
|
60
|
-
const
|
|
60
|
+
const selected = capsArg == null ? DEFAULT_CAPS : String(capsArg).split(',').map((s) => s.trim()).filter(Boolean)
|
|
61
|
+
const caps = new Set(selected)
|
|
62
|
+
const tools = all.filter((t) => caps.has(t.cap))
|
|
61
63
|
return {
|
|
62
64
|
tools,
|
|
63
65
|
byName: new Map(tools.map((t) => [t.name, t])),
|