@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.
- package/CHANGELOG.md +62 -0
- package/README.md +3 -3
- package/bin/guardian.js +212 -8
- package/package.json +6 -1
- package/src/guardian/attempt-engine.js +19 -5
- package/src/guardian/attempt.js +61 -39
- package/src/guardian/attempts-filter.js +63 -0
- package/src/guardian/baseline.js +44 -10
- package/src/guardian/browser-pool.js +131 -0
- package/src/guardian/browser.js +28 -1
- package/src/guardian/ci-mode.js +15 -0
- package/src/guardian/ci-output.js +37 -0
- package/src/guardian/cli-summary.js +117 -4
- package/src/guardian/data-guardian-detector.js +189 -0
- package/src/guardian/detection-layers.js +271 -0
- package/src/guardian/first-run.js +49 -0
- package/src/guardian/flag-validator.js +97 -0
- package/src/guardian/flow-executor.js +309 -44
- package/src/guardian/language-detection.js +99 -0
- package/src/guardian/market-reporter.js +16 -1
- package/src/guardian/parallel-executor.js +116 -0
- package/src/guardian/prerequisite-checker.js +101 -0
- package/src/guardian/preset-loader.js +18 -12
- package/src/guardian/profile-loader.js +96 -0
- package/src/guardian/reality.js +382 -46
- package/src/guardian/run-summary.js +20 -0
- package/src/guardian/semantic-contact-detection.js +255 -0
- package/src/guardian/semantic-contact-finder.js +200 -0
- package/src/guardian/semantic-targets.js +234 -0
- package/src/guardian/smoke.js +258 -0
- package/src/guardian/snapshot.js +23 -1
- package/src/guardian/success-evaluator.js +214 -0
- package/src/guardian/timeout-profiles.js +57 -0
- package/src/guardian/wait-for-outcome.js +120 -0
- package/src/guardian/watch-runner.js +185 -0
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* Generate final CLI summary
|
|
10
10
|
* @param {object} snapshot - Guardian snapshot
|
|
11
11
|
* @param {object} policyEval - Policy evaluation result
|
|
12
|
+
* @param {object} baselineCheckResult - Optional baseline check result
|
|
12
13
|
* @returns {string} Formatted CLI summary
|
|
13
14
|
*/
|
|
14
|
-
function generateCliSummary(snapshot, policyEval) {
|
|
15
|
+
function generateCliSummary(snapshot, policyEval, baselineCheckResult) {
|
|
15
16
|
if (!snapshot) {
|
|
16
17
|
return 'No snapshot data available.';
|
|
17
18
|
}
|
|
@@ -68,17 +69,62 @@ function generateCliSummary(snapshot, policyEval) {
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
// Attempt Summary
|
|
72
|
+
// Phase 7.4: Include SKIPPED in summary
|
|
71
73
|
const successfulAttempts = attempts.filter(a => a.outcome === 'SUCCESS').length;
|
|
74
|
+
const skippedAttempts = attempts.filter(a => a.outcome === 'SKIPPED').length;
|
|
72
75
|
const totalAttempts = attempts.length;
|
|
73
76
|
if (totalAttempts > 0) {
|
|
74
77
|
output += '🎯 Attempts:\n';
|
|
75
78
|
output += ` ${successfulAttempts}/${totalAttempts} successful`;
|
|
76
79
|
if (successfulAttempts < totalAttempts) {
|
|
77
|
-
|
|
80
|
+
const failed = totalAttempts - successfulAttempts - skippedAttempts;
|
|
81
|
+
if (failed > 0) output += ` (${failed} failed)`;
|
|
82
|
+
if (skippedAttempts > 0) output += ` (${skippedAttempts} skipped)`;
|
|
78
83
|
}
|
|
79
84
|
output += '\n\n';
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
// Flow Submit Outcomes (Wave 1.3)
|
|
88
|
+
const flows = snapshot.flows || [];
|
|
89
|
+
const flowsWithEval = flows.filter(f => f.successEval);
|
|
90
|
+
if (flowsWithEval.length > 0) {
|
|
91
|
+
output += '🚦 Submit Outcomes:\n';
|
|
92
|
+
flowsWithEval.slice(0, 5).forEach(f => {
|
|
93
|
+
const status = (f.successEval.status || 'unknown').toUpperCase();
|
|
94
|
+
const confidence = f.successEval.confidence || 'low';
|
|
95
|
+
output += ` ${f.flowName}: ${status} (confidence: ${confidence})\n`;
|
|
96
|
+
const reasons = (f.successEval.reasons || []).slice(0, 3);
|
|
97
|
+
if (reasons.length) {
|
|
98
|
+
output += ' Reasons:\n';
|
|
99
|
+
reasons.forEach(r => { output += ` - ${r}\n`; });
|
|
100
|
+
}
|
|
101
|
+
// Compact evidence summary
|
|
102
|
+
const ev = f.successEval.evidence || {};
|
|
103
|
+
const net = Array.isArray(ev.network) ? ev.network : [];
|
|
104
|
+
const primary = net.find(n => (n.method === 'POST' || n.method === 'PUT') && n.status != null) || net[0];
|
|
105
|
+
const reqLine = (() => {
|
|
106
|
+
if (!primary) return null;
|
|
107
|
+
try { const p = new URL(primary.url); return `request: ${primary.method} ${p.pathname} → ${primary.status}`; }
|
|
108
|
+
catch { return `request: ${primary.method} ${primary.url} → ${primary.status}`; }
|
|
109
|
+
})();
|
|
110
|
+
const navLine = ev.urlChanged ? (() => {
|
|
111
|
+
try { const from = new URL(snapshot.meta.url).pathname; const to = ''; return `navigation: changed`; }
|
|
112
|
+
catch { return `navigation: changed`; }
|
|
113
|
+
})() : null;
|
|
114
|
+
const formStates = [];
|
|
115
|
+
if (ev.formCleared) formStates.push('cleared');
|
|
116
|
+
if (ev.formDisabled) formStates.push('disabled');
|
|
117
|
+
if (ev.formDisappeared) formStates.push('disappeared');
|
|
118
|
+
const formLine = formStates.length ? `form: ${formStates.join(', ')}` : null;
|
|
119
|
+
const evidenceLines = [reqLine, navLine, formLine].filter(Boolean);
|
|
120
|
+
if (evidenceLines.length) {
|
|
121
|
+
output += ' Evidence:\n';
|
|
122
|
+
evidenceLines.slice(0, 2).forEach(line => { output += ` - ${line}\n`; });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
output += '\n';
|
|
126
|
+
}
|
|
127
|
+
|
|
82
128
|
// Discovery Summary
|
|
83
129
|
if (discovery.pagesVisitedCount > 0) {
|
|
84
130
|
output += '🔍 Discovery:\n';
|
|
@@ -104,6 +150,73 @@ function generateCliSummary(snapshot, policyEval) {
|
|
|
104
150
|
output += ` Exit code: ${policyEval.exitCode || 0}\n\n`;
|
|
105
151
|
}
|
|
106
152
|
|
|
153
|
+
// Baseline Comparison (Phase 3.2)
|
|
154
|
+
if (baselineCheckResult) {
|
|
155
|
+
const verdict = baselineCheckResult.overallRegressionVerdict || 'NO_BASELINE';
|
|
156
|
+
|
|
157
|
+
if (verdict === 'NO_BASELINE') {
|
|
158
|
+
output += '📊 Baseline Comparison: not found (no comparison)\n\n';
|
|
159
|
+
} else if (verdict === 'BASELINE_UNUSABLE') {
|
|
160
|
+
output += '📊 Baseline Comparison: unusable (skipped)\n\n';
|
|
161
|
+
} else {
|
|
162
|
+
const emoji = verdict === 'NO_REGRESSION' ? '✅' :
|
|
163
|
+
verdict === 'REGRESSION_FRICTION' ? '🟡' :
|
|
164
|
+
'🔴';
|
|
165
|
+
|
|
166
|
+
output += '📊 Baseline Comparison:\n';
|
|
167
|
+
output += ` ${emoji} ${verdict.replace(/_/g, ' ')}\n`;
|
|
168
|
+
|
|
169
|
+
// Show per-attempt changes
|
|
170
|
+
const comparisons = baselineCheckResult.comparisons || [];
|
|
171
|
+
const regressions = comparisons.filter(c => c.regressionType !== 'NO_REGRESSION');
|
|
172
|
+
const improvements = comparisons.filter(c => c.improvements && c.improvements.length > 0);
|
|
173
|
+
|
|
174
|
+
if (regressions.length > 0) {
|
|
175
|
+
output += ' \n';
|
|
176
|
+
output += ' Regressions detected:\n';
|
|
177
|
+
regressions.slice(0, 3).forEach(r => {
|
|
178
|
+
const label = r.attemptId || 'unknown';
|
|
179
|
+
const type = r.regressionType.replace(/_/g, ' ');
|
|
180
|
+
const reasons = r.regressionReasons.slice(0, 1).join('; ') || 'See report';
|
|
181
|
+
output += ` • ${label}: ${type}\n`;
|
|
182
|
+
output += ` ${reasons}\n`;
|
|
183
|
+
});
|
|
184
|
+
if (regressions.length > 3) {
|
|
185
|
+
output += ` ... and ${regressions.length - 3} more regressions\n`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (improvements.length > 0) {
|
|
190
|
+
output += ' \n';
|
|
191
|
+
output += ' Improvements detected:\n';
|
|
192
|
+
improvements.slice(0, 3).forEach(i => {
|
|
193
|
+
const label = i.attemptId || 'unknown';
|
|
194
|
+
const improvementText = i.improvements.slice(0, 1).join('; ') || 'Improved';
|
|
195
|
+
output += ` • ${label}: ${improvementText}\n`;
|
|
196
|
+
});
|
|
197
|
+
if (improvements.length > 3) {
|
|
198
|
+
output += ` ... and ${improvements.length - 3} more improvements\n`;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Show per-flow changes
|
|
203
|
+
const flowComparisons = baselineCheckResult.flowComparisons || [];
|
|
204
|
+
const flowRegressions = flowComparisons.filter(c => c.regressionType !== 'NO_REGRESSION');
|
|
205
|
+
|
|
206
|
+
if (flowRegressions.length > 0) {
|
|
207
|
+
output += ' \n';
|
|
208
|
+
output += ' Flow regressions:\n';
|
|
209
|
+
flowRegressions.forEach(f => {
|
|
210
|
+
const label = f.flowId || 'unknown';
|
|
211
|
+
const type = f.regressionType.replace(/_/g, ' ');
|
|
212
|
+
output += ` • ${label}: ${type}\n`;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
output += '\n';
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
107
220
|
// Next Action
|
|
108
221
|
output += '👉 Next Action:\n';
|
|
109
222
|
if (counts.CRITICAL > 0) {
|
|
@@ -130,8 +243,8 @@ function generateCliSummary(snapshot, policyEval) {
|
|
|
130
243
|
/**
|
|
131
244
|
* Print summary to console
|
|
132
245
|
*/
|
|
133
|
-
function printCliSummary(snapshot, policyEval) {
|
|
134
|
-
const summary = generateCliSummary(snapshot, policyEval);
|
|
246
|
+
function printCliSummary(snapshot, policyEval, baselineCheckResult) {
|
|
247
|
+
const summary = generateCliSummary(snapshot, policyEval, baselineCheckResult);
|
|
135
248
|
console.log(summary);
|
|
136
249
|
}
|
|
137
250
|
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Guardian Attribute Detector
|
|
3
|
+
*
|
|
4
|
+
* Deterministic detection of data-guardian attributes for stable element targeting.
|
|
5
|
+
* Supports attribute variants and tokenized values.
|
|
6
|
+
*
|
|
7
|
+
* LAYER 1 in the detection priority hierarchy (highest priority).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { normalizeText } = require('./semantic-targets');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Supported data-guardian attribute targets
|
|
14
|
+
*/
|
|
15
|
+
const GUARDIAN_TARGETS = {
|
|
16
|
+
CONTACT: 'contact',
|
|
17
|
+
ABOUT: 'about',
|
|
18
|
+
FORM: 'form',
|
|
19
|
+
SUBMIT: 'submit'
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Find elements by data-guardian attribute
|
|
24
|
+
*
|
|
25
|
+
* @param {Page} page - Playwright page object
|
|
26
|
+
* @param {string} target - Target to search for (contact, about, form, submit)
|
|
27
|
+
* @returns {Promise<Array>} Array of matching elements with metadata
|
|
28
|
+
*/
|
|
29
|
+
async function findByGuardianAttribute(page, target) {
|
|
30
|
+
if (!GUARDIAN_TARGETS[target.toUpperCase()]) {
|
|
31
|
+
throw new Error(`Unsupported guardian target: ${target}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const normalizedTarget = target.toLowerCase();
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const results = await page.evaluate((targetName) => {
|
|
38
|
+
const elements = [];
|
|
39
|
+
|
|
40
|
+
// Rule 1: Exact match on data-guardian attribute
|
|
41
|
+
const exactMatches = document.querySelectorAll(`[data-guardian="${targetName}"]`);
|
|
42
|
+
for (const el of exactMatches) {
|
|
43
|
+
elements.push({
|
|
44
|
+
element: el,
|
|
45
|
+
tagName: el.tagName.toLowerCase(),
|
|
46
|
+
text: el.textContent?.trim() || '',
|
|
47
|
+
href: el.href || el.getAttribute('href') || '',
|
|
48
|
+
ariaLabel: el.getAttribute('aria-label') || '',
|
|
49
|
+
title: el.getAttribute('title') || '',
|
|
50
|
+
dataGuardian: el.getAttribute('data-guardian'),
|
|
51
|
+
dataGuardianVariant: null,
|
|
52
|
+
matchType: 'exact', // Exact attribute match
|
|
53
|
+
confidence: 'high'
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Rule 2: Tokenized match (e.g., "contact primary" contains "contact")
|
|
58
|
+
const allGuardianElements = document.querySelectorAll('[data-guardian]');
|
|
59
|
+
for (const el of allGuardianElements) {
|
|
60
|
+
const dataGuardian = el.getAttribute('data-guardian') || '';
|
|
61
|
+
const tokens = dataGuardian.split(/[\s\-_]+/).map(t => t.toLowerCase());
|
|
62
|
+
|
|
63
|
+
if (tokens.includes(targetName)) {
|
|
64
|
+
// Only add if not already in exact matches
|
|
65
|
+
const alreadyFound = Array.from(exactMatches).includes(el);
|
|
66
|
+
if (!alreadyFound) {
|
|
67
|
+
elements.push({
|
|
68
|
+
element: el,
|
|
69
|
+
tagName: el.tagName.toLowerCase(),
|
|
70
|
+
text: el.textContent?.trim() || '',
|
|
71
|
+
href: el.href || el.getAttribute('href') || '',
|
|
72
|
+
ariaLabel: el.getAttribute('aria-label') || '',
|
|
73
|
+
title: el.getAttribute('title') || '',
|
|
74
|
+
dataGuardian: el.getAttribute('data-guardian'),
|
|
75
|
+
dataGuardianVariant: null,
|
|
76
|
+
matchType: 'tokenized', // Tokenized match
|
|
77
|
+
confidence: 'high'
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Rule 3: Variant attributes (e.g., data-guardian-role="contact")
|
|
84
|
+
const variantAttr = `data-guardian-${targetName}`;
|
|
85
|
+
const variantMatches = document.querySelectorAll(`[${variantAttr}]`);
|
|
86
|
+
for (const el of variantMatches) {
|
|
87
|
+
const variantValue = el.getAttribute(variantAttr);
|
|
88
|
+
elements.push({
|
|
89
|
+
element: el,
|
|
90
|
+
tagName: el.tagName.toLowerCase(),
|
|
91
|
+
text: el.textContent?.trim() || '',
|
|
92
|
+
href: el.href || el.getAttribute('href') || '',
|
|
93
|
+
ariaLabel: el.getAttribute('aria-label') || '',
|
|
94
|
+
title: el.getAttribute('title') || '',
|
|
95
|
+
dataGuardian: el.getAttribute('data-guardian'),
|
|
96
|
+
dataGuardianVariant: variantAttr, // Track the variant attribute name
|
|
97
|
+
matchType: 'variant', // Variant attribute match
|
|
98
|
+
confidence: 'medium'
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return elements;
|
|
103
|
+
}, normalizedTarget);
|
|
104
|
+
|
|
105
|
+
return results;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.warn(`Failed to find elements by guardian attribute: ${error.message}`);
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check if element has any data-guardian attribute
|
|
114
|
+
* Returns the attribute value and match type
|
|
115
|
+
*/
|
|
116
|
+
async function getGuardianAttribute(page, selector) {
|
|
117
|
+
try {
|
|
118
|
+
const result = await page.evaluate((sel) => {
|
|
119
|
+
const el = document.querySelector(sel);
|
|
120
|
+
if (!el) return null;
|
|
121
|
+
|
|
122
|
+
const dataGuardian = el.getAttribute('data-guardian');
|
|
123
|
+
const allAttrs = el.attributes;
|
|
124
|
+
let variantAttr = null;
|
|
125
|
+
|
|
126
|
+
// Check for variant attributes
|
|
127
|
+
for (const attr of allAttrs) {
|
|
128
|
+
if (attr.name.startsWith('data-guardian-')) {
|
|
129
|
+
variantAttr = {
|
|
130
|
+
name: attr.name,
|
|
131
|
+
value: attr.value
|
|
132
|
+
};
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
dataGuardian: dataGuardian,
|
|
139
|
+
variantAttribute: variantAttr,
|
|
140
|
+
hasGuardianAttr: !!dataGuardian || !!variantAttr
|
|
141
|
+
};
|
|
142
|
+
}, selector);
|
|
143
|
+
|
|
144
|
+
return result;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
console.warn(`Failed to get guardian attribute: ${error.message}`);
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Build CSS selector for a data-guardian attribute
|
|
153
|
+
*/
|
|
154
|
+
function buildGuardianSelector(target, variant = null) {
|
|
155
|
+
if (variant) {
|
|
156
|
+
// Variant attribute selector
|
|
157
|
+
return `[data-guardian-${target}]`;
|
|
158
|
+
} else {
|
|
159
|
+
// Standard data-guardian attribute selector
|
|
160
|
+
return `[data-guardian="${target}"]`;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Check if an element's data-guardian value matches a target
|
|
166
|
+
*/
|
|
167
|
+
function matchesGuardianTarget(elementDataGuardian, target) {
|
|
168
|
+
if (!elementDataGuardian) return false;
|
|
169
|
+
|
|
170
|
+
const normalized = normalizeText(elementDataGuardian);
|
|
171
|
+
const normalizedTarget = normalizeText(target);
|
|
172
|
+
|
|
173
|
+
// Exact match
|
|
174
|
+
if (normalized === normalizedTarget) return true;
|
|
175
|
+
|
|
176
|
+
// Tokenized match (split by whitespace/hyphens)
|
|
177
|
+
const tokens = normalized.split(/[\s\-_]+/);
|
|
178
|
+
if (tokens.includes(normalizedTarget)) return true;
|
|
179
|
+
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = {
|
|
184
|
+
findByGuardianAttribute,
|
|
185
|
+
getGuardianAttribute,
|
|
186
|
+
buildGuardianSelector,
|
|
187
|
+
matchesGuardianTarget,
|
|
188
|
+
GUARDIAN_TARGETS
|
|
189
|
+
};
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detection Priority Layers
|
|
3
|
+
*
|
|
4
|
+
* Implements deterministic, layered detection with strict priority order:
|
|
5
|
+
* LAYER 0: profile overrides (highest)
|
|
6
|
+
* LAYER 1: data-guardian attributes
|
|
7
|
+
* LAYER 2: semantic href matching
|
|
8
|
+
* LAYER 3: semantic visible text matching
|
|
9
|
+
* LAYER 4: structural heuristics (nav/footer proximity)
|
|
10
|
+
*
|
|
11
|
+
* Each detection includes metadata: target, source layer, confidence, evidence.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { findByGuardianAttribute, matchesGuardianTarget, buildGuardianSelector } = require('./data-guardian-detector');
|
|
15
|
+
const { detectContactCandidates, DETECTION_SOURCE, CONFIDENCE } = require('./semantic-contact-detection');
|
|
16
|
+
const { resolveProfileForUrl } = require('./profile-loader');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Detection layer names (for reporting)
|
|
20
|
+
*/
|
|
21
|
+
const LAYER = {
|
|
22
|
+
PROFILE: 'profile',
|
|
23
|
+
DATA_GUARDIAN: 'data-guardian',
|
|
24
|
+
HREF: 'href',
|
|
25
|
+
TEXT: 'text',
|
|
26
|
+
STRUCTURE: 'structure'
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Detect element by layer, respecting strict priority
|
|
31
|
+
*
|
|
32
|
+
* @param {Page} page - Playwright page object
|
|
33
|
+
* @param {string} target - Target to detect (contact, about, form, submit)
|
|
34
|
+
* @param {string} baseUrl - Base URL for relative links
|
|
35
|
+
* @returns {Promise<Object>} Detection result with layer, confidence, evidence
|
|
36
|
+
*/
|
|
37
|
+
async function detectByLayers(page, target, baseUrl = '') {
|
|
38
|
+
const result = {
|
|
39
|
+
target: target,
|
|
40
|
+
found: false,
|
|
41
|
+
layer: null,
|
|
42
|
+
confidence: null,
|
|
43
|
+
evidence: null,
|
|
44
|
+
candidates: [],
|
|
45
|
+
primaryCandidate: null,
|
|
46
|
+
reason: ''
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const profile = resolveProfileForUrl(baseUrl || page.url());
|
|
51
|
+
if (profile) {
|
|
52
|
+
console.log(`🔧 Loaded profile for ${profile.site}`);
|
|
53
|
+
const profileSelector = (profile.selectors || {})[target];
|
|
54
|
+
if (profileSelector) {
|
|
55
|
+
let nodes = [];
|
|
56
|
+
try {
|
|
57
|
+
nodes = await page.$$(profileSelector);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.log(` ❌ Profile selector error for ${target}: ${err.message}`);
|
|
60
|
+
return {
|
|
61
|
+
...result,
|
|
62
|
+
layer: LAYER.PROFILE,
|
|
63
|
+
reason: `Profile selector error for ${target}: ${err.message}`,
|
|
64
|
+
profileSite: profile.site,
|
|
65
|
+
hardFailure: true
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!nodes || nodes.length === 0) {
|
|
70
|
+
console.log(` ❌ Profile selector for ${target} not found on page (${profile.site})`);
|
|
71
|
+
return {
|
|
72
|
+
...result,
|
|
73
|
+
layer: LAYER.PROFILE,
|
|
74
|
+
reason: `Profile selector for ${target} not found (${profileSelector})`,
|
|
75
|
+
profileSite: profile.site,
|
|
76
|
+
hardFailure: true
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log(` 🎯 Using profile selector for ${target}: ${profileSelector}`);
|
|
81
|
+
const candidates = [{
|
|
82
|
+
selector: profileSelector,
|
|
83
|
+
matchedText: null,
|
|
84
|
+
matchedToken: target,
|
|
85
|
+
source: 'profile',
|
|
86
|
+
confidence: CONFIDENCE.HIGH,
|
|
87
|
+
href: null,
|
|
88
|
+
ariaLabel: null,
|
|
89
|
+
tagName: nodes[0] ? await nodes[0].evaluate(el => el.tagName.toLowerCase()).catch(() => undefined) : undefined
|
|
90
|
+
}];
|
|
91
|
+
return {
|
|
92
|
+
...result,
|
|
93
|
+
found: true,
|
|
94
|
+
layer: LAYER.PROFILE,
|
|
95
|
+
confidence: CONFIDENCE.HIGH,
|
|
96
|
+
candidates,
|
|
97
|
+
primaryCandidate: candidates[0],
|
|
98
|
+
evidence: `Profile selector ${profileSelector}`,
|
|
99
|
+
reason: `Detected via profile override (site: ${profile.site})`,
|
|
100
|
+
profileSite: profile.site
|
|
101
|
+
};
|
|
102
|
+
} else {
|
|
103
|
+
console.log(` 🔧 Profile loaded for ${profile.site} (no selector for ${target}, falling back)`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// LAYER 1: data-guardian attributes (HIGHEST priority)
|
|
108
|
+
const guardianResults = await detectLayer1DataGuardian(page, target);
|
|
109
|
+
if (guardianResults.length > 0) {
|
|
110
|
+
result.found = true;
|
|
111
|
+
result.layer = LAYER.DATA_GUARDIAN;
|
|
112
|
+
result.confidence = CONFIDENCE.HIGH;
|
|
113
|
+
result.candidates = guardianResults;
|
|
114
|
+
result.primaryCandidate = guardianResults[0];
|
|
115
|
+
result.evidence = `Exact data-guardian="${target}" attribute match`;
|
|
116
|
+
result.reason = 'Highest priority layer matched: data-guardian attribute provides guaranteed stability.';
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// LAYER 2: semantic href matching
|
|
121
|
+
const hrefResults = await detectLayer2Href(page, target, baseUrl);
|
|
122
|
+
if (hrefResults.length > 0) {
|
|
123
|
+
result.found = true;
|
|
124
|
+
result.layer = LAYER.HREF;
|
|
125
|
+
result.confidence = CONFIDENCE.HIGH;
|
|
126
|
+
result.candidates = hrefResults;
|
|
127
|
+
result.primaryCandidate = hrefResults[0];
|
|
128
|
+
result.evidence = hrefResults[0].matchedToken;
|
|
129
|
+
result.reason = 'Matched via href attribute using semantic tokens (e.g., "/kontakt" matches German token "kontakt").';
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// LAYER 3: semantic visible text matching
|
|
134
|
+
const textResults = await detectLayer3Text(page, target, baseUrl);
|
|
135
|
+
if (textResults.length > 0) {
|
|
136
|
+
result.found = true;
|
|
137
|
+
result.layer = LAYER.TEXT;
|
|
138
|
+
result.confidence = textResults[0].confidence; // medium or high if in nav/footer
|
|
139
|
+
result.candidates = textResults;
|
|
140
|
+
result.primaryCandidate = textResults[0];
|
|
141
|
+
result.evidence = `Text "${textResults[0].matchedText}" matched token "${textResults[0].matchedToken}"`;
|
|
142
|
+
result.reason = `Matched via visible text using semantic tokens. ${
|
|
143
|
+
result.confidence === CONFIDENCE.HIGH ?
|
|
144
|
+
'Located in navigation/footer (high confidence).' :
|
|
145
|
+
'Consider adding data-guardian="' + target + '" for guaranteed stability.'
|
|
146
|
+
}`;
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// LAYER 4: structural heuristics (LOWEST priority)
|
|
151
|
+
const structureResults = await detectLayer4Structure(page, target, baseUrl);
|
|
152
|
+
if (structureResults.length > 0) {
|
|
153
|
+
result.found = true;
|
|
154
|
+
result.layer = LAYER.STRUCTURE;
|
|
155
|
+
result.confidence = CONFIDENCE.LOW;
|
|
156
|
+
result.candidates = structureResults;
|
|
157
|
+
result.primaryCandidate = structureResults[0];
|
|
158
|
+
result.evidence = `Located in ${structureResults[0].source} based on page structure`;
|
|
159
|
+
result.reason = `Heuristic detection only (low confidence). Add data-guardian="${target}" attribute for guaranteed stability.`;
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Nothing found
|
|
164
|
+
result.reason = `No ${target} element detected. Consider:
|
|
165
|
+
1. Adding data-guardian="${target}" attribute
|
|
166
|
+
2. Using semantic-friendly text (e.g., "Contact", "Kontakt", "Contacto")
|
|
167
|
+
3. Using semantic-friendly href (e.g., "/contact", "/kontakt")`;
|
|
168
|
+
|
|
169
|
+
return result;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.warn(`Detection by layers failed: ${error.message}`);
|
|
172
|
+
result.reason = `Detection error: ${error.message}`;
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* LAYER 1: data-guardian attribute detection (HIGHEST priority)
|
|
179
|
+
*/
|
|
180
|
+
async function detectLayer1DataGuardian(page, target) {
|
|
181
|
+
try {
|
|
182
|
+
const elements = await findByGuardianAttribute(page, target);
|
|
183
|
+
return elements.map(el => ({
|
|
184
|
+
selector: buildGuardianSelector(target),
|
|
185
|
+
matchedText: el.text || el.dataGuardian,
|
|
186
|
+
matchedToken: target,
|
|
187
|
+
source: DETECTION_SOURCE.DATA_GUARDIAN,
|
|
188
|
+
confidence: CONFIDENCE.HIGH,
|
|
189
|
+
href: el.href,
|
|
190
|
+
ariaLabel: el.ariaLabel,
|
|
191
|
+
tagName: el.tagName
|
|
192
|
+
}));
|
|
193
|
+
} catch (error) {
|
|
194
|
+
// Guardian target not supported or error occurred
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* LAYER 2: semantic href matching (semantic + deterministic)
|
|
201
|
+
*/
|
|
202
|
+
async function detectLayer2Href(page, target, baseUrl) {
|
|
203
|
+
try {
|
|
204
|
+
// Use Wave 1.1 semantic detection but filter by target and href-only
|
|
205
|
+
const allCandidates = await detectContactCandidates(page, baseUrl);
|
|
206
|
+
|
|
207
|
+
// Filter: only href-based matches for the requested target
|
|
208
|
+
const hrefMatches = allCandidates.filter(c =>
|
|
209
|
+
c.source === DETECTION_SOURCE.HREF &&
|
|
210
|
+
isTargetMatch(target, c.matchedToken)
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
return hrefMatches;
|
|
214
|
+
} catch (error) {
|
|
215
|
+
console.warn(`Layer 2 href detection failed: ${error.message}`);
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* LAYER 3: semantic visible text matching
|
|
222
|
+
*/
|
|
223
|
+
async function detectLayer3Text(page, target, baseUrl) {
|
|
224
|
+
try {
|
|
225
|
+
// Use Wave 1.1 semantic detection but filter by target and text source
|
|
226
|
+
const allCandidates = await detectContactCandidates(page, baseUrl);
|
|
227
|
+
|
|
228
|
+
// Filter: text-based or nav/footer matches for the requested target
|
|
229
|
+
const textMatches = allCandidates.filter(c =>
|
|
230
|
+
(c.source === DETECTION_SOURCE.TEXT || c.source === DETECTION_SOURCE.NAV_FOOTER) &&
|
|
231
|
+
isTargetMatch(target, c.matchedToken)
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
return textMatches;
|
|
235
|
+
} catch (error) {
|
|
236
|
+
console.warn(`Layer 3 text detection failed: ${error.message}`);
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* LAYER 4: structural heuristics (LOWEST priority, fallback only)
|
|
243
|
+
*/
|
|
244
|
+
async function detectLayer4Structure(page, target, baseUrl) {
|
|
245
|
+
// Structural heuristics would look for elements in footer, nav, etc.
|
|
246
|
+
// by position alone (no semantic matching)
|
|
247
|
+
// Currently a placeholder — can be enhanced in future waves
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Check if a matched token corresponds to the requested target
|
|
253
|
+
* (e.g., "kontakt" token matches "contact" target in German)
|
|
254
|
+
*/
|
|
255
|
+
function isTargetMatch(target, matchedToken) {
|
|
256
|
+
// For Wave 1.2, we simplify: contact tokens match contact target, etc.
|
|
257
|
+
// This can be expanded to support token-to-target mappings if needed
|
|
258
|
+
if (target.toLowerCase() === 'contact') {
|
|
259
|
+
// Wave 1.1 already groups all contact tokens under 'contact'
|
|
260
|
+
return matchedToken && matchedToken.toLowerCase() !== 'about';
|
|
261
|
+
}
|
|
262
|
+
// Extend as needed for other targets
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = {
|
|
267
|
+
detectByLayers,
|
|
268
|
+
LAYER,
|
|
269
|
+
DETECTION_SOURCE,
|
|
270
|
+
CONFIDENCE
|
|
271
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guardian First-Run Detection & Initialization
|
|
3
|
+
* Deterministically detects first run and coordinates welcome behavior.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const STATE_FILE = '.odavl-guardian/.first-run-state.json';
|
|
10
|
+
|
|
11
|
+
function hasRunBefore(stateDir = '.odavl-guardian') {
|
|
12
|
+
try {
|
|
13
|
+
const filePath = path.join(stateDir, '.first-run-state.json');
|
|
14
|
+
return fs.existsSync(filePath);
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function markAsRun(stateDir = '.odavl-guardian') {
|
|
21
|
+
try {
|
|
22
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
23
|
+
const filePath = path.join(stateDir, '.first-run-state.json');
|
|
24
|
+
fs.writeFileSync(filePath, JSON.stringify({ firstRunAt: new Date().toISOString() }, null, 2));
|
|
25
|
+
} catch (e) {
|
|
26
|
+
// Silently ignore state write failures (e.g., permission issues)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isFirstRun(stateDir = '.odavl-guardian') {
|
|
31
|
+
return !hasRunBefore(stateDir);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function printWelcome(label = 'ODAVL Guardian') {
|
|
35
|
+
const lines = [
|
|
36
|
+
'',
|
|
37
|
+
`Welcome to ${label}!`,
|
|
38
|
+
'Running first-time setup…',
|
|
39
|
+
''
|
|
40
|
+
];
|
|
41
|
+
console.log(lines.join('\n'));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = {
|
|
45
|
+
isFirstRun,
|
|
46
|
+
hasRunBefore,
|
|
47
|
+
markAsRun,
|
|
48
|
+
printWelcome
|
|
49
|
+
};
|