weavatrix 0.2.4 → 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.
@@ -68,6 +68,11 @@ export function buildEvidence(stats) {
68
68
  if (stats.maxLoopDepth > 1) out.push(`loop depth ${stats.maxLoopDepth}`);
69
69
  if (stats.sorts) out.push(plural(stats.sorts, "sort", "sorts"));
70
70
  if (stats.spreadCopies) out.push(plural(stats.spreadCopies, "shallow copy", "shallow copies"));
71
+ if (stats.allocationsInLoops) out.push(`${plural(stats.allocationsInLoops, "allocation", "allocations")} inside iteration`);
72
+ if (stats.copiesInLoops) out.push(`${plural(stats.copiesInLoops, "copy", "copies")} inside iteration`);
73
+ if (stats.linearOpsInLoops) out.push(`${plural(stats.linearOpsInLoops, "linear operation", "linear operations")} inside iteration`);
74
+ if (stats.sortsInLoops) out.push(`${plural(stats.sortsInLoops, "sort", "sorts")} inside iteration`);
75
+ if (stats.recursionInLoops) out.push(`${plural(stats.recursionInLoops, "recursive call", "recursive calls")} inside iteration`);
71
76
  if (stats.branches) out.push(plural(stats.branches, "branch point", "branch points"));
72
77
  if (stats.awaits) out.push(plural(stats.awaits, "await boundary", "await boundaries"));
73
78
  if (stats.callCount) out.push(plural(stats.callCount, "call", "calls"));
@@ -32,10 +32,23 @@ function createStats(parameters, family) {
32
32
  producerCalls: 0,
33
33
  recursion: false,
34
34
  maxSortLoopDepth: 0,
35
- maxVariableAllocationDepth: 0
35
+ maxVariableAllocationDepth: 0,
36
+ allocationsInLoops: 0,
37
+ copiesInLoops: 0,
38
+ linearOpsInLoops: 0,
39
+ sortsInLoops: 0,
40
+ recursionInLoops: 0,
41
+ hotEvidence: []
36
42
  };
37
43
  }
38
44
 
45
+ function recordHotEvidence(stats, node, kind, detail) {
46
+ if (stats.hotEvidence.length >= 24) return;
47
+ const line = node?.startPosition ? node.startPosition.row + 1 : 0;
48
+ if (stats.hotEvidence.some((item) => item.kind === kind && item.line === line)) return;
49
+ stats.hotEvidence.push({ kind, line, detail });
50
+ }
51
+
39
52
  function shouldSkipNode(root, node, state) {
40
53
  if (sameNode(node, root)) return false;
41
54
  const type = String(node.type || "");
@@ -70,39 +83,74 @@ function recordBasicSignals(stats, node, facts) {
70
83
  if (RETURN_NODES.has(type)) stats.returns++;
71
84
  if (AWAIT_NODES.has(type)) { stats.awaits++; stats.asyncBoundaries++; }
72
85
  if (OBJECT_NODES.has(type)) stats.objectLiterals++;
73
- if (FIXED_ALLOCATION_NODES.has(type)) stats.allocations++;
86
+ if (FIXED_ALLOCATION_NODES.has(type)) {
87
+ stats.allocations++;
88
+ if (currentDepth > 0) {
89
+ stats.allocationsInLoops++;
90
+ recordHotEvidence(stats, node, "allocation-in-loop", type);
91
+ }
92
+ }
74
93
  if (!VARIABLE_ALLOCATION_NODES.has(type)) return;
75
94
  stats.producerCalls++;
76
95
  stats.maxVariableAllocationDepth = Math.max(stats.maxVariableAllocationDepth, currentDepth + 1);
77
96
  }
78
97
 
79
- function recordSpread(stats, facts, parent) {
98
+ function recordSpread(stats, node, facts, parent) {
80
99
  if (!SPREAD_NODES.has(facts.type) || /parameters|parameter_list/.test(String(parent?.type || ""))) return;
81
100
  stats.spreadCopies++;
82
101
  stats.linearOps++;
83
102
  stats.maxVariableAllocationDepth = Math.max(stats.maxVariableAllocationDepth, facts.currentDepth + 1);
103
+ if (facts.currentDepth > 0) {
104
+ stats.copiesInLoops++;
105
+ stats.linearOpsInLoops++;
106
+ recordHotEvidence(stats, node, "copy-in-loop", facts.type);
107
+ }
84
108
  }
85
109
 
86
- function recordCall(stats, facts, state, targetName) {
110
+ function recordCall(stats, node, facts, state, targetName) {
87
111
  if (!facts.isCall) return;
88
112
  stats.callCount++;
89
113
  if (state.awaited || looksLikeIoCall(facts.nameAtCall)) stats.externalCalls++;
90
- if (targetName && facts.normalizedCall === targetName) stats.recursion = true;
114
+ if (targetName && facts.normalizedCall === targetName) {
115
+ stats.recursion = true;
116
+ if (facts.currentDepth > 0) {
117
+ stats.recursionInLoops++;
118
+ recordHotEvidence(stats, node, "recursion-in-loop", facts.nameAtCall || targetName);
119
+ }
120
+ }
91
121
  if (facts.sortCall) {
92
122
  stats.sorts++;
93
123
  stats.maxSortLoopDepth = Math.max(stats.maxSortLoopDepth, facts.currentDepth);
94
- } else if (LINEAR_CALLS.has(facts.normalizedCall) && !facts.iteratorCall) stats.linearOps++;
124
+ if (facts.currentDepth > 0) {
125
+ stats.sortsInLoops++;
126
+ recordHotEvidence(stats, node, "sort-in-loop", facts.nameAtCall || "sort");
127
+ }
128
+ } else if (LINEAR_CALLS.has(facts.normalizedCall) && !facts.iteratorCall) {
129
+ stats.linearOps++;
130
+ if (facts.currentDepth > 0) {
131
+ stats.linearOpsInLoops++;
132
+ recordHotEvidence(stats, node, "linear-scan-in-loop", facts.nameAtCall || "collection operation");
133
+ }
134
+ } else if (facts.iteratorCall && facts.currentDepth > 0) {
135
+ stats.linearOpsInLoops++;
136
+ recordHotEvidence(stats, node, "linear-scan-in-loop", facts.nameAtCall || "iterator");
137
+ }
95
138
  if (!facts.producerCall) return;
96
139
  stats.producerCalls++;
97
140
  stats.allocations++;
98
141
  stats.maxVariableAllocationDepth = Math.max(stats.maxVariableAllocationDepth, facts.currentDepth + 1);
142
+ if (facts.currentDepth > 0) {
143
+ stats.allocationsInLoops++;
144
+ recordHotEvidence(stats, node, "allocation-in-loop", facts.nameAtCall || "collection producer");
145
+ }
99
146
  }
100
147
 
101
- function recordLoop(stats, facts) {
148
+ function recordLoop(stats, node, facts) {
102
149
  if (!facts.isLoop && !facts.iteratorCall) return facts.currentDepth;
103
150
  const nextDepth = facts.currentDepth + 1;
104
151
  stats.loops++;
105
152
  stats.maxLoopDepth = Math.max(stats.maxLoopDepth, nextDepth);
153
+ if (nextDepth > 1) recordHotEvidence(stats, node, "nested-iteration", `depth ${nextDepth}`);
106
154
  return nextDepth;
107
155
  }
108
156
 
@@ -121,9 +169,9 @@ function walkSyntax(root, node, state, context) {
121
169
  const callbackContext = state.callbackContext || ARGUMENT_NODES.has(String(parent?.type || ""));
122
170
  const facts = nodeFacts(node, state);
123
171
  recordBasicSignals(context.stats, node, facts);
124
- recordSpread(context.stats, facts, parent);
125
- recordCall(context.stats, facts, state, context.targetName);
126
- const nextDepth = recordLoop(context.stats, facts);
172
+ recordSpread(context.stats, node, facts, parent);
173
+ recordCall(context.stats, node, facts, state, context.targetName);
174
+ const nextDepth = recordLoop(context.stats, node, facts);
127
175
  for (const child of children(node)) {
128
176
  const childIsArgs = ARGUMENT_NODES.has(String(child.type || ""));
129
177
  walkSyntax(root, child, {
@@ -165,6 +213,12 @@ export function analyzeSyntaxComplexity(root, { family = "", name = "" } = {}) {
165
213
  sorts: stats.sorts,
166
214
  linearOps: stats.linearOps,
167
215
  recursion: stats.recursion,
216
+ allocationsInLoops: stats.allocationsInLoops,
217
+ copiesInLoops: stats.copiesInLoops,
218
+ linearOpsInLoops: stats.linearOpsInLoops,
219
+ sortsInLoops: stats.sortsInLoops,
220
+ recursionInLoops: stats.recursionInLoops,
221
+ hotEvidence: stats.hotEvidence,
168
222
  ...labels,
169
223
  scope: "local",
170
224
  complexityScope: "local",
@@ -250,6 +250,28 @@ export default {
250
250
  local: typedDeclaration[2],
251
251
  typeOnly: typedDeclaration[1] !== "enum",
252
252
  });
253
+ const defaultValue = field(node, "value");
254
+ if (defaultValue?.type === "object") {
255
+ // Service facades commonly expose local helpers through `export default { getSchema,
256
+ // save: persist }`. Record the public member -> local binding instead of treating the
257
+ // object as an opaque default export.
258
+ recordJsExport({kind: "facade-root", exported: "default", local: "default", typeOnly: false});
259
+ for (const property of defaultValue.namedChildren || []) {
260
+ if (["shorthand_property_identifier", "shorthand_property_identifier_pattern"].includes(property.type)) {
261
+ recordJsExport({kind: "facade-member", member: property.text, local: property.text, typeOnly: false});
262
+ markExported(property.text);
263
+ continue;
264
+ }
265
+ if (property.type !== "pair") continue;
266
+ const key = field(property, "key");
267
+ const value = field(property, "value");
268
+ if (!key || value?.type !== "identifier") continue;
269
+ const member = key.text.replace(/^['"`]|['"`]$/g, "");
270
+ if (!/^[A-Za-z_$][\w$]*$/.test(member)) continue;
271
+ recordJsExport({kind: "facade-member", member, local: value.text, typeOnly: false});
272
+ markExported(value.text);
273
+ }
274
+ }
253
275
  }
254
276
 
255
277
  // ---- export markers beyond declarations: `export { a, b }`, `export default X`, CJS module.exports ----
@@ -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 = 3
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: 3,
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) < 3 || 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
  }
@@ -139,7 +139,14 @@ export async function buildInternalGraph(repoDir, opts = {}) {
139
139
  ...(extra && extra.visibility ? { visibility: extra.visibility } : {})
140
140
  });
141
141
  links.push({ source: fileRel, target: id, relation: "contains", confidence: "EXTRACTED" });
142
- 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
+ });
143
150
  if (!nameToId.has(name)) nameToId.set(name, id);
144
151
  if (extra?.moduleDeclaration && !moduleNameToId.has(name)) moduleNameToId.set(name, id);
145
152
  return id;
@@ -243,11 +250,19 @@ export async function buildInternalGraph(repoDir, opts = {}) {
243
250
  let code; try { code = readFileSync(abs, "utf8"); } catch { continue; }
244
251
  const parser = new Parser(); parser.setLanguage(langs[grammar]);
245
252
  let tree; try { tree = parser.parse(code); } catch { continue; }
246
- for (const cap of caps(grammar, lang.calls, tree.rootNode)) {
253
+ if (!lang.customCalls) for (const cap of caps(grammar, lang.calls, tree.rootNode)) {
247
254
  const caller = enclosing(fileRel, cap.node.startPosition.row + 1); if (!caller) continue;
248
255
  const target = resolveCall(cap.node.text, fileRel); if (!target || target === caller.id) continue;
249
256
  links.push({ source: caller.id, target, relation: "calls", confidence: "INFERRED", line: cap.node.startPosition.row + 1 });
250
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; }
265
+ }
251
266
  // qualified/selector calls (Go): `pkg.Func()` → the imported package's dir; else `receiver.Method()` → the
252
267
  // SAME package (heuristic by method name — connects lifecycle methods like peer.Enable() that need type info).
253
268
  if (lang.selectorCall) for (const cap of caps(grammar, lang.selectorCall, tree.rootNode)) {
@@ -277,7 +292,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
277
292
  const object = field(cap.node, "object"), property = field(cap.node, "property");
278
293
  if (!object || object.type !== "identifier" || !property) continue;
279
294
  const imp = importedLocals.get(fileRel)?.get(object.text);
280
- if (!imp || imp.imported !== "*" || imp.typeOnly) continue;
295
+ if (!imp || !["*", "default"].includes(imp.imported) || imp.typeOnly) continue;
281
296
  const origin = resolveNamespaceMember(fileRel, imp, property.text, "call");
282
297
  if (origin.status !== "resolved") continue;
283
298
  const target = symByFileName.get(origin.origin.file)?.get(origin.origin.name);
@@ -361,10 +376,10 @@ export async function buildInternalGraph(repoDir, opts = {}) {
361
376
  extImportsV: 2,
362
377
  edgeTypesV: 2,
363
378
  edgeProvenanceV: EDGE_PROVENANCE_V,
364
- complexityV: 1,
379
+ complexityV: 2,
365
380
  repoBoundaryV: 1,
366
381
  barrelResolutionV: 1,
367
- extractorSchemaV: 3,
382
+ extractorSchemaV: 4,
368
383
  jsExportRecords: Object.fromEntries([...jsExports.entries()].sort(([a], [b]) => a.localeCompare(b))),
369
384
  fileHashes: snapshot.fileHashes,
370
385
  fileExportSignatures: snapshot.fileExportSignatures,
@@ -26,9 +26,9 @@ 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)},
@@ -36,7 +36,7 @@ function buildTools({tg, ti, th, ta, tar, thi, tc}) {
36
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,11 +84,13 @@ 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
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)},
@@ -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])