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.
@@ -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