@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.
Files changed (101) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +155 -97
  3. package/bin/guardian.js +1544 -55
  4. package/config/README.md +59 -0
  5. package/config/profiles/landing-demo.yaml +16 -0
  6. package/package.json +26 -11
  7. package/policies/landing-demo.json +22 -0
  8. package/src/enterprise/audit-logger.js +166 -0
  9. package/src/enterprise/pdf-exporter.js +267 -0
  10. package/src/enterprise/rbac-gate.js +142 -0
  11. package/src/enterprise/rbac.js +239 -0
  12. package/src/enterprise/site-manager.js +180 -0
  13. package/src/founder/feedback-system.js +156 -0
  14. package/src/founder/founder-tracker.js +213 -0
  15. package/src/founder/usage-signals.js +141 -0
  16. package/src/guardian/alert-ledger.js +121 -0
  17. package/src/guardian/attempt-engine.js +587 -12
  18. package/src/guardian/attempt-registry.js +42 -1
  19. package/src/guardian/attempt-relevance.js +106 -0
  20. package/src/guardian/attempt.js +85 -39
  21. package/src/guardian/attempts-filter.js +63 -0
  22. package/src/guardian/baseline.js +50 -8
  23. package/src/guardian/breakage-intelligence.js +1 -0
  24. package/src/guardian/browser-pool.js +131 -0
  25. package/src/guardian/browser.js +28 -1
  26. package/src/guardian/ci-cli.js +121 -0
  27. package/src/guardian/ci-mode.js +15 -0
  28. package/src/guardian/ci-output.js +38 -0
  29. package/src/guardian/cli-summary.js +167 -67
  30. package/src/guardian/config-loader.js +162 -0
  31. package/src/guardian/data-guardian-detector.js +189 -0
  32. package/src/guardian/detection-layers.js +271 -0
  33. package/src/guardian/drift-detector.js +100 -0
  34. package/src/guardian/enhanced-html-reporter.js +221 -4
  35. package/src/guardian/env-guard.js +127 -0
  36. package/src/guardian/failure-intelligence.js +173 -0
  37. package/src/guardian/first-run-profile.js +89 -0
  38. package/src/guardian/first-run.js +54 -0
  39. package/src/guardian/flag-validator.js +111 -0
  40. package/src/guardian/flow-executor.js +309 -44
  41. package/src/guardian/html-reporter.js +2 -0
  42. package/src/guardian/human-reporter.js +431 -0
  43. package/src/guardian/index.js +22 -19
  44. package/src/guardian/init-command.js +9 -5
  45. package/src/guardian/intent-detector.js +146 -0
  46. package/src/guardian/journey-definitions.js +132 -0
  47. package/src/guardian/journey-scan-cli.js +145 -0
  48. package/src/guardian/journey-scanner.js +583 -0
  49. package/src/guardian/junit-reporter.js +18 -1
  50. package/src/guardian/language-detection.js +99 -0
  51. package/src/guardian/live-cli.js +95 -0
  52. package/src/guardian/live-scheduler-runner.js +137 -0
  53. package/src/guardian/live-scheduler.js +146 -0
  54. package/src/guardian/market-reporter.js +357 -82
  55. package/src/guardian/parallel-executor.js +116 -0
  56. package/src/guardian/pattern-analyzer.js +348 -0
  57. package/src/guardian/policy.js +80 -3
  58. package/src/guardian/prerequisite-checker.js +101 -0
  59. package/src/guardian/preset-loader.js +27 -18
  60. package/src/guardian/profile-loader.js +96 -0
  61. package/src/guardian/reality.js +1612 -115
  62. package/src/guardian/reporter.js +27 -41
  63. package/src/guardian/run-artifacts.js +212 -0
  64. package/src/guardian/run-cleanup.js +207 -0
  65. package/src/guardian/run-latest.js +90 -0
  66. package/src/guardian/run-list.js +211 -0
  67. package/src/guardian/run-summary.js +20 -0
  68. package/src/guardian/scan-presets.js +100 -11
  69. package/src/guardian/selector-fallbacks.js +394 -0
  70. package/src/guardian/semantic-contact-detection.js +255 -0
  71. package/src/guardian/semantic-contact-finder.js +201 -0
  72. package/src/guardian/semantic-targets.js +234 -0
  73. package/src/guardian/site-introspection.js +257 -0
  74. package/src/guardian/smoke.js +258 -0
  75. package/src/guardian/snapshot-schema.js +25 -1
  76. package/src/guardian/snapshot.js +69 -3
  77. package/src/guardian/stability-scorer.js +169 -0
  78. package/src/guardian/success-evaluator.js +214 -0
  79. package/src/guardian/template-command.js +184 -0
  80. package/src/guardian/text-formatters.js +426 -0
  81. package/src/guardian/timeout-profiles.js +57 -0
  82. package/src/guardian/verdict.js +320 -0
  83. package/src/guardian/verdicts.js +74 -0
  84. package/src/guardian/wait-for-outcome.js +120 -0
  85. package/src/guardian/watch-runner.js +181 -0
  86. package/src/payments/stripe-checkout.js +169 -0
  87. package/src/plans/plan-definitions.js +148 -0
  88. package/src/plans/plan-manager.js +211 -0
  89. package/src/plans/usage-tracker.js +210 -0
  90. package/src/recipes/recipe-engine.js +188 -0
  91. package/src/recipes/recipe-failure-analysis.js +159 -0
  92. package/src/recipes/recipe-registry.js +134 -0
  93. package/src/recipes/recipe-runtime.js +507 -0
  94. package/src/recipes/recipe-store.js +410 -0
  95. package/guardian-contract-v1.md +0 -149
  96. /package/{guardian.config.json → config/guardian.config.json} +0 -0
  97. /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
  98. /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
  99. /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
  100. /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
  101. /package/{guardian.profile.saas.yaml → config/profiles/saas.yaml} +0 -0
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Robust Selector Discovery with Fallbacks
3
+ *
4
+ * Attempts to find elements using multiple strategies:
5
+ * 1. data-guardian attributes (highest priority if site is instrumented)
6
+ * 2. Semantic input types (email/password/text/submit)
7
+ * 3. Autocomplete attributes (email/current-password/new-password)
8
+ * 4. Name/ID contains patterns
9
+ * 5. Role and ARIA attributes
10
+ * 6. Link href patterns
11
+ * 7. Visible text matching (case-insensitive)
12
+ * 8. Heuristic form/button detection
13
+ *
14
+ * Returns: { element, strategy, selector, confidence, discoverySignals }
15
+ * discoverySignals includes: formsCounted, linksFound, buttonsCounted, finalSelection
16
+ */
17
+
18
+ /**
19
+ * Build selector chain for a specific element type
20
+ * @param {string} goalType - email_input, password_input, submit_button, login_link, contact_link, language_toggle, etc.
21
+ * @returns {string[]} Array of selectors in priority order
22
+ */
23
+ function buildSelectorChain(goalType) {
24
+ const chains = {
25
+ email_input: [
26
+ // data-guardian (site instrumented)
27
+ '[data-guardian="email"], [data-guardian="login-email"], [data-guardian="signup-email"]',
28
+ // semantic type
29
+ 'input[type="email"]',
30
+ // autocomplete
31
+ 'input[autocomplete="email"], input[autocomplete*="email"]',
32
+ // name/id patterns
33
+ 'input[name*="email" i], input[id*="email" i]',
34
+ 'input[name*="mail" i], input[id*="mail" i]',
35
+ 'input[name*="user" i], input[id*="user" i]',
36
+ // placeholder
37
+ 'input[placeholder*="email" i], input[placeholder*="mail" i]',
38
+ // aria-label
39
+ 'input[aria-label*="email" i], input[aria-label*="mail" i]',
40
+ // broad form field with label
41
+ 'form input[type="text"]:first-of-type'
42
+ ],
43
+
44
+ password_input: [
45
+ '[data-guardian="password"], [data-guardian="login-password"], [data-guardian="signup-password"]',
46
+ 'input[type="password"]',
47
+ 'input[autocomplete="current-password"], input[autocomplete="new-password"]',
48
+ 'input[name*="pass" i], input[id*="pass" i]',
49
+ 'input[name*="pwd" i], input[id*="pwd" i]',
50
+ 'input[placeholder*="pass" i], input[placeholder*="pwd" i]',
51
+ 'input[aria-label*="pass" i], input[aria-label*="pwd" i]'
52
+ ],
53
+
54
+ submit_button: [
55
+ '[data-guardian="submit"], [data-guardian="login-submit"], [data-guardian="signup-submit"], [data-guardian="signup-account-submit"]',
56
+ 'button[type="submit"]',
57
+ 'button:has-text("Sign in") >> nth=0',
58
+ 'button:has-text("Sign up") >> nth=0',
59
+ 'button:has-text("Log in") >> nth=0',
60
+ 'button:has-text("Register") >> nth=0',
61
+ 'button:has-text("Submit") >> nth=0',
62
+ 'button:has-text("Send") >> nth=0',
63
+ 'button:has-text("Create account") >> nth=0',
64
+ 'button[name*="submit" i], button[id*="submit" i]',
65
+ 'input[type="submit"], input[type="button"][value*="Submit" i], input[type="button"][value*="Sign" i]',
66
+ 'button:not([type="reset"]):not([type="button"]):visible >> nth=0'
67
+ ],
68
+
69
+ contact_form: [
70
+ '[data-guardian="contact-form"]',
71
+ 'form[name*="contact" i]',
72
+ 'form[id*="contact" i]',
73
+ 'form[class*="contact" i]',
74
+ '[role="form"][aria-label*="contact" i]',
75
+ 'form >> nth=0' // Fallback: first form on page
76
+ ],
77
+
78
+ contact_link: [
79
+ '[data-guardian="contact-link"]',
80
+ 'a[data-testid="contact-link"]',
81
+ 'a:has-text("Contact")',
82
+ 'a:has-text("Contact Us")',
83
+ 'a:has-text("Get in Touch")',
84
+ 'a[href*="/contact" i]',
85
+ 'a[href*="/contact-us" i]',
86
+ 'a[href*="/support" i]',
87
+ 'a[href="#contact" i]',
88
+ 'nav a:has-text("Contact")',
89
+ '[role="navigation"] a:has-text("Contact")'
90
+ ],
91
+
92
+ login_link: [
93
+ '[data-guardian="account-login-link"]',
94
+ 'a:has-text("Log in")',
95
+ 'a:has-text("Login")',
96
+ 'a:has-text("Sign in")',
97
+ 'a[href*="/login" i]',
98
+ 'a[href*="/signin" i]',
99
+ 'a[href*="/auth" i]',
100
+ 'a[href*="/account" i]',
101
+ 'nav a:has-text("Log in")'
102
+ ],
103
+
104
+ signup_link: [
105
+ '[data-guardian="account-signup-link"]',
106
+ 'a:has-text("Sign up")',
107
+ 'a:has-text("Signup")',
108
+ 'a:has-text("Register")',
109
+ 'a:has-text("Create account")',
110
+ 'a[href*="/signup" i]',
111
+ 'a[href*="/sign-up" i]',
112
+ 'a[href*="/register" i]',
113
+ 'a[href*="/join" i]',
114
+ 'nav a:has-text("Sign up")'
115
+ ],
116
+
117
+ checkout_link: [
118
+ '[data-guardian="checkout-link"]',
119
+ 'a:has-text("Checkout")',
120
+ 'a:has-text("Check out")',
121
+ 'a:has-text("Cart")',
122
+ 'a[href*="/checkout" i]',
123
+ 'a[href*="/cart" i]',
124
+ 'a[href*="/order" i]',
125
+ 'a[href*="/buy" i]'
126
+ ],
127
+
128
+ checkout_button: [
129
+ '[data-guardian="checkout-place-order"]',
130
+ 'button:has-text("Checkout")',
131
+ 'button:has-text("Place order")',
132
+ 'button:has-text("Place Order")',
133
+ 'button:has-text("Complete purchase")',
134
+ 'button:has-text("Buy now")',
135
+ 'button[name*="checkout" i]'
136
+ ],
137
+
138
+ language_toggle: [
139
+ '[data-guardian="lang-toggle"]',
140
+ 'button:has-text("Language")',
141
+ 'button:has-text("Lang")',
142
+ 'button[aria-label*="language" i]',
143
+ 'button[aria-label*="lang" i]',
144
+ 'select[name*="lang" i]',
145
+ 'select[id*="language" i]',
146
+ 'a[hreflang]:first-of-type'
147
+ ],
148
+
149
+ language_option_de: [
150
+ '[data-guardian="lang-option-de"]',
151
+ 'button:has-text("DE")',
152
+ 'button:has-text("Deutsch")',
153
+ 'a[hreflang="de"]',
154
+ '[lang="de"] >> nth=0'
155
+ ],
156
+
157
+ success_message: [
158
+ '[data-guardian="success"]',
159
+ '[data-testid="success"]',
160
+ '.success-message',
161
+ '.alert-success',
162
+ '[role="alert"]:has-text("success")',
163
+ '[role="alert"]:has-text("submitted")',
164
+ '[role="alert"]:has-text("confirmed")',
165
+ 'div:has-text("success")'
166
+ ]
167
+ };
168
+
169
+ return chains[goalType] || [];
170
+ }
171
+
172
+ /**
173
+ * Attempt to find element using selector chain
174
+ * @param {Page} page - Playwright page
175
+ * @param {string[]} selectorChain - Array of selectors to try
176
+ * @param {Object} options - { timeout, requireVisible }
177
+ * @returns {Promise<{element, strategy, selector, confidence, discoverySignals}>}
178
+ */
179
+ async function findElement(page, selectorChain, options = {}) {
180
+ const { timeout = 5000, requireVisible = true } = options;
181
+ const discoverySignals = {
182
+ formsCounted: 0,
183
+ linksCounted: 0,
184
+ buttonsCounted: 0,
185
+ candidatesEvaluated: [],
186
+ finalSelection: null
187
+ };
188
+
189
+ if (!selectorChain || selectorChain.length === 0) {
190
+ return {
191
+ element: null,
192
+ strategy: 'EMPTY_CHAIN',
193
+ selector: null,
194
+ confidence: 0,
195
+ discoverySignals
196
+ };
197
+ }
198
+
199
+ // Quick scan for discovery signals
200
+ try {
201
+ discoverySignals.formsCounted = (await page.locator('form').count()).toString();
202
+ discoverySignals.linksCounted = (await page.locator('a').count()).toString();
203
+ discoverySignals.buttonsCounted = (await page.locator('button').count()).toString();
204
+ } catch (_) {
205
+ // Non-critical
206
+ }
207
+
208
+ // Try each selector in chain
209
+ for (let i = 0; i < selectorChain.length; i++) {
210
+ const selector = selectorChain[i];
211
+ try {
212
+ const locator = page.locator(selector);
213
+ const count = await locator.count();
214
+
215
+ if (count > 0) {
216
+ // Found candidate(s)
217
+ discoverySignals.candidatesEvaluated.push({
218
+ strategy: i,
219
+ selector,
220
+ found: count
221
+ });
222
+
223
+ // Use first match
224
+ const element = locator.first();
225
+
226
+ // Check visibility if required
227
+ if (requireVisible) {
228
+ const isVisible = await element.isVisible().catch(() => false);
229
+ if (!isVisible) {
230
+ discoverySignals.candidatesEvaluated[discoverySignals.candidatesEvaluated.length - 1].visible = false;
231
+ continue; // Try next selector
232
+ }
233
+ }
234
+
235
+ discoverySignals.finalSelection = {
236
+ strategy: i,
237
+ selector,
238
+ found: count,
239
+ used: 'first'
240
+ };
241
+
242
+ return {
243
+ element,
244
+ strategy: `FALLBACK_${i}`,
245
+ selector,
246
+ confidence: 0.95 - (i * 0.05), // Decrease confidence for later strategies
247
+ discoverySignals
248
+ };
249
+ }
250
+ } catch (err) {
251
+ // Selector parse error or timeout - try next
252
+ discoverySignals.candidatesEvaluated.push({
253
+ strategy: i,
254
+ selector,
255
+ error: err.message
256
+ });
257
+ continue;
258
+ }
259
+ }
260
+
261
+ // No element found
262
+ return {
263
+ element: null,
264
+ strategy: 'NOT_FOUND',
265
+ selector: null,
266
+ confidence: 0,
267
+ discoverySignals
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Detect if a specific feature is present on the page
273
+ * @param {Page} page - Playwright page
274
+ * @param {string} featureType - 'login', 'signup', 'checkout', 'contact_form', 'newsletter', 'language_switch'
275
+ * @returns {Promise<{present: boolean, confidence: number, evidence: string[]}>}
276
+ */
277
+ async function detectFeature(page, featureType) {
278
+ const evidence = [];
279
+
280
+ try {
281
+ switch (featureType) {
282
+ case 'login': {
283
+ // Look for login form, login link, or auth elements
284
+ const hasLoginLink = (await page.locator('a:has-text("Log in"), a:has-text("Login"), a:has-text("Sign in")').count()) > 0;
285
+ const hasLoginForm = (await page.locator('input[type="email"] + input[type="password"]').count()) > 0;
286
+ const hasAuthSection = (await page.locator('[role="form"][aria-label*="login" i]').count()) > 0;
287
+
288
+ if (hasLoginLink) evidence.push('login_link_found');
289
+ if (hasLoginForm) evidence.push('auth_form_found');
290
+ if (hasAuthSection) evidence.push('auth_section_found');
291
+
292
+ return {
293
+ present: hasLoginLink || hasLoginForm || hasAuthSection,
294
+ confidence: evidence.length / 3,
295
+ evidence
296
+ };
297
+ }
298
+
299
+ case 'signup': {
300
+ const hasSignupLink = (await page.locator('a:has-text("Sign up"), a:has-text("Signup"), a:has-text("Register")').count()) > 0;
301
+ const hasSignupForm = (await page.locator('input[type="email"]').count()) > 0; // Weak signal
302
+ const hasCreateText = (await page.locator('text=/create.*account/i').count()) > 0;
303
+
304
+ if (hasSignupLink) evidence.push('signup_link_found');
305
+ if (hasSignupForm) evidence.push('email_field_found');
306
+ if (hasCreateText) evidence.push('create_account_text_found');
307
+
308
+ return {
309
+ present: hasSignupLink || (hasSignupForm && hasCreateText),
310
+ confidence: evidence.length / 3,
311
+ evidence
312
+ };
313
+ }
314
+
315
+ case 'checkout': {
316
+ const hasCheckoutLink = (await page.locator('a:has-text("Checkout"), a:has-text("Cart"), a[href*="/checkout" i]').count()) > 0;
317
+ const hasPriceElements = (await page.locator('text=/\\$|€|¥|£/').count()) > 0;
318
+ const hasAddToCart = (await page.locator('button:has-text("Add to cart"), a:has-text("Add to cart")').count()) > 0;
319
+
320
+ if (hasCheckoutLink) evidence.push('checkout_link_found');
321
+ if (hasPriceElements) evidence.push('price_indicators_found');
322
+ if (hasAddToCart) evidence.push('add_to_cart_found');
323
+
324
+ return {
325
+ present: hasCheckoutLink || (hasPriceElements && hasAddToCart),
326
+ confidence: evidence.length / 3,
327
+ evidence
328
+ };
329
+ }
330
+
331
+ case 'contact_form': {
332
+ const hasContactForm = (await page.locator('form[name*="contact" i], form[id*="contact" i], [role="form"][aria-label*="contact"]').count()) > 0;
333
+ const hasContactLink = (await page.locator('a:has-text("Contact")').count()) > 0;
334
+ const hasEmailField = (await page.locator('input[type="email"]').count()) > 0;
335
+ const hasMessageField = (await page.locator('textarea').count()) > 0;
336
+
337
+ if (hasContactForm) evidence.push('contact_form_found');
338
+ if (hasContactLink) evidence.push('contact_link_found');
339
+ if (hasEmailField && hasMessageField) evidence.push('contact_form_elements_found');
340
+
341
+ return {
342
+ present: hasContactForm || hasContactLink || (hasEmailField && hasMessageField),
343
+ confidence: evidence.length / 3,
344
+ evidence
345
+ };
346
+ }
347
+
348
+ case 'newsletter': {
349
+ const hasNewsletterForm = (await page.locator('form[name*="newsletter" i], [aria-label*="newsletter" i]').count()) > 0;
350
+ const hasSubscribeButton = (await page.locator('button:has-text("Subscribe"), button:has-text("Sign up")').count()) > 0;
351
+ const hasNewsletterText = (await page.locator('text=/newsletter|subscribe/i').count()) > 0;
352
+
353
+ if (hasNewsletterForm) evidence.push('newsletter_form_found');
354
+ if (hasSubscribeButton) evidence.push('subscribe_button_found');
355
+ if (hasNewsletterText) evidence.push('newsletter_text_found');
356
+
357
+ return {
358
+ present: hasNewsletterForm || (hasSubscribeButton && hasNewsletterText),
359
+ confidence: evidence.length / 3,
360
+ evidence
361
+ };
362
+ }
363
+
364
+ case 'language_switch': {
365
+ const hasLangButton = (await page.locator('button[aria-label*="language" i], button:has-text("Language")').count()) > 0;
366
+ const hasLangSelect = (await page.locator('select[name*="lang" i]').count()) > 0;
367
+ const hasMultipleLangs = (await page.locator('a[hreflang]').count()) > 1;
368
+ const hasLangIndicator = (await page.locator('text=/en|de|fr|es|it|pt/i').count()) > 0;
369
+
370
+ if (hasLangButton) evidence.push('lang_button_found');
371
+ if (hasLangSelect) evidence.push('lang_select_found');
372
+ if (hasMultipleLangs) evidence.push('hreflang_links_found');
373
+ if (hasLangIndicator) evidence.push('lang_codes_found');
374
+
375
+ return {
376
+ present: hasLangButton || hasLangSelect || hasMultipleLangs,
377
+ confidence: evidence.length / 4,
378
+ evidence
379
+ };
380
+ }
381
+
382
+ default:
383
+ return { present: false, confidence: 0, evidence: [] };
384
+ }
385
+ } catch (err) {
386
+ return { present: false, confidence: 0, evidence: [`error: ${err.message}`] };
387
+ }
388
+ }
389
+
390
+ module.exports = {
391
+ buildSelectorChain,
392
+ findElement,
393
+ detectFeature
394
+ };
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Semantic Contact Detection
3
+ *
4
+ * Deterministic, multilingual detection of contact links and elements.
5
+ * Returns ranked candidates with source, confidence, and matched tokens.
6
+ */
7
+
8
+ const { getTokensForTarget, normalizeText, getMatchedToken } = require('./semantic-targets');
9
+
10
+ /**
11
+ * Confidence levels
12
+ */
13
+ const CONFIDENCE = {
14
+ HIGH: 'high',
15
+ MEDIUM: 'medium',
16
+ LOW: 'low'
17
+ };
18
+
19
+ /**
20
+ * Detection sources
21
+ */
22
+ const DETECTION_SOURCE = {
23
+ DATA_GUARDIAN: 'data-guardian',
24
+ ARIA: 'aria',
25
+ HREF: 'href',
26
+ TEXT: 'text',
27
+ NAV_FOOTER: 'nav/footer',
28
+ HEURISTIC: 'heuristic'
29
+ };
30
+
31
+ /**
32
+ * Detect contact candidates on page
33
+ *
34
+ * @param {Page} page - Playwright page object
35
+ * @param {string} baseUrl - Base URL for relative link resolution
36
+ * @returns {Promise<Array>} Array of contact candidates, ranked by confidence
37
+ */
38
+ async function detectContactCandidates(page, baseUrl = '') {
39
+ const candidates = [];
40
+
41
+ try {
42
+ const pageData = await page.evaluate(async () => {
43
+ const results = [];
44
+
45
+ // Find all clickable/linkable elements
46
+ const elements = document.querySelectorAll('a, button, [role="link"], [role="button"], [data-guardian], .nav a, footer a');
47
+
48
+ for (const el of elements) {
49
+ const data = {
50
+ tagName: el.tagName.toLowerCase(),
51
+ text: el.textContent?.trim() || '',
52
+ href: el.href || el.getAttribute('href') || '',
53
+ dataGuardian: el.getAttribute('data-guardian') || '',
54
+ ariaLabel: el.getAttribute('aria-label') || '',
55
+ title: el.getAttribute('title') || '',
56
+ className: el.className,
57
+ isInNav: !!el.closest('nav, [role="navigation"]'),
58
+ isInFooter: !!el.closest('footer, [role="contentinfo"]')
59
+ };
60
+
61
+ results.push(data);
62
+ }
63
+
64
+ return results;
65
+ });
66
+
67
+ // Process each element
68
+ for (const element of pageData) {
69
+ const contactCandidates = evaluateElement(element, baseUrl);
70
+ candidates.push(...contactCandidates);
71
+ }
72
+
73
+ // Sort by confidence (high > medium > low) and then by detection order
74
+ candidates.sort((a, b) => {
75
+ const confidenceOrder = { high: 0, medium: 1, low: 2 };
76
+ return confidenceOrder[a.confidence] - confidenceOrder[b.confidence];
77
+ });
78
+
79
+ // Remove duplicates (same href or same text)
80
+ const seen = new Set();
81
+ const deduplicated = [];
82
+
83
+ for (const candidate of candidates) {
84
+ const key = `${candidate.matchedText}:${candidate.href}`;
85
+ if (!seen.has(key)) {
86
+ seen.add(key);
87
+ deduplicated.push(candidate);
88
+ }
89
+ }
90
+
91
+ return deduplicated;
92
+ } catch (error) {
93
+ console.warn(`Failed to detect contact candidates: ${error.message}`);
94
+ return candidates;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Evaluate a single element for contact relevance
100
+ */
101
+ function evaluateElement(element, baseUrl = '') {
102
+ const candidates = [];
103
+ const contactTokens = getTokensForTarget('contact');
104
+
105
+ // Rule A: data-guardian attribute (highest priority)
106
+ if (element.dataGuardian) {
107
+ const normalized = normalizeText(element.dataGuardian);
108
+ if (normalized.includes('contact')) {
109
+ candidates.push({
110
+ selector: buildSelector(element),
111
+ matchedText: element.text || element.dataGuardian,
112
+ matchedToken: 'contact',
113
+ source: DETECTION_SOURCE.DATA_GUARDIAN,
114
+ confidence: CONFIDENCE.HIGH,
115
+ href: element.href,
116
+ ariaLabel: element.ariaLabel
117
+ });
118
+ return candidates;
119
+ }
120
+ }
121
+
122
+ // Rule B: href-based detection
123
+ if (element.href) {
124
+ const normalizedHref = normalizeText(element.href);
125
+ const matchedToken = getMatchedToken(normalizedHref, contactTokens);
126
+
127
+ if (matchedToken) {
128
+ candidates.push({
129
+ selector: buildSelector(element),
130
+ matchedText: element.text || element.href,
131
+ matchedToken: matchedToken,
132
+ source: DETECTION_SOURCE.HREF,
133
+ confidence: CONFIDENCE.HIGH,
134
+ href: element.href,
135
+ ariaLabel: element.ariaLabel
136
+ });
137
+ }
138
+ }
139
+
140
+ // Rule C: visible text-based detection
141
+ if (element.text) {
142
+ const normalizedText = normalizeText(element.text);
143
+ const matchedToken = getMatchedToken(normalizedText, contactTokens);
144
+
145
+ if (matchedToken) {
146
+ // Higher confidence if in nav or footer
147
+ let confidence = CONFIDENCE.MEDIUM;
148
+ let source = DETECTION_SOURCE.TEXT;
149
+
150
+ if (element.isInNav || element.isInFooter) {
151
+ confidence = CONFIDENCE.HIGH;
152
+ source = element.isInNav ? DETECTION_SOURCE.NAV_FOOTER : DETECTION_SOURCE.NAV_FOOTER;
153
+ }
154
+
155
+ candidates.push({
156
+ selector: buildSelector(element),
157
+ matchedText: element.text,
158
+ matchedToken: matchedToken,
159
+ source: source,
160
+ confidence: confidence,
161
+ href: element.href,
162
+ ariaLabel: element.ariaLabel
163
+ });
164
+ }
165
+ }
166
+
167
+ // Rule D: aria-label or title attribute
168
+ if (element.ariaLabel || element.title) {
169
+ const textToCheck = element.ariaLabel || element.title;
170
+ const normalizedText = normalizeText(textToCheck);
171
+ const matchedToken = getMatchedToken(normalizedText, contactTokens);
172
+
173
+ if (matchedToken) {
174
+ candidates.push({
175
+ selector: buildSelector(element),
176
+ matchedText: textToCheck,
177
+ matchedToken: matchedToken,
178
+ source: DETECTION_SOURCE.ARIA,
179
+ confidence: CONFIDENCE.MEDIUM,
180
+ href: element.href,
181
+ ariaLabel: element.ariaLabel
182
+ });
183
+ }
184
+ }
185
+
186
+ return candidates;
187
+ }
188
+
189
+ /**
190
+ * Build a CSS selector for an element
191
+ */
192
+ function buildSelector(element) {
193
+ // Prefer data-guardian if available
194
+ if (element.dataGuardian) {
195
+ return `[data-guardian="${element.dataGuardian}"]`;
196
+ }
197
+
198
+ // For links/buttons, use href or text
199
+ if (element.tagName === 'a' && element.href) {
200
+ // Use href in selector
201
+ return `a[href*="${normalizeHrefForSelector(element.href)}"]`;
202
+ }
203
+
204
+ if (element.ariaLabel) {
205
+ return `${element.tagName}[aria-label*="${element.ariaLabel}"]`;
206
+ }
207
+
208
+ // Fallback
209
+ return `${element.tagName}`;
210
+ }
211
+
212
+ /**
213
+ * Normalize href for use in CSS selector
214
+ */
215
+ function normalizeHrefForSelector(href) {
216
+ // Extract path portion
217
+ try {
218
+ const url = new URL(href, 'http://localhost');
219
+ return url.pathname.split('/').filter(p => p)[0] || '';
220
+ } catch {
221
+ // If URL parsing fails, extract first path component
222
+ return href.split('/')[1] || '';
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Format detection result for human-readable output
228
+ */
229
+ function formatDetectionResult(candidate, language = 'unknown') {
230
+ const languageStr = language !== 'unknown' ? `lang=${language}` : 'lang=unknown';
231
+ const parts = [
232
+ `Contact detected`,
233
+ `(${languageStr}`,
234
+ `source=${candidate.source}`,
235
+ `token=${candidate.matchedToken}`,
236
+ `confidence=${candidate.confidence})`
237
+ ];
238
+
239
+ return parts.join(', ');
240
+ }
241
+
242
+ /**
243
+ * Get hint message if contact not found
244
+ */
245
+ function getNoContactFoundHint() {
246
+ return 'No contact found. Consider adding a stable marker like data-guardian="contact" or ensure contact link text/href is recognizable.';
247
+ }
248
+
249
+ module.exports = {
250
+ detectContactCandidates,
251
+ formatDetectionResult,
252
+ getNoContactFoundHint,
253
+ CONFIDENCE,
254
+ DETECTION_SOURCE
255
+ };