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.
Files changed (42) hide show
  1. package/README.md +117 -21
  2. package/SECURITY.md +31 -0
  3. package/package.json +16 -4
  4. package/skill/SKILL.md +69 -12
  5. package/src/analysis/coverage-reports.js +14 -11
  6. package/src/analysis/dead-check.js +87 -6
  7. package/src/analysis/dep-check.js +84 -8
  8. package/src/analysis/dep-rules.js +74 -8
  9. package/src/analysis/duplicates.compute.js +215 -215
  10. package/src/analysis/duplicates.js +15 -15
  11. package/src/analysis/duplicates.run.js +52 -51
  12. package/src/analysis/duplicates.tokenize.js +182 -182
  13. package/src/analysis/endpoints.js +50 -5
  14. package/src/analysis/graph-analysis.aggregate.js +16 -4
  15. package/src/analysis/internal-audit.collect.js +154 -31
  16. package/src/analysis/internal-audit.reach.js +52 -11
  17. package/src/analysis/internal-audit.run.js +72 -21
  18. package/src/build-graph.js +2 -1
  19. package/src/child-env.js +7 -0
  20. package/src/graph/builder/lang-js.js +66 -23
  21. package/src/graph/internal-builder.build.js +48 -10
  22. package/src/graph/internal-builder.langs.js +49 -3
  23. package/src/graph/internal-builder.resolvers.js +96 -20
  24. package/src/mcp/catalog.mjs +10 -8
  25. package/src/mcp/graph-context.mjs +107 -17
  26. package/src/mcp/sync-payload.mjs +110 -0
  27. package/src/mcp/tools-actions.mjs +42 -18
  28. package/src/mcp/tools-graph.mjs +46 -12
  29. package/src/mcp/tools-health.mjs +42 -18
  30. package/src/mcp/tools-impact.mjs +55 -43
  31. package/src/mcp-rg.mjs +2 -1
  32. package/src/mcp-server.mjs +25 -16
  33. package/src/mcp-source-tools.mjs +16 -5
  34. package/src/process.js +4 -3
  35. package/src/repo-path.js +53 -0
  36. package/src/security/advisory-store.js +51 -7
  37. package/src/security/installed.js +44 -31
  38. package/src/security/malware-heuristics.exclusions.js +134 -134
  39. package/src/security/malware-heuristics.js +15 -15
  40. package/src/security/malware-heuristics.roots.js +142 -142
  41. package/src/security/malware-heuristics.scan.js +85 -85
  42. 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
- // early-out on statement_block (we're nested inside a function not a module-level export) keeps this
36
- // O(1) for nested symbols; program is the top; the hop cap is the backstop. A method of an EXPORTED class
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: node.startPosition.row + 1 });
84
- else if (rawSpec) recordUnresolved(rawSpec, "esm", node.startPosition.row + 1);
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 source: (string (string_fragment) @src))`, tree.rootNode)) {
143
- if (cap.name !== "src") continue;
144
- const rawSpec = cap.node.text;
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: cap.node.startPosition.row + 1 });
148
- else if (rawSpec) recordUnresolved(rawSpec, "reexport", cap.node.startPosition.row + 1);
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 }); if (!nameToId.has(name)) nameToId.set(name, id);
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) => { if (tgt && tgt !== fileRel) links.push({ source: fileRel, target: tgt, relation: "imports", confidence: "EXTRACTED" }); };
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 = nameToId.get(name); const n = id && nodeById.get(id); if (n) n.exported = true; };
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 walk(dir, acc = [], seen = new Set(), depth = 0) {
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 { real = dir; }
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()) walk(full, acc, seen, depth + 1);
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(readFileSync(join(repoDir, "go.mod"), "utf8"));
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
- // path aliases (tsconfig compilerOptions.paths + vite/webpack alias) without these, @components/@/etc
44
- // imports are missed and their targets look falsely DEAD.
45
- const aliasList = [];
46
- const addAlias = (a, t) => {
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(/\/\*$/, "").replace(/^\.\//, "").replace(/\/$/, "");
49
- if (a && t && !aliasList.some((x) => x.alias === a)) aliasList.push({ alias: a, target: 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 jsBaseUrls = []; // tsconfig/jsconfig baseUrl roots bare "components/Button" may be baseUrl-rooted, not an npm package
52
- for (const cfg of ["tsconfig.json", "tsconfig.app.json", "tsconfig.base.json", "jsconfig.json"]) {
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 raw = readFileSync(join(repoDir, cfg), "utf8").replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/,(\s*[}\]])/g, "$1");
55
- const tj = JSON.parse(raw); const co = tj.compilerOptions || {}; const paths = co.paths || {};
56
- const baseUrl = String(co.baseUrl || ".").replace(/^\.\/?/, "").replace(/\/$/, "");
57
- if (co.baseUrl != null && !jsBaseUrls.includes(baseUrl)) jsBaseUrls.push(baseUrl);
58
- for (const [k, v] of Object.entries(paths)) { const t = Array.isArray(v) ? v[0] : v; if (t) addAlias(k, (baseUrl && !String(t).startsWith("./") ? baseUrl + "/" : "") + t); }
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 ["vite.config.ts", "vite.config.js", "vite.config.mjs", "webpack.config.js"]) {
62
- try { const src = readFileSync(join(repoDir, vc), "utf8"); for (const m of src.matchAll(/['"`]([^'"`]+)['"`]\s*:\s*path\.resolve\([^,]+,\s*['"`]([^'"`]+)['"`]\s*\)/g)) addAlias(m[1], m[2]); } catch { /* no bundler config */ }
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
- aliasList.sort((a, b) => b.alias.length - a.alias.length);
65
- const resolveAlias = (spec) => { for (const { alias, target } of aliasList) { if (spec === alias) return target; if (spec.startsWith(alias + "/")) return target + spec.slice(alias.length); } return null; };
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 jsBaseUrls) {
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
  }
@@ -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: 'Return the most connected nodes - the core abstractions of the knowledge graph.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', default: 10}}}, run: (g, a) => tg.tGodNodes(g, a)},
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: 'build', name: 'open_repo', description: 'Retarget this server at another local repository: loads its graph from the central weavatrix-graphs layout next to the repo, building it first when missing (large repos can take minutes; pass build:false to probe without building). Afterwards every tool answers for the new repo.', inputSchema: {type: 'object', properties: {path: {type: 'string', description: 'Absolute path to the repository folder'}, 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)},
41
- {cap: 'graph', name: 'list_known_repos', description: 'List sibling repositories that already have a built graph in the central weavatrix-graphs folder next to the current repo — ready targets for open_repo.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tListKnownRepos(g, a, ctx)},
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: push the current graph.json to your weavatrix site or self-hosted endpoint (env WEAVATRIX_SYNC_URL, optional WEAVATRIX_SYNC_TOKEN bearer auth) for a hosted graph view. Payload is the graph only file paths, symbol names, edges never file contents.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tSyncGraph(g, a, ctx)},
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 = no per-repo config ALL tools; a present string (even '') is an
49
- // explicit selection expose exactly those groups, so "select nothing" really exposes nothing.
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 caps = capsArg == null ? null : new Set(String(capsArg).split(',').map((s) => s.trim()).filter(Boolean))
60
- const tools = caps ? all.filter((t) => caps.has(t.cap)) : all
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])),