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