ai-mind-map 1.12.16 → 1.13.0
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/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/knowledge-graph/parser.d.ts.map +1 -1
- package/dist/knowledge-graph/parser.js +5 -64
- package/dist/knowledge-graph/parser.js.map +1 -1
- package/dist/tools/quality-tools.d.ts +32 -0
- package/dist/tools/quality-tools.d.ts.map +1 -0
- package/dist/tools/quality-tools.js +747 -0
- package/dist/tools/quality-tools.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Mind Map — Quality MCP Tools
|
|
3
|
+
*
|
|
4
|
+
* Registers code-quality analysis tools on the MCP server:
|
|
5
|
+
* 1. mindmap_code_metrics – cyclomatic/cognitive complexity & health scoring
|
|
6
|
+
* 2. mindmap_security_scan – pattern-based SAST-lite vulnerability detection
|
|
7
|
+
* 3. mindmap_code_duplication – duplicated code block detection
|
|
8
|
+
*
|
|
9
|
+
* Each tool returns structured JSON wrapped in the standard MCP
|
|
10
|
+
* text-content envelope.
|
|
11
|
+
*/
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
14
|
+
import { resolve } from 'node:path';
|
|
15
|
+
/** Simple 4-chars-per-token estimator used as default. */
|
|
16
|
+
const defaultEstimator = {
|
|
17
|
+
estimate: (text) => Math.ceil(text.length / 4),
|
|
18
|
+
};
|
|
19
|
+
// ============================================================
|
|
20
|
+
// Helpers
|
|
21
|
+
// ============================================================
|
|
22
|
+
/**
|
|
23
|
+
* Wrap a ToolResult in the MCP text-content format.
|
|
24
|
+
*/
|
|
25
|
+
function mcpText(result) {
|
|
26
|
+
return {
|
|
27
|
+
content: [{ type: 'text', text: JSON.stringify(result) }],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Build a successful ToolResult.
|
|
32
|
+
*/
|
|
33
|
+
function ok(data, estimator) {
|
|
34
|
+
const serialised = JSON.stringify(data);
|
|
35
|
+
const tokens = estimator.estimate(serialised);
|
|
36
|
+
return { success: true, data, tokenCount: tokens, tokensSaved: 0 };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Build an error ToolResult.
|
|
40
|
+
*/
|
|
41
|
+
function fail(message) {
|
|
42
|
+
return { success: false, data: null, tokenCount: 0, tokensSaved: 0, message };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Check if a file appears to be binary by inspecting its first 512 bytes
|
|
46
|
+
* for null bytes.
|
|
47
|
+
*/
|
|
48
|
+
function isBinaryFile(absPath) {
|
|
49
|
+
try {
|
|
50
|
+
const fd = require('node:fs').openSync(absPath, 'r');
|
|
51
|
+
const buf = Buffer.alloc(512);
|
|
52
|
+
const bytesRead = require('node:fs').readSync(fd, buf, 0, 512, 0);
|
|
53
|
+
require('node:fs').closeSync(fd);
|
|
54
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
55
|
+
if (buf[i] === 0)
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Simple fast hash function for strings.
|
|
66
|
+
*/
|
|
67
|
+
function simpleHash(str) {
|
|
68
|
+
let hash = 0;
|
|
69
|
+
for (let i = 0; i < str.length; i++) {
|
|
70
|
+
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
|
71
|
+
}
|
|
72
|
+
return hash;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Clamp a number to [min, max].
|
|
76
|
+
*/
|
|
77
|
+
function clamp(value, min, max) {
|
|
78
|
+
return Math.max(min, Math.min(max, value));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Count decision-point patterns in a block of source code lines.
|
|
82
|
+
*/
|
|
83
|
+
function analyzeComplexity(bodyLines) {
|
|
84
|
+
let cyclomatic = 1; // base complexity
|
|
85
|
+
let cognitive = 0;
|
|
86
|
+
let currentNesting = 0;
|
|
87
|
+
let maxNesting = 0;
|
|
88
|
+
for (const rawLine of bodyLines) {
|
|
89
|
+
const line = rawLine.trim();
|
|
90
|
+
// Skip empty lines and single-line comments
|
|
91
|
+
if (!line || line.startsWith('//') || line.startsWith('#') || line.startsWith('*')) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// Track nesting via braces
|
|
95
|
+
const openBraces = (line.match(/\{/g) || []).length;
|
|
96
|
+
const closeBraces = (line.match(/\}/g) || []).length;
|
|
97
|
+
// Close braces first to handle `} else {` correctly
|
|
98
|
+
currentNesting -= closeBraces;
|
|
99
|
+
if (currentNesting < 0)
|
|
100
|
+
currentNesting = 0;
|
|
101
|
+
const nestLevel = Math.max(1, currentNesting + 1);
|
|
102
|
+
// Cyclomatic & cognitive complexity patterns
|
|
103
|
+
// if / else if / elif
|
|
104
|
+
if (/\b(if|else\s+if|elif)\b/.test(line)) {
|
|
105
|
+
cyclomatic += 1;
|
|
106
|
+
cognitive += nestLevel;
|
|
107
|
+
}
|
|
108
|
+
// for / foreach / while / do
|
|
109
|
+
if (/\b(for|foreach|while|do)\b/.test(line) && !/\bdo\s*\{?\s*$/.test(line) === false) {
|
|
110
|
+
// Match loop keywords
|
|
111
|
+
}
|
|
112
|
+
if (/\b(for|foreach|while)\b/.test(line)) {
|
|
113
|
+
cyclomatic += 1;
|
|
114
|
+
cognitive += nestLevel;
|
|
115
|
+
}
|
|
116
|
+
if (/\bdo\b/.test(line) && !/\bdo\s*\(/.test(line)) {
|
|
117
|
+
// 'do' keyword for do-while (but not Ruby do blocks with parens)
|
|
118
|
+
cyclomatic += 1;
|
|
119
|
+
cognitive += nestLevel;
|
|
120
|
+
}
|
|
121
|
+
// switch case
|
|
122
|
+
if (/\bcase\b/.test(line) && !/\blowerCase\b|\bupperCase\b|\bcamelCase\b/.test(line)) {
|
|
123
|
+
cyclomatic += 1;
|
|
124
|
+
}
|
|
125
|
+
// catch / except
|
|
126
|
+
if (/\b(catch|except)\b/.test(line)) {
|
|
127
|
+
cyclomatic += 1;
|
|
128
|
+
cognitive += 1;
|
|
129
|
+
}
|
|
130
|
+
// Logical operators
|
|
131
|
+
const andOr = line.match(/&&|\|\||\band\b|\bor\b/g);
|
|
132
|
+
if (andOr) {
|
|
133
|
+
cyclomatic += andOr.length;
|
|
134
|
+
}
|
|
135
|
+
// Ternary operator
|
|
136
|
+
const ternary = line.match(/\?(?!=)/g);
|
|
137
|
+
if (ternary) {
|
|
138
|
+
// Filter out ?. (optional chaining) and ?? (nullish coalescing)
|
|
139
|
+
for (const _match of ternary) {
|
|
140
|
+
const idx = line.indexOf('?');
|
|
141
|
+
if (idx >= 0) {
|
|
142
|
+
const nextChar = line[idx + 1];
|
|
143
|
+
if (nextChar !== '.' && nextChar !== '?') {
|
|
144
|
+
cyclomatic += 1;
|
|
145
|
+
cognitive += 1;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Update nesting after processing the line
|
|
151
|
+
currentNesting += openBraces;
|
|
152
|
+
if (currentNesting > maxNesting) {
|
|
153
|
+
maxNesting = currentNesting;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { cyclomatic, cognitive, maxNesting };
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Extract parameter count from a function signature.
|
|
160
|
+
*/
|
|
161
|
+
function countParams(signature) {
|
|
162
|
+
// Find content inside first pair of parentheses
|
|
163
|
+
const match = signature.match(/\(([^)]*)\)/);
|
|
164
|
+
if (!match || !match[1].trim())
|
|
165
|
+
return 0;
|
|
166
|
+
// Count commas + 1
|
|
167
|
+
return match[1].split(',').filter(p => p.trim().length > 0).length;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Calculate health score from metrics.
|
|
171
|
+
*/
|
|
172
|
+
function calculateHealthScore(cyclomatic, cognitive, loc, params, nesting) {
|
|
173
|
+
let score = 10;
|
|
174
|
+
if (cyclomatic > 10)
|
|
175
|
+
score -= 2;
|
|
176
|
+
if (cyclomatic > 20)
|
|
177
|
+
score -= 2;
|
|
178
|
+
if (cognitive > 15)
|
|
179
|
+
score -= 2;
|
|
180
|
+
if (cognitive > 30)
|
|
181
|
+
score -= 2;
|
|
182
|
+
if (loc > 100)
|
|
183
|
+
score -= 1;
|
|
184
|
+
if (loc > 200)
|
|
185
|
+
score -= 1;
|
|
186
|
+
if (params > 5)
|
|
187
|
+
score -= 1;
|
|
188
|
+
if (nesting > 4)
|
|
189
|
+
score -= 1;
|
|
190
|
+
return clamp(score, 1, 10);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Determine risk level from health score.
|
|
194
|
+
*/
|
|
195
|
+
function riskLevel(score) {
|
|
196
|
+
if (score >= 8)
|
|
197
|
+
return 'low';
|
|
198
|
+
if (score >= 5)
|
|
199
|
+
return 'medium';
|
|
200
|
+
if (score >= 3)
|
|
201
|
+
return 'high';
|
|
202
|
+
return 'critical';
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Analyze a set of graph nodes for complexity metrics.
|
|
206
|
+
* Reads source files and calculates per-function metrics.
|
|
207
|
+
*/
|
|
208
|
+
function analyzeFunctions(nodes, config) {
|
|
209
|
+
const functionTypes = new Set([
|
|
210
|
+
'function', 'method', 'constructor', 'hook',
|
|
211
|
+
]);
|
|
212
|
+
const functionNodes = nodes.filter(n => functionTypes.has(n.type));
|
|
213
|
+
const metrics = [];
|
|
214
|
+
// Group by file to avoid re-reading the same file
|
|
215
|
+
const byFile = new Map();
|
|
216
|
+
for (const node of functionNodes) {
|
|
217
|
+
const list = byFile.get(node.filePath) || [];
|
|
218
|
+
list.push(node);
|
|
219
|
+
byFile.set(node.filePath, list);
|
|
220
|
+
}
|
|
221
|
+
for (const [filePath, fnNodes] of byFile) {
|
|
222
|
+
const absPath = resolve(config.projectRoot, filePath);
|
|
223
|
+
let fileLines;
|
|
224
|
+
try {
|
|
225
|
+
const content = readFileSync(absPath, 'utf-8');
|
|
226
|
+
fileLines = content.split('\n');
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
continue; // Skip unreadable files
|
|
230
|
+
}
|
|
231
|
+
for (const node of fnNodes) {
|
|
232
|
+
const startIdx = Math.max(0, node.startLine - 1);
|
|
233
|
+
const endIdx = Math.min(fileLines.length, node.endLine);
|
|
234
|
+
const bodyLines = fileLines.slice(startIdx, endIdx);
|
|
235
|
+
const loc = node.endLine - node.startLine;
|
|
236
|
+
const { cyclomatic, cognitive, maxNesting } = analyzeComplexity(bodyLines);
|
|
237
|
+
const params = node.parameters
|
|
238
|
+
? node.parameters.length
|
|
239
|
+
: countParams(node.signature);
|
|
240
|
+
const health = calculateHealthScore(cyclomatic, cognitive, loc, params, maxNesting);
|
|
241
|
+
metrics.push({
|
|
242
|
+
name: node.qualifiedName || node.name,
|
|
243
|
+
line: node.startLine,
|
|
244
|
+
cyclomaticComplexity: cyclomatic,
|
|
245
|
+
cognitiveComplexity: cognitive,
|
|
246
|
+
loc,
|
|
247
|
+
params,
|
|
248
|
+
maxNesting,
|
|
249
|
+
healthScore: health,
|
|
250
|
+
risk: riskLevel(health),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return metrics;
|
|
255
|
+
}
|
|
256
|
+
const VULN_PATTERNS = [
|
|
257
|
+
// Critical
|
|
258
|
+
{
|
|
259
|
+
severity: 'critical',
|
|
260
|
+
rule: 'hardcoded_password',
|
|
261
|
+
pattern: /password\s*=\s*['"][^'"]+['"]/i,
|
|
262
|
+
remediation: 'Use environment variables or a secrets manager',
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
severity: 'critical',
|
|
266
|
+
rule: 'hardcoded_api_key',
|
|
267
|
+
pattern: /(api_key|apikey|api_secret|secret_key)\s*=\s*['"][^'"]+['"]/i,
|
|
268
|
+
remediation: 'Use environment variables or a secrets manager',
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
severity: 'critical',
|
|
272
|
+
rule: 'private_key',
|
|
273
|
+
pattern: /-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/,
|
|
274
|
+
remediation: 'Remove private keys from source code. Use a key vault or secrets manager',
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
severity: 'critical',
|
|
278
|
+
rule: 'aws_access_key',
|
|
279
|
+
pattern: /AKIA[0-9A-Z]{16}/,
|
|
280
|
+
remediation: 'Remove AWS access keys. Use IAM roles or environment variables',
|
|
281
|
+
},
|
|
282
|
+
// High
|
|
283
|
+
{
|
|
284
|
+
severity: 'high',
|
|
285
|
+
rule: 'sql_injection',
|
|
286
|
+
pattern: /(query|execute|exec)\s*\([^)]*(\+|\$\{)/,
|
|
287
|
+
remediation: 'Use parameterized queries or prepared statements',
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
severity: 'high',
|
|
291
|
+
rule: 'sql_injection_fstring',
|
|
292
|
+
pattern: /f['"].*SELECT/i,
|
|
293
|
+
remediation: 'Use parameterized queries instead of f-strings for SQL',
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
severity: 'high',
|
|
297
|
+
rule: 'sql_injection_format',
|
|
298
|
+
pattern: /string\.Format.*SELECT/i,
|
|
299
|
+
remediation: 'Use parameterized queries instead of string.Format for SQL',
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
severity: 'high',
|
|
303
|
+
rule: 'command_injection',
|
|
304
|
+
pattern: /(exec|system|popen|subprocess\.call|Runtime\.exec)\s*\(/,
|
|
305
|
+
remediation: 'Validate and sanitize all inputs. Use allowlists for permitted commands',
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
severity: 'high',
|
|
309
|
+
rule: 'xss',
|
|
310
|
+
pattern: /innerHTML\s*=|document\.write\s*\(|dangerouslySetInnerHTML/,
|
|
311
|
+
remediation: 'Use textContent or a sanitization library like DOMPurify',
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
severity: 'high',
|
|
315
|
+
rule: 'eval_usage',
|
|
316
|
+
pattern: /\beval\s*\(|new\s+Function\s*\(/,
|
|
317
|
+
remediation: 'Avoid eval/Function constructor. Use safer alternatives like JSON.parse',
|
|
318
|
+
},
|
|
319
|
+
// Medium
|
|
320
|
+
{
|
|
321
|
+
severity: 'medium',
|
|
322
|
+
rule: 'weak_crypto',
|
|
323
|
+
pattern: /\b(MD5|SHA1|DES|RC4)\b/,
|
|
324
|
+
remediation: 'Use stronger algorithms: SHA-256+, AES-256, bcrypt/scrypt for passwords',
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
severity: 'medium',
|
|
328
|
+
rule: 'http_not_https',
|
|
329
|
+
pattern: /http:\/\/(?!localhost|127\.0\.0\.1|0\.0\.0\.0)/,
|
|
330
|
+
remediation: 'Use HTTPS for all external communication',
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
severity: 'medium',
|
|
334
|
+
rule: 'hardcoded_ip',
|
|
335
|
+
pattern: /\b(?!127\.0\.0\.1\b)(?!0\.0\.0\.0\b)\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/,
|
|
336
|
+
remediation: 'Use configuration or environment variables for IP addresses',
|
|
337
|
+
},
|
|
338
|
+
// Low
|
|
339
|
+
{
|
|
340
|
+
severity: 'low',
|
|
341
|
+
rule: 'security_todo',
|
|
342
|
+
pattern: /(TODO|FIXME|HACK|XXX).*(security|vuln|auth|password|encrypt)/i,
|
|
343
|
+
remediation: 'Address security-related TODOs before deployment',
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
severity: 'low',
|
|
347
|
+
rule: 'sensitive_log',
|
|
348
|
+
pattern: /console\.log.*(password|token|secret|key|credential)/i,
|
|
349
|
+
remediation: 'Remove sensitive data from log statements',
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
severity: 'low',
|
|
353
|
+
rule: 'disabled_ssl',
|
|
354
|
+
pattern: /verify\s*=\s*False|NODE_TLS_REJECT_UNAUTHORIZED/,
|
|
355
|
+
remediation: 'Enable SSL/TLS verification in production',
|
|
356
|
+
},
|
|
357
|
+
];
|
|
358
|
+
/**
|
|
359
|
+
* Check if a line is a comment.
|
|
360
|
+
*/
|
|
361
|
+
function isCommentLine(line) {
|
|
362
|
+
const trimmed = line.trim();
|
|
363
|
+
return (trimmed.startsWith('//') ||
|
|
364
|
+
trimmed.startsWith('#') ||
|
|
365
|
+
trimmed.startsWith('/*') ||
|
|
366
|
+
trimmed.startsWith('*') ||
|
|
367
|
+
trimmed.startsWith('<!--'));
|
|
368
|
+
}
|
|
369
|
+
// ============================================================
|
|
370
|
+
// Registration
|
|
371
|
+
// ============================================================
|
|
372
|
+
/**
|
|
373
|
+
* Register all Quality tools on the given MCP server.
|
|
374
|
+
*
|
|
375
|
+
* @param server The MCP server instance.
|
|
376
|
+
* @param graph Direct reference to the KnowledgeGraph.
|
|
377
|
+
* @param config The MindMapConfig for project settings.
|
|
378
|
+
* @param tokenEstimator Optional token estimator (defaults to 4-char heuristic).
|
|
379
|
+
*/
|
|
380
|
+
export function registerQualityTools(server, graph, config, tokenEstimator = defaultEstimator) {
|
|
381
|
+
// ── mindmap_code_metrics ──────────────────────────────────────
|
|
382
|
+
server.tool('mindmap_code_metrics', 'Calculate code complexity and health scoring for files/functions. ' +
|
|
383
|
+
'Returns cyclomatic complexity, cognitive complexity, LOC, parameter count, ' +
|
|
384
|
+
'nesting depth, health score (1-10), and risk level for each function.', {
|
|
385
|
+
filePath: z
|
|
386
|
+
.string()
|
|
387
|
+
.optional()
|
|
388
|
+
.describe('Analyze a specific file (relative to projectRoot)'),
|
|
389
|
+
symbol: z
|
|
390
|
+
.string()
|
|
391
|
+
.optional()
|
|
392
|
+
.describe('Analyze a specific function/method by name'),
|
|
393
|
+
top: z
|
|
394
|
+
.number()
|
|
395
|
+
.optional()
|
|
396
|
+
.default(20)
|
|
397
|
+
.describe('Show top N most complex functions (default: 20)'),
|
|
398
|
+
}, async ({ filePath, symbol, top }) => {
|
|
399
|
+
try {
|
|
400
|
+
let targetNodes;
|
|
401
|
+
let targetFile = filePath;
|
|
402
|
+
if (symbol) {
|
|
403
|
+
// Find symbol via graph search
|
|
404
|
+
const results = graph.search(symbol, 10);
|
|
405
|
+
const functionTypes = new Set([
|
|
406
|
+
'function', 'method', 'constructor', 'hook',
|
|
407
|
+
]);
|
|
408
|
+
targetNodes = results.filter(n => functionTypes.has(n.type));
|
|
409
|
+
if (targetNodes.length === 0) {
|
|
410
|
+
return mcpText(fail(`Symbol "${symbol}" not found in the knowledge graph`));
|
|
411
|
+
}
|
|
412
|
+
if (!targetFile) {
|
|
413
|
+
targetFile = targetNodes[0].filePath;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
else if (filePath) {
|
|
417
|
+
// Get all nodes for the file
|
|
418
|
+
targetNodes = graph.getFileStructure(filePath);
|
|
419
|
+
if (targetNodes.length === 0) {
|
|
420
|
+
// Try with resolved path relative to project root
|
|
421
|
+
const allFiles = graph.getIndexedFiles();
|
|
422
|
+
const match = allFiles.find(f => f === filePath || f.endsWith(filePath) || filePath.endsWith(f));
|
|
423
|
+
if (match) {
|
|
424
|
+
targetNodes = graph.getFileStructure(match);
|
|
425
|
+
targetFile = match;
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
return mcpText(fail(`File "${filePath}" not found in the index`));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
// Analyze all indexed files
|
|
434
|
+
targetNodes = graph.getAllNodes();
|
|
435
|
+
}
|
|
436
|
+
const metrics = analyzeFunctions(targetNodes, config);
|
|
437
|
+
// Sort by health score ascending (worst first), then by cyclomatic descending
|
|
438
|
+
metrics.sort((a, b) => a.healthScore - b.healthScore || b.cyclomaticComplexity - a.cyclomaticComplexity);
|
|
439
|
+
const topN = metrics.slice(0, top);
|
|
440
|
+
const totalFunctions = metrics.length;
|
|
441
|
+
const avgComplexity = totalFunctions > 0
|
|
442
|
+
? Math.round((metrics.reduce((s, m) => s + m.cyclomaticComplexity, 0) / totalFunctions) * 10) / 10
|
|
443
|
+
: 0;
|
|
444
|
+
const avgHealth = totalFunctions > 0
|
|
445
|
+
? Math.round((metrics.reduce((s, m) => s + m.healthScore, 0) / totalFunctions) * 10) / 10
|
|
446
|
+
: 0;
|
|
447
|
+
const criticalCount = metrics.filter(m => m.risk === 'critical').length;
|
|
448
|
+
const highRiskCount = metrics.filter(m => m.risk === 'high').length;
|
|
449
|
+
const fileHealthScore = totalFunctions > 0
|
|
450
|
+
? Math.round(avgHealth * 10) / 10
|
|
451
|
+
: 10;
|
|
452
|
+
const result = {
|
|
453
|
+
file: targetFile || '(all files)',
|
|
454
|
+
fileHealthScore,
|
|
455
|
+
totalFunctions,
|
|
456
|
+
functions: topN,
|
|
457
|
+
summary: {
|
|
458
|
+
avgComplexity,
|
|
459
|
+
avgHealth,
|
|
460
|
+
criticalCount,
|
|
461
|
+
highRiskCount,
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
return mcpText(ok(result, tokenEstimator));
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
468
|
+
return mcpText(fail(`Code metrics analysis failed: ${msg}`));
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
// ── mindmap_security_scan ─────────────────────────────────────
|
|
472
|
+
server.tool('mindmap_security_scan', 'Pattern-based vulnerability detection (SAST lite). ' +
|
|
473
|
+
'Scans files for hardcoded secrets, injection vulnerabilities, weak crypto, ' +
|
|
474
|
+
'and other security issues. Returns findings with severity, location, and remediation.', {
|
|
475
|
+
filePath: z
|
|
476
|
+
.string()
|
|
477
|
+
.optional()
|
|
478
|
+
.describe('Scan a specific file (relative to projectRoot)'),
|
|
479
|
+
severity: z
|
|
480
|
+
.string()
|
|
481
|
+
.optional()
|
|
482
|
+
.default('all')
|
|
483
|
+
.describe("Filter by severity: 'critical', 'high', 'medium', 'low', or 'all' (default: 'all')"),
|
|
484
|
+
}, async ({ filePath, severity }) => {
|
|
485
|
+
try {
|
|
486
|
+
const MAX_FILE_SIZE = 1 * 1024 * 1024; // 1MB
|
|
487
|
+
let filesToScan;
|
|
488
|
+
if (filePath) {
|
|
489
|
+
// Try to resolve to an indexed file
|
|
490
|
+
const allFiles = graph.getIndexedFiles();
|
|
491
|
+
const match = allFiles.find(f => f === filePath || f.endsWith(filePath) || filePath.endsWith(f));
|
|
492
|
+
filesToScan = match ? [match] : [filePath];
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
filesToScan = graph.getIndexedFiles();
|
|
496
|
+
}
|
|
497
|
+
// Filter patterns by severity if requested
|
|
498
|
+
const patterns = severity === 'all'
|
|
499
|
+
? VULN_PATTERNS
|
|
500
|
+
: VULN_PATTERNS.filter(p => p.severity === severity);
|
|
501
|
+
if (patterns.length === 0) {
|
|
502
|
+
return mcpText(fail(`Invalid severity filter: "${severity}". Use 'critical', 'high', 'medium', 'low', or 'all'`));
|
|
503
|
+
}
|
|
504
|
+
const findings = [];
|
|
505
|
+
let filesScanned = 0;
|
|
506
|
+
const summaryCounts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
507
|
+
for (const file of filesToScan) {
|
|
508
|
+
const absPath = resolve(config.projectRoot, file);
|
|
509
|
+
// Skip large files
|
|
510
|
+
try {
|
|
511
|
+
const stat = statSync(absPath);
|
|
512
|
+
if (stat.size > MAX_FILE_SIZE)
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
// Skip binary files
|
|
519
|
+
if (isBinaryFile(absPath))
|
|
520
|
+
continue;
|
|
521
|
+
let content;
|
|
522
|
+
try {
|
|
523
|
+
content = readFileSync(absPath, 'utf-8');
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
filesScanned++;
|
|
529
|
+
const lines = content.split('\n');
|
|
530
|
+
let inBlockComment = false;
|
|
531
|
+
for (let i = 0; i < lines.length; i++) {
|
|
532
|
+
const line = lines[i];
|
|
533
|
+
const trimmedLine = line.trim();
|
|
534
|
+
// Track block comments
|
|
535
|
+
if (trimmedLine.includes('/*'))
|
|
536
|
+
inBlockComment = true;
|
|
537
|
+
if (trimmedLine.includes('*/')) {
|
|
538
|
+
inBlockComment = false;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
if (inBlockComment)
|
|
542
|
+
continue;
|
|
543
|
+
// Skip single-line comments
|
|
544
|
+
if (isCommentLine(line))
|
|
545
|
+
continue;
|
|
546
|
+
// Test each pattern
|
|
547
|
+
for (const vuln of patterns) {
|
|
548
|
+
if (vuln.pattern.test(line)) {
|
|
549
|
+
findings.push({
|
|
550
|
+
severity: vuln.severity,
|
|
551
|
+
rule: vuln.rule,
|
|
552
|
+
file,
|
|
553
|
+
line: i + 1,
|
|
554
|
+
code: trimmedLine.substring(0, 120), // Limit code context length
|
|
555
|
+
remediation: vuln.remediation,
|
|
556
|
+
});
|
|
557
|
+
summaryCounts[vuln.severity]++;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
// Sort by severity priority
|
|
563
|
+
const severityOrder = {
|
|
564
|
+
critical: 0,
|
|
565
|
+
high: 1,
|
|
566
|
+
medium: 2,
|
|
567
|
+
low: 3,
|
|
568
|
+
};
|
|
569
|
+
findings.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
570
|
+
const result = {
|
|
571
|
+
totalFiles: filesToScan.length,
|
|
572
|
+
filesScanned,
|
|
573
|
+
totalFindings: findings.length,
|
|
574
|
+
findings,
|
|
575
|
+
summary: summaryCounts,
|
|
576
|
+
};
|
|
577
|
+
return mcpText(ok(result, tokenEstimator));
|
|
578
|
+
}
|
|
579
|
+
catch (err) {
|
|
580
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
581
|
+
return mcpText(fail(`Security scan failed: ${msg}`));
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
// ── mindmap_code_duplication ───────────────────────────────────
|
|
585
|
+
server.tool('mindmap_code_duplication', 'Detect duplicated code blocks across the codebase. ' +
|
|
586
|
+
'Uses sliding-window hashing to find exact code clones (Type 1). ' +
|
|
587
|
+
'Returns clone groups sorted by size with file locations.', {
|
|
588
|
+
minLines: z
|
|
589
|
+
.number()
|
|
590
|
+
.optional()
|
|
591
|
+
.default(6)
|
|
592
|
+
.describe('Minimum consecutive lines to consider a clone (default: 6)'),
|
|
593
|
+
filePath: z
|
|
594
|
+
.string()
|
|
595
|
+
.optional()
|
|
596
|
+
.describe('Check a specific file for clones (relative to projectRoot)'),
|
|
597
|
+
threshold: z
|
|
598
|
+
.number()
|
|
599
|
+
.optional()
|
|
600
|
+
.default(0.8)
|
|
601
|
+
.describe('Similarity threshold 0-1 (default: 0.8). Currently only exact matches (1.0) are detected'),
|
|
602
|
+
}, async ({ minLines, filePath, threshold: _threshold }) => {
|
|
603
|
+
try {
|
|
604
|
+
const MAX_FILE_SIZE = 500 * 1024; // 500KB
|
|
605
|
+
const MAX_FILES = 200;
|
|
606
|
+
let filesToScan;
|
|
607
|
+
if (filePath) {
|
|
608
|
+
const allFiles = graph.getIndexedFiles();
|
|
609
|
+
const match = allFiles.find(f => f === filePath || f.endsWith(filePath) || filePath.endsWith(f));
|
|
610
|
+
filesToScan = match ? [match] : [filePath];
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
filesToScan = graph.getIndexedFiles().slice(0, MAX_FILES);
|
|
614
|
+
}
|
|
615
|
+
// Map from hash -> list of { file, startLine }
|
|
616
|
+
const hashMap = new Map();
|
|
617
|
+
let totalLinesScanned = 0;
|
|
618
|
+
for (const file of filesToScan) {
|
|
619
|
+
const absPath = resolve(config.projectRoot, file);
|
|
620
|
+
// Skip large files
|
|
621
|
+
try {
|
|
622
|
+
const stat = statSync(absPath);
|
|
623
|
+
if (stat.size > MAX_FILE_SIZE)
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
// Skip binary files
|
|
630
|
+
if (isBinaryFile(absPath))
|
|
631
|
+
continue;
|
|
632
|
+
let content;
|
|
633
|
+
try {
|
|
634
|
+
content = readFileSync(absPath, 'utf-8');
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
const rawLines = content.split('\n');
|
|
640
|
+
// Normalize lines: trim, remove blank lines, remove single-line comments
|
|
641
|
+
const normalized = [];
|
|
642
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
643
|
+
const trimmed = rawLines[i].trim();
|
|
644
|
+
if (!trimmed)
|
|
645
|
+
continue;
|
|
646
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('#'))
|
|
647
|
+
continue;
|
|
648
|
+
if (trimmed === '{' || trimmed === '}')
|
|
649
|
+
continue; // Skip lone braces
|
|
650
|
+
normalized.push({ text: trimmed.toLowerCase(), originalLine: i + 1 });
|
|
651
|
+
}
|
|
652
|
+
totalLinesScanned += normalized.length;
|
|
653
|
+
// Sliding window
|
|
654
|
+
for (let i = 0; i <= normalized.length - minLines; i++) {
|
|
655
|
+
const windowText = normalized
|
|
656
|
+
.slice(i, i + minLines)
|
|
657
|
+
.map(l => l.text)
|
|
658
|
+
.join('\n');
|
|
659
|
+
const hash = simpleHash(windowText);
|
|
660
|
+
const entry = {
|
|
661
|
+
file,
|
|
662
|
+
startLine: normalized[i].originalLine,
|
|
663
|
+
endLine: normalized[i + minLines - 1].originalLine,
|
|
664
|
+
normalized: windowText,
|
|
665
|
+
};
|
|
666
|
+
const existing = hashMap.get(hash);
|
|
667
|
+
if (existing) {
|
|
668
|
+
existing.push(entry);
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
hashMap.set(hash, [entry]);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
const rawCloneGroups = [];
|
|
676
|
+
for (const [_hash, entries] of hashMap) {
|
|
677
|
+
if (entries.length < 2)
|
|
678
|
+
continue;
|
|
679
|
+
// Deduplicate overlapping windows within the same file
|
|
680
|
+
const uniqueLocations = [];
|
|
681
|
+
for (const entry of entries) {
|
|
682
|
+
const overlapping = uniqueLocations.find(u => u.file === entry.file && Math.abs(u.startLine - entry.startLine) < minLines);
|
|
683
|
+
if (!overlapping) {
|
|
684
|
+
uniqueLocations.push(entry);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (uniqueLocations.length < 2)
|
|
688
|
+
continue;
|
|
689
|
+
// Verify actual string equality (not just hash collision)
|
|
690
|
+
const firstNorm = uniqueLocations[0].normalized;
|
|
691
|
+
const matched = uniqueLocations.filter(e => e.normalized === firstNorm);
|
|
692
|
+
if (matched.length < 2)
|
|
693
|
+
continue;
|
|
694
|
+
rawCloneGroups.push({
|
|
695
|
+
lines: minLines,
|
|
696
|
+
similarity: 1.0,
|
|
697
|
+
locations: matched.map(e => ({
|
|
698
|
+
file: e.file,
|
|
699
|
+
startLine: e.startLine,
|
|
700
|
+
endLine: e.endLine,
|
|
701
|
+
})),
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
// Try to merge adjacent clone groups into larger clones
|
|
705
|
+
// Sort by file+startLine for merge detection
|
|
706
|
+
rawCloneGroups.sort((a, b) => {
|
|
707
|
+
const aFirst = a.locations[0];
|
|
708
|
+
const bFirst = b.locations[0];
|
|
709
|
+
return aFirst.file.localeCompare(bFirst.file) || aFirst.startLine - bFirst.startLine;
|
|
710
|
+
});
|
|
711
|
+
// Deduplicate: remove groups where all locations are subsets of a larger group
|
|
712
|
+
const seen = new Set();
|
|
713
|
+
const deduped = [];
|
|
714
|
+
for (const group of rawCloneGroups) {
|
|
715
|
+
const key = group.locations
|
|
716
|
+
.map(l => `${l.file}:${l.startLine}`)
|
|
717
|
+
.sort()
|
|
718
|
+
.join('|');
|
|
719
|
+
if (!seen.has(key)) {
|
|
720
|
+
seen.add(key);
|
|
721
|
+
deduped.push(group);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// Sort by lines descending (largest clones first), take top 20
|
|
725
|
+
deduped.sort((a, b) => b.lines - a.lines || b.locations.length - a.locations.length);
|
|
726
|
+
const topClones = deduped.slice(0, 20);
|
|
727
|
+
// Calculate total duplicated lines
|
|
728
|
+
const totalDuplicatedLines = topClones.reduce((sum, g) => sum + g.lines * (g.locations.length - 1), 0);
|
|
729
|
+
const duplicationPercentage = totalLinesScanned > 0
|
|
730
|
+
? Math.round((totalDuplicatedLines / totalLinesScanned) * 1000) / 10
|
|
731
|
+
: 0;
|
|
732
|
+
const result = {
|
|
733
|
+
totalFilesScanned: filesToScan.length,
|
|
734
|
+
totalCloneGroups: deduped.length,
|
|
735
|
+
totalDuplicatedLines,
|
|
736
|
+
duplicationPercentage,
|
|
737
|
+
clones: topClones,
|
|
738
|
+
};
|
|
739
|
+
return mcpText(ok(result, tokenEstimator));
|
|
740
|
+
}
|
|
741
|
+
catch (err) {
|
|
742
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
743
|
+
return mcpText(fail(`Code duplication detection failed: ${msg}`));
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
//# sourceMappingURL=quality-tools.js.map
|