@odavl/guardian 0.2.0 → 1.0.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 (84) hide show
  1. package/CHANGELOG.md +86 -2
  2. package/README.md +155 -97
  3. package/bin/guardian.js +1345 -60
  4. package/config/README.md +59 -0
  5. package/config/profiles/landing-demo.yaml +16 -0
  6. package/package.json +21 -11
  7. package/policies/landing-demo.json +22 -0
  8. package/src/enterprise/audit-logger.js +166 -0
  9. package/src/enterprise/pdf-exporter.js +267 -0
  10. package/src/enterprise/rbac-gate.js +142 -0
  11. package/src/enterprise/rbac.js +239 -0
  12. package/src/enterprise/site-manager.js +180 -0
  13. package/src/founder/feedback-system.js +156 -0
  14. package/src/founder/founder-tracker.js +213 -0
  15. package/src/founder/usage-signals.js +141 -0
  16. package/src/guardian/alert-ledger.js +121 -0
  17. package/src/guardian/attempt-engine.js +568 -7
  18. package/src/guardian/attempt-registry.js +42 -1
  19. package/src/guardian/attempt-relevance.js +106 -0
  20. package/src/guardian/attempt.js +24 -0
  21. package/src/guardian/baseline.js +12 -4
  22. package/src/guardian/breakage-intelligence.js +1 -0
  23. package/src/guardian/ci-cli.js +121 -0
  24. package/src/guardian/ci-output.js +4 -3
  25. package/src/guardian/cli-summary.js +79 -92
  26. package/src/guardian/config-loader.js +162 -0
  27. package/src/guardian/drift-detector.js +100 -0
  28. package/src/guardian/enhanced-html-reporter.js +221 -4
  29. package/src/guardian/env-guard.js +127 -0
  30. package/src/guardian/failure-intelligence.js +173 -0
  31. package/src/guardian/first-run-profile.js +89 -0
  32. package/src/guardian/first-run.js +6 -1
  33. package/src/guardian/flag-validator.js +17 -3
  34. package/src/guardian/html-reporter.js +2 -0
  35. package/src/guardian/human-reporter.js +431 -0
  36. package/src/guardian/index.js +22 -19
  37. package/src/guardian/init-command.js +9 -5
  38. package/src/guardian/intent-detector.js +146 -0
  39. package/src/guardian/journey-definitions.js +132 -0
  40. package/src/guardian/journey-scan-cli.js +145 -0
  41. package/src/guardian/journey-scanner.js +583 -0
  42. package/src/guardian/junit-reporter.js +18 -1
  43. package/src/guardian/live-cli.js +95 -0
  44. package/src/guardian/live-scheduler-runner.js +137 -0
  45. package/src/guardian/live-scheduler.js +146 -0
  46. package/src/guardian/market-reporter.js +341 -81
  47. package/src/guardian/pattern-analyzer.js +348 -0
  48. package/src/guardian/policy.js +80 -3
  49. package/src/guardian/preset-loader.js +9 -6
  50. package/src/guardian/reality.js +1278 -117
  51. package/src/guardian/reporter.js +27 -41
  52. package/src/guardian/run-artifacts.js +212 -0
  53. package/src/guardian/run-cleanup.js +207 -0
  54. package/src/guardian/run-latest.js +90 -0
  55. package/src/guardian/run-list.js +211 -0
  56. package/src/guardian/scan-presets.js +100 -11
  57. package/src/guardian/selector-fallbacks.js +394 -0
  58. package/src/guardian/semantic-contact-finder.js +2 -1
  59. package/src/guardian/site-introspection.js +257 -0
  60. package/src/guardian/smoke.js +2 -2
  61. package/src/guardian/snapshot-schema.js +25 -1
  62. package/src/guardian/snapshot.js +46 -2
  63. package/src/guardian/stability-scorer.js +169 -0
  64. package/src/guardian/template-command.js +184 -0
  65. package/src/guardian/text-formatters.js +426 -0
  66. package/src/guardian/verdict.js +320 -0
  67. package/src/guardian/verdicts.js +74 -0
  68. package/src/guardian/watch-runner.js +3 -7
  69. package/src/payments/stripe-checkout.js +169 -0
  70. package/src/plans/plan-definitions.js +148 -0
  71. package/src/plans/plan-manager.js +211 -0
  72. package/src/plans/usage-tracker.js +210 -0
  73. package/src/recipes/recipe-engine.js +188 -0
  74. package/src/recipes/recipe-failure-analysis.js +159 -0
  75. package/src/recipes/recipe-registry.js +134 -0
  76. package/src/recipes/recipe-runtime.js +507 -0
  77. package/src/recipes/recipe-store.js +410 -0
  78. package/guardian-contract-v1.md +0 -149
  79. /package/{guardian.config.json → config/guardian.config.json} +0 -0
  80. /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
  81. /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
  82. /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
  83. /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
  84. /package/{guardian.profile.saas.yaml → config/profiles/saas.yaml} +0 -0
@@ -0,0 +1,162 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const DEFAULTS = {
5
+ crawl: {
6
+ maxPages: 25,
7
+ maxDepth: 3,
8
+ },
9
+ timeouts: {
10
+ navigationMs: 20000,
11
+ },
12
+ output: {
13
+ dir: './.odavlguardian',
14
+ }
15
+ };
16
+
17
+ function findConfigFile(startDir = process.cwd()) {
18
+ let current = path.resolve(startDir);
19
+ while (true) {
20
+ const candidate = path.join(current, 'guardian.config.json');
21
+ if (fs.existsSync(candidate)) {
22
+ return candidate;
23
+ }
24
+ const parent = path.dirname(current);
25
+ if (parent === current) break;
26
+ current = parent;
27
+ }
28
+ return null;
29
+ }
30
+
31
+ function validatePositiveInt(value, name, allowZero = false) {
32
+ if (typeof value !== 'number' || Number.isNaN(value)) {
33
+ throw new Error(`${name} must be a number`);
34
+ }
35
+ if (!Number.isInteger(value)) {
36
+ throw new Error(`${name} must be an integer`);
37
+ }
38
+ if (allowZero) {
39
+ if (value < 0) throw new Error(`${name} must be >= 0`);
40
+ } else {
41
+ if (value <= 0) throw new Error(`${name} must be > 0`);
42
+ }
43
+ }
44
+
45
+ function validateConfigShape(raw, filePath) {
46
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
47
+ throw new Error(`Invalid config in ${filePath}: expected an object`);
48
+ }
49
+
50
+ const effective = {
51
+ crawl: {
52
+ maxPages: DEFAULTS.crawl.maxPages,
53
+ maxDepth: DEFAULTS.crawl.maxDepth
54
+ },
55
+ timeouts: {
56
+ navigationMs: DEFAULTS.timeouts.navigationMs
57
+ },
58
+ output: {
59
+ dir: DEFAULTS.output.dir
60
+ }
61
+ };
62
+
63
+ if (raw.crawl) {
64
+ if (raw.crawl.maxPages !== undefined) {
65
+ validatePositiveInt(raw.crawl.maxPages, 'crawl.maxPages');
66
+ effective.crawl.maxPages = raw.crawl.maxPages;
67
+ }
68
+ if (raw.crawl.maxDepth !== undefined) {
69
+ validatePositiveInt(raw.crawl.maxDepth, 'crawl.maxDepth', true);
70
+ effective.crawl.maxDepth = raw.crawl.maxDepth;
71
+ }
72
+ }
73
+
74
+ if (raw.timeouts) {
75
+ if (raw.timeouts.navigationMs !== undefined) {
76
+ validatePositiveInt(raw.timeouts.navigationMs, 'timeouts.navigationMs');
77
+ effective.timeouts.navigationMs = raw.timeouts.navigationMs;
78
+ }
79
+ }
80
+
81
+ if (raw.output) {
82
+ if (raw.output.dir !== undefined) {
83
+ if (typeof raw.output.dir !== 'string' || raw.output.dir.trim() === '') {
84
+ throw new Error('output.dir must be a non-empty string');
85
+ }
86
+ effective.output.dir = raw.output.dir;
87
+ }
88
+ }
89
+
90
+ return effective;
91
+ }
92
+
93
+ function applyLocalConfig(inputConfig) {
94
+ const cliSource = inputConfig._cliSource || {};
95
+ const discoveredPath = findConfigFile();
96
+ let fileConfig = null;
97
+ let fileEffective = null;
98
+
99
+ if (discoveredPath) {
100
+ try {
101
+ fileConfig = JSON.parse(fs.readFileSync(discoveredPath, 'utf8'));
102
+ fileEffective = validateConfigShape(fileConfig, discoveredPath);
103
+ } catch (err) {
104
+ throw new Error(`Invalid guardian.config.json at ${discoveredPath}: ${err.message}`);
105
+ }
106
+ }
107
+
108
+ const effective = {
109
+ crawl: { ...DEFAULTS.crawl },
110
+ timeouts: { ...DEFAULTS.timeouts },
111
+ output: { ...DEFAULTS.output }
112
+ };
113
+
114
+ if (fileEffective) {
115
+ effective.crawl = { ...effective.crawl, ...fileEffective.crawl };
116
+ effective.timeouts = { ...effective.timeouts, ...fileEffective.timeouts };
117
+ effective.output = { ...effective.output, ...fileEffective.output };
118
+ }
119
+
120
+ // CLI overrides
121
+ if (inputConfig.maxPages !== undefined) {
122
+ validatePositiveInt(inputConfig.maxPages, 'crawl.maxPages');
123
+ effective.crawl.maxPages = inputConfig.maxPages;
124
+ }
125
+ if (inputConfig.maxDepth !== undefined) {
126
+ validatePositiveInt(inputConfig.maxDepth, 'crawl.maxDepth', true);
127
+ effective.crawl.maxDepth = inputConfig.maxDepth;
128
+ }
129
+ if (inputConfig.timeout !== undefined) {
130
+ validatePositiveInt(inputConfig.timeout, 'timeouts.navigationMs');
131
+ effective.timeouts.navigationMs = inputConfig.timeout;
132
+ }
133
+ if (inputConfig.artifactsDir !== undefined) {
134
+ if (typeof inputConfig.artifactsDir !== 'string' || inputConfig.artifactsDir.trim() === '') {
135
+ throw new Error('output.dir must be a non-empty string');
136
+ }
137
+ effective.output.dir = inputConfig.artifactsDir;
138
+ }
139
+
140
+ // Determine source label
141
+ let source = 'defaults';
142
+ if (fileEffective) source = 'guardian.config.json';
143
+ const cliOverride = cliSource.maxPages || cliSource.maxDepth || cliSource.timeout || cliSource.artifactsDir;
144
+ if (cliOverride) source = 'cli';
145
+
146
+ const finalConfig = { ...inputConfig };
147
+ finalConfig.maxPages = effective.crawl.maxPages;
148
+ finalConfig.maxDepth = effective.crawl.maxDepth;
149
+ finalConfig.timeout = effective.timeouts.navigationMs;
150
+ finalConfig.artifactsDir = effective.output.dir;
151
+
152
+ return {
153
+ config: finalConfig,
154
+ report: {
155
+ source,
156
+ path: fileEffective ? path.resolve(discoveredPath) : null,
157
+ effective
158
+ }
159
+ };
160
+ }
161
+
162
+ module.exports = { applyLocalConfig, DEFAULTS };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Drift Detection Engine for Journey Results
3
+ * Deterministic comparison against stored baseline snapshot.
4
+ */
5
+
6
+ function buildBaselineFromJourneyResult(result) {
7
+ const keySteps = (result.executedSteps || []).map(s => ({ id: s.id, success: !!s.success }));
8
+ const baseline = {
9
+ decision: result.finalDecision,
10
+ intent: (result.intentDetection && result.intentDetection.intent) || 'unknown',
11
+ goalReached: !!(result.goal && result.goal.goalReached),
12
+ keySteps,
13
+ timestamp: new Date().toISOString()
14
+ };
15
+ return baseline;
16
+ }
17
+
18
+ function compareAgainstBaseline(baseline, current) {
19
+ const reasons = [];
20
+ const currentSteps = {};
21
+ (current.executedSteps || []).forEach(s => { currentSteps[s.id] = !!s.success; });
22
+
23
+ // Decision drift
24
+ const before = baseline.decision;
25
+ const after = current.finalDecision;
26
+ if (before === 'SAFE' && (after === 'RISK' || after === 'DO_NOT_LAUNCH')) {
27
+ reasons.push(`Decision regressed: ${before} → ${after}`);
28
+ }
29
+
30
+ // Goal drift
31
+ const baselineGoal = !!baseline.goalReached;
32
+ const currentGoal = !!(current.goal && current.goal.goalReached);
33
+ if (baselineGoal && !currentGoal) {
34
+ reasons.push('Visitors can no longer reach goal');
35
+ }
36
+
37
+ // Intent drift
38
+ const baselineIntent = (baseline.intent || 'unknown').toLowerCase();
39
+ const currentIntent = ((current.intentDetection && current.intentDetection.intent) || 'unknown').toLowerCase();
40
+ if (baselineIntent !== 'unknown' && currentIntent !== 'unknown' && baselineIntent !== currentIntent) {
41
+ reasons.push(`Site intent changed from ${baselineIntent.toUpperCase()} to ${currentIntent.toUpperCase()}`);
42
+ }
43
+
44
+ // Critical step regression: any step that succeeded before now fails
45
+ for (const ks of baseline.keySteps || []) {
46
+ if (ks.success === true) {
47
+ const now = currentSteps[ks.id];
48
+ if (now === false || now === undefined) {
49
+ reasons.push(`Critical step failed: ${ks.id}`);
50
+ }
51
+ }
52
+ }
53
+
54
+ return {
55
+ hasDrift: reasons.length > 0,
56
+ reasons: reasons
57
+ };
58
+ }
59
+
60
+ function classifySeverity(driftInfo, currentResult) {
61
+ if (!driftInfo || !driftInfo.hasDrift) return 'NONE';
62
+
63
+ const reasons = driftInfo.reasons || [];
64
+
65
+ // CRITICAL conditions:
66
+ // 1. Decision drifted to DO_NOT_LAUNCH
67
+ if (currentResult.decision === 'DO_NOT_LAUNCH') {
68
+ // Exception: if run stability is very low (<50), downgrade to WARN
69
+ // unless site is unreachable
70
+ if (!reasons.some(r => r.includes('SITE_UNREACHABLE'))) {
71
+ const stabilityScore = currentResult.stability?.runStabilityScore || 100;
72
+ if (stabilityScore < 50) {
73
+ return 'WARN'; // Downgrade: unstable run, re-check recommended
74
+ }
75
+ }
76
+ return 'CRITICAL';
77
+ }
78
+
79
+ // 2. Site unreachable - always CRITICAL (do not downgrade)
80
+ if (reasons.some(r => r.includes('SITE_UNREACHABLE'))) return 'CRITICAL';
81
+
82
+ // 3. Goal drift (visitors can no longer reach goal)
83
+ if (reasons.some(r => r.includes('goal drift: true → false'))) {
84
+ // Check stability before deciding
85
+ const stabilityScore = currentResult.stability?.runStabilityScore || 100;
86
+ if (stabilityScore < 50) {
87
+ return 'WARN'; // Downgrade: unstable, suspicious drift
88
+ }
89
+ return 'CRITICAL';
90
+ }
91
+
92
+ // Default: WARN for all other drift
93
+ return 'WARN';
94
+ }
95
+
96
+ module.exports = {
97
+ buildBaselineFromJourneyResult,
98
+ compareAgainstBaseline,
99
+ classifySeverity
100
+ };
@@ -12,11 +12,38 @@
12
12
 
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
+ const { analyzePatterns, loadRecentRunsForSite } = require('./pattern-analyzer');
16
+ const {
17
+ formatVerdictStatus,
18
+ formatConfidence,
19
+ formatVerdictWhy,
20
+ formatKeyFindings,
21
+ formatLimits,
22
+ formatConfidenceMicroLine,
23
+ formatFirstRunNote,
24
+ formatJourneyMessage,
25
+ formatNextRunHint,
26
+ formatPatternSummary,
27
+ formatPatternWhy,
28
+ formatPatternFocus,
29
+ formatPatternLimits,
30
+ formatConfidenceDrivers,
31
+ formatFocusSummary,
32
+ formatDeltaInsight,
33
+ // Stage V / Step 5.2: Silence Discipline helpers
34
+ shouldRenderFocusSummary,
35
+ shouldRenderDeltaInsight,
36
+ shouldRenderPatterns,
37
+ shouldRenderConfidenceDrivers,
38
+ shouldRenderJourneyMessage,
39
+ shouldRenderNextRunHint,
40
+ shouldRenderFirstRunNote
41
+ } = require('./text-formatters');
15
42
 
16
43
  /**
17
44
  * Generate enhanced HTML report
18
45
  */
19
- function generateEnhancedHtml(snapshot, outputDir) {
46
+ function generateEnhancedHtml(snapshot, outputDir, options = {}) {
20
47
  if (!snapshot) {
21
48
  return '<html><body><h1>No snapshot data</h1></body></html>';
22
49
  }
@@ -51,12 +78,24 @@ function generateEnhancedHtml(snapshot, outputDir) {
51
78
  .meta { color: #7f8c8d; margin-bottom: 30px; }
52
79
  .meta span { display: inline-block; margin-right: 20px; }
53
80
  .summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 30px; }
81
+ .verdict-card { background: #ffffff; border: 2px solid #3498db; padding: 16px; border-radius: 8px; margin: 20px 0; }
82
+ .verdict-title { font-weight: 600; font-size: 18px; color: #2c3e50; margin-bottom: 8px; }
83
+ .verdict-item { margin: 4px 0; }
84
+ .bullets { margin-top: 8px; padding-left: 18px; }
54
85
  .stat-card { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 8px; text-align: center; }
55
86
  .stat-card.critical { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
56
87
  .stat-card.warning { background: linear-gradient(135deg, #fad961 0%, #f76b1c 100%); }
57
88
  .stat-card.info { background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); color: #333; }
58
89
  .stat-number { font-size: 48px; font-weight: bold; margin-bottom: 10px; }
59
90
  .stat-label { font-size: 14px; text-transform: uppercase; letter-spacing: 1px; opacity: 0.9; }
91
+ .pattern-item { background: #f9f9f9; border-left: 4px solid #9b59b6; padding: 15px; margin-bottom: 15px; border-radius: 4px; }
92
+ .pattern-item.high { border-left-color: #e74c3c; }
93
+ .pattern-item.medium { border-left-color: #f39c12; }
94
+ .pattern-item.low { border-left-color: #95a5a6; }
95
+ .pattern-summary { font-weight: 600; margin-bottom: 8px; }
96
+ .pattern-why { color: #7f8c8d; font-size: 14px; margin-bottom: 8px; }
97
+ .pattern-limits { color: #95a5a6; font-size: 13px; font-style: italic; }
98
+ .pattern-focus { color: #2c3e50; font-size: 14px; margin-bottom: 6px; }
60
99
  .risk-item { background: #fff; border-left: 4px solid #e74c3c; padding: 15px; margin-bottom: 15px; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
61
100
  .risk-item.warning { border-left-color: #f39c12; }
62
101
  .risk-item.info { border-left-color: #3498db; }
@@ -99,6 +138,179 @@ function generateEnhancedHtml(snapshot, outputDir) {
99
138
  <span><strong>Date:</strong> ${meta.createdAt || 'Unknown'}</span>
100
139
  </div>
101
140
 
141
+ <!-- Verdict & Confidence -->
142
+ <div class="verdict-card">
143
+ <div class="verdict-title">Verdict & Confidence</div>
144
+ ${(() => {
145
+ const v = snapshot.verdict || meta.verdict || null;
146
+ if (!v) return '<div class="verdict-item">No verdict available</div>';
147
+ // First-run context detection
148
+ let firstRunLine = '';
149
+ var journeyLineHtml = '';
150
+ let priorRuns = 0; // Declare in outer scope for use in drivers logic
151
+ try {
152
+ const artifactsDir = options.artifactsDir;
153
+ const siteSlug = options.siteSlug || (meta.siteSlug);
154
+ if (artifactsDir && siteSlug) {
155
+ const runs = loadRecentRunsForSite(artifactsDir, siteSlug, 10);
156
+ priorRuns = runs.length;
157
+ }
158
+ const runIndex = priorRuns;
159
+ if (shouldRenderFirstRunNote(runIndex)) {
160
+ firstRunLine = `<div class=\"verdict-item\"><em>${formatFirstRunNote()}</em></div>`;
161
+ }
162
+ // Confidence interpretation micro-line (Stage IV)
163
+ const cfLevel = (v.confidence || {}).level;
164
+ const showMicro = ((cfLevel && cfLevel !== 'high') || runIndex < 2);
165
+ var confidenceLineHtml = showMicro ? `<div class=\"verdict-item\">${formatConfidenceMicroLine()}</div>` : '';
166
+
167
+ // Three-Runs Journey Messaging (Stage IV)
168
+ try {
169
+ if (artifactsDir && siteSlug) {
170
+ const patterns = analyzePatterns(artifactsDir, siteSlug, 10) || [];
171
+ const patternsPresent = patterns.length > 0;
172
+ if (!patternsPresent) {
173
+ if (shouldRenderJourneyMessage(runIndex)) {
174
+ const journeyMsg = formatJourneyMessage(runIndex);
175
+ if (journeyMsg) {
176
+ journeyLineHtml = `<div class=\"verdict-item\">${journeyMsg}</div>`;
177
+ }
178
+ }
179
+ }
180
+ }
181
+ } catch (_) {}
182
+ } catch (_) {}
183
+ const vStatus = formatVerdictStatus(v);
184
+ const vConf = formatConfidence(v);
185
+ const vWhy = formatVerdictWhy(v);
186
+ const vFindings = formatKeyFindings(v);
187
+ const vLimits = formatLimits(v);
188
+ const vNextHint = formatNextRunHint(v);
189
+
190
+ // Confidence Drivers Card (Layer 4 / Step 4.2)
191
+ // Stage V / Step 5.2: Use centralized suppression helper
192
+ let vDrivers = [];
193
+ if (shouldRenderConfidenceDrivers(v, priorRuns)) {
194
+ vDrivers = formatConfidenceDrivers(v);
195
+ }
196
+
197
+ // Focus Summary (Layer 5 - Advisor Mode)
198
+ // Stage V / Step 5.2: Use centralized suppression helper
199
+ let vFocus = [];
200
+ try {
201
+ const patterns = options.artifactsDir && options.siteSlug
202
+ ? analyzePatterns(options.artifactsDir, options.siteSlug, 10)
203
+ : [];
204
+ if (shouldRenderFocusSummary(v, patterns)) {
205
+ vFocus = formatFocusSummary(v, patterns);
206
+ }
207
+ } catch (_) {}
208
+
209
+ // Delta Insight (Stage V / Step 5.1)
210
+ let deltaImproved = [];
211
+ let deltaRegressed = [];
212
+ try {
213
+ if (options.artifactsDir && options.siteSlug) {
214
+ const runs = loadRecentRunsForSite(options.artifactsDir, options.siteSlug, 10);
215
+ if (runs.length >= 2) {
216
+ const previousRun = runs[1];
217
+ let previousVerdict = null;
218
+ let previousPatterns = [];
219
+
220
+ if (previousRun.snapshotPath) {
221
+ try {
222
+ const prevSnap = JSON.parse(fs.readFileSync(previousRun.snapshotPath, 'utf8'));
223
+ previousVerdict = prevSnap.verdict || prevSnap.meta?.verdict || null;
224
+ } catch (_) {}
225
+ }
226
+
227
+ try {
228
+ previousPatterns = analyzePatterns(options.artifactsDir, options.siteSlug, 10, previousRun.runId) || [];
229
+ } catch (_) {}
230
+
231
+ const detectedPatterns = options.artifactsDir && options.siteSlug
232
+ ? analyzePatterns(options.artifactsDir, options.siteSlug, 10)
233
+ : [];
234
+
235
+ const delta = formatDeltaInsight(v, previousVerdict, detectedPatterns, previousPatterns);
236
+
237
+ // Stage V / Step 5.2: Use centralized suppression helper
238
+ if (shouldRenderDeltaInsight(delta)) {
239
+ deltaImproved = delta.improved || [];
240
+ deltaRegressed = delta.regressed || [];
241
+ }
242
+ }
243
+ }
244
+ } catch (_) {}
245
+
246
+ return `
247
+ <div class="verdict-item"><strong>Verdict:</strong> ${vStatus}</div>
248
+ <div class="verdict-item"><strong>Confidence:</strong> ${vConf}</div>
249
+ ${confidenceLineHtml || ''}
250
+ ${vWhy ? `<div class="verdict-item"><strong>Why:</strong> ${vWhy}</div>` : ''}
251
+ ${vDrivers.length ? `<div class="verdict-item"><strong>Confidence Drivers:</strong>
252
+ <ul class="bullets">${vDrivers.map(d => `<li>${d}</li>`).join('')}</ul>
253
+ </div>` : ''}
254
+ ${vFocus.length ? `<div class="verdict-item"><strong>Focus Summary:</strong>
255
+ <ul class="bullets">${vFocus.map(f => `<li>${f}</li>`).join('')}</ul>
256
+ </div>` : ''}
257
+ ${(deltaImproved.length || deltaRegressed.length) ? `<div class="verdict-item"><strong>Delta Insight:</strong>
258
+ <ul class="bullets">
259
+ ${deltaImproved.map(line => `<li>✅ ${line}</li>`).join('')}
260
+ ${deltaRegressed.map(line => `<li>⚠️ ${line}</li>`).join('')}
261
+ </ul>
262
+ </div>` : ''}
263
+ ${firstRunLine}
264
+ ${journeyLineHtml}
265
+ ${vFindings.length ? `<div class="verdict-item"><strong>Key Findings:</strong>
266
+ <ul class="bullets">${vFindings.map(f => `<li>${f}</li>`).join('')}</ul>
267
+ </div>` : ''}
268
+ ${vLimits.length ? `<div class="verdict-item"><strong>Limits:</strong>
269
+ <ul class="bullets">${vLimits.map(l => `<li>${l}</li>`).join('')}</ul>
270
+ </div>` : ''}
271
+ ${(() => {
272
+ if (shouldRenderNextRunHint(v)) {
273
+ const hint = formatNextRunHint(v);
274
+ return hint ? `<div class="verdict-item"><strong>Next Run Hint:</strong> ${hint}</div>` : '';
275
+ }
276
+ return '';
277
+ })()}
278
+ `;
279
+ })()}
280
+ </div>
281
+
282
+ <!-- Observed Patterns -->
283
+ ${(() => {
284
+ if (!options.artifactsDir || !options.siteSlug) return '';
285
+ try {
286
+ const patterns = analyzePatterns(options.artifactsDir, options.siteSlug, 10);
287
+ // Stage V / Step 5.2: Use centralized suppression helper
288
+ if (!shouldRenderPatterns(patterns)) return '';
289
+ return `
290
+ <div style="margin-top: 30px;">
291
+ <h2>🔍 Observed Patterns (Cross-Run Analysis)</h2>
292
+ ${patterns.slice(0, 5).map((pattern, idx) => {
293
+ const summary = formatPatternSummary(pattern);
294
+ const why = formatPatternWhy(pattern);
295
+ const focus = formatPatternFocus(pattern);
296
+ const limits = formatPatternLimits(pattern);
297
+ return `
298
+ <div class="pattern-item ${pattern.confidence}">
299
+ <div class="pattern-summary">${idx + 1}. ${summary}</div>
300
+ <div class="pattern-why">${why}</div>
301
+ ${focus ? `<div class="pattern-focus">${focus}</div>` : ''}
302
+ ${limits ? `<div class="pattern-limits">Limits: ${limits}</div>` : ''}
303
+ </div>
304
+ `;
305
+ }).join('')}
306
+ ${patterns.length > 5 ? `<p style="color: #7f8c8d; margin-top: 10px;">... ${patterns.length - 5} more pattern(s) detected.</p>` : ''}
307
+ </div>
308
+ `;
309
+ } catch (err) {
310
+ return '';
311
+ }
312
+ })()}
313
+
102
314
  <!-- Summary Cards -->
103
315
  <div class="summary">
104
316
  <div class="stat-card critical">
@@ -155,12 +367,17 @@ function generateEnhancedHtml(snapshot, outputDir) {
155
367
  `;
156
368
  attempts.forEach(attempt => {
157
369
  const outcomeClass = attempt.outcome === 'SUCCESS' ? 'success' : 'failure';
370
+ const outcomeLabel = attempt.outcome === 'SKIPPED' ? 'Not Executed' : (attempt.outcome || 'UNKNOWN');
371
+ const reasonLine = attempt.outcome === 'SKIPPED' && attempt.skipReason ? `<div class="risk-details" style="font-size:12px;color:#7f8c8d;">Reason: ${attempt.skipReason}</div>` : '';
158
372
  html += `
159
373
  <li class="attempt-item">
160
374
  <span class="attempt-name">${attempt.attemptName || attempt.attemptId}</span>
161
- <span class="attempt-outcome ${outcomeClass}">${attempt.outcome || 'UNKNOWN'}</span>
375
+ <span class="attempt-outcome ${outcomeClass}">${outcomeLabel}</span>
162
376
  </li>
163
377
  `;
378
+ if (reasonLine) {
379
+ html += reasonLine;
380
+ }
164
381
  });
165
382
  html += `
166
383
  </ul>
@@ -286,8 +503,8 @@ function generateEnhancedHtml(snapshot, outputDir) {
286
503
  /**
287
504
  * Write enhanced HTML report to file
288
505
  */
289
- function writeEnhancedHtml(snapshot, outputDir) {
290
- const html = generateEnhancedHtml(snapshot, outputDir);
506
+ function writeEnhancedHtml(snapshot, outputDir, options = {}) {
507
+ const html = generateEnhancedHtml(snapshot, outputDir, options);
291
508
  const reportPath = path.join(outputDir, 'report.html');
292
509
 
293
510
  const dir = path.dirname(reportPath);
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Environment Readiness Guard
3
+ *
4
+ * Detects missing critical dependencies and fails early with actionable errors.
5
+ * NO stack traces. NO noise. Just the fix.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { execSync } = require('child_process');
11
+
12
+ /**
13
+ * Check if Playwright browsers are installed
14
+ */
15
+ function checkPlaywrightBrowsers() {
16
+ try {
17
+ // Playwright caches browsers under ~/.cache/ms-playwright or similar
18
+ // Quick check: can we require the Playwright package?
19
+ require('playwright');
20
+ // If that works, browsers should be available
21
+ return { ok: true };
22
+ } catch (e) {
23
+ return {
24
+ ok: false,
25
+ error: 'Playwright not properly installed or browsers missing',
26
+ fix: 'npx playwright install'
27
+ };
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Check Node version compatibility
33
+ */
34
+ function checkNodeVersion() {
35
+ const version = process.version; // e.g., "v18.0.0"
36
+ const major = parseInt(version.slice(1).split('.')[0], 10);
37
+ if (major < 18) {
38
+ return {
39
+ ok: false,
40
+ error: `Node.js ${version} is too old (minimum: 18.0.0)`,
41
+ fix: 'Upgrade Node.js to version 18 or later'
42
+ };
43
+ }
44
+ return { ok: true };
45
+ }
46
+
47
+ /**
48
+ * Check disk space (rough heuristic)
49
+ */
50
+ function checkDiskSpace() {
51
+ try {
52
+ // Try to write a small temp file
53
+ const tempFile = path.join(require('os').tmpdir(), `.guardian-space-check-${Date.now()}`);
54
+ fs.writeFileSync(tempFile, 'test', 'utf8');
55
+ fs.unlinkSync(tempFile);
56
+ return { ok: true };
57
+ } catch (e) {
58
+ return {
59
+ ok: false,
60
+ error: 'Insufficient disk space or write permission denied',
61
+ fix: 'Ensure /tmp (or temp directory) has at least 100MB free'
62
+ };
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Run all environment checks
68
+ * @returns {object} - { allOk: boolean, issues: array }
69
+ */
70
+ function checkEnvironment() {
71
+ const checks = [
72
+ { name: 'Node.js version', check: checkNodeVersion },
73
+ { name: 'Playwright browsers', check: checkPlaywrightBrowsers },
74
+ { name: 'Disk space', check: checkDiskSpace }
75
+ ];
76
+
77
+ const issues = [];
78
+
79
+ for (const { name, check } of checks) {
80
+ try {
81
+ const result = check();
82
+ if (!result.ok) {
83
+ issues.push({
84
+ name,
85
+ error: result.error,
86
+ fix: result.fix
87
+ });
88
+ }
89
+ } catch (e) {
90
+ issues.push({
91
+ name,
92
+ error: e.message,
93
+ fix: 'See documentation or contact support'
94
+ });
95
+ }
96
+ }
97
+
98
+ return {
99
+ allOk: issues.length === 0,
100
+ issues
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Print environment guard error and exit
106
+ */
107
+ function failWithEnvironmentError(issues) {
108
+ console.error('\n❌ Environment Check Failed\n');
109
+ console.error('Guardian cannot run due to missing or incompatible dependencies:\n');
110
+
111
+ issues.forEach((issue, idx) => {
112
+ console.error(`${idx + 1}. ${issue.name}`);
113
+ console.error(` Error: ${issue.error}`);
114
+ console.error(` Fix: ${issue.fix}`);
115
+ console.error('');
116
+ });
117
+
118
+ console.error('After fixing the above, run Guardian again:\n');
119
+ console.error(' guardian reality --url <your-site-url>\n');
120
+
121
+ process.exit(1);
122
+ }
123
+
124
+ module.exports = {
125
+ checkEnvironment,
126
+ failWithEnvironmentError
127
+ };