@mrxkun/mcfast-mcp 2.2.0 → 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 +1 -1
- package/src/index.js +60 -0
- package/src/strategies/multi-file-coordinator.js +298 -0
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -23,6 +23,11 @@ import {
|
|
|
23
23
|
inlineVariable,
|
|
24
24
|
applyASTTransformation
|
|
25
25
|
} from './strategies/ast-detector.js';
|
|
26
|
+
import {
|
|
27
|
+
detectCrossFileEdit,
|
|
28
|
+
batchRenameSymbol,
|
|
29
|
+
updateImportPaths
|
|
30
|
+
} from './strategies/multi-file-coordinator.js';
|
|
26
31
|
import { safeEdit } from './utils/backup.js';
|
|
27
32
|
import { formatError } from './utils/error-formatter.js';
|
|
28
33
|
|
|
@@ -500,6 +505,61 @@ async function handleReapply({ instruction, files, errorContext = "", attempt =
|
|
|
500
505
|
* Auto-detects best strategy based on input
|
|
501
506
|
*/
|
|
502
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
|
+
|
|
503
563
|
const strategy = detectEditStrategy({ instruction, code_edit, files });
|
|
504
564
|
|
|
505
565
|
console.error(`${colors.cyan}[EDIT STRATEGY]${colors.reset} ${strategy}`);
|
|
@@ -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
|
+
}
|