@odavl/guardian 2.0.0 → 2.0.1

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 (172) hide show
  1. package/CHANGELOG.md +210 -210
  2. package/LICENSE +21 -21
  3. package/README.md +297 -184
  4. package/bin/guardian.js +2242 -2221
  5. package/config/README.md +59 -59
  6. package/config/guardian.config.json +54 -54
  7. package/config/guardian.policy.json +12 -12
  8. package/config/profiles/docs.yaml +18 -18
  9. package/config/profiles/ecommerce.yaml +17 -17
  10. package/config/profiles/landing-demo.yaml +16 -16
  11. package/config/profiles/marketing.yaml +18 -18
  12. package/config/profiles/saas.yaml +21 -21
  13. package/flows/example-login-flow.json +36 -36
  14. package/flows/example-signup-flow.json +44 -44
  15. package/package.json +124 -116
  16. package/policies/enterprise.json +12 -12
  17. package/policies/landing-demo.json +22 -22
  18. package/policies/saas.json +12 -12
  19. package/policies/startup.json +12 -12
  20. package/src/enterprise/audit-logger.js +166 -166
  21. package/src/enterprise/pdf-exporter.js +267 -267
  22. package/src/enterprise/rbac-gate.js +142 -142
  23. package/src/enterprise/rbac.js +239 -239
  24. package/src/enterprise/site-manager.js +180 -180
  25. package/src/founder/feedback-system.js +156 -156
  26. package/src/founder/founder-tracker.js +213 -213
  27. package/src/founder/usage-signals.js +141 -141
  28. package/src/guardian/action-hints.js +439 -439
  29. package/src/guardian/alert-ledger.js +121 -121
  30. package/src/guardian/artifact-sanitizer.js +56 -56
  31. package/src/guardian/attempt-engine.js +1069 -1029
  32. package/src/guardian/attempt-registry.js +267 -267
  33. package/src/guardian/attempt-relevance.js +106 -106
  34. package/src/guardian/attempt-reporter.js +513 -507
  35. package/src/guardian/attempt.js +274 -273
  36. package/src/guardian/attempts-filter.js +63 -63
  37. package/src/guardian/auto-attempt-builder.js +283 -283
  38. package/src/guardian/baseline-registry.js +177 -177
  39. package/src/guardian/baseline-reporter.js +143 -143
  40. package/src/guardian/baseline-storage.js +285 -285
  41. package/src/guardian/baseline.js +535 -534
  42. package/src/guardian/behavioral-signals.js +261 -261
  43. package/src/guardian/breakage-intelligence.js +224 -224
  44. package/src/guardian/browser-pool.js +131 -131
  45. package/src/guardian/browser.js +119 -119
  46. package/src/guardian/canonical-truth.js +308 -308
  47. package/src/guardian/ci-cli.js +121 -121
  48. package/src/guardian/ci-gate.js +96 -96
  49. package/src/guardian/ci-mode.js +15 -15
  50. package/src/guardian/ci-output.js +55 -38
  51. package/src/guardian/cli-summary.js +102 -102
  52. package/src/guardian/confidence-signals.js +251 -251
  53. package/src/guardian/config-loader.js +161 -161
  54. package/src/guardian/config-validator.js +285 -283
  55. package/src/guardian/coverage-model.js +239 -239
  56. package/src/guardian/coverage-packs.js +58 -58
  57. package/src/guardian/crawler.js +142 -142
  58. package/src/guardian/data-guardian-detector.js +189 -189
  59. package/src/guardian/decision-authority.js +746 -725
  60. package/src/guardian/detection-layers.js +271 -271
  61. package/src/guardian/determinism.js +146 -146
  62. package/src/guardian/discovery-engine.js +661 -661
  63. package/src/guardian/drift-detector.js +100 -100
  64. package/src/guardian/enhanced-html-reporter.js +522 -522
  65. package/src/guardian/env-guard.js +128 -127
  66. package/src/guardian/error-clarity.js +399 -399
  67. package/src/guardian/export-contract.js +196 -196
  68. package/src/guardian/fail-safe.js +212 -212
  69. package/src/guardian/failure-intelligence.js +173 -173
  70. package/src/guardian/failure-taxonomy.js +169 -169
  71. package/src/guardian/final-outcome.js +206 -206
  72. package/src/guardian/first-run-profile.js +89 -89
  73. package/src/guardian/first-run.js +65 -67
  74. package/src/guardian/flag-validator.js +111 -111
  75. package/src/guardian/flow-executor.js +641 -639
  76. package/src/guardian/flow-registry.js +67 -67
  77. package/src/guardian/honesty.js +394 -394
  78. package/src/guardian/html-reporter.js +416 -416
  79. package/src/guardian/human-intent-resolver.js +296 -296
  80. package/src/guardian/human-interaction-model.js +351 -351
  81. package/src/guardian/human-journey-context.js +184 -184
  82. package/src/guardian/human-navigator.js +544 -544
  83. package/src/guardian/human-reporter.js +435 -431
  84. package/src/guardian/index.js +226 -221
  85. package/src/guardian/init-command.js +143 -143
  86. package/src/guardian/intent-detector.js +148 -146
  87. package/src/guardian/journey-definitions.js +132 -132
  88. package/src/guardian/journey-scan-cli.js +142 -145
  89. package/src/guardian/journey-scanner.js +583 -583
  90. package/src/guardian/junit-reporter.js +281 -281
  91. package/src/guardian/language-detection.js +99 -99
  92. package/src/guardian/live-alert.js +56 -56
  93. package/src/guardian/live-baseline-compare.js +146 -146
  94. package/src/guardian/live-cli.js +95 -95
  95. package/src/guardian/live-guardian.js +210 -210
  96. package/src/guardian/live-scheduler-runner.js +137 -137
  97. package/src/guardian/live-scheduler-state.js +167 -168
  98. package/src/guardian/live-scheduler.js +146 -146
  99. package/src/guardian/live-state.js +110 -110
  100. package/src/guardian/market-criticality.js +335 -335
  101. package/src/guardian/market-reporter.js +577 -577
  102. package/src/guardian/network-trace.js +178 -178
  103. package/src/guardian/obs-logger.js +110 -110
  104. package/src/guardian/observed-capabilities.js +427 -427
  105. package/src/guardian/output-contract.js +154 -0
  106. package/src/guardian/output-readability.js +264 -264
  107. package/src/guardian/parallel-executor.js +116 -116
  108. package/src/guardian/path-safety.js +56 -56
  109. package/src/guardian/pattern-analyzer.js +348 -348
  110. package/src/guardian/policy.js +432 -434
  111. package/src/guardian/prelaunch-gate.js +193 -193
  112. package/src/guardian/prerequisite-checker.js +101 -101
  113. package/src/guardian/preset-loader.js +152 -157
  114. package/src/guardian/profile-loader.js +96 -96
  115. package/src/guardian/reality.js +3025 -2826
  116. package/src/guardian/realworld-scenarios.js +94 -94
  117. package/src/guardian/reporter.js +167 -167
  118. package/src/guardian/retry-policy.js +123 -123
  119. package/src/guardian/root-cause-analysis.js +171 -171
  120. package/src/guardian/rules-engine.js +558 -558
  121. package/src/guardian/run-artifacts.js +212 -212
  122. package/src/guardian/run-cleanup.js +207 -207
  123. package/src/guardian/run-export.js +522 -522
  124. package/src/guardian/run-latest.js +90 -90
  125. package/src/guardian/run-list.js +211 -211
  126. package/src/guardian/run-summary.js +20 -20
  127. package/src/guardian/runtime-root.js +246 -246
  128. package/src/guardian/safety.js +248 -248
  129. package/src/guardian/scan-presets.js +133 -149
  130. package/src/guardian/screenshot.js +152 -152
  131. package/src/guardian/secret-hygiene.js +44 -44
  132. package/src/guardian/selector-fallbacks.js +394 -394
  133. package/src/guardian/semantic-contact-detection.js +255 -255
  134. package/src/guardian/semantic-contact-finder.js +201 -201
  135. package/src/guardian/semantic-targets.js +234 -234
  136. package/src/guardian/site-intelligence.js +588 -588
  137. package/src/guardian/site-introspection.js +257 -257
  138. package/src/guardian/sitemap.js +225 -225
  139. package/src/guardian/smoke.js +283 -258
  140. package/src/guardian/snapshot-schema.js +177 -290
  141. package/src/guardian/snapshot.js +430 -397
  142. package/src/guardian/stability-scorer.js +169 -169
  143. package/src/guardian/success-evaluator.js +214 -214
  144. package/src/guardian/template-command.js +184 -184
  145. package/src/guardian/text-formatters.js +426 -426
  146. package/src/guardian/timeout-profiles.js +57 -57
  147. package/src/guardian/truth/attempt.contract.js +158 -0
  148. package/src/guardian/truth/decision.contract.js +275 -0
  149. package/src/guardian/truth/snapshot.contract.js +363 -0
  150. package/src/guardian/validators.js +323 -323
  151. package/src/guardian/verdict-card.js +474 -474
  152. package/src/guardian/verdict-clarity.js +298 -298
  153. package/src/guardian/verdict-policy.js +363 -363
  154. package/src/guardian/verdict.js +333 -333
  155. package/src/guardian/verdicts.js +79 -74
  156. package/src/guardian/visual-diff.js +247 -247
  157. package/src/guardian/wait-for-outcome.js +119 -119
  158. package/src/guardian/watch-runner.js +181 -181
  159. package/src/guardian/watchdog-diff.js +167 -167
  160. package/src/guardian/webhook.js +206 -206
  161. package/src/payments/stripe-checkout.js +169 -169
  162. package/src/plans/plan-definitions.js +148 -148
  163. package/src/plans/plan-manager.js +211 -211
  164. package/src/plans/usage-tracker.js +210 -210
  165. package/src/recipes/recipe-engine.js +188 -188
  166. package/src/recipes/recipe-failure-analysis.js +159 -159
  167. package/src/recipes/recipe-registry.js +134 -134
  168. package/src/recipes/recipe-runtime.js +507 -507
  169. package/src/recipes/recipe-store.js +410 -410
  170. package/SECURITY.md +0 -77
  171. package/VERSIONING.md +0 -100
  172. package/guardian-contract-v1.md +0 -502
@@ -1,427 +1,427 @@
1
- /**
2
- * Observed Capabilities Extractor
3
- * Phase 11: Extract what's actually observable on the site
4
- *
5
- * CORE PRINCIPLE: VISIBLE = MUST WORK, NOT VISIBLE = NOT APPLICABLE
6
- *
7
- * This module maps observable UI elements to capabilities.
8
- * If a capability is not observed, any attempt targeting it is NOT_APPLICABLE.
9
- * NOT_APPLICABLE results do NOT count as failures or friction.
10
- */
11
-
12
- /**
13
- * Capability mappings: what to look for on the page
14
- */
15
- const CAPABILITY_MAPPINGS = {
16
- // Login/Authentication capabilities
17
- login: {
18
- selectors: [
19
- 'a[href*="login"]',
20
- 'a[href*="signin"]',
21
- 'a[href*="auth"]',
22
- 'button:has-text("Login")',
23
- 'button:has-text("Sign in")',
24
- 'button:has-text("Sign In")',
25
- 'a:has-text("Login")',
26
- 'a:has-text("Sign in")',
27
- 'a:has-text("Sign In")',
28
- '[data-testid*="login"]',
29
- '[data-guardian*="login"]'
30
- ],
31
- textPatterns: ['login', 'sign in', 'signin', 'sign-in'],
32
- attemptIds: ['login', 'login_flow']
33
- },
34
-
35
- // Signup/Registration capabilities
36
- signup: {
37
- selectors: [
38
- 'a[href*="signup"]',
39
- 'a[href*="register"]',
40
- 'a[href*="join"]',
41
- 'a[href*="onboarding"]',
42
- 'button:has-text("Sign up")',
43
- 'button:has-text("Sign Up")',
44
- 'button:has-text("Register")',
45
- 'button:has-text("Get started")',
46
- 'a:has-text("Sign up")',
47
- 'a:has-text("Sign Up")',
48
- 'a:has-text("Register")',
49
- '[data-testid*="signup"]',
50
- '[data-guardian*="signup"]'
51
- ],
52
- textPatterns: ['signup', 'sign up', 'register', 'get started', 'join'],
53
- attemptIds: ['signup', 'signup_flow']
54
- },
55
-
56
- // Checkout/Payment capabilities
57
- checkout: {
58
- selectors: [
59
- 'a[href*="checkout"]',
60
- 'a[href*="cart"]',
61
- 'a[href*="cart"]',
62
- 'button:has-text("Checkout")',
63
- 'button:has-text("Buy")',
64
- 'button:has-text("Purchase")',
65
- 'button:has-text("Add to cart")',
66
- 'a:has-text("Checkout")',
67
- '[data-testid*="checkout"]',
68
- '[data-guardian*="checkout"]'
69
- ],
70
- textPatterns: ['checkout', 'add to cart', 'buy', 'purchase', 'add to cart'],
71
- attemptIds: ['checkout', 'checkout_flow']
72
- },
73
-
74
- // Contact form capability
75
- contact_form: {
76
- selectors: [
77
- 'a[href*="contact"]',
78
- 'form[action*="contact"]',
79
- 'input[name*="email"]',
80
- 'textarea[name*="message"]',
81
- 'button:has-text("Contact")',
82
- 'a:has-text("Contact")',
83
- '[data-guardian*="contact"]',
84
- '[data-testid*="contact"]'
85
- ],
86
- textPatterns: ['contact', 'contact us', 'get in touch'],
87
- attemptIds: ['contact_form', 'contact_discovery_v2']
88
- },
89
-
90
- // Newsletter signup capability
91
- newsletter_signup: {
92
- selectors: [
93
- 'a[href*="newsletter"]',
94
- 'input[name*="newsletter"]',
95
- 'input[placeholder*="email"]',
96
- 'button:has-text("Subscribe")',
97
- 'button:has-text("Sign up")',
98
- 'a:has-text("Subscribe")',
99
- '[data-guardian*="newsletter"]',
100
- '[data-testid*="newsletter"]'
101
- ],
102
- textPatterns: ['newsletter', 'subscribe', 'email'],
103
- attemptIds: ['newsletter_signup']
104
- },
105
-
106
- // Language switch capability
107
- language_switch: {
108
- selectors: [
109
- 'button:has-text("Language")',
110
- 'button:has-text("Lang")',
111
- '[data-guardian*="lang"]',
112
- '[data-testid*="lang"]',
113
- 'a[href*="lang"]',
114
- 'a[hreflang]'
115
- ],
116
- textPatterns: ['language', 'lang', 'deutsch', 'english', 'français'],
117
- attemptIds: ['language_switch']
118
- },
119
-
120
- // Primary CTAs (always present on marketing sites)
121
- primary_ctas: {
122
- selectors: [
123
- 'button',
124
- 'a[class*="button"]',
125
- 'a[class*="cta"]',
126
- '[role="button"]'
127
- ],
128
- minCount: 1,
129
- attemptIds: ['primary_ctas']
130
- }
131
- };
132
-
133
- /**
134
- * Extract observed capabilities from site intelligence data
135
- *
136
- * @param {Object} siteIntelligence - Result from analyzeSite()
137
- * @returns {Object} Observed capabilities map
138
- */
139
- function extractObservedCapabilities(siteIntelligence) {
140
- const observed = {
141
- timestamp: new Date().toISOString(),
142
- capabilities: {},
143
- mapping: {} // Maps capability name to evidence
144
- };
145
-
146
- if (!siteIntelligence || !siteIntelligence.capabilities) {
147
- // Conservative: if no intelligence, assume ALL capabilities present
148
- // This prevents false NOT_APPLICABLE when analysis fails
149
- return {
150
- ...observed,
151
- capabilities: {
152
- login: true,
153
- signup: true,
154
- checkout: true,
155
- contact_form: true,
156
- newsletter_signup: true,
157
- language_switch: true,
158
- primary_ctas: true
159
- }
160
- };
161
- }
162
-
163
- // Map site-intelligence capabilities to observed capabilities
164
- const caps = siteIntelligence.capabilities || {};
165
-
166
- observed.capabilities.login = (
167
- caps.supports_login?.supported === true ||
168
- caps[Object.keys(CAPABILITY_MAPPINGS).find(k => k === 'login')]?.supported === true
169
- );
170
- observed.mapping.login = caps.supports_login || {};
171
-
172
- observed.capabilities.signup = (
173
- caps.supports_signup?.supported === true ||
174
- caps[Object.keys(CAPABILITY_MAPPINGS).find(k => k === 'signup')]?.supported === true
175
- );
176
- observed.mapping.signup = caps.supports_signup || {};
177
-
178
- observed.capabilities.checkout = (
179
- caps.supports_checkout?.supported === true ||
180
- caps[Object.keys(CAPABILITY_MAPPINGS).find(k => k === 'checkout')]?.supported === true
181
- );
182
- observed.mapping.checkout = caps.supports_checkout || {};
183
-
184
- observed.capabilities.contact_form = (
185
- caps.supports_contact?.supported === true ||
186
- caps[Object.keys(CAPABILITY_MAPPINGS).find(k => k === 'contact_form')]?.supported === true
187
- );
188
- observed.mapping.contact_form = caps.supports_contact || {};
189
-
190
- observed.capabilities.newsletter_signup = (
191
- caps.supports_newsletter?.supported === true ||
192
- caps[Object.keys(CAPABILITY_MAPPINGS).find(k => k === 'newsletter_signup')]?.supported === true
193
- );
194
- observed.mapping.newsletter_signup = caps.supports_newsletter || {};
195
-
196
- observed.capabilities.language_switch = (
197
- caps.supports_language_switch?.supported === true ||
198
- caps[Object.keys(CAPABILITY_MAPPINGS).find(k => k === 'language_switch')]?.supported === true
199
- );
200
- observed.mapping.language_switch = caps.supports_language_switch || {};
201
-
202
- // Primary CTAs: assume true unless we have explicit evidence of none
203
- observed.capabilities.primary_ctas = (
204
- caps.supports_primary_cta?.supported !== false
205
- );
206
- observed.mapping.primary_ctas = caps.supports_primary_cta || { supported: true };
207
-
208
- // PHASE 11: Internal/admin surfaces detection
209
- // Requires BOTH URL pattern AND UI label evidence to avoid false positives
210
- observed.capabilities.internal_admin = detectInternalSurfaces(siteIntelligence);
211
- observed.mapping.internal_admin = { detected: observed.capabilities.internal_admin };
212
-
213
- return observed;
214
- }
215
-
216
- /**
217
- * Detect internal/admin surfaces with strong evidence requirements
218
- * PHASE 11: Internal surfaces must NOT penalize public readiness
219
- *
220
- * Detection requires BOTH:
221
- * - URL pattern (/admin, /wp-admin, /internal, /dashboard, /staff)
222
- * - UI label evidence ("Admin", "Staff", "Internal", "Management")
223
- *
224
- * @param {Object} siteIntelligence - Site intelligence data
225
- * @returns {boolean} - true if internal admin surface detected
226
- */
227
- function detectInternalSurfaces(siteIntelligence) {
228
- if (!siteIntelligence || !siteIntelligence.crawl || !siteIntelligence.crawl.discovered) {
229
- return false;
230
- }
231
-
232
- const discovered = siteIntelligence.crawl.discovered;
233
- const internalUrlPatterns = ['/admin', '/wp-admin', '/internal', '/dashboard', '/staff', '/management'];
234
- const internalLabelPatterns = ['admin', 'staff', 'internal', 'management', 'dashboard'];
235
-
236
- // Check each discovered URL (can be string or object with url + metadata)
237
- for (const urlEntry of discovered) {
238
- // Handle both string URLs and object format
239
- let url = '';
240
- let text = '';
241
-
242
- if (typeof urlEntry === 'string') {
243
- url = urlEntry;
244
- } else if (urlEntry && typeof urlEntry === 'object') {
245
- url = urlEntry.url || urlEntry.href || '';
246
- text = urlEntry.text || urlEntry.label || urlEntry.linkText || '';
247
- }
248
-
249
- // Check if URL matches internal pattern
250
- const hasInternalUrl = internalUrlPatterns.some(pattern => url.toLowerCase().includes(pattern));
251
-
252
- // Check if text/label matches internal pattern (case-insensitive)
253
- const hasInternalLabel = text && internalLabelPatterns.some(pattern =>
254
- text.toLowerCase().includes(pattern)
255
- );
256
-
257
- // Require BOTH URL and label evidence to minimize false positives
258
- if (hasInternalUrl && hasInternalLabel) {
259
- return true;
260
- }
261
- }
262
-
263
- return false;
264
- }
265
-
266
- /**
267
- * Check if a capability is observed
268
- *
269
- * @param {Object} observedCapabilities - Result from extractObservedCapabilities()
270
- * @param {string} capabilityName - Name of capability (e.g., 'login', 'signup')
271
- * @returns {boolean} True if capability is observed
272
- */
273
- function isCapabilityObserved(observedCapabilities, capabilityName) {
274
- if (!observedCapabilities || !observedCapabilities.capabilities) {
275
- return false;
276
- }
277
- return observedCapabilities.capabilities[capabilityName] === true;
278
- }
279
-
280
- /**
281
- * Get applicable attempts based on observed capabilities
282
- * Maps attempt IDs to the capabilities they require
283
- *
284
- * @param {Array} attemptIds - List of attempt IDs to filter
285
- * @param {Object} observedCapabilities - Result from extractObservedCapabilities()
286
- * @returns {Object} { applicable: Array, notApplicable: Array }
287
- */
288
- function filterAttemptsByObservedCapabilities(attemptIds, observedCapabilities) {
289
- const applicable = [];
290
- const notApplicable = [];
291
-
292
- if (!attemptIds || !Array.isArray(attemptIds)) {
293
- return { applicable: [], notApplicable: [] };
294
- }
295
-
296
- for (const attemptId of attemptIds) {
297
- // Find which capability this attempt requires
298
- let requiredCapability = null;
299
- for (const [capName, mapping] of Object.entries(CAPABILITY_MAPPINGS)) {
300
- if (mapping.attemptIds.includes(attemptId)) {
301
- requiredCapability = capName;
302
- break;
303
- }
304
- }
305
-
306
- // If no capability mapping, always run (universal attempt)
307
- if (!requiredCapability) {
308
- applicable.push(attemptId);
309
- continue;
310
- }
311
-
312
- // Check if capability is observed
313
- if (isCapabilityObserved(observedCapabilities, requiredCapability)) {
314
- applicable.push(attemptId);
315
- } else {
316
- notApplicable.push({
317
- attemptId,
318
- reason: `Capability not observed: ${requiredCapability}`,
319
- capabilityRequired: requiredCapability
320
- });
321
- }
322
- }
323
-
324
- return { applicable, notApplicable };
325
- }
326
-
327
- /**
328
- * Get applicable flows based on observed capabilities
329
- *
330
- * @param {Array} flowIds - List of flow IDs to filter
331
- * @param {Object} observedCapabilities - Result from extractObservedCapabilities()
332
- * @returns {Object} { applicable: Array, notApplicable: Array }
333
- */
334
- function filterFlowsByObservedCapabilities(flowIds, observedCapabilities) {
335
- const applicable = [];
336
- const notApplicable = [];
337
-
338
- if (!flowIds || !Array.isArray(flowIds)) {
339
- return { applicable: [], notApplicable: [] };
340
- }
341
-
342
- const flowCapabilityMap = {
343
- 'login_flow': 'login',
344
- 'signup_flow': 'signup',
345
- 'checkout_flow': 'checkout',
346
- 'contact_flow': 'contact_form'
347
- };
348
-
349
- for (const flowId of flowIds) {
350
- const requiredCapability = flowCapabilityMap[flowId];
351
-
352
- // If no mapping, always run
353
- if (!requiredCapability) {
354
- applicable.push(flowId);
355
- continue;
356
- }
357
-
358
- // Check if capability is observed
359
- if (isCapabilityObserved(observedCapabilities, requiredCapability)) {
360
- applicable.push(flowId);
361
- } else {
362
- notApplicable.push({
363
- flowId,
364
- reason: `Capability not observed: ${requiredCapability}`,
365
- capabilityRequired: requiredCapability
366
- });
367
- }
368
- }
369
-
370
- return { applicable, notApplicable };
371
- }
372
-
373
- /**
374
- * Create NOT_APPLICABLE result for an attempt
375
- *
376
- * @param {string} attemptId - Attempt ID
377
- * @param {string} capabilityName - Name of unobserved capability
378
- * @returns {Object} Result object with NOT_APPLICABLE outcome
379
- */
380
- function createNotApplicableAttemptResult(attemptId, capabilityName) {
381
- return {
382
- attemptId,
383
- attemptName: attemptId,
384
- goal: 'Unknown',
385
- riskCategory: 'UNKNOWN',
386
- source: 'capability-check',
387
- outcome: 'NOT_APPLICABLE',
388
- skipReason: `Capability not observed: ${capabilityName}`,
389
- skipReasonCode: 'NOT_APPLICABLE', // CRITICAL: Must match SKIP_CODES.NOT_APPLICABLE for coverage calculation
390
- exitCode: 0, // NOT_APPLICABLE doesn't affect exit code
391
- executed: false,
392
- steps: [],
393
- friction: null,
394
- error: null
395
- };
396
- }
397
-
398
- /**
399
- * Create NOT_APPLICABLE result for a flow
400
- *
401
- * @param {string} flowId - Flow ID
402
- * @param {string} capabilityName - Name of unobserved capability
403
- * @returns {Object} Result object with NOT_APPLICABLE outcome
404
- */
405
- function createNotApplicableFlowResult(flowId, capabilityName) {
406
- return {
407
- flowId,
408
- flowName: flowId,
409
- outcome: 'NOT_APPLICABLE',
410
- skipReason: `Capability not observed: ${capabilityName}`,
411
- skipReasonCode: 'NOT_OBSERVABLE',
412
- success: null,
413
- executed: false,
414
- steps: [],
415
- error: null
416
- };
417
- }
418
-
419
- module.exports = {
420
- CAPABILITY_MAPPINGS,
421
- extractObservedCapabilities,
422
- isCapabilityObserved,
423
- filterAttemptsByObservedCapabilities,
424
- filterFlowsByObservedCapabilities,
425
- createNotApplicableAttemptResult,
426
- createNotApplicableFlowResult
427
- };
1
+ /**
2
+ * Observed Capabilities Extractor
3
+ * Phase 11: Extract what's actually observable on the site
4
+ *
5
+ * CORE PRINCIPLE: VISIBLE = MUST WORK, NOT VISIBLE = NOT APPLICABLE
6
+ *
7
+ * This module maps observable UI elements to capabilities.
8
+ * If a capability is not observed, any attempt targeting it is NOT_APPLICABLE.
9
+ * NOT_APPLICABLE results do NOT count as failures or friction.
10
+ */
11
+
12
+ /**
13
+ * Capability mappings: what to look for on the page
14
+ */
15
+ const CAPABILITY_MAPPINGS = {
16
+ // Login/Authentication capabilities
17
+ login: {
18
+ selectors: [
19
+ 'a[href*="login"]',
20
+ 'a[href*="signin"]',
21
+ 'a[href*="auth"]',
22
+ 'button:has-text("Login")',
23
+ 'button:has-text("Sign in")',
24
+ 'button:has-text("Sign In")',
25
+ 'a:has-text("Login")',
26
+ 'a:has-text("Sign in")',
27
+ 'a:has-text("Sign In")',
28
+ '[data-testid*="login"]',
29
+ '[data-guardian*="login"]'
30
+ ],
31
+ textPatterns: ['login', 'sign in', 'signin', 'sign-in'],
32
+ attemptIds: ['login', 'login_flow']
33
+ },
34
+
35
+ // Signup/Registration capabilities
36
+ signup: {
37
+ selectors: [
38
+ 'a[href*="signup"]',
39
+ 'a[href*="register"]',
40
+ 'a[href*="join"]',
41
+ 'a[href*="onboarding"]',
42
+ 'button:has-text("Sign up")',
43
+ 'button:has-text("Sign Up")',
44
+ 'button:has-text("Register")',
45
+ 'button:has-text("Get started")',
46
+ 'a:has-text("Sign up")',
47
+ 'a:has-text("Sign Up")',
48
+ 'a:has-text("Register")',
49
+ '[data-testid*="signup"]',
50
+ '[data-guardian*="signup"]'
51
+ ],
52
+ textPatterns: ['signup', 'sign up', 'register', 'get started', 'join'],
53
+ attemptIds: ['signup', 'signup_flow']
54
+ },
55
+
56
+ // Checkout/Payment capabilities
57
+ checkout: {
58
+ selectors: [
59
+ 'a[href*="checkout"]',
60
+ 'a[href*="cart"]',
61
+ 'a[href*="cart"]',
62
+ 'button:has-text("Checkout")',
63
+ 'button:has-text("Buy")',
64
+ 'button:has-text("Purchase")',
65
+ 'button:has-text("Add to cart")',
66
+ 'a:has-text("Checkout")',
67
+ '[data-testid*="checkout"]',
68
+ '[data-guardian*="checkout"]'
69
+ ],
70
+ textPatterns: ['checkout', 'add to cart', 'buy', 'purchase', 'add to cart'],
71
+ attemptIds: ['checkout', 'checkout_flow']
72
+ },
73
+
74
+ // Contact form capability
75
+ contact_form: {
76
+ selectors: [
77
+ 'a[href*="contact"]',
78
+ 'form[action*="contact"]',
79
+ 'input[name*="email"]',
80
+ 'textarea[name*="message"]',
81
+ 'button:has-text("Contact")',
82
+ 'a:has-text("Contact")',
83
+ '[data-guardian*="contact"]',
84
+ '[data-testid*="contact"]'
85
+ ],
86
+ textPatterns: ['contact', 'contact us', 'get in touch'],
87
+ attemptIds: ['contact_form', 'contact_discovery_v2']
88
+ },
89
+
90
+ // Newsletter signup capability
91
+ newsletter_signup: {
92
+ selectors: [
93
+ 'a[href*="newsletter"]',
94
+ 'input[name*="newsletter"]',
95
+ 'input[placeholder*="email"]',
96
+ 'button:has-text("Subscribe")',
97
+ 'button:has-text("Sign up")',
98
+ 'a:has-text("Subscribe")',
99
+ '[data-guardian*="newsletter"]',
100
+ '[data-testid*="newsletter"]'
101
+ ],
102
+ textPatterns: ['newsletter', 'subscribe', 'email'],
103
+ attemptIds: ['newsletter_signup']
104
+ },
105
+
106
+ // Language switch capability
107
+ language_switch: {
108
+ selectors: [
109
+ 'button:has-text("Language")',
110
+ 'button:has-text("Lang")',
111
+ '[data-guardian*="lang"]',
112
+ '[data-testid*="lang"]',
113
+ 'a[href*="lang"]',
114
+ 'a[hreflang]'
115
+ ],
116
+ textPatterns: ['language', 'lang', 'deutsch', 'english', 'français'],
117
+ attemptIds: ['language_switch']
118
+ },
119
+
120
+ // Primary CTAs (always present on marketing sites)
121
+ primary_ctas: {
122
+ selectors: [
123
+ 'button',
124
+ 'a[class*="button"]',
125
+ 'a[class*="cta"]',
126
+ '[role="button"]'
127
+ ],
128
+ minCount: 1,
129
+ attemptIds: ['primary_ctas']
130
+ }
131
+ };
132
+
133
+ /**
134
+ * Extract observed capabilities from site intelligence data
135
+ *
136
+ * @param {Object} siteIntelligence - Result from analyzeSite()
137
+ * @returns {Object} Observed capabilities map
138
+ */
139
+ function extractObservedCapabilities(siteIntelligence) {
140
+ const observed = {
141
+ timestamp: new Date().toISOString(),
142
+ capabilities: {},
143
+ mapping: {} // Maps capability name to evidence
144
+ };
145
+
146
+ if (!siteIntelligence || !siteIntelligence.capabilities) {
147
+ // Conservative: if no intelligence, assume ALL capabilities present
148
+ // This prevents false NOT_APPLICABLE when analysis fails
149
+ return {
150
+ ...observed,
151
+ capabilities: {
152
+ login: true,
153
+ signup: true,
154
+ checkout: true,
155
+ contact_form: true,
156
+ newsletter_signup: true,
157
+ language_switch: true,
158
+ primary_ctas: true
159
+ }
160
+ };
161
+ }
162
+
163
+ // Map site-intelligence capabilities to observed capabilities
164
+ const caps = siteIntelligence.capabilities || {};
165
+
166
+ observed.capabilities.login = (
167
+ caps.supports_login?.supported === true ||
168
+ caps[Object.keys(CAPABILITY_MAPPINGS).find(k => k === 'login')]?.supported === true
169
+ );
170
+ observed.mapping.login = caps.supports_login || {};
171
+
172
+ observed.capabilities.signup = (
173
+ caps.supports_signup?.supported === true ||
174
+ caps[Object.keys(CAPABILITY_MAPPINGS).find(k => k === 'signup')]?.supported === true
175
+ );
176
+ observed.mapping.signup = caps.supports_signup || {};
177
+
178
+ observed.capabilities.checkout = (
179
+ caps.supports_checkout?.supported === true ||
180
+ caps[Object.keys(CAPABILITY_MAPPINGS).find(k => k === 'checkout')]?.supported === true
181
+ );
182
+ observed.mapping.checkout = caps.supports_checkout || {};
183
+
184
+ observed.capabilities.contact_form = (
185
+ caps.supports_contact?.supported === true ||
186
+ caps[Object.keys(CAPABILITY_MAPPINGS).find(k => k === 'contact_form')]?.supported === true
187
+ );
188
+ observed.mapping.contact_form = caps.supports_contact || {};
189
+
190
+ observed.capabilities.newsletter_signup = (
191
+ caps.supports_newsletter?.supported === true ||
192
+ caps[Object.keys(CAPABILITY_MAPPINGS).find(k => k === 'newsletter_signup')]?.supported === true
193
+ );
194
+ observed.mapping.newsletter_signup = caps.supports_newsletter || {};
195
+
196
+ observed.capabilities.language_switch = (
197
+ caps.supports_language_switch?.supported === true ||
198
+ caps[Object.keys(CAPABILITY_MAPPINGS).find(k => k === 'language_switch')]?.supported === true
199
+ );
200
+ observed.mapping.language_switch = caps.supports_language_switch || {};
201
+
202
+ // Primary CTAs: assume true unless we have explicit evidence of none
203
+ observed.capabilities.primary_ctas = (
204
+ caps.supports_primary_cta?.supported !== false
205
+ );
206
+ observed.mapping.primary_ctas = caps.supports_primary_cta || { supported: true };
207
+
208
+ // PHASE 11: Internal/admin surfaces detection
209
+ // Requires BOTH URL pattern AND UI label evidence to avoid false positives
210
+ observed.capabilities.internal_admin = detectInternalSurfaces(siteIntelligence);
211
+ observed.mapping.internal_admin = { detected: observed.capabilities.internal_admin };
212
+
213
+ return observed;
214
+ }
215
+
216
+ /**
217
+ * Detect internal/admin surfaces with strong evidence requirements
218
+ * PHASE 11: Internal surfaces must NOT penalize public readiness
219
+ *
220
+ * Detection requires BOTH:
221
+ * - URL pattern (/admin, /wp-admin, /internal, /dashboard, /staff)
222
+ * - UI label evidence ("Admin", "Staff", "Internal", "Management")
223
+ *
224
+ * @param {Object} siteIntelligence - Site intelligence data
225
+ * @returns {boolean} - true if internal admin surface detected
226
+ */
227
+ function detectInternalSurfaces(siteIntelligence) {
228
+ if (!siteIntelligence || !siteIntelligence.crawl || !siteIntelligence.crawl.discovered) {
229
+ return false;
230
+ }
231
+
232
+ const discovered = siteIntelligence.crawl.discovered;
233
+ const internalUrlPatterns = ['/admin', '/wp-admin', '/internal', '/dashboard', '/staff', '/management'];
234
+ const internalLabelPatterns = ['admin', 'staff', 'internal', 'management', 'dashboard'];
235
+
236
+ // Check each discovered URL (can be string or object with url + metadata)
237
+ for (const urlEntry of discovered) {
238
+ // Handle both string URLs and object format
239
+ let url = '';
240
+ let text = '';
241
+
242
+ if (typeof urlEntry === 'string') {
243
+ url = urlEntry;
244
+ } else if (urlEntry && typeof urlEntry === 'object') {
245
+ url = urlEntry.url || urlEntry.href || '';
246
+ text = urlEntry.text || urlEntry.label || urlEntry.linkText || '';
247
+ }
248
+
249
+ // Check if URL matches internal pattern
250
+ const hasInternalUrl = internalUrlPatterns.some(pattern => url.toLowerCase().includes(pattern));
251
+
252
+ // Check if text/label matches internal pattern (case-insensitive)
253
+ const hasInternalLabel = text && internalLabelPatterns.some(pattern =>
254
+ text.toLowerCase().includes(pattern)
255
+ );
256
+
257
+ // Require BOTH URL and label evidence to minimize false positives
258
+ if (hasInternalUrl && hasInternalLabel) {
259
+ return true;
260
+ }
261
+ }
262
+
263
+ return false;
264
+ }
265
+
266
+ /**
267
+ * Check if a capability is observed
268
+ *
269
+ * @param {Object} observedCapabilities - Result from extractObservedCapabilities()
270
+ * @param {string} capabilityName - Name of capability (e.g., 'login', 'signup')
271
+ * @returns {boolean} True if capability is observed
272
+ */
273
+ function isCapabilityObserved(observedCapabilities, capabilityName) {
274
+ if (!observedCapabilities || !observedCapabilities.capabilities) {
275
+ return false;
276
+ }
277
+ return observedCapabilities.capabilities[capabilityName] === true;
278
+ }
279
+
280
+ /**
281
+ * Get applicable attempts based on observed capabilities
282
+ * Maps attempt IDs to the capabilities they require
283
+ *
284
+ * @param {Array} attemptIds - List of attempt IDs to filter
285
+ * @param {Object} observedCapabilities - Result from extractObservedCapabilities()
286
+ * @returns {Object} { applicable: Array, notApplicable: Array }
287
+ */
288
+ function filterAttemptsByObservedCapabilities(attemptIds, observedCapabilities) {
289
+ const applicable = [];
290
+ const notApplicable = [];
291
+
292
+ if (!attemptIds || !Array.isArray(attemptIds)) {
293
+ return { applicable: [], notApplicable: [] };
294
+ }
295
+
296
+ for (const attemptId of attemptIds) {
297
+ // Find which capability this attempt requires
298
+ let requiredCapability = null;
299
+ for (const [capName, mapping] of Object.entries(CAPABILITY_MAPPINGS)) {
300
+ if (mapping.attemptIds.includes(attemptId)) {
301
+ requiredCapability = capName;
302
+ break;
303
+ }
304
+ }
305
+
306
+ // If no capability mapping, always run (universal attempt)
307
+ if (!requiredCapability) {
308
+ applicable.push(attemptId);
309
+ continue;
310
+ }
311
+
312
+ // Check if capability is observed
313
+ if (isCapabilityObserved(observedCapabilities, requiredCapability)) {
314
+ applicable.push(attemptId);
315
+ } else {
316
+ notApplicable.push({
317
+ attemptId,
318
+ reason: `Capability not observed: ${requiredCapability}`,
319
+ capabilityRequired: requiredCapability
320
+ });
321
+ }
322
+ }
323
+
324
+ return { applicable, notApplicable };
325
+ }
326
+
327
+ /**
328
+ * Get applicable flows based on observed capabilities
329
+ *
330
+ * @param {Array} flowIds - List of flow IDs to filter
331
+ * @param {Object} observedCapabilities - Result from extractObservedCapabilities()
332
+ * @returns {Object} { applicable: Array, notApplicable: Array }
333
+ */
334
+ function filterFlowsByObservedCapabilities(flowIds, observedCapabilities) {
335
+ const applicable = [];
336
+ const notApplicable = [];
337
+
338
+ if (!flowIds || !Array.isArray(flowIds)) {
339
+ return { applicable: [], notApplicable: [] };
340
+ }
341
+
342
+ const flowCapabilityMap = {
343
+ 'login_flow': 'login',
344
+ 'signup_flow': 'signup',
345
+ 'checkout_flow': 'checkout',
346
+ 'contact_flow': 'contact_form'
347
+ };
348
+
349
+ for (const flowId of flowIds) {
350
+ const requiredCapability = flowCapabilityMap[flowId];
351
+
352
+ // If no mapping, always run
353
+ if (!requiredCapability) {
354
+ applicable.push(flowId);
355
+ continue;
356
+ }
357
+
358
+ // Check if capability is observed
359
+ if (isCapabilityObserved(observedCapabilities, requiredCapability)) {
360
+ applicable.push(flowId);
361
+ } else {
362
+ notApplicable.push({
363
+ flowId,
364
+ reason: `Capability not observed: ${requiredCapability}`,
365
+ capabilityRequired: requiredCapability
366
+ });
367
+ }
368
+ }
369
+
370
+ return { applicable, notApplicable };
371
+ }
372
+
373
+ /**
374
+ * Create NOT_APPLICABLE result for an attempt
375
+ *
376
+ * @param {string} attemptId - Attempt ID
377
+ * @param {string} capabilityName - Name of unobserved capability
378
+ * @returns {Object} Result object with NOT_APPLICABLE outcome
379
+ */
380
+ function createNotApplicableAttemptResult(attemptId, capabilityName) {
381
+ return {
382
+ attemptId,
383
+ attemptName: attemptId,
384
+ goal: 'Unknown',
385
+ riskCategory: 'UNKNOWN',
386
+ source: 'capability-check',
387
+ outcome: 'NOT_APPLICABLE',
388
+ skipReason: `Capability not observed: ${capabilityName}`,
389
+ skipReasonCode: 'NOT_APPLICABLE', // CRITICAL: Must match SKIP_CODES.NOT_APPLICABLE for coverage calculation
390
+ exitCode: 0, // NOT_APPLICABLE doesn't affect exit code
391
+ executed: false,
392
+ steps: [],
393
+ friction: null,
394
+ error: null
395
+ };
396
+ }
397
+
398
+ /**
399
+ * Create NOT_APPLICABLE result for a flow
400
+ *
401
+ * @param {string} flowId - Flow ID
402
+ * @param {string} capabilityName - Name of unobserved capability
403
+ * @returns {Object} Result object with NOT_APPLICABLE outcome
404
+ */
405
+ function createNotApplicableFlowResult(flowId, capabilityName) {
406
+ return {
407
+ flowId,
408
+ flowName: flowId,
409
+ outcome: 'NOT_APPLICABLE',
410
+ skipReason: `Capability not observed: ${capabilityName}`,
411
+ skipReasonCode: 'NOT_OBSERVABLE',
412
+ success: null,
413
+ executed: false,
414
+ steps: [],
415
+ error: null
416
+ };
417
+ }
418
+
419
+ module.exports = {
420
+ CAPABILITY_MAPPINGS,
421
+ extractObservedCapabilities,
422
+ isCapabilityObserved,
423
+ filterAttemptsByObservedCapabilities,
424
+ filterFlowsByObservedCapabilities,
425
+ createNotApplicableAttemptResult,
426
+ createNotApplicableFlowResult
427
+ };