@mrxkun/mcfast-mcp 2.1.2 → 2.2.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrxkun/mcfast-mcp",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"description": "Ultra-fast code editing with fuzzy patching, auto-rollback, and 5 unified tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"url": "git+https://github.com/ndpmmo/mcfast.git"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
+
"@babel/generator": "^7.29.1",
|
|
31
|
+
"@babel/parser": "^7.29.0",
|
|
32
|
+
"@babel/traverse": "^7.29.0",
|
|
33
|
+
"@babel/types": "^7.29.0",
|
|
30
34
|
"@modelcontextprotocol/sdk": "^0.6.0",
|
|
31
35
|
"fast-glob": "^3.3.3",
|
|
32
36
|
"ignore": "^7.0.5"
|
package/src/index.js
CHANGED
|
@@ -16,6 +16,18 @@ import fg from "fast-glob";
|
|
|
16
16
|
import { detectEditStrategy, extractSearchReplace } from './strategies/edit-strategy.js';
|
|
17
17
|
import { detectSearchStrategy } from './strategies/search-strategy.js';
|
|
18
18
|
import { applyFuzzyPatch } from './strategies/fuzzy-patch.js';
|
|
19
|
+
import {
|
|
20
|
+
detectRefactoringPattern,
|
|
21
|
+
parseCode,
|
|
22
|
+
renameIdentifier,
|
|
23
|
+
inlineVariable,
|
|
24
|
+
applyASTTransformation
|
|
25
|
+
} from './strategies/ast-detector.js';
|
|
26
|
+
import {
|
|
27
|
+
detectCrossFileEdit,
|
|
28
|
+
batchRenameSymbol,
|
|
29
|
+
updateImportPaths
|
|
30
|
+
} from './strategies/multi-file-coordinator.js';
|
|
19
31
|
import { safeEdit } from './utils/backup.js';
|
|
20
32
|
import { formatError } from './utils/error-formatter.js';
|
|
21
33
|
|
|
@@ -493,6 +505,61 @@ async function handleReapply({ instruction, files, errorContext = "", attempt =
|
|
|
493
505
|
* Auto-detects best strategy based on input
|
|
494
506
|
*/
|
|
495
507
|
async function handleEdit({ instruction, files, code_edit, dryRun = false }) {
|
|
508
|
+
// Check for multi-file edits first
|
|
509
|
+
const multiFileEdit = detectCrossFileEdit(instruction, files);
|
|
510
|
+
|
|
511
|
+
if (multiFileEdit && Object.keys(files).length > 1) {
|
|
512
|
+
console.error(`${colors.cyan}[MULTI-FILE EDIT]${colors.reset} ${multiFileEdit.type} - ${multiFileEdit.reason}`);
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
let result;
|
|
516
|
+
|
|
517
|
+
if (multiFileEdit.type === 'symbol_rename') {
|
|
518
|
+
result = await batchRenameSymbol(files, multiFileEdit.symbol, multiFileEdit.newSymbol);
|
|
519
|
+
} else if (multiFileEdit.type === 'dependency') {
|
|
520
|
+
// Extract old and new paths from instruction
|
|
521
|
+
const pathMatch = instruction.match(/from\s+['"]([^'"]+)['"]\s+to\s+['"]([^'"]+)['"]/i);
|
|
522
|
+
if (pathMatch) {
|
|
523
|
+
result = await updateImportPaths(files, pathMatch[1], pathMatch[2]);
|
|
524
|
+
} else {
|
|
525
|
+
throw new Error('Could not extract import paths from instruction');
|
|
526
|
+
}
|
|
527
|
+
} else {
|
|
528
|
+
throw new Error(`Unsupported multi-file edit type: ${multiFileEdit.type}`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (!result.success) {
|
|
532
|
+
return {
|
|
533
|
+
content: [{
|
|
534
|
+
type: "text",
|
|
535
|
+
text: formatError('generic', {
|
|
536
|
+
error: result.message || result.error
|
|
537
|
+
})
|
|
538
|
+
}],
|
|
539
|
+
isError: true
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
content: [{
|
|
545
|
+
type: "text",
|
|
546
|
+
text: `✅ Multi-File Edit Successful\n\nType: ${multiFileEdit.type}\nFiles Modified: ${result.files.length}\n\nBackups created for all files.`
|
|
547
|
+
}]
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
} catch (error) {
|
|
551
|
+
return {
|
|
552
|
+
content: [{
|
|
553
|
+
type: "text",
|
|
554
|
+
text: formatError('generic', {
|
|
555
|
+
error: `Multi-file edit failed: ${error.message}`
|
|
556
|
+
})
|
|
557
|
+
}],
|
|
558
|
+
isError: true
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
496
563
|
const strategy = detectEditStrategy({ instruction, code_edit, files });
|
|
497
564
|
|
|
498
565
|
console.error(`${colors.cyan}[EDIT STRATEGY]${colors.reset} ${strategy}`);
|
|
@@ -565,7 +632,88 @@ async function handleEdit({ instruction, files, code_edit, dryRun = false }) {
|
|
|
565
632
|
}
|
|
566
633
|
}
|
|
567
634
|
|
|
568
|
-
// Strategy 2:
|
|
635
|
+
// Strategy 2: AST Refactor (symbol-aware operations)
|
|
636
|
+
if (strategy === 'ast_refactor') {
|
|
637
|
+
const filePath = Object.keys(files)[0];
|
|
638
|
+
const fileContent = files[filePath];
|
|
639
|
+
const pattern = detectRefactoringPattern(instruction);
|
|
640
|
+
|
|
641
|
+
if (!pattern) {
|
|
642
|
+
return {
|
|
643
|
+
content: [{
|
|
644
|
+
type: "text",
|
|
645
|
+
text: formatError('generic', {
|
|
646
|
+
error: 'Could not detect refactoring pattern from instruction'
|
|
647
|
+
})
|
|
648
|
+
}],
|
|
649
|
+
isError: true
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
try {
|
|
654
|
+
let transformResult;
|
|
655
|
+
const editResult = await safeEdit(filePath, async () => {
|
|
656
|
+
// Apply AST transformation based on pattern type
|
|
657
|
+
if (pattern.type.startsWith('rename')) {
|
|
658
|
+
transformResult = applyASTTransformation(fileContent, filePath, (ast) => {
|
|
659
|
+
return renameIdentifier(ast, pattern.oldName, pattern.newName);
|
|
660
|
+
});
|
|
661
|
+
} else if (pattern.type.startsWith('inline')) {
|
|
662
|
+
transformResult = applyASTTransformation(fileContent, filePath, (ast) => {
|
|
663
|
+
return inlineVariable(ast, pattern.oldName);
|
|
664
|
+
});
|
|
665
|
+
} else {
|
|
666
|
+
throw new Error(`Unsupported refactoring type: ${pattern.type}`);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (!transformResult.success) {
|
|
670
|
+
throw new Error(transformResult.message);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Write transformed code
|
|
674
|
+
await fs.writeFile(filePath, transformResult.code, 'utf8');
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
if (!editResult.success) {
|
|
678
|
+
let errorType = 'generic';
|
|
679
|
+
if (editResult.error.includes('ENOENT')) errorType = 'file_not_found';
|
|
680
|
+
else if (editResult.error.includes('EACCES')) errorType = 'permission_denied';
|
|
681
|
+
else if (editResult.error.includes('Validation failed')) errorType = 'syntax_error';
|
|
682
|
+
|
|
683
|
+
return {
|
|
684
|
+
content: [{
|
|
685
|
+
type: "text",
|
|
686
|
+
text: formatError(errorType, {
|
|
687
|
+
filePath,
|
|
688
|
+
error: editResult.error,
|
|
689
|
+
backupPath: editResult.backupPath
|
|
690
|
+
})
|
|
691
|
+
}],
|
|
692
|
+
isError: true
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return {
|
|
697
|
+
content: [{
|
|
698
|
+
type: "text",
|
|
699
|
+
text: `✅ AST Refactor Applied Successfully\n\nOperation: ${pattern.type}\nChanges: ${transformResult.count || transformResult.replacements || 0} locations\n\nBackup: ${editResult.backupPath}`
|
|
700
|
+
}]
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
} catch (error) {
|
|
704
|
+
return {
|
|
705
|
+
content: [{
|
|
706
|
+
type: "text",
|
|
707
|
+
text: formatError('generic', {
|
|
708
|
+
error: `AST refactor failed: ${error.message}`
|
|
709
|
+
})
|
|
710
|
+
}],
|
|
711
|
+
isError: true
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Strategy 3: Search/Replace (deterministic, fastest)
|
|
569
717
|
if (strategy === 'search_replace') {
|
|
570
718
|
const extracted = extractSearchReplace(instruction);
|
|
571
719
|
if (extracted) {
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AST-Aware Detection for mcfast v2.2
|
|
3
|
+
* Enables symbol-aware refactoring using Babel parser
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import parser from '@babel/parser';
|
|
7
|
+
import _traverse from '@babel/traverse';
|
|
8
|
+
import _generate from '@babel/generator';
|
|
9
|
+
import * as t from '@babel/types';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
|
|
12
|
+
// Handle default exports from Babel (CommonJS compatibility)
|
|
13
|
+
const traverse = _traverse.default || _traverse;
|
|
14
|
+
const generate = _generate.default || _generate;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse code into AST based on file extension
|
|
18
|
+
*/
|
|
19
|
+
export function parseCode(code, filePath) {
|
|
20
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
21
|
+
const plugins = [];
|
|
22
|
+
|
|
23
|
+
// TypeScript support
|
|
24
|
+
if (['.ts', '.tsx', '.mts', '.cts'].includes(ext)) {
|
|
25
|
+
plugins.push('typescript');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// JSX support
|
|
29
|
+
if (['.jsx', '.tsx'].includes(ext)) {
|
|
30
|
+
plugins.push('jsx');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Common plugins
|
|
34
|
+
plugins.push('decorators-legacy', 'classProperties', 'objectRestSpread');
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
return parser.parse(code, {
|
|
38
|
+
sourceType: 'module',
|
|
39
|
+
plugins,
|
|
40
|
+
errorRecovery: true
|
|
41
|
+
});
|
|
42
|
+
} catch (error) {
|
|
43
|
+
// Fallback: try as script
|
|
44
|
+
try {
|
|
45
|
+
return parser.parse(code, {
|
|
46
|
+
sourceType: 'script',
|
|
47
|
+
plugins,
|
|
48
|
+
errorRecovery: true
|
|
49
|
+
});
|
|
50
|
+
} catch (fallbackError) {
|
|
51
|
+
throw new Error(`Failed to parse ${filePath}: ${error.message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Detect refactoring patterns from instruction
|
|
58
|
+
*/
|
|
59
|
+
export function detectRefactoringPattern(instruction) {
|
|
60
|
+
const patterns = {
|
|
61
|
+
rename_variable: /rename\s+(?:variable|var|const|let)\s+["`']?(\w+)["`']?\s+to\s+["`']?(\w+)["`']?/i,
|
|
62
|
+
rename_function: /rename\s+(?:function|func)\s+["`']?(\w+)["`']?\s+to\s+["`']?(\w+)["`']?/i,
|
|
63
|
+
rename_class: /rename\s+class\s+["`']?(\w+)["`']?\s+to\s+["`']?(\w+)["`']?/i,
|
|
64
|
+
rename_any: /rename\s+["`']?(\w+)["`']?\s+to\s+["`']?(\w+)["`']?/i,
|
|
65
|
+
extract_function: /extract\s+(?:function|method)/i,
|
|
66
|
+
inline_variable: /inline\s+(?:variable|var|const|let)\s+["`']?(\w+)["`']?/i,
|
|
67
|
+
inline_function: /inline\s+(?:function|func)\s+["`']?(\w+)["`']?/i
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
for (const [type, pattern] of Object.entries(patterns)) {
|
|
71
|
+
const match = instruction.match(pattern);
|
|
72
|
+
if (match) {
|
|
73
|
+
return {
|
|
74
|
+
type,
|
|
75
|
+
oldName: match[1],
|
|
76
|
+
newName: match[2] || null
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Rename identifier in AST
|
|
86
|
+
*/
|
|
87
|
+
export function renameIdentifier(ast, oldName, newName, options = {}) {
|
|
88
|
+
const { scope = 'all' } = options;
|
|
89
|
+
const renamedLocations = [];
|
|
90
|
+
|
|
91
|
+
traverse(ast, {
|
|
92
|
+
Identifier(path) {
|
|
93
|
+
if (path.node.name === oldName) {
|
|
94
|
+
// Check if this is a binding (declaration) or reference
|
|
95
|
+
const binding = path.scope.getBinding(oldName);
|
|
96
|
+
|
|
97
|
+
if (scope === 'all' || (binding && path.isReferencedIdentifier())) {
|
|
98
|
+
path.node.name = newName;
|
|
99
|
+
renamedLocations.push({
|
|
100
|
+
line: path.node.loc?.start.line,
|
|
101
|
+
column: path.node.loc?.start.column,
|
|
102
|
+
type: path.parent.type
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
success: true,
|
|
111
|
+
ast,
|
|
112
|
+
count: renamedLocations.length,
|
|
113
|
+
locations: renamedLocations
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Find all usages of an identifier
|
|
119
|
+
*/
|
|
120
|
+
export function findIdentifierUsages(ast, name) {
|
|
121
|
+
const usages = [];
|
|
122
|
+
|
|
123
|
+
traverse(ast, {
|
|
124
|
+
Identifier(path) {
|
|
125
|
+
if (path.node.name === name) {
|
|
126
|
+
const binding = path.scope.getBinding(name);
|
|
127
|
+
usages.push({
|
|
128
|
+
line: path.node.loc?.start.line,
|
|
129
|
+
column: path.node.loc?.start.column,
|
|
130
|
+
type: path.parent.type,
|
|
131
|
+
isDeclaration: binding && path.isBindingIdentifier(),
|
|
132
|
+
isReference: path.isReferencedIdentifier(),
|
|
133
|
+
scope: path.scope.uid
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return usages;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Extract function from selected code
|
|
144
|
+
*/
|
|
145
|
+
export function extractFunction(ast, startLine, endLine, functionName) {
|
|
146
|
+
let extractedStatements = [];
|
|
147
|
+
let insertionPoint = null;
|
|
148
|
+
|
|
149
|
+
traverse(ast, {
|
|
150
|
+
enter(path) {
|
|
151
|
+
const loc = path.node.loc;
|
|
152
|
+
if (!loc) return;
|
|
153
|
+
|
|
154
|
+
// Find statements within range
|
|
155
|
+
if (loc.start.line >= startLine && loc.end.line <= endLine) {
|
|
156
|
+
if (t.isStatement(path.node) && !path.findParent(p => extractedStatements.includes(p.node))) {
|
|
157
|
+
extractedStatements.push(path.node);
|
|
158
|
+
if (!insertionPoint) {
|
|
159
|
+
insertionPoint = path.getStatementParent();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
if (extractedStatements.length === 0) {
|
|
167
|
+
return { success: false, message: 'No statements found in range' };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Create new function
|
|
171
|
+
const newFunction = t.functionDeclaration(
|
|
172
|
+
t.identifier(functionName),
|
|
173
|
+
[],
|
|
174
|
+
t.blockStatement(extractedStatements)
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Replace extracted code with function call
|
|
178
|
+
const functionCall = t.expressionStatement(
|
|
179
|
+
t.callExpression(t.identifier(functionName), [])
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
success: true,
|
|
184
|
+
newFunction,
|
|
185
|
+
functionCall,
|
|
186
|
+
insertionPoint
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Inline variable (replace all usages with its value)
|
|
192
|
+
*/
|
|
193
|
+
export function inlineVariable(ast, variableName) {
|
|
194
|
+
let variableValue = null;
|
|
195
|
+
let declarationPath = null;
|
|
196
|
+
const replacements = [];
|
|
197
|
+
|
|
198
|
+
// Find variable declaration
|
|
199
|
+
traverse(ast, {
|
|
200
|
+
VariableDeclarator(path) {
|
|
201
|
+
if (path.node.id.name === variableName) {
|
|
202
|
+
variableValue = path.node.init;
|
|
203
|
+
declarationPath = path;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
if (!variableValue) {
|
|
209
|
+
return { success: false, message: `Variable ${variableName} not found` };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Replace all references
|
|
213
|
+
traverse(ast, {
|
|
214
|
+
Identifier(path) {
|
|
215
|
+
if (path.node.name === variableName && path.isReferencedIdentifier()) {
|
|
216
|
+
// Clone the value to avoid reference issues
|
|
217
|
+
const clonedValue = t.cloneNode(variableValue, true);
|
|
218
|
+
path.replaceWith(clonedValue);
|
|
219
|
+
replacements.push({
|
|
220
|
+
line: path.node.loc?.start.line,
|
|
221
|
+
column: path.node.loc?.start.column
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Remove declaration
|
|
228
|
+
if (declarationPath) {
|
|
229
|
+
const parent = declarationPath.parentPath;
|
|
230
|
+
if (parent.node.declarations.length === 1) {
|
|
231
|
+
parent.remove();
|
|
232
|
+
} else {
|
|
233
|
+
declarationPath.remove();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
success: true,
|
|
239
|
+
ast,
|
|
240
|
+
replacements: replacements.length,
|
|
241
|
+
locations: replacements
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Apply AST transformation and generate code
|
|
247
|
+
*/
|
|
248
|
+
export function applyASTTransformation(code, filePath, transformation) {
|
|
249
|
+
try {
|
|
250
|
+
const ast = parseCode(code, filePath);
|
|
251
|
+
const result = transformation(ast);
|
|
252
|
+
|
|
253
|
+
if (!result.success) {
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const output = generate(result.ast || ast, {
|
|
258
|
+
retainLines: true,
|
|
259
|
+
comments: true
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
success: true,
|
|
264
|
+
code: output.code,
|
|
265
|
+
...result
|
|
266
|
+
};
|
|
267
|
+
} catch (error) {
|
|
268
|
+
return {
|
|
269
|
+
success: false,
|
|
270
|
+
message: `AST transformation failed: ${error.message}`
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Check if instruction requires AST-based refactoring
|
|
277
|
+
*/
|
|
278
|
+
export function requiresAST(instruction) {
|
|
279
|
+
const astPatterns = [
|
|
280
|
+
/rename\s+(?:variable|function|class|method)/i,
|
|
281
|
+
/extract\s+(?:function|method)/i,
|
|
282
|
+
/inline\s+(?:variable|function)/i,
|
|
283
|
+
/move\s+(?:function|class)/i,
|
|
284
|
+
/split\s+(?:function|class)/i
|
|
285
|
+
];
|
|
286
|
+
|
|
287
|
+
return astPatterns.some(pattern => pattern.test(instruction));
|
|
288
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { isDiffBasedEdit } from './fuzzy-patch.js';
|
|
7
|
+
import { requiresAST, detectRefactoringPattern } from './ast-detector.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Detect if instruction is a simple search/replace
|
|
@@ -64,7 +65,7 @@ export function hasPlaceholders(codeEdit) {
|
|
|
64
65
|
|
|
65
66
|
/**
|
|
66
67
|
* Determine the best edit strategy
|
|
67
|
-
* @returns {'fuzzy_patch' | 'search_replace' | 'placeholder_merge' | 'mercury_intelligent'}
|
|
68
|
+
* @returns {'fuzzy_patch' | 'ast_refactor' | 'search_replace' | 'placeholder_merge' | 'mercury_intelligent'}
|
|
68
69
|
*/
|
|
69
70
|
export function detectEditStrategy({ instruction, code_edit, files }) {
|
|
70
71
|
// Priority 1: Fuzzy Patch (unified diff format)
|
|
@@ -72,16 +73,21 @@ export function detectEditStrategy({ instruction, code_edit, files }) {
|
|
|
72
73
|
return 'fuzzy_patch';
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
// Priority 2:
|
|
76
|
+
// Priority 2: AST Refactor (symbol-aware operations)
|
|
77
|
+
if (requiresAST(instruction)) {
|
|
78
|
+
return 'ast_refactor';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Priority 3: Search/Replace (fastest, deterministic)
|
|
76
82
|
if (isSearchReplace(instruction)) {
|
|
77
83
|
return 'search_replace';
|
|
78
84
|
}
|
|
79
85
|
|
|
80
|
-
// Priority
|
|
86
|
+
// Priority 4: Placeholder Merge (token-efficient)
|
|
81
87
|
if (code_edit && hasPlaceholders(code_edit)) {
|
|
82
88
|
return 'placeholder_merge';
|
|
83
89
|
}
|
|
84
90
|
|
|
85
|
-
// Priority
|
|
91
|
+
// Priority 5: Mercury Intelligent (most flexible)
|
|
86
92
|
return 'mercury_intelligent';
|
|
87
93
|
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-File Coordination for mcfast v2.2
|
|
3
|
+
* Enables cross-file edits with atomic rollback
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'fs/promises';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { parseCode, renameIdentifier, findIdentifierUsages } from './ast-detector.js';
|
|
9
|
+
import { createBackup, restoreFromBackup, validateFile } from '../utils/backup.js';
|
|
10
|
+
import _generate from '@babel/generator';
|
|
11
|
+
|
|
12
|
+
// Handle default export from Babel (CommonJS compatibility)
|
|
13
|
+
const generate = _generate.default || _generate;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Detect if edit requires multiple files
|
|
17
|
+
*/
|
|
18
|
+
export function detectCrossFileEdit(instruction, files) {
|
|
19
|
+
const fileKeys = Object.keys(files);
|
|
20
|
+
|
|
21
|
+
// Pattern 1: Explicit multi-file mention
|
|
22
|
+
if (/in\s+all\s+files|across\s+files|in\s+every|everywhere/i.test(instruction)) {
|
|
23
|
+
return {
|
|
24
|
+
type: 'broadcast',
|
|
25
|
+
files: fileKeys,
|
|
26
|
+
reason: 'Explicit multi-file instruction'
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Pattern 2: Import/Export changes
|
|
31
|
+
if (/change\s+import|update\s+import|fix\s+import|update\s+export/i.test(instruction)) {
|
|
32
|
+
return {
|
|
33
|
+
type: 'dependency',
|
|
34
|
+
files: fileKeys,
|
|
35
|
+
reason: 'Import/export modification'
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Pattern 3: Symbol rename (check if symbol exists in multiple files)
|
|
40
|
+
const renameMatch = instruction.match(/rename\s+(?:variable|function|class|const|let)?\s*["`']?(\w+)["`']?\s+to\s+["`']?(\w+)["`']?/i);
|
|
41
|
+
if (renameMatch && fileKeys.length > 1) {
|
|
42
|
+
const [, oldName, newName] = renameMatch;
|
|
43
|
+
const filesWithSymbol = findFilesUsingSymbol(files, oldName);
|
|
44
|
+
|
|
45
|
+
if (filesWithSymbol.length > 1) {
|
|
46
|
+
return {
|
|
47
|
+
type: 'symbol_rename',
|
|
48
|
+
symbol: oldName,
|
|
49
|
+
newSymbol: newName,
|
|
50
|
+
files: filesWithSymbol,
|
|
51
|
+
reason: `Symbol "${oldName}" found in ${filesWithSymbol.length} files`
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Find files that use a specific symbol
|
|
61
|
+
*/
|
|
62
|
+
export function findFilesUsingSymbol(files, symbolName) {
|
|
63
|
+
const filesWithSymbol = [];
|
|
64
|
+
|
|
65
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
66
|
+
// Quick text search first (performance optimization)
|
|
67
|
+
if (!content.includes(symbolName)) continue;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const ast = parseCode(content, filePath);
|
|
71
|
+
const usages = findIdentifierUsages(ast, symbolName);
|
|
72
|
+
|
|
73
|
+
if (usages.length > 0) {
|
|
74
|
+
filesWithSymbol.push({
|
|
75
|
+
path: filePath,
|
|
76
|
+
usageCount: usages.length,
|
|
77
|
+
usages
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
} catch (error) {
|
|
81
|
+
// If AST parsing fails, fall back to text search
|
|
82
|
+
const regex = new RegExp(`\\b${symbolName}\\b`, 'g');
|
|
83
|
+
const matches = content.match(regex);
|
|
84
|
+
if (matches && matches.length > 0) {
|
|
85
|
+
filesWithSymbol.push({
|
|
86
|
+
path: filePath,
|
|
87
|
+
usageCount: matches.length,
|
|
88
|
+
usages: [],
|
|
89
|
+
parseError: error.message
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return filesWithSymbol.map(f => f.path);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Find files that import from a specific file
|
|
100
|
+
*/
|
|
101
|
+
export function findDependentFiles(files, targetFile) {
|
|
102
|
+
const dependents = [];
|
|
103
|
+
const targetBasename = path.basename(targetFile, path.extname(targetFile));
|
|
104
|
+
|
|
105
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
106
|
+
if (filePath === targetFile) continue;
|
|
107
|
+
|
|
108
|
+
// Check for imports from target file
|
|
109
|
+
const importPatterns = [
|
|
110
|
+
new RegExp(`from\\s+['"](\\.\\/|\\.\\.\\/)*${targetBasename}['"]`, 'g'),
|
|
111
|
+
new RegExp(`require\\(['"](\\.\\/|\\.\\.\\/)*${targetBasename}['"]\\)`, 'g'),
|
|
112
|
+
new RegExp(`import\\(['"](\\.\\/|\\.\\.\\/)*${targetBasename}['"]\\)`, 'g')
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
const hasImport = importPatterns.some(pattern => pattern.test(content));
|
|
116
|
+
if (hasImport) {
|
|
117
|
+
dependents.push(filePath);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return dependents;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Atomic multi-file edit with rollback
|
|
126
|
+
*/
|
|
127
|
+
export async function safeMultiFileEdit(fileEdits) {
|
|
128
|
+
const backups = new Map();
|
|
129
|
+
const completed = [];
|
|
130
|
+
const results = [];
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
// Phase 1: Backup all files
|
|
134
|
+
console.error(`[MULTI-FILE] Backing up ${fileEdits.length} files...`);
|
|
135
|
+
for (const { filePath } of fileEdits) {
|
|
136
|
+
const backupPath = await createBackup(filePath);
|
|
137
|
+
backups.set(filePath, backupPath);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Phase 2: Apply all edits
|
|
141
|
+
console.error(`[MULTI-FILE] Applying edits...`);
|
|
142
|
+
for (const { filePath, editFn } of fileEdits) {
|
|
143
|
+
try {
|
|
144
|
+
const result = await editFn();
|
|
145
|
+
completed.push(filePath);
|
|
146
|
+
results.push({ filePath, success: true, result });
|
|
147
|
+
} catch (error) {
|
|
148
|
+
throw new Error(`Edit failed for ${filePath}: ${error.message}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Phase 3: Validate all files
|
|
153
|
+
console.error(`[MULTI-FILE] Validating ${completed.length} files...`);
|
|
154
|
+
for (const filePath of completed) {
|
|
155
|
+
const validation = await validateFile(filePath);
|
|
156
|
+
if (!validation.valid) {
|
|
157
|
+
throw new Error(`Validation failed for ${filePath}: ${validation.error}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
console.error(`[MULTI-FILE] ✅ All ${completed.length} files edited successfully`);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
success: true,
|
|
165
|
+
files: completed,
|
|
166
|
+
results,
|
|
167
|
+
backups: Array.from(backups.entries())
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
} catch (error) {
|
|
171
|
+
// Rollback ALL files
|
|
172
|
+
console.error(`[MULTI-FILE] ❌ Error: ${error.message}`);
|
|
173
|
+
console.error(`[MULTI-FILE] Rolling back ${backups.size} files...`);
|
|
174
|
+
|
|
175
|
+
for (const [filePath, backupPath] of backups) {
|
|
176
|
+
try {
|
|
177
|
+
await restoreFromBackup(filePath, backupPath);
|
|
178
|
+
} catch (restoreError) {
|
|
179
|
+
console.error(`[MULTI-FILE] Failed to restore ${filePath}: ${restoreError.message}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
success: false,
|
|
185
|
+
error: error.message,
|
|
186
|
+
rolledBack: Array.from(backups.keys())
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Batch rename symbol across multiple files
|
|
193
|
+
*/
|
|
194
|
+
export async function batchRenameSymbol(files, oldName, newName) {
|
|
195
|
+
const fileEdits = [];
|
|
196
|
+
|
|
197
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
198
|
+
// Skip if symbol not in file
|
|
199
|
+
if (!content.includes(oldName)) continue;
|
|
200
|
+
|
|
201
|
+
fileEdits.push({
|
|
202
|
+
filePath,
|
|
203
|
+
editFn: async () => {
|
|
204
|
+
const ast = parseCode(content, filePath);
|
|
205
|
+
const result = renameIdentifier(ast, oldName, newName);
|
|
206
|
+
|
|
207
|
+
if (!result.success) {
|
|
208
|
+
throw new Error(result.message);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const output = generate(result.ast, {
|
|
212
|
+
retainLines: true,
|
|
213
|
+
comments: true
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await fs.writeFile(filePath, output.code, 'utf8');
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
count: result.count,
|
|
220
|
+
locations: result.locations
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (fileEdits.length === 0) {
|
|
227
|
+
return {
|
|
228
|
+
success: false,
|
|
229
|
+
message: `Symbol "${oldName}" not found in any files`
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return await safeMultiFileEdit(fileEdits);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Update import paths across multiple files
|
|
238
|
+
*/
|
|
239
|
+
export async function updateImportPaths(files, oldPath, newPath) {
|
|
240
|
+
const fileEdits = [];
|
|
241
|
+
const oldBasename = path.basename(oldPath, path.extname(oldPath));
|
|
242
|
+
const newBasename = path.basename(newPath, path.extname(newPath));
|
|
243
|
+
|
|
244
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
245
|
+
const hasImport = content.includes(oldBasename);
|
|
246
|
+
if (!hasImport) continue;
|
|
247
|
+
|
|
248
|
+
fileEdits.push({
|
|
249
|
+
filePath,
|
|
250
|
+
editFn: async () => {
|
|
251
|
+
// Replace import paths
|
|
252
|
+
let newContent = content;
|
|
253
|
+
|
|
254
|
+
// Handle various import formats
|
|
255
|
+
const patterns = [
|
|
256
|
+
{
|
|
257
|
+
regex: new RegExp(`from\\s+(['"])(\\.\\/|\\.\\.\\/)*${oldBasename}\\1`, 'g'),
|
|
258
|
+
replace: (match, quote, prefix) => `from ${quote}${prefix || './'}${newBasename}${quote}`
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
regex: new RegExp(`require\\((['"])(\\.\\/|\\.\\.\\/)*${oldBasename}\\1\\)`, 'g'),
|
|
262
|
+
replace: (match, quote, prefix) => `require(${quote}${prefix || './'}${newBasename}${quote})`
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
regex: new RegExp(`import\\((['"])(\\.\\/|\\.\\.\\/)*${oldBasename}\\1\\)`, 'g'),
|
|
266
|
+
replace: (match, quote, prefix) => `import(${quote}${prefix || './'}${newBasename}${quote})`
|
|
267
|
+
}
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
let changeCount = 0;
|
|
271
|
+
for (const { regex, replace } of patterns) {
|
|
272
|
+
const matches = newContent.match(regex);
|
|
273
|
+
if (matches) {
|
|
274
|
+
changeCount += matches.length;
|
|
275
|
+
newContent = newContent.replace(regex, replace);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (changeCount === 0) {
|
|
280
|
+
throw new Error('No import statements found to update');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
await fs.writeFile(filePath, newContent, 'utf8');
|
|
284
|
+
|
|
285
|
+
return { changeCount };
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (fileEdits.length === 0) {
|
|
291
|
+
return {
|
|
292
|
+
success: false,
|
|
293
|
+
message: `No files import from "${oldPath}"`
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return await safeMultiFileEdit(fileEdits);
|
|
298
|
+
}
|