@odavl/guardian 0.1.0-rc1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +3 -3
  3. package/bin/guardian.js +212 -8
  4. package/package.json +6 -1
  5. package/src/guardian/attempt-engine.js +19 -5
  6. package/src/guardian/attempt.js +61 -39
  7. package/src/guardian/attempts-filter.js +63 -0
  8. package/src/guardian/baseline.js +44 -10
  9. package/src/guardian/browser-pool.js +131 -0
  10. package/src/guardian/browser.js +28 -1
  11. package/src/guardian/ci-mode.js +15 -0
  12. package/src/guardian/ci-output.js +37 -0
  13. package/src/guardian/cli-summary.js +117 -4
  14. package/src/guardian/data-guardian-detector.js +189 -0
  15. package/src/guardian/detection-layers.js +271 -0
  16. package/src/guardian/first-run.js +49 -0
  17. package/src/guardian/flag-validator.js +97 -0
  18. package/src/guardian/flow-executor.js +309 -44
  19. package/src/guardian/language-detection.js +99 -0
  20. package/src/guardian/market-reporter.js +16 -1
  21. package/src/guardian/parallel-executor.js +116 -0
  22. package/src/guardian/prerequisite-checker.js +101 -0
  23. package/src/guardian/preset-loader.js +18 -12
  24. package/src/guardian/profile-loader.js +96 -0
  25. package/src/guardian/reality.js +382 -46
  26. package/src/guardian/run-summary.js +20 -0
  27. package/src/guardian/semantic-contact-detection.js +255 -0
  28. package/src/guardian/semantic-contact-finder.js +200 -0
  29. package/src/guardian/semantic-targets.js +234 -0
  30. package/src/guardian/smoke.js +258 -0
  31. package/src/guardian/snapshot.js +23 -1
  32. package/src/guardian/success-evaluator.js +214 -0
  33. package/src/guardian/timeout-profiles.js +57 -0
  34. package/src/guardian/wait-for-outcome.js +120 -0
  35. package/src/guardian/watch-runner.js +185 -0
@@ -121,6 +121,21 @@ class MarketReporter {
121
121
  const flowRows = flows.map((flow, idx) => {
122
122
  const color = flow.outcome === 'SUCCESS' ? '#10b981' : '#ef4444';
123
123
  const badge = flow.outcome === 'SUCCESS' ? '✅ SUCCESS' : '❌ FAILURE';
124
+ const evalSummary = flow.successEval ? (() => {
125
+ const reasons = (flow.successEval.reasons || []).slice(0, 3).map(r => `• ${r}`).join('<br/>');
126
+ const ev = flow.successEval.evidence || {};
127
+ const net = Array.isArray(ev.network) ? ev.network : [];
128
+ const primary = net.find(n => (n.method === 'POST' || n.method === 'PUT') && n.status != null) || net[0];
129
+ const reqLine = primary ? (() => { try { const p = new URL(primary.url); return `${primary.method} ${p.pathname} → ${primary.status}`; } catch { return `${primary.method} ${primary.url} → ${primary.status}`; } })() : null;
130
+ const navLine = ev.urlChanged ? 'navigation: changed' : null;
131
+ const formStates = [];
132
+ if (ev.formCleared) formStates.push('cleared');
133
+ if (ev.formDisabled) formStates.push('disabled');
134
+ if (ev.formDisappeared) formStates.push('disappeared');
135
+ const formLine = formStates.length ? `form: ${formStates.join(', ')}` : null;
136
+ const evidences = [reqLine, navLine, formLine].filter(Boolean).map(e => `• ${e}`).join('<br/>' );
137
+ return `<div><strong>Reasons:</strong><br/>${reasons || '—'}<br/><strong>Evidence:</strong><br/>${evidences || '—'}</div>`;
138
+ })() : '';
124
139
  return `
125
140
  <tr>
126
141
  <td>${idx + 1}</td>
@@ -128,7 +143,7 @@ class MarketReporter {
128
143
  <td>${flow.flowName || ''}</td>
129
144
  <td><span class="badge" style="background:${color}">${badge}</span></td>
130
145
  <td>${flow.stepsExecuted || 0}/${flow.stepsTotal || 0}</td>
131
- <td>${flow.error || ''}</td>
146
+ <td>${flow.error || evalSummary || ''}</td>
132
147
  </tr>
133
148
  `;
134
149
  }).join('');
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Phase 7.2 - Parallel Executor
3
+ * Controlled parallel execution of attempts with bounded concurrency
4
+ */
5
+
6
+ /**
7
+ * Execute attempts with bounded parallelism
8
+ * @param {Array} attempts - Array of attempts to execute
9
+ * @param {Function} executeAttemptFn - Async function(attempt) => result
10
+ * @param {number} maxConcurrency - Max concurrent attempts (must be >= 1)
11
+ * @param {Object} options - { shouldStop?: Function }
12
+ * @returns {Promise<Array>} Results in original input order
13
+ */
14
+ async function executeParallel(attempts, executeAttemptFn, maxConcurrency = 1, options = {}) {
15
+ if (maxConcurrency < 1) {
16
+ throw new Error('maxConcurrency must be >= 1');
17
+ }
18
+
19
+ // Handle empty queue immediately
20
+ if (!attempts || attempts.length === 0) {
21
+ return [];
22
+ }
23
+
24
+ const results = new Array(attempts.length);
25
+ const queue = attempts.map((attempt, index) => ({ attempt, index }));
26
+ let executing = 0;
27
+ let queueIndex = 0;
28
+ let shouldStop = false;
29
+
30
+ return new Promise((resolve, reject) => {
31
+ const processNext = async () => {
32
+ // Check if we should stop (e.g., fail-fast triggered)
33
+ if (options.shouldStop && options.shouldStop()) {
34
+ shouldStop = true;
35
+ }
36
+
37
+ // If no more work and nothing executing, we're done
38
+ if (queueIndex >= queue.length && executing === 0) {
39
+ resolve(results);
40
+ return;
41
+ }
42
+
43
+ // If we've hit the concurrency limit or have no more items, wait
44
+ if (executing >= maxConcurrency || queueIndex >= queue.length) {
45
+ return;
46
+ }
47
+
48
+ // Get next item from queue
49
+ const { attempt, index } = queue[queueIndex];
50
+ queueIndex++;
51
+ executing++;
52
+
53
+ try {
54
+ // Only execute if we haven't stopped
55
+ if (!shouldStop) {
56
+ results[index] = await executeAttemptFn(attempt);
57
+ } else {
58
+ // Mark as skipped if we stopped
59
+ results[index] = { skipped: true };
60
+ }
61
+ } catch (err) {
62
+ results[index] = { error: err, skipped: false };
63
+ } finally {
64
+ executing--;
65
+ // Process next item(s)
66
+ processNext();
67
+ if (executing < maxConcurrency && queueIndex < queue.length && !shouldStop) {
68
+ processNext();
69
+ }
70
+ }
71
+ };
72
+
73
+ // Start workers
74
+ for (let i = 0; i < Math.min(maxConcurrency, queue.length); i++) {
75
+ processNext();
76
+ }
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Validate parallel concurrency value
82
+ * @param {number|string} value - Value to validate
83
+ * @returns {{ valid: boolean, parallel?: number, error?: string, hint?: string }}
84
+ */
85
+ function validateParallel(value) {
86
+ if (value === undefined || value === null) {
87
+ return { valid: true, parallel: 1 };
88
+ }
89
+
90
+ const num = parseInt(value, 10);
91
+
92
+ // Check for NaN
93
+ if (isNaN(num)) {
94
+ return {
95
+ valid: false,
96
+ error: `Invalid --parallel value: '${value}' (expected integer >= 1)`,
97
+ hint: 'Example: --parallel 2'
98
+ };
99
+ }
100
+
101
+ // Check if less than 1
102
+ if (num < 1) {
103
+ return {
104
+ valid: false,
105
+ error: `Invalid --parallel value: ${num} (must be >= 1)`,
106
+ hint: 'Example: --parallel 2'
107
+ };
108
+ }
109
+
110
+ return { valid: true, parallel: num };
111
+ }
112
+
113
+ module.exports = {
114
+ executeParallel,
115
+ validateParallel
116
+ };
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Phase 7.4: Prerequisite Checker
3
+ *
4
+ * Validates hard prerequisites before executing attempts.
5
+ * Enables early skip of impossible attempts to save time.
6
+ */
7
+
8
+ /**
9
+ * Define prerequisites for each attempt type
10
+ * Returns deterministic checks that can fail fast
11
+ */
12
+ const ATTEMPT_PREREQUISITES = {
13
+ checkout: {
14
+ description: 'Checkout requires accessible checkout page or cart',
15
+ checks: [
16
+ {
17
+ type: 'elementExists',
18
+ selector: 'a[href*="checkout"], a[href*="cart"], [data-guardian="checkout-link"], [data-guardian="cart-link"]',
19
+ reason: 'No checkout/cart link found on page'
20
+ }
21
+ ]
22
+ },
23
+ login: {
24
+ description: 'Login requires accessible login page',
25
+ checks: [
26
+ {
27
+ type: 'elementExists',
28
+ selector: 'a[href*="login"], a[href*="signin"], a:has-text("Login"), a:has-text("Sign in"), [data-guardian="account-login-link"], input[type="email"][id*="login"], input[type="email"][name*="login"]',
29
+ reason: 'No login link or login form found'
30
+ }
31
+ ]
32
+ },
33
+ newsletter_signup: {
34
+ description: 'Newsletter requires email input',
35
+ checks: [
36
+ {
37
+ type: 'elementExists',
38
+ selector: 'input[type="email"], input[placeholder*="email" i], input[name*="email"], [data-guardian="email-input"]',
39
+ reason: 'No email input found for newsletter'
40
+ }
41
+ ]
42
+ },
43
+ signup: {
44
+ description: 'Signup requires accessible signup page',
45
+ checks: [
46
+ {
47
+ type: 'elementExists',
48
+ selector: 'a[href*="signup"], a[href*="register"], a:has-text("Sign up"), a:has-text("Register"), [data-guardian="signup-link"]',
49
+ reason: 'No signup/register link found'
50
+ }
51
+ ]
52
+ }
53
+ };
54
+
55
+ /**
56
+ * Check if an attempt's prerequisites are met
57
+ * @param {Page} page - Playwright page
58
+ * @param {string} attemptId - Attempt identifier
59
+ * @param {number} timeout - Max time to check (short, e.g., 2000ms)
60
+ * @returns {Promise<{canProceed: boolean, reason: string|null}>}
61
+ */
62
+ async function checkPrerequisites(page, attemptId, timeout = 2000) {
63
+ const prereqs = ATTEMPT_PREREQUISITES[attemptId];
64
+
65
+ // No prerequisites defined = can proceed
66
+ if (!prereqs || !prereqs.checks || prereqs.checks.length === 0) {
67
+ return { canProceed: true, reason: null };
68
+ }
69
+
70
+ // Check each prerequisite
71
+ for (const check of prereqs.checks) {
72
+ if (check.type === 'elementExists') {
73
+ try {
74
+ // Quick check with short timeout
75
+ const exists = await page.locator(check.selector).first().isVisible({ timeout });
76
+ if (!exists) {
77
+ return { canProceed: false, reason: check.reason };
78
+ }
79
+ } catch (err) {
80
+ // Element not found = prerequisite failed
81
+ return { canProceed: false, reason: check.reason };
82
+ }
83
+ }
84
+ }
85
+
86
+ return { canProceed: true, reason: null };
87
+ }
88
+
89
+ /**
90
+ * Get list of attempt IDs that have prerequisites defined
91
+ * @returns {string[]}
92
+ */
93
+ function getAttemptsWithPrerequisites() {
94
+ return Object.keys(ATTEMPT_PREREQUISITES);
95
+ }
96
+
97
+ module.exports = {
98
+ checkPrerequisites,
99
+ getAttemptsWithPrerequisites,
100
+ ATTEMPT_PREREQUISITES
101
+ };
@@ -71,20 +71,26 @@ function parsePolicyOption(policyOption) {
71
71
  }
72
72
 
73
73
  // Otherwise, treat as file path
74
- if (fs.existsSync(optionStr)) {
75
- try {
76
- const content = fs.readFileSync(optionStr, 'utf-8');
77
- const policy = JSON.parse(content);
78
- console.log(`✅ Loaded policy from: ${optionStr}`);
79
- return policy;
80
- } catch (error) {
81
- console.error(`⚠️ Failed to load policy from ${optionStr}: ${error.message}`);
82
- return null;
83
- }
74
+ // Fix for Wave 0.5: Validate policy file exists BEFORE attempting to load
75
+ if (!fs.existsSync(optionStr)) {
76
+ console.error(`Error: Policy file not found: ${optionStr}`);
77
+ console.error('');
78
+ console.error('Please provide a valid policy file or use a preset:');
79
+ console.error(' --policy preset:startup (Permissive for fast-moving startups)');
80
+ console.error(' --policy preset:saas (Balanced for SaaS products)');
81
+ console.error(' --policy preset:enterprise (Strict for enterprise deployments)');
82
+ process.exit(1);
84
83
  }
85
84
 
86
- console.warn(`⚠️ Policy file not found: ${optionStr}`);
87
- return null;
85
+ try {
86
+ const content = fs.readFileSync(optionStr, 'utf-8');
87
+ const policy = JSON.parse(content);
88
+ console.log(`✅ Loaded policy from: ${optionStr}`);
89
+ return policy;
90
+ } catch (error) {
91
+ console.error(`Error: Failed to load policy from ${optionStr}: ${error.message}`);
92
+ process.exit(1);
93
+ }
88
94
  }
89
95
 
90
96
  /**
@@ -0,0 +1,96 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const PROFILE_PATH = path.join(__dirname, '../../data/guardian-profiles.json');
5
+ let cachedProfiles = null;
6
+ let cachedMtime = null;
7
+
8
+ function normalizeOrigin(value) {
9
+ if (!value) return null;
10
+ try {
11
+ const url = value.startsWith('http://') || value.startsWith('https://')
12
+ ? new URL(value)
13
+ : new URL(`http://${value}`);
14
+ return url.origin;
15
+ } catch (err) {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ function loadProfilesFile() {
21
+ try {
22
+ if (!fs.existsSync(PROFILE_PATH)) {
23
+ return [];
24
+ }
25
+ const stat = fs.statSync(PROFILE_PATH);
26
+ if (cachedProfiles && cachedMtime === stat.mtimeMs) {
27
+ return cachedProfiles;
28
+ }
29
+ const raw = fs.readFileSync(PROFILE_PATH, 'utf8');
30
+ const data = JSON.parse(raw);
31
+ const profiles = [];
32
+
33
+ if (Array.isArray(data)) {
34
+ data.forEach((entry) => {
35
+ const origin = normalizeOrigin(entry.origin || entry.site);
36
+ if (!origin || !entry.selectors) return;
37
+ profiles.push({ site: origin, selectors: entry.selectors, navigation: entry.navigation });
38
+ });
39
+ } else if (data && typeof data === 'object') {
40
+ Object.keys(data).forEach((key) => {
41
+ const origin = normalizeOrigin(key);
42
+ if (!origin) return;
43
+ const entry = data[key];
44
+ if (!entry || typeof entry !== 'object') return;
45
+ profiles.push({ site: origin, selectors: entry.selectors || entry, navigation: entry.navigation });
46
+ });
47
+ }
48
+
49
+ cachedProfiles = profiles;
50
+ cachedMtime = stat.mtimeMs;
51
+ return profiles;
52
+ } catch (err) {
53
+ console.log(`⚠️ Failed to load profiles: ${err.message}`);
54
+ return [];
55
+ }
56
+ }
57
+
58
+ function resolveProfileForUrl(url) {
59
+ if (!url) return null;
60
+ let origin = null;
61
+ let host = null;
62
+ try {
63
+ const u = new URL(url);
64
+ origin = u.origin;
65
+ host = u.hostname;
66
+ } catch (err) {
67
+ return null;
68
+ }
69
+
70
+ const profiles = loadProfilesFile();
71
+ if (profiles.length === 0) {
72
+ return null;
73
+ }
74
+
75
+ const exact = profiles.find((p) => p.site === origin);
76
+ if (exact) return exact;
77
+
78
+ const hostMatch = profiles.find((p) => {
79
+ try {
80
+ const u = new URL(p.site);
81
+ if (u.port && u.port !== '80') {
82
+ return u.hostname === host && u.port === (new URL(origin)).port;
83
+ }
84
+ return u.hostname === host;
85
+ } catch {
86
+ return false;
87
+ }
88
+ });
89
+
90
+ return hostMatch || null;
91
+ }
92
+
93
+ module.exports = {
94
+ resolveProfileForUrl,
95
+ loadProfilesFile,
96
+ };