@odavl/guardian 0.1.0-rc1 → 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 (101) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +155 -97
  3. package/bin/guardian.js +1544 -55
  4. package/config/README.md +59 -0
  5. package/config/profiles/landing-demo.yaml +16 -0
  6. package/package.json +26 -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 +587 -12
  18. package/src/guardian/attempt-registry.js +42 -1
  19. package/src/guardian/attempt-relevance.js +106 -0
  20. package/src/guardian/attempt.js +85 -39
  21. package/src/guardian/attempts-filter.js +63 -0
  22. package/src/guardian/baseline.js +50 -8
  23. package/src/guardian/breakage-intelligence.js +1 -0
  24. package/src/guardian/browser-pool.js +131 -0
  25. package/src/guardian/browser.js +28 -1
  26. package/src/guardian/ci-cli.js +121 -0
  27. package/src/guardian/ci-mode.js +15 -0
  28. package/src/guardian/ci-output.js +38 -0
  29. package/src/guardian/cli-summary.js +167 -67
  30. package/src/guardian/config-loader.js +162 -0
  31. package/src/guardian/data-guardian-detector.js +189 -0
  32. package/src/guardian/detection-layers.js +271 -0
  33. package/src/guardian/drift-detector.js +100 -0
  34. package/src/guardian/enhanced-html-reporter.js +221 -4
  35. package/src/guardian/env-guard.js +127 -0
  36. package/src/guardian/failure-intelligence.js +173 -0
  37. package/src/guardian/first-run-profile.js +89 -0
  38. package/src/guardian/first-run.js +54 -0
  39. package/src/guardian/flag-validator.js +111 -0
  40. package/src/guardian/flow-executor.js +309 -44
  41. package/src/guardian/html-reporter.js +2 -0
  42. package/src/guardian/human-reporter.js +431 -0
  43. package/src/guardian/index.js +22 -19
  44. package/src/guardian/init-command.js +9 -5
  45. package/src/guardian/intent-detector.js +146 -0
  46. package/src/guardian/journey-definitions.js +132 -0
  47. package/src/guardian/journey-scan-cli.js +145 -0
  48. package/src/guardian/journey-scanner.js +583 -0
  49. package/src/guardian/junit-reporter.js +18 -1
  50. package/src/guardian/language-detection.js +99 -0
  51. package/src/guardian/live-cli.js +95 -0
  52. package/src/guardian/live-scheduler-runner.js +137 -0
  53. package/src/guardian/live-scheduler.js +146 -0
  54. package/src/guardian/market-reporter.js +357 -82
  55. package/src/guardian/parallel-executor.js +116 -0
  56. package/src/guardian/pattern-analyzer.js +348 -0
  57. package/src/guardian/policy.js +80 -3
  58. package/src/guardian/prerequisite-checker.js +101 -0
  59. package/src/guardian/preset-loader.js +27 -18
  60. package/src/guardian/profile-loader.js +96 -0
  61. package/src/guardian/reality.js +1612 -115
  62. package/src/guardian/reporter.js +27 -41
  63. package/src/guardian/run-artifacts.js +212 -0
  64. package/src/guardian/run-cleanup.js +207 -0
  65. package/src/guardian/run-latest.js +90 -0
  66. package/src/guardian/run-list.js +211 -0
  67. package/src/guardian/run-summary.js +20 -0
  68. package/src/guardian/scan-presets.js +100 -11
  69. package/src/guardian/selector-fallbacks.js +394 -0
  70. package/src/guardian/semantic-contact-detection.js +255 -0
  71. package/src/guardian/semantic-contact-finder.js +201 -0
  72. package/src/guardian/semantic-targets.js +234 -0
  73. package/src/guardian/site-introspection.js +257 -0
  74. package/src/guardian/smoke.js +258 -0
  75. package/src/guardian/snapshot-schema.js +25 -1
  76. package/src/guardian/snapshot.js +69 -3
  77. package/src/guardian/stability-scorer.js +169 -0
  78. package/src/guardian/success-evaluator.js +214 -0
  79. package/src/guardian/template-command.js +184 -0
  80. package/src/guardian/text-formatters.js +426 -0
  81. package/src/guardian/timeout-profiles.js +57 -0
  82. package/src/guardian/verdict.js +320 -0
  83. package/src/guardian/verdicts.js +74 -0
  84. package/src/guardian/wait-for-outcome.js +120 -0
  85. package/src/guardian/watch-runner.js +181 -0
  86. package/src/payments/stripe-checkout.js +169 -0
  87. package/src/plans/plan-definitions.js +148 -0
  88. package/src/plans/plan-manager.js +211 -0
  89. package/src/plans/usage-tracker.js +210 -0
  90. package/src/recipes/recipe-engine.js +188 -0
  91. package/src/recipes/recipe-failure-analysis.js +159 -0
  92. package/src/recipes/recipe-registry.js +134 -0
  93. package/src/recipes/recipe-runtime.js +507 -0
  94. package/src/recipes/recipe-store.js +410 -0
  95. package/guardian-contract-v1.md +0 -149
  96. /package/{guardian.config.json → config/guardian.config.json} +0 -0
  97. /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
  98. /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
  99. /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
  100. /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
  101. /package/{guardian.profile.saas.yaml → config/profiles/saas.yaml} +0 -0
@@ -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,121 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const { scanJourney } = require('./journey-scanner');
4
+ const { detectSiteIntent, selectJourneyByIntent } = require('./intent-detector');
5
+ const { buildBaselineFromJourneyResult, compareAgainstBaseline, classifySeverity } = require('./drift-detector');
6
+ const { shouldEmitAlert } = require('./alert-ledger');
7
+
8
+ /**
9
+ * Run CI gate scan:
10
+ * - Single journey run
11
+ * - Compare against baseline if exists
12
+ * - Apply alert dedup + cooldown
13
+ * - Generate ci-result.json
14
+ * - Exit with stable codes
15
+ */
16
+ async function runCIGate(config) {
17
+ const { baseUrl, artifactsDir, headless, timeout, preset, presetProvided } = config;
18
+
19
+ const outDir = path.resolve(artifactsDir, 'ci');
20
+ fs.mkdirSync(outDir, { recursive: true });
21
+
22
+ console.log(`[CI] Scanning ${baseUrl}...`);
23
+
24
+ let selectedPreset = preset;
25
+ let siteIntent = null;
26
+
27
+ // Auto-detect intent if no preset provided
28
+ if (!presetProvided) {
29
+ console.log('[CI] Auto-detecting site intent...');
30
+ try {
31
+ siteIntent = await detectSiteIntent(baseUrl, { headless, timeout });
32
+ selectedPreset = selectJourneyByIntent(siteIntent);
33
+ console.log(`[CI] Detected intent: ${siteIntent.type}, selected preset: ${selectedPreset}`);
34
+ } catch (err) {
35
+ console.error('[CI] Intent detection failed:', err.message);
36
+ selectedPreset = 'saas';
37
+ }
38
+ }
39
+
40
+ // Run journey
41
+ let result;
42
+ try {
43
+ result = await scanJourney({ baseUrl, preset: selectedPreset, headless, timeout });
44
+ } catch (err) {
45
+ console.error('[CI] Journey scan failed:', err.message);
46
+ const ciResult = {
47
+ decision: 'DO_NOT_LAUNCH',
48
+ severity: 'CRITICAL',
49
+ driftDetected: false,
50
+ goalReached: false,
51
+ intent: siteIntent ? siteIntent.type : 'unknown',
52
+ reasons: ['Journey scan failed: ' + err.message],
53
+ timestamp: new Date().toISOString()
54
+ };
55
+ fs.writeFileSync(path.join(outDir, 'ci-result.json'), JSON.stringify(ciResult, null, 2));
56
+ return 2;
57
+ }
58
+
59
+ // Check for baseline
60
+ const baselinePath = path.resolve(artifactsDir, 'baseline.json');
61
+ let driftInfo = null;
62
+ let severity = 'WARN';
63
+
64
+ if (fs.existsSync(baselinePath)) {
65
+ console.log('[CI] Comparing against baseline...');
66
+ const baselineData = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
67
+ driftInfo = compareAgainstBaseline(result, baselineData);
68
+
69
+ if (driftInfo.hasDrift) {
70
+ severity = classifySeverity(driftInfo, result);
71
+ console.log(`[CI] Drift detected (${severity}): ${driftInfo.reasons.join(', ')}`);
72
+
73
+ // Check alert dedup and cooldown
74
+ const alertDecision = shouldEmitAlert(driftInfo.reasons, severity, artifactsDir);
75
+ if (!alertDecision.emit) {
76
+ console.log(`[CI] Alert suppressed: ${alertDecision.reason}`);
77
+ // Generate CI result showing suppression
78
+ const ciResult = {
79
+ decision: result.decision,
80
+ severity,
81
+ driftDetected: true,
82
+ goalReached: result.goalReached || false,
83
+ intent: result.intent || 'unknown',
84
+ reasons: driftInfo.reasons,
85
+ alertSuppressed: true,
86
+ suppressionReason: alertDecision.reason,
87
+ timestamp: new Date().toISOString()
88
+ };
89
+ fs.writeFileSync(path.join(outDir, 'ci-result.json'), JSON.stringify(ciResult, null, 2));
90
+ return severity === 'CRITICAL' ? 4 : 0;
91
+ }
92
+ } else {
93
+ console.log('[CI] No drift detected');
94
+ }
95
+ } else {
96
+ console.log('[CI] No baseline found, capturing new baseline...');
97
+ const baseline = buildBaselineFromJourneyResult(result);
98
+ fs.writeFileSync(baselinePath, JSON.stringify(baseline, null, 2));
99
+ }
100
+
101
+ // Generate ci-result.json
102
+ const ciResult = {
103
+ decision: result.decision,
104
+ severity: driftInfo ? severity : (result.decision === 'DO_NOT_LAUNCH' ? 'CRITICAL' : 'WARN'),
105
+ driftDetected: driftInfo ? driftInfo.hasDrift : false,
106
+ goalReached: result.goalReached || false,
107
+ intent: result.intent || 'unknown',
108
+ reasons: driftInfo ? driftInfo.reasons : [],
109
+ timestamp: new Date().toISOString()
110
+ };
111
+
112
+ fs.writeFileSync(path.join(outDir, 'ci-result.json'), JSON.stringify(ciResult, null, 2));
113
+ console.log(`[CI] Result: ${ciResult.decision} (${ciResult.severity})`);
114
+
115
+ // Exit code based on decision and severity
116
+ if (ciResult.decision === 'DO_NOT_LAUNCH') return 2;
117
+ if (ciResult.decision === 'RISK' || ciResult.severity === 'WARN') return 1;
118
+ return 0;
119
+ }
120
+
121
+ module.exports = { runCIGate };
@@ -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,38 @@
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
+ const verdict = exitCode === 0 ? 'OBSERVED' : exitCode === 1 ? 'PARTIAL' : 'INSUFFICIENT_DATA';
21
+ lines.push(`Result: ${verdict}`);
22
+
23
+ if (exitCode !== 0) {
24
+ lines.push('Observed issues:');
25
+ const troubled = flowResults.filter(f => f.outcome === 'FAILURE' || f.outcome === 'FRICTION');
26
+ troubled.slice(0, maxReasons).forEach(flow => {
27
+ const reason = pickReason(flow);
28
+ lines.push(` - ${flow.flowName || flow.flowId || 'flow'}: ${flow.outcome} | ${reason}`);
29
+ });
30
+ if (troubled.length > maxReasons) {
31
+ lines.push(` - … ${troubled.length - maxReasons} more issues`);
32
+ }
33
+ }
34
+
35
+ return lines.join('\n');
36
+ }
37
+
38
+ module.exports = { formatCiSummary, deriveBaselineVerdict };
@@ -1,82 +1,115 @@
1
1
  /**
2
- * CLI Summary Module
3
- *
4
- * Generate human-friendly CLI summaries at the end of Guardian runs.
5
- * Shows critical info, top risks, and actionable next steps.
6
- */
7
-
8
- /**
9
- * Generate final CLI summary
10
- * @param {object} snapshot - Guardian snapshot
11
- * @param {object} policyEval - Policy evaluation result
12
- * @returns {string} Formatted CLI summary
13
- */
14
- function generateCliSummary(snapshot, policyEval) {
15
- if (!snapshot) {
16
- return 'No snapshot data available.';
17
- }
18
-
2
+ // STRICT CLI SUMMARY: factual, artifact-traceable lines only
3
+ function generateCliSummary(snapshot, policyEval, baselineCheckResult, options = {}) {
4
+ if (!snapshot) return 'No snapshot data available.';
19
5
  const meta = snapshot.meta || {};
20
- const marketImpact = snapshot.marketImpactSummary || {};
21
- const counts = marketImpact.countsBySeverity || { CRITICAL: 0, WARNING: 0, INFO: 0 };
22
- const topRisks = marketImpact.topRisks || [];
23
- const attempts = snapshot.attempts || [];
24
- const discovery = snapshot.discovery || {};
6
+ const coverage = snapshot.coverage || {};
7
+ const counts = coverage.counts || {};
8
+ const evidence = snapshot.evidenceMetrics || {};
9
+ const resolved = snapshot.resolved || {};
25
10
 
26
11
  let output = '\n';
27
12
  output += '━'.repeat(70) + '\n';
28
13
  output += '🛡️ Guardian Reality Summary\n';
29
14
  output += '━'.repeat(70) + '\n\n';
30
15
 
31
- // Target URL
32
16
  output += `Target: ${meta.url || 'unknown'}\n`;
33
17
  output += `Run ID: ${meta.runId || 'unknown'}\n\n`;
34
18
 
35
- // Risk Counts
36
- output += '📊 Risk Summary:\n';
37
- output += ` 🚨 CRITICAL: ${counts.CRITICAL}`;
38
- if (counts.CRITICAL > 0) output += ' (Revenue impact)';
39
- output += '\n';
40
- output += ` ⚠️ WARNING: ${counts.WARNING}`;
41
- if (counts.WARNING > 0) output += ' (User experience)';
42
- output += '\n';
43
- output += ` ℹ️ INFO: ${counts.INFO}`;
44
- if (counts.INFO > 0) output += ' (Minor issues)';
45
- output += '\n\n';
46
-
47
- // Top Risks (up to 3)
48
- if (topRisks.length > 0) {
49
- // Preserve legacy label for backward compatibility
50
- output += '🔥 Top Risk:\n';
51
- // New: compact list of top 3 issues
52
- output += ' (Top Issues)\n';
53
- topRisks.slice(0, 3).forEach((risk, idx) => {
54
- output += ` ${idx + 1}. ${risk.humanReadableReason || 'Unknown issue'}\n`;
55
- output += ` Impact: ${risk.impactScore || 0} (${risk.category || 'UNKNOWN'}) | Severity: ${risk.severity || 'INFO'}\n`;
56
- });
57
-
58
- // Link evidence if available for the top-most
59
- const topRisk = topRisks[0];
60
- const relatedAttempt = attempts.find(a =>
61
- a.attemptId === topRisk.attemptId ||
62
- (topRisk.humanReadableReason || '').toLowerCase().includes(a.attemptName?.toLowerCase() || '')
63
- );
64
- if (relatedAttempt && relatedAttempt.evidence) {
65
- output += ` Evidence: ${relatedAttempt.evidence.screenshotPath || 'See report'}\n`;
19
+ const pe = snapshot.policyEvaluation || {};
20
+ output += `Policy Verdict: ${meta.result || (pe.passed ? 'PASSED' : pe.exitCode === 2 ? 'WARN' : 'FAILED')}\n`;
21
+ output += `Exit Code: ${pe.exitCode ?? 'unknown'}\n`;
22
+
23
+ const planned = coverage.total ?? (resolved.coverage?.total) ?? 'unknown';
24
+ const executed = counts.executedCount ?? (resolved.coverage?.executedCount) ?? coverage.executed ?? 'unknown';
25
+ output += `Executed / Planned: ${executed} / ${planned}\n`;
26
+
27
+ const completeness = evidence.completeness ?? resolved.evidenceMetrics?.completeness ?? 'unknown';
28
+ const integrity = evidence.integrity ?? resolved.evidenceMetrics?.integrity ?? 'unknown';
29
+ output += `Coverage Completeness: ${typeof completeness === 'number' ? completeness.toFixed(4) : completeness}\n`;
30
+ output += `Evidence Integrity: ${typeof integrity === 'number' ? integrity.toFixed(4) : integrity}\n`;
31
+
32
+ if (meta.attestation?.hash) {
33
+ output += `Attestation: ${meta.attestation.hash}\n`;
34
+ // STRICT CLI SUMMARY: factual, artifact-traceable lines only
35
+ function generateCliSummary(snapshot, policyEval, baselineCheckResult, options = {}) {
36
+ if (!snapshot) return 'No snapshot data available.';
37
+ const meta = snapshot.meta || {};
38
+ const coverage = snapshot.coverage || {};
39
+ const counts = coverage.counts || {};
40
+ const evidence = snapshot.evidenceMetrics || {};
41
+ const resolved = snapshot.resolved || {};
42
+
43
+ let output = '\n';
44
+ output += '━'.repeat(70) + '\n';
45
+ output += '🛡️ Guardian Reality Summary\n';
46
+ output += ''.repeat(70) + '\n\n';
47
+
48
+ output += `Target: ${meta.url || 'unknown'}\n`;
49
+ output += `Run ID: ${meta.runId || 'unknown'}\n\n`;
50
+
51
+ const pe = snapshot.policyEvaluation || {};
52
+ output += `Policy Verdict: ${meta.result || (pe.passed ? 'PASSED' : pe.exitCode === 2 ? 'WARN' : 'FAILED')}\n`;
53
+ output += `Exit Code: ${pe.exitCode ?? 'unknown'}\n`;
54
+
55
+ const planned = coverage.total ?? (resolved.coverage?.total) ?? 'unknown';
56
+ const executed = counts.executedCount ?? (resolved.coverage?.executedCount) ?? coverage.executed ?? 'unknown';
57
+ output += `Executed / Planned: ${executed} / ${planned}\n`;
58
+
59
+ const completeness = evidence.completeness ?? resolved.evidenceMetrics?.completeness ?? 'unknown';
60
+ const integrity = evidence.integrity ?? resolved.evidenceMetrics?.integrity ?? 'unknown';
61
+ output += `Coverage Completeness: ${typeof completeness === 'number' ? completeness.toFixed(4) : completeness}\n`;
62
+ output += `Evidence Integrity: ${typeof integrity === 'number' ? integrity.toFixed(4) : integrity}\n`;
63
+
64
+ if (meta.attestation?.hash) {
65
+ output += `Attestation: ${meta.attestation.hash}\n`;
66
66
  }
67
- output += '\n';
68
- }
69
67
 
70
- // Attempt Summary
71
- const successfulAttempts = attempts.filter(a => a.outcome === 'SUCCESS').length;
72
- const totalAttempts = attempts.length;
73
- if (totalAttempts > 0) {
74
- output += '🎯 Attempts:\n';
75
- output += ` ${successfulAttempts}/${totalAttempts} successful`;
76
- if (successfulAttempts < totalAttempts) {
77
- output += ` (${totalAttempts - successfulAttempts} failed)`;
68
+ // Audit Summary
69
+ const executedAttempts = (snapshot.attempts || []).filter(a => a.executed).map(a => a.attemptId);
70
+ output += '\nAudit Summary:\n';
71
+ output += ` Tested (${executedAttempts.length}): ${executedAttempts.join(', ') || 'none'}\n`;
72
+ const skippedDisabled = (coverage.skippedDisabledByPreset || []).map(s => s.attempt);
73
+ const skippedUserFiltered = (coverage.skippedUserFiltered || []).map(s => s.attempt);
74
+ const skippedNotApplicable = (coverage.skippedNotApplicable || []).map(s => s.attempt);
75
+ const skippedMissing = (coverage.skippedMissing || []).map(s => s.attempt);
76
+ output += ` Not Tested — DisabledByPreset (${skippedDisabled.length}): ${skippedDisabled.join(', ') || 'none'}\n`;
77
+ output += ` Not Tested — UserFiltered (${skippedUserFiltered.length}): ${skippedUserFiltered.join(', ') || 'none'}\n`;
78
+ output += ` Not Tested — NotApplicable (${skippedNotApplicable.length}): ${skippedNotApplicable.join(', ') || 'none'}\n`;
79
+ output += ` Not Tested — Missing (${skippedMissing.length}): ${skippedMissing.join(', ') || 'none'}\n`;
80
+
81
+ const reasons = Array.isArray(pe.reasons) ? pe.reasons : [];
82
+ if (reasons.length > 0) {
83
+ output += '\nPolicy Reasons:\n';
84
+ reasons.forEach(r => {
85
+ if (typeof r === 'string') output += ` • ${r}\n`; else if (r.message) output += ` • ${r.message}\n`; else output += ` • ${JSON.stringify(r)}\n`;
86
+ });
78
87
  }
79
- output += '\n\n';
88
+
89
+ output += '\n📁 Full report: ' + (meta.runId ? `artifacts/${meta.runId}/` : 'See artifacts/') + '\n\n';
90
+ output += '━'.repeat(70) + '\n';
91
+ return output;
92
+ }
93
+ if (!primary) return null;
94
+ try { const p = new URL(primary.url); return `request: ${primary.method} ${p.pathname} → ${primary.status}`; }
95
+ catch { return `request: ${primary.method} ${primary.url} → ${primary.status}`; }
96
+ })();
97
+ const navLine = ev.urlChanged ? (() => {
98
+ try { const from = new URL(snapshot.meta.url).pathname; const to = ''; return `navigation: changed`; }
99
+ catch { return `navigation: changed`; }
100
+ })() : null;
101
+ const formStates = [];
102
+ if (ev.formCleared) formStates.push('cleared');
103
+ if (ev.formDisabled) formStates.push('disabled');
104
+ if (ev.formDisappeared) formStates.push('disappeared');
105
+ const formLine = formStates.length ? `form: ${formStates.join(', ')}` : null;
106
+ const evidenceLines = [reqLine, navLine, formLine].filter(Boolean);
107
+ if (evidenceLines.length) {
108
+ output += ' Evidence:\n';
109
+ evidenceLines.slice(0, 2).forEach(line => { output += ` - ${line}\n`; });
110
+ }
111
+ });
112
+ output += '\n';
80
113
  }
81
114
 
82
115
  // Discovery Summary
@@ -104,6 +137,73 @@ function generateCliSummary(snapshot, policyEval) {
104
137
  output += ` Exit code: ${policyEval.exitCode || 0}\n\n`;
105
138
  }
106
139
 
140
+ // Baseline Comparison (Phase 3.2)
141
+ if (baselineCheckResult) {
142
+ const verdict = baselineCheckResult.overallRegressionVerdict || 'NO_BASELINE';
143
+
144
+ if (verdict === 'NO_BASELINE') {
145
+ output += '📊 Baseline Comparison: not found (no comparison)\n\n';
146
+ } else if (verdict === 'BASELINE_UNUSABLE') {
147
+ output += '📊 Baseline Comparison: unusable (skipped)\n\n';
148
+ } else {
149
+ const emoji = verdict === 'NO_REGRESSION' ? '✅' :
150
+ verdict === 'REGRESSION_FRICTION' ? '🟡' :
151
+ '🔴';
152
+
153
+ output += '📊 Baseline Comparison:\n';
154
+ output += ` ${emoji} ${verdict.replace(/_/g, ' ')}\n`;
155
+
156
+ // Show per-attempt changes
157
+ const comparisons = baselineCheckResult.comparisons || [];
158
+ const regressions = comparisons.filter(c => c.regressionType !== 'NO_REGRESSION');
159
+ const improvements = comparisons.filter(c => c.improvements && c.improvements.length > 0);
160
+
161
+ if (regressions.length > 0) {
162
+ output += ' \n';
163
+ output += ' Regressions detected:\n';
164
+ regressions.slice(0, 3).forEach(r => {
165
+ const label = r.attemptId || 'unknown';
166
+ const type = r.regressionType.replace(/_/g, ' ');
167
+ const reasons = r.regressionReasons.slice(0, 1).join('; ') || 'See report';
168
+ output += ` • ${label}: ${type}\n`;
169
+ output += ` ${reasons}\n`;
170
+ });
171
+ if (regressions.length > 3) {
172
+ output += ` ... and ${regressions.length - 3} more regressions\n`;
173
+ }
174
+ }
175
+
176
+ if (improvements.length > 0) {
177
+ output += ' \n';
178
+ output += ' Improvements detected:\n';
179
+ improvements.slice(0, 3).forEach(i => {
180
+ const label = i.attemptId || 'unknown';
181
+ const improvementText = i.improvements.slice(0, 1).join('; ') || 'Improved';
182
+ output += ` • ${label}: ${improvementText}\n`;
183
+ });
184
+ if (improvements.length > 3) {
185
+ output += ` ... and ${improvements.length - 3} more improvements\n`;
186
+ }
187
+ }
188
+
189
+ // Show per-flow changes
190
+ const flowComparisons = baselineCheckResult.flowComparisons || [];
191
+ const flowRegressions = flowComparisons.filter(c => c.regressionType !== 'NO_REGRESSION');
192
+
193
+ if (flowRegressions.length > 0) {
194
+ output += ' \n';
195
+ output += ' Flow regressions:\n';
196
+ flowRegressions.forEach(f => {
197
+ const label = f.flowId || 'unknown';
198
+ const type = f.regressionType.replace(/_/g, ' ');
199
+ output += ` • ${label}: ${type}\n`;
200
+ });
201
+ }
202
+
203
+ output += '\n';
204
+ }
205
+ }
206
+
107
207
  // Next Action
108
208
  output += '👉 Next Action:\n';
109
209
  if (counts.CRITICAL > 0) {
@@ -130,8 +230,8 @@ function generateCliSummary(snapshot, policyEval) {
130
230
  /**
131
231
  * Print summary to console
132
232
  */
133
- function printCliSummary(snapshot, policyEval) {
134
- const summary = generateCliSummary(snapshot, policyEval);
233
+ function printCliSummary(snapshot, policyEval, baselineCheckResult, options = {}) {
234
+ const summary = generateCliSummary(snapshot, policyEval, baselineCheckResult, options);
135
235
  console.log(summary);
136
236
  }
137
237
 
@@ -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 };