@odavl/guardian 0.1.0-rc1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +3 -3
  3. package/bin/guardian.js +212 -8
  4. package/package.json +6 -1
  5. package/src/guardian/attempt-engine.js +19 -5
  6. package/src/guardian/attempt.js +61 -39
  7. package/src/guardian/attempts-filter.js +63 -0
  8. package/src/guardian/baseline.js +44 -10
  9. package/src/guardian/browser-pool.js +131 -0
  10. package/src/guardian/browser.js +28 -1
  11. package/src/guardian/ci-mode.js +15 -0
  12. package/src/guardian/ci-output.js +37 -0
  13. package/src/guardian/cli-summary.js +117 -4
  14. package/src/guardian/data-guardian-detector.js +189 -0
  15. package/src/guardian/detection-layers.js +271 -0
  16. package/src/guardian/first-run.js +49 -0
  17. package/src/guardian/flag-validator.js +97 -0
  18. package/src/guardian/flow-executor.js +309 -44
  19. package/src/guardian/language-detection.js +99 -0
  20. package/src/guardian/market-reporter.js +16 -1
  21. package/src/guardian/parallel-executor.js +116 -0
  22. package/src/guardian/prerequisite-checker.js +101 -0
  23. package/src/guardian/preset-loader.js +18 -12
  24. package/src/guardian/profile-loader.js +96 -0
  25. package/src/guardian/reality.js +382 -46
  26. package/src/guardian/run-summary.js +20 -0
  27. package/src/guardian/semantic-contact-detection.js +255 -0
  28. package/src/guardian/semantic-contact-finder.js +200 -0
  29. package/src/guardian/semantic-targets.js +234 -0
  30. package/src/guardian/smoke.js +258 -0
  31. package/src/guardian/snapshot.js +23 -1
  32. package/src/guardian/success-evaluator.js +214 -0
  33. package/src/guardian/timeout-profiles.js +57 -0
  34. package/src/guardian/wait-for-outcome.js +120 -0
  35. package/src/guardian/watch-runner.js +185 -0
@@ -3,7 +3,7 @@ const path = require('path');
3
3
  const { executeAttempt } = require('./attempt');
4
4
  const { MarketReporter } = require('./market-reporter');
5
5
  const { getDefaultAttemptIds, getAttemptDefinition, registerAttempt } = require('./attempt-registry');
6
- const { GuardianFlowExecutor } = require('./flow-executor');
6
+ const { GuardianFlowExecutor, validateFlowDefinition } = require('./flow-executor');
7
7
  const { getDefaultFlowIds, getFlowDefinition } = require('./flow-registry');
8
8
  const { GuardianBrowser } = require('./browser');
9
9
  const { GuardianCrawler } = require('./crawler');
@@ -18,6 +18,19 @@ const { aggregateIntelligence } = require('./breakage-intelligence');
18
18
  const { writeEnhancedHtml } = require('./enhanced-html-reporter');
19
19
  const { printCliSummary } = require('./cli-summary');
20
20
  const { sendWebhooks, getWebhookUrl, buildWebhookPayload } = require('./webhook');
21
+ const { findContactOnPage, formatDetectionForReport } = require('./semantic-contact-finder');
22
+ const { formatRunSummary } = require('./run-summary');
23
+ const { isCiMode } = require('./ci-mode');
24
+ const { formatCiSummary, deriveBaselineVerdict } = require('./ci-output');
25
+ // Phase 7.1: Performance modes
26
+ const { getTimeoutProfile } = require('./timeout-profiles');
27
+ const { validateAttemptFilter, filterAttempts, filterFlows } = require('./attempts-filter');
28
+ // Phase 7.2: Parallel execution
29
+ const { executeParallel, validateParallel } = require('./parallel-executor');
30
+ // Phase 7.3: Browser reuse
31
+ const { BrowserPool } = require('./browser-pool');
32
+ // Phase 7.4: Smart skips
33
+ const { checkPrerequisites } = require('./prerequisite-checker');
21
34
 
22
35
  function generateRunId(prefix = 'market-run') {
23
36
  const now = new Date();
@@ -25,7 +38,23 @@ function generateRunId(prefix = 'market-run') {
25
38
  return `${prefix}-${dateStr}`;
26
39
  }
27
40
 
41
+ function applySafeDefaults(config, warn) {
42
+ const updated = { ...config };
43
+ if (!Array.isArray(updated.attempts) || updated.attempts.length === 0) {
44
+ if (warn) warn('No attempts provided; using curated defaults.');
45
+ updated.attempts = getDefaultAttemptIds();
46
+ }
47
+ if (!Array.isArray(updated.flows) || updated.flows.length === 0) {
48
+ if (warn) warn('No flows provided; using curated defaults.');
49
+ updated.flows = getDefaultFlowIds();
50
+ }
51
+ return updated;
52
+ }
53
+
28
54
  async function executeReality(config) {
55
+ const baseWarn = (...args) => console.warn(...args);
56
+ const safeConfig = applySafeDefaults(config, baseWarn);
57
+
29
58
  const {
30
59
  baseUrl,
31
60
  attempts = getDefaultAttemptIds(),
@@ -47,8 +76,52 @@ async function executeReality(config) {
47
76
  autoAttemptOptions = {},
48
77
  enableFlows = true,
49
78
  flows = getDefaultFlowIds(),
50
- flowOptions = {}
51
- } = config;
79
+ flowOptions = {},
80
+ // Phase 7.1: Performance modes
81
+ timeoutProfile = 'default',
82
+ failFast = false,
83
+ fast = false,
84
+ attemptsFilter = null,
85
+ // Phase 7.2: Parallel execution
86
+ parallel = 1
87
+ } = safeConfig;
88
+
89
+ // Phase 7.1: Validate and apply attempts filter
90
+ let validation = null;
91
+ if (attemptsFilter) {
92
+ validation = validateAttemptFilter(attemptsFilter);
93
+ if (!validation.valid) {
94
+ console.error(`Error: ${validation.error}`);
95
+ if (validation.hint) console.error(`Hint: ${validation.hint}`);
96
+ process.exit(2);
97
+ }
98
+ }
99
+
100
+ // Phase 7.2: Validate parallel value
101
+ const parallelValidation = validateParallel(parallel);
102
+ if (!parallelValidation.valid) {
103
+ console.error(`Error: ${parallelValidation.error}`);
104
+ if (parallelValidation.hint) console.error(`Hint: ${parallelValidation.hint}`);
105
+ process.exit(2);
106
+ }
107
+ const validatedParallel = parallelValidation.parallel || 1;
108
+
109
+ // Phase 7.1: Filter attempts and flows
110
+ let filteredAttempts = attempts;
111
+ let filteredFlows = flows;
112
+ if (attemptsFilter && validation && validation.valid && validation.ids.length > 0) {
113
+ filteredAttempts = filterAttempts(attempts, validation.ids);
114
+ filteredFlows = filterFlows(flows, validation.ids);
115
+ if (filteredAttempts.length === 0 && filteredFlows.length === 0) {
116
+ console.error('Error: No matching attempts or flows found after filtering');
117
+ console.error(`Hint: Check your --attempts filter: ${attemptsFilter}`);
118
+ process.exit(2);
119
+ }
120
+ }
121
+
122
+ // Phase 7.1: Resolve timeout profile
123
+ const timeoutProfileConfig = getTimeoutProfile(timeoutProfile);
124
+ const resolvedTimeout = timeout || timeoutProfileConfig.default;
52
125
 
53
126
  // Validate baseUrl
54
127
  try {
@@ -60,12 +133,32 @@ async function executeReality(config) {
60
133
  const runId = generateRunId();
61
134
  const runDir = path.join(artifactsDir, runId);
62
135
  fs.mkdirSync(runDir, { recursive: true });
136
+ const ciMode = isCiMode();
137
+
138
+ // Phase 7.1: Print mode info
139
+ if (!ciMode) {
140
+ const modeLines = [];
141
+ if (fast) modeLines.push('MODE: fast');
142
+ if (failFast) modeLines.push('FAIL-FAST: enabled');
143
+ if (timeoutProfile !== 'default') modeLines.push(`TIMEOUT: ${timeoutProfile}`);
144
+ if (attemptsFilter) modeLines.push(`ATTEMPTS: ${attemptsFilter}`);
145
+ if (modeLines.length > 0) {
146
+ console.log(`\n⚡ ${modeLines.join(' | ')}`);
147
+ }
148
+ }
63
149
 
64
- console.log(`\n🧪 Market Reality Snapshot v1`);
65
- console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
66
- console.log(`📍 Base URL: ${baseUrl}`);
67
- console.log(`🎯 Attempts: ${attempts.join(', ')}`);
68
- console.log(`📁 Run Dir: ${runDir}`);
150
+ if (ciMode) {
151
+ console.log(`\nCI RUN: Market Reality Snapshot`);
152
+ console.log(`Base URL: ${baseUrl}`);
153
+ console.log(`Attempts: ${filteredAttempts.join(', ')}`);
154
+ console.log(`Run Dir: ${runDir}`);
155
+ } else {
156
+ console.log(`\n🧪 Market Reality Snapshot v1`);
157
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
158
+ console.log(`📍 Base URL: ${baseUrl}`);
159
+ console.log(`🎯 Attempts: ${filteredAttempts.join(', ')}`);
160
+ console.log(`📁 Run Dir: ${runDir}`);
161
+ }
69
162
 
70
163
  // Initialize snapshot builder
71
164
  const snapshotBuilder = new SnapshotBuilder(baseUrl, runId, toolVersion);
@@ -73,13 +166,27 @@ async function executeReality(config) {
73
166
 
74
167
  let crawlResult = null;
75
168
  let discoveryResult = null;
169
+ let pageLanguage = 'unknown';
170
+ let contactDetectionResult = null;
76
171
 
77
172
  // Optional: Crawl to discover URLs (lightweight, first N pages)
78
173
  if (enableCrawl) {
79
174
  console.log(`\n🔍 Crawling for discovered URLs...`);
80
175
  const browser = new GuardianBrowser();
81
176
  try {
82
- await browser.launch(timeout);
177
+ await browser.launch(resolvedTimeout);
178
+ await browser.page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: resolvedTimeout });
179
+
180
+ // Wave 1.1: Detect page language and contact
181
+ try {
182
+ contactDetectionResult = await findContactOnPage(browser.page, baseUrl);
183
+ pageLanguage = contactDetectionResult.language;
184
+ console.log(`\n${formatDetectionForReport(contactDetectionResult)}\n`);
185
+ } catch (detectionErr) {
186
+ // Language detection non-critical
187
+ console.warn(`⚠️ Language/contact detection failed: ${detectionErr.message}`);
188
+ }
189
+
83
190
  const crawler = new GuardianCrawler(baseUrl, maxPages, maxDepth);
84
191
  crawlResult = await crawler.crawl(browser);
85
192
  console.log(`✅ Crawl complete: discovered ${crawlResult.totalDiscovered}, visited ${crawlResult.totalVisited}`);
@@ -96,11 +203,11 @@ async function executeReality(config) {
96
203
  console.log(`\n🔎 Running discovery engine...`);
97
204
  const browser = new GuardianBrowser();
98
205
  try {
99
- await browser.launch(timeout);
206
+ await browser.launch(resolvedTimeout);
100
207
  const engine = new DiscoveryEngine({
101
208
  baseUrl,
102
209
  maxPages,
103
- timeout,
210
+ timeout: resolvedTimeout,
104
211
  executeInteractions: false,
105
212
  browser,
106
213
  });
@@ -167,74 +274,237 @@ async function executeReality(config) {
167
274
  }
168
275
  }
169
276
 
170
- // Execute all registered attempts
171
- console.log(`\n🎬 Executing attempts...`);
172
- for (const attemptId of attemptsToRun) {
173
- const attemptDef = getAttemptDefinition(attemptId);
174
- if (!attemptDef) {
175
- throw new Error(`Attempt ${attemptId} not found in registry`);
277
+ // Phase 7.1: Apply attempts filter
278
+ if (attemptsFilter && validation && validation.valid && validation.ids.length > 0) {
279
+ attemptsToRun = filterAttempts(attemptsToRun, validation.ids);
280
+ }
281
+
282
+ // Phase 7.2: Print parallel mode if enabled
283
+ if (!ciMode && validatedParallel > 1) {
284
+ console.log(`\n⚡ PARALLEL: ${validatedParallel} concurrent attempts`);
285
+ }
286
+
287
+ // Phase 7.3: Initialize browser pool (single browser per run)
288
+ const browserPool = new BrowserPool();
289
+ const browserOptions = {
290
+ headless: !headful,
291
+ args: !headful ? [] : [],
292
+ timeout: resolvedTimeout
293
+ };
294
+
295
+ try {
296
+ await browserPool.launch(browserOptions);
297
+ if (!ciMode) {
298
+ console.log(`🌐 Browser pool ready (reuse enabled)`);
176
299
  }
300
+ } catch (err) {
301
+ throw new Error(`Failed to launch browser pool: ${err.message}`);
302
+ }
177
303
 
178
- console.log(` ${attemptDef.name}...`);
179
- const attemptArtifactsDir = path.join(runDir, attemptId);
180
- const result = await executeAttempt({
181
- baseUrl,
182
- attemptId,
183
- artifactsDir: attemptArtifactsDir,
184
- headful,
185
- enableTrace,
186
- enableScreenshots
187
- });
304
+ // Execute all registered attempts (with optional parallelism)
305
+ console.log(`\n🎬 Executing attempts...`);
306
+
307
+ // Shared state for fail-fast coordination
308
+ let shouldStopScheduling = false;
309
+
310
+ // Phase 7.2: Execute attempts with bounded parallelism
311
+ // Phase 7.3: Pass browser pool to attempts
312
+ const attemptResults_parallel = await executeParallel(
313
+ attemptsToRun,
314
+ async (attemptId) => {
315
+ const attemptDef = getAttemptDefinition(attemptId);
316
+ if (!attemptDef) {
317
+ throw new Error(`Attempt ${attemptId} not found in registry`);
318
+ }
188
319
 
189
- attemptResults.push({
190
- attemptId,
191
- attemptName: attemptDef.name,
192
- goal: attemptDef.goal,
193
- riskCategory: attemptDef.riskCategory || 'UNKNOWN',
194
- source: attemptDef.source || 'manual',
195
- ...result
196
- });
320
+ if (!ciMode) {
321
+ console.log(` • ${attemptDef.name}...`);
322
+ }
323
+
324
+ const attemptArtifactsDir = path.join(runDir, attemptId);
325
+
326
+ // Phase 7.3: Create isolated context for this attempt
327
+ const { context, page } = await browserPool.createContext({
328
+ timeout: resolvedTimeout
329
+ });
330
+
331
+ let result;
332
+ try {
333
+ // Phase 7.4: Check prerequisites before executing attempt
334
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: resolvedTimeout });
335
+ const prereqCheck = await checkPrerequisites(page, attemptId, 2000);
336
+
337
+ if (!prereqCheck.canProceed) {
338
+ // Skip attempt - prerequisites not met
339
+ if (!ciMode) {
340
+ console.log(` ⊘ Skipped: ${prereqCheck.reason}`);
341
+ }
342
+
343
+ result = {
344
+ outcome: 'SKIPPED',
345
+ skipReason: prereqCheck.reason,
346
+ exitCode: 0, // SKIPPED does not affect exit code
347
+ steps: [],
348
+ friction: null,
349
+ error: null
350
+ };
351
+ } else {
352
+ // Prerequisites met - execute normally
353
+ result = await executeAttempt({
354
+ baseUrl,
355
+ attemptId,
356
+ artifactsDir: attemptArtifactsDir,
357
+ headful,
358
+ enableTrace,
359
+ enableScreenshots,
360
+ quiet: ciMode,
361
+ timeout: resolvedTimeout,
362
+ // Phase 7.3: Pass context from pool
363
+ browserContext: context,
364
+ browserPage: page
365
+ });
366
+ }
367
+ } finally {
368
+ // Phase 7.3: Cleanup context after attempt
369
+ await browserPool.closeContext(context);
370
+ }
371
+
372
+ const attemptResult = {
373
+ attemptId,
374
+ attemptName: attemptDef.name,
375
+ goal: attemptDef.goal,
376
+ riskCategory: attemptDef.riskCategory || 'UNKNOWN',
377
+ source: attemptDef.source || 'manual',
378
+ ...result
379
+ };
380
+
381
+ // Phase 7.1: Fail-fast logic (stop on FAILURE, not FRICTION)
382
+ // Phase 7.4: SKIPPED does NOT trigger fail-fast
383
+ if (failFast && attemptResult.outcome === 'FAILURE') {
384
+ shouldStopScheduling = true;
385
+ if (!ciMode) {
386
+ console.log(`\n⚡ FAIL-FAST: stopping after failure: ${attemptDef.name}`);
387
+ }
388
+ }
197
389
 
390
+ return attemptResult;
391
+ },
392
+ validatedParallel,
393
+ { shouldStop: () => shouldStopScheduling }
394
+ );
395
+
396
+ // Collect results in order
397
+ for (const result of attemptResults_parallel) {
398
+ if (result && !result.skipped) {
399
+ attemptResults.push(result);
400
+ }
198
401
  }
199
402
 
200
403
  // Phase 3: Execute intent flows (deterministic, curated)
201
404
  if (enableFlows) {
202
405
  console.log(`\n🎯 Executing intent flows...`);
203
406
  const flowExecutor = new GuardianFlowExecutor({
204
- timeout,
407
+ timeout: resolvedTimeout,
205
408
  screenshotOnStep: enableScreenshots,
409
+ baseUrl,
410
+ quiet: ciMode,
206
411
  ...flowOptions
207
412
  });
208
413
  const browser = new GuardianBrowser();
209
414
 
210
415
  try {
211
- await browser.launch(timeout);
212
- for (const flowId of (Array.isArray(flows) && flows.length ? flows : getDefaultFlowIds())) {
416
+ await browser.launch(resolvedTimeout);
417
+ // Phase 7.1: Apply flows filter
418
+ let flowsToRun = Array.isArray(filteredFlows) && filteredFlows.length ? filteredFlows : getDefaultFlowIds();
419
+
420
+ for (const flowId of flowsToRun) {
213
421
  const flowDef = getFlowDefinition(flowId);
214
422
  if (!flowDef) {
215
423
  console.warn(`⚠️ Flow ${flowId} not found, skipping`);
216
424
  continue;
217
425
  }
218
426
 
427
+ const validation = validateFlowDefinition(flowDef);
428
+ if (!validation.ok) {
429
+ const reason = validation.reason || 'Flow misconfigured';
430
+ const flowResult = {
431
+ flowId,
432
+ flowName: flowDef.name,
433
+ riskCategory: flowDef.riskCategory || 'TRUST/UX',
434
+ description: flowDef.description,
435
+ outcome: 'FAILURE',
436
+ stepsExecuted: 0,
437
+ stepsTotal: Array.isArray(flowDef.steps) ? flowDef.steps.length : 0,
438
+ failedStep: 0,
439
+ error: reason,
440
+ screenshots: [],
441
+ failureReasons: [reason],
442
+ source: 'flow'
443
+ };
444
+ flowResults.push(flowResult);
445
+
446
+ // Phase 7.1: Fail-fast on flow failure
447
+ if (failFast && flowResult.outcome === 'FAILURE') {
448
+ console.log(`\n⚡ FAIL-FAST: stopping after first failure: ${flowDef.name}`);
449
+ break;
450
+ }
451
+ continue;
452
+ }
453
+
219
454
  console.log(` • ${flowDef.name}...`);
220
455
  const flowArtifactsDir = path.join(runDir, 'flows', flowId);
221
456
  fs.mkdirSync(flowArtifactsDir, { recursive: true });
222
457
 
223
- const flowResult = await flowExecutor.executeFlow(browser.page, flowDef, flowArtifactsDir, baseUrl);
224
- flowResults.push({
458
+ let flowResult;
459
+ try {
460
+ flowResult = await flowExecutor.executeFlow(browser.page, flowDef, flowArtifactsDir, baseUrl);
461
+ } catch (flowErr) {
462
+ console.warn(`⚠️ Flow ${flowDef.name} crashed: ${flowErr.message}`);
463
+ flowResult = {
464
+ flowId,
465
+ flowName: flowDef.name,
466
+ riskCategory: flowDef.riskCategory || 'TRUST/UX',
467
+ description: flowDef.description,
468
+ outcome: 'FAILURE',
469
+ stepsExecuted: 0,
470
+ stepsTotal: flowDef.steps.length,
471
+ durationMs: 0,
472
+ failedStep: null,
473
+ error: flowErr.message,
474
+ screenshots: [],
475
+ failureReasons: [`flow crashed: ${flowErr.message}`]
476
+ };
477
+ }
478
+
479
+ const resultWithMetadata = {
225
480
  flowId,
226
481
  flowName: flowDef.name,
227
482
  riskCategory: flowDef.riskCategory || 'TRUST/UX',
228
483
  description: flowDef.description,
229
- outcome: flowResult.success ? 'SUCCESS' : 'FAILURE',
484
+ outcome: flowResult.outcome || (flowResult.success ? 'SUCCESS' : 'FAILURE'),
230
485
  stepsExecuted: flowResult.stepsExecuted,
231
486
  stepsTotal: flowResult.stepsTotal,
232
487
  durationMs: flowResult.durationMs,
233
488
  failedStep: flowResult.failedStep,
234
489
  error: flowResult.error,
235
490
  screenshots: flowResult.screenshots,
236
- source: 'flow'
237
- });
491
+ failureReasons: flowResult.failureReasons || [],
492
+ source: 'flow',
493
+ successEval: flowResult.successEval ? {
494
+ status: flowResult.successEval.status,
495
+ confidence: flowResult.successEval.confidence,
496
+ reasons: (flowResult.successEval.reasons || []).slice(0, 3),
497
+ evidence: flowResult.successEval.evidence || {}
498
+ } : null
499
+ };
500
+
501
+ flowResults.push(resultWithMetadata);
502
+
503
+ // Phase 7.1: Fail-fast logic for flows (stop on FAILURE, not FRICTION)
504
+ if (failFast && resultWithMetadata.outcome === 'FAILURE') {
505
+ console.log(`\n⚡ FAIL-FAST: stopping after first failure: ${flowDef.name}`);
506
+ break;
507
+ }
238
508
  }
239
509
  } catch (flowErr) {
240
510
  console.warn(`⚠️ Flow execution failed (non-critical): ${flowErr.message}`);
@@ -243,6 +513,19 @@ async function executeReality(config) {
243
513
  }
244
514
  }
245
515
 
516
+ // Flow summary logging
517
+ if (flowResults.length > 0 && !ciMode) {
518
+ const successCount = flowResults.filter(f => (f.outcome || f.success === true ? f.outcome === 'SUCCESS' || f.success === true : false)).length;
519
+ const frictionCount = flowResults.filter(f => f.outcome === 'FRICTION').length;
520
+ const failureCount = flowResults.filter(f => f.outcome === 'FAILURE' || f.success === false).length;
521
+ console.log(`\nRun completed: ${flowResults.length} flows (${successCount} successes, ${frictionCount} frictions, ${failureCount} failures)`);
522
+ const troubled = flowResults.filter(f => f.outcome === 'FRICTION' || f.outcome === 'FAILURE');
523
+ troubled.forEach(f => {
524
+ const reason = (f.failureReasons && f.failureReasons[0]) || (f.error) || (f.successEval && f.successEval.reasons && f.successEval.reasons[0]) || 'no reason captured';
525
+ console.log(` - ${f.flowName}: ${reason}`);
526
+ });
527
+ }
528
+
246
529
  // Generate market report (existing flow)
247
530
  const reporter = new MarketReporter();
248
531
  const report = reporter.createReport({
@@ -449,6 +732,16 @@ async function executeReality(config) {
449
732
  console.log(`🛡️ Policy override: exit code ${exitCode}`);
450
733
  }
451
734
 
735
+ // Flow-based exit code aggregation (0/1/2)
736
+ const flowExitCode = computeFlowExitCode(flowResults);
737
+ exitCode = flowExitCode;
738
+ if (!ciMode && flowResults.length > 0) {
739
+ console.log(`Exit code (flows): ${flowExitCode}`);
740
+ }
741
+
742
+ // Phase 7.3: Cleanup browser pool
743
+ await browserPool.close();
744
+
452
745
  return {
453
746
  exitCode,
454
747
  report,
@@ -467,15 +760,49 @@ async function executeReality(config) {
467
760
 
468
761
  async function runRealityCLI(config) {
469
762
  try {
763
+ if (config.watch) {
764
+ const { startWatchMode } = require('./watch-runner');
765
+ const watchResult = await startWatchMode(config);
766
+ if (watchResult && watchResult.watchStarted === false && typeof watchResult.exitCode === 'number') {
767
+ process.exit(watchResult.exitCode);
768
+ }
769
+ // When watch is active, do not exit; watcher owns lifecycle
770
+ return;
771
+ }
772
+
470
773
  const result = await executeReality(config);
471
774
 
472
775
  // Phase 6: Print enhanced CLI summary
473
- printCliSummary(result.snapshot, result.policyEval);
776
+ const ciMode = isCiMode();
777
+ if (ciMode) {
778
+ const ciSummary = formatCiSummary({
779
+ flowResults: result.flowResults || [],
780
+ diffResult: result.diffResult || null,
781
+ baselineCreated: result.baselineCreated || false,
782
+ exitCode: result.exitCode,
783
+ maxReasons: 5
784
+ });
785
+ console.log(ciSummary);
786
+ } else {
787
+ printCliSummary(result.snapshot, result.policyEval);
788
+ console.log(formatRunSummary({
789
+ flowResults: result.flowResults || [],
790
+ diffResult: result.diffResult || null,
791
+ baselineCreated: result.baselineCreated || false,
792
+ exitCode: result.exitCode
793
+ }, { label: 'Summary' }));
794
+ }
474
795
 
475
796
  process.exit(result.exitCode);
476
797
  } catch (err) {
477
798
  console.error(`\n❌ Error: ${err.message}`);
478
- if (err.stack) console.error(err.stack);
799
+ if (process.env.GUARDIAN_DEBUG) {
800
+ if (err.stack) console.error(err.stack);
801
+ } else if (err.stack) {
802
+ const stackLine = (err.stack.split('\n')[1] || '').trim();
803
+ if (stackLine) console.error(` at ${stackLine}`);
804
+ console.error(' (Set GUARDIAN_DEBUG=1 for full stack)');
805
+ }
479
806
  process.exit(1);
480
807
  }
481
808
  }
@@ -544,4 +871,13 @@ function computeMarketRiskSummary(attemptResults) {
544
871
  return summary;
545
872
  }
546
873
 
547
- module.exports = { executeReality, runRealityCLI };
874
+ function computeFlowExitCode(flowResults) {
875
+ if (!Array.isArray(flowResults) || flowResults.length === 0) return 0;
876
+ const hasFailure = flowResults.some(f => f.outcome === 'FAILURE' || f.success === false);
877
+ if (hasFailure) return 2;
878
+ const hasFriction = flowResults.some(f => f.outcome === 'FRICTION');
879
+ if (hasFriction) return 1;
880
+ return 0;
881
+ }
882
+
883
+ module.exports = { executeReality, runRealityCLI, computeFlowExitCode, applySafeDefaults };
@@ -0,0 +1,20 @@
1
+ function deriveBaselineVerdict({ baselineCreated, diffResult }) {
2
+ if (baselineCreated) return 'BASELINE_CREATED';
3
+ if (!diffResult) return 'NO_BASELINE';
4
+ const hasRegressions = diffResult.regressions && Object.keys(diffResult.regressions).length > 0;
5
+ const hasImprovements = diffResult.improvements && Object.keys(diffResult.improvements).length > 0;
6
+ if (hasRegressions) return 'REGRESSION_DETECTED';
7
+ if (hasImprovements) return 'IMPROVEMENT_DETECTED';
8
+ return 'NO_REGRESSION';
9
+ }
10
+
11
+ function formatRunSummary({ flowResults = [], diffResult = null, baselineCreated = false, exitCode = 0 }, options = {}) {
12
+ const success = flowResults.filter(f => f.outcome === 'SUCCESS').length;
13
+ const friction = flowResults.filter(f => f.outcome === 'FRICTION').length;
14
+ const failure = flowResults.filter(f => f.outcome === 'FAILURE').length;
15
+ const baseline = deriveBaselineVerdict({ baselineCreated, diffResult });
16
+ const label = options.label || 'Summary';
17
+ return `${label}: flows=${flowResults.length} success=${success} friction=${friction} failure=${failure} | baseline=${baseline} | exit=${exitCode}`;
18
+ }
19
+
20
+ module.exports = { formatRunSummary, deriveBaselineVerdict };