@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,348 @@
1
+ /**
2
+ * Pattern Analyzer
3
+ * Detects recurring risk patterns across multiple runs.
4
+ *
5
+ * Patterns detected:
6
+ * - repeated_skipped_attempts: Same attempt consistently skipped
7
+ * - recurring_friction_path: Specific attempt/page shows friction across runs
8
+ * - confidence_degradation: Confidence score declining over time
9
+ * - single_point_failure: One attempt always fails while others succeed
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ /**
16
+ * Load all recent run metadata for a site
17
+ * @param {string} artifactsDir - e.g., ./artifacts
18
+ * @param {string} siteSlug - e.g., example-com
19
+ * @param {number} maxRuns - Maximum runs to analyze (default 10)
20
+ * @returns {array} - [{runDirName, runId, timestamp, meta, snapshotPath}, ...]
21
+ */
22
+ function loadRecentRunsForSite(artifactsDir, siteSlug, maxRuns = 10) {
23
+ const runs = [];
24
+
25
+ try {
26
+ const entries = fs.readdirSync(artifactsDir, { withFileTypes: true });
27
+
28
+ for (const entry of entries) {
29
+ if (!entry.isDirectory()) continue;
30
+
31
+ const dirName = entry.name;
32
+ // Format: YYYY-MM-DD_HH-MM-SS_<siteSlug>_<policy>_<result>
33
+ if (!dirName.includes(siteSlug)) continue;
34
+
35
+ const metaPath = path.join(artifactsDir, dirName, 'META.json');
36
+ const snapshotPath = path.join(artifactsDir, dirName, 'snapshot.json');
37
+
38
+ // Check if run has artifacts
39
+ if (!fs.existsSync(metaPath)) continue;
40
+
41
+ try {
42
+ const metaRaw = fs.readFileSync(metaPath, 'utf8');
43
+ const meta = JSON.parse(metaRaw);
44
+
45
+ runs.push({
46
+ runDirName: dirName,
47
+ runId: dirName,
48
+ timestamp: new Date(meta.timestamp || dirName.split('_')[0]),
49
+ meta,
50
+ snapshotPath: fs.existsSync(snapshotPath) ? snapshotPath : null,
51
+ snapshot: null // lazy-loaded
52
+ });
53
+ } catch (parseErr) {
54
+ // Skip unparseable runs
55
+ continue;
56
+ }
57
+ }
58
+ } catch (err) {
59
+ return [];
60
+ }
61
+
62
+ // Sort by timestamp descending, take recent N
63
+ runs.sort((a, b) => b.timestamp - a.timestamp);
64
+ return runs.slice(0, maxRuns);
65
+ }
66
+
67
+ /**
68
+ * Load snapshot data for a run if available
69
+ */
70
+ function loadSnapshot(run) {
71
+ if (run.snapshot) return run.snapshot;
72
+ if (!run.snapshotPath || !fs.existsSync(run.snapshotPath)) return null;
73
+
74
+ try {
75
+ const raw = fs.readFileSync(run.snapshotPath, 'utf8');
76
+ run.snapshot = JSON.parse(raw);
77
+ return run.snapshot;
78
+ } catch (err) {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Detect repeated skipped attempts
85
+ * If an attempt is SKIPPED in multiple runs, it's a pattern
86
+ */
87
+ function detectRepeatedSkippedAttempts(runs) {
88
+ const patterns = [];
89
+ const attemptSkipCounts = {}; // { attemptId: { count, runIds } }
90
+
91
+ for (const run of runs) {
92
+ const snapshot = loadSnapshot(run);
93
+ if (!snapshot || !snapshot.attempts) continue;
94
+
95
+ for (const attempt of snapshot.attempts) {
96
+ if (attempt.outcome === 'SKIPPED') {
97
+ if (!attemptSkipCounts[attempt.attemptId]) {
98
+ attemptSkipCounts[attempt.attemptId] = { count: 0, runIds: [] };
99
+ }
100
+ attemptSkipCounts[attempt.attemptId].count++;
101
+ attemptSkipCounts[attempt.attemptId].runIds.push(run.runId);
102
+ }
103
+ }
104
+ }
105
+
106
+ for (const [attemptId, data] of Object.entries(attemptSkipCounts)) {
107
+ if (data.count >= 2) {
108
+ patterns.push({
109
+ patternId: `repeated_skipped_${attemptId}`,
110
+ type: 'repeated_skipped_attempts',
111
+ summary: `Attempt "${attemptId}" was not executed in ${data.count} of the last ${runs.length} runs.`,
112
+ whyItMatters: `Skipped attempts leave critical user paths untested. Consider ensuring this attempt runs in every evaluation.`,
113
+ recommendedFocus: 'Coverage gap detected; this path has not been exercised.',
114
+ evidence: {
115
+ attemptId,
116
+ occurrences: data.count,
117
+ runIds: data.runIds,
118
+ basedOnRuns: runs.length
119
+ },
120
+ confidence: data.count >= 3 ? 'high' : 'medium',
121
+ limits: `Based on last ${Math.min(runs.length, 10)} runs. If you intentionally skip this attempt, ignore this pattern.`
122
+ });
123
+ }
124
+ }
125
+
126
+ return patterns;
127
+ }
128
+
129
+ /**
130
+ * Detect recurring friction on specific paths/attempts
131
+ * If same attempt shows FRICTION in 2+ runs, it's a pattern
132
+ */
133
+ function detectRecurringFriction(runs) {
134
+ const patterns = [];
135
+ const frictionCounts = {}; // { attemptId: { count, runIds, totalDuration } }
136
+
137
+ for (const run of runs) {
138
+ const snapshot = loadSnapshot(run);
139
+ if (!snapshot || !snapshot.attempts) continue;
140
+
141
+ for (const attempt of snapshot.attempts) {
142
+ if (attempt.outcome === 'FRICTION' || (attempt.friction && attempt.friction.isFriction)) {
143
+ if (!frictionCounts[attempt.attemptId]) {
144
+ frictionCounts[attempt.attemptId] = { count: 0, runIds: [], durations: [] };
145
+ }
146
+ frictionCounts[attempt.attemptId].count++;
147
+ frictionCounts[attempt.attemptId].runIds.push(run.runId);
148
+ frictionCounts[attempt.attemptId].durations.push(attempt.totalDurationMs || 0);
149
+ }
150
+ }
151
+ }
152
+
153
+ for (const [attemptId, data] of Object.entries(frictionCounts)) {
154
+ if (data.count >= 2) {
155
+ const avgDuration = data.durations.length > 0
156
+ ? Math.round(data.durations.reduce((a, b) => a + b, 0) / data.durations.length)
157
+ : 0;
158
+
159
+ patterns.push({
160
+ patternId: `recurring_friction_${attemptId}`,
161
+ type: 'recurring_friction',
162
+ summary: `Attempt "${attemptId}" showed friction in ${data.count} of the last ${runs.length} runs (avg ${avgDuration}ms).`,
163
+ whyItMatters: `Recurring friction signals friction is not random—there's a systematic issue (slow endpoint, unreliable element, poor UX). This harms user satisfaction and should be investigated.`,
164
+ recommendedFocus: 'User experience may be degrading on this path.',
165
+ evidence: {
166
+ attemptId,
167
+ occurrences: data.count,
168
+ runIds: data.runIds,
169
+ avgDurationMs: avgDuration,
170
+ basedOnRuns: runs.length
171
+ },
172
+ confidence: data.count >= 3 ? 'high' : 'medium',
173
+ limits: `Based on last ${Math.min(runs.length, 10)} runs. High variability in network or load may cause friction; consider examining environment factors.`
174
+ });
175
+ }
176
+ }
177
+
178
+ return patterns;
179
+ }
180
+
181
+ /**
182
+ * Detect confidence score degradation over time
183
+ * If confidence declining across last 3+ runs, user should investigate
184
+ */
185
+ function detectConfidenceDegradation(runs) {
186
+ const patterns = [];
187
+ const runsWithVerdicts = runs
188
+ .map(run => {
189
+ const snapshot = loadSnapshot(run);
190
+ const verdict = snapshot && snapshot.verdict;
191
+ return {
192
+ runId: run.runId,
193
+ timestamp: run.timestamp,
194
+ score: verdict && verdict.confidence ? verdict.confidence.score : null
195
+ };
196
+ })
197
+ .filter(r => r.score !== null)
198
+ .reverse(); // oldest first
199
+
200
+ if (runsWithVerdicts.length < 3) return patterns;
201
+
202
+ // Check if trend is declining (slope analysis)
203
+ const scores = runsWithVerdicts.map(r => r.score);
204
+ let isDecreasing = true;
205
+ for (let i = 1; i < scores.length; i++) {
206
+ if (scores[i] >= scores[i - 1]) {
207
+ isDecreasing = false;
208
+ break;
209
+ }
210
+ }
211
+
212
+ if (isDecreasing && runsWithVerdicts.length >= 3) {
213
+ const firstScore = scores[0];
214
+ const lastScore = scores[scores.length - 1];
215
+ const drop = firstScore - lastScore;
216
+
217
+ if (drop >= 0.2) { // significant drop (20+ percentage points)
218
+ patterns.push({
219
+ patternId: 'confidence_degradation',
220
+ type: 'confidence_degradation',
221
+ summary: `Confidence declined from ${(firstScore * 100).toFixed(0)}% to ${(lastScore * 100).toFixed(0)}% over ${runsWithVerdicts.length} runs.`,
222
+ whyItMatters: `Declining confidence indicates growing test failures or friction. Site quality may be degrading, or test coverage may be revealing previously hidden issues.`,
223
+ recommendedFocus: 'Overall quality signals are trending down across runs.',
224
+ evidence: {
225
+ runCount: runsWithVerdicts.length,
226
+ runIds: runsWithVerdicts.map(r => r.runId),
227
+ scores: runsWithVerdicts.map(r => r.score),
228
+ trend: 'declining'
229
+ },
230
+ confidence: drop >= 0.3 ? 'high' : 'medium',
231
+ limits: `Based on last ${runsWithVerdicts.length} runs with verdicts. Short-term fluctuations are normal; patterns become clearer with 5+ runs.`
232
+ });
233
+ }
234
+ }
235
+
236
+ return patterns;
237
+ }
238
+
239
+ /**
240
+ * Detect single-point-of-failure: one attempt fails consistently while others succeed
241
+ */
242
+ function detectSinglePointFailure(runs) {
243
+ const patterns = [];
244
+ const attemptOutcomes = {}; // { attemptId: { success, failure, friction } }
245
+
246
+ for (const run of runs) {
247
+ const snapshot = loadSnapshot(run);
248
+ if (!snapshot || !snapshot.attempts) continue;
249
+
250
+ for (const attempt of snapshot.attempts) {
251
+ if (!attemptOutcomes[attempt.attemptId]) {
252
+ attemptOutcomes[attempt.attemptId] = { success: 0, failure: 0, friction: 0, skipped: 0, runIds: [] };
253
+ }
254
+ const counts = attemptOutcomes[attempt.attemptId];
255
+ if (attempt.outcome === 'SKIPPED') counts.skipped++;
256
+ else if (attempt.outcome === 'FAILURE') counts.failure++;
257
+ else if (attempt.outcome === 'FRICTION') counts.friction++;
258
+ else counts.success++;
259
+ counts.runIds.push(run.runId);
260
+ }
261
+ }
262
+
263
+ // Find attempts that fail in most runs while others succeed
264
+ const attemptResults = [];
265
+ for (const [attemptId, counts] of Object.entries(attemptOutcomes)) {
266
+ const executed = counts.success + counts.failure + counts.friction;
267
+ if (executed >= 2) {
268
+ const failureRate = executed > 0 ? counts.failure / executed : 0;
269
+ attemptResults.push({
270
+ attemptId,
271
+ failureRate,
272
+ failureCount: counts.failure,
273
+ executedCount: executed,
274
+ ...counts
275
+ });
276
+ }
277
+ }
278
+
279
+ // Filter to attempts with high failure rate while others succeed
280
+ const avgFailureRate = attemptResults.length > 0
281
+ ? attemptResults.reduce((sum, a) => sum + a.failureRate, 0) / attemptResults.length
282
+ : 0;
283
+
284
+ // Detect outliers: 2+ failures AND (rate >= 0.6 OR significantly higher than average)
285
+ const outliers = attemptResults.filter(a =>
286
+ a.failureCount >= 2 && (a.failureRate >= 0.6 || a.failureRate > avgFailureRate + 0.3)
287
+ );
288
+
289
+ for (const outlier of outliers) {
290
+ patterns.push({
291
+ patternId: `single_point_failure_${outlier.attemptId}`,
292
+ type: 'single_point_failure',
293
+ summary: `Attempt "${outlier.attemptId}" did not complete in ${outlier.failure} of ${outlier.executedCount} runs—much higher than other attempts.`,
294
+ whyItMatters: `This attempt is a bottleneck. It's preventing users from reaching critical functionality. Prioritize fixing whatever blocks this path.`,
295
+ recommendedFocus: 'This path is a bottleneck and blocks user progress.',
296
+ evidence: {
297
+ attemptId: outlier.attemptId,
298
+ failureCount: outlier.failure,
299
+ executedCount: outlier.executedCount,
300
+ failureRate: (outlier.failureRate * 100).toFixed(0) + '%',
301
+ runIds: outlier.runIds.slice(0, 5) // show first 5
302
+ },
303
+ confidence: outlier.executedCount >= 4 ? 'high' : 'medium',
304
+ limits: `Based on ${outlier.executedCount} executions across last ${runs.length} runs. If this is intentionally experimental, consider removing or documenting it.`
305
+ });
306
+ }
307
+
308
+ return patterns;
309
+ }
310
+
311
+ /**
312
+ * Main: Analyze all patterns for a given site
313
+ * @param {string} artifactsDir - Path to artifacts directory
314
+ * @param {string} siteSlug - Site slug (e.g., example-com)
315
+ * @param {number} maxRuns - Max runs to consider (default 10)
316
+ * @returns {array} - Array of pattern objects
317
+ */
318
+ function analyzePatterns(artifactsDir, siteSlug, maxRuns = 10) {
319
+ const runs = loadRecentRunsForSite(artifactsDir, siteSlug, maxRuns);
320
+
321
+ if (runs.length < 2) {
322
+ // Need at least 2 runs to detect patterns
323
+ return [];
324
+ }
325
+
326
+ const allPatterns = [
327
+ ...detectRepeatedSkippedAttempts(runs),
328
+ ...detectRecurringFriction(runs),
329
+ ...detectConfidenceDegradation(runs),
330
+ ...detectSinglePointFailure(runs)
331
+ ];
332
+
333
+ // Sort by confidence level and type
334
+ const confidenceRank = { high: 3, medium: 2, low: 1 };
335
+ allPatterns.sort((a, b) => confidenceRank[b.confidence] - confidenceRank[a.confidence]);
336
+
337
+ return allPatterns;
338
+ }
339
+
340
+ module.exports = {
341
+ analyzePatterns,
342
+ loadRecentRunsForSite,
343
+ loadSnapshot,
344
+ detectRepeatedSkippedAttempts,
345
+ detectRecurringFriction,
346
+ detectConfidenceDegradation,
347
+ detectSinglePointFailure
348
+ };
@@ -51,12 +51,24 @@ function loadPolicy(policyPath = null) {
51
51
  CRITICAL: 0, // Fail if any CRITICAL visual diffs
52
52
  WARNING: 999, // Warn if more than 999 WARNING visual diffs
53
53
  maxDiffPercent: 25 // Fail if visual change > 25% of page
54
+ },
55
+ // Coverage and evidence expectations
56
+ coverage: {
57
+ failOnGap: true,
58
+ warnOnGap: false
59
+ },
60
+ evidence: {
61
+ minCompleteness: 1.0,
62
+ minIntegrity: 0.9,
63
+ requireScreenshots: false,
64
+ requireTraces: false
54
65
  }
55
66
  };
56
67
 
57
68
  if (!policyPath) {
58
- // Try to find guardian.policy.json in current directory or .odavl-guardian/
69
+ // Try to find guardian.policy.json in current directory, config/, or .odavl-guardian/
59
70
  const candidates = [
71
+ 'config/guardian.policy.json',
60
72
  'guardian.policy.json',
61
73
  '.odavl-guardian/policy.json',
62
74
  '.odavl-guardian/guardian.policy.json'
@@ -90,11 +102,31 @@ function loadPolicy(policyPath = null) {
90
102
  * Evaluate snapshot against policy
91
103
  * Returns { passed: boolean, exitCode: 0|1|2, reasons: string[], summary: string }
92
104
  */
93
- function evaluatePolicy(snapshot, policy) {
105
+ function evaluatePolicy(snapshot, policy, signals = {}) {
94
106
  const effectivePolicy = policy || loadPolicy();
95
107
  const reasons = [];
96
108
  let exitCode = 0;
97
109
 
110
+ // Check for INSUFFICIENT_EVIDENCE verdict - always exit 2 (WARN)
111
+ const verdict = snapshot.verdict || {};
112
+ if (verdict.verdict === 'INSUFFICIENT_EVIDENCE') {
113
+ reasons.push('No meaningful attempts executed; element discovery failed on uninstrumented site or all journeys not applicable');
114
+ exitCode = 2; // WARN - cannot make a confident decision
115
+ return {
116
+ passed: false,
117
+ exitCode,
118
+ reasons,
119
+ summary: '⚠️ Policy evaluation INSUFFICIENT_EVIDENCE (exit code 2)',
120
+ counts: {
121
+ critical: 0,
122
+ warning: 0,
123
+ info: 0,
124
+ softFailures: 0,
125
+ totalRisk: 0
126
+ }
127
+ };
128
+ }
129
+
98
130
  // Extract market impact summary (Phase 3)
99
131
  const marketImpact = snapshot.marketImpactSummary || {};
100
132
  const criticalCount = marketImpact.countsBySeverity?.CRITICAL || 0;
@@ -102,6 +134,13 @@ function evaluatePolicy(snapshot, policy) {
102
134
  const infoCount = marketImpact.countsBySeverity?.INFO || 0;
103
135
  const totalRisk = marketImpact.totalRiskCount || 0;
104
136
 
137
+ // Coverage and evidence signals
138
+ const coverage = signals.coverage || { gaps: 0, total: 0, executed: 0 };
139
+ const evidenceMetrics = signals.evidence?.metrics || { completeness: 0, integrity: 0 };
140
+ const missingScreenshots = signals.evidence?.missingScreenshots || false;
141
+ const missingTraces = signals.evidence?.missingTraces || false;
142
+ const runtimeSignals = Array.isArray(signals.runtimeSignals) ? signals.runtimeSignals : [];
143
+
105
144
  // Extract soft failures (Phase 2)
106
145
  const softFailureCount = snapshot.attempts?.reduce((sum, attempt) => {
107
146
  return sum + (attempt.softFailureCount || 0);
@@ -222,6 +261,44 @@ function evaluatePolicy(snapshot, policy) {
222
261
  exitCode = 2;
223
262
  }
224
263
 
264
+ // Coverage gaps (attempts skipped/not applicable)
265
+ if (!exitCode && effectivePolicy.coverage) {
266
+ if (effectivePolicy.coverage.failOnGap && coverage.gaps > 0) {
267
+ reasons.push(`Coverage gaps detected: ${coverage.gaps} of ${coverage.total || 'n/a'} attempts not executed`);
268
+ exitCode = 1;
269
+ } else if (effectivePolicy.coverage.warnOnGap && coverage.gaps > 0) {
270
+ reasons.push(`Coverage gaps detected: ${coverage.gaps} of ${coverage.total || 'n/a'} attempts not executed`);
271
+ exitCode = 2;
272
+ }
273
+ }
274
+
275
+ // Evidence completeness/integrity
276
+ if (!exitCode && effectivePolicy.evidence) {
277
+ if (evidenceMetrics.completeness < (effectivePolicy.evidence.minCompleteness ?? 1)) {
278
+ reasons.push(`Evidence completeness ${evidenceMetrics.completeness.toFixed(2)} below policy minimum ${(effectivePolicy.evidence.minCompleteness ?? 1)}`);
279
+ exitCode = exitCode || (effectivePolicy.evidence.minCompleteness >= 1 ? 1 : 2);
280
+ }
281
+ if (evidenceMetrics.integrity < (effectivePolicy.evidence.minIntegrity ?? 0)) {
282
+ reasons.push(`Evidence integrity ${evidenceMetrics.integrity.toFixed(2)} below policy minimum ${(effectivePolicy.evidence.minIntegrity ?? 0)}`);
283
+ exitCode = exitCode || 2;
284
+ }
285
+ if (effectivePolicy.evidence.requireScreenshots && missingScreenshots) {
286
+ reasons.push('Screenshots disabled but required by policy');
287
+ exitCode = 1;
288
+ }
289
+ if (effectivePolicy.evidence.requireTraces && missingTraces) {
290
+ reasons.push('Network traces disabled but required by policy');
291
+ exitCode = 1;
292
+ }
293
+ }
294
+
295
+ // Runtime signals (crawl/discovery/system)
296
+ if (!exitCode && runtimeSignals.length > 0) {
297
+ const desc = runtimeSignals.map(s => s.description).slice(0, 3).join('; ');
298
+ reasons.push(`Runtime issues detected: ${desc}`);
299
+ exitCode = 2;
300
+ }
301
+
225
302
  // Evaluate baseline requirement
226
303
  if (!exitCode && effectivePolicy.requireBaseline) {
227
304
  const baseline = snapshot.baseline || {};
@@ -287,7 +364,7 @@ function formatPolicyOutput(evaluation) {
287
364
  /**
288
365
  * Create a default policy file
289
366
  */
290
- function createDefaultPolicyFile(outputPath = 'guardian.policy.json') {
367
+ function createDefaultPolicyFile(outputPath = 'config/guardian.policy.json') {
291
368
  const defaultPolicy = {
292
369
  failOnSeverity: 'CRITICAL',
293
370
  maxWarnings: 0,
@@ -13,18 +13,16 @@ const path = require('path');
13
13
  * @returns {object|null} Policy object or null if not found
14
14
  */
15
15
  function loadPreset(presetName) {
16
- const validPresets = ['startup', 'saas', 'enterprise'];
16
+ const validPresets = ['startup', 'saas', 'enterprise', 'landing-demo'];
17
17
 
18
18
  if (!presetName || !validPresets.includes(presetName.toLowerCase())) {
19
- console.warn(`⚠️ Invalid preset: ${presetName}. Valid presets: ${validPresets.join(', ')}`);
20
- return null;
19
+ throw new Error(`Invalid preset: ${presetName}. Valid presets: ${validPresets.join(', ')}`);
21
20
  }
22
21
 
23
22
  const presetPath = path.join(__dirname, '../../policies', `${presetName.toLowerCase()}.json`);
24
23
 
25
24
  if (!fs.existsSync(presetPath)) {
26
- console.error(`⚠️ Preset file not found: ${presetPath}`);
27
- return null;
25
+ throw new Error(`Preset file not found: ${presetPath}`);
28
26
  }
29
27
 
30
28
  try {
@@ -67,7 +65,12 @@ function parsePolicyOption(policyOption) {
67
65
  // Check if it's a preset
68
66
  if (optionStr.startsWith('preset:')) {
69
67
  const presetName = optionStr.substring(7); // Remove 'preset:' prefix
70
- return loadPreset(presetName);
68
+ try {
69
+ return loadPreset(presetName);
70
+ } catch (err) {
71
+ console.error(`Error: ${err.message}`);
72
+ process.exit(1);
73
+ }
71
74
  }
72
75
 
73
76
  // Otherwise, treat as file path