@grafema/mcp 0.1.0-alpha.1
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/LICENSE +190 -0
- package/README.md +67 -0
- package/dist/analysis-worker.d.ts +9 -0
- package/dist/analysis-worker.d.ts.map +1 -0
- package/dist/analysis-worker.js +194 -0
- package/dist/analysis.d.ts +13 -0
- package/dist/analysis.d.ts.map +1 -0
- package/dist/analysis.js +116 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +165 -0
- package/dist/definitions.d.ts +23 -0
- package/dist/definitions.d.ts.map +1 -0
- package/dist/definitions.js +419 -0
- package/dist/handlers.d.ts +34 -0
- package/dist/handlers.d.ts.map +1 -0
- package/dist/handlers.js +747 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +122 -0
- package/dist/state.d.ts +23 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +138 -0
- package/dist/types.d.ts +197 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/utils.d.ts +24 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +180 -0
- package/package.json +53 -0
- package/src/.rfguard/current-session.txt +1 -0
- package/src/analysis-worker.ts +286 -0
- package/src/analysis.ts +158 -0
- package/src/config.ts +240 -0
- package/src/definitions.ts +438 -0
- package/src/handlers.ts +918 -0
- package/src/server.ts +178 -0
- package/src/state.ts +169 -0
- package/src/types.ts +245 -0
- package/src/utils.ts +204 -0
package/src/handlers.ts
ADDED
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Tool Handlers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { ensureAnalyzed } from './analysis.js';
|
|
7
|
+
import { getProjectPath, getAnalysisStatus, setIsAnalyzed, getOrCreateBackend, getGuaranteeManager, getGuaranteeAPI } from './state.js';
|
|
8
|
+
import {
|
|
9
|
+
normalizeLimit,
|
|
10
|
+
formatPaginationInfo,
|
|
11
|
+
guardResponseSize,
|
|
12
|
+
serializeBigInt,
|
|
13
|
+
findSimilarTypes,
|
|
14
|
+
textResult,
|
|
15
|
+
errorResult,
|
|
16
|
+
} from './utils.js';
|
|
17
|
+
import type {
|
|
18
|
+
ToolResult,
|
|
19
|
+
QueryGraphArgs,
|
|
20
|
+
FindCallsArgs,
|
|
21
|
+
FindNodesArgs,
|
|
22
|
+
TraceAliasArgs,
|
|
23
|
+
TraceDataFlowArgs,
|
|
24
|
+
CheckInvariantArgs,
|
|
25
|
+
AnalyzeProjectArgs,
|
|
26
|
+
GetSchemaArgs,
|
|
27
|
+
CreateGuaranteeArgs,
|
|
28
|
+
CheckGuaranteesArgs,
|
|
29
|
+
DeleteGuaranteeArgs,
|
|
30
|
+
GetCoverageArgs,
|
|
31
|
+
GetDocumentationArgs,
|
|
32
|
+
GraphBackend,
|
|
33
|
+
GraphNode,
|
|
34
|
+
} from './types.js';
|
|
35
|
+
import { isGuaranteeType } from '@grafema/core';
|
|
36
|
+
|
|
37
|
+
// === QUERY HANDLERS ===
|
|
38
|
+
|
|
39
|
+
export async function handleQueryGraph(args: QueryGraphArgs): Promise<ToolResult> {
|
|
40
|
+
const db = await ensureAnalyzed();
|
|
41
|
+
const { query, limit: requestedLimit, offset: requestedOffset, format } = args;
|
|
42
|
+
const explain = (args as any).explain;
|
|
43
|
+
|
|
44
|
+
const limit = normalizeLimit(requestedLimit);
|
|
45
|
+
const offset = Math.max(0, requestedOffset || 0);
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
// Check if backend supports Datalog queries
|
|
49
|
+
if (!('checkGuarantee' in db)) {
|
|
50
|
+
return errorResult('Backend does not support Datalog queries');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const checkFn = (db as unknown as { checkGuarantee: (q: string) => Promise<Array<{ bindings: Array<{ name: string; value: string }> }>> }).checkGuarantee;
|
|
54
|
+
const results = await checkFn(query);
|
|
55
|
+
const total = results.length;
|
|
56
|
+
|
|
57
|
+
if (total === 0) {
|
|
58
|
+
const nodeCounts = await db.countNodesByType();
|
|
59
|
+
const totalNodes = Object.values(nodeCounts).reduce((a, b) => a + b, 0);
|
|
60
|
+
|
|
61
|
+
const typeMatch = query.match(/node\([^,]+,\s*"([^"]+)"\)/);
|
|
62
|
+
const queriedType = typeMatch ? typeMatch[1] : null;
|
|
63
|
+
|
|
64
|
+
let hint = '';
|
|
65
|
+
if (queriedType && !nodeCounts[queriedType]) {
|
|
66
|
+
const availableTypes = Object.keys(nodeCounts);
|
|
67
|
+
const similar = findSimilarTypes(queriedType, availableTypes);
|
|
68
|
+
if (similar.length > 0) {
|
|
69
|
+
hint = `\n💡 Did you mean: ${similar.join(', ')}?`;
|
|
70
|
+
} else {
|
|
71
|
+
hint = `\n💡 Available types: ${availableTypes.slice(0, 10).join(', ')}${availableTypes.length > 10 ? '...' : ''}`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return textResult(
|
|
76
|
+
`Query returned no results.${hint}\n📊 Graph: ${totalNodes.toLocaleString()} nodes`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const paginatedResults = results.slice(offset, offset + limit);
|
|
81
|
+
const hasMore = offset + limit < total;
|
|
82
|
+
|
|
83
|
+
const enrichedResults: unknown[] = [];
|
|
84
|
+
for (const result of paginatedResults) {
|
|
85
|
+
const nodeId = result.bindings?.find((b: any) => b.name === 'X')?.value;
|
|
86
|
+
if (nodeId) {
|
|
87
|
+
const node = await db.getNode(nodeId);
|
|
88
|
+
if (node) {
|
|
89
|
+
enrichedResults.push({
|
|
90
|
+
...node,
|
|
91
|
+
id: nodeId,
|
|
92
|
+
file: node.file,
|
|
93
|
+
line: node.line,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const paginationInfo = formatPaginationInfo({
|
|
100
|
+
limit,
|
|
101
|
+
offset,
|
|
102
|
+
returned: enrichedResults.length,
|
|
103
|
+
total,
|
|
104
|
+
hasMore,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const responseText = `Found ${total} result(s):${paginationInfo}\n\n${JSON.stringify(
|
|
108
|
+
serializeBigInt(enrichedResults),
|
|
109
|
+
null,
|
|
110
|
+
2
|
|
111
|
+
)}`;
|
|
112
|
+
|
|
113
|
+
return textResult(guardResponseSize(responseText));
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return errorResult((error as Error).message);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function handleFindCalls(args: FindCallsArgs): Promise<ToolResult> {
|
|
120
|
+
const db = await ensureAnalyzed();
|
|
121
|
+
const { target: name, limit: requestedLimit, offset: requestedOffset } = args;
|
|
122
|
+
const className = (args as any).className;
|
|
123
|
+
|
|
124
|
+
const limit = normalizeLimit(requestedLimit);
|
|
125
|
+
const offset = Math.max(0, requestedOffset || 0);
|
|
126
|
+
|
|
127
|
+
const calls: unknown[] = [];
|
|
128
|
+
let skipped = 0;
|
|
129
|
+
let totalMatched = 0;
|
|
130
|
+
|
|
131
|
+
for await (const node of db.queryNodes({ type: 'CALL' })) {
|
|
132
|
+
if ((node as any).name !== name && (node as any).method !== name) continue;
|
|
133
|
+
if (className && (node as any).object !== className) continue;
|
|
134
|
+
|
|
135
|
+
totalMatched++;
|
|
136
|
+
|
|
137
|
+
if (skipped < offset) {
|
|
138
|
+
skipped++;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (calls.length >= limit) continue;
|
|
143
|
+
|
|
144
|
+
const callsEdges = await db.getOutgoingEdges(node.id, ['CALLS']);
|
|
145
|
+
const isResolved = callsEdges.length > 0;
|
|
146
|
+
|
|
147
|
+
let target = null;
|
|
148
|
+
if (isResolved) {
|
|
149
|
+
const targetNode = await db.getNode(callsEdges[0].dst);
|
|
150
|
+
target = targetNode
|
|
151
|
+
? {
|
|
152
|
+
type: targetNode.type,
|
|
153
|
+
name: targetNode.name,
|
|
154
|
+
file: targetNode.file,
|
|
155
|
+
line: targetNode.line,
|
|
156
|
+
}
|
|
157
|
+
: null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
calls.push({
|
|
161
|
+
id: node.id,
|
|
162
|
+
name: (node as any).name,
|
|
163
|
+
object: (node as any).object,
|
|
164
|
+
file: node.file,
|
|
165
|
+
line: node.line,
|
|
166
|
+
resolved: isResolved,
|
|
167
|
+
target,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (totalMatched === 0) {
|
|
172
|
+
return textResult(`No calls found for "${className ? className + '.' : ''}${name}"`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const resolved = calls.filter((c: any) => c.resolved).length;
|
|
176
|
+
const unresolved = calls.length - resolved;
|
|
177
|
+
const hasMore = offset + calls.length < totalMatched;
|
|
178
|
+
|
|
179
|
+
const paginationInfo = formatPaginationInfo({
|
|
180
|
+
limit,
|
|
181
|
+
offset,
|
|
182
|
+
returned: calls.length,
|
|
183
|
+
total: totalMatched,
|
|
184
|
+
hasMore,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const responseText =
|
|
188
|
+
`Found ${totalMatched} call(s) to "${className ? className + '.' : ''}${name}":${paginationInfo}\n` +
|
|
189
|
+
`- Resolved: ${resolved}\n` +
|
|
190
|
+
`- Unresolved: ${unresolved}\n\n` +
|
|
191
|
+
JSON.stringify(serializeBigInt(calls), null, 2);
|
|
192
|
+
|
|
193
|
+
return textResult(guardResponseSize(responseText));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function handleFindNodes(args: FindNodesArgs): Promise<ToolResult> {
|
|
197
|
+
const db = await ensureAnalyzed();
|
|
198
|
+
const { type, name, file, limit: requestedLimit, offset: requestedOffset } = args;
|
|
199
|
+
|
|
200
|
+
const limit = normalizeLimit(requestedLimit);
|
|
201
|
+
const offset = Math.max(0, requestedOffset || 0);
|
|
202
|
+
|
|
203
|
+
const filter: Record<string, unknown> = {};
|
|
204
|
+
if (type) filter.type = type;
|
|
205
|
+
if (name) filter.name = name;
|
|
206
|
+
if (file) filter.file = file;
|
|
207
|
+
|
|
208
|
+
const nodes: GraphNode[] = [];
|
|
209
|
+
let skipped = 0;
|
|
210
|
+
let totalMatched = 0;
|
|
211
|
+
|
|
212
|
+
for await (const node of db.queryNodes(filter)) {
|
|
213
|
+
totalMatched++;
|
|
214
|
+
|
|
215
|
+
if (skipped < offset) {
|
|
216
|
+
skipped++;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (nodes.length < limit) {
|
|
221
|
+
nodes.push(node);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (totalMatched === 0) {
|
|
226
|
+
return textResult('No nodes found matching criteria');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const hasMore = offset + nodes.length < totalMatched;
|
|
230
|
+
const paginationInfo = formatPaginationInfo({
|
|
231
|
+
limit,
|
|
232
|
+
offset,
|
|
233
|
+
returned: nodes.length,
|
|
234
|
+
total: totalMatched,
|
|
235
|
+
hasMore,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
return textResult(
|
|
239
|
+
`Found ${totalMatched} node(s):${paginationInfo}\n\n${JSON.stringify(
|
|
240
|
+
serializeBigInt(nodes),
|
|
241
|
+
null,
|
|
242
|
+
2
|
|
243
|
+
)}`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// === TRACE HANDLERS ===
|
|
248
|
+
|
|
249
|
+
export async function handleTraceAlias(args: TraceAliasArgs): Promise<ToolResult> {
|
|
250
|
+
const db = await ensureAnalyzed();
|
|
251
|
+
const { identifier: variableName, file } = args;
|
|
252
|
+
const projectPath = getProjectPath();
|
|
253
|
+
|
|
254
|
+
let varNode: GraphNode | null = null;
|
|
255
|
+
|
|
256
|
+
for await (const node of db.queryNodes({ type: 'VARIABLE' })) {
|
|
257
|
+
if (node.name === variableName && node.file?.includes(file || '')) {
|
|
258
|
+
varNode = node;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!varNode) {
|
|
264
|
+
for await (const node of db.queryNodes({ type: 'CONSTANT' })) {
|
|
265
|
+
if (node.name === variableName && node.file?.includes(file || '')) {
|
|
266
|
+
varNode = node;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!varNode) {
|
|
273
|
+
return errorResult(`Variable "${variableName}" not found in ${file || 'project'}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const chain: unknown[] = [];
|
|
277
|
+
const visited = new Set<string>();
|
|
278
|
+
let current: GraphNode | null = varNode;
|
|
279
|
+
const MAX_DEPTH = 20;
|
|
280
|
+
|
|
281
|
+
while (current && chain.length < MAX_DEPTH) {
|
|
282
|
+
if (visited.has(current.id)) {
|
|
283
|
+
chain.push({ type: 'CYCLE_DETECTED', id: current.id });
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
visited.add(current.id);
|
|
287
|
+
|
|
288
|
+
chain.push({
|
|
289
|
+
type: current.type,
|
|
290
|
+
name: current.name,
|
|
291
|
+
file: current.file,
|
|
292
|
+
line: current.line,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const edges = await db.getOutgoingEdges(current.id, ['ASSIGNED_FROM']);
|
|
296
|
+
if (edges.length === 0) break;
|
|
297
|
+
|
|
298
|
+
current = await db.getNode(edges[0].dst);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return textResult(
|
|
302
|
+
`Alias chain for "${variableName}" (${chain.length} steps):\n\n${JSON.stringify(
|
|
303
|
+
serializeBigInt(chain),
|
|
304
|
+
null,
|
|
305
|
+
2
|
|
306
|
+
)}`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function handleTraceDataFlow(args: TraceDataFlowArgs): Promise<ToolResult> {
|
|
311
|
+
const db = await ensureAnalyzed();
|
|
312
|
+
const { source, direction = 'forward', max_depth = 10 } = args;
|
|
313
|
+
|
|
314
|
+
// Find source node
|
|
315
|
+
let sourceNode: GraphNode | null = null;
|
|
316
|
+
|
|
317
|
+
// Try to find by ID first
|
|
318
|
+
sourceNode = await db.getNode(source);
|
|
319
|
+
|
|
320
|
+
// If not found, search by name
|
|
321
|
+
if (!sourceNode) {
|
|
322
|
+
for await (const node of db.queryNodes({ name: source })) {
|
|
323
|
+
sourceNode = node;
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!sourceNode) {
|
|
329
|
+
return errorResult(`Source "${source}" not found`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const visited = new Set<string>();
|
|
333
|
+
const paths: unknown[] = [];
|
|
334
|
+
|
|
335
|
+
async function trace(nodeId: string, depth: number, path: string[]): Promise<void> {
|
|
336
|
+
if (depth > max_depth || visited.has(nodeId)) return;
|
|
337
|
+
visited.add(nodeId);
|
|
338
|
+
|
|
339
|
+
const newPath = [...path, nodeId];
|
|
340
|
+
|
|
341
|
+
if (direction === 'forward' || direction === 'both') {
|
|
342
|
+
const outEdges = await db.getOutgoingEdges(nodeId, [
|
|
343
|
+
'ASSIGNED_FROM',
|
|
344
|
+
'DERIVES_FROM',
|
|
345
|
+
'PASSES_ARGUMENT',
|
|
346
|
+
]);
|
|
347
|
+
for (const edge of outEdges) {
|
|
348
|
+
await trace(edge.dst, depth + 1, newPath);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (direction === 'backward' || direction === 'both') {
|
|
353
|
+
const inEdges = await db.getIncomingEdges(nodeId, [
|
|
354
|
+
'ASSIGNED_FROM',
|
|
355
|
+
'DERIVES_FROM',
|
|
356
|
+
'PASSES_ARGUMENT',
|
|
357
|
+
]);
|
|
358
|
+
for (const edge of inEdges) {
|
|
359
|
+
await trace(edge.src, depth + 1, newPath);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (depth > 0) {
|
|
364
|
+
paths.push(newPath);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
await trace(sourceNode.id, 0, []);
|
|
369
|
+
|
|
370
|
+
return textResult(
|
|
371
|
+
`Data flow from "${source}" (${paths.length} paths):\n\n${JSON.stringify(paths, null, 2)}`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function handleCheckInvariant(args: CheckInvariantArgs): Promise<ToolResult> {
|
|
376
|
+
const db = await ensureAnalyzed();
|
|
377
|
+
const { rule, name: description } = args;
|
|
378
|
+
|
|
379
|
+
if (!('checkGuarantee' in db)) {
|
|
380
|
+
return errorResult('Backend does not support Datalog queries');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
const checkFn = (db as unknown as { checkGuarantee: (q: string) => Promise<Array<{ bindings: Array<{ name: string; value: string }> }>> }).checkGuarantee;
|
|
385
|
+
const violations = await checkFn(rule);
|
|
386
|
+
const total = violations.length;
|
|
387
|
+
|
|
388
|
+
if (total === 0) {
|
|
389
|
+
return textResult(`✅ Invariant holds: ${description || 'No violations found'}`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const enrichedViolations: unknown[] = [];
|
|
393
|
+
for (const v of violations.slice(0, 20)) {
|
|
394
|
+
const nodeId = v.bindings?.find((b: any) => b.name === 'X')?.value;
|
|
395
|
+
if (nodeId) {
|
|
396
|
+
const node = await db.getNode(nodeId);
|
|
397
|
+
if (node) {
|
|
398
|
+
enrichedViolations.push({
|
|
399
|
+
id: nodeId,
|
|
400
|
+
type: node.type,
|
|
401
|
+
name: node.name,
|
|
402
|
+
file: node.file,
|
|
403
|
+
line: node.line,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return textResult(
|
|
410
|
+
`❌ ${total} violation(s) found:\n\n${JSON.stringify(
|
|
411
|
+
serializeBigInt(enrichedViolations),
|
|
412
|
+
null,
|
|
413
|
+
2
|
|
414
|
+
)}${total > 20 ? `\n\n... and ${total - 20} more` : ''}`
|
|
415
|
+
);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
return errorResult((error as Error).message);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// === ANALYSIS HANDLERS ===
|
|
422
|
+
|
|
423
|
+
export async function handleAnalyzeProject(args: AnalyzeProjectArgs): Promise<ToolResult> {
|
|
424
|
+
const { service, force } = args;
|
|
425
|
+
|
|
426
|
+
if (force) {
|
|
427
|
+
setIsAnalyzed(false);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
await ensureAnalyzed(service || null);
|
|
432
|
+
const status = getAnalysisStatus();
|
|
433
|
+
|
|
434
|
+
return textResult(
|
|
435
|
+
`✅ Analysis complete!\n` +
|
|
436
|
+
`- Services discovered: ${status.servicesDiscovered}\n` +
|
|
437
|
+
`- Services analyzed: ${status.servicesAnalyzed}\n` +
|
|
438
|
+
`- Total time: ${status.timings.total || 'N/A'}s`
|
|
439
|
+
);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
return errorResult((error as Error).message);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export async function handleGetAnalysisStatus(): Promise<ToolResult> {
|
|
446
|
+
const status = getAnalysisStatus();
|
|
447
|
+
|
|
448
|
+
return textResult(
|
|
449
|
+
`Analysis Status:\n` +
|
|
450
|
+
`- Running: ${status.running}\n` +
|
|
451
|
+
`- Phase: ${status.phase || 'N/A'}\n` +
|
|
452
|
+
`- Message: ${status.message || 'N/A'}\n` +
|
|
453
|
+
`- Services discovered: ${status.servicesDiscovered}\n` +
|
|
454
|
+
`- Services analyzed: ${status.servicesAnalyzed}\n` +
|
|
455
|
+
(status.error ? `- Error: ${status.error}\n` : '')
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export async function handleGetStats(): Promise<ToolResult> {
|
|
460
|
+
const db = await getOrCreateBackend();
|
|
461
|
+
|
|
462
|
+
const nodeCount = await db.nodeCount();
|
|
463
|
+
const edgeCount = await db.edgeCount();
|
|
464
|
+
const nodesByType = await db.countNodesByType();
|
|
465
|
+
const edgesByType = await db.countEdgesByType();
|
|
466
|
+
|
|
467
|
+
return textResult(
|
|
468
|
+
`Graph Statistics:\n\n` +
|
|
469
|
+
`Total nodes: ${nodeCount.toLocaleString()}\n` +
|
|
470
|
+
`Total edges: ${edgeCount.toLocaleString()}\n\n` +
|
|
471
|
+
`Nodes by type:\n${JSON.stringify(nodesByType, null, 2)}\n\n` +
|
|
472
|
+
`Edges by type:\n${JSON.stringify(edgesByType, null, 2)}`
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export async function handleGetSchema(args: GetSchemaArgs): Promise<ToolResult> {
|
|
477
|
+
const db = await getOrCreateBackend();
|
|
478
|
+
const { type = 'all' } = args;
|
|
479
|
+
|
|
480
|
+
const nodesByType = await db.countNodesByType();
|
|
481
|
+
const edgesByType = await db.countEdgesByType();
|
|
482
|
+
|
|
483
|
+
let output = '';
|
|
484
|
+
|
|
485
|
+
if (type === 'nodes' || type === 'all') {
|
|
486
|
+
output += `Node Types (${Object.keys(nodesByType).length}):\n`;
|
|
487
|
+
for (const [t, count] of Object.entries(nodesByType)) {
|
|
488
|
+
output += ` - ${t}: ${count}\n`;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (type === 'edges' || type === 'all') {
|
|
493
|
+
output += `\nEdge Types (${Object.keys(edgesByType).length}):\n`;
|
|
494
|
+
for (const [t, count] of Object.entries(edgesByType)) {
|
|
495
|
+
output += ` - ${t}: ${count}\n`;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return textResult(output);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// === GUARANTEE HANDLERS ===
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Create a new guarantee (Datalog-based or contract-based)
|
|
506
|
+
*/
|
|
507
|
+
export async function handleCreateGuarantee(args: CreateGuaranteeArgs): Promise<ToolResult> {
|
|
508
|
+
await getOrCreateBackend(); // Ensure managers are initialized
|
|
509
|
+
|
|
510
|
+
const { name, rule, type, priority, status, owner, schema, condition, description, governs, severity } = args;
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
// Determine if this is a contract-based guarantee
|
|
514
|
+
if (type && isGuaranteeType(type)) {
|
|
515
|
+
// Contract-based guarantee
|
|
516
|
+
const api = getGuaranteeAPI();
|
|
517
|
+
if (!api) {
|
|
518
|
+
return errorResult('GuaranteeAPI not initialized');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const guarantee = await api.createGuarantee({
|
|
522
|
+
type,
|
|
523
|
+
name,
|
|
524
|
+
priority,
|
|
525
|
+
status,
|
|
526
|
+
owner,
|
|
527
|
+
schema,
|
|
528
|
+
condition,
|
|
529
|
+
description,
|
|
530
|
+
governs,
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
return textResult(
|
|
534
|
+
`✅ Created contract-based guarantee: ${guarantee.id}\n` +
|
|
535
|
+
`Type: ${guarantee.type}\n` +
|
|
536
|
+
`Priority: ${guarantee.priority}\n` +
|
|
537
|
+
`Status: ${guarantee.status}` +
|
|
538
|
+
(guarantee.description ? `\nDescription: ${guarantee.description}` : '')
|
|
539
|
+
);
|
|
540
|
+
} else {
|
|
541
|
+
// Datalog-based guarantee
|
|
542
|
+
if (!rule) {
|
|
543
|
+
return errorResult('Datalog-based guarantee requires "rule" field');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const manager = getGuaranteeManager();
|
|
547
|
+
if (!manager) {
|
|
548
|
+
return errorResult('GuaranteeManager not initialized');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const guarantee = await manager.create({
|
|
552
|
+
id: name,
|
|
553
|
+
name,
|
|
554
|
+
rule,
|
|
555
|
+
severity: severity || 'warning',
|
|
556
|
+
governs: governs || ['**/*.js'],
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
return textResult(
|
|
560
|
+
`✅ Created Datalog-based guarantee: ${guarantee.id}\n` +
|
|
561
|
+
`Rule: ${guarantee.rule}\n` +
|
|
562
|
+
`Severity: ${guarantee.severity}`
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
} catch (error) {
|
|
566
|
+
return errorResult(`Failed to create guarantee: ${(error as Error).message}`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* List all guarantees (both Datalog-based and contract-based)
|
|
572
|
+
*/
|
|
573
|
+
export async function handleListGuarantees(): Promise<ToolResult> {
|
|
574
|
+
await getOrCreateBackend(); // Ensure managers are initialized
|
|
575
|
+
|
|
576
|
+
const results: string[] = [];
|
|
577
|
+
|
|
578
|
+
try {
|
|
579
|
+
// List Datalog-based guarantees
|
|
580
|
+
const manager = getGuaranteeManager();
|
|
581
|
+
if (manager) {
|
|
582
|
+
const datalogGuarantees = await manager.list();
|
|
583
|
+
if (datalogGuarantees.length > 0) {
|
|
584
|
+
results.push('## Datalog-based Guarantees\n');
|
|
585
|
+
for (const g of datalogGuarantees) {
|
|
586
|
+
results.push(`- **${g.id}** (${g.severity})`);
|
|
587
|
+
results.push(` Rule: ${g.rule.substring(0, 80)}${g.rule.length > 80 ? '...' : ''}`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// List contract-based guarantees
|
|
593
|
+
const api = getGuaranteeAPI();
|
|
594
|
+
if (api) {
|
|
595
|
+
const contractGuarantees = await api.findGuarantees();
|
|
596
|
+
if (contractGuarantees.length > 0) {
|
|
597
|
+
if (results.length > 0) results.push('\n');
|
|
598
|
+
results.push('## Contract-based Guarantees\n');
|
|
599
|
+
for (const g of contractGuarantees) {
|
|
600
|
+
results.push(`- **${g.id}** [${g.priority}] (${g.status})`);
|
|
601
|
+
if (g.description) results.push(` ${g.description}`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (results.length === 0) {
|
|
607
|
+
return textResult('No guarantees defined yet.');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return textResult(results.join('\n'));
|
|
611
|
+
} catch (error) {
|
|
612
|
+
return errorResult(`Failed to list guarantees: ${(error as Error).message}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Check guarantees (both Datalog-based and contract-based)
|
|
618
|
+
*/
|
|
619
|
+
export async function handleCheckGuarantees(args: CheckGuaranteesArgs): Promise<ToolResult> {
|
|
620
|
+
await getOrCreateBackend(); // Ensure managers are initialized
|
|
621
|
+
|
|
622
|
+
const { names } = args;
|
|
623
|
+
const results: string[] = [];
|
|
624
|
+
let totalPassed = 0;
|
|
625
|
+
let totalFailed = 0;
|
|
626
|
+
|
|
627
|
+
try {
|
|
628
|
+
const manager = getGuaranteeManager();
|
|
629
|
+
const api = getGuaranteeAPI();
|
|
630
|
+
|
|
631
|
+
if (names && names.length > 0) {
|
|
632
|
+
// Check specific guarantees
|
|
633
|
+
for (const name of names) {
|
|
634
|
+
// Try Datalog-based first
|
|
635
|
+
if (manager) {
|
|
636
|
+
try {
|
|
637
|
+
const result = await manager.check(name);
|
|
638
|
+
if (result.passed) {
|
|
639
|
+
totalPassed++;
|
|
640
|
+
results.push(`✅ ${result.guaranteeId}: PASSED`);
|
|
641
|
+
} else {
|
|
642
|
+
totalFailed++;
|
|
643
|
+
results.push(`❌ ${result.guaranteeId}: FAILED (${result.violationCount} violations)`);
|
|
644
|
+
for (const v of result.violations.slice(0, 5)) {
|
|
645
|
+
results.push(` - ${v.file}:${v.line} (${v.type})`);
|
|
646
|
+
}
|
|
647
|
+
if (result.violationCount > 5) {
|
|
648
|
+
results.push(` ... and ${result.violationCount - 5} more`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
continue;
|
|
652
|
+
} catch {
|
|
653
|
+
// Not a Datalog guarantee, try contract-based
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Try contract-based
|
|
658
|
+
if (api) {
|
|
659
|
+
try {
|
|
660
|
+
const result = await api.checkGuarantee(name);
|
|
661
|
+
if (result.passed) {
|
|
662
|
+
totalPassed++;
|
|
663
|
+
results.push(`✅ ${result.id}: PASSED`);
|
|
664
|
+
} else {
|
|
665
|
+
totalFailed++;
|
|
666
|
+
results.push(`❌ ${result.id}: FAILED`);
|
|
667
|
+
for (const err of result.errors.slice(0, 5)) {
|
|
668
|
+
results.push(` - ${err}`);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
} catch {
|
|
672
|
+
results.push(`⚠️ ${name}: Not found`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
} else {
|
|
677
|
+
// Check all guarantees
|
|
678
|
+
if (manager) {
|
|
679
|
+
const datalogResult = await manager.checkAll();
|
|
680
|
+
totalPassed += datalogResult.passed;
|
|
681
|
+
totalFailed += datalogResult.failed;
|
|
682
|
+
|
|
683
|
+
if (datalogResult.total > 0) {
|
|
684
|
+
results.push('## Datalog Guarantees\n');
|
|
685
|
+
for (const r of datalogResult.results) {
|
|
686
|
+
if (r.passed) {
|
|
687
|
+
results.push(`✅ ${r.guaranteeId}: PASSED`);
|
|
688
|
+
} else {
|
|
689
|
+
results.push(`❌ ${r.guaranteeId}: FAILED (${r.violationCount} violations)`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (api) {
|
|
696
|
+
const contractResult = await api.checkAllGuarantees();
|
|
697
|
+
totalPassed += contractResult.passed;
|
|
698
|
+
totalFailed += contractResult.failed;
|
|
699
|
+
|
|
700
|
+
if (contractResult.total > 0) {
|
|
701
|
+
if (results.length > 0) results.push('\n');
|
|
702
|
+
results.push('## Contract Guarantees\n');
|
|
703
|
+
for (const r of contractResult.results) {
|
|
704
|
+
if (r.passed) {
|
|
705
|
+
results.push(`✅ ${r.id}: PASSED`);
|
|
706
|
+
} else {
|
|
707
|
+
results.push(`❌ ${r.id}: FAILED`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (results.length === 0) {
|
|
715
|
+
return textResult('No guarantees to check.');
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const summary = `\n---\nTotal: ${totalPassed + totalFailed} | ✅ Passed: ${totalPassed} | ❌ Failed: ${totalFailed}`;
|
|
719
|
+
return textResult(results.join('\n') + summary);
|
|
720
|
+
} catch (error) {
|
|
721
|
+
return errorResult(`Failed to check guarantees: ${(error as Error).message}`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Delete a guarantee
|
|
727
|
+
*/
|
|
728
|
+
export async function handleDeleteGuarantee(args: DeleteGuaranteeArgs): Promise<ToolResult> {
|
|
729
|
+
await getOrCreateBackend(); // Ensure managers are initialized
|
|
730
|
+
|
|
731
|
+
const { name } = args;
|
|
732
|
+
|
|
733
|
+
try {
|
|
734
|
+
// Try Datalog-based first
|
|
735
|
+
const manager = getGuaranteeManager();
|
|
736
|
+
if (manager) {
|
|
737
|
+
try {
|
|
738
|
+
await manager.delete(name);
|
|
739
|
+
return textResult(`✅ Deleted Datalog guarantee: ${name}`);
|
|
740
|
+
} catch {
|
|
741
|
+
// Not found in Datalog, try contract-based
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Try contract-based
|
|
746
|
+
const api = getGuaranteeAPI();
|
|
747
|
+
if (api) {
|
|
748
|
+
const deleted = await api.deleteGuarantee(name);
|
|
749
|
+
if (deleted) {
|
|
750
|
+
return textResult(`✅ Deleted contract guarantee: ${name}`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return errorResult(`Guarantee not found: ${name}`);
|
|
755
|
+
} catch (error) {
|
|
756
|
+
return errorResult(`Failed to delete guarantee: ${(error as Error).message}`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// === COVERAGE & DOCS ===
|
|
761
|
+
|
|
762
|
+
export async function handleGetCoverage(args: GetCoverageArgs): Promise<ToolResult> {
|
|
763
|
+
const db = await getOrCreateBackend();
|
|
764
|
+
const projectPath = getProjectPath();
|
|
765
|
+
const { path: targetPath = projectPath } = args;
|
|
766
|
+
|
|
767
|
+
const nodeCount = await db.nodeCount();
|
|
768
|
+
const moduleNodes = db.findByType ? await db.findByType('MODULE') : [];
|
|
769
|
+
|
|
770
|
+
return textResult(
|
|
771
|
+
`Coverage for ${targetPath}:\n` +
|
|
772
|
+
`- Analyzed files: ${moduleNodes.length}\n` +
|
|
773
|
+
`- Total nodes: ${nodeCount}\n`
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
export async function handleGetDocumentation(args: GetDocumentationArgs): Promise<ToolResult> {
|
|
778
|
+
const { topic = 'overview' } = args;
|
|
779
|
+
|
|
780
|
+
const docs: Record<string, string> = {
|
|
781
|
+
overview: `
|
|
782
|
+
# Grafema Code Analysis
|
|
783
|
+
|
|
784
|
+
Grafema is a static code analyzer that builds a graph of your codebase.
|
|
785
|
+
|
|
786
|
+
## Key Tools
|
|
787
|
+
- query_graph: Execute Datalog queries
|
|
788
|
+
- find_calls: Find function/method calls
|
|
789
|
+
- trace_alias: Trace variable aliases
|
|
790
|
+
- check_invariant: Verify code invariants
|
|
791
|
+
|
|
792
|
+
## Quick Start
|
|
793
|
+
1. Use get_stats to see graph size
|
|
794
|
+
2. Use find_nodes to explore the codebase
|
|
795
|
+
3. Use query_graph for complex queries
|
|
796
|
+
`,
|
|
797
|
+
queries: `
|
|
798
|
+
# Datalog Queries
|
|
799
|
+
|
|
800
|
+
## Syntax
|
|
801
|
+
violation(X) :- node(X, "TYPE"), attr(X, "name", "value").
|
|
802
|
+
|
|
803
|
+
## Available Predicates
|
|
804
|
+
- node(Id, Type) - match nodes
|
|
805
|
+
- edge(Src, Dst, Type) - match edges
|
|
806
|
+
- attr(Id, Name, Value) - match attributes
|
|
807
|
+
- \\+ - negation (not)
|
|
808
|
+
|
|
809
|
+
## Examples
|
|
810
|
+
Find all functions:
|
|
811
|
+
violation(X) :- node(X, "FUNCTION").
|
|
812
|
+
|
|
813
|
+
Find unresolved calls:
|
|
814
|
+
violation(X) :- node(X, "CALL"), \\+ edge(X, _, "CALLS").
|
|
815
|
+
`,
|
|
816
|
+
types: `
|
|
817
|
+
# Node & Edge Types
|
|
818
|
+
|
|
819
|
+
## Core Node Types
|
|
820
|
+
- MODULE, FUNCTION, CLASS, METHOD, VARIABLE
|
|
821
|
+
- CALL, IMPORT, EXPORT, PARAMETER
|
|
822
|
+
|
|
823
|
+
## HTTP/Network
|
|
824
|
+
- http:route, http:request, db:query
|
|
825
|
+
|
|
826
|
+
## Edge Types
|
|
827
|
+
- CONTAINS, CALLS, DEPENDS_ON
|
|
828
|
+
- ASSIGNED_FROM, INSTANCE_OF, PASSES_ARGUMENT
|
|
829
|
+
`,
|
|
830
|
+
guarantees: `
|
|
831
|
+
# Code Guarantees
|
|
832
|
+
|
|
833
|
+
Guarantees are persistent code invariants.
|
|
834
|
+
|
|
835
|
+
## Create
|
|
836
|
+
Use create_guarantee with a name and Datalog rule.
|
|
837
|
+
|
|
838
|
+
## Check
|
|
839
|
+
Use check_guarantees to verify all guarantees.
|
|
840
|
+
|
|
841
|
+
## Example
|
|
842
|
+
Name: no-eval
|
|
843
|
+
Rule: violation(X) :- node(X, "CALL"), attr(X, "name", "eval").
|
|
844
|
+
`,
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
const content = docs[topic] || docs.overview;
|
|
848
|
+
return textResult(content.trim());
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// === BUG REPORTING ===
|
|
852
|
+
|
|
853
|
+
export async function handleReportIssue(args: import('./types.js').ReportIssueArgs): Promise<ToolResult> {
|
|
854
|
+
const { title, description, context, labels = ['bug'] } = args;
|
|
855
|
+
const githubToken = process.env.GITHUB_TOKEN;
|
|
856
|
+
const repo = 'Disentinel/grafema';
|
|
857
|
+
|
|
858
|
+
// Build issue body
|
|
859
|
+
const body = `## Description
|
|
860
|
+
${description}
|
|
861
|
+
|
|
862
|
+
${context ? `## Context\n\`\`\`\n${context}\n\`\`\`\n` : ''}
|
|
863
|
+
## Environment
|
|
864
|
+
- Grafema version: 0.1.0-alpha.1
|
|
865
|
+
- Reported via: MCP tool
|
|
866
|
+
|
|
867
|
+
---
|
|
868
|
+
*This issue was automatically created via Grafema MCP server.*`;
|
|
869
|
+
|
|
870
|
+
// Try GitHub API if token is available
|
|
871
|
+
if (githubToken) {
|
|
872
|
+
try {
|
|
873
|
+
const response = await fetch(`https://api.github.com/repos/${repo}/issues`, {
|
|
874
|
+
method: 'POST',
|
|
875
|
+
headers: {
|
|
876
|
+
'Authorization': `token ${githubToken}`,
|
|
877
|
+
'Content-Type': 'application/json',
|
|
878
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
879
|
+
},
|
|
880
|
+
body: JSON.stringify({
|
|
881
|
+
title,
|
|
882
|
+
body,
|
|
883
|
+
labels: labels.filter(l => ['bug', 'enhancement', 'documentation', 'question'].includes(l)),
|
|
884
|
+
}),
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
if (response.ok) {
|
|
888
|
+
const issue = await response.json() as { html_url: string; number: number };
|
|
889
|
+
return textResult(
|
|
890
|
+
`✅ Issue created successfully!\n\n` +
|
|
891
|
+
`**Issue #${issue.number}**: ${issue.html_url}\n\n` +
|
|
892
|
+
`Thank you for reporting this issue.`
|
|
893
|
+
);
|
|
894
|
+
} else {
|
|
895
|
+
const error = await response.text();
|
|
896
|
+
throw new Error(`GitHub API error: ${response.status} - ${error}`);
|
|
897
|
+
}
|
|
898
|
+
} catch (error) {
|
|
899
|
+
// Fall through to manual template if API fails
|
|
900
|
+
console.error('[report_issue] GitHub API failed:', error);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Fallback: return template for manual submission
|
|
905
|
+
const issueUrl = `https://github.com/${repo}/issues/new`;
|
|
906
|
+
const encodedTitle = encodeURIComponent(title);
|
|
907
|
+
const encodedBody = encodeURIComponent(body);
|
|
908
|
+
const encodedLabels = encodeURIComponent(labels.join(','));
|
|
909
|
+
const directUrl = `${issueUrl}?title=${encodedTitle}&body=${encodedBody}&labels=${encodedLabels}`;
|
|
910
|
+
|
|
911
|
+
return textResult(
|
|
912
|
+
`⚠️ GITHUB_TOKEN not configured. Please create the issue manually:\n\n` +
|
|
913
|
+
`**Quick link** (may truncate long descriptions):\n${directUrl}\n\n` +
|
|
914
|
+
`**Or copy this template to** ${issueUrl}:\n\n` +
|
|
915
|
+
`---\n**Title:** ${title}\n\n${body}\n---\n\n` +
|
|
916
|
+
`To enable automatic issue creation, set GITHUB_TOKEN environment variable.`
|
|
917
|
+
);
|
|
918
|
+
}
|