@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
@@ -25,9 +25,17 @@ async function executeAttempt(config) {
25
25
  artifactsDir = './artifacts',
26
26
  enableTrace = true,
27
27
  enableScreenshots = true,
28
- headful = false
28
+ headful = false,
29
+ quiet = false,
30
+ // Phase 7.3: Accept browser context from pool
31
+ browserContext = null,
32
+ browserPage = null
29
33
  } = config;
30
34
 
35
+ const log = (...args) => {
36
+ if (!quiet) console.log(...args);
37
+ };
38
+
31
39
  // Validate baseUrl
32
40
  try {
33
41
  new URL(baseUrl);
@@ -38,6 +46,7 @@ async function executeAttempt(config) {
38
46
  const browser = new GuardianBrowser();
39
47
  let attemptResult = null;
40
48
  let runDir = null;
49
+ const usingPoolContext = browserContext && browserPage;
41
50
 
42
51
  try {
43
52
  // Prepare artifacts directory
@@ -53,16 +62,24 @@ async function executeAttempt(config) {
53
62
  fs.mkdirSync(runDir, { recursive: true });
54
63
  }
55
64
 
56
- console.log(`\nšŸ“ Artifacts: ${runDir}`);
65
+ log(`\nšŸ“ Artifacts: ${runDir}`);
57
66
 
58
- // Launch browser
59
- console.log(`\nšŸš€ Launching browser...`);
60
- const browserOptions = {
61
- headless: !headful,
62
- args: !headful ? ['--no-sandbox', '--disable-setuid-sandbox'] : []
63
- };
64
- await browser.launch(30000, browserOptions);
65
- console.log(`āœ… Browser launched`);
67
+ // Phase 7.3: Use pool context or launch own browser
68
+ if (usingPoolContext) {
69
+ browser.useContext(browserContext, browserPage, config.timeout || 30000);
70
+ if (!quiet) {
71
+ // Silent - don't log for each attempt in pool mode
72
+ }
73
+ } else {
74
+ // Legacy mode: launch own browser
75
+ log(`\nšŸš€ Launching browser...`);
76
+ const browserOptions = {
77
+ headless: !headful,
78
+ args: !headful ? ['--no-sandbox', '--disable-setuid-sandbox'] : []
79
+ };
80
+ await browser.launch(30000, browserOptions);
81
+ log(`āœ… Browser launched`);
82
+ }
66
83
 
67
84
  // Start trace if enabled
68
85
  let tracePath = null;
@@ -70,12 +87,12 @@ async function executeAttempt(config) {
70
87
  const networkTrace = new GuardianNetworkTrace({ enableTrace: true });
71
88
  tracePath = await networkTrace.startTrace(browser.context, runDir);
72
89
  if (tracePath) {
73
- console.log(`šŸ“¹ Trace recording started`);
90
+ log(`šŸ“¹ Trace recording started`);
74
91
  }
75
92
  }
76
93
 
77
94
  // Execute attempt
78
- console.log(`\nšŸŽ¬ Executing attempt...`);
95
+ log(`\nšŸŽ¬ Executing attempt...`);
79
96
  const engine = new AttemptEngine({
80
97
  attemptId,
81
98
  timeout: config.timeout || 30000,
@@ -92,79 +109,81 @@ async function executeAttempt(config) {
92
109
 
93
110
  attemptResult = await engine.executeAttempt(browser.page, attemptId, baseUrl, runDir, validators);
94
111
 
95
- console.log(`\nāœ… Attempt completed: ${attemptResult.outcome}`);
112
+ log(`\nāœ… Attempt completed: ${attemptResult.outcome}`);
96
113
 
97
114
  // Stop trace if enabled
98
115
  if (enableTrace && browser.context && tracePath) {
99
116
  const networkTrace = new GuardianNetworkTrace({ enableTrace: true });
100
117
  await networkTrace.stopTrace(browser.context, tracePath);
101
- console.log(`āœ… Trace saved: trace.zip`);
118
+ log(`āœ… Trace saved: trace.zip`);
102
119
  }
103
120
 
104
121
  // Generate reports
105
- console.log(`\nšŸ“Š Generating reports...`);
122
+ log(`\nšŸ“Š Generating reports...`);
106
123
  const reporter = new AttemptReporter();
107
124
  const report = reporter.createReport(attemptResult, baseUrl, attemptId);
108
125
 
109
126
  // Save JSON report
110
127
  const jsonPath = reporter.saveJsonReport(report, runDir);
111
- console.log(`āœ… JSON report: ${path.basename(jsonPath)}`);
128
+ log(`āœ… JSON report: ${path.basename(jsonPath)}`);
112
129
 
113
130
  // Save HTML report
114
131
  const htmlContent = reporter.generateHtmlReport(report);
115
132
  const htmlPath = reporter.saveHtmlReport(htmlContent, runDir);
116
- console.log(`āœ… HTML report: ${path.basename(htmlPath)}`);
133
+ log(`āœ… HTML report: ${path.basename(htmlPath)}`);
117
134
 
118
135
  // Display summary
119
- console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
136
+ log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
120
137
 
121
138
  const outcomeEmoji = attemptResult.outcome === 'SUCCESS' ? '🟢' :
122
139
  attemptResult.outcome === 'FRICTION' ? '🟔' : 'šŸ”“';
123
140
 
124
- console.log(`\n${outcomeEmoji} ${attemptResult.outcome}`);
141
+ log(`\n${outcomeEmoji} ${attemptResult.outcome}`);
125
142
 
126
143
  if (attemptResult.outcome === 'SUCCESS') {
127
- console.log(`\nāœ… User successfully completed the attempt!`);
128
- console.log(` ${attemptResult.successReason}`);
144
+ log(`\nāœ… User successfully completed the attempt!`);
145
+ log(` ${attemptResult.successReason}`);
129
146
  } else if (attemptResult.outcome === 'FRICTION') {
130
- console.log(`\nāš ļø Attempt succeeded but with friction:`);
147
+ log(`\nāš ļø Attempt succeeded but with friction:`);
131
148
  attemptResult.friction.reasons.forEach(reason => {
132
- console.log(` • ${reason}`);
149
+ log(` • ${reason}`);
133
150
  });
134
151
  } else {
135
- console.log(`\nāŒ Attempt failed:`);
136
- console.log(` ${attemptResult.error}`);
152
+ log(`\nāŒ Attempt failed:`);
153
+ log(` ${attemptResult.error}`);
137
154
  }
138
155
 
139
- console.log(`\nā±ļø Duration: ${attemptResult.totalDurationMs}ms`);
140
- console.log(`šŸ“‹ Steps: ${attemptResult.steps.length}`);
156
+ log(`\nā±ļø Duration: ${attemptResult.totalDurationMs}ms`);
157
+ log(`šŸ“‹ Steps: ${attemptResult.steps.length}`);
141
158
 
142
159
  if (attemptResult.steps.length > 0) {
143
160
  const failedSteps = attemptResult.steps.filter(s => s.status === 'failed');
144
161
  if (failedSteps.length > 0) {
145
- console.log(`āŒ Failed steps: ${failedSteps.length}`);
162
+ log(`āŒ Failed steps: ${failedSteps.length}`);
146
163
  failedSteps.forEach(step => {
147
- console.log(` • ${step.id}: ${step.error}`);
164
+ log(` • ${step.id}: ${step.error}`);
148
165
  });
149
166
  }
150
167
 
151
168
  const retriedSteps = attemptResult.steps.filter(s => s.retries > 0);
152
169
  if (retriedSteps.length > 0) {
153
- console.log(`šŸ”„ Steps with retries: ${retriedSteps.length}`);
170
+ log(`šŸ”„ Steps with retries: ${retriedSteps.length}`);
154
171
  retriedSteps.forEach(step => {
155
- console.log(` • ${step.id}: ${step.retries} retries`);
172
+ log(` • ${step.id}: ${step.retries} retries`);
156
173
  });
157
174
  }
158
175
  }
159
176
 
160
- console.log(`\nšŸ’¾ Full report: ${runDir}`);
161
- console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
177
+ log(`\nšŸ’¾ Full report: ${runDir}`);
178
+ log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
162
179
 
163
- // Close browser before returning
164
- try {
165
- await browser.close();
166
- } catch (closeErr) {
167
- // Ignore browser close errors
180
+ // Phase 7.3: Close browser only if we own it (not using pool)
181
+ if (!usingPoolContext) {
182
+ try {
183
+ await browser.close();
184
+ } catch (closeErr) {
185
+ // Ignore browser close errors
186
+ }
168
187
  }
169
188
 
170
189
  // Determine exit code
@@ -193,7 +212,10 @@ async function executeAttempt(config) {
193
212
  };
194
213
 
195
214
  } catch (err) {
196
- await browser.close().catch(() => {});
215
+ // Phase 7.3: Only close if we own the browser
216
+ if (!usingPoolContext) {
217
+ await browser.close().catch(() => {});
218
+ }
197
219
  throw err;
198
220
  }
199
221
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Guardian Attempts Filter
3
+ * Validates and filters attempts/flows based on user input
4
+ */
5
+
6
+ const { getDefaultAttemptIds, getAttemptDefinition } = require('./attempt-registry');
7
+ const { getDefaultFlowIds, getFlowDefinition } = require('./flow-registry');
8
+
9
+ function validateAttemptFilter(filterString) {
10
+ if (!filterString || !filterString.trim()) {
11
+ return { valid: true, ids: [] };
12
+ }
13
+
14
+ const ids = filterString.split(',').map(s => s.trim()).filter(Boolean);
15
+
16
+ if (ids.length === 0) {
17
+ return { valid: true, ids: [] };
18
+ }
19
+
20
+ // Validate each ID exists
21
+ const invalid = [];
22
+ for (const id of ids) {
23
+ const attemptDef = getAttemptDefinition(id);
24
+ const flowDef = getFlowDefinition(id);
25
+ if (!attemptDef && !flowDef) {
26
+ invalid.push(id);
27
+ }
28
+ }
29
+
30
+ if (invalid.length > 0) {
31
+ return {
32
+ valid: false,
33
+ error: `Unknown attempt/flow: ${invalid[0]}`,
34
+ hint: `Run 'guardian presets' to see available attempts/flows`
35
+ };
36
+ }
37
+
38
+ return { valid: true, ids };
39
+ }
40
+
41
+ function filterAttempts(attempts, filterIds) {
42
+ if (!filterIds || filterIds.length === 0) {
43
+ return attempts;
44
+ }
45
+
46
+ const filterSet = new Set(filterIds);
47
+ return attempts.filter(id => filterSet.has(id));
48
+ }
49
+
50
+ function filterFlows(flows, filterIds) {
51
+ if (!filterIds || filterIds.length === 0) {
52
+ return flows;
53
+ }
54
+
55
+ const filterSet = new Set(filterIds);
56
+ return flows.filter(id => filterSet.has(id));
57
+ }
58
+
59
+ module.exports = {
60
+ validateAttemptFilter,
61
+ filterAttempts,
62
+ filterFlows
63
+ };
@@ -316,11 +316,25 @@ async function checkBaseline(options) {
316
316
 
317
317
  const baselinePath = path.join(baselineDir ? baselineDir : path.join(artifactsDir, 'baselines'), `${name}.json`);
318
318
  let baseline;
319
+ let baselineStatus = 'LOADED';
319
320
  try {
320
321
  baseline = loadBaselineOrThrow(baselinePath);
321
322
  } catch (err) {
322
- console.error(`\nāŒ Baseline error: ${err.message}`);
323
- return { exitCode: 1, error: err.message };
323
+ // Handle missing baseline gracefully
324
+ if (err.code === 'BASELINE_MISSING') {
325
+ console.log(`Baseline: not found (no comparison)`);
326
+ baselineStatus = 'NO_BASELINE';
327
+ baseline = null;
328
+ } else if (err.code === 'SCHEMA_MISMATCH') {
329
+ console.warn(`āš ļø Baseline schema mismatch - skipping comparison`);
330
+ baselineStatus = 'BASELINE_UNUSABLE';
331
+ baseline = null;
332
+ } else {
333
+ // Assume JSON parse error or other corruption
334
+ console.warn(`āš ļø Baseline corrupt or unreadable - skipping comparison`);
335
+ baselineStatus = 'BASELINE_UNUSABLE';
336
+ baseline = null;
337
+ }
324
338
  }
325
339
 
326
340
  const current = await executeReality({
@@ -338,6 +352,18 @@ async function checkBaseline(options) {
338
352
  flowOptions
339
353
  });
340
354
 
355
+ // If baseline is not available, skip comparison and return early
356
+ if (!baseline || baselineStatus !== 'LOADED') {
357
+ return {
358
+ exitCode: 0, // Exit code based on flows only, not baseline
359
+ runDir: current.runDir,
360
+ overallRegressionVerdict: baselineStatus === 'NO_BASELINE' ? 'NO_BASELINE' : 'BASELINE_UNUSABLE',
361
+ comparisons: [],
362
+ flowComparisons: [],
363
+ baselineStatus
364
+ };
365
+ }
366
+
341
367
  // Use baseline attempts (includes auto-generated) for comparison
342
368
  const comparisonAttempts = baseline.attempts || attempts;
343
369
 
@@ -441,22 +467,30 @@ async function checkBaseline(options) {
441
467
  console.log(` - ${label}: ${r.regressionType} (${reasons})`);
442
468
  }
443
469
  }
444
- console.log(`Report: ${htmlPath}`);
445
-
446
- let exitCode = 0;
447
- if (overallRegressionVerdict === 'REGRESSION_FAILURE') exitCode = 4;
448
- else if (overallRegressionVerdict === 'REGRESSION_FRICTION') exitCode = 3;
449
- else exitCode = 0;
470
+
471
+ // Show improvements
472
+ const improvementList = comparisons.filter(c => c.improvements && c.improvements.length > 0);
473
+ if (improvementList.length > 0) {
474
+ console.log('\nāœ… Improvements detected:');
475
+ for (const i of improvementList.slice(0, 5)) {
476
+ const label = i.attemptId || 'unknown';
477
+ const improvementText = i.improvements.slice(0, 2).join('; ');
478
+ console.log(` + ${label}: ${improvementText}`);
479
+ }
480
+ }
450
481
 
482
+ // CRITICAL: Exit code is ALWAYS 0 from baseline check
483
+ // Exit codes come from flow outcomes only (Phase 2.x policy)
451
484
  return {
452
- exitCode,
485
+ exitCode: 0,
453
486
  runDir: current.runDir,
454
487
  reportJsonPath: jsonPath,
455
488
  reportHtmlPath: htmlPath,
456
489
  junitPath: junit || null,
457
490
  overallRegressionVerdict,
458
491
  comparisons,
459
- flowComparisons: comparisonFlows
492
+ flowComparisons: comparisonFlows,
493
+ baselineStatus: 'LOADED'
460
494
  };
461
495
  }
462
496
 
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Browser Pool - Phase 7.3
3
+ *
4
+ * Manages a single browser instance per run with context-based isolation.
5
+ * - One browser launched per run
6
+ * - Each attempt gets a fresh context (isolated cookies, storage, viewport)
7
+ * - Deterministic cleanup of contexts and browser
8
+ * - Compatible with parallel execution
9
+ */
10
+
11
+ const { chromium } = require('playwright');
12
+
13
+ class BrowserPool {
14
+ constructor() {
15
+ this.browser = null;
16
+ this.contexts = new Set();
17
+ this.launched = false;
18
+ }
19
+
20
+ /**
21
+ * Launch the shared browser instance
22
+ * @param {Object} options - Launch options (headless, args, timeout)
23
+ * @returns {Promise<void>}
24
+ */
25
+ async launch(options = {}) {
26
+ if (this.launched) {
27
+ return; // Already launched
28
+ }
29
+
30
+ const launchOptions = {
31
+ headless: options.headless !== undefined ? options.headless : true,
32
+ args: options.args || []
33
+ };
34
+
35
+ try {
36
+ this.browser = await chromium.launch(launchOptions);
37
+ this.launched = true;
38
+ } catch (err) {
39
+ throw new Error(`Failed to launch browser: ${err.message}`);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Create a new isolated context for an attempt
45
+ * @param {Object} contextOptions - Context options (viewport, recordHar, etc.)
46
+ * @returns {Promise<Object>} { context, page }
47
+ */
48
+ async createContext(contextOptions = {}) {
49
+ if (!this.browser) {
50
+ throw new Error('Browser not launched. Call launch() first.');
51
+ }
52
+
53
+ try {
54
+ const options = { ...contextOptions };
55
+
56
+ // Enable HAR recording if requested
57
+ if (contextOptions.recordHar && contextOptions.harPath) {
58
+ options.recordHar = { path: contextOptions.harPath };
59
+ }
60
+
61
+ const context = await this.browser.newContext(options);
62
+ this.contexts.add(context);
63
+
64
+ const page = await context.newPage();
65
+ if (contextOptions.timeout) {
66
+ page.setDefaultTimeout(contextOptions.timeout);
67
+ }
68
+
69
+ return { context, page };
70
+ } catch (err) {
71
+ throw new Error(`Failed to create context: ${err.message}`);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Close a specific context (deterministic cleanup)
77
+ * @param {BrowserContext} context - The context to close
78
+ * @returns {Promise<void>}
79
+ */
80
+ async closeContext(context) {
81
+ if (!context) return;
82
+
83
+ try {
84
+ this.contexts.delete(context);
85
+ await context.close();
86
+ } catch (err) {
87
+ // Ignore close errors (may already be closed)
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Close all contexts and the browser (end of run)
93
+ * @returns {Promise<void>}
94
+ */
95
+ async close() {
96
+ // Close all remaining contexts first
97
+ const contextArray = Array.from(this.contexts);
98
+ for (const context of contextArray) {
99
+ await this.closeContext(context);
100
+ }
101
+
102
+ // Close browser
103
+ if (this.browser) {
104
+ try {
105
+ await this.browser.close();
106
+ } catch (err) {
107
+ // Ignore close errors
108
+ }
109
+ this.browser = null;
110
+ this.launched = false;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Check if browser is launched
116
+ * @returns {boolean}
117
+ */
118
+ isLaunched() {
119
+ return this.launched && this.browser !== null;
120
+ }
121
+
122
+ /**
123
+ * Get count of active contexts (for testing/debugging)
124
+ * @returns {number}
125
+ */
126
+ getActiveContextCount() {
127
+ return this.contexts.size;
128
+ }
129
+ }
130
+
131
+ module.exports = { BrowserPool };
@@ -5,8 +5,15 @@ class GuardianBrowser {
5
5
  this.browser = null;
6
6
  this.context = null;
7
7
  this.page = null;
8
+ this.ownsContext = false; // Track if we own the context for cleanup
8
9
  }
9
10
 
11
+ /**
12
+ * Launch browser with its own context (legacy mode)
13
+ * @param {number} timeout - Default timeout
14
+ * @param {Object} options - Launch options
15
+ * @returns {Promise<boolean>}
16
+ */
10
17
  async launch(timeout = 20000, options = {}) {
11
18
  try {
12
19
  const launchOptions = {
@@ -30,12 +37,28 @@ class GuardianBrowser {
30
37
  this.context = await this.browser.newContext(contextOptions);
31
38
  this.page = await this.context.newPage();
32
39
  this.page.setDefaultTimeout(timeout);
40
+ this.ownsContext = true; // We own browser and context
33
41
  return true;
34
42
  } catch (err) {
35
43
  throw new Error(`Failed to launch browser: ${err.message}`);
36
44
  }
37
45
  }
38
46
 
47
+ /**
48
+ * Phase 7.3: Use an existing context from browser pool
49
+ * @param {BrowserContext} context - Playwright browser context
50
+ * @param {Page} page - Playwright page
51
+ * @param {number} timeout - Default timeout
52
+ */
53
+ useContext(context, page, timeout = 20000) {
54
+ this.context = context;
55
+ this.page = page;
56
+ this.ownsContext = false; // Pool owns the context
57
+ if (timeout) {
58
+ this.page.setDefaultTimeout(timeout);
59
+ }
60
+ }
61
+
39
62
  async navigate(url, timeout = 20000) {
40
63
  try {
41
64
  const response = await this.page.goto(url, {
@@ -82,7 +105,11 @@ class GuardianBrowser {
82
105
 
83
106
  async close() {
84
107
  try {
85
- if (this.browser) await this.browser.close();
108
+ // Phase 7.3: Only close browser if we own it (legacy mode)
109
+ // If using pool context, pool handles cleanup
110
+ if (this.ownsContext && this.browser) {
111
+ await this.browser.close();
112
+ }
86
113
  } catch (err) {
87
114
  // Ignore close errors
88
115
  }
@@ -0,0 +1,15 @@
1
+ function isCiMode() {
2
+ const ci = process.env.CI;
3
+ const gha = process.env.GITHUB_ACTIONS;
4
+ const guardianCi = process.env.GUARDIAN_CI;
5
+
6
+ const normalize = (value) => String(value || '').toLowerCase();
7
+ const isTrueish = (value) => {
8
+ const v = normalize(value);
9
+ return v === 'true' || v === '1' || v === 'yes' || v === 'on';
10
+ };
11
+
12
+ return isTrueish(ci) || isTrueish(gha) || isTrueish(guardianCi);
13
+ }
14
+
15
+ module.exports = { isCiMode };
@@ -0,0 +1,37 @@
1
+ const { formatRunSummary, deriveBaselineVerdict } = require('./run-summary');
2
+ const MAX_REASONS_DEFAULT = 3;
3
+
4
+ function pickReason(flow) {
5
+ if (Array.isArray(flow.failureReasons) && flow.failureReasons.length > 0) {
6
+ return flow.failureReasons[0];
7
+ }
8
+ if (flow.error) return flow.error;
9
+ if (flow.successEval && Array.isArray(flow.successEval.reasons) && flow.successEval.reasons.length > 0) {
10
+ return flow.successEval.reasons[0];
11
+ }
12
+ return 'no reason captured';
13
+ }
14
+
15
+ function formatCiSummary({ flowResults = [], diffResult = null, baselineCreated = false, exitCode = 0, maxReasons = MAX_REASONS_DEFAULT }) {
16
+ const lines = [];
17
+ lines.push('CI MODE: ON');
18
+ lines.push(formatRunSummary({ flowResults, diffResult, baselineCreated, exitCode }, { label: 'Summary' }));
19
+
20
+ if (exitCode !== 0) {
21
+ lines.push('Why CI failed:');
22
+ const troubled = flowResults.filter(f => f.outcome === 'FAILURE' || f.outcome === 'FRICTION');
23
+ troubled.slice(0, maxReasons).forEach(flow => {
24
+ const reason = pickReason(flow);
25
+ lines.push(` - ${flow.flowName || flow.flowId || 'flow'}: ${flow.outcome} | ${reason}`);
26
+ });
27
+ if (troubled.length > maxReasons) {
28
+ lines.push(` - … ${troubled.length - maxReasons} more issues`);
29
+ }
30
+ } else {
31
+ lines.push('Result: PASS');
32
+ }
33
+
34
+ return lines.join('\n');
35
+ }
36
+
37
+ module.exports = { formatCiSummary, deriveBaselineVerdict };