@nclamvn/vibecode-cli 1.2.0 → 1.5.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.
@@ -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
  }
@@ -0,0 +1,329 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════════
2
+ // VIBECODE DEBUG - Root Cause Analyzer
3
+ // Analyzes evidence to determine root cause and generate hypotheses
4
+ // ═══════════════════════════════════════════════════════════════════════════════
5
+
6
+ /**
7
+ * Root Cause Analyzer Class
8
+ * Uses pattern matching and heuristics to identify bug sources
9
+ */
10
+ export class RootCauseAnalyzer {
11
+ constructor() {
12
+ this.patterns = this.initPatterns();
13
+ }
14
+
15
+ /**
16
+ * Analyze evidence to find root cause
17
+ */
18
+ async analyze(evidence) {
19
+ const analysis = {
20
+ category: evidence.category,
21
+ rootCause: null,
22
+ suggestedFix: null,
23
+ relatedFiles: evidence.files || [],
24
+ confidence: 0,
25
+ patterns: []
26
+ };
27
+
28
+ // Match against known patterns
29
+ for (const pattern of this.patterns) {
30
+ if (this.matchesPattern(evidence, pattern)) {
31
+ analysis.patterns.push(pattern.name);
32
+
33
+ if (pattern.confidence > analysis.confidence) {
34
+ analysis.rootCause = pattern.rootCause;
35
+ analysis.suggestedFix = pattern.fix;
36
+ analysis.confidence = pattern.confidence;
37
+ }
38
+ }
39
+ }
40
+
41
+ // If no pattern matched, use generic analysis
42
+ if (!analysis.rootCause) {
43
+ analysis.rootCause = this.inferRootCause(evidence);
44
+ analysis.confidence = 0.3;
45
+ }
46
+
47
+ return analysis;
48
+ }
49
+
50
+ /**
51
+ * Build hypotheses from analysis
52
+ */
53
+ buildHypotheses(analysis) {
54
+ const hypotheses = [];
55
+
56
+ // Primary hypothesis from analysis
57
+ if (analysis.suggestedFix) {
58
+ hypotheses.push({
59
+ description: analysis.suggestedFix,
60
+ confidence: analysis.confidence,
61
+ category: analysis.category,
62
+ rootCause: analysis.rootCause
63
+ });
64
+ }
65
+
66
+ // Add category-specific hypotheses
67
+ const categoryFixes = this.getCategoryFixes(analysis.category);
68
+ for (const fix of categoryFixes) {
69
+ if (!hypotheses.find(h => h.description === fix.description)) {
70
+ hypotheses.push({
71
+ ...fix,
72
+ category: analysis.category
73
+ });
74
+ }
75
+ }
76
+
77
+ // Sort by confidence
78
+ return hypotheses.sort((a, b) => b.confidence - a.confidence);
79
+ }
80
+
81
+ /**
82
+ * Initialize pattern database
83
+ */
84
+ initPatterns() {
85
+ return [
86
+ // Next.js specific
87
+ {
88
+ name: 'nextjs-server-client-boundary',
89
+ match: (e) => {
90
+ const msg = (e.message || e.description || '').toLowerCase();
91
+ return msg.includes('functions cannot be passed directly to client components') ||
92
+ msg.includes('client component') && msg.includes('server');
93
+ },
94
+ rootCause: 'Server/Client Component boundary violation in Next.js',
95
+ fix: 'Convert function props to serializable format (e.g., use formatType string instead of formatValue function). Or mark the function with "use server".',
96
+ confidence: 0.95
97
+ },
98
+ {
99
+ name: 'nextjs-hydration',
100
+ match: (e) => {
101
+ const msg = (e.message || e.description || '').toLowerCase();
102
+ return msg.includes('hydration') || msg.includes('text content does not match');
103
+ },
104
+ rootCause: 'Hydration mismatch between server and client render',
105
+ fix: 'Ensure server and client render identical content. Use useEffect for client-only code.',
106
+ confidence: 0.85
107
+ },
108
+
109
+ // Type errors
110
+ {
111
+ name: 'undefined-property-access',
112
+ match: (e) => {
113
+ const msg = (e.message || '').toLowerCase();
114
+ return msg.includes('cannot read properties of undefined') ||
115
+ msg.includes('cannot read property') && msg.includes('undefined');
116
+ },
117
+ rootCause: 'Accessing property on undefined object',
118
+ fix: 'Add null check or optional chaining (?.) before property access',
119
+ confidence: 0.85
120
+ },
121
+ {
122
+ name: 'null-property-access',
123
+ match: (e) => {
124
+ const msg = (e.message || '').toLowerCase();
125
+ return msg.includes('cannot read properties of null');
126
+ },
127
+ rootCause: 'Accessing property on null value',
128
+ fix: 'Add null check before property access. Check if data is loaded before accessing.',
129
+ confidence: 0.85
130
+ },
131
+
132
+ // Import errors
133
+ {
134
+ name: 'module-not-found',
135
+ match: (e) => {
136
+ const msg = (e.message || e.description || '').toLowerCase();
137
+ return msg.includes('cannot find module') || msg.includes('module not found');
138
+ },
139
+ rootCause: 'Missing import or incorrect module path',
140
+ fix: 'Install missing package with npm install, or fix import path',
141
+ confidence: 0.9
142
+ },
143
+ {
144
+ name: 'export-not-found',
145
+ match: (e) => {
146
+ const msg = (e.message || '').toLowerCase();
147
+ return msg.includes('does not provide an export named') ||
148
+ msg.includes('is not exported from');
149
+ },
150
+ rootCause: 'Importing non-existent export',
151
+ fix: 'Check export name in source module. Use correct named or default import.',
152
+ confidence: 0.9
153
+ },
154
+
155
+ // Syntax errors
156
+ {
157
+ name: 'unexpected-token',
158
+ match: (e) => {
159
+ const msg = (e.message || '').toLowerCase();
160
+ return msg.includes('unexpected token') || e.type === 'SyntaxError';
161
+ },
162
+ rootCause: 'Syntax error in JavaScript/TypeScript code',
163
+ fix: 'Fix syntax error at indicated line. Check for missing brackets, semicolons, or incorrect syntax.',
164
+ confidence: 0.9
165
+ },
166
+ {
167
+ name: 'jsx-syntax',
168
+ match: (e) => {
169
+ const msg = (e.message || '').toLowerCase();
170
+ return msg.includes('jsx') && (msg.includes('unexpected') || msg.includes('syntax'));
171
+ },
172
+ rootCause: 'JSX syntax error',
173
+ fix: 'Check JSX syntax. Ensure proper closing tags and valid JSX expressions.',
174
+ confidence: 0.85
175
+ },
176
+
177
+ // Reference errors
178
+ {
179
+ name: 'undefined-variable',
180
+ match: (e) => {
181
+ const msg = (e.message || '').toLowerCase();
182
+ return msg.includes('is not defined') || e.type === 'ReferenceError';
183
+ },
184
+ rootCause: 'Using undefined variable',
185
+ fix: 'Define the variable before use, or import it from the correct module',
186
+ confidence: 0.85
187
+ },
188
+
189
+ // Database errors
190
+ {
191
+ name: 'prisma-client',
192
+ match: (e) => {
193
+ const msg = (e.message || '').toLowerCase();
194
+ return msg.includes('prisma') || msg.includes('@prisma/client');
195
+ },
196
+ rootCause: 'Prisma database client error',
197
+ fix: 'Run npx prisma generate and npx prisma db push. Check DATABASE_URL.',
198
+ confidence: 0.8
199
+ },
200
+
201
+ // Auth errors
202
+ {
203
+ name: 'nextauth-error',
204
+ match: (e) => {
205
+ const msg = (e.message || '').toLowerCase();
206
+ return msg.includes('next-auth') || msg.includes('nextauth');
207
+ },
208
+ rootCause: 'NextAuth configuration error',
209
+ fix: 'Check NEXTAUTH_URL and NEXTAUTH_SECRET in .env. Verify auth provider config.',
210
+ confidence: 0.8
211
+ },
212
+
213
+ // ESLint/Lint errors
214
+ {
215
+ name: 'eslint-error',
216
+ match: (e) => e.category === 'LINT' || (e.message || '').toLowerCase().includes('eslint'),
217
+ rootCause: 'ESLint code style violation',
218
+ fix: 'Fix the linting error or add eslint-disable comment if intentional',
219
+ confidence: 0.75
220
+ },
221
+
222
+ // Test failures
223
+ {
224
+ name: 'test-assertion',
225
+ match: (e) => {
226
+ const msg = (e.message || '').toLowerCase();
227
+ return msg.includes('expect') || msg.includes('assertion') || e.category === 'TEST';
228
+ },
229
+ rootCause: 'Test assertion failure',
230
+ fix: 'Review test expectations and implementation. Update test or fix code.',
231
+ confidence: 0.7
232
+ }
233
+ ];
234
+ }
235
+
236
+ /**
237
+ * Check if evidence matches a pattern
238
+ */
239
+ matchesPattern(evidence, pattern) {
240
+ try {
241
+ return pattern.match(evidence);
242
+ } catch {
243
+ return false;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Infer root cause when no pattern matches
249
+ */
250
+ inferRootCause(evidence) {
251
+ const parts = [];
252
+
253
+ if (evidence.type && evidence.type !== 'unknown') {
254
+ parts.push(evidence.type);
255
+ }
256
+
257
+ if (evidence.files.length > 0) {
258
+ parts.push(`in ${evidence.files[0]}`);
259
+ }
260
+
261
+ if (evidence.lines.length > 0) {
262
+ parts.push(`at line ${evidence.lines[0]}`);
263
+ }
264
+
265
+ if (parts.length === 0) {
266
+ return 'Unknown error - manual investigation needed';
267
+ }
268
+
269
+ return parts.join(' ');
270
+ }
271
+
272
+ /**
273
+ * Get category-specific fixes
274
+ */
275
+ getCategoryFixes(category) {
276
+ const fixes = {
277
+ SYNTAX: [
278
+ { description: 'Check for missing brackets, parentheses, or semicolons', confidence: 0.6 },
279
+ { description: 'Verify JSX syntax and proper tag closing', confidence: 0.5 }
280
+ ],
281
+ TYPE: [
282
+ { description: 'Add null/undefined check before property access', confidence: 0.6 },
283
+ { description: 'Use optional chaining (?.) for nested access', confidence: 0.6 },
284
+ { description: 'Verify variable types match expected types', confidence: 0.5 }
285
+ ],
286
+ REFERENCE: [
287
+ { description: 'Import or define the missing variable', confidence: 0.6 },
288
+ { description: 'Check for typos in variable names', confidence: 0.5 }
289
+ ],
290
+ IMPORT: [
291
+ { description: 'Install missing package: npm install <package>', confidence: 0.7 },
292
+ { description: 'Fix import path to correct location', confidence: 0.6 },
293
+ { description: 'Check if export exists in source module', confidence: 0.5 }
294
+ ],
295
+ FILE: [
296
+ { description: 'Create missing file or directory', confidence: 0.6 },
297
+ { description: 'Fix file path in configuration', confidence: 0.5 }
298
+ ],
299
+ LINT: [
300
+ { description: 'Fix code style violation', confidence: 0.6 },
301
+ { description: 'Add eslint-disable comment if intentional', confidence: 0.4 }
302
+ ],
303
+ TEST: [
304
+ { description: 'Update test expectations to match implementation', confidence: 0.5 },
305
+ { description: 'Fix implementation to pass test', confidence: 0.5 }
306
+ ],
307
+ NEXTJS: [
308
+ { description: 'Check Server/Client Component boundaries', confidence: 0.7 },
309
+ { description: 'Move client-only code to useEffect', confidence: 0.6 }
310
+ ],
311
+ DATABASE: [
312
+ { description: 'Run prisma generate and db push', confidence: 0.7 },
313
+ { description: 'Check DATABASE_URL environment variable', confidence: 0.6 }
314
+ ],
315
+ RUNTIME: [
316
+ { description: 'Debug runtime error with console.log', confidence: 0.3 },
317
+ { description: 'Check error stack trace for source', confidence: 0.4 }
318
+ ]
319
+ };
320
+
321
+ return fixes[category] || [
322
+ { description: 'Investigate error message and stack trace', confidence: 0.2 }
323
+ ];
324
+ }
325
+ }
326
+
327
+ export function createRootCauseAnalyzer() {
328
+ return new RootCauseAnalyzer();
329
+ }
@@ -0,0 +1,228 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════════
2
+ // VIBECODE DEBUG - Evidence Collector
3
+ // Gathers error information from multiple sources
4
+ // ═══════════════════════════════════════════════════════════════════════════════
5
+
6
+ import { exec } from 'child_process';
7
+ import { promisify } from 'util';
8
+ import fs from 'fs-extra';
9
+ import path from 'path';
10
+
11
+ const execAsync = promisify(exec);
12
+
13
+ /**
14
+ * Evidence Collector Class
15
+ * Gathers and parses error information from various sources
16
+ */
17
+ export class EvidenceCollector {
18
+ constructor(projectPath) {
19
+ this.projectPath = projectPath;
20
+ }
21
+
22
+ /**
23
+ * Collect evidence from all available sources
24
+ */
25
+ async collect(input) {
26
+ const evidence = {
27
+ type: 'unknown',
28
+ category: 'RUNTIME',
29
+ description: input.description || '',
30
+ message: '',
31
+ stackTrace: [],
32
+ files: [],
33
+ lines: [],
34
+ logs: [],
35
+ hasImage: false,
36
+ imagePath: null,
37
+ timestamp: new Date().toISOString()
38
+ };
39
+
40
+ // From description
41
+ if (input.description) {
42
+ this.parseDescription(evidence, input.description);
43
+ }
44
+
45
+ // From log paste
46
+ if (input.log) {
47
+ this.parseLog(evidence, input.log);
48
+ }
49
+
50
+ // From image (note for future OCR)
51
+ if (input.image) {
52
+ await this.parseImage(evidence, input.image);
53
+ }
54
+
55
+ // Auto-scan mode
56
+ if (input.auto) {
57
+ await this.autoScan(evidence);
58
+ }
59
+
60
+ // Categorize error
61
+ evidence.category = this.categorizeError(evidence);
62
+
63
+ return evidence;
64
+ }
65
+
66
+ /**
67
+ * Parse error description text
68
+ */
69
+ parseDescription(evidence, description) {
70
+ const desc = description.toLowerCase();
71
+
72
+ // Detect error type from description
73
+ if (desc.includes('typeerror')) evidence.type = 'TypeError';
74
+ else if (desc.includes('syntaxerror')) evidence.type = 'SyntaxError';
75
+ else if (desc.includes('referenceerror')) evidence.type = 'ReferenceError';
76
+ else if (desc.includes('cannot find module')) evidence.type = 'ImportError';
77
+ else if (desc.includes('undefined')) evidence.type = 'UndefinedError';
78
+ else if (desc.includes('cannot read properties')) evidence.type = 'TypeError';
79
+
80
+ // Extract file references
81
+ const fileMatches = description.match(/[\w\/\-\.]+\.(js|ts|tsx|jsx|json|mjs|cjs)/gi);
82
+ if (fileMatches) {
83
+ evidence.files = [...new Set(fileMatches)];
84
+ }
85
+
86
+ // Extract line numbers
87
+ const lineMatches = description.match(/:(\d+)(?::\d+)?/g);
88
+ if (lineMatches) {
89
+ evidence.lines = lineMatches.map(m => parseInt(m.split(':')[1]));
90
+ }
91
+
92
+ // Extract error message
93
+ const errorMatch = description.match(/(Error|TypeError|SyntaxError|ReferenceError):\s*(.+?)(?:\n|$)/i);
94
+ if (errorMatch) {
95
+ evidence.type = errorMatch[1];
96
+ evidence.message = errorMatch[2].trim();
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Parse error log text
102
+ */
103
+ parseLog(evidence, log) {
104
+ evidence.logs.push(log);
105
+
106
+ // Parse stack trace
107
+ const stackLines = log.split('\n').filter(line =>
108
+ line.trim().startsWith('at ') || line.includes('Error:')
109
+ );
110
+ evidence.stackTrace = stackLines.slice(0, 15);
111
+
112
+ // Extract error message
113
+ const errorMatch = log.match(/(Error|TypeError|SyntaxError|ReferenceError|RangeError):\s*(.+?)(?:\n|$)/i);
114
+ if (errorMatch) {
115
+ evidence.type = errorMatch[1];
116
+ evidence.message = errorMatch[2].trim();
117
+ }
118
+
119
+ // Extract files from stack trace
120
+ const fileMatches = log.match(/(?:at\s+)?(?:\w+\s+)?\(?([^\s()]+\.(js|ts|tsx|jsx)):(\d+)(?::\d+)?\)?/gi);
121
+ if (fileMatches) {
122
+ for (const match of fileMatches) {
123
+ const fileMatch = match.match(/([^\s()]+\.(js|ts|tsx|jsx)):(\d+)/i);
124
+ if (fileMatch) {
125
+ evidence.files.push(fileMatch[1]);
126
+ evidence.lines.push(parseInt(fileMatch[3]));
127
+ }
128
+ }
129
+ evidence.files = [...new Set(evidence.files)];
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Parse image evidence (placeholder for OCR/Vision)
135
+ */
136
+ async parseImage(evidence, imagePath) {
137
+ if (await fs.pathExists(imagePath)) {
138
+ evidence.hasImage = true;
139
+ evidence.imagePath = imagePath;
140
+ // Future: OCR or Claude Vision API
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Auto-scan project for errors
146
+ */
147
+ async autoScan(evidence) {
148
+ const checks = [
149
+ { name: 'npm test', cmd: 'npm test', softFail: true },
150
+ { name: 'npm build', cmd: 'npm run build', softFail: false },
151
+ { name: 'npm lint', cmd: 'npm run lint', softFail: true },
152
+ { name: 'tsc', cmd: 'npx tsc --noEmit', softFail: true }
153
+ ];
154
+
155
+ // Check if package.json exists
156
+ const pkgPath = path.join(this.projectPath, 'package.json');
157
+ if (!await fs.pathExists(pkgPath)) {
158
+ evidence.logs.push({ source: 'auto-scan', error: 'No package.json found' });
159
+ return;
160
+ }
161
+
162
+ const pkg = await fs.readJson(pkgPath);
163
+
164
+ for (const check of checks) {
165
+ // Skip if script doesn't exist
166
+ const scriptName = check.cmd.replace('npm run ', '').replace('npm ', '');
167
+ if (check.cmd.startsWith('npm') && scriptName !== 'test' && !pkg.scripts?.[scriptName]) {
168
+ continue;
169
+ }
170
+
171
+ try {
172
+ await execAsync(check.cmd, {
173
+ cwd: this.projectPath,
174
+ timeout: 60000,
175
+ maxBuffer: 10 * 1024 * 1024
176
+ });
177
+ } catch (error) {
178
+ const errorOutput = error.stderr || error.stdout || error.message;
179
+ evidence.logs.push({
180
+ source: check.name,
181
+ error: errorOutput.substring(0, 5000), // Limit size
182
+ exitCode: error.code
183
+ });
184
+
185
+ // Parse errors from output
186
+ this.parseLog(evidence, errorOutput);
187
+ }
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Categorize error by type
193
+ */
194
+ categorizeError(evidence) {
195
+ const type = (evidence.type || '').toLowerCase();
196
+ const msg = (evidence.message || evidence.description || '').toLowerCase();
197
+
198
+ if (type.includes('syntax') || msg.includes('unexpected token')) return 'SYNTAX';
199
+ if (type.includes('type') || msg.includes('cannot read properties')) return 'TYPE';
200
+ if (type.includes('reference') || msg.includes('is not defined')) return 'REFERENCE';
201
+ if (msg.includes('cannot find module') || msg.includes('module not found')) return 'IMPORT';
202
+ if (msg.includes('enoent') || msg.includes('no such file')) return 'FILE';
203
+ if (msg.includes('eslint') || msg.includes('lint')) return 'LINT';
204
+ if (msg.includes('test') || msg.includes('expect') || msg.includes('assert')) return 'TEST';
205
+ if (msg.includes('client component') || msg.includes('server component')) return 'NEXTJS';
206
+ if (msg.includes('prisma') || msg.includes('database')) return 'DATABASE';
207
+
208
+ return 'RUNTIME';
209
+ }
210
+
211
+ /**
212
+ * Get evidence summary
213
+ */
214
+ getSummary(evidence) {
215
+ return {
216
+ type: evidence.type,
217
+ category: evidence.category,
218
+ message: evidence.message || evidence.description?.substring(0, 100),
219
+ fileCount: evidence.files.length,
220
+ hasStackTrace: evidence.stackTrace.length > 0,
221
+ logSources: evidence.logs.length
222
+ };
223
+ }
224
+ }
225
+
226
+ export function createEvidenceCollector(projectPath) {
227
+ return new EvidenceCollector(projectPath);
228
+ }