@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
@@ -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
- output += ` (${totalAttempts - successfulAttempts} failed)`;
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
+ };