@veraxhq/verax 0.1.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 (135) hide show
  1. package/README.md +123 -88
  2. package/bin/verax.js +11 -452
  3. package/package.json +24 -36
  4. package/src/cli/commands/default.js +681 -0
  5. package/src/cli/commands/doctor.js +197 -0
  6. package/src/cli/commands/inspect.js +109 -0
  7. package/src/cli/commands/run.js +586 -0
  8. package/src/cli/entry.js +196 -0
  9. package/src/cli/util/atomic-write.js +37 -0
  10. package/src/cli/util/detection-engine.js +297 -0
  11. package/src/cli/util/env-url.js +33 -0
  12. package/src/cli/util/errors.js +44 -0
  13. package/src/cli/util/events.js +110 -0
  14. package/src/cli/util/expectation-extractor.js +388 -0
  15. package/src/cli/util/findings-writer.js +32 -0
  16. package/src/cli/util/idgen.js +87 -0
  17. package/src/cli/util/learn-writer.js +39 -0
  18. package/src/cli/util/observation-engine.js +412 -0
  19. package/src/cli/util/observe-writer.js +25 -0
  20. package/src/cli/util/paths.js +30 -0
  21. package/src/cli/util/project-discovery.js +297 -0
  22. package/src/cli/util/project-writer.js +26 -0
  23. package/src/cli/util/redact.js +128 -0
  24. package/src/cli/util/run-id.js +30 -0
  25. package/src/cli/util/runtime-budget.js +147 -0
  26. package/src/cli/util/summary-writer.js +43 -0
  27. package/src/types/global.d.ts +28 -0
  28. package/src/types/ts-ast.d.ts +24 -0
  29. package/src/verax/cli/ci-summary.js +35 -0
  30. package/src/verax/cli/context-explanation.js +89 -0
  31. package/src/verax/cli/doctor.js +277 -0
  32. package/src/verax/cli/error-normalizer.js +154 -0
  33. package/src/verax/cli/explain-output.js +105 -0
  34. package/src/verax/cli/finding-explainer.js +130 -0
  35. package/src/verax/cli/init.js +237 -0
  36. package/src/verax/cli/run-overview.js +163 -0
  37. package/src/verax/cli/url-safety.js +111 -0
  38. package/src/verax/cli/wizard.js +109 -0
  39. package/src/verax/cli/zero-findings-explainer.js +57 -0
  40. package/src/verax/cli/zero-interaction-explainer.js +127 -0
  41. package/src/verax/core/action-classifier.js +86 -0
  42. package/src/verax/core/budget-engine.js +218 -0
  43. package/src/verax/core/canonical-outcomes.js +157 -0
  44. package/src/verax/core/decision-snapshot.js +335 -0
  45. package/src/verax/core/determinism-model.js +432 -0
  46. package/src/verax/core/incremental-store.js +245 -0
  47. package/src/verax/core/invariants.js +356 -0
  48. package/src/verax/core/promise-model.js +230 -0
  49. package/src/verax/core/replay-validator.js +350 -0
  50. package/src/verax/core/replay.js +222 -0
  51. package/src/verax/core/run-id.js +175 -0
  52. package/src/verax/core/run-manifest.js +99 -0
  53. package/src/verax/core/silence-impact.js +369 -0
  54. package/src/verax/core/silence-model.js +523 -0
  55. package/src/verax/detect/comparison.js +7 -34
  56. package/src/verax/detect/confidence-engine.js +764 -329
  57. package/src/verax/detect/detection-engine.js +293 -0
  58. package/src/verax/detect/evidence-index.js +127 -0
  59. package/src/verax/detect/expectation-model.js +241 -168
  60. package/src/verax/detect/explanation-helpers.js +187 -0
  61. package/src/verax/detect/finding-detector.js +450 -0
  62. package/src/verax/detect/findings-writer.js +41 -12
  63. package/src/verax/detect/flow-detector.js +366 -0
  64. package/src/verax/detect/index.js +200 -288
  65. package/src/verax/detect/interactive-findings.js +612 -0
  66. package/src/verax/detect/signal-mapper.js +308 -0
  67. package/src/verax/detect/skip-classifier.js +4 -4
  68. package/src/verax/detect/verdict-engine.js +561 -0
  69. package/src/verax/evidence-index-writer.js +61 -0
  70. package/src/verax/flow/flow-engine.js +3 -2
  71. package/src/verax/flow/flow-spec.js +1 -2
  72. package/src/verax/index.js +103 -15
  73. package/src/verax/intel/effect-detector.js +368 -0
  74. package/src/verax/intel/handler-mapper.js +249 -0
  75. package/src/verax/intel/index.js +281 -0
  76. package/src/verax/intel/route-extractor.js +280 -0
  77. package/src/verax/intel/ts-program.js +256 -0
  78. package/src/verax/intel/vue-navigation-extractor.js +642 -0
  79. package/src/verax/intel/vue-router-extractor.js +325 -0
  80. package/src/verax/learn/action-contract-extractor.js +338 -104
  81. package/src/verax/learn/ast-contract-extractor.js +148 -6
  82. package/src/verax/learn/flow-extractor.js +172 -0
  83. package/src/verax/learn/index.js +36 -2
  84. package/src/verax/learn/manifest-writer.js +122 -58
  85. package/src/verax/learn/project-detector.js +40 -0
  86. package/src/verax/learn/route-extractor.js +28 -97
  87. package/src/verax/learn/route-validator.js +8 -7
  88. package/src/verax/learn/state-extractor.js +212 -0
  89. package/src/verax/learn/static-extractor-navigation.js +114 -0
  90. package/src/verax/learn/static-extractor-validation.js +88 -0
  91. package/src/verax/learn/static-extractor.js +119 -10
  92. package/src/verax/learn/truth-assessor.js +24 -21
  93. package/src/verax/learn/ts-contract-resolver.js +14 -12
  94. package/src/verax/observe/aria-sensor.js +211 -0
  95. package/src/verax/observe/browser.js +30 -6
  96. package/src/verax/observe/console-sensor.js +2 -18
  97. package/src/verax/observe/domain-boundary.js +10 -1
  98. package/src/verax/observe/expectation-executor.js +513 -0
  99. package/src/verax/observe/flow-matcher.js +143 -0
  100. package/src/verax/observe/focus-sensor.js +196 -0
  101. package/src/verax/observe/human-driver.js +660 -273
  102. package/src/verax/observe/index.js +910 -26
  103. package/src/verax/observe/interaction-discovery.js +378 -15
  104. package/src/verax/observe/interaction-runner.js +562 -197
  105. package/src/verax/observe/loading-sensor.js +145 -0
  106. package/src/verax/observe/navigation-sensor.js +255 -0
  107. package/src/verax/observe/network-sensor.js +55 -7
  108. package/src/verax/observe/observed-expectation-deriver.js +186 -0
  109. package/src/verax/observe/observed-expectation.js +305 -0
  110. package/src/verax/observe/page-frontier.js +234 -0
  111. package/src/verax/observe/settle.js +38 -17
  112. package/src/verax/observe/state-sensor.js +393 -0
  113. package/src/verax/observe/state-ui-sensor.js +7 -1
  114. package/src/verax/observe/timing-sensor.js +228 -0
  115. package/src/verax/observe/traces-writer.js +73 -21
  116. package/src/verax/observe/ui-signal-sensor.js +143 -17
  117. package/src/verax/scan-summary-writer.js +80 -15
  118. package/src/verax/shared/artifact-manager.js +111 -9
  119. package/src/verax/shared/budget-profiles.js +136 -0
  120. package/src/verax/shared/caching.js +1 -1
  121. package/src/verax/shared/ci-detection.js +39 -0
  122. package/src/verax/shared/config-loader.js +169 -0
  123. package/src/verax/shared/dynamic-route-utils.js +224 -0
  124. package/src/verax/shared/expectation-coverage.js +44 -0
  125. package/src/verax/shared/expectation-prover.js +81 -0
  126. package/src/verax/shared/expectation-tracker.js +201 -0
  127. package/src/verax/shared/expectations-writer.js +60 -0
  128. package/src/verax/shared/first-run.js +44 -0
  129. package/src/verax/shared/progress-reporter.js +171 -0
  130. package/src/verax/shared/retry-policy.js +9 -1
  131. package/src/verax/shared/root-artifacts.js +49 -0
  132. package/src/verax/shared/scan-budget.js +86 -0
  133. package/src/verax/shared/url-normalizer.js +162 -0
  134. package/src/verax/shared/zip-artifacts.js +66 -0
  135. package/src/verax/validate/context-validator.js +244 -0
@@ -0,0 +1,681 @@
1
+ import { resolve } from 'path';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { fileURLToPath } from 'url';
4
+ import { dirname } from 'path';
5
+ import inquirer from 'inquirer';
6
+ import { DataError } from '../util/errors.js';
7
+ import { generateRunId } from '../util/run-id.js';
8
+ import { getRunPaths, ensureRunDirectories } from '../util/paths.js';
9
+ import { atomicWriteJson, atomicWriteText } from '../util/atomic-write.js';
10
+ import { RunEventEmitter } from '../util/events.js';
11
+ import { tryResolveUrlFromEnv } from '../util/env-url.js';
12
+ import { discoverProject, getFrameworkDisplayName, extractPortFromScript } from '../util/project-discovery.js';
13
+ import { writeProjectJson } from '../util/project-writer.js';
14
+ import { extractExpectations } from '../util/expectation-extractor.js';
15
+ import { writeLearnJson } from '../util/learn-writer.js';
16
+ import { observeExpectations } from '../util/observation-engine.js';
17
+ import { writeObserveJson } from '../util/observe-writer.js';
18
+ import { detectFindings } from '../util/detection-engine.js';
19
+ import { writeFindingsJson } from '../util/findings-writer.js';
20
+ import { writeSummaryJson } from '../util/summary-writer.js';
21
+ import { computeRuntimeBudget, withTimeout } from '../util/runtime-budget.js';
22
+
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = dirname(__filename);
25
+
26
+ function getVersion() {
27
+ try {
28
+ const pkgPath = resolve(__dirname, '../../../package.json');
29
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
30
+ return pkg.version;
31
+ } catch {
32
+ return '0.2.0';
33
+ }
34
+ }
35
+
36
+ /**
37
+ * `verax` smart default command
38
+ * Interactive mode with intelligent URL detection
39
+ */
40
+ export async function defaultCommand(options = {}) {
41
+ const {
42
+ src = '.',
43
+ out = '.verax',
44
+ url = null,
45
+ json = false,
46
+ verbose = false,
47
+ } = options;
48
+
49
+ const projectRoot = resolve(process.cwd());
50
+ const srcPath = resolve(projectRoot, src);
51
+
52
+ // Validate src directory exists
53
+ if (!existsSync(srcPath)) {
54
+ throw new DataError(`Source directory not found: ${srcPath}`);
55
+ }
56
+
57
+ // Create event emitter
58
+ const events = new RunEventEmitter();
59
+
60
+ // Setup event handlers
61
+ if (json) {
62
+ events.on('*', (event) => {
63
+ console.log(JSON.stringify(event));
64
+ });
65
+ }
66
+
67
+ // Show progress if not JSON
68
+ if (!json && !verbose) {
69
+ events.on('phase:started', (event) => {
70
+ if (!json) {
71
+ console.log(`${event.phase}...`);
72
+ }
73
+ });
74
+ }
75
+
76
+ let runId = null;
77
+ /** @type {ReturnType<typeof getRunPaths> | null} */
78
+ let paths = null;
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
+ // TypeScript narrowing: paths is guaranteed to be non-null here due to control flow
92
+ if (paths && runId && startedAt) {
93
+ try {
94
+ const failedAt = new Date().toISOString();
95
+ atomicWriteJson(paths.runStatusJson, {
96
+ status: 'FAILED',
97
+ runId,
98
+ startedAt,
99
+ failedAt,
100
+ error: reason,
101
+ });
102
+
103
+ atomicWriteJson(paths.runMetaJson, {
104
+ veraxVersion: getVersion(),
105
+ nodeVersion: process.version,
106
+ platform: process.platform,
107
+ cwd: projectRoot,
108
+ command: 'default',
109
+ args: { url: url || null, src },
110
+ url: url || null,
111
+ src: srcPath,
112
+ startedAt,
113
+ completedAt: failedAt,
114
+ error: reason,
115
+ });
116
+
117
+ try {
118
+ writeSummaryJson(paths.summaryJson, {
119
+ runId,
120
+ status: 'FAILED',
121
+ startedAt,
122
+ completedAt: failedAt,
123
+ command: 'default',
124
+ url: url || null,
125
+ notes: `Run timed out: ${reason}`,
126
+ }, {
127
+ expectationsTotal: 0,
128
+ attempted: 0,
129
+ observed: 0,
130
+ silentFailures: 0,
131
+ coverageGaps: 0,
132
+ unproven: 0,
133
+ informational: 0,
134
+ });
135
+ } catch (summaryError) {
136
+ // Ignore summary write errors during timeout handling
137
+ }
138
+ } catch (statusError) {
139
+ // Ignore errors when writing failure status
140
+ }
141
+ }
142
+
143
+ events.emit('error', {
144
+ message: reason,
145
+ type: 'timeout',
146
+ });
147
+ };
148
+
149
+ try {
150
+ events.emit('phase:started', {
151
+ phase: 'Detect Project',
152
+ message: 'Detecting project type...',
153
+ });
154
+
155
+ // Discover project configuration
156
+ let projectProfile;
157
+ try {
158
+ projectProfile = await discoverProject(srcPath);
159
+ } catch (error) {
160
+ // If discovery fails, create a minimal profile
161
+ projectProfile = {
162
+ framework: 'unknown',
163
+ router: null,
164
+ sourceRoot: srcPath,
165
+ packageManager: 'unknown',
166
+ scripts: { dev: null, build: null, start: null },
167
+ detectedAt: new Date().toISOString(),
168
+ };
169
+ }
170
+
171
+ const frameworkName = getFrameworkDisplayName(projectProfile.framework, projectProfile.router);
172
+
173
+ if (!json) {
174
+ console.log(`Detected framework: ${frameworkName}`);
175
+ console.log(`Resolved source root: ${projectProfile.sourceRoot}`);
176
+ }
177
+
178
+ events.emit('project:detected', {
179
+ framework: projectProfile.framework,
180
+ router: projectProfile.router,
181
+ frameworkName,
182
+ sourceRoot: projectProfile.sourceRoot,
183
+ });
184
+
185
+ events.emit('phase:completed', {
186
+ phase: 'Detect Project',
187
+ message: `Detected framework: ${frameworkName}`,
188
+ });
189
+
190
+ // Phase: Resolve URL
191
+ let resolvedUrl = url;
192
+
193
+ if (!resolvedUrl) {
194
+ events.emit('phase:started', {
195
+ phase: 'Resolve URL',
196
+ message: 'Attempting to resolve URL from environment...',
197
+ });
198
+
199
+ resolvedUrl = tryResolveUrlFromEnv();
200
+
201
+ // Try to extract URL from dev script if available
202
+ if (!resolvedUrl && projectProfile.scripts.dev) {
203
+ const port = extractPortFromScript(projectProfile.scripts.dev);
204
+ if (port) {
205
+ resolvedUrl = `http://localhost:${port}`;
206
+ if (!json) {
207
+ console.log(`Detected dev script: ${projectProfile.scripts.dev}`);
208
+ }
209
+ events.emit('dev:script:detected', {
210
+ script: projectProfile.scripts.dev,
211
+ port,
212
+ });
213
+ }
214
+ }
215
+
216
+ if (resolvedUrl && !json) {
217
+ console.log(`Detected URL: ${resolvedUrl}`);
218
+ }
219
+ }
220
+
221
+ // If still no URL, prompt interactively
222
+ if (!resolvedUrl) {
223
+ events.emit('phase:started', {
224
+ phase: 'Resolve URL',
225
+ message: 'Prompting for URL...',
226
+ });
227
+
228
+ if (!json) {
229
+ console.log(''); // blank line
230
+ }
231
+
232
+ const answer = await inquirer.prompt([
233
+ {
234
+ type: 'input',
235
+ name: 'url',
236
+ message: 'Enter the URL to scan',
237
+ validate: (input) => {
238
+ if (!input.trim()) {
239
+ return 'URL is required';
240
+ }
241
+ if (!input.startsWith('http://') && !input.startsWith('https://')) {
242
+ return 'URL must start with http:// or https://';
243
+ }
244
+ return true;
245
+ },
246
+ },
247
+ ]);
248
+
249
+ resolvedUrl = answer.url;
250
+ }
251
+
252
+ if (!json) {
253
+ console.log(`Using URL: ${resolvedUrl}`);
254
+ }
255
+
256
+ events.emit('phase:completed', {
257
+ phase: 'Resolve URL',
258
+ message: `URL resolved: ${resolvedUrl}`,
259
+ });
260
+
261
+ // Generate run ID
262
+ let runId = generateRunId();
263
+ if (verbose && !json) console.log(`Run ID: ${runId}`);
264
+
265
+ let paths = getRunPaths(projectRoot, out, runId);
266
+ ensureRunDirectories(paths);
267
+
268
+ // Initialize Run
269
+ events.emit('phase:started', {
270
+ phase: 'Initialize Run',
271
+ message: 'Initializing run artifacts...',
272
+ });
273
+
274
+ const now = new Date();
275
+ let startedAt = now.toISOString();
276
+
277
+ atomicWriteJson(paths.runStatusJson, {
278
+ status: 'RUNNING',
279
+ runId,
280
+ startedAt,
281
+ });
282
+
283
+ atomicWriteJson(paths.runMetaJson, {
284
+ veraxVersion: getVersion(),
285
+ nodeVersion: process.version,
286
+ platform: process.platform,
287
+ cwd: projectRoot,
288
+ command: 'default',
289
+ args: { url: resolvedUrl, src },
290
+ url: resolvedUrl,
291
+ src: srcPath,
292
+ startedAt,
293
+ completedAt: null,
294
+ error: null,
295
+ });
296
+
297
+ events.emit('phase:completed', {
298
+ phase: 'Initialize Run',
299
+ message: 'Run initialized',
300
+ });
301
+
302
+ // Learning phase (placeholder)
303
+ events.emit('phase:started', {
304
+ phase: 'Learn',
305
+ message: 'Analyzing project structure...',
306
+ });
307
+
308
+ events.startHeartbeat('Learn', json);
309
+
310
+ let expectations, skipped;
311
+ try {
312
+ // Extract expectations
313
+ const result = await extractExpectations(projectProfile, projectProfile.sourceRoot);
314
+ expectations = result.expectations;
315
+ skipped = result.skipped;
316
+ } finally {
317
+ events.stopHeartbeat();
318
+ }
319
+
320
+ if (!json) {
321
+ console.log(`Found ${expectations.length} expectations`);
322
+ if (Object.values(skipped).reduce((a, b) => a + b, 0) > 0) {
323
+ console.log(`Skipped: ${Object.values(skipped).reduce((a, b) => a + b, 0)} (dynamic/computed)`);
324
+ }
325
+ }
326
+
327
+ // Emit expectations found events
328
+ expectations.slice(0, 5).forEach(exp => {
329
+ events.emit('expectation:found', {
330
+ type: exp.type,
331
+ promise: exp.promise,
332
+ file: exp.source.file,
333
+ });
334
+ });
335
+
336
+ if (expectations.length > 5) {
337
+ events.emit('expectation:found', {
338
+ message: `... and ${expectations.length - 5} more expectations`,
339
+ });
340
+ }
341
+
342
+ // Compute runtime budget based on expectations count
343
+ budget = computeRuntimeBudget({
344
+ expectationsCount: expectations.length,
345
+ mode: 'default',
346
+ framework: projectProfile.framework,
347
+ fileCount: projectProfile.fileCount || expectations.length,
348
+ });
349
+
350
+ // Set up global watchdog timer
351
+ watchdogTimer = setTimeout(async () => {
352
+ await finalizeOnTimeout(`Global timeout exceeded: ${budget.totalMaxMs}ms`);
353
+ // Exit with code 0 (tool executed, just timed out)
354
+ process.exit(0);
355
+ }, budget.totalMaxMs);
356
+
357
+ // Wrap Learn phase with timeout
358
+ try {
359
+ await withTimeout(
360
+ budget.learnMaxMs,
361
+ Promise.resolve(), // Learn phase already completed
362
+ 'Learn'
363
+ );
364
+ } catch (error) {
365
+ if (error.message.includes('timeout')) {
366
+ await finalizeOnTimeout(`Learn phase timeout: ${budget.learnMaxMs}ms`);
367
+ process.exit(0);
368
+ }
369
+ throw error;
370
+ }
371
+
372
+ events.emit('phase:completed', {
373
+ phase: 'Learn',
374
+ message: 'Project analysis complete',
375
+ });
376
+
377
+ // Observe phase with timeout
378
+ events.emit('phase:started', {
379
+ phase: 'Observe',
380
+ message: 'Launching browser and observing expectations...',
381
+ });
382
+
383
+ events.startHeartbeat('Observe', json);
384
+
385
+ let observeData = null;
386
+ try {
387
+ if (expectations.length > 0) {
388
+ try {
389
+ observeData = await withTimeout(
390
+ budget.observeMaxMs,
391
+ observeExpectations(
392
+ expectations,
393
+ resolvedUrl,
394
+ paths.evidenceDir,
395
+ (progress) => {
396
+ events.emit(progress.event, progress);
397
+ if (!json && progress.event === 'observe:result') {
398
+ const status = progress.observed ? '✓' : '✗';
399
+ console.log(` ${status} ${progress.index}/${expectations.length}`);
400
+ }
401
+ }
402
+ ),
403
+ 'Observe'
404
+ );
405
+
406
+ if (!json) {
407
+ console.log(`Observed: ${observeData.stats.observed}/${expectations.length}`);
408
+ }
409
+ } catch (error) {
410
+ if (error.message.includes('timeout')) {
411
+ if (!json) {
412
+ console.error(`Observe error: timeout after ${budget.observeMaxMs}ms`);
413
+ }
414
+ events.emit('observe:error', {
415
+ message: `Observe phase timeout: ${budget.observeMaxMs}ms`,
416
+ });
417
+ observeData = {
418
+ observations: [],
419
+ stats: { attempted: 0, observed: 0, notObserved: 0 },
420
+ observedAt: new Date().toISOString(),
421
+ };
422
+ } else {
423
+ if (!json) {
424
+ console.error(`Observe error: ${error.message}`);
425
+ }
426
+ events.emit('observe:error', {
427
+ message: error.message,
428
+ });
429
+ observeData = {
430
+ observations: [],
431
+ stats: { attempted: 0, observed: 0, notObserved: 0 },
432
+ observedAt: new Date().toISOString(),
433
+ };
434
+ }
435
+ }
436
+ } else {
437
+ observeData = {
438
+ observations: [],
439
+ stats: { attempted: 0, observed: 0, notObserved: 0 },
440
+ observedAt: new Date().toISOString(),
441
+ };
442
+ }
443
+ } finally {
444
+ events.stopHeartbeat();
445
+ }
446
+
447
+ events.emit('phase:completed', {
448
+ phase: 'Observe',
449
+ message: 'Browser observation complete',
450
+ });
451
+
452
+ // Detect phase with timeout
453
+ events.emit('phase:started', {
454
+ phase: 'Detect',
455
+ message: 'Analyzing findings and detecting silent failures...',
456
+ });
457
+
458
+ events.startHeartbeat('Detect', json);
459
+
460
+ // Load learn and observe data for detection
461
+ let learnData = { expectations: [] };
462
+ let detectData = null;
463
+
464
+ try {
465
+ try {
466
+ learnData = {
467
+ expectations,
468
+ skipped,
469
+ };
470
+
471
+ detectData = await withTimeout(
472
+ budget.detectMaxMs,
473
+ detectFindings(learnData, observeData, projectRoot, (progress) => {
474
+ events.emit(progress.event, progress);
475
+ if (!json && progress.event === 'detect:classified') {
476
+ const symbol = progress.classification === 'silent-failure' ? '✗' :
477
+ progress.classification === 'observed' ? '✓' :
478
+ progress.classification === 'coverage-gap' ? '⊘' : '⚠';
479
+ console.log(` ${symbol} ${progress.index}/${learnData.expectations.length}`);
480
+ }
481
+ }),
482
+ 'Detect'
483
+ );
484
+
485
+ if (!json && detectData.stats.silentFailures > 0) {
486
+ console.log(`Silent failures detected: ${detectData.stats.silentFailures}`);
487
+ }
488
+ } catch (error) {
489
+ if (error.message.includes('timeout')) {
490
+ if (!json) {
491
+ console.error(`Detect error: timeout after ${budget.detectMaxMs}ms`);
492
+ }
493
+ events.emit('detect:error', {
494
+ message: `Detect phase timeout: ${budget.detectMaxMs}ms`,
495
+ });
496
+ detectData = {
497
+ findings: [],
498
+ stats: { total: 0, silentFailures: 0, observed: 0, coverageGaps: 0, unproven: 0, informational: 0 },
499
+ detectedAt: new Date().toISOString(),
500
+ };
501
+ } else {
502
+ if (!json) {
503
+ console.error(`Detect error: ${error.message}`);
504
+ }
505
+ events.emit('detect:error', {
506
+ message: error.message,
507
+ });
508
+ detectData = {
509
+ findings: [],
510
+ stats: { total: 0, silentFailures: 0, observed: 0, coverageGaps: 0, unproven: 0, informational: 0 },
511
+ detectedAt: new Date().toISOString(),
512
+ };
513
+ }
514
+ }
515
+ } finally {
516
+ events.stopHeartbeat();
517
+ }
518
+
519
+ events.emit('phase:completed', {
520
+ phase: 'Detect',
521
+ message: 'Silent failure detection complete',
522
+ });
523
+
524
+ // Clear watchdog timer on successful completion
525
+ if (watchdogTimer) {
526
+ clearTimeout(watchdogTimer);
527
+ watchdogTimer = null;
528
+ }
529
+
530
+ // Finalize Artifacts
531
+ events.emit('phase:started', {
532
+ phase: 'Finalize Artifacts',
533
+ message: 'Writing run results...',
534
+ });
535
+
536
+ events.stopHeartbeat();
537
+
538
+ const completedAt = new Date().toISOString();
539
+
540
+ atomicWriteJson(paths.runStatusJson, {
541
+ status: 'COMPLETE',
542
+ runId,
543
+ startedAt,
544
+ completedAt,
545
+ });
546
+
547
+ atomicWriteJson(paths.runMetaJson, {
548
+ veraxVersion: getVersion(),
549
+ nodeVersion: process.version,
550
+ platform: process.platform,
551
+ cwd: projectRoot,
552
+ command: 'default',
553
+ args: { url: resolvedUrl, src },
554
+ url: resolvedUrl,
555
+ src: srcPath,
556
+ startedAt,
557
+ completedAt,
558
+ error: null,
559
+ });
560
+
561
+ // Write summary with stable digest
562
+ writeSummaryJson(paths.summaryJson, {
563
+ runId,
564
+ status: 'COMPLETE',
565
+ startedAt,
566
+ completedAt,
567
+ command: 'default',
568
+ url: resolvedUrl,
569
+ notes: 'Run completed successfully',
570
+ }, {
571
+ expectationsTotal: expectations.length,
572
+ attempted: observeData.stats?.attempted || 0,
573
+ observed: observeData.stats?.observed || 0,
574
+ silentFailures: detectData.stats?.silentFailures || 0,
575
+ coverageGaps: detectData.stats?.coverageGaps || 0,
576
+ unproven: detectData.stats?.unproven || 0,
577
+ informational: detectData.stats?.informational || 0,
578
+ });
579
+
580
+ // Write detect results (or empty if detection failed)
581
+ writeFindingsJson(paths.baseDir, detectData);
582
+
583
+ // Write traces (include all events including heartbeats)
584
+ const allEvents = events.getEvents();
585
+ const tracesContent = allEvents
586
+ .map(e => JSON.stringify(e))
587
+ .join('\n') + '\n';
588
+ atomicWriteText(paths.tracesJsonl, tracesContent);
589
+
590
+ // Write project profile
591
+ writeProjectJson(paths, projectProfile);
592
+
593
+ // Write learn results
594
+ writeLearnJson(paths, expectations, skipped);
595
+
596
+ // Write observe results
597
+ writeObserveJson(paths.baseDir, observeData);
598
+
599
+ events.emit('phase:completed', {
600
+ phase: 'Finalize Artifacts',
601
+ message: 'Run artifacts written',
602
+ });
603
+
604
+ // Print summary if not JSON mode
605
+ if (!json) {
606
+ console.log('\nRun complete.');
607
+ console.log(`Run ID: ${runId}`);
608
+ console.log(`Artifacts: ${paths.baseDir}`);
609
+ }
610
+
611
+ return { runId, paths, url: resolvedUrl, success: true };
612
+ } catch (error) {
613
+ // Clear watchdog timer on error
614
+ if (watchdogTimer) {
615
+ clearTimeout(watchdogTimer);
616
+ watchdogTimer = null;
617
+ }
618
+
619
+ events.stopHeartbeat();
620
+
621
+ // Mark run as FAILED if we have paths
622
+ if (paths && runId && startedAt && typeof paths === 'object') {
623
+ try {
624
+ const failedAt = new Date().toISOString();
625
+ atomicWriteJson(paths.runStatusJson, {
626
+ status: 'FAILED',
627
+ runId,
628
+ startedAt,
629
+ failedAt,
630
+ error: error.message,
631
+ });
632
+
633
+ // Update metadata
634
+ atomicWriteJson(paths.runMetaJson, {
635
+ veraxVersion: getVersion(),
636
+ nodeVersion: process.version,
637
+ platform: process.platform,
638
+ cwd: projectRoot,
639
+ command: 'default',
640
+ args: { url: url || null, src },
641
+ url: url || null,
642
+ src: srcPath,
643
+ startedAt,
644
+ completedAt: failedAt,
645
+ error: error.message,
646
+ });
647
+
648
+ // Write summary with digest even on failure
649
+ try {
650
+ writeSummaryJson(paths.summaryJson, {
651
+ runId,
652
+ status: 'FAILED',
653
+ startedAt,
654
+ completedAt: failedAt,
655
+ command: 'default',
656
+ url: url || null,
657
+ notes: `Run failed: ${error.message}`,
658
+ }, {
659
+ expectationsTotal: 0,
660
+ attempted: 0,
661
+ observed: 0,
662
+ silentFailures: 0,
663
+ coverageGaps: 0,
664
+ unproven: 0,
665
+ informational: 0,
666
+ });
667
+ } catch (summaryError) {
668
+ // Ignore summary write errors during failure handling
669
+ }
670
+ } catch (statusError) {
671
+ // Ignore errors when writing failure status
672
+ }
673
+ }
674
+
675
+ events.emit('error', {
676
+ message: error.message,
677
+ stack: error.stack,
678
+ });
679
+ throw error;
680
+ }
681
+ }