@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
|
@@ -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,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drift Detection Engine for Journey Results
|
|
3
|
+
* Deterministic comparison against stored baseline snapshot.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function buildBaselineFromJourneyResult(result) {
|
|
7
|
+
const keySteps = (result.executedSteps || []).map(s => ({ id: s.id, success: !!s.success }));
|
|
8
|
+
const baseline = {
|
|
9
|
+
decision: result.finalDecision,
|
|
10
|
+
intent: (result.intentDetection && result.intentDetection.intent) || 'unknown',
|
|
11
|
+
goalReached: !!(result.goal && result.goal.goalReached),
|
|
12
|
+
keySteps,
|
|
13
|
+
timestamp: new Date().toISOString()
|
|
14
|
+
};
|
|
15
|
+
return baseline;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function compareAgainstBaseline(baseline, current) {
|
|
19
|
+
const reasons = [];
|
|
20
|
+
const currentSteps = {};
|
|
21
|
+
(current.executedSteps || []).forEach(s => { currentSteps[s.id] = !!s.success; });
|
|
22
|
+
|
|
23
|
+
// Decision drift
|
|
24
|
+
const before = baseline.decision;
|
|
25
|
+
const after = current.finalDecision;
|
|
26
|
+
if (before === 'SAFE' && (after === 'RISK' || after === 'DO_NOT_LAUNCH')) {
|
|
27
|
+
reasons.push(`Decision regressed: ${before} → ${after}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Goal drift
|
|
31
|
+
const baselineGoal = !!baseline.goalReached;
|
|
32
|
+
const currentGoal = !!(current.goal && current.goal.goalReached);
|
|
33
|
+
if (baselineGoal && !currentGoal) {
|
|
34
|
+
reasons.push('Visitors can no longer reach goal');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Intent drift
|
|
38
|
+
const baselineIntent = (baseline.intent || 'unknown').toLowerCase();
|
|
39
|
+
const currentIntent = ((current.intentDetection && current.intentDetection.intent) || 'unknown').toLowerCase();
|
|
40
|
+
if (baselineIntent !== 'unknown' && currentIntent !== 'unknown' && baselineIntent !== currentIntent) {
|
|
41
|
+
reasons.push(`Site intent changed from ${baselineIntent.toUpperCase()} to ${currentIntent.toUpperCase()}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Critical step regression: any step that succeeded before now fails
|
|
45
|
+
for (const ks of baseline.keySteps || []) {
|
|
46
|
+
if (ks.success === true) {
|
|
47
|
+
const now = currentSteps[ks.id];
|
|
48
|
+
if (now === false || now === undefined) {
|
|
49
|
+
reasons.push(`Critical step failed: ${ks.id}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
hasDrift: reasons.length > 0,
|
|
56
|
+
reasons: reasons
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function classifySeverity(driftInfo, currentResult) {
|
|
61
|
+
if (!driftInfo || !driftInfo.hasDrift) return 'NONE';
|
|
62
|
+
|
|
63
|
+
const reasons = driftInfo.reasons || [];
|
|
64
|
+
|
|
65
|
+
// CRITICAL conditions:
|
|
66
|
+
// 1. Decision drifted to DO_NOT_LAUNCH
|
|
67
|
+
if (currentResult.decision === 'DO_NOT_LAUNCH') {
|
|
68
|
+
// Exception: if run stability is very low (<50), downgrade to WARN
|
|
69
|
+
// unless site is unreachable
|
|
70
|
+
if (!reasons.some(r => r.includes('SITE_UNREACHABLE'))) {
|
|
71
|
+
const stabilityScore = currentResult.stability?.runStabilityScore || 100;
|
|
72
|
+
if (stabilityScore < 50) {
|
|
73
|
+
return 'WARN'; // Downgrade: unstable run, re-check recommended
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return 'CRITICAL';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 2. Site unreachable - always CRITICAL (do not downgrade)
|
|
80
|
+
if (reasons.some(r => r.includes('SITE_UNREACHABLE'))) return 'CRITICAL';
|
|
81
|
+
|
|
82
|
+
// 3. Goal drift (visitors can no longer reach goal)
|
|
83
|
+
if (reasons.some(r => r.includes('goal drift: true → false'))) {
|
|
84
|
+
// Check stability before deciding
|
|
85
|
+
const stabilityScore = currentResult.stability?.runStabilityScore || 100;
|
|
86
|
+
if (stabilityScore < 50) {
|
|
87
|
+
return 'WARN'; // Downgrade: unstable, suspicious drift
|
|
88
|
+
}
|
|
89
|
+
return 'CRITICAL';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Default: WARN for all other drift
|
|
93
|
+
return 'WARN';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = {
|
|
97
|
+
buildBaselineFromJourneyResult,
|
|
98
|
+
compareAgainstBaseline,
|
|
99
|
+
classifySeverity
|
|
100
|
+
};
|