weavatrix 0.2.3 → 0.2.5

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 (36) hide show
  1. package/README.md +91 -20
  2. package/SECURITY.md +18 -0
  3. package/package.json +3 -1
  4. package/skill/SKILL.md +49 -13
  5. package/src/analysis/dead-check.js +48 -2
  6. package/src/analysis/dead-code-review.js +22 -8
  7. package/src/analysis/duplicates.compute.js +11 -6
  8. package/src/analysis/git-ref-graph.js +10 -2
  9. package/src/analysis/hot-path-review.js +228 -0
  10. package/src/analysis/internal-audit.run.js +36 -0
  11. package/src/analysis/source-complexity.report.js +5 -0
  12. package/src/analysis/source-complexity.walk.js +64 -10
  13. package/src/build-graph.js +49 -10
  14. package/src/graph/build-worker.js +21 -1
  15. package/src/graph/builder/lang-java.js +1 -0
  16. package/src/graph/builder/lang-js.js +24 -0
  17. package/src/graph/builder/lang-python.js +161 -4
  18. package/src/graph/freshness-probe.js +3 -3
  19. package/src/graph/incremental-refresh.js +1 -1
  20. package/src/graph/internal-builder.barrels.js +33 -4
  21. package/src/graph/internal-builder.build.js +52 -8
  22. package/src/mcp/catalog.mjs +14 -11
  23. package/src/mcp/graph-context.mjs +143 -14
  24. package/src/mcp/sync-payload.mjs +2 -1
  25. package/src/mcp/tools-actions.mjs +59 -20
  26. package/src/mcp/tools-company.mjs +11 -3
  27. package/src/mcp/tools-graph.mjs +12 -6
  28. package/src/mcp/tools-health.mjs +112 -11
  29. package/src/mcp/tools-impact.mjs +24 -6
  30. package/src/mcp/tools-source.mjs +237 -0
  31. package/src/mcp-server.mjs +99 -11
  32. package/src/path-classification.js +2 -2
  33. package/src/precision/lsp-client.js +682 -0
  34. package/src/precision/lsp-overlay.js +872 -0
  35. package/src/precision/symbol-query.js +137 -0
  36. package/src/precision/typescript-lsp-provider.js +682 -0
@@ -11,11 +11,12 @@ export default {
11
11
  grammars: ["python"],
12
12
  exts: { ".py": "python", ".pyi": "python" },
13
13
  isWeb: false,
14
+ customCalls: true,
14
15
  calls: `(call function: (identifier) @callee)`,
15
16
  heritage: [`(class_definition superclasses: (argument_list (identifier) @super))`],
16
17
 
17
18
  pass1(ctx) {
18
- const { grammar, tree, fileRel, caps, field, addSym, addImportEdge, addExternalImport, imports, resolvePyPath, pyBaseDir, pyTopDirs } = ctx;
19
+ const { grammar, tree, fileRel, code, caps, field, addSym, addImportEdge, addExternalImport, markExported, imports, links, nameToId, resolvePyPath, pyBaseDir, pyTopDirs } = ctx;
19
20
  // the importing file's own dir is on sys.path when it runs as a script — try siblings for absolute
20
21
  // imports, EXCEPT stdlib names (a sibling logging.py must not shadow `import logging`; py3 absolute
21
22
  // imports always pick the stdlib, and a wrong internal edge distorts dead-code + reachability)
@@ -30,15 +31,37 @@ export default {
30
31
 
31
32
  // ---- symbols (decorated defs are framework-entered — @app.route/@app.event/@pytest.fixture — the
32
33
  // flag keeps dead-code checks from calling them dead; tree wraps them in decorated_definition) ----
34
+ const ownedMethods = [];
33
35
  for (const cap of caps(grammar, `(function_definition name: (identifier) @fn) (class_definition name: (identifier) @class)`, tree.rootNode)) {
34
36
  const decorated = cap.node.parent?.parent?.type === "decorated_definition";
35
- addSym(cap.node.text, cap.node.startPosition.row + 1, cap.name === "fn", {
37
+ let ownerName = null;
38
+ if (cap.name === "fn") {
39
+ let parent = cap.node.parent?.parent;
40
+ while (parent && !["function_definition", "class_definition", "module"].includes(parent.type)) parent = parent.parent;
41
+ if (parent?.type === "class_definition") ownerName = field(parent, "name")?.text || null;
42
+ }
43
+ const visibility = /^__[^_].*/.test(cap.node.text) ? "private"
44
+ : /^_[^_]/.test(cap.node.text) ? "protected" : "public";
45
+ const id = addSym(cap.node.text, cap.node.startPosition.row + 1, cap.name === "fn", {
36
46
  sourceNode: cap.node.parent,
37
- ...(decorated ? { decorated: true } : {})
47
+ selectionNode: cap.node,
48
+ ...(decorated ? { decorated: true } : {}),
49
+ symbolKind: cap.name === "class" ? "class" : ownerName ? "method" : "function",
50
+ ...(ownerName ? {memberOf: ownerName, visibility} : {moduleDeclaration: true}),
38
51
  });
52
+ if (ownerName) ownedMethods.push({ownerName, id});
53
+ }
54
+ for (const method of ownedMethods) {
55
+ const ownerId = nameToId.get(method.ownerName);
56
+ if (ownerId) links.push({source: ownerId, target: method.id, relation: "contains", confidence: "EXTRACTED"});
39
57
  }
40
58
  for (const cap of caps(grammar, `(module (expression_statement (assignment left: (identifier) @var)))`, tree.rootNode))
41
- addSym(cap.node.text, cap.node.startPosition.row + 1, false, { sourceNode: cap.node.parent });
59
+ addSym(cap.node.text, cap.node.startPosition.row + 1, false, { sourceNode: cap.node.parent, selectionNode: cap.node, symbolKind: "variable", moduleDeclaration: true });
60
+
61
+ // Static __all__ is authoritative for wildcard imports. The assignment itself remains indexed,
62
+ // while listed declarations are marked as the explicit public module surface.
63
+ const allMatch = String(code || "").match(/(?:^|\n)\s*__all__\s*=\s*[\[(]([\s\S]*?)[\])]/m);
64
+ if (allMatch) for (const match of allMatch[1].matchAll(/["']([A-Za-z_]\w*)["']/g)) markExported(match[1]);
42
65
 
43
66
  // ---- import a.b [as c] ----
44
67
  for (const cap of caps(grammar, `(import_statement) @imp`, tree.rootNode)) {
@@ -61,6 +84,15 @@ export default {
61
84
  else if (modNode.type === "dotted_name") modParts = modNode.text.split(".");
62
85
  }
63
86
  const baseDir = pyBaseDir(fileRel, dots);
87
+ const wildcard = node.namedChildren.some((child) => child.type === "wildcard_import");
88
+ if (wildcard) {
89
+ const modFile = resolvePyPath(baseDir, modParts) || (dots === 0 ? resolveAbs(modParts) : null);
90
+ if (modFile) {
91
+ addImportEdge(modFile);
92
+ imports.set(`*:${node.startPosition.row + 1}`, {imported: "*", targetFile: modFile, wildcard: true, line: node.startPosition.row + 1});
93
+ } else if (dots === 0 && modParts.length) recordExternal(modParts.join("."), node.startPosition.row + 1);
94
+ continue;
95
+ }
64
96
  const names = node.namedChildren.filter((c) => c !== modNode && (c.type === "dotted_name" || c.type === "aliased_import"));
65
97
  let externalDone = false; // one record per statement, not per imported name
66
98
  for (const nm of names) {
@@ -75,4 +107,129 @@ export default {
75
107
  }
76
108
  }
77
109
  },
110
+
111
+ pass2(ctx) {
112
+ const {grammar, tree, fileRel, caps, field, enclosing, links, nodeById, perFileSymbols, symByFileName, importedLocals, resolveCall} = ctx;
113
+ const imports = importedLocals.get(fileRel) || new Map();
114
+ const wildcardImports = [...imports.values()].filter((entry) => entry?.wildcard && entry.targetFile);
115
+ const emitted = new Set();
116
+ const addCall = (caller, target, line, evidence) => {
117
+ if (!caller || !target || target === caller.id) return;
118
+ const key = `${caller.id}\0${target}\0${line}`;
119
+ if (emitted.has(key) || links.some((link) => link.source === caller.id && link.target === target && link.relation === "calls" && link.line === line)) return;
120
+ emitted.add(key);
121
+ const provenance = ["receiver-type", "wildcard-import", "module-member"].includes(evidence) ? "RESOLVED" : "INFERRED";
122
+ links.push({source: caller.id, target, relation: "calls", confidence: "INFERRED", provenance, line, pythonResolution: evidence});
123
+ };
124
+ const moduleSymbol = (targetFile, name) => {
125
+ const id = symByFileName.get(targetFile)?.get(name);
126
+ if (!id) return null;
127
+ const node = nodeById.get(id);
128
+ return node?.member_of ? null : id;
129
+ };
130
+ const wildcardCandidates = (name) => {
131
+ const candidates = [];
132
+ for (const imp of wildcardImports) {
133
+ const symbols = perFileSymbols.get(imp.targetFile) || [];
134
+ const explicitAll = symbols.some((symbol) => symbol.name === "__all__");
135
+ const symbol = symbols.find((entry) => entry.name === name && !entry.memberOf);
136
+ if (!symbol || (explicitAll ? nodeById.get(symbol.id)?.exported !== true : name.startsWith("_"))) continue;
137
+ candidates.push(symbol.id);
138
+ }
139
+ return [...new Set(candidates)];
140
+ };
141
+ const classInfo = (name) => {
142
+ const local = symByFileName.get(fileRel)?.get(name);
143
+ if (local && nodeById.get(local)?.symbol_kind === "class") return {id: local, name, file: fileRel};
144
+ const imp = imports.get(name);
145
+ if (imp?.targetFile && !imp.wildcard) {
146
+ const targetFile = imp.originFile || imp.targetFile;
147
+ const targetName = imp.originName || imp.imported || name;
148
+ const id = symByFileName.get(targetFile)?.get(targetName);
149
+ if (id && nodeById.get(id)?.symbol_kind === "class") return {id, name: targetName, file: targetFile};
150
+ }
151
+ const wildcard = wildcardCandidates(name).filter((id) => nodeById.get(id)?.symbol_kind === "class");
152
+ if (wildcard.length === 1) {
153
+ const node = nodeById.get(wildcard[0]);
154
+ return {id: wildcard[0], name: node.label || name, file: node.source_file};
155
+ }
156
+ return null;
157
+ };
158
+ const methodOf = (klass, method) => (perFileSymbols.get(klass.file) || [])
159
+ .find((symbol) => symbol.memberOf === String(nodeById.get(klass.id)?.label || klass.name).replace(/\(.*$/, "") && symbol.name === method)?.id || null;
160
+ const bindings = new Map();
161
+ const shadows = new Set();
162
+ const scopeKey = (caller, name) => `${caller?.id || ""}\0${name}`;
163
+ const bind = (caller, name, klass) => { if (caller && name && klass) bindings.set(scopeKey(caller, name), klass); };
164
+ const typeName = (node) => {
165
+ const text = String(node?.text || "").replace(/^['"]|['"]$/g, "");
166
+ return (text.match(/[A-Za-z_]\w*/) || [])[0] || "";
167
+ };
168
+
169
+ for (const cap of caps(grammar, `(parameters (identifier) @param) (typed_parameter) @typed`, tree.rootNode)) {
170
+ const line = cap.node.startPosition.row + 1;
171
+ const caller = enclosing(fileRel, line);
172
+ if (!caller) continue;
173
+ if (cap.name === "param") {
174
+ shadows.add(scopeKey(caller, cap.node.text));
175
+ continue;
176
+ }
177
+ const name = cap.node.namedChildren.find((child) => child.type === "identifier")?.text;
178
+ if (!name) continue;
179
+ shadows.add(scopeKey(caller, name));
180
+ bind(caller, name, classInfo(typeName(field(cap.node, "type"))));
181
+ }
182
+ for (const cap of caps(grammar, `(assignment) @assign`, tree.rootNode)) {
183
+ const assignment = cap.node;
184
+ const caller = enclosing(fileRel, assignment.startPosition.row + 1);
185
+ if (!caller) continue;
186
+ const left = field(assignment, "left");
187
+ const right = field(assignment, "right");
188
+ let receiver = "";
189
+ if (left?.type === "identifier") receiver = left.text;
190
+ else if (left?.type === "attribute") receiver = left.text;
191
+ if (!receiver) continue;
192
+ shadows.add(scopeKey(caller, receiver));
193
+ const annotation = typeName(field(assignment, "type"));
194
+ const constructor = right?.type === "call" && field(right, "function")?.type === "identifier"
195
+ ? field(right, "function").text : "";
196
+ bind(caller, receiver, classInfo(annotation || constructor));
197
+ }
198
+
199
+ for (const cap of caps(grammar, `(call function: (identifier) @callee)`, tree.rootNode)) {
200
+ const line = cap.node.startPosition.row + 1;
201
+ const caller = enclosing(fileRel, line);
202
+ if (!caller || shadows.has(scopeKey(caller, cap.node.text))) continue;
203
+ let target = resolveCall(cap.node.text, fileRel);
204
+ if (target && nodeById.get(target)?.member_of) target = null;
205
+ let evidence = "bare";
206
+ if (!target) {
207
+ const candidates = wildcardCandidates(cap.node.text);
208
+ if (candidates.length === 1) { target = candidates[0]; evidence = "wildcard-import"; }
209
+ }
210
+ addCall(caller, target, line, evidence);
211
+ }
212
+
213
+ for (const cap of caps(grammar, `(call function: (attribute) @attributeCall)`, tree.rootNode)) {
214
+ const attribute = cap.node;
215
+ const object = field(attribute, "object");
216
+ const member = field(attribute, "attribute");
217
+ if (!object || !member) continue;
218
+ const line = attribute.startPosition.row + 1;
219
+ const caller = enclosing(fileRel, line);
220
+ if (!caller) continue;
221
+ if (object.type === "identifier") {
222
+ const moduleImport = imports.get(object.text);
223
+ if (moduleImport?.targetFile && moduleImport.imported === "*" && !moduleImport.wildcard) {
224
+ addCall(caller, moduleSymbol(moduleImport.targetFile, member.text), line, "module-member");
225
+ continue;
226
+ }
227
+ }
228
+ let klass = null;
229
+ if (["self", "cls"].includes(object.text) && caller.memberOf) klass = classInfo(caller.memberOf);
230
+ if (!klass) klass = bindings.get(scopeKey(caller, object.text));
231
+ if (!klass && object.type === "call" && field(object, "function")?.type === "identifier") klass = classInfo(field(object, "function").text);
232
+ addCall(caller, klass && methodOf(klass, member.text), line, "receiver-type");
233
+ }
234
+ },
78
235
  };
@@ -9,7 +9,7 @@ import {childProcessEnv} from '../child-env.js'
9
9
  import {createRepoBoundary} from '../repo-path.js'
10
10
 
11
11
  export const REPOSITORY_FRESHNESS_PROBE_V = 1
12
- export const GRAPH_BUILDER_SCHEMA_V = 2
12
+ export const GRAPH_BUILDER_SCHEMA_V = 4
13
13
  export const GRAPH_BUILDER_VERSION = (() => {
14
14
  try { return String(createRequire(import.meta.url)('../../package.json').version) }
15
15
  catch { return '0.0.0' }
@@ -22,10 +22,10 @@ const CURRENT_GRAPH_SCHEMA = Object.freeze({
22
22
  extImportsV: 2,
23
23
  edgeTypesV: 2,
24
24
  edgeProvenanceV: 1,
25
- complexityV: 1,
25
+ complexityV: 2,
26
26
  repoBoundaryV: 1,
27
27
  barrelResolutionV: 1,
28
- extractorSchemaV: 1,
28
+ extractorSchemaV: 4,
29
29
  })
30
30
  const CONTROL_FILES = ['.gitignore', '.weavatrixignore', '.weavatrix.json']
31
31
  const MAX_CONTROL_BYTES = 1_000_000
@@ -183,7 +183,7 @@ export async function refreshGraphIncrementally(repoDir, existingGraph, {
183
183
 
184
184
  if (!existingGraph || !existingGraph.fileHashes || !existingGraph.fileExportSignatures
185
185
  || !existingGraph.controlHashes || !existingGraph.jsExportRecords || Number(existingGraph.barrelResolutionV) < 1
186
- || Number(existingGraph.extractorSchemaV) < 1 || Number(existingGraph.edgeProvenanceV) < 1) {
186
+ || Number(existingGraph.extractorSchemaV) < 4 || Number(existingGraph.edgeProvenanceV) < 1) {
187
187
  return full("incremental-baseline-unavailable");
188
188
  }
189
189
  if (!sameRecord(existingGraph.controlHashes, snapshot.controlHashes)) return full("ignore-or-control-config-changed");
@@ -31,12 +31,19 @@ function withTypeOnly(result, typeOnly) {
31
31
  export function resolveJsBarrels({ jsExports, importedLocals, links }) {
32
32
  const tables = new Map();
33
33
  for (const [file, records] of jsExports) {
34
- const table = { explicit: new Map(), stars: [] };
34
+ const table = { explicit: new Map(), stars: [], facadeMembers: new Map() };
35
35
  for (const record of records || []) {
36
36
  if (record.kind === "star") {
37
37
  table.stars.push(record);
38
38
  continue;
39
39
  }
40
+ if (record.kind === "facade-member") {
41
+ if (!record.member) continue;
42
+ const list = table.facadeMembers.get(record.member) || [];
43
+ list.push(record);
44
+ table.facadeMembers.set(record.member, list);
45
+ continue;
46
+ }
40
47
  if (!record.exported) continue;
41
48
  const list = table.explicit.get(record.exported) || [];
42
49
  list.push(record);
@@ -87,6 +94,23 @@ export function resolveJsBarrels({ jsExports, importedLocals, links }) {
87
94
  return result;
88
95
  };
89
96
 
97
+ const resolveFacadeMember = (file, member, trail = new Set()) => {
98
+ const key = `${file}\0default.${member}`;
99
+ if (trail.has(key)) return MISSING;
100
+ const records = tables.get(file)?.facadeMembers.get(member) || [];
101
+ if (!records.length) return MISSING;
102
+ const nextTrail = new Set(trail);
103
+ nextTrail.add(key);
104
+ return mergeCandidates(records.map((record) => {
105
+ const local = record.local || member;
106
+ const binding = importedLocals.get(file)?.get(local);
107
+ if (binding?.targetFile && binding.imported && binding.imported !== "*") {
108
+ return withTypeOnly(resolveExport(binding.targetFile, binding.imported, nextTrail), record.typeOnly || binding.typeOnly);
109
+ }
110
+ return resolved(file, local, record.typeOnly);
111
+ }));
112
+ };
113
+
90
114
  // Every internal re-export is a physical facade hop. It remains in the graph for runtime cycle truth,
91
115
  // but semantic consumers ignore it in favor of importer -> declaration edges below.
92
116
  for (const link of links) {
@@ -149,8 +173,13 @@ export function resolveJsBarrels({ jsExports, importedLocals, links }) {
149
173
  // Namespace member usage is only knowable in pass 2 (`ui.Button`, `ui.run`). Expose a bounded helper
150
174
  // that uses the same cache and emits the corresponding semantic file edge exactly once.
151
175
  const resolveNamespaceMember = (source, imp, member, usage = null) => {
152
- if (!imp?.targetFile || imp.imported !== "*") return MISSING;
153
- const result = resolveExport(imp.targetFile, member);
176
+ if (!imp?.targetFile) return MISSING;
177
+ const defaultFacade = imp.imported === "default" || imp.originName === "default";
178
+ const result = imp.imported === "*"
179
+ ? resolveExport(imp.targetFile, member)
180
+ : defaultFacade
181
+ ? resolveFacadeMember(imp.originFile || imp.targetFile, member)
182
+ : MISSING;
154
183
  if (result.status !== "resolved") return result;
155
184
  if (result.origin.file !== imp.targetFile) {
156
185
  for (const link of links) {
@@ -165,5 +194,5 @@ export function resolveJsBarrels({ jsExports, importedLocals, links }) {
165
194
  return result;
166
195
  };
167
196
 
168
- return { resolveExport, resolveNamespaceMember };
197
+ return { resolveExport, resolveNamespaceMember, resolveFacadeMember };
169
198
  }
@@ -89,6 +89,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
89
89
  const suffix = /^:[A-Za-z0-9_-]+$/.test(extra?.idSuffix || "") ? extra.idSuffix : "";
90
90
  const id = `${fileRel}#${name}@${line}${suffix}`; if (nodeIds.has(id)) return;
91
91
  const sourceNode = extra && extra.sourceNode;
92
+ const selectionNode = extra && extra.selectionNode;
92
93
  const endLine = sourceNode?.endPosition ? sourceNode.endPosition.row + 1 : 0;
93
94
  let complexity = null;
94
95
  if (callable && sourceNode) {
@@ -98,10 +99,38 @@ export async function buildInternalGraph(repoDir, opts = {}) {
98
99
  addNode({
99
100
  id,
100
101
  label: callable ? `${name}()` : name,
102
+ ...(callable ? { callable: true } : {}),
101
103
  file_type: "code",
102
104
  source_file: fileRel,
103
105
  source_location: `L${line}`,
104
106
  ...(endLine >= line ? { source_end: `L${endLine}` } : {}),
107
+ ...(sourceNode?.startPosition && sourceNode?.endPosition ? {
108
+ // web-tree-sitter Point columns are zero-based UTF-16 code-unit offsets. Keep the
109
+ // declaration body range as well as the identifier selection so LSP reference
110
+ // locations on a boundary line cannot be attributed to the wrong symbol.
111
+ source_range: {
112
+ start: {
113
+ line: sourceNode.startPosition.row,
114
+ character: sourceNode.startPosition.column,
115
+ },
116
+ end: {
117
+ line: sourceNode.endPosition.row,
118
+ character: sourceNode.endPosition.column,
119
+ },
120
+ },
121
+ } : {}),
122
+ ...(selectionNode?.startPosition && selectionNode?.endPosition ? {
123
+ // web-tree-sitter's JavaScript Point columns are already zero-based UTF-16 code-unit
124
+ // offsets, matching LSP positions exactly (including text before non-ASCII identifiers).
125
+ selection_start: {
126
+ line: selectionNode.startPosition.row,
127
+ character: selectionNode.startPosition.column,
128
+ },
129
+ selection_end: {
130
+ line: selectionNode.endPosition.row,
131
+ character: selectionNode.endPosition.column,
132
+ },
133
+ } : {}),
105
134
  ...(complexity ? { complexity } : {}),
106
135
  ...(extra && extra.exported ? { exported: true } : {}),
107
136
  ...(extra && extra.decorated ? { decorated: true } : {}),
@@ -110,7 +139,14 @@ export async function buildInternalGraph(repoDir, opts = {}) {
110
139
  ...(extra && extra.visibility ? { visibility: extra.visibility } : {})
111
140
  });
112
141
  links.push({ source: fileRel, target: id, relation: "contains", confidence: "EXTRACTED" });
113
- syms.push({ id, name, start: line, end: endLine >= line ? endLine : 0 });
142
+ syms.push({
143
+ id,
144
+ name,
145
+ start: line,
146
+ end: endLine >= line ? endLine : 0,
147
+ ...(extra?.memberOf ? {memberOf: extra.memberOf} : {}),
148
+ ...(extra?.symbolKind ? {symbolKind: extra.symbolKind} : {}),
149
+ });
114
150
  if (!nameToId.has(name)) nameToId.set(name, id);
115
151
  if (extra?.moduleDeclaration && !moduleNameToId.has(name)) moduleNameToId.set(name, id);
116
152
  return id;
@@ -214,10 +250,18 @@ export async function buildInternalGraph(repoDir, opts = {}) {
214
250
  let code; try { code = readFileSync(abs, "utf8"); } catch { continue; }
215
251
  const parser = new Parser(); parser.setLanguage(langs[grammar]);
216
252
  let tree; try { tree = parser.parse(code); } catch { continue; }
217
- for (const cap of caps(grammar, lang.calls, tree.rootNode)) {
253
+ if (!lang.customCalls) for (const cap of caps(grammar, lang.calls, tree.rootNode)) {
218
254
  const caller = enclosing(fileRel, cap.node.startPosition.row + 1); if (!caller) continue;
219
255
  const target = resolveCall(cap.node.text, fileRel); if (!target || target === caller.id) continue;
220
- links.push({ source: caller.id, target, relation: "calls", confidence: "INFERRED" });
256
+ links.push({ source: caller.id, target, relation: "calls", confidence: "INFERRED", line: cap.node.startPosition.row + 1 });
257
+ }
258
+ if (typeof lang.pass2 === "function") {
259
+ try {
260
+ lang.pass2({
261
+ grammar, tree, fileRel, code, caps, field, enclosing, links, nodeById,
262
+ perFileSymbols, symByFileName, importedLocals, resolveCall,
263
+ });
264
+ } catch (e) { /* one language-specific resolver never sinks the graph */ void e; }
221
265
  }
222
266
  // qualified/selector calls (Go): `pkg.Func()` → the imported package's dir; else `receiver.Method()` → the
223
267
  // SAME package (heuristic by method name — connects lifecycle methods like peer.Enable() that need type info).
@@ -229,7 +273,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
229
273
  const dir = fileRel.includes("/") ? fileRel.slice(0, fileRel.lastIndexOf("/")) : "";
230
274
  const dm = goDirSymbols.get(imp && imp.targetDir ? imp.targetDir : dir);
231
275
  const target = dm && dm.get(fld.text);
232
- if (target && target !== caller.id) links.push({ source: caller.id, target, relation: "calls", confidence: "INFERRED" });
276
+ if (target && target !== caller.id) links.push({ source: caller.id, target, relation: "calls", confidence: "INFERRED", line: cap.node.startPosition.row + 1 });
233
277
  }
234
278
  for (const heritageSpec of lang.heritage || []) {
235
279
  const query = typeof heritageSpec === "string" ? heritageSpec : heritageSpec.query;
@@ -248,12 +292,12 @@ export async function buildInternalGraph(repoDir, opts = {}) {
248
292
  const object = field(cap.node, "object"), property = field(cap.node, "property");
249
293
  if (!object || object.type !== "identifier" || !property) continue;
250
294
  const imp = importedLocals.get(fileRel)?.get(object.text);
251
- if (!imp || imp.imported !== "*" || imp.typeOnly) continue;
295
+ if (!imp || !["*", "default"].includes(imp.imported) || imp.typeOnly) continue;
252
296
  const origin = resolveNamespaceMember(fileRel, imp, property.text, "call");
253
297
  if (origin.status !== "resolved") continue;
254
298
  const target = symByFileName.get(origin.origin.file)?.get(origin.origin.name);
255
299
  const caller = enclosing(fileRel, cap.node.startPosition.row + 1);
256
- if (target && caller && target !== caller.id) links.push({ source: caller.id, target, relation: "calls", confidence: "INFERRED" });
300
+ if (target && caller && target !== caller.id) links.push({ source: caller.id, target, relation: "calls", confidence: "INFERRED", line: cap.node.startPosition.row + 1 });
257
301
  }
258
302
  for (const cap of caps(grammar, `[
259
303
  (jsx_opening_element name: (_) @jsx)
@@ -332,10 +376,10 @@ export async function buildInternalGraph(repoDir, opts = {}) {
332
376
  extImportsV: 2,
333
377
  edgeTypesV: 2,
334
378
  edgeProvenanceV: EDGE_PROVENANCE_V,
335
- complexityV: 1,
379
+ complexityV: 2,
336
380
  repoBoundaryV: 1,
337
381
  barrelResolutionV: 1,
338
- extractorSchemaV: 1,
382
+ extractorSchemaV: 4,
339
383
  jsExportRecords: Object.fromEntries([...jsExports.entries()].sort(([a], [b]) => a.localeCompare(b))),
340
384
  fileHashes: snapshot.fileHashes,
341
385
  fileExportSignatures: snapshot.fileExportSignatures,
@@ -26,17 +26,17 @@ const PROFILE_CAPS = Object.freeze({
26
26
 
27
27
  // The files whose mtime the stdio shell watches for hot reload — keep in sync with the imports in
28
28
  // loadHotApi below (catalog.mjs itself is last: a change here re-derives the whole table).
29
- export const HOT_FILES = ['tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-actions.mjs', 'tools-architecture.mjs', 'tools-history.mjs', 'tools-company.mjs', 'catalog.mjs']
29
+ export const HOT_FILES = ['tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-source.mjs', 'tools-actions.mjs', 'tools-architecture.mjs', 'tools-history.mjs', 'tools-company.mjs', 'catalog.mjs']
30
30
 
31
- function buildTools({tg, ti, th, ta, tar, thi, tc}) {
31
+ function buildTools({tg, ti, th, ts, ta, tar, thi, tc}) {
32
32
  const tools = [
33
33
  {cap: 'graph', name: 'graph_stats', description: 'Return summary statistics: node count, edge count, communities, versioned edge-provenance/legacy-confidence breakdowns, and graph build time vs repo HEAD (staleness).', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tg.tGraphStats(g, ctx)},
34
34
  {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)},
35
35
  {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)},
36
- {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'}}, seed_files: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Optional exact repo-relative file paths. When resolved, these are the only seeds unless augment_seeds is true'}, augment_seeds: {type: 'boolean', default: false, description: 'With seed_files, also add fuzzy question-derived seeds; false keeps traversal strictly pinned'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, required: ['question']}, run: (g, a) => tg.tQueryGraph(g, a)},
36
+ {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'}}, seed_files: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Optional exact repo-relative file paths. When resolved, these are the only seeds unless augment_seeds is true'}, augment_seeds: {type: 'boolean', default: false, description: 'With seed_files, also add fuzzy question-derived seeds; false keeps traversal strictly pinned'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, required: ['question']}, run: (g, a, ctx) => tg.tQueryGraph(g, a, ctx)},
37
37
  {cap: 'graph', name: 'god_nodes', description: 'Rank production-code connectivity hubs by unique call/import/reference neighbors, with class/method ownership reported separately from runtime connectivity. Repeated call sites do not inflate the rank; classified tests, generated/build output and other non-product paths are excluded by default.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', default: 10}, include_classified: {type: 'boolean', default: false, description: 'Include tests/e2e/generated/build output/mocks/stories/docs/benchmarks/temp and paths explicitly excluded by repository classification'}}}, run: (g, a, ctx) => tg.tGodNodes(g, a, ctx)},
38
38
  {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)},
39
- {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)},
39
+ {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). Symbol queries stay symbol-precise by default; set include_container_importers only for a conservative module-wide radius. 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}, include_container_importers: {type: 'boolean', description: 'Also seed importers of the symbol containing file (broader, conservative; default false)', default: false}}, required: ['label']}, run: (g, a) => ti.tGetDependents(g, a)},
40
40
  {cap: 'graph', name: 'change_impact', description: 'Verdict-first, symbol-aware blast radius. Parses a zero-context git diff, distinguishes additive exports from signature/body/removal risk, and reverse-traverses only exact changed seeds so a new API does not inherit every legacy importer of its file. Measured coverage is used when present; otherwise static test reachability is labelled actualCoverage=NOT_AVAILABLE. Pass `diff` for a not-checked-out PR; `files` without a diff remains a conservative fallback.', 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)'}, diff: {type: 'string', maxLength: 2097152, description: 'Optional unified diff (prefer --unified=0) for a PR/change that is not checked out; enables symbol-level classification'}, files: {type: 'array', items: {type: 'string'}, maxItems: 500, description: 'Optional repo-relative changed-file hints. Without diff evidence these are classified conservatively rather than guessed additive'}, 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)},
41
41
  {cap: 'graph', name: 'git_history', description: 'Behavioral architecture evidence from bounded local git history: churn × connectivity hotspots, hidden co-change coupling, and expected test/source coupling. Reads numstat only — never commit messages, authors, or source bodies.', inputSchema: {type: 'object', properties: {months: {type: 'integer', enum: [3, 6, 12], default: 6}, max_commits: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, min_pair_count: {type: 'integer', minimum: 2, maximum: 100, default: 3}, max_pairs: {type: 'integer', minimum: 1, maximum: 500, default: 100}, top_n: {type: 'integer', minimum: 1, maximum: 50, default: 10}}}, run: (g, a, ctx) => thi.tGitHistory(g, a, ctx)},
42
42
  {
@@ -84,22 +84,24 @@ function buildTools({tg, ti, th, ta, tar, thi, tc}) {
84
84
  },
85
85
  {cap: 'graph', name: 'get_community', description: 'Get all nodes in a community by community ID (0-indexed by size).', inputSchema: {type: 'object', properties: {community_id: {type: 'integer', description: 'Community ID (0-indexed by size)'}}, required: ['community_id']}, run: (g, a) => tg.tGetCommunity(g, a)},
86
86
  {cap: 'search', name: 'search_code', description: 'Full-text or regex search across the repo source (ripgrep-backed, Node fallback). The graph only stores structure — use this to find literal text/patterns, then get_node/get_neighbors for structure.', inputSchema: {type: 'object', properties: {query: {type: 'string', description: 'text or regex to search for'}, is_regex: {type: 'boolean', default: false}, glob: {type: 'string', description: 'optional path glob, e.g. "*.js" or "src/**"'}, max_results: {type: 'integer', default: 40}}, required: ['query']}, run: (g, a, ctx) => searchCode({repoRoot: ctx.repoRoot, resolveRg}, a)},
87
- {cap: 'source', name: 'read_source', description: "Read the actual source of a node (by label/ID) or a repo-relative file path — the symbol's lines with context. The graph stores only locations, not source text. For a path read, pass start_line to anchor the window anywhere in the file (otherwise it shows the head).", inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'node label or ID'}, path: {type: 'string', description: 'or a repo-relative file path'}, start_line: {type: 'integer', description: 'anchor line: window = start_line-before .. start_line+after'}, before: {type: 'integer', default: 3}, after: {type: 'integer', default: 40}}}, run: (g, a, ctx) => readSource({repoRoot: ctx.repoRoot, resolveNode, isSymbol}, g, a)},
88
- {cap: 'health', name: 'find_duplicates', description: "Content-based clone detection over production code (MOSS winnowing over method bodies). Tests, e2e, generated code, mocks, stories, docs, benchmarks and temporary roots are classified separately and excluded by default; opt them in explicitly.", inputSchema: {type: 'object', properties: {min_similarity: {type: 'integer', description: '50-100, default 80 (ignored in semantic mode)'}, min_tokens: {type: 'integer', description: 'min fragment size, default 50'}, mode: {type: 'string', enum: ['renamed', 'strict', 'semantic'], default: 'renamed'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, include_strings: {type: 'boolean', description: 'Also clone-check large multi-line string literals', default: false}, top_n: {type: 'integer', default: 15}}}, run: (g, a, ctx) => th.tFindDuplicates(g, a, ctx)},
87
+ {cap: 'source', name: 'read_source', description: "Read the actual source of a node (by label/ID) or a repo-relative file path — the symbol's lines with context. The graph stores only locations, not source text. For a path read, pass start_line to anchor the window anywhere in the file (otherwise it shows the head).", inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'node label or ID'}, path: {type: 'string', description: 'or a repo-relative file path'}, start_line: {type: 'integer', description: 'anchor line: window = start_line-before .. start_line+after'}, before: {type: 'integer', default: 3}, after: {type: 'integer', default: 40}}}, run: (g, a, ctx) => readSource({repoRoot: ctx.repoRoot, resolveNode, isSymbol}, g, a)},
88
+ {cap: 'source', refreshGraph: true, name: 'inspect_symbol', description: 'Inspect one exact symbol with an on-demand TypeScript/JavaScript LSP reference query, grouped occurrence containers, graph blast radius, complexity facts and bounded local source context. Ambiguous labels fail closed; point queries never replace the broad precision overlay.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_containers: {type: 'integer', minimum: 1, maximum: 50, default: 15}, context_lines: {type: 'integer', minimum: 0, maximum: 40, default: 8}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => ts.tInspectSymbol(g, a, ctx)},
89
+ {cap: 'health', name: 'find_duplicates', description: "Content-based clone detection over production code (MOSS winnowing over method bodies). Supports high-confidence small clones down to 12 tokens when min_tokens is lowered. Tests, e2e, generated code, mocks, stories, docs, benchmarks and temporary roots are classified separately and excluded by default; opt them in explicitly.", inputSchema: {type: 'object', properties: {min_similarity: {type: 'integer', description: '50-100, default 80 (ignored in semantic mode)'}, min_tokens: {type: 'integer', minimum: 12, maximum: 400, description: 'min fragment size, 12-400; default 50'}, mode: {type: 'string', enum: ['renamed', 'strict', 'semantic'], default: 'renamed'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, include_strings: {type: 'boolean', description: 'Also clone-check large multi-line string literals', default: false}, top_n: {type: 'integer', default: 15}}}, run: (g, a, ctx) => th.tFindDuplicates(g, a, ctx)},
89
90
  {cap: 'health', name: 'find_dead_code', description: 'Conservative review queue for statically unreferenced files, functions, methods and symbols. Returns confidence, reason, bounded evidence and explicit framework/dynamic/reflection/public-API caveats; never an auto-delete verdict. Tests, generated code, mocks, stories, docs, benchmarks and temporary roots are excluded by default.', inputSchema: {type: 'object', properties: {path: {type: 'string', maxLength: 1024, description: 'Optional repo-relative path prefix'}, kinds: {type: 'array', items: {type: 'string', enum: ['file', 'function', 'method', 'symbol']}, maxItems: 4, uniqueItems: true, description: 'Optional candidate kinds; defaults to all'}, min_confidence: {type: 'string', enum: ['high', 'medium', 'low'], default: 'medium', description: 'Minimum confidence to include. low explicitly includes public/framework/dynamic review candidates'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, top_n: {type: 'integer', minimum: 1, maximum: 100, default: 30}}}, run: (g, a, ctx) => th.tFindDeadCode(g, a, ctx)},
90
91
  {cap: 'health', name: 'run_audit', description: 'Full internal health audit. With base_ref, builds and audits an immutable Git checkout, derives changed files, and compares stable deterministic finding IDs; debt defaults to genuinely new findings. Supply-chain checks remain explicitly uncomparable across the source-only baseline. changed_files without base_ref is only changed-scope, never a new-debt claim.', inputSchema: {type: 'object', properties: {category: {type: 'string', enum: ['unused', 'structure', 'vulnerability', 'malware'], description: 'Only findings of this category'}, min_severity: {type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'], description: 'Minimum severity to include'}, max_findings: {type: 'integer', description: 'Max findings to list, default 30'}, include_malware_scan: {type: 'boolean', description: 'Also grep installed packages for malware heuristics (slow)', default: false}, base_ref: {type: 'string', maxLength: 200, description: 'Optional immutable Git baseline (for example HEAD~1 or origin/main). Enables honest new/existing/fixed debt comparison'}, changed_files: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 500, description: 'Optional explicit repo-relative scope. Without base_ref this is changed-scope only; when omitted with base_ref, files are derived from the Git diff'}, debt: {type: 'string', enum: ['new', 'existing', 'all'], default: 'new', description: 'Baseline comparison view. Defaults to genuinely new deterministic findings when base_ref is present'}}}, run: (g, a, ctx) => th.tRunAudit(g, a, ctx)},
91
92
  {cap: 'health', name: 'coverage_map', description: 'Map a real existing coverage report onto the graph. If no report exists, return clearly labelled static test reachability (a test imports/reaches a source file) with actualCoverage=NOT_AVAILABLE; reachability is never presented as measured coverage.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max risk hotspots to list, default 15'}, path: {type: 'string', description: 'Optional repo-relative path prefix filter, e.g. src/query'}}}, run: (g, a, ctx) => th.tCoverageMap(g, a, ctx)},
93
+ {cap: 'health', name: 'hot_path_review', description: 'Rank bounded production-symbol hot paths from parser-derived local complexity, inside-loop allocations/copies/scans/sorts/recursion, graph fan-in/fan-out, and measured coverage or clearly labelled static test reachability. Local syntax cost stays separate from graph risk; this is not profiler data or interprocedural Big-O.', inputSchema: {type: 'object', properties: {path: {type: 'string', maxLength: 1024, description: 'Optional repository-relative path prefix'}, top_n: {type: 'integer', minimum: 1, maximum: 100, default: 20}, min_score: {type: 'integer', minimum: 0, maximum: 100, default: 0}, cyclomatic_threshold: {type: 'integer', minimum: 2, maximum: 1000, default: 8}, call_threshold: {type: 'integer', minimum: 1, maximum: 10000, default: 12}, loop_depth_threshold: {type: 'integer', minimum: 1, maximum: 10, default: 2}, time_rank_threshold: {type: 'integer', minimum: 0, maximum: 5, default: 2}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and explicitly excluded paths; tests still require include_tests'}}}, run: (g, a, ctx) => th.tHotPathReview(g, a, ctx)},
92
94
  {cap: 'graph', name: 'list_communities', description: 'List graph communities named by their dominant folder (largest first) with sample files — a readable module overview; feed the list position into get_community.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max communities to list, default 20'}}}, run: (g, a, ctx) => th.tListCommunities(g, a, ctx)},
93
- {cap: 'graph', name: 'module_map', description: 'Folder-level architecture map with file/symbol counts and strongest module dependencies, separating runtime, TypeScript type-only and language compile-only coupling.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max modules to list, default 25'}}}, run: (g, a, ctx) => th.tModuleMap(g, a, ctx)},
95
+ {cap: 'graph', name: 'module_map', description: 'Production-first folder architecture map with file/symbol counts and strongest module dependencies, separating runtime, TypeScript type-only and language compile-only coupling.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max modules to list, default 25'}, include_non_product: {type: 'boolean', default: false, description: 'Include tests, fixtures, benchmarks, generated output, docs and other classified non-product files; false by default'}}}, run: (g, a, ctx) => th.tModuleMap(g, a, ctx)},
94
96
  {cap: 'source', refreshGraph: true, name: 'list_endpoints', description: 'Inventory of HTTP endpoints defined in the repo (Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web/Spring MVC and WebFlux): method, composed path, handler, and file:line.', inputSchema: {type: 'object', properties: {max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
95
- {cap: 'build', name: 'rebuild_graph', description: "Rebuild the active full-repository graph and report a structural delta. With scope, build an isolated diagnostic graph without replacing or diffing the full graph; normal graph queries remain pinned to the full repository.", inputSchema: {type: 'object', properties: {mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], default: 'full'}, scope: {type: 'string', description: 'Optional isolated diagnostic path prefix; never replaces the active full graph'}}}, run: (g, a, ctx) => ta.tRebuildGraph(g, a, ctx)},
97
+ {cap: 'build', name: 'rebuild_graph', description: "Rebuild the active full-repository graph and report a structural delta. Omitted mode/precision preserve the active graph; a first build uses full and the startup precision setting (lsp unless WEAVATRIX_PRECISION=off). The local TypeScript/JavaScript LSP overlay validates bounded ambiguous edges; precision:off is an explicit fallback. With scope, build an isolated diagnostic graph without replacing or diffing the full graph.", inputSchema: {type: 'object', properties: {mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], description: 'Build mode; omit to preserve the active mode (or use full for a first build)'}, precision: {type: 'string', enum: ['lsp', 'off'], description: 'Semantic precision; omit to preserve the active mode (or use the startup setting for a first build)'}, scope: {type: 'string', description: 'Optional isolated diagnostic path prefix; never replaces the active full graph'}}}, run: (g, a, ctx) => ta.tRebuildGraph(g, a, ctx)},
96
98
  {cap: 'graph', name: 'graph_diff', description: 'Structural graph diff: compare the current graph with an immutable Git-ref baseline (base_ref such as HEAD~1 or main), or with graph.prev.json from the last rebuild when base_ref is omitted. Reports architecture drift, cycle changes and symbols that lost their last caller.', inputSchema: {type: 'object', properties: {base_ref: {type: 'string', maxLength: 256, description: 'Optional immutable Git baseline to build in isolation, e.g. HEAD~1, main or origin/main; never checks out or mutates the working tree'}, 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)},
97
99
  {cap: 'graph', name: 'get_architecture_contract', description: 'Return the executable target-architecture contract, quality budgets and ratchet mode that an agent must follow before editing.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tar.tGetArchitectureContract(g, a, ctx)},
98
100
  {cap: 'graph', name: 'prepare_change', description: 'Select active target-architecture rules for an intended set of changed files. Run before a non-trivial edit.', inputSchema: {type: 'object', properties: {intent: {type: 'string'}, files: {type: 'array', items: {type: 'string'}, maxItems: 200}}, required: ['files']}, run: (g, a, ctx) => tar.tPrepareChange(g, a, ctx)},
99
101
  {cap: 'health', name: 'verify_architecture', description: 'Verify the fresh graph against the active target contract and ratchet; separates new, existing, fixed and excepted debt.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tar.tVerifyArchitecture(g, a, ctx)},
100
102
  {cap: 'health', name: 'explain_architecture_violation', description: 'Explain one active architecture violation and the governing rule.', inputSchema: {type: 'object', properties: {fingerprint: {type: 'string'}}, required: ['fingerprint']}, run: (g, a, ctx) => tar.tExplainArchitectureViolation(g, a, ctx)},
101
103
  {cap: 'health', name: 'propose_architecture_exception', description: 'Prepare, but never apply, a bounded exception proposal for human review.', inputSchema: {type: 'object', properties: {fingerprint: {type: 'string'}, reason: {type: 'string'}, expires: {type: 'string', description: 'Optional YYYY-MM-DD'}}, required: ['fingerprint', 'reason']}, run: (g, a, ctx) => tar.tProposeArchitectureException(g, a, ctx)},
102
- {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)},
104
+ {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. Omitted mode/precision preserve an existing graph; a new graph uses full and the startup precision setting (lsp unless WEAVATRIX_PRECISION=off). 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'], description: 'Optional build mode override; omit to preserve an existing graph'}, precision: {type: 'string', enum: ['lsp', 'off'], description: 'Optional semantic precision override; omit to preserve an existing graph or use the startup setting for a new graph'}} , required: ['path']}, run: (g, a, ctx) => ta.tOpenRepo(g, a, ctx)},
103
105
  {cap: 'retarget', name: 'list_known_repos', description: 'OFFLINE RETARGET: list every registered local repository graph from the global per-user registry, regardless of parent folder.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tListKnownRepos(g, a, ctx)},
104
106
  {cap: 'advisories', name: 'refresh_advisories', description: "NETWORK / explicit advisories profile: 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)},
105
107
  {cap: 'hosted', name: 'pull_architecture_contract', description: 'NETWORK / explicit hosted profile: fetch the owner-approved target architecture for the active stable repository UUID, validate it and cache it locally. Sends only the opaque UUID with bearer auth; never source or paths.', inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000}}}, run: (g, a, ctx) => ta.tPullArchitectureContract(g, a, ctx)},
@@ -131,16 +133,17 @@ function buildTools({tg, ti, th, ta, tar, thi, tc}) {
131
133
  // present string (even '') is an explicit selection, so "select nothing" really exposes nothing.
132
134
  export async function loadHotApi(version, capsArg) {
133
135
  const v = version ? `?v=${version}` : ''
134
- const [tg, ti, th, ta, tar, thi, tc] = await Promise.all([
136
+ const [tg, ti, th, ts, ta, tar, thi, tc] = await Promise.all([
135
137
  import(new URL(`./tools-graph.mjs${v}`, import.meta.url).href),
136
138
  import(new URL(`./tools-impact.mjs${v}`, import.meta.url).href),
137
139
  import(new URL(`./tools-health.mjs${v}`, import.meta.url).href),
140
+ import(new URL(`./tools-source.mjs${v}`, import.meta.url).href),
138
141
  import(new URL(`./tools-actions.mjs${v}`, import.meta.url).href),
139
142
  import(new URL(`./tools-architecture.mjs${v}`, import.meta.url).href),
140
143
  import(new URL(`./tools-history.mjs${v}`, import.meta.url).href),
141
144
  import(new URL(`./tools-company.mjs${v}`, import.meta.url).href),
142
145
  ])
143
- const all = buildTools({tg, ti, th, ta, tar, thi, tc})
146
+ const all = buildTools({tg, ti, th, ts, ta, tar, thi, tc})
144
147
  const raw = capsArg == null ? 'offline' : String(capsArg).trim()
145
148
  const selected = PROFILE_CAPS[raw] || raw.split(',').map((s) => s.trim()).filter(Boolean)
146
149
  .flatMap((cap) => cap === 'online' ? ['advisories', 'hosted'] : [cap])