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.
- package/README.md +36 -10
- package/package.json +1 -1
- package/skill/SKILL.md +17 -7
- package/src/analysis/dead-check.js +48 -2
- package/src/analysis/dead-code-review.js +9 -5
- package/src/analysis/duplicates.compute.js +11 -6
- package/src/analysis/hot-path-review.js +228 -0
- package/src/analysis/internal-audit.run.js +36 -0
- package/src/analysis/source-complexity.report.js +5 -0
- package/src/analysis/source-complexity.walk.js +64 -10
- package/src/graph/builder/lang-js.js +22 -0
- package/src/graph/builder/lang-python.js +161 -4
- package/src/graph/freshness-probe.js +3 -3
- package/src/graph/incremental-refresh.js +1 -1
- package/src/graph/internal-builder.barrels.js +33 -4
- package/src/graph/internal-builder.build.js +20 -5
- package/src/mcp/catalog.mjs +10 -7
- package/src/mcp/sync-payload.mjs +2 -1
- package/src/mcp/tools-actions.mjs +1 -1
- package/src/mcp/tools-health.mjs +66 -5
- package/src/mcp/tools-impact.mjs +3 -3
- package/src/mcp/tools-source.mjs +237 -0
- package/src/precision/lsp-client.js +1 -1
- package/src/precision/lsp-overlay.js +28 -3
- package/src/precision/symbol-query.js +137 -0
|
@@ -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))
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
25
|
+
complexityV: 2,
|
|
26
26
|
repoBoundaryV: 1,
|
|
27
27
|
barrelResolutionV: 1,
|
|
28
|
-
extractorSchemaV:
|
|
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) <
|
|
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
|
|
153
|
-
const
|
|
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({
|
|
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
|
|
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:
|
|
379
|
+
complexityV: 2,
|
|
365
380
|
repoBoundaryV: 1,
|
|
366
381
|
barrelResolutionV: 1,
|
|
367
|
-
extractorSchemaV:
|
|
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,
|
package/src/mcp/catalog.mjs
CHANGED
|
@@ -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).
|
|
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
|
-
|
|
88
|
-
|
|
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])
|