@nclamvn/vibecode-cli 1.3.0 → 1.6.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,325 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════════
2
+ // VIBECODE CLI - Backup Manager
3
+ // Phase H4: Undo/Rollback - Auto-backup before every action
4
+ // ═══════════════════════════════════════════════════════════════════════════════
5
+
6
+ import fs from 'fs-extra';
7
+ import path from 'path';
8
+ import { createHash } from 'crypto';
9
+
10
+ const BACKUP_DIR = '.vibecode/backups';
11
+ const MAX_BACKUPS = 10;
12
+
13
+ /**
14
+ * BackupManager - Creates and manages backups for undo functionality
15
+ *
16
+ * Usage:
17
+ * const backup = new BackupManager();
18
+ * const id = await backup.createBackup('build');
19
+ * // ... do stuff ...
20
+ * await backup.restore(id); // Undo!
21
+ */
22
+ export class BackupManager {
23
+ constructor(projectPath = process.cwd()) {
24
+ this.projectPath = projectPath;
25
+ this.backupPath = path.join(projectPath, BACKUP_DIR);
26
+ }
27
+
28
+ /**
29
+ * Initialize backup directory
30
+ */
31
+ async init() {
32
+ await fs.ensureDir(this.backupPath);
33
+ }
34
+
35
+ /**
36
+ * Create backup before action
37
+ * @param {string} actionName - Name of action (build, agent, go, etc.)
38
+ * @param {string[]} files - Specific files to backup (optional)
39
+ * @returns {string} Backup ID
40
+ */
41
+ async createBackup(actionName, files = null) {
42
+ await this.init();
43
+
44
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
45
+ const backupId = `${timestamp}_${actionName}`;
46
+ const backupDir = path.join(this.backupPath, backupId);
47
+
48
+ await fs.ensureDir(backupDir);
49
+
50
+ // If specific files provided, backup only those
51
+ // Otherwise, backup common source files
52
+ const filesToBackup = files || await this.getSourceFiles();
53
+
54
+ const manifest = {
55
+ id: backupId,
56
+ action: actionName,
57
+ timestamp: new Date().toISOString(),
58
+ files: []
59
+ };
60
+
61
+ for (const file of filesToBackup) {
62
+ try {
63
+ const sourcePath = path.join(this.projectPath, file);
64
+ const stats = await fs.stat(sourcePath).catch(() => null);
65
+
66
+ if (stats && stats.isFile()) {
67
+ const content = await fs.readFile(sourcePath);
68
+ const hash = createHash('md5').update(content).digest('hex');
69
+
70
+ // Replace slashes with __ for flat storage
71
+ const backupFileName = file.replace(/[/\\]/g, '__');
72
+ const backupFilePath = path.join(backupDir, backupFileName);
73
+
74
+ await fs.writeFile(backupFilePath, content);
75
+
76
+ manifest.files.push({
77
+ path: file,
78
+ hash,
79
+ size: stats.size,
80
+ backupName: backupFileName
81
+ });
82
+ }
83
+ } catch (error) {
84
+ // Skip files that can't be backed up
85
+ }
86
+ }
87
+
88
+ // Save manifest
89
+ await fs.writeFile(
90
+ path.join(backupDir, 'manifest.json'),
91
+ JSON.stringify(manifest, null, 2)
92
+ );
93
+
94
+ // Cleanup old backups
95
+ await this.cleanupOldBackups();
96
+
97
+ return backupId;
98
+ }
99
+
100
+ /**
101
+ * Get list of source files to backup
102
+ * @returns {string[]} File paths relative to project
103
+ */
104
+ async getSourceFiles() {
105
+ const files = [];
106
+ const ignoreDirs = [
107
+ 'node_modules', '.git', '.next', 'dist', 'build',
108
+ '.vibecode/backups', 'coverage', '.cache', '__pycache__'
109
+ ];
110
+ const extensions = [
111
+ '.js', '.ts', '.tsx', '.jsx', '.json', '.css', '.scss',
112
+ '.html', '.md', '.vue', '.svelte', '.prisma', '.env'
113
+ ];
114
+
115
+ const scan = async (dir, prefix = '') => {
116
+ try {
117
+ const entries = await fs.readdir(dir, { withFileTypes: true });
118
+
119
+ for (const entry of entries) {
120
+ const fullPath = path.join(dir, entry.name);
121
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
122
+
123
+ if (entry.isDirectory()) {
124
+ if (!ignoreDirs.includes(entry.name) && !entry.name.startsWith('.')) {
125
+ await scan(fullPath, relativePath);
126
+ }
127
+ } else if (entry.isFile()) {
128
+ const ext = path.extname(entry.name);
129
+ if (extensions.includes(ext)) {
130
+ files.push(relativePath);
131
+ }
132
+ }
133
+ }
134
+ } catch (error) {
135
+ // Skip directories that can't be read
136
+ }
137
+ };
138
+
139
+ await scan(this.projectPath);
140
+ return files.slice(0, 100); // Limit to 100 files
141
+ }
142
+
143
+ /**
144
+ * List available backups
145
+ * @returns {Object[]} Array of backup manifests
146
+ */
147
+ async listBackups() {
148
+ await this.init();
149
+
150
+ try {
151
+ const entries = await fs.readdir(this.backupPath, { withFileTypes: true });
152
+ const backups = [];
153
+
154
+ for (const entry of entries) {
155
+ if (entry.isDirectory()) {
156
+ const manifestPath = path.join(this.backupPath, entry.name, 'manifest.json');
157
+ try {
158
+ const manifest = await fs.readJson(manifestPath);
159
+ backups.push(manifest);
160
+ } catch {
161
+ // Skip invalid backups
162
+ }
163
+ }
164
+ }
165
+
166
+ // Sort by timestamp, newest first
167
+ return backups.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
168
+ } catch {
169
+ return [];
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Restore from backup
175
+ * @param {string} backupId - Backup ID to restore
176
+ * @returns {Object} Restore result
177
+ */
178
+ async restore(backupId) {
179
+ const backupDir = path.join(this.backupPath, backupId);
180
+ const manifestPath = path.join(backupDir, 'manifest.json');
181
+
182
+ try {
183
+ const manifest = await fs.readJson(manifestPath);
184
+ const restored = [];
185
+
186
+ for (const file of manifest.files) {
187
+ const backupFilePath = path.join(backupDir, file.backupName);
188
+ const targetPath = path.join(this.projectPath, file.path);
189
+
190
+ // Ensure directory exists
191
+ await fs.ensureDir(path.dirname(targetPath));
192
+
193
+ // Restore file
194
+ const content = await fs.readFile(backupFilePath);
195
+ await fs.writeFile(targetPath, content);
196
+
197
+ restored.push(file.path);
198
+ }
199
+
200
+ return {
201
+ success: true,
202
+ backupId,
203
+ action: manifest.action,
204
+ timestamp: manifest.timestamp,
205
+ filesRestored: restored.length,
206
+ files: restored
207
+ };
208
+ } catch (error) {
209
+ return {
210
+ success: false,
211
+ error: error.message
212
+ };
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Restore to N steps ago
218
+ * @param {number} steps - Number of steps back
219
+ * @returns {Object} Restore result
220
+ */
221
+ async restoreSteps(steps = 1) {
222
+ const backups = await this.listBackups();
223
+
224
+ if (steps > backups.length) {
225
+ return {
226
+ success: false,
227
+ error: `Only ${backups.length} backups available`
228
+ };
229
+ }
230
+
231
+ const backup = backups[steps - 1];
232
+ return await this.restore(backup.id);
233
+ }
234
+
235
+ /**
236
+ * Get the most recent backup
237
+ * @returns {Object|null} Latest backup manifest or null
238
+ */
239
+ async getLatestBackup() {
240
+ const backups = await this.listBackups();
241
+ return backups.length > 0 ? backups[0] : null;
242
+ }
243
+
244
+ /**
245
+ * Cleanup old backups to maintain MAX_BACKUPS limit
246
+ */
247
+ async cleanupOldBackups() {
248
+ const backups = await this.listBackups();
249
+
250
+ if (backups.length > MAX_BACKUPS) {
251
+ const toDelete = backups.slice(MAX_BACKUPS);
252
+
253
+ for (const backup of toDelete) {
254
+ const backupDir = path.join(this.backupPath, backup.id);
255
+ await fs.remove(backupDir);
256
+ }
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Delete specific backup
262
+ * @param {string} backupId - Backup ID to delete
263
+ */
264
+ async deleteBackup(backupId) {
265
+ const backupDir = path.join(this.backupPath, backupId);
266
+ await fs.remove(backupDir);
267
+ }
268
+
269
+ /**
270
+ * Clear all backups
271
+ */
272
+ async clearAllBackups() {
273
+ await fs.remove(this.backupPath);
274
+ await this.init();
275
+ }
276
+
277
+ /**
278
+ * Get backup size in bytes
279
+ * @param {string} backupId - Backup ID
280
+ * @returns {number} Size in bytes
281
+ */
282
+ async getBackupSize(backupId) {
283
+ const backupDir = path.join(this.backupPath, backupId);
284
+ let totalSize = 0;
285
+
286
+ try {
287
+ const files = await fs.readdir(backupDir);
288
+ for (const file of files) {
289
+ const stats = await fs.stat(path.join(backupDir, file));
290
+ totalSize += stats.size;
291
+ }
292
+ } catch {
293
+ // Ignore errors
294
+ }
295
+
296
+ return totalSize;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Helper to wrap action with auto-backup
302
+ * @param {string} actionName - Name of action
303
+ * @param {Function} fn - Async function to execute
304
+ * @returns {*} Result of fn()
305
+ */
306
+ export async function withBackup(actionName, fn) {
307
+ const backup = new BackupManager();
308
+ const backupId = await backup.createBackup(actionName);
309
+
310
+ try {
311
+ return await fn();
312
+ } catch (error) {
313
+ // Error occurred - backup is available for restore
314
+ console.log(`\n💾 Backup created: ${backupId}`);
315
+ console.log(` Run 'vibecode undo' to restore previous state.\n`);
316
+ throw error;
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Create backup manager instance
322
+ */
323
+ export function createBackupManager(projectPath) {
324
+ return new BackupManager(projectPath);
325
+ }
@@ -0,0 +1,295 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════════
2
+ // VIBECODE CLI - Learning Engine
3
+ // Phase H5: AI learns from user feedback
4
+ // ═══════════════════════════════════════════════════════════════════════════════
5
+
6
+ import fs from 'fs/promises';
7
+ import path from 'path';
8
+ import os from 'os';
9
+
10
+ const LEARNING_DIR = '.vibecode/learning';
11
+ const GLOBAL_LEARNING_DIR = path.join(os.homedir(), '.vibecode/learning');
12
+
13
+ /**
14
+ * Learning Engine - Records and retrieves learning data
15
+ *
16
+ * Features:
17
+ * - Records fix attempts and outcomes
18
+ * - Stores user preferences
19
+ * - Provides suggestions based on past successes
20
+ * - Anonymizes data for global storage
21
+ */
22
+ export class LearningEngine {
23
+ constructor(projectPath = process.cwd()) {
24
+ this.projectPath = projectPath;
25
+ this.localPath = path.join(projectPath, LEARNING_DIR);
26
+ this.globalPath = GLOBAL_LEARNING_DIR;
27
+ }
28
+
29
+ /**
30
+ * Initialize learning directories
31
+ */
32
+ async init() {
33
+ await fs.mkdir(this.localPath, { recursive: true });
34
+ await fs.mkdir(this.globalPath, { recursive: true });
35
+ }
36
+
37
+ /**
38
+ * Record a fix attempt and its outcome
39
+ */
40
+ async recordFix(fixData) {
41
+ await this.init();
42
+
43
+ const record = {
44
+ id: Date.now().toString(36),
45
+ timestamp: new Date().toISOString(),
46
+ errorType: fixData.errorType,
47
+ errorMessage: fixData.errorMessage?.substring(0, 200),
48
+ errorCategory: fixData.errorCategory,
49
+ fixApplied: fixData.fixApplied?.substring(0, 500),
50
+ success: fixData.success,
51
+ userFeedback: fixData.userFeedback,
52
+ userCorrection: fixData.userCorrection,
53
+ projectType: await this.detectProjectType(),
54
+ tags: fixData.tags || []
55
+ };
56
+
57
+ // Save to local project
58
+ const localFile = path.join(this.localPath, 'fixes.json');
59
+ const localFixes = await this.loadJson(localFile, []);
60
+ localFixes.push(record);
61
+ await this.saveJson(localFile, localFixes.slice(-100)); // Keep last 100
62
+
63
+ // Save to global (anonymized)
64
+ const globalFile = path.join(this.globalPath, 'fixes.json');
65
+ const globalFixes = await this.loadJson(globalFile, []);
66
+ globalFixes.push({
67
+ ...record,
68
+ errorMessage: this.anonymize(record.errorMessage),
69
+ fixApplied: this.anonymize(record.fixApplied)
70
+ });
71
+ await this.saveJson(globalFile, globalFixes.slice(-500)); // Keep last 500
72
+
73
+ return record.id;
74
+ }
75
+
76
+ /**
77
+ * Record user preference
78
+ */
79
+ async recordPreference(key, value, context = {}) {
80
+ await this.init();
81
+
82
+ const prefsFile = path.join(this.localPath, 'preferences.json');
83
+ const prefs = await this.loadJson(prefsFile, {});
84
+
85
+ if (!prefs[key]) {
86
+ prefs[key] = { values: [], contexts: [] };
87
+ }
88
+
89
+ prefs[key].values.push(value);
90
+ prefs[key].contexts.push(context);
91
+ prefs[key].lastUsed = new Date().toISOString();
92
+
93
+ // Keep only recent values
94
+ prefs[key].values = prefs[key].values.slice(-20);
95
+ prefs[key].contexts = prefs[key].contexts.slice(-20);
96
+
97
+ await this.saveJson(prefsFile, prefs);
98
+ }
99
+
100
+ /**
101
+ * Get suggestion based on learnings
102
+ */
103
+ async getSuggestion(errorType, errorCategory) {
104
+ const localFixes = await this.loadJson(
105
+ path.join(this.localPath, 'fixes.json'),
106
+ []
107
+ );
108
+ const globalFixes = await this.loadJson(
109
+ path.join(this.globalPath, 'fixes.json'),
110
+ []
111
+ );
112
+
113
+ // Find similar successful fixes
114
+ const allFixes = [...localFixes, ...globalFixes];
115
+ const similarFixes = allFixes.filter(f =>
116
+ f.success &&
117
+ (f.errorType === errorType || f.errorCategory === errorCategory)
118
+ );
119
+
120
+ if (similarFixes.length === 0) {
121
+ return null;
122
+ }
123
+
124
+ // Calculate confidence based on success rate
125
+ const totalSimilar = allFixes.filter(f =>
126
+ f.errorType === errorType || f.errorCategory === errorCategory
127
+ ).length;
128
+
129
+ const successRate = similarFixes.length / totalSimilar;
130
+
131
+ // Get most recent successful fix
132
+ const recentFix = similarFixes.sort((a, b) =>
133
+ new Date(b.timestamp) - new Date(a.timestamp)
134
+ )[0];
135
+
136
+ return {
137
+ suggestion: recentFix.fixApplied,
138
+ confidence: successRate,
139
+ basedOn: similarFixes.length,
140
+ lastUsed: recentFix.timestamp
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Get user preference
146
+ */
147
+ async getPreference(key, defaultValue = null) {
148
+ const prefsFile = path.join(this.localPath, 'preferences.json');
149
+ const prefs = await this.loadJson(prefsFile, {});
150
+
151
+ if (!prefs[key] || prefs[key].values.length === 0) {
152
+ return defaultValue;
153
+ }
154
+
155
+ // Return most common value
156
+ const counts = {};
157
+ for (const v of prefs[key].values) {
158
+ counts[v] = (counts[v] || 0) + 1;
159
+ }
160
+
161
+ const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
162
+ return sorted[0][0];
163
+ }
164
+
165
+ /**
166
+ * Get learning statistics
167
+ */
168
+ async getStats() {
169
+ const localFixes = await this.loadJson(
170
+ path.join(this.localPath, 'fixes.json'),
171
+ []
172
+ );
173
+ const globalFixes = await this.loadJson(
174
+ path.join(this.globalPath, 'fixes.json'),
175
+ []
176
+ );
177
+ const prefs = await this.loadJson(
178
+ path.join(this.localPath, 'preferences.json'),
179
+ {}
180
+ );
181
+
182
+ const localSuccess = localFixes.filter(f => f.success).length;
183
+ const globalSuccess = globalFixes.filter(f => f.success).length;
184
+
185
+ // Group by error category
186
+ const byCategory = {};
187
+ for (const fix of localFixes) {
188
+ const cat = fix.errorCategory || 'unknown';
189
+ if (!byCategory[cat]) {
190
+ byCategory[cat] = { total: 0, success: 0 };
191
+ }
192
+ byCategory[cat].total++;
193
+ if (fix.success) byCategory[cat].success++;
194
+ }
195
+
196
+ return {
197
+ local: {
198
+ total: localFixes.length,
199
+ success: localSuccess,
200
+ rate: localFixes.length > 0 ? (localSuccess / localFixes.length * 100).toFixed(1) : '0'
201
+ },
202
+ global: {
203
+ total: globalFixes.length,
204
+ success: globalSuccess,
205
+ rate: globalFixes.length > 0 ? (globalSuccess / globalFixes.length * 100).toFixed(1) : '0'
206
+ },
207
+ byCategory,
208
+ preferences: Object.keys(prefs).length,
209
+ lastLearning: localFixes[localFixes.length - 1]?.timestamp || null
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Detect project type
215
+ */
216
+ async detectProjectType() {
217
+ try {
218
+ const pkgPath = path.join(this.projectPath, 'package.json');
219
+ const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8'));
220
+
221
+ if (pkg.dependencies?.next) return 'nextjs';
222
+ if (pkg.dependencies?.react) return 'react';
223
+ if (pkg.dependencies?.vue) return 'vue';
224
+ if (pkg.dependencies?.express) return 'express';
225
+ if (pkg.dependencies?.['@prisma/client']) return 'prisma';
226
+
227
+ return 'node';
228
+ } catch {
229
+ return 'unknown';
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Anonymize sensitive data for global storage
235
+ */
236
+ anonymize(text) {
237
+ if (!text) return text;
238
+ return text
239
+ .replace(/\/Users\/[^\/\s]+/g, '/Users/***')
240
+ .replace(/\/home\/[^\/\s]+/g, '/home/***')
241
+ .replace(/C:\\Users\\[^\\]+/g, 'C:\\Users\\***')
242
+ .replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '***@***.***')
243
+ .replace(/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, '***.***.***.***')
244
+ .replace(/api[_-]?key[=:]\s*["']?[\w-]+["']?/gi, 'api_key=***')
245
+ .replace(/token[=:]\s*["']?[\w-]+["']?/gi, 'token=***')
246
+ .replace(/password[=:]\s*["']?[^"'\s]+["']?/gi, 'password=***');
247
+ }
248
+
249
+ /**
250
+ * Clear all local learnings
251
+ */
252
+ async clearLocal() {
253
+ await this.saveJson(path.join(this.localPath, 'fixes.json'), []);
254
+ await this.saveJson(path.join(this.localPath, 'preferences.json'), {});
255
+ }
256
+
257
+ /**
258
+ * Load JSON file
259
+ */
260
+ async loadJson(filePath, defaultValue) {
261
+ try {
262
+ const content = await fs.readFile(filePath, 'utf-8');
263
+ return JSON.parse(content);
264
+ } catch {
265
+ return defaultValue;
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Save JSON file
271
+ */
272
+ async saveJson(filePath, data) {
273
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2));
274
+ }
275
+ }
276
+
277
+ // Singleton instance
278
+ let learningEngine = null;
279
+
280
+ /**
281
+ * Get or create LearningEngine instance
282
+ */
283
+ export function getLearningEngine(projectPath = process.cwd()) {
284
+ if (!learningEngine || learningEngine.projectPath !== projectPath) {
285
+ learningEngine = new LearningEngine(projectPath);
286
+ }
287
+ return learningEngine;
288
+ }
289
+
290
+ /**
291
+ * Create a new LearningEngine instance
292
+ */
293
+ export function createLearningEngine(projectPath = process.cwd()) {
294
+ return new LearningEngine(projectPath);
295
+ }
@@ -43,9 +43,10 @@ export async function runTests(projectPath) {
43
43
  results.tests.push(npmTest);
44
44
  }
45
45
 
46
- // 3. Run npm run lint (if script exists)
46
+ // 3. Run npm run lint (if script exists) - soft fail (warnings only)
47
47
  if (pkg.scripts?.lint) {
48
48
  const npmLint = await runCommand('npm run lint', projectPath, 'npm lint');
49
+ npmLint.softFail = true; // Lint errors are warnings, don't block build
49
50
  results.tests.push(npmLint);
50
51
  }
51
52
 
@@ -70,11 +71,21 @@ export async function runTests(projectPath) {
70
71
  }
71
72
 
72
73
  // 7. Aggregate results
74
+ // Separate hard tests from soft-fail tests (like lint)
75
+ const hardTests = results.tests.filter(t => !t.softFail);
76
+ const softTests = results.tests.filter(t => t.softFail);
77
+
73
78
  results.summary.total = results.tests.length;
74
79
  results.summary.passed = results.tests.filter(t => t.passed).length;
75
80
  results.summary.failed = results.tests.filter(t => !t.passed).length;
76
- results.passed = results.tests.length === 0 || results.tests.every(t => t.passed);
77
- results.errors = results.tests.filter(t => !t.passed).flatMap(t => t.errors || []);
81
+ results.summary.warnings = softTests.filter(t => !t.passed).length;
82
+
83
+ // Only hard tests determine pass/fail
84
+ results.passed = hardTests.length === 0 || hardTests.every(t => t.passed);
85
+
86
+ // Collect errors, but mark soft-fail errors as warnings
87
+ results.errors = results.tests.filter(t => !t.passed && !t.softFail).flatMap(t => t.errors || []);
88
+ results.warnings = softTests.filter(t => !t.passed).flatMap(t => t.errors || []);
78
89
  results.duration = Date.now() - startTime;
79
90
 
80
91
  return results;
@@ -230,12 +241,17 @@ export function formatTestResults(results) {
230
241
  const lines = [];
231
242
 
232
243
  lines.push(`Tests: ${results.summary.passed}/${results.summary.total} passed`);
244
+ if (results.summary.warnings > 0) {
245
+ lines.push(`Warnings: ${results.summary.warnings} (lint)`);
246
+ }
233
247
  lines.push(`Duration: ${(results.duration / 1000).toFixed(1)}s`);
234
248
 
235
- if (!results.passed) {
249
+ // Show hard failures
250
+ const hardFailures = results.tests.filter(t => !t.passed && !t.softFail);
251
+ if (hardFailures.length > 0) {
236
252
  lines.push('');
237
253
  lines.push('Failed tests:');
238
- for (const test of results.tests.filter(t => !t.passed)) {
254
+ for (const test of hardFailures) {
239
255
  lines.push(` ❌ ${test.name}`);
240
256
  for (const error of test.errors || []) {
241
257
  const loc = error.file ? `${error.file}:${error.line || '?'}` : '';
@@ -244,5 +260,22 @@ export function formatTestResults(results) {
244
260
  }
245
261
  }
246
262
 
263
+ // Show soft failures (warnings)
264
+ const softFailures = results.tests.filter(t => !t.passed && t.softFail);
265
+ if (softFailures.length > 0) {
266
+ lines.push('');
267
+ lines.push('Warnings (non-blocking):');
268
+ for (const test of softFailures) {
269
+ lines.push(` ⚠️ ${test.name}`);
270
+ for (const error of (test.errors || []).slice(0, 3)) {
271
+ const loc = error.file ? `${error.file}:${error.line || '?'}` : '';
272
+ lines.push(` ${loc} ${error.message?.substring(0, 80) || ''}`);
273
+ }
274
+ if ((test.errors?.length || 0) > 3) {
275
+ lines.push(` ... and ${test.errors.length - 3} more`);
276
+ }
277
+ }
278
+ }
279
+
247
280
  return lines.join('\n');
248
281
  }