kiro-spec-engine 1.4.4 → 1.5.2

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.
@@ -85,10 +85,11 @@ class AdoptionStrategy {
85
85
  * @param {Object} options - Copy options
86
86
  * @param {boolean} options.overwrite - Whether to overwrite existing files
87
87
  * @param {string[]} options.skip - Files to skip
88
+ * @param {Object} options.resolutionMap - Map of file paths to resolutions ('keep' | 'overwrite')
88
89
  * @returns {Promise<{created: string[], updated: string[], skipped: string[]}>}
89
90
  */
90
91
  async copyTemplateFiles(projectPath, options = {}) {
91
- const { overwrite = false, skip = [] } = options;
92
+ const { overwrite = false, skip = [], resolutionMap = {} } = options;
92
93
  const kiroPath = this.getKiroPath(projectPath);
93
94
  const templatePath = this.getTemplatePath();
94
95
 
@@ -124,6 +125,15 @@ class AdoptionStrategy {
124
125
  continue;
125
126
  }
126
127
 
128
+ // Check resolution map for this file
129
+ if (resolutionMap[file]) {
130
+ if (resolutionMap[file] === 'keep') {
131
+ skipped.push(file);
132
+ continue;
133
+ }
134
+ // If 'overwrite', proceed with copying
135
+ }
136
+
127
137
  const sourcePath = path.join(templatePath, file);
128
138
  const destPath = path.join(kiroPath, file);
129
139
 
@@ -137,13 +147,19 @@ class AdoptionStrategy {
137
147
  // Check if destination exists
138
148
  const destExists = await pathExists(destPath);
139
149
 
140
- if (destExists && !overwrite) {
150
+ // Determine if we should overwrite
151
+ let shouldOverwrite = overwrite;
152
+ if (resolutionMap[file] === 'overwrite') {
153
+ shouldOverwrite = true;
154
+ }
155
+
156
+ if (destExists && !shouldOverwrite) {
141
157
  skipped.push(file);
142
158
  continue;
143
159
  }
144
160
 
145
161
  try {
146
- await safeCopy(sourcePath, destPath, { overwrite });
162
+ await safeCopy(sourcePath, destPath, { overwrite: shouldOverwrite });
147
163
 
148
164
  if (destExists) {
149
165
  updated.push(file);
@@ -263,7 +279,7 @@ class PartialAdoption extends AdoptionStrategy {
263
279
  * @returns {Promise<AdoptionResult>}
264
280
  */
265
281
  async execute(projectPath, mode, options = {}) {
266
- const { kseVersion = '1.0.0', dryRun = false, backupId = null } = options;
282
+ const { kseVersion = '1.0.0', dryRun = false, backupId = null, force = false, resolutionMap = {} } = options;
267
283
 
268
284
  const filesCreated = [];
269
285
  const filesUpdated = [];
@@ -326,8 +342,8 @@ class PartialAdoption extends AdoptionStrategy {
326
342
  filesCreated.push('backups/');
327
343
  }
328
344
 
329
- // Copy template files (don't overwrite existing)
330
- const copyResult = await this.copyTemplateFiles(projectPath, { overwrite: false });
345
+ // Copy template files (overwrite if force is enabled)
346
+ const copyResult = await this.copyTemplateFiles(projectPath, { overwrite: force, resolutionMap });
331
347
  filesCreated.push(...copyResult.created);
332
348
  filesUpdated.push(...copyResult.updated);
333
349
  filesSkipped.push(...copyResult.skipped);
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Conflict Resolver
3
+ *
4
+ * Manages interactive conflict resolution prompts and user decisions.
5
+ * Provides three-tier resolution: skip-all, overwrite-all, or review-each.
6
+ */
7
+
8
+ const chalk = require('chalk');
9
+ const inquirer = require('inquirer');
10
+ const path = require('path');
11
+ const DiffViewer = require('./diff-viewer');
12
+
13
+ /**
14
+ * ConflictResolver class for interactive conflict resolution
15
+ */
16
+ class ConflictResolver {
17
+ constructor() {
18
+ this.diffViewer = new DiffViewer();
19
+ }
20
+
21
+ /**
22
+ * Displays conflict summary grouped by category
23
+ *
24
+ * @param {FileConflict[]} conflicts - Array of conflicts
25
+ * @returns {void}
26
+ */
27
+ displayConflictSummary(conflicts) {
28
+ console.log();
29
+ console.log(chalk.yellow('⚠️ Conflicts Detected'));
30
+ console.log(chalk.yellow('═══════════════════════════════════════════════════════'));
31
+ console.log();
32
+
33
+ // Categorize conflicts
34
+ const categorized = this.categorizeConflicts(conflicts);
35
+
36
+ // Display by category
37
+ if (categorized.steering.length > 0) {
38
+ console.log(chalk.blue('Steering Files:'));
39
+ categorized.steering.forEach(c => console.log(` - ${c.path}`));
40
+ console.log();
41
+ }
42
+
43
+ if (categorized.documentation.length > 0) {
44
+ console.log(chalk.blue('Documentation:'));
45
+ categorized.documentation.forEach(c => console.log(` - ${c.path}`));
46
+ console.log();
47
+ }
48
+
49
+ if (categorized.tools.length > 0) {
50
+ console.log(chalk.blue('Tools:'));
51
+ categorized.tools.forEach(c => console.log(` - ${c.path}`));
52
+ console.log();
53
+ }
54
+
55
+ if (categorized.other.length > 0) {
56
+ console.log(chalk.blue('Other:'));
57
+ categorized.other.forEach(c => console.log(` - ${c.path}`));
58
+ console.log();
59
+ }
60
+
61
+ console.log(chalk.yellow(`Total: ${conflicts.length} conflict(s)`));
62
+ console.log(chalk.yellow('═══════════════════════════════════════════════════════'));
63
+ console.log();
64
+ }
65
+
66
+ /**
67
+ * Categorizes conflicts by type
68
+ *
69
+ * @param {FileConflict[]} conflicts - Array of conflicts
70
+ * @returns {CategorizedConflicts}
71
+ */
72
+ categorizeConflicts(conflicts) {
73
+ return {
74
+ steering: conflicts.filter(c => c.path.startsWith('steering/')),
75
+ documentation: conflicts.filter(c =>
76
+ c.path.endsWith('.md') && !c.path.startsWith('steering/')
77
+ ),
78
+ tools: conflicts.filter(c => c.path.startsWith('tools/')),
79
+ other: conflicts.filter(c =>
80
+ !c.path.startsWith('steering/') &&
81
+ !c.path.startsWith('tools/') &&
82
+ !c.path.endsWith('.md')
83
+ )
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Prompts user for overall conflict resolution strategy
89
+ *
90
+ * @param {FileConflict[]} conflicts - Array of detected conflicts
91
+ * @returns {Promise<ConflictStrategy>} - 'skip-all' | 'overwrite-all' | 'review-each'
92
+ */
93
+ async promptStrategy(conflicts) {
94
+ const { strategy } = await inquirer.prompt([
95
+ {
96
+ type: 'list',
97
+ name: 'strategy',
98
+ message: 'How would you like to handle these conflicts?',
99
+ choices: [
100
+ {
101
+ name: 'Skip conflicting files (keep existing files)',
102
+ value: 'skip-all'
103
+ },
104
+ {
105
+ name: 'Overwrite conflicting files (backup will be created)',
106
+ value: 'overwrite-all'
107
+ },
108
+ {
109
+ name: 'Review conflicts one by one',
110
+ value: 'review-each'
111
+ }
112
+ ],
113
+ default: 'skip-all'
114
+ }
115
+ ]);
116
+
117
+ return strategy;
118
+ }
119
+
120
+ /**
121
+ * Prompts user for resolution of a single file conflict
122
+ *
123
+ * @param {FileConflict} conflict - The conflict to resolve
124
+ * @param {number} currentIndex - Current conflict number (for display)
125
+ * @param {number} totalConflicts - Total number of conflicts
126
+ * @param {string} projectPath - Project root path
127
+ * @returns {Promise<FileResolution>} - 'keep' | 'overwrite'
128
+ */
129
+ async promptFileResolution(conflict, currentIndex, totalConflicts, projectPath) {
130
+ console.log();
131
+ console.log(chalk.blue('─────────────────────────────────────────────────────'));
132
+ console.log(chalk.blue(`Conflict ${currentIndex} of ${totalConflicts}`));
133
+ console.log(chalk.blue('─────────────────────────────────────────────────────'));
134
+ console.log();
135
+ console.log(chalk.cyan('File:'), conflict.path);
136
+ console.log();
137
+
138
+ let resolution = null;
139
+
140
+ while (resolution === null) {
141
+ const { action } = await inquirer.prompt([
142
+ {
143
+ type: 'list',
144
+ name: 'action',
145
+ message: 'What would you like to do?',
146
+ choices: [
147
+ {
148
+ name: 'Keep existing file',
149
+ value: 'keep'
150
+ },
151
+ {
152
+ name: 'Use template file (backup will be created)',
153
+ value: 'overwrite'
154
+ },
155
+ {
156
+ name: 'View diff',
157
+ value: 'view-diff'
158
+ }
159
+ ],
160
+ default: 'keep'
161
+ }
162
+ ]);
163
+
164
+ if (action === 'view-diff') {
165
+ // Show diff
166
+ const existingPath = path.join(projectPath, '.kiro', conflict.path);
167
+ const templatePath = conflict.templatePath || path.join(projectPath, 'template', '.kiro', conflict.path);
168
+
169
+ await this.diffViewer.showDiff(existingPath, templatePath);
170
+
171
+ // Re-prompt with only keep/overwrite options
172
+ const { finalAction } = await inquirer.prompt([
173
+ {
174
+ type: 'list',
175
+ name: 'finalAction',
176
+ message: 'After viewing the diff, what would you like to do?',
177
+ choices: [
178
+ {
179
+ name: 'Keep existing file',
180
+ value: 'keep'
181
+ },
182
+ {
183
+ name: 'Use template file (backup will be created)',
184
+ value: 'overwrite'
185
+ }
186
+ ],
187
+ default: 'keep'
188
+ }
189
+ ]);
190
+
191
+ resolution = finalAction;
192
+ } else {
193
+ resolution = action;
194
+ }
195
+ }
196
+
197
+ return resolution;
198
+ }
199
+
200
+ /**
201
+ * Processes all conflicts based on strategy and returns resolution map
202
+ *
203
+ * @param {FileConflict[]} conflicts - Array of conflicts
204
+ * @param {ConflictStrategy} strategy - Overall strategy
205
+ * @param {string} projectPath - Project root path
206
+ * @returns {Promise<ResolutionMap>} - Map of file paths to resolutions
207
+ */
208
+ async resolveConflicts(conflicts, strategy, projectPath) {
209
+ const resolutionMap = {};
210
+
211
+ if (strategy === 'skip-all') {
212
+ // Mark all as 'keep'
213
+ conflicts.forEach(conflict => {
214
+ resolutionMap[conflict.path] = 'keep';
215
+ });
216
+ } else if (strategy === 'overwrite-all') {
217
+ // Mark all as 'overwrite'
218
+ conflicts.forEach(conflict => {
219
+ resolutionMap[conflict.path] = 'overwrite';
220
+ });
221
+ } else if (strategy === 'review-each') {
222
+ // Prompt for each conflict
223
+ for (let i = 0; i < conflicts.length; i++) {
224
+ const conflict = conflicts[i];
225
+ const resolution = await this.promptFileResolution(
226
+ conflict,
227
+ i + 1,
228
+ conflicts.length,
229
+ projectPath
230
+ );
231
+ resolutionMap[conflict.path] = resolution;
232
+ }
233
+ }
234
+
235
+ return resolutionMap;
236
+ }
237
+ }
238
+
239
+ module.exports = ConflictResolver;
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Diff Viewer
3
+ *
4
+ * Displays file differences in a user-friendly format for conflict review.
5
+ * Shows metadata comparison and text diffs for files during adoption.
6
+ */
7
+
8
+ const fs = require('fs').promises;
9
+ const path = require('path');
10
+ const chalk = require('chalk');
11
+ const { pathExists } = require('../utils/fs-utils');
12
+
13
+ /**
14
+ * DiffViewer class for displaying file comparisons
15
+ */
16
+ class DiffViewer {
17
+ /**
18
+ * Gets file metadata for comparison
19
+ *
20
+ * @param {string} filePath - Path to file
21
+ * @returns {Promise<FileMetadata>}
22
+ */
23
+ async getFileMetadata(filePath) {
24
+ const exists = await pathExists(filePath);
25
+
26
+ if (!exists) {
27
+ return {
28
+ path: filePath,
29
+ size: 0,
30
+ sizeFormatted: '0 B',
31
+ modified: null,
32
+ modifiedFormatted: 'N/A',
33
+ isText: false,
34
+ isBinary: false,
35
+ exists: false
36
+ };
37
+ }
38
+
39
+ const stats = await fs.stat(filePath);
40
+ const size = stats.size;
41
+ const modified = stats.mtime.toISOString();
42
+
43
+ // Format size
44
+ const sizeFormatted = this.formatSize(size);
45
+
46
+ // Format date
47
+ const modifiedFormatted = this.formatDate(stats.mtime);
48
+
49
+ // Detect if file is text or binary
50
+ const isText = await this.isTextFile(filePath);
51
+ const isBinary = !isText;
52
+
53
+ return {
54
+ path: filePath,
55
+ size,
56
+ sizeFormatted,
57
+ modified,
58
+ modifiedFormatted,
59
+ isText,
60
+ isBinary,
61
+ exists: true
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Formats file size in human-readable format
67
+ *
68
+ * @param {number} bytes - File size in bytes
69
+ * @returns {string}
70
+ */
71
+ formatSize(bytes) {
72
+ if (bytes === 0) return '0 B';
73
+
74
+ const units = ['B', 'KB', 'MB', 'GB'];
75
+ const k = 1024;
76
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
77
+
78
+ return `${(bytes / Math.pow(k, i)).toFixed(1)} ${units[i]}`;
79
+ }
80
+
81
+ /**
82
+ * Formats date in human-readable format
83
+ *
84
+ * @param {Date} date - Date object
85
+ * @returns {string}
86
+ */
87
+ formatDate(date) {
88
+ const year = date.getFullYear();
89
+ const month = String(date.getMonth() + 1).padStart(2, '0');
90
+ const day = String(date.getDate()).padStart(2, '0');
91
+ const hours = String(date.getHours()).padStart(2, '0');
92
+ const minutes = String(date.getMinutes()).padStart(2, '0');
93
+ const seconds = String(date.getSeconds()).padStart(2, '0');
94
+
95
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
96
+ }
97
+
98
+ /**
99
+ * Detects if a file is text or binary
100
+ *
101
+ * @param {string} filePath - Path to file
102
+ * @returns {Promise<boolean>}
103
+ */
104
+ async isTextFile(filePath) {
105
+ try {
106
+ // Read first 8KB to detect binary content
107
+ const buffer = Buffer.alloc(8192);
108
+ const fd = await fs.open(filePath, 'r');
109
+ const { bytesRead } = await fd.read(buffer, 0, 8192, 0);
110
+ await fd.close();
111
+
112
+ // Check for null bytes (common in binary files)
113
+ for (let i = 0; i < bytesRead; i++) {
114
+ if (buffer[i] === 0) {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ return true;
120
+ } catch (error) {
121
+ // If we can't read it, assume binary
122
+ return false;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Displays a summary diff between existing and template files
128
+ *
129
+ * @param {string} existingPath - Path to existing file
130
+ * @param {string} templatePath - Path to template file
131
+ * @returns {Promise<void>}
132
+ */
133
+ async showDiff(existingPath, templatePath) {
134
+ console.log();
135
+ console.log(chalk.blue('═══════════════════════════════════════════════════════'));
136
+ console.log(chalk.blue('Comparing:'), chalk.cyan(path.basename(existingPath)));
137
+ console.log(chalk.blue('═══════════════════════════════════════════════════════'));
138
+ console.log();
139
+
140
+ // Get metadata for both files
141
+ const existingMeta = await this.getFileMetadata(existingPath);
142
+ const templateMeta = await this.getFileMetadata(templatePath);
143
+
144
+ // Display metadata comparison
145
+ console.log(chalk.yellow('Existing File:'));
146
+ console.log(` Size: ${existingMeta.sizeFormatted}`);
147
+ console.log(` Modified: ${existingMeta.modifiedFormatted}`);
148
+ console.log();
149
+
150
+ console.log(chalk.green('Template File:'));
151
+ console.log(` Size: ${templateMeta.sizeFormatted}`);
152
+ console.log(` Modified: ${templateMeta.modifiedFormatted}`);
153
+ console.log();
154
+
155
+ // Show diff content if both are text files
156
+ if (existingMeta.isText && templateMeta.isText) {
157
+ await this.showLineDiff(existingPath, templatePath, 10);
158
+ } else if (existingMeta.isBinary || templateMeta.isBinary) {
159
+ console.log(chalk.gray('⚠️ Binary file - detailed diff not available'));
160
+ console.log(chalk.gray(' Open files in an editor to compare'));
161
+ }
162
+
163
+ console.log();
164
+ console.log(chalk.blue('═══════════════════════════════════════════════════════'));
165
+ console.log();
166
+ }
167
+
168
+ /**
169
+ * Displays first N lines of differences
170
+ *
171
+ * @param {string} existingPath - Path to existing file
172
+ * @param {string} templatePath - Path to template file
173
+ * @param {number} maxLines - Maximum lines to show (default: 10)
174
+ * @returns {Promise<void>}
175
+ */
176
+ async showLineDiff(existingPath, templatePath, maxLines = 10) {
177
+ try {
178
+ const existingContent = await fs.readFile(existingPath, 'utf-8');
179
+ const templateContent = await fs.readFile(templatePath, 'utf-8');
180
+
181
+ const existingLines = existingContent.split('\n');
182
+ const templateLines = templateContent.split('\n');
183
+
184
+ console.log(chalk.blue('First differences:'));
185
+ console.log();
186
+
187
+ let diffsShown = 0;
188
+ const maxLineNum = Math.max(existingLines.length, templateLines.length);
189
+
190
+ for (let i = 0; i < maxLineNum && diffsShown < maxLines; i++) {
191
+ const existingLine = existingLines[i] || '';
192
+ const templateLine = templateLines[i] || '';
193
+
194
+ if (existingLine !== templateLine) {
195
+ console.log(chalk.gray(` Line ${i + 1}:`));
196
+
197
+ if (existingLine) {
198
+ console.log(chalk.red(` - ${existingLine.substring(0, 80)}`));
199
+ }
200
+
201
+ if (templateLine) {
202
+ console.log(chalk.green(` + ${templateLine.substring(0, 80)}`));
203
+ }
204
+
205
+ console.log();
206
+ diffsShown++;
207
+ }
208
+ }
209
+
210
+ if (diffsShown === 0) {
211
+ console.log(chalk.gray(' No differences found in first 10 lines'));
212
+ console.log();
213
+ } else if (diffsShown >= maxLines) {
214
+ console.log(chalk.gray(` ... (showing first ${maxLines} differences)`));
215
+ console.log();
216
+ }
217
+
218
+ console.log(chalk.gray('[Note: Full diff available by opening files in editor]'));
219
+ } catch (error) {
220
+ console.log(chalk.red('⚠️ Unable to generate diff'));
221
+ console.log(chalk.gray(` ${error.message}`));
222
+ }
223
+ }
224
+ }
225
+
226
+ module.exports = DiffViewer;