@veraxhq/verax 0.2.0 → 0.2.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.
Files changed (89) hide show
  1. package/package.json +14 -4
  2. package/src/cli/commands/default.js +244 -86
  3. package/src/cli/commands/doctor.js +36 -4
  4. package/src/cli/commands/run.js +253 -69
  5. package/src/cli/entry.js +5 -5
  6. package/src/cli/util/detection-engine.js +4 -3
  7. package/src/cli/util/events.js +76 -0
  8. package/src/cli/util/expectation-extractor.js +11 -1
  9. package/src/cli/util/findings-writer.js +1 -0
  10. package/src/cli/util/observation-engine.js +69 -23
  11. package/src/cli/util/paths.js +3 -2
  12. package/src/cli/util/project-discovery.js +20 -0
  13. package/src/cli/util/redact.js +2 -2
  14. package/src/cli/util/runtime-budget.js +147 -0
  15. package/src/cli/util/summary-writer.js +12 -1
  16. package/src/types/global.d.ts +28 -0
  17. package/src/types/ts-ast.d.ts +24 -0
  18. package/src/verax/cli/doctor.js +2 -2
  19. package/src/verax/cli/init.js +1 -1
  20. package/src/verax/cli/url-safety.js +12 -2
  21. package/src/verax/cli/wizard.js +13 -2
  22. package/src/verax/core/budget-engine.js +1 -1
  23. package/src/verax/core/decision-snapshot.js +2 -2
  24. package/src/verax/core/determinism-model.js +35 -6
  25. package/src/verax/core/incremental-store.js +15 -7
  26. package/src/verax/core/replay-validator.js +4 -4
  27. package/src/verax/core/replay.js +1 -1
  28. package/src/verax/core/silence-impact.js +1 -1
  29. package/src/verax/core/silence-model.js +9 -7
  30. package/src/verax/detect/comparison.js +8 -3
  31. package/src/verax/detect/confidence-engine.js +17 -17
  32. package/src/verax/detect/detection-engine.js +1 -1
  33. package/src/verax/detect/evidence-index.js +15 -65
  34. package/src/verax/detect/expectation-model.js +54 -3
  35. package/src/verax/detect/explanation-helpers.js +1 -1
  36. package/src/verax/detect/finding-detector.js +2 -2
  37. package/src/verax/detect/findings-writer.js +9 -16
  38. package/src/verax/detect/flow-detector.js +4 -4
  39. package/src/verax/detect/index.js +37 -11
  40. package/src/verax/detect/interactive-findings.js +3 -4
  41. package/src/verax/detect/signal-mapper.js +2 -2
  42. package/src/verax/detect/skip-classifier.js +4 -4
  43. package/src/verax/detect/verdict-engine.js +4 -6
  44. package/src/verax/flow/flow-engine.js +3 -2
  45. package/src/verax/flow/flow-spec.js +1 -2
  46. package/src/verax/index.js +15 -3
  47. package/src/verax/intel/effect-detector.js +1 -1
  48. package/src/verax/intel/index.js +2 -2
  49. package/src/verax/intel/route-extractor.js +3 -3
  50. package/src/verax/intel/vue-navigation-extractor.js +81 -18
  51. package/src/verax/intel/vue-router-extractor.js +4 -2
  52. package/src/verax/learn/action-contract-extractor.js +3 -3
  53. package/src/verax/learn/ast-contract-extractor.js +53 -1
  54. package/src/verax/learn/index.js +36 -2
  55. package/src/verax/learn/manifest-writer.js +28 -14
  56. package/src/verax/learn/route-extractor.js +1 -1
  57. package/src/verax/learn/route-validator.js +8 -7
  58. package/src/verax/learn/state-extractor.js +1 -1
  59. package/src/verax/learn/static-extractor-navigation.js +1 -1
  60. package/src/verax/learn/static-extractor-validation.js +2 -2
  61. package/src/verax/learn/static-extractor.js +8 -7
  62. package/src/verax/learn/ts-contract-resolver.js +14 -12
  63. package/src/verax/observe/browser.js +22 -3
  64. package/src/verax/observe/console-sensor.js +2 -2
  65. package/src/verax/observe/expectation-executor.js +2 -1
  66. package/src/verax/observe/focus-sensor.js +1 -1
  67. package/src/verax/observe/human-driver.js +29 -10
  68. package/src/verax/observe/index.js +10 -7
  69. package/src/verax/observe/interaction-discovery.js +27 -15
  70. package/src/verax/observe/interaction-runner.js +6 -6
  71. package/src/verax/observe/loading-sensor.js +6 -0
  72. package/src/verax/observe/navigation-sensor.js +1 -1
  73. package/src/verax/observe/settle.js +1 -0
  74. package/src/verax/observe/state-sensor.js +8 -4
  75. package/src/verax/observe/state-ui-sensor.js +7 -1
  76. package/src/verax/observe/traces-writer.js +27 -16
  77. package/src/verax/observe/ui-signal-sensor.js +7 -0
  78. package/src/verax/scan-summary-writer.js +5 -2
  79. package/src/verax/shared/artifact-manager.js +1 -1
  80. package/src/verax/shared/budget-profiles.js +2 -2
  81. package/src/verax/shared/caching.js +1 -1
  82. package/src/verax/shared/config-loader.js +1 -2
  83. package/src/verax/shared/dynamic-route-utils.js +12 -6
  84. package/src/verax/shared/retry-policy.js +1 -6
  85. package/src/verax/shared/root-artifacts.js +1 -1
  86. package/src/verax/shared/zip-artifacts.js +1 -0
  87. package/src/verax/validate/context-validator.js +1 -1
  88. package/src/verax/observe/index.js.backup +0 -1
  89. package/src/verax/validate/context-validator.js.bak +0 -0
@@ -1,8 +1,8 @@
1
- import { resolve, join } from 'path';
1
+ import { resolve } from 'path';
2
2
  import { existsSync, readFileSync } from 'fs';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { dirname } from 'path';
5
- import { UsageError, DataError, CrashError } from '../util/errors.js';
5
+ import { UsageError, DataError } from '../util/errors.js';
6
6
  import { generateRunId } from '../util/run-id.js';
7
7
  import { getRunPaths, ensureRunDirectories } from '../util/paths.js';
8
8
  import { atomicWriteJson, atomicWriteText } from '../util/atomic-write.js';
@@ -16,6 +16,7 @@ import { writeObserveJson } from '../util/observe-writer.js';
16
16
  import { detectFindings } from '../util/detection-engine.js';
17
17
  import { writeFindingsJson } from '../util/findings-writer.js';
18
18
  import { writeSummaryJson } from '../util/summary-writer.js';
19
+ import { computeRuntimeBudget, withTimeout } from '../util/runtime-budget.js';
19
20
 
20
21
  const __filename = fileURLToPath(import.meta.url);
21
22
  const __dirname = dirname(__filename);
@@ -61,6 +62,7 @@ export async function runCommand(options) {
61
62
 
62
63
  // Setup event handlers
63
64
  if (json) {
65
+ // In JSON mode, emit events as JSONL (one JSON object per line)
64
66
  events.on('*', (event) => {
65
67
  console.log(JSON.stringify(event));
66
68
  });
@@ -75,6 +77,73 @@ export async function runCommand(options) {
75
77
  let runId = null;
76
78
  let paths = null;
77
79
  let startedAt = null;
80
+ let watchdogTimer = null;
81
+ let budget = null;
82
+ let timedOut = false;
83
+
84
+ // Graceful finalization function
85
+ const finalizeOnTimeout = async (reason) => {
86
+ if (timedOut) return; // Prevent double finalization
87
+ timedOut = true;
88
+
89
+ events.stopHeartbeat();
90
+
91
+ if (paths && runId && startedAt) {
92
+ try {
93
+ const failedAt = new Date().toISOString();
94
+ atomicWriteJson(paths.runStatusJson, {
95
+ status: 'FAILED',
96
+ runId,
97
+ startedAt,
98
+ failedAt,
99
+ error: reason,
100
+ });
101
+
102
+ atomicWriteJson(paths.runMetaJson, {
103
+ veraxVersion: getVersion(),
104
+ nodeVersion: process.version,
105
+ platform: process.platform,
106
+ cwd: projectRoot,
107
+ command: 'run',
108
+ args: { url, src, out },
109
+ url,
110
+ src: srcPath,
111
+ startedAt,
112
+ completedAt: failedAt,
113
+ error: reason,
114
+ });
115
+
116
+ try {
117
+ writeSummaryJson(paths.summaryJson, {
118
+ runId,
119
+ status: 'FAILED',
120
+ startedAt,
121
+ completedAt: failedAt,
122
+ command: 'run',
123
+ url,
124
+ notes: `Run timed out: ${reason}`,
125
+ }, {
126
+ expectationsTotal: 0,
127
+ attempted: 0,
128
+ observed: 0,
129
+ silentFailures: 0,
130
+ coverageGaps: 0,
131
+ unproven: 0,
132
+ informational: 0,
133
+ });
134
+ } catch (summaryError) {
135
+ // Ignore summary write errors during timeout handling
136
+ }
137
+ } catch (statusError) {
138
+ // Ignore errors when writing failure status
139
+ }
140
+ }
141
+
142
+ events.emit('error', {
143
+ message: reason,
144
+ type: 'timeout',
145
+ });
146
+ };
78
147
 
79
148
  try {
80
149
  // Generate run ID
@@ -148,14 +217,53 @@ export async function runCommand(options) {
148
217
  error: null,
149
218
  });
150
219
 
151
- // Simulate learning phase (placeholder)
220
+ // Extract expectations first to compute budget
152
221
  events.emit('phase:started', {
153
222
  phase: 'Learn',
154
223
  message: 'Analyzing project structure...',
155
224
  });
156
225
 
157
- // Extract expectations
158
- const { expectations, skipped } = await extractExpectations(projectProfile, projectProfile.sourceRoot);
226
+ events.startHeartbeat('Learn', json);
227
+
228
+ let expectations, skipped;
229
+ try {
230
+ // Extract expectations (quick operation, no timeout needed here)
231
+ const result = await extractExpectations(projectProfile, projectProfile.sourceRoot);
232
+ expectations = result.expectations;
233
+ skipped = result.skipped;
234
+ } finally {
235
+ events.stopHeartbeat();
236
+ }
237
+
238
+ // Compute runtime budget based on expectations count
239
+ budget = computeRuntimeBudget({
240
+ expectationsCount: expectations.length,
241
+ mode: 'run',
242
+ framework: projectProfile.framework,
243
+ fileCount: projectProfile.fileCount || expectations.length,
244
+ });
245
+
246
+ // Set up global watchdog timer
247
+ watchdogTimer = setTimeout(async () => {
248
+ await finalizeOnTimeout(`Global timeout exceeded: ${budget.totalMaxMs}ms`);
249
+ // Exit with code 0 (tool executed, just timed out)
250
+ process.exit(0);
251
+ }, budget.totalMaxMs);
252
+
253
+ // Wrap Learn phase with timeout
254
+ try {
255
+ await withTimeout(
256
+ budget.learnMaxMs,
257
+ Promise.resolve(), // Learn phase already completed
258
+ 'Learn'
259
+ );
260
+ } catch (error) {
261
+ if (error.message.includes('timeout')) {
262
+ await finalizeOnTimeout(`Learn phase timeout: ${budget.learnMaxMs}ms`);
263
+ process.exit(0);
264
+ }
265
+ throw error;
266
+ }
159
267
 
160
268
  // For now, emit a placeholder trace event
161
269
  events.emit('phase:completed', {
@@ -163,39 +271,60 @@ export async function runCommand(options) {
163
271
  message: 'Project analysis complete',
164
272
  });
165
273
 
166
- // Observe phase
274
+ // Observe phase with timeout
167
275
  events.emit('phase:started', {
168
276
  phase: 'Observe',
169
277
  message: 'Launching browser and observing expectations...',
170
278
  });
171
279
 
280
+ events.startHeartbeat('Observe', json);
281
+
172
282
  let observeData = null;
173
- if (expectations.length > 0) {
174
- try {
175
- observeData = await observeExpectations(
176
- expectations,
177
- url,
178
- paths.evidenceDir,
179
- (progress) => {
180
- events.emit(progress.event, progress);
283
+ try {
284
+ if (expectations.length > 0) {
285
+ try {
286
+ observeData = await withTimeout(
287
+ budget.observeMaxMs,
288
+ observeExpectations(
289
+ expectations,
290
+ url,
291
+ paths.evidenceDir,
292
+ (progress) => {
293
+ events.emit(progress.event, progress);
294
+ }
295
+ ),
296
+ 'Observe'
297
+ );
298
+ } catch (error) {
299
+ if (error.message.includes('timeout')) {
300
+ events.emit('observe:error', {
301
+ message: `Observe phase timeout: ${budget.observeMaxMs}ms`,
302
+ });
303
+ observeData = {
304
+ observations: [],
305
+ stats: { attempted: 0, observed: 0, notObserved: 0 },
306
+ observedAt: new Date().toISOString(),
307
+ };
308
+ } else {
309
+ events.emit('observe:error', {
310
+ message: error.message,
311
+ });
312
+ observeData = {
313
+ observations: [],
314
+ stats: { attempted: 0, observed: 0, notObserved: 0 },
315
+ observedAt: new Date().toISOString(),
316
+ };
181
317
  }
182
- );
183
- } catch (error) {
184
- events.emit('observe:error', {
185
- message: error.message,
186
- });
318
+ }
319
+ } else {
187
320
  observeData = {
188
321
  observations: [],
189
322
  stats: { attempted: 0, observed: 0, notObserved: 0 },
190
323
  observedAt: new Date().toISOString(),
191
324
  };
192
325
  }
193
- } else {
194
- observeData = {
195
- observations: [],
196
- stats: { attempted: 0, observed: 0, notObserved: 0 },
197
- observedAt: new Date().toISOString(),
198
- };
326
+ } finally {
327
+ events.stopHeartbeat();
199
328
  }
200
329
 
201
330
  events.emit('phase:completed', {
@@ -203,32 +332,53 @@ export async function runCommand(options) {
203
332
  message: 'Browser observation complete',
204
333
  });
205
334
 
206
- // Detect phase
335
+ // Detect phase with timeout
207
336
  events.emit('phase:started', {
208
337
  phase: 'Detect',
209
338
  message: 'Analyzing findings and detecting silent failures...',
210
339
  });
211
340
 
341
+ events.startHeartbeat('Detect', json);
342
+
212
343
  let detectData = null;
213
344
  try {
214
- // Use already-extracted expectations
215
- const learnData = {
216
- expectations,
217
- skipped,
218
- };
219
-
220
- detectData = await detectFindings(learnData, observeData, projectRoot, (progress) => {
221
- events.emit(progress.event, progress);
222
- });
223
- } catch (error) {
224
- events.emit('detect:error', {
225
- message: error.message,
226
- });
227
- detectData = {
228
- findings: [],
229
- stats: { total: 0, silentFailures: 0, observed: 0, coverageGaps: 0, unproven: 0, informational: 0 },
230
- detectedAt: new Date().toISOString(),
231
- };
345
+ try {
346
+ // Use already-extracted expectations
347
+ const learnData = {
348
+ expectations,
349
+ skipped,
350
+ };
351
+
352
+ detectData = await withTimeout(
353
+ budget.detectMaxMs,
354
+ detectFindings(learnData, observeData, projectRoot, (progress) => {
355
+ events.emit(progress.event, progress);
356
+ }),
357
+ 'Detect'
358
+ );
359
+ } catch (error) {
360
+ if (error.message.includes('timeout')) {
361
+ events.emit('detect:error', {
362
+ message: `Detect phase timeout: ${budget.detectMaxMs}ms`,
363
+ });
364
+ detectData = {
365
+ findings: [],
366
+ stats: { total: 0, silentFailures: 0, observed: 0, coverageGaps: 0, unproven: 0, informational: 0 },
367
+ detectedAt: new Date().toISOString(),
368
+ };
369
+ } else {
370
+ events.emit('detect:error', {
371
+ message: error.message,
372
+ });
373
+ detectData = {
374
+ findings: [],
375
+ stats: { total: 0, silentFailures: 0, observed: 0, coverageGaps: 0, unproven: 0, informational: 0 },
376
+ detectedAt: new Date().toISOString(),
377
+ };
378
+ }
379
+ }
380
+ } finally {
381
+ events.stopHeartbeat();
232
382
  }
233
383
 
234
384
  events.emit('phase:completed', {
@@ -236,12 +386,20 @@ export async function runCommand(options) {
236
386
  message: 'Silent failure detection complete',
237
387
  });
238
388
 
389
+ // Clear watchdog timer on successful completion
390
+ if (watchdogTimer) {
391
+ clearTimeout(watchdogTimer);
392
+ watchdogTimer = null;
393
+ }
394
+
239
395
  // Emit finalize phase
240
396
  events.emit('phase:started', {
241
397
  phase: 'Finalize Artifacts',
242
398
  message: 'Writing run results...',
243
399
  });
244
400
 
401
+ events.stopHeartbeat();
402
+
245
403
  const completedAt = new Date().toISOString();
246
404
 
247
405
  // Write completed status
@@ -267,6 +425,20 @@ export async function runCommand(options) {
267
425
  error: null,
268
426
  });
269
427
 
428
+ const runDurationMs = completedAt && startedAt ? (Date.parse(completedAt) - Date.parse(startedAt)) : 0;
429
+ const metrics = {
430
+ learnMs: observeData?.timings?.learnMs || 0,
431
+ observeMs: observeData?.timings?.observeMs || observeData?.timings?.totalMs || 0,
432
+ detectMs: detectData?.timings?.detectMs || detectData?.timings?.totalMs || 0,
433
+ totalMs: runDurationMs > 0 ? runDurationMs : (budget?.ms || 0)
434
+ };
435
+ const findingsCounts = detectData?.findingsCounts || {
436
+ HIGH: 0,
437
+ MEDIUM: 0,
438
+ LOW: 0,
439
+ UNKNOWN: 0,
440
+ };
441
+
270
442
  // Write summary with stable digest
271
443
  writeSummaryJson(paths.summaryJson, {
272
444
  runId,
@@ -276,6 +448,8 @@ export async function runCommand(options) {
276
448
  command: 'run',
277
449
  url,
278
450
  notes: 'Run completed successfully',
451
+ metrics,
452
+ findingsCounts,
279
453
  }, {
280
454
  expectationsTotal: expectations.length,
281
455
  attempted: observeData.stats?.attempted || 0,
@@ -284,36 +458,18 @@ export async function runCommand(options) {
284
458
  coverageGaps: detectData.stats?.coverageGaps || 0,
285
459
  unproven: detectData.stats?.unproven || 0,
286
460
  informational: detectData.stats?.informational || 0,
461
+ ...metrics,
462
+ ...findingsCounts,
287
463
  });
288
464
 
289
465
  // Write detect results (or empty if detection failed)
290
466
  writeFindingsJson(paths.baseDir, detectData);
291
467
 
292
- // Write traces (at least phase events)
293
- const traces = [
294
- {
295
- type: 'phase:started',
296
- timestamp: startedAt,
297
- phase: 'Detect Project',
298
- },
299
- {
300
- type: 'phase:completed',
301
- timestamp: new Date().toISOString(),
302
- phase: 'Detect Project',
303
- },
304
- {
305
- type: 'phase:started',
306
- timestamp: new Date().toISOString(),
307
- phase: 'Learn',
308
- },
309
- {
310
- type: 'phase:completed',
311
- timestamp: completedAt,
312
- phase: 'Learn',
313
- },
314
- ];
315
-
316
- const tracesContent = traces.map(t => JSON.stringify(t)).join('\n') + '\n';
468
+ // Write traces (include all events including heartbeats)
469
+ const allEvents = events.getEvents();
470
+ const tracesContent = allEvents
471
+ .map(e => JSON.stringify(e))
472
+ .join('\n') + '\n';
317
473
  atomicWriteText(paths.tracesJsonl, tracesContent);
318
474
 
319
475
  // Write project profile
@@ -330,6 +486,26 @@ export async function runCommand(options) {
330
486
  message: 'Run artifacts written',
331
487
  });
332
488
 
489
+ // Emit final summary event
490
+ if (json) {
491
+ events.emit('run:complete', {
492
+ runId,
493
+ url,
494
+ command: 'run',
495
+ findingsCounts,
496
+ metrics,
497
+ digest: {
498
+ expectationsTotal: expectations.length,
499
+ attempted: observeData.stats?.attempted || 0,
500
+ observed: observeData.stats?.observed || 0,
501
+ silentFailures: detectData.stats?.silentFailures || 0,
502
+ coverageGaps: detectData.stats?.coverageGaps || 0,
503
+ unproven: detectData.stats?.unproven || 0,
504
+ informational: detectData.stats?.informational || 0,
505
+ }
506
+ });
507
+ }
508
+
333
509
  // Print summary if not JSON mode
334
510
  if (!json) {
335
511
  console.log('\nRun complete.');
@@ -339,6 +515,14 @@ export async function runCommand(options) {
339
515
 
340
516
  return { runId, paths, success: true };
341
517
  } catch (error) {
518
+ // Clear watchdog timer on error
519
+ if (watchdogTimer) {
520
+ clearTimeout(watchdogTimer);
521
+ watchdogTimer = null;
522
+ }
523
+
524
+ events.stopHeartbeat();
525
+
342
526
  // Mark run as FAILED if we have paths
343
527
  if (paths && runId && startedAt) {
344
528
  try {
package/src/cli/entry.js CHANGED
@@ -22,7 +22,7 @@ import { defaultCommand } from './commands/default.js';
22
22
  import { runCommand } from './commands/run.js';
23
23
  import { inspectCommand } from './commands/inspect.js';
24
24
  import { doctorCommand } from './commands/doctor.js';
25
- import { getExitCode, UsageError, DataError, CrashError } from './util/errors.js';
25
+ import { getExitCode, UsageError } from './util/errors.js';
26
26
 
27
27
  const __filename = fileURLToPath(import.meta.url);
28
28
  const __dirname = dirname(__filename);
@@ -74,7 +74,7 @@ async function main() {
74
74
  throw new UsageError('run command requires --url <url> argument');
75
75
  }
76
76
 
77
- const result = await runCommand({ url, src, out, json, verbose });
77
+ await runCommand({ url, src, out, json, verbose });
78
78
  process.exit(0);
79
79
  }
80
80
 
@@ -87,7 +87,7 @@ async function main() {
87
87
  const runPath = args[1];
88
88
  const json = args.includes('--json');
89
89
 
90
- const result = await inspectCommand(runPath, { json });
90
+ await inspectCommand(runPath, { json });
91
91
  process.exit(0);
92
92
  }
93
93
 
@@ -96,7 +96,7 @@ async function main() {
96
96
  const allowedFlags = new Set(['--json']);
97
97
  const extraFlags = args.slice(1).filter((a) => a.startsWith('-') && !allowedFlags.has(a));
98
98
  const json = args.includes('--json');
99
- const result = await doctorCommand({ json, extraFlags });
99
+ await doctorCommand({ json, extraFlags });
100
100
  process.exit(0);
101
101
  }
102
102
 
@@ -114,7 +114,7 @@ async function main() {
114
114
  const json = args.includes('--json');
115
115
  const verbose = args.includes('--verbose');
116
116
 
117
- const result = await defaultCommand({ url, src, out, json, verbose });
117
+ await defaultCommand({ url, src, out, json, verbose });
118
118
  process.exit(0);
119
119
  } catch (error) {
120
120
  // Print error message
@@ -4,7 +4,8 @@
4
4
  * Produces exactly one finding per expectation with deterministic confidence and impact.
5
5
  */
6
6
 
7
- export async function detectFindings(learnData, observeData, projectPath, onProgress) {
7
+ export async function detectFindings(learnData, observeData, projectPath, onProgress, options = {}) {
8
+ const log = options.silent ? () => {} : console.log;
8
9
  const findings = [];
9
10
  const stats = {
10
11
  total: 0,
@@ -58,8 +59,8 @@ export async function detectFindings(learnData, observeData, projectPath, onProg
58
59
  }
59
60
 
60
61
  // Terminal narration (one line per finding + summary)
61
- narration.forEach((line) => console.log(line));
62
- console.log(`SUMMARY findings=${findings.length} observed=${stats.observed} silent-failure=${stats.silentFailures} coverage-gap=${stats.coverageGaps} unproven=${stats.unproven}`);
62
+ narration.forEach((line) => log(line));
63
+ log(`SUMMARY findings=${findings.length} observed=${stats.observed} silent-failure=${stats.silentFailures} coverage-gap=${stats.coverageGaps} unproven=${stats.unproven}`);
63
64
 
64
65
  // Emit completion event
65
66
  if (onProgress) {
@@ -5,6 +5,10 @@ export class RunEventEmitter {
5
5
  constructor() {
6
6
  this.events = [];
7
7
  this.listeners = [];
8
+ this.heartbeatInterval = null;
9
+ this.heartbeatStartTime = null;
10
+ this.currentPhase = null;
11
+ this.heartbeatIntervalMs = 2500; // 2.5 seconds
8
12
  }
9
13
 
10
14
  on(event, handler) {
@@ -31,4 +35,76 @@ export class RunEventEmitter {
31
35
  getEvents() {
32
36
  return this.events;
33
37
  }
38
+
39
+ /**
40
+ * Start heartbeat for a phase
41
+ * @param {string} phase - Current phase name
42
+ * @param {boolean} jsonMode - Whether in JSON output mode
43
+ */
44
+ startHeartbeat(phase, jsonMode = false) {
45
+ this.currentPhase = phase;
46
+ this.heartbeatStartTime = Date.now();
47
+
48
+ if (this.heartbeatInterval) {
49
+ clearInterval(this.heartbeatInterval);
50
+ }
51
+
52
+ this.heartbeatInterval = setInterval(() => {
53
+ const elapsedMs = Date.now() - this.heartbeatStartTime;
54
+ const elapsedSeconds = Math.floor(elapsedMs / 1000);
55
+
56
+ const heartbeatEvent = {
57
+ type: 'heartbeat',
58
+ phase: this.currentPhase,
59
+ elapsedMs,
60
+ elapsedSeconds,
61
+ timestamp: new Date().toISOString(),
62
+ };
63
+
64
+ // Add to events array
65
+ this.events.push(heartbeatEvent);
66
+
67
+ // Emit to listeners
68
+ this.listeners.forEach(({ event: listenEvent, handler }) => {
69
+ if (listenEvent === 'heartbeat' || listenEvent === '*') {
70
+ if (jsonMode) {
71
+ // In JSON mode, emit as JSON line
72
+ handler(heartbeatEvent);
73
+ } else {
74
+ // Human-readable format: single line, overwrite-friendly
75
+ process.stdout.write(`\r…still working (phase=${this.currentPhase}, elapsed=${elapsedSeconds}s)`);
76
+ }
77
+ }
78
+ });
79
+ }, this.heartbeatIntervalMs);
80
+
81
+ // CRITICAL: Unref the interval so it doesn't keep the process alive
82
+ // This allows tests to exit cleanly even if stopHeartbeat() is not called
83
+ if (this.heartbeatInterval && this.heartbeatInterval.unref) {
84
+ this.heartbeatInterval.unref();
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Stop heartbeat
90
+ */
91
+ stopHeartbeat() {
92
+ if (this.heartbeatInterval) {
93
+ clearInterval(this.heartbeatInterval);
94
+ this.heartbeatInterval = null;
95
+ }
96
+ // Clear the progress line in human mode
97
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
98
+ this.currentPhase = null;
99
+ this.heartbeatStartTime = null;
100
+ }
101
+
102
+ /**
103
+ * Update current phase for heartbeat
104
+ * @param {string} phase - New phase name
105
+ */
106
+ updatePhase(phase) {
107
+ this.currentPhase = phase;
108
+ this.heartbeatStartTime = Date.now();
109
+ }
34
110
  }
@@ -7,7 +7,7 @@ import { expIdFromHash, compareExpectations } from './idgen.js';
7
7
  * Extracts explicit, static expectations from source files
8
8
  */
9
9
 
10
- export async function extractExpectations(projectProfile, srcPath) {
10
+ export async function extractExpectations(projectProfile, _srcPath) {
11
11
  const expectations = [];
12
12
  const skipped = {
13
13
  dynamic: 0,
@@ -68,6 +68,16 @@ function getScanPaths(projectProfile, sourceRoot) {
68
68
  return [sourceRoot];
69
69
  }
70
70
 
71
+ // Unknown framework or no framework detected - check if it's a static HTML project
72
+ // (This handles cases where framework detection failed but HTML files exist)
73
+ if (framework === 'unknown') {
74
+ const htmlFiles = readdirSync(sourceRoot, { withFileTypes: true })
75
+ .filter(e => e.isFile() && e.name.endsWith('.html'));
76
+ if (htmlFiles.length > 0) {
77
+ return [sourceRoot];
78
+ }
79
+ }
80
+
71
81
  // Unknown framework - scan src if it exists
72
82
  const srcPath = resolve(sourceRoot, 'src');
73
83
  try {
@@ -16,6 +16,7 @@ export function writeFindingsJson(runDir, findingsData) {
16
16
 
17
17
  const payload = {
18
18
  findings: findingsWithIds,
19
+ total: findingsData.stats?.total || 0,
19
20
  stats: {
20
21
  total: findingsData.stats?.total || 0,
21
22
  silentFailures: findingsData.stats?.silentFailures || 0,