revert-ai 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +25 -0
- package/README.md +103 -0
- package/bin/revert-ai.js +589 -0
- package/index.js +4 -0
- package/package.json +74 -0
- package/src/core/AgentDetector.js +27 -0
- package/src/core/AgentParser.js +34 -0
- package/src/core/AiderSessionParser.js +143 -0
- package/src/core/ClaudeSessionParser.js +243 -0
- package/src/core/DependencyCascading.js +74 -0
- package/src/core/DependencyResolver.js +206 -0
- package/src/core/Operation.js +39 -0
- package/src/core/OperationPreview.js +176 -0
- package/src/core/RedoManager.js +270 -0
- package/src/core/SessionTracker.js +98 -0
- package/src/core/UndoManager.js +207 -0
- package/src/core/UndoTracker.js +72 -0
- package/src/hooks/claude-tracker.js +92 -0
- package/src/i18n/i18n.js +72 -0
- package/src/i18n/languages.js +471 -0
- package/src/utils/diffRenderer.js +112 -0
- package/src/utils/formatting.js +15 -0
- package/src/utils/gitHelper.js +66 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export class AgentParser {
|
|
2
|
+
/**
|
|
3
|
+
* Get the mapped directory name in the agent's storage for the current workspace CWD
|
|
4
|
+
* @returns {Promise<string>}
|
|
5
|
+
*/
|
|
6
|
+
async getCurrentProjectDir() {
|
|
7
|
+
throw new Error('getCurrentProjectDir() not implemented');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get the path to the most recent session log file
|
|
12
|
+
* @returns {Promise<string|null>}
|
|
13
|
+
*/
|
|
14
|
+
async getCurrentSessionFile() {
|
|
15
|
+
throw new Error('getCurrentSessionFile() not implemented');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse a session log file and return an array of Operations
|
|
20
|
+
* @param {string} sessionFile
|
|
21
|
+
* @returns {Promise<import('./Operation.js').Operation[]>}
|
|
22
|
+
*/
|
|
23
|
+
async parseSessionFile(sessionFile) {
|
|
24
|
+
throw new Error('parseSessionFile() not implemented');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get all session files for this agent
|
|
29
|
+
* @returns {Promise<object[]>}
|
|
30
|
+
*/
|
|
31
|
+
async getAllSessions() {
|
|
32
|
+
throw new Error('getAllSessions() not implemented');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import { AgentParser } from './AgentParser.js';
|
|
5
|
+
import { Operation, OperationType } from './Operation.js';
|
|
6
|
+
import { UndoTracker } from './UndoTracker.js';
|
|
7
|
+
|
|
8
|
+
export class AiderSessionParser extends AgentParser {
|
|
9
|
+
constructor() {
|
|
10
|
+
super();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async detect() {
|
|
14
|
+
// Check if .aider.chat.history.md exists
|
|
15
|
+
const localAiderHistory = path.join(process.cwd(), '.aider.chat.history.md');
|
|
16
|
+
const exists = await fs.access(localAiderHistory).then(() => true).catch(() => false);
|
|
17
|
+
if (exists) return true;
|
|
18
|
+
|
|
19
|
+
// Check if git has aider commits
|
|
20
|
+
try {
|
|
21
|
+
const gitLogs = execSync('git log --grep="^[aA]ider:" -n 1 --oneline 2>/dev/null', { encoding: 'utf8' });
|
|
22
|
+
return gitLogs.trim().length > 0;
|
|
23
|
+
} catch (e) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async getCurrentProjectDir() {
|
|
29
|
+
return process.cwd();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async getCurrentSessionFile() {
|
|
33
|
+
const historyFile = path.join(process.cwd(), '.aider.chat.history.md');
|
|
34
|
+
const exists = await fs.access(historyFile).then(() => true).catch(() => false);
|
|
35
|
+
return exists ? historyFile : 'git-history';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async parseSessionFile(sessionFile, includeUndone = false) {
|
|
39
|
+
const operations = [];
|
|
40
|
+
|
|
41
|
+
// Check if we are in a git repository
|
|
42
|
+
let isGit = false;
|
|
43
|
+
try {
|
|
44
|
+
execSync('git rev-parse --is-inside-work-tree 2>/dev/null');
|
|
45
|
+
isGit = true;
|
|
46
|
+
} catch (e) {
|
|
47
|
+
isGit = false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!isGit) {
|
|
51
|
+
return operations; // No git support, return empty (or we can fallback to md parse if we build it later)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
// Get all Aider commits in reverse chronological order (newest first)
|
|
56
|
+
const commitLog = execSync('git log --grep="^[aA]ider:" --pretty=format:"%H|%ct|%s" --date=unix', { encoding: 'utf8' });
|
|
57
|
+
if (!commitLog.trim()) return [];
|
|
58
|
+
|
|
59
|
+
const lines = commitLog.split('\n');
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
if (!line.trim()) continue;
|
|
62
|
+
const [hash, timestampStr, message] = line.split('|');
|
|
63
|
+
const timestamp = new Date(parseInt(timestampStr, 10) * 1000);
|
|
64
|
+
|
|
65
|
+
// Get list of changed files for this commit
|
|
66
|
+
const fileStatus = execSync(`git show --name-status --pretty="" ${hash}`, { encoding: 'utf8' });
|
|
67
|
+
const fileLines = fileStatus.split('\n');
|
|
68
|
+
|
|
69
|
+
for (const fileLine of fileLines) {
|
|
70
|
+
if (!fileLine.trim()) continue;
|
|
71
|
+
const [status, filePath] = fileLine.split(/\s+/);
|
|
72
|
+
|
|
73
|
+
if (!filePath) continue;
|
|
74
|
+
|
|
75
|
+
// Formulate operation data based on git status
|
|
76
|
+
let opType;
|
|
77
|
+
let opData = { filePath: path.resolve(filePath) };
|
|
78
|
+
|
|
79
|
+
if (status === 'A') {
|
|
80
|
+
opType = OperationType.FILE_CREATE;
|
|
81
|
+
// Get content written
|
|
82
|
+
try {
|
|
83
|
+
opData.content = execSync(`git show ${hash}:${filePath}`, { encoding: 'utf8' });
|
|
84
|
+
} catch (e) {
|
|
85
|
+
opData.content = '';
|
|
86
|
+
}
|
|
87
|
+
} else if (status === 'D') {
|
|
88
|
+
opType = OperationType.FILE_DELETE;
|
|
89
|
+
// Get content before deletion
|
|
90
|
+
try {
|
|
91
|
+
opData.content = execSync(`git show ${hash}~1:${filePath}`, { encoding: 'utf8' });
|
|
92
|
+
} catch (e) {
|
|
93
|
+
opData.content = '';
|
|
94
|
+
}
|
|
95
|
+
} else if (status === 'M') {
|
|
96
|
+
opType = OperationType.FILE_EDIT;
|
|
97
|
+
// Get original and new content to reverse string edit
|
|
98
|
+
try {
|
|
99
|
+
opData.originalContent = execSync(`git show ${hash}~1:${filePath}`, { encoding: 'utf8' });
|
|
100
|
+
opData.newContent = execSync(`git show ${hash}:${filePath}`, { encoding: 'utf8' });
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// fallback if we can't show parent
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
// General support
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const op = new Operation(opType, opData);
|
|
110
|
+
op.id = `${hash}-${filePath.replace(/[^a-zA-Z0-9]/g, '_')}`; // unique operation id per file change in commit
|
|
111
|
+
op.timestamp = timestamp;
|
|
112
|
+
operations.push(op);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error('Error parsing Aider git history:', error.message);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (includeUndone) {
|
|
120
|
+
return operations;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Filter out operations already undone
|
|
124
|
+
const undoTracker = new UndoTracker();
|
|
125
|
+
await undoTracker.init();
|
|
126
|
+
const filteredOperations = await undoTracker.filterUndoneOperations(operations, 'aider-session');
|
|
127
|
+
|
|
128
|
+
return filteredOperations;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async getAllSessions() {
|
|
132
|
+
const hasAider = await this.detect();
|
|
133
|
+
if (hasAider) {
|
|
134
|
+
return [{
|
|
135
|
+
id: 'aider-session',
|
|
136
|
+
project: process.cwd(),
|
|
137
|
+
rawProjectDir: path.basename(process.cwd()),
|
|
138
|
+
file: 'git-history'
|
|
139
|
+
}];
|
|
140
|
+
}
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { createReadStream } from 'fs';
|
|
5
|
+
import { createInterface } from 'readline';
|
|
6
|
+
import { Operation, OperationType } from './Operation.js';
|
|
7
|
+
import { UndoTracker } from './UndoTracker.js';
|
|
8
|
+
import { AgentParser } from './AgentParser.js';
|
|
9
|
+
|
|
10
|
+
export class ClaudeSessionParser extends AgentParser {
|
|
11
|
+
constructor() {
|
|
12
|
+
super();
|
|
13
|
+
this.claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async getCurrentProjectDir() {
|
|
17
|
+
const cwd = process.cwd();
|
|
18
|
+
let safePath;
|
|
19
|
+
|
|
20
|
+
// Claude uses different formats for different operating systems
|
|
21
|
+
if (process.platform === 'win32') {
|
|
22
|
+
// Windows: C:\Users\... → C--Users-...
|
|
23
|
+
safePath = cwd.replace(/:[\\\/]/g, '--')
|
|
24
|
+
.replace(/[\\/]/g, '-')
|
|
25
|
+
.replace(/[\s_]/g, '-');
|
|
26
|
+
} else {
|
|
27
|
+
// Linux/macOS: /home/... → -home-...
|
|
28
|
+
safePath = cwd.replace(/[\s\/_]/g, '-');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return path.join(this.claudeProjectsDir, safePath);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async getCurrentSessionFile() {
|
|
35
|
+
const projectDir = await this.getCurrentProjectDir();
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const files = await fs.readdir(projectDir);
|
|
39
|
+
const sessionFiles = files.filter(f => f.endsWith('.jsonl'));
|
|
40
|
+
|
|
41
|
+
if (sessionFiles.length === 0) return null;
|
|
42
|
+
|
|
43
|
+
// Get the most recently modified session file
|
|
44
|
+
const stats = await Promise.all(
|
|
45
|
+
sessionFiles.map(async f => ({
|
|
46
|
+
file: f,
|
|
47
|
+
path: path.join(projectDir, f),
|
|
48
|
+
mtime: (await fs.stat(path.join(projectDir, f))).mtime
|
|
49
|
+
}))
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
stats.sort((a, b) => b.mtime - a.mtime);
|
|
53
|
+
return stats[0].path;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error.code === 'ENOENT') return null;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async parseSessionFile(sessionFile, includeUndone = false) {
|
|
61
|
+
const operations = [];
|
|
62
|
+
const fileStream = createReadStream(sessionFile);
|
|
63
|
+
const rl = createInterface({
|
|
64
|
+
input: fileStream,
|
|
65
|
+
crlfDelay: Infinity
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
for await (const line of rl) {
|
|
69
|
+
try {
|
|
70
|
+
const entry = JSON.parse(line);
|
|
71
|
+
|
|
72
|
+
// Look for tool use messages
|
|
73
|
+
if (entry.type === 'assistant' && entry.message?.content) {
|
|
74
|
+
for (const content of entry.message.content) {
|
|
75
|
+
if (content.type === 'tool_use') {
|
|
76
|
+
const operation = this.extractOperation(content, entry.timestamp);
|
|
77
|
+
if (operation) {
|
|
78
|
+
operations.push(operation);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch (e) {
|
|
84
|
+
// Skip invalid JSON lines
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (includeUndone) {
|
|
89
|
+
return operations;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Filter out operations that have already been undone
|
|
93
|
+
const undoTracker = new UndoTracker();
|
|
94
|
+
await undoTracker.init();
|
|
95
|
+
const filteredOperations = await undoTracker.filterUndoneOperations(operations, sessionFile);
|
|
96
|
+
|
|
97
|
+
return filteredOperations;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
extractOperation(toolUse, timestamp) {
|
|
101
|
+
const { name, input } = toolUse;
|
|
102
|
+
|
|
103
|
+
switch (name) {
|
|
104
|
+
case 'Write':
|
|
105
|
+
if (input.file_path) {
|
|
106
|
+
const op = new Operation(OperationType.FILE_CREATE, {
|
|
107
|
+
filePath: input.file_path,
|
|
108
|
+
content: input.content || ''
|
|
109
|
+
});
|
|
110
|
+
op.timestamp = new Date(timestamp);
|
|
111
|
+
op.id = toolUse.id;
|
|
112
|
+
return op;
|
|
113
|
+
}
|
|
114
|
+
break;
|
|
115
|
+
|
|
116
|
+
case 'Edit':
|
|
117
|
+
if (input.file_path) {
|
|
118
|
+
const op = new Operation(OperationType.FILE_EDIT, {
|
|
119
|
+
filePath: input.file_path,
|
|
120
|
+
// Note: We only have the string that was replaced, not the full file content
|
|
121
|
+
// This will be handled differently in UndoManager
|
|
122
|
+
oldString: input.old_string || '',
|
|
123
|
+
newString: input.new_string || '',
|
|
124
|
+
replaceAll: input.replace_all || false
|
|
125
|
+
});
|
|
126
|
+
op.timestamp = new Date(timestamp);
|
|
127
|
+
op.id = toolUse.id;
|
|
128
|
+
return op;
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
|
|
132
|
+
case 'MultiEdit':
|
|
133
|
+
if (input.file_path) {
|
|
134
|
+
// For MultiEdit, we have multiple edits but still just the strings
|
|
135
|
+
const op = new Operation(OperationType.FILE_EDIT, {
|
|
136
|
+
filePath: input.file_path,
|
|
137
|
+
edits: input.edits || [],
|
|
138
|
+
isMultiEdit: true
|
|
139
|
+
});
|
|
140
|
+
op.timestamp = new Date(timestamp);
|
|
141
|
+
op.id = toolUse.id;
|
|
142
|
+
return op;
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
|
|
146
|
+
case 'Bash':
|
|
147
|
+
if (input.command) {
|
|
148
|
+
const command = input.command;
|
|
149
|
+
|
|
150
|
+
// Try to detect file operations in bash commands
|
|
151
|
+
if (command.includes('rm ') && !command.includes('rmdir')) {
|
|
152
|
+
const match = command.match(/rm\s+(?:-[rf]+\s+)?([^\s]+)/);
|
|
153
|
+
if (match) {
|
|
154
|
+
const op = new Operation(OperationType.FILE_DELETE, {
|
|
155
|
+
filePath: match[1],
|
|
156
|
+
content: '' // We can't recover content from session history
|
|
157
|
+
});
|
|
158
|
+
op.timestamp = new Date(timestamp);
|
|
159
|
+
op.id = toolUse.id;
|
|
160
|
+
return op;
|
|
161
|
+
}
|
|
162
|
+
} else if (command.includes('mv ')) {
|
|
163
|
+
const match = command.match(/mv\s+([^\s]+)\s+([^\s]+)/);
|
|
164
|
+
if (match) {
|
|
165
|
+
const op = new Operation(OperationType.FILE_RENAME, {
|
|
166
|
+
oldPath: match[1],
|
|
167
|
+
newPath: match[2]
|
|
168
|
+
});
|
|
169
|
+
op.timestamp = new Date(timestamp);
|
|
170
|
+
op.id = toolUse.id;
|
|
171
|
+
return op;
|
|
172
|
+
}
|
|
173
|
+
} else if (command.includes('mkdir')) {
|
|
174
|
+
const match = command.match(/mkdir\s+(?:-p\s+)?([^\s]+)/);
|
|
175
|
+
if (match) {
|
|
176
|
+
const op = new Operation(OperationType.DIRECTORY_CREATE, {
|
|
177
|
+
dirPath: match[1]
|
|
178
|
+
});
|
|
179
|
+
op.timestamp = new Date(timestamp);
|
|
180
|
+
op.id = toolUse.id;
|
|
181
|
+
return op;
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
// Generic bash command
|
|
185
|
+
const op = new Operation(OperationType.BASH_COMMAND, {
|
|
186
|
+
command: command
|
|
187
|
+
});
|
|
188
|
+
op.timestamp = new Date(timestamp);
|
|
189
|
+
op.id = toolUse.id;
|
|
190
|
+
return op;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async getAllSessions() {
|
|
200
|
+
try {
|
|
201
|
+
const projectDirs = await fs.readdir(this.claudeProjectsDir);
|
|
202
|
+
const sessions = [];
|
|
203
|
+
|
|
204
|
+
for (const projectDir of projectDirs) {
|
|
205
|
+
const fullPath = path.join(this.claudeProjectsDir, projectDir);
|
|
206
|
+
const stat = await fs.stat(fullPath);
|
|
207
|
+
|
|
208
|
+
if (stat.isDirectory()) {
|
|
209
|
+
const files = await fs.readdir(fullPath);
|
|
210
|
+
const sessionFiles = files.filter(f => f.endsWith('.jsonl'));
|
|
211
|
+
|
|
212
|
+
for (const sessionFile of sessionFiles) {
|
|
213
|
+
const sessionId = sessionFile.replace('.jsonl', '');
|
|
214
|
+
// Convert back to proper path - reverse the Claude encoding
|
|
215
|
+
let projectPath = projectDir;
|
|
216
|
+
|
|
217
|
+
if (process.platform === 'win32') {
|
|
218
|
+
// Windows: C--Users-... → C:\Users\...
|
|
219
|
+
projectPath = projectPath.replace(/--/g, ':\\');
|
|
220
|
+
projectPath = projectPath.replace(/-/g, '\\');
|
|
221
|
+
} else {
|
|
222
|
+
// Linux/macOS: -home-... → /home/...
|
|
223
|
+
projectPath = projectPath.replace(/^-/, '/');
|
|
224
|
+
projectPath = projectPath.replace(/-/g, '/');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
sessions.push({
|
|
228
|
+
id: sessionId,
|
|
229
|
+
project: projectPath, // Decoded path for display
|
|
230
|
+
rawProjectDir: projectDir, // Raw directory name for comparison
|
|
231
|
+
file: path.join(fullPath, sessionFile)
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return sessions;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (error.code === 'ENOENT') return [];
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { DependencyResolver } from './DependencyResolver.js';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns the list of files involved in an operation.
|
|
6
|
+
* @param {import('./Operation.js').Operation} op
|
|
7
|
+
* @returns {string[]}
|
|
8
|
+
*/
|
|
9
|
+
export function getOperationFiles(op) {
|
|
10
|
+
const files = [];
|
|
11
|
+
if (op.data.filePath) files.push(op.data.filePath);
|
|
12
|
+
if (op.data.oldPath) files.push(op.data.oldPath);
|
|
13
|
+
if (op.data.newPath) files.push(op.data.newPath);
|
|
14
|
+
return files;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Given a selected operation to undo, returns only the operations that directly or
|
|
19
|
+
* transitively depend on it, filter-cascading from the reversed operations list.
|
|
20
|
+
* @param {number} selectedOpIndex
|
|
21
|
+
* @param {import('./Operation.js').Operation[]} reversedOps Newest first
|
|
22
|
+
* @param {string} workspaceRoot
|
|
23
|
+
* @returns {import('./Operation.js').Operation[]}
|
|
24
|
+
*/
|
|
25
|
+
export function getDependentOperations(selectedOpIndex, reversedOps, workspaceRoot) {
|
|
26
|
+
const dependentIndices = new Set([selectedOpIndex]);
|
|
27
|
+
|
|
28
|
+
// Extract all files involved in any of the operations for dependency tracing context
|
|
29
|
+
const allFiles = [...new Set(reversedOps.map(getOperationFiles).flat())];
|
|
30
|
+
|
|
31
|
+
// Process subsequent operations from oldest (closest to selected) to newest (index 0)
|
|
32
|
+
for (let i = selectedOpIndex - 1; i >= 0; i--) {
|
|
33
|
+
const op = reversedOps[i];
|
|
34
|
+
const filesB = getOperationFiles(op);
|
|
35
|
+
let depends = false;
|
|
36
|
+
|
|
37
|
+
for (const depIdx of dependentIndices) {
|
|
38
|
+
const depOp = reversedOps[depIdx];
|
|
39
|
+
const filesA = getOperationFiles(depOp);
|
|
40
|
+
|
|
41
|
+
// 1. File path overlap (e.g. editing the same file)
|
|
42
|
+
const overlap = filesA.some(f => filesB.includes(f));
|
|
43
|
+
if (overlap) {
|
|
44
|
+
depends = true;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 2. Import-based dependency check
|
|
49
|
+
for (const fileB of filesB) {
|
|
50
|
+
for (const fileA of filesA) {
|
|
51
|
+
if (DependencyResolver.transitivelyDependsOn(fileB, fileA, workspaceRoot, allFiles)) {
|
|
52
|
+
depends = true;
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (depends) break;
|
|
57
|
+
}
|
|
58
|
+
if (depends) break;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (depends) {
|
|
62
|
+
dependentIndices.add(i);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Return the selected operations preserving their reversed order (newest first)
|
|
67
|
+
const result = [];
|
|
68
|
+
for (let i = 0; i <= selectedOpIndex; i++) {
|
|
69
|
+
if (dependentIndices.has(i)) {
|
|
70
|
+
result.push(reversedOps[i]);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import { parse } from '@babel/parser';
|
|
5
|
+
|
|
6
|
+
export class DependencyResolver {
|
|
7
|
+
/**
|
|
8
|
+
* Parse a JS/TS file and return its relative import paths.
|
|
9
|
+
* @param {string} filePath
|
|
10
|
+
* @returns {string[]}
|
|
11
|
+
*/
|
|
12
|
+
static getJsImports(filePath) {
|
|
13
|
+
const imports = [];
|
|
14
|
+
let fileContent;
|
|
15
|
+
try {
|
|
16
|
+
fileContent = fs.readFileSync(filePath, 'utf8');
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const ast = parse(fileContent, {
|
|
23
|
+
sourceType: 'module',
|
|
24
|
+
plugins: ['typescript', 'jsx', 'classProperties', 'dynamicImport', 'decorators-legacy']
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const traverse = (node) => {
|
|
28
|
+
if (!node) return;
|
|
29
|
+
if (node.type === 'ImportDeclaration' && node.source && node.source.value) {
|
|
30
|
+
imports.push(node.source.value);
|
|
31
|
+
}
|
|
32
|
+
if (
|
|
33
|
+
node.type === 'CallExpression' &&
|
|
34
|
+
node.callee &&
|
|
35
|
+
node.callee.name === 'require' &&
|
|
36
|
+
node.arguments &&
|
|
37
|
+
node.arguments[0] &&
|
|
38
|
+
typeof node.arguments[0].value === 'string'
|
|
39
|
+
) {
|
|
40
|
+
imports.push(node.arguments[0].value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const key in node) {
|
|
44
|
+
if (node[key] && typeof node[key] === 'object') {
|
|
45
|
+
if (Array.isArray(node[key])) {
|
|
46
|
+
node[key].forEach(traverse);
|
|
47
|
+
} else {
|
|
48
|
+
traverse(node[key]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
traverse(ast.program);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
// Fallback: syntax error (half-written file) -> Regex matching
|
|
57
|
+
const importRegex = /import\s+.*?\s+from\s+['"](.*?)['"]|require\(['"](.*?)['"]\)/g;
|
|
58
|
+
let match;
|
|
59
|
+
while ((match = importRegex.exec(fileContent)) !== null) {
|
|
60
|
+
imports.push(match[1] || match[2]);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Filter to keep only relative paths within project
|
|
65
|
+
return imports.filter(imp => imp.startsWith('.') || imp.startsWith('/'));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse a Python file and return its module imports.
|
|
70
|
+
* @param {string} filePath
|
|
71
|
+
* @returns {string[]}
|
|
72
|
+
*/
|
|
73
|
+
static getPythonImports(filePath) {
|
|
74
|
+
const pythonScript = `
|
|
75
|
+
import ast, sys, json
|
|
76
|
+
try:
|
|
77
|
+
with open(sys.argv[1], "r", encoding="utf-8") as f:
|
|
78
|
+
tree = ast.parse(f.read())
|
|
79
|
+
imports = []
|
|
80
|
+
for node in ast.walk(tree):
|
|
81
|
+
if isinstance(node, ast.Import):
|
|
82
|
+
for name in node.names:
|
|
83
|
+
imports.append(name.name)
|
|
84
|
+
elif isinstance(node, ast.ImportFrom):
|
|
85
|
+
if node.module:
|
|
86
|
+
imports.append(node.module)
|
|
87
|
+
print(json.dumps(imports))
|
|
88
|
+
except Exception:
|
|
89
|
+
print("[]")
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const output = execSync(`python3 -c '${pythonScript.replace(/'/g, "'\\''")}' "${filePath}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] });
|
|
94
|
+
return JSON.parse(output.trim() || '[]');
|
|
95
|
+
} catch (e) {
|
|
96
|
+
// Fallback: Regex matching if python3 ast fails or is unavailable
|
|
97
|
+
const imports = [];
|
|
98
|
+
try {
|
|
99
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
100
|
+
const importRegex = /^(?:from\s+(\S+)\s+import|import\s+(\S+))/gm;
|
|
101
|
+
let match;
|
|
102
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
103
|
+
const matched = match[1] || match[2];
|
|
104
|
+
if (matched) imports.push(matched);
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {}
|
|
107
|
+
return imports;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve an import statement relative to the importer file.
|
|
113
|
+
* @param {string} importerPath Absolute path of the file importing
|
|
114
|
+
* @param {string} importStr The raw string import path (e.g. '../../utils')
|
|
115
|
+
* @returns {string|null} Resolved absolute path or null
|
|
116
|
+
*/
|
|
117
|
+
static resolveJsPath(importerPath, importStr) {
|
|
118
|
+
const dir = path.dirname(importerPath);
|
|
119
|
+
const absolute = path.resolve(dir, importStr);
|
|
120
|
+
|
|
121
|
+
// Check possible extensions
|
|
122
|
+
const extensions = ['.js', '.jsx', '.ts', '.tsx', '/index.js', '/index.ts'];
|
|
123
|
+
if (fs.existsSync(absolute) && fs.statSync(absolute).isFile()) {
|
|
124
|
+
return absolute;
|
|
125
|
+
}
|
|
126
|
+
for (const ext of extensions) {
|
|
127
|
+
const candidate = absolute + ext;
|
|
128
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
129
|
+
return candidate;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Check if fileB depends on fileA.
|
|
137
|
+
* @param {string} fileB Importer candidate
|
|
138
|
+
* @param {string} fileA Target dependency candidate
|
|
139
|
+
* @param {string} workspaceRoot The workspace root
|
|
140
|
+
* @returns {boolean}
|
|
141
|
+
*/
|
|
142
|
+
static dependsOn(fileB, fileA, workspaceRoot) {
|
|
143
|
+
const extB = path.extname(fileB);
|
|
144
|
+
const absA = path.resolve(workspaceRoot, fileA);
|
|
145
|
+
const absB = path.resolve(workspaceRoot, fileB);
|
|
146
|
+
|
|
147
|
+
if (absA === absB) return false;
|
|
148
|
+
|
|
149
|
+
// JavaScript / TypeScript dependency resolution
|
|
150
|
+
if (['.js', '.jsx', '.ts', '.tsx'].includes(extB)) {
|
|
151
|
+
const imports = this.getJsImports(absB);
|
|
152
|
+
for (const imp of imports) {
|
|
153
|
+
const resolved = this.resolveJsPath(absB, imp);
|
|
154
|
+
if (resolved && resolved === absA) {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Python dependency resolution
|
|
161
|
+
if (extB === '.py') {
|
|
162
|
+
const imports = this.getPythonImports(absB);
|
|
163
|
+
// Python imports can be e.g. 'src.utils.math' or '.utils'
|
|
164
|
+
const baseNameA = path.basename(fileA, '.py');
|
|
165
|
+
for (const imp of imports) {
|
|
166
|
+
// Simple module name match (e.g. if fileA is src/utils/math.py, imp contains 'math' or 'src.utils.math')
|
|
167
|
+
if (imp === baseNameA || imp.endsWith('.' + baseNameA) || fileA.replace(/\//g, '.').includes(imp)) {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Determine transitive dependency map to see if fileB transitively depends on fileA.
|
|
178
|
+
* Uses BFS to trace dependency chain.
|
|
179
|
+
* @param {string} fileB Importer candidate
|
|
180
|
+
* @param {string} fileA Target dependency candidate
|
|
181
|
+
* @param {string} workspaceRoot The workspace root
|
|
182
|
+
* @param {string[]} allFiles All modified files in the session context
|
|
183
|
+
* @returns {boolean}
|
|
184
|
+
*/
|
|
185
|
+
static transitivelyDependsOn(fileB, fileA, workspaceRoot, allFiles) {
|
|
186
|
+
const queue = [fileB];
|
|
187
|
+
const visited = new Set();
|
|
188
|
+
|
|
189
|
+
while (queue.length > 0) {
|
|
190
|
+
const current = queue.shift();
|
|
191
|
+
if (current === fileA) return true;
|
|
192
|
+
if (visited.has(current)) continue;
|
|
193
|
+
visited.add(current);
|
|
194
|
+
|
|
195
|
+
for (const otherFile of allFiles) {
|
|
196
|
+
if (otherFile !== current && !visited.has(otherFile)) {
|
|
197
|
+
if (this.dependsOn(current, otherFile, workspaceRoot)) {
|
|
198
|
+
queue.push(otherFile);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|