@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.
- package/CHANGELOG.md +146 -0
- package/README.md +155 -97
- package/bin/guardian.js +1544 -55
- package/config/README.md +59 -0
- package/config/profiles/landing-demo.yaml +16 -0
- package/package.json +26 -11
- package/policies/landing-demo.json +22 -0
- package/src/enterprise/audit-logger.js +166 -0
- package/src/enterprise/pdf-exporter.js +267 -0
- package/src/enterprise/rbac-gate.js +142 -0
- package/src/enterprise/rbac.js +239 -0
- package/src/enterprise/site-manager.js +180 -0
- package/src/founder/feedback-system.js +156 -0
- package/src/founder/founder-tracker.js +213 -0
- package/src/founder/usage-signals.js +141 -0
- package/src/guardian/alert-ledger.js +121 -0
- package/src/guardian/attempt-engine.js +587 -12
- package/src/guardian/attempt-registry.js +42 -1
- package/src/guardian/attempt-relevance.js +106 -0
- package/src/guardian/attempt.js +85 -39
- package/src/guardian/attempts-filter.js +63 -0
- package/src/guardian/baseline.js +50 -8
- package/src/guardian/breakage-intelligence.js +1 -0
- package/src/guardian/browser-pool.js +131 -0
- package/src/guardian/browser.js +28 -1
- package/src/guardian/ci-cli.js +121 -0
- package/src/guardian/ci-mode.js +15 -0
- package/src/guardian/ci-output.js +38 -0
- package/src/guardian/cli-summary.js +167 -67
- package/src/guardian/config-loader.js +162 -0
- package/src/guardian/data-guardian-detector.js +189 -0
- package/src/guardian/detection-layers.js +271 -0
- package/src/guardian/drift-detector.js +100 -0
- package/src/guardian/enhanced-html-reporter.js +221 -4
- package/src/guardian/env-guard.js +127 -0
- package/src/guardian/failure-intelligence.js +173 -0
- package/src/guardian/first-run-profile.js +89 -0
- package/src/guardian/first-run.js +54 -0
- package/src/guardian/flag-validator.js +111 -0
- package/src/guardian/flow-executor.js +309 -44
- package/src/guardian/html-reporter.js +2 -0
- package/src/guardian/human-reporter.js +431 -0
- package/src/guardian/index.js +22 -19
- package/src/guardian/init-command.js +9 -5
- package/src/guardian/intent-detector.js +146 -0
- package/src/guardian/journey-definitions.js +132 -0
- package/src/guardian/journey-scan-cli.js +145 -0
- package/src/guardian/journey-scanner.js +583 -0
- package/src/guardian/junit-reporter.js +18 -1
- package/src/guardian/language-detection.js +99 -0
- package/src/guardian/live-cli.js +95 -0
- package/src/guardian/live-scheduler-runner.js +137 -0
- package/src/guardian/live-scheduler.js +146 -0
- package/src/guardian/market-reporter.js +357 -82
- package/src/guardian/parallel-executor.js +116 -0
- package/src/guardian/pattern-analyzer.js +348 -0
- package/src/guardian/policy.js +80 -3
- package/src/guardian/prerequisite-checker.js +101 -0
- package/src/guardian/preset-loader.js +27 -18
- package/src/guardian/profile-loader.js +96 -0
- package/src/guardian/reality.js +1612 -115
- package/src/guardian/reporter.js +27 -41
- package/src/guardian/run-artifacts.js +212 -0
- package/src/guardian/run-cleanup.js +207 -0
- package/src/guardian/run-latest.js +90 -0
- package/src/guardian/run-list.js +211 -0
- package/src/guardian/run-summary.js +20 -0
- package/src/guardian/scan-presets.js +100 -11
- package/src/guardian/selector-fallbacks.js +394 -0
- package/src/guardian/semantic-contact-detection.js +255 -0
- package/src/guardian/semantic-contact-finder.js +201 -0
- package/src/guardian/semantic-targets.js +234 -0
- package/src/guardian/site-introspection.js +257 -0
- package/src/guardian/smoke.js +258 -0
- package/src/guardian/snapshot-schema.js +25 -1
- package/src/guardian/snapshot.js +69 -3
- package/src/guardian/stability-scorer.js +169 -0
- package/src/guardian/success-evaluator.js +214 -0
- package/src/guardian/template-command.js +184 -0
- package/src/guardian/text-formatters.js +426 -0
- package/src/guardian/timeout-profiles.js +57 -0
- package/src/guardian/verdict.js +320 -0
- package/src/guardian/verdicts.js +74 -0
- package/src/guardian/wait-for-outcome.js +120 -0
- package/src/guardian/watch-runner.js +181 -0
- package/src/payments/stripe-checkout.js +169 -0
- package/src/plans/plan-definitions.js +148 -0
- package/src/plans/plan-manager.js +211 -0
- package/src/plans/usage-tracker.js +210 -0
- package/src/recipes/recipe-engine.js +188 -0
- package/src/recipes/recipe-failure-analysis.js +159 -0
- package/src/recipes/recipe-registry.js +134 -0
- package/src/recipes/recipe-runtime.js +507 -0
- package/src/recipes/recipe-store.js +410 -0
- package/guardian-contract-v1.md +0 -149
- /package/{guardian.config.json → config/guardian.config.json} +0 -0
- /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
- /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
- /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
- /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
- /package/{guardian.profile.saas.yaml → config/profiles/saas.yaml} +0 -0
package/src/guardian/browser.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
21
|
-
const counts =
|
|
22
|
-
const
|
|
23
|
-
const
|
|
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
|
-
|
|
36
|
-
output += '
|
|
37
|
-
output += `
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
output += '\n
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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 };
|