kiro-spec-engine 1.1.0 → 1.2.0

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,242 @@
1
+ /**
2
+ * Detection Engine
3
+ *
4
+ * Analyzes project structure and determines the appropriate adoption strategy.
5
+ * Detects project type, existing .kiro/ components, and potential conflicts.
6
+ */
7
+
8
+ const path = require('path');
9
+ const {
10
+ pathExists,
11
+ listFiles,
12
+ readJSON
13
+ } = require('../utils/fs-utils');
14
+
15
+ class DetectionEngine {
16
+ constructor() {
17
+ this.kiroDir = '.kiro';
18
+ this.versionFile = 'version.json';
19
+ this.specsDir = 'specs';
20
+ this.steeringDir = 'steering';
21
+ this.toolsDir = 'tools';
22
+ }
23
+
24
+ /**
25
+ * Analyzes project directory and returns detection result
26
+ *
27
+ * @param {string} projectPath - Absolute path to project root
28
+ * @returns {Promise<DetectionResult>}
29
+ */
30
+ async analyze(projectPath) {
31
+ try {
32
+ const kiroPath = path.join(projectPath, this.kiroDir);
33
+ const hasKiroDir = await pathExists(kiroPath);
34
+
35
+ let hasVersionFile = false;
36
+ let hasSpecs = false;
37
+ let hasSteering = false;
38
+ let hasTools = false;
39
+ let existingVersion = null;
40
+
41
+ if (hasKiroDir) {
42
+ // Check for version.json
43
+ const versionPath = path.join(kiroPath, this.versionFile);
44
+ hasVersionFile = await pathExists(versionPath);
45
+
46
+ if (hasVersionFile) {
47
+ try {
48
+ const versionInfo = await readJSON(versionPath);
49
+ existingVersion = versionInfo['kse-version'] || null;
50
+ } catch (error) {
51
+ // Invalid version file
52
+ hasVersionFile = false;
53
+ }
54
+ }
55
+
56
+ // Check for specs/
57
+ const specsPath = path.join(kiroPath, this.specsDir);
58
+ hasSpecs = await pathExists(specsPath);
59
+
60
+ // Check for steering/
61
+ const steeringPath = path.join(kiroPath, this.steeringDir);
62
+ hasSteering = await pathExists(steeringPath);
63
+
64
+ // Check for tools/
65
+ const toolsPath = path.join(kiroPath, this.toolsDir);
66
+ hasTools = await pathExists(toolsPath);
67
+ }
68
+
69
+ // Detect project type
70
+ const projectType = await this.detectProjectType(projectPath);
71
+
72
+ // Detect conflicts (only if we're going to add template files)
73
+ const conflicts = hasKiroDir ? await this.detectConflicts(projectPath) : [];
74
+
75
+ return {
76
+ hasKiroDir,
77
+ hasVersionFile,
78
+ hasSpecs,
79
+ hasSteering,
80
+ hasTools,
81
+ projectType,
82
+ existingVersion,
83
+ conflicts
84
+ };
85
+ } catch (error) {
86
+ throw new Error(`Failed to analyze project: ${error.message}`);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Determines which adoption strategy to use
92
+ *
93
+ * @param {DetectionResult} result - Detection result from analyze()
94
+ * @returns {AdoptionMode} - 'fresh', 'partial', or 'full'
95
+ */
96
+ determineStrategy(result) {
97
+ // Fresh adoption: no .kiro/ directory
98
+ if (!result.hasKiroDir) {
99
+ return 'fresh';
100
+ }
101
+
102
+ // Partial adoption: .kiro/ exists but no version.json
103
+ if (!result.hasVersionFile) {
104
+ return 'partial';
105
+ }
106
+
107
+ // Full adoption: complete .kiro/ with version.json
108
+ return 'full';
109
+ }
110
+
111
+ /**
112
+ * Detects project type (Node.js, Python, mixed, unknown)
113
+ *
114
+ * @param {string} projectPath - Absolute path to project root
115
+ * @returns {Promise<ProjectType>}
116
+ */
117
+ async detectProjectType(projectPath) {
118
+ try {
119
+ const hasPackageJson = await pathExists(path.join(projectPath, 'package.json'));
120
+ const hasRequirementsTxt = await pathExists(path.join(projectPath, 'requirements.txt'));
121
+ const hasPyprojectToml = await pathExists(path.join(projectPath, 'pyproject.toml'));
122
+ const hasSetupPy = await pathExists(path.join(projectPath, 'setup.py'));
123
+
124
+ const isNodeJs = hasPackageJson;
125
+ const isPython = hasRequirementsTxt || hasPyprojectToml || hasSetupPy;
126
+
127
+ if (isNodeJs && isPython) {
128
+ return 'mixed';
129
+ } else if (isNodeJs) {
130
+ return 'nodejs';
131
+ } else if (isPython) {
132
+ return 'python';
133
+ } else {
134
+ return 'unknown';
135
+ }
136
+ } catch (error) {
137
+ throw new Error(`Failed to detect project type: ${error.message}`);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Detects conflicts between existing files and template files
143
+ *
144
+ * @param {string} projectPath - Absolute path to project root
145
+ * @returns {Promise<FileConflict[]>}
146
+ */
147
+ async detectConflicts(projectPath) {
148
+ const conflicts = [];
149
+
150
+ try {
151
+ const kiroPath = path.join(projectPath, this.kiroDir);
152
+
153
+ // Define template files that might conflict
154
+ const templateFiles = [
155
+ 'steering/CORE_PRINCIPLES.md',
156
+ 'steering/ENVIRONMENT.md',
157
+ 'steering/CURRENT_CONTEXT.md',
158
+ 'steering/RULES_GUIDE.md',
159
+ 'tools/ultrawork_enhancer.py',
160
+ 'README.md',
161
+ 'ultrawork-application-guide.md',
162
+ 'ultrawork-integration-summary.md',
163
+ 'sisyphus-deep-dive.md'
164
+ ];
165
+
166
+ for (const templateFile of templateFiles) {
167
+ const filePath = path.join(kiroPath, templateFile);
168
+ const exists = await pathExists(filePath);
169
+
170
+ if (exists) {
171
+ conflicts.push({
172
+ path: templateFile,
173
+ type: 'file',
174
+ existingContent: filePath,
175
+ templateContent: `template:${templateFile}`
176
+ });
177
+ }
178
+ }
179
+
180
+ return conflicts;
181
+ } catch (error) {
182
+ throw new Error(`Failed to detect conflicts: ${error.message}`);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Validates that a project path is valid
188
+ *
189
+ * @param {string} projectPath - Path to validate
190
+ * @returns {Promise<boolean>}
191
+ */
192
+ async validateProjectPath(projectPath) {
193
+ try {
194
+ const exists = await pathExists(projectPath);
195
+ if (!exists) {
196
+ return false;
197
+ }
198
+
199
+ // Check if it's a directory
200
+ const fs = require('fs-extra');
201
+ const stats = await fs.stat(projectPath);
202
+ return stats.isDirectory();
203
+ } catch (error) {
204
+ return false;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Gets a summary of the detection result for display
210
+ *
211
+ * @param {DetectionResult} result - Detection result
212
+ * @returns {string} - Human-readable summary
213
+ */
214
+ getSummary(result) {
215
+ const lines = [];
216
+
217
+ lines.push('Project Analysis:');
218
+ lines.push(` Project Type: ${result.projectType}`);
219
+ lines.push(` .kiro/ Directory: ${result.hasKiroDir ? 'Yes' : 'No'}`);
220
+
221
+ if (result.hasKiroDir) {
222
+ lines.push(` version.json: ${result.hasVersionFile ? 'Yes' : 'No'}`);
223
+ if (result.existingVersion) {
224
+ lines.push(` Current Version: ${result.existingVersion}`);
225
+ }
226
+ lines.push(` specs/: ${result.hasSpecs ? 'Yes' : 'No'}`);
227
+ lines.push(` steering/: ${result.hasSteering ? 'Yes' : 'No'}`);
228
+ lines.push(` tools/: ${result.hasTools ? 'Yes' : 'No'}`);
229
+
230
+ if (result.conflicts.length > 0) {
231
+ lines.push(` Conflicts: ${result.conflicts.length} file(s)`);
232
+ }
233
+ }
234
+
235
+ const strategy = this.determineStrategy(result);
236
+ lines.push(` Recommended Strategy: ${strategy}`);
237
+
238
+ return lines.join('\n');
239
+ }
240
+ }
241
+
242
+ module.exports = DetectionEngine;
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Backup System
3
+ *
4
+ * Creates, manages, and restores backups of the .kiro/ directory.
5
+ * Provides rollback capability for safe adoption and upgrade operations.
6
+ */
7
+
8
+ const path = require('path');
9
+ const fs = require('fs-extra');
10
+ const {
11
+ pathExists,
12
+ copyDirectory,
13
+ ensureDirectory,
14
+ listFiles,
15
+ listFilesRecursive,
16
+ getDirectorySize,
17
+ readJSON,
18
+ writeJSON,
19
+ remove
20
+ } = require('../utils/fs-utils');
21
+
22
+ class BackupSystem {
23
+ constructor() {
24
+ this.backupDirName = 'backups';
25
+ }
26
+
27
+ /**
28
+ * Gets the path to the backups directory
29
+ *
30
+ * @param {string} projectPath - Absolute path to project root
31
+ * @returns {string} - Absolute path to backups directory
32
+ */
33
+ getBackupDir(projectPath) {
34
+ return path.join(projectPath, '.kiro', this.backupDirName);
35
+ }
36
+
37
+ /**
38
+ * Gets the path to the .kiro directory
39
+ *
40
+ * @param {string} projectPath - Absolute path to project root
41
+ * @returns {string} - Absolute path to .kiro directory
42
+ */
43
+ getKiroDir(projectPath) {
44
+ return path.join(projectPath, '.kiro');
45
+ }
46
+
47
+ /**
48
+ * Generates a backup ID based on type and timestamp
49
+ *
50
+ * @param {string} type - Backup type (adopt, upgrade, pre-rollback)
51
+ * @returns {string} - Backup ID (e.g., "adopt-2026-01-23-100000")
52
+ */
53
+ generateBackupId(type) {
54
+ const now = new Date();
55
+ const year = now.getFullYear();
56
+ const month = String(now.getMonth() + 1).padStart(2, '0');
57
+ const day = String(now.getDate()).padStart(2, '0');
58
+ const hours = String(now.getHours()).padStart(2, '0');
59
+ const minutes = String(now.getMinutes()).padStart(2, '0');
60
+ const seconds = String(now.getSeconds()).padStart(2, '0');
61
+
62
+ return `${type}-${year}-${month}-${day}-${hours}${minutes}${seconds}`;
63
+ }
64
+
65
+ /**
66
+ * Creates backup of .kiro/ directory
67
+ *
68
+ * @param {string} projectPath - Absolute path to project root
69
+ * @param {Object} options - Backup options
70
+ * @param {string} options.type - Backup type (adopt, upgrade, pre-rollback)
71
+ * @returns {Promise<BackupInfo>}
72
+ */
73
+ async createBackup(projectPath, options = {}) {
74
+ const { type = 'manual' } = options;
75
+
76
+ try {
77
+ const kiroDir = this.getKiroDir(projectPath);
78
+
79
+ // Check if .kiro/ exists
80
+ const kiroExists = await pathExists(kiroDir);
81
+ if (!kiroExists) {
82
+ throw new Error('.kiro/ directory does not exist');
83
+ }
84
+
85
+ // Create backups directory if it doesn't exist
86
+ const backupDir = this.getBackupDir(projectPath);
87
+ await ensureDirectory(backupDir);
88
+
89
+ // Generate backup ID
90
+ const backupId = this.generateBackupId(type);
91
+ const backupPath = path.join(backupDir, backupId);
92
+
93
+ // Check if backup already exists (shouldn't happen with timestamp)
94
+ const backupExists = await pathExists(backupPath);
95
+ if (backupExists) {
96
+ throw new Error(`Backup already exists: ${backupId}`);
97
+ }
98
+
99
+ // Create backup directory
100
+ await ensureDirectory(backupPath);
101
+
102
+ // Copy .kiro/ contents to backup (excluding backups/ itself)
103
+ const items = await listFiles(kiroDir);
104
+
105
+ for (const item of items) {
106
+ // Skip the backups directory itself
107
+ if (item === this.backupDirName) {
108
+ continue;
109
+ }
110
+
111
+ const sourcePath = path.join(kiroDir, item);
112
+ const destPath = path.join(backupPath, item);
113
+
114
+ await copyDirectory(sourcePath, destPath, { overwrite: false });
115
+ }
116
+
117
+ // Get backup metadata
118
+ const files = await listFilesRecursive(backupPath);
119
+ const size = await getDirectorySize(backupPath);
120
+
121
+ // Read version from backup if it exists
122
+ const versionPath = path.join(backupPath, 'version.json');
123
+ let version = 'unknown';
124
+ try {
125
+ const versionExists = await pathExists(versionPath);
126
+ if (versionExists) {
127
+ const versionInfo = await readJSON(versionPath);
128
+ version = versionInfo['kse-version'] || 'unknown';
129
+ }
130
+ } catch (error) {
131
+ // Ignore version read errors
132
+ }
133
+
134
+ // Create metadata file
135
+ const metadata = {
136
+ id: backupId,
137
+ type,
138
+ created: new Date().toISOString(),
139
+ version,
140
+ size,
141
+ files: files.length
142
+ };
143
+
144
+ const metadataPath = path.join(backupPath, 'metadata.json');
145
+ await writeJSON(metadataPath, metadata, { spaces: 2 });
146
+
147
+ // Return backup info
148
+ return {
149
+ id: backupId,
150
+ type,
151
+ created: metadata.created,
152
+ version,
153
+ size,
154
+ files: files.length,
155
+ path: backupPath
156
+ };
157
+ } catch (error) {
158
+ throw new Error(`Failed to create backup: ${error.message}`);
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Lists available backups
164
+ *
165
+ * @param {string} projectPath - Absolute path to project root
166
+ * @returns {Promise<BackupInfo[]>} - Array of backup info, sorted by date (newest first)
167
+ */
168
+ async listBackups(projectPath) {
169
+ try {
170
+ const backupDir = this.getBackupDir(projectPath);
171
+
172
+ // Check if backups directory exists
173
+ const backupDirExists = await pathExists(backupDir);
174
+ if (!backupDirExists) {
175
+ return [];
176
+ }
177
+
178
+ // List backup directories
179
+ const items = await listFiles(backupDir);
180
+ const backups = [];
181
+
182
+ for (const item of items) {
183
+ const backupPath = path.join(backupDir, item);
184
+ const metadataPath = path.join(backupPath, 'metadata.json');
185
+
186
+ try {
187
+ // Check if metadata exists
188
+ const metadataExists = await pathExists(metadataPath);
189
+ if (!metadataExists) {
190
+ continue;
191
+ }
192
+
193
+ // Read metadata
194
+ const metadata = await readJSON(metadataPath);
195
+
196
+ backups.push({
197
+ id: metadata.id,
198
+ type: metadata.type,
199
+ created: metadata.created,
200
+ version: metadata.version,
201
+ size: metadata.size,
202
+ files: metadata.files,
203
+ path: backupPath
204
+ });
205
+ } catch (error) {
206
+ // Skip backups with invalid metadata
207
+ continue;
208
+ }
209
+ }
210
+
211
+ // Sort by created date (newest first)
212
+ backups.sort((a, b) => {
213
+ return new Date(b.created) - new Date(a.created);
214
+ });
215
+
216
+ return backups;
217
+ } catch (error) {
218
+ throw new Error(`Failed to list backups: ${error.message}`);
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Restores from backup
224
+ *
225
+ * @param {string} projectPath - Absolute path to project root
226
+ * @param {string} backupId - Backup ID to restore from
227
+ * @returns {Promise<RestoreResult>}
228
+ */
229
+ async restore(projectPath, backupId) {
230
+ try {
231
+ const backupDir = this.getBackupDir(projectPath);
232
+ const backupPath = path.join(backupDir, backupId);
233
+
234
+ // Check if backup exists
235
+ const backupExists = await pathExists(backupPath);
236
+ if (!backupExists) {
237
+ throw new Error(`Backup not found: ${backupId}`);
238
+ }
239
+
240
+ // Validate backup before restoring
241
+ const isValid = await this.validateBackup(backupPath);
242
+ if (!isValid) {
243
+ throw new Error(`Backup validation failed: ${backupId}`);
244
+ }
245
+
246
+ const kiroDir = this.getKiroDir(projectPath);
247
+
248
+ // Get list of items to restore (excluding metadata.json)
249
+ const items = await listFiles(backupPath);
250
+ const itemsToRestore = items.filter(item => item !== 'metadata.json');
251
+
252
+ // Remove existing .kiro/ contents (except backups/)
253
+ const existingItems = await listFiles(kiroDir);
254
+ for (const item of existingItems) {
255
+ if (item === this.backupDirName) {
256
+ continue;
257
+ }
258
+
259
+ const itemPath = path.join(kiroDir, item);
260
+ await remove(itemPath);
261
+ }
262
+
263
+ // Restore items from backup
264
+ const restoredFiles = [];
265
+ for (const item of itemsToRestore) {
266
+ const sourcePath = path.join(backupPath, item);
267
+ const destPath = path.join(kiroDir, item);
268
+
269
+ await copyDirectory(sourcePath, destPath, { overwrite: true });
270
+ restoredFiles.push(item);
271
+ }
272
+
273
+ return {
274
+ success: true,
275
+ backupId,
276
+ filesRestored: restoredFiles.length,
277
+ files: restoredFiles
278
+ };
279
+ } catch (error) {
280
+ throw new Error(`Failed to restore backup: ${error.message}`);
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Validates backup integrity
286
+ *
287
+ * @param {string} backupPath - Absolute path to backup directory
288
+ * @returns {Promise<boolean>}
289
+ */
290
+ async validateBackup(backupPath) {
291
+ try {
292
+ // Check if backup directory exists
293
+ const backupExists = await pathExists(backupPath);
294
+ if (!backupExists) {
295
+ return false;
296
+ }
297
+
298
+ // Check if metadata exists
299
+ const metadataPath = path.join(backupPath, 'metadata.json');
300
+ const metadataExists = await pathExists(metadataPath);
301
+ if (!metadataExists) {
302
+ return false;
303
+ }
304
+
305
+ // Read and validate metadata
306
+ const metadata = await readJSON(metadataPath);
307
+ if (!metadata.id || !metadata.type || !metadata.created) {
308
+ return false;
309
+ }
310
+
311
+ // Count files in backup
312
+ const files = await listFilesRecursive(backupPath);
313
+ // Subtract 1 for metadata.json itself
314
+ const fileCount = files.length - 1;
315
+
316
+ // Verify file count matches metadata (allow some tolerance)
317
+ // Files might be slightly different due to metadata.json
318
+ if (Math.abs(fileCount - metadata.files) > 1) {
319
+ return false;
320
+ }
321
+
322
+ // Verify version.json exists and is valid (if present)
323
+ const versionPath = path.join(backupPath, 'version.json');
324
+ const versionExists = await pathExists(versionPath);
325
+ if (versionExists) {
326
+ try {
327
+ const versionInfo = await readJSON(versionPath);
328
+ // Basic validation
329
+ if (!versionInfo['kse-version']) {
330
+ return false;
331
+ }
332
+ } catch (error) {
333
+ return false;
334
+ }
335
+ }
336
+
337
+ return true;
338
+ } catch (error) {
339
+ return false;
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Cleans old backups (keeps last N backups)
345
+ *
346
+ * @param {string} projectPath - Absolute path to project root
347
+ * @param {number} keepCount - Number of backups to keep (default: 5)
348
+ * @returns {Promise<void>}
349
+ */
350
+ async cleanOldBackups(projectPath, keepCount = 5) {
351
+ try {
352
+ // Get all backups sorted by date (newest first)
353
+ const backups = await this.listBackups(projectPath);
354
+
355
+ // If we have fewer backups than keepCount, nothing to do
356
+ if (backups.length <= keepCount) {
357
+ return;
358
+ }
359
+
360
+ // Remove old backups
361
+ const backupsToRemove = backups.slice(keepCount);
362
+
363
+ for (const backup of backupsToRemove) {
364
+ await remove(backup.path);
365
+ }
366
+ } catch (error) {
367
+ throw new Error(`Failed to clean old backups: ${error.message}`);
368
+ }
369
+ }
370
+ }
371
+
372
+ module.exports = BackupSystem;