@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,507 @@
1
+ /**
2
+ * Phase B: Recipe Runtime Enforcement
3
+ *
4
+ * Executes recipes as strict, enforced runtime journeys.
5
+ * Each step maps to a browser action; deviations trigger deterministic failure.
6
+ */
7
+
8
+ const { GuardianBrowser } = require('../guardian/browser');
9
+ const { getRecipe } = require('./recipe-store');
10
+
11
+ /**
12
+ * Execute a recipe as an enforced runtime journey
13
+ *
14
+ * @param {string} recipeId - ID of recipe to execute
15
+ * @param {string} baseUrl - Target URL
16
+ * @param {Object} options - Execution options
17
+ * @returns {Promise<Object>} Structured result: { success, steps, failedStep, failureReason, evidence }
18
+ */
19
+ async function executeRecipeRuntime(recipeId, baseUrl, options = {}) {
20
+ const recipe = getRecipe(recipeId);
21
+ if (!recipe) {
22
+ return {
23
+ success: false,
24
+ recipe: recipeId,
25
+ baseUrl,
26
+ steps: [],
27
+ failedStep: null,
28
+ failureReason: `Recipe not found: ${recipeId}`,
29
+ evidence: [],
30
+ startedAt: new Date().toISOString(),
31
+ endedAt: new Date().toISOString(),
32
+ duration: 0
33
+ };
34
+ }
35
+
36
+ const timeout = options.timeout || 20000;
37
+ const headless = options.headless !== false;
38
+ const screenshotDir = options.screenshotDir;
39
+
40
+ const browser = new GuardianBrowser();
41
+ const executionSteps = [];
42
+ let failedStepId = null;
43
+ let failureReason = null;
44
+ const startedAt = new Date();
45
+
46
+ try {
47
+ // Launch browser
48
+ await browser.launch(timeout, { headless });
49
+
50
+ // Execute each recipe step in strict order
51
+ let stepIndex = 0;
52
+ for (const stepDef of recipe.steps) {
53
+ const stepId = `${recipe.id}-step-${stepIndex}`;
54
+
55
+ try {
56
+ // Parse step definition (string format for now)
57
+ const actionObj = parseRecipeStep(stepDef, stepIndex);
58
+
59
+ // Execute action
60
+ const result = await executeRecipeAction(browser, actionObj, baseUrl, timeout);
61
+
62
+ executionSteps.push({
63
+ id: stepId,
64
+ index: stepIndex,
65
+ text: stepDef,
66
+ action: actionObj.action,
67
+ success: result.success,
68
+ duration: result.duration || 0,
69
+ evidence: result.evidence || {}
70
+ });
71
+
72
+ if (!result.success) {
73
+ failedStepId = stepId;
74
+ failureReason = result.error || `Step failed: ${stepDef}`;
75
+ break;
76
+ }
77
+ } catch (err) {
78
+ executionSteps.push({
79
+ id: stepId,
80
+ index: stepIndex,
81
+ text: stepDef,
82
+ success: false,
83
+ error: err.message
84
+ });
85
+ failedStepId = stepId;
86
+ failureReason = `Step execution error: ${err.message}`;
87
+ break;
88
+ }
89
+
90
+ stepIndex++;
91
+ }
92
+
93
+ // Evaluate goal if all steps succeeded
94
+ let goalReached = false;
95
+ if (!failedStepId) {
96
+ try {
97
+ goalReached = await evaluateRecipeGoal(browser, recipe, baseUrl);
98
+ if (!goalReached) {
99
+ failureReason = `Goal not reached: ${recipe.expectedGoal}`;
100
+ }
101
+ } catch (err) {
102
+ failureReason = `Goal evaluation error: ${err.message}`;
103
+ }
104
+ }
105
+
106
+ const endedAt = new Date();
107
+ const duration = Math.round((endedAt - startedAt) / 1000);
108
+
109
+ return {
110
+ success: !failedStepId && goalReached,
111
+ recipe: recipe.id,
112
+ recipeName: recipe.name,
113
+ baseUrl,
114
+ steps: executionSteps,
115
+ failedStep: failedStepId,
116
+ failureReason,
117
+ goalReached,
118
+ evidence: {
119
+ stepCount: executionSteps.length,
120
+ successCount: executionSteps.filter(s => s.success).length
121
+ },
122
+ startedAt: startedAt.toISOString(),
123
+ endedAt: endedAt.toISOString(),
124
+ duration
125
+ };
126
+ } catch (err) {
127
+ const endedAt = new Date();
128
+ return {
129
+ success: false,
130
+ recipe: recipe.id,
131
+ baseUrl,
132
+ steps: executionSteps,
133
+ failedStep: null,
134
+ failureReason: `Runtime error: ${err.message}`,
135
+ evidence: {},
136
+ startedAt: startedAt.toISOString(),
137
+ endedAt: endedAt.toISOString(),
138
+ duration: Math.round((endedAt - startedAt) / 1000)
139
+ };
140
+ } finally {
141
+ await browser.close();
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Parse a recipe step string into an action object
147
+ *
148
+ * Step format:
149
+ * "Navigate to product page"
150
+ * "Navigate to the homepage"
151
+ * "Click on 'Add to Cart' button"
152
+ * "Click on 'Contact' button"
153
+ * "Fill email with 'test@example.com'"
154
+ * "Assert visible 'Success message'"
155
+ */
156
+ function parseRecipeStep(stepText, index) {
157
+ const text = stepText.toLowerCase().trim();
158
+
159
+ // Navigate: "Navigate to X" or "Go to X"
160
+ if (text.startsWith('navigate') || text.startsWith('go to ')) {
161
+ // Try to extract something meaningful
162
+ let target = 'homepage';
163
+ if (text.includes('homepage') || text.includes('home')) {
164
+ target = 'homepage';
165
+ } else if (text.includes('product')) {
166
+ target = 'product';
167
+ } else {
168
+ // Get last noun-like word
169
+ const quoted = stepText.match(/'([^']+)'/) || stepText.match(/"([^"]+)"/);
170
+ if (quoted) {
171
+ target = quoted[1];
172
+ } else {
173
+ const words = stepText.split(/\s+/).slice(2); // Skip "Navigate to"
174
+ target = words.join('-').toLowerCase();
175
+ }
176
+ }
177
+ return {
178
+ action: 'navigate',
179
+ target,
180
+ index
181
+ };
182
+ }
183
+
184
+ // Click: "Click on X" or "Click 'Button text'"
185
+ if (text.includes('click')) {
186
+ let selector = 'button';
187
+
188
+ // Try to find quoted button text
189
+ const quoted = stepText.match(/'([^']+)'/) || stepText.match(/"([^"]+)"/);
190
+ if (quoted) {
191
+ selector = quoted[1];
192
+ } else {
193
+ // Extract button description from text
194
+ if (text.includes('contact')) {
195
+ selector = 'Contact';
196
+ } else if (text.includes('submit')) {
197
+ selector = 'Submit';
198
+ } else if (text.includes('add')) {
199
+ selector = 'Add';
200
+ } else {
201
+ // Last word after "click"
202
+ const match = stepText.match(/click(?:\s+on)?\s+(.+)/i);
203
+ selector = match ? match[1].replace('button', '').trim() : 'button';
204
+ }
205
+ }
206
+
207
+ return {
208
+ action: 'click',
209
+ selector,
210
+ index
211
+ };
212
+ }
213
+
214
+ // Fill: "Fill X with 'value'" or "Enter email"
215
+ if (text.startsWith('fill ') || text.startsWith('enter ')) {
216
+ let field = 'email';
217
+ let value = 'test-data';
218
+
219
+ const parts = stepText.split(' with ');
220
+
221
+ // Extract field name
222
+ if (parts[0].includes('email')) {
223
+ field = 'email';
224
+ } else if (parts[0].includes('name')) {
225
+ field = 'name';
226
+ } else if (parts[0].includes('password')) {
227
+ field = 'password';
228
+ } else {
229
+ const quoted = parts[0].match(/'([^']+)'/) || parts[0].match(/"([^"]+)"/);
230
+ field = quoted ? quoted[1] : 'email';
231
+ }
232
+
233
+ // Extract value
234
+ if (parts[1]) {
235
+ const quoted = parts[1].match(/'([^']+)'/) || parts[1].match(/"([^"]+)"/);
236
+ value = quoted ? quoted[1] : parts[1].trim();
237
+ }
238
+
239
+ return {
240
+ action: 'fill',
241
+ field,
242
+ value,
243
+ index
244
+ };
245
+ }
246
+
247
+ // Submit: "Submit form" or "Submit"
248
+ if (text.includes('submit')) {
249
+ return {
250
+ action: 'submit',
251
+ selector: 'button[type="submit"], input[type="submit"]',
252
+ index
253
+ };
254
+ }
255
+
256
+ // Wait: "Wait for X" or "Wait until X"
257
+ if (text.includes('wait')) {
258
+ const quoted = stepText.match(/'([^']+)'/) || stepText.match(/"([^"]+)"/);
259
+ const target = quoted ? quoted[1] : 'element';
260
+ return {
261
+ action: 'waitFor',
262
+ selector: target,
263
+ index
264
+ };
265
+ }
266
+
267
+ // Assert: "Assert visible X" or "Verify X"
268
+ if (text.includes('assert') || text.includes('verify')) {
269
+ const quoted = stepText.match(/'([^']+)'/) || stepText.match(/"([^"]+)"/);
270
+ const target = quoted ? quoted[1] : 'element';
271
+ return {
272
+ action: 'assertVisible',
273
+ selector: target,
274
+ index
275
+ };
276
+ }
277
+
278
+ // Default: treat as navigation
279
+ return {
280
+ action: 'navigate',
281
+ target: stepText,
282
+ index
283
+ };
284
+ }
285
+
286
+ /**
287
+ * Extract quoted text or last word from a string
288
+ */
289
+ function extractQuotedOrLastWord(text) {
290
+ const quoted = text.match(/'([^']+)'/) || text.match(/"([^"]+)"/);
291
+ if (quoted) return quoted[1];
292
+
293
+ const words = text.trim().split(/\s+/);
294
+ return words[words.length - 1];
295
+ }
296
+
297
+ /**
298
+ * Execute a single recipe action against the browser
299
+ */
300
+ async function executeRecipeAction(browser, action, baseUrl, timeout) {
301
+ const startTime = Date.now();
302
+
303
+ try {
304
+ if (action.action === 'navigate') {
305
+ const url = action.target.startsWith('http')
306
+ ? action.target
307
+ : new URL(action.target, baseUrl).href;
308
+
309
+ const response = await browser.navigate(url, timeout);
310
+ if (!response.success) {
311
+ return {
312
+ success: false,
313
+ error: response.error || `Navigation failed to ${url}`,
314
+ duration: Date.now() - startTime
315
+ };
316
+ }
317
+
318
+ return {
319
+ success: true,
320
+ duration: Date.now() - startTime,
321
+ evidence: { url: response.url, status: response.status }
322
+ };
323
+ }
324
+
325
+ if (action.action === 'click') {
326
+ const selector = action.selector.toLowerCase().trim();
327
+ let targetElement = null;
328
+
329
+ // Find all clickable elements
330
+ try {
331
+ const allElements = await browser.page.$$('button, a, [role="button"], input[type="button"]');
332
+
333
+ for (const el of allElements) {
334
+ try {
335
+ const text = await browser.page.evaluate(elem => (elem.textContent || elem.value || '').toLowerCase().trim(), el);
336
+ const isVisible = await browser.page.evaluate(elem => elem.offsetParent !== null, el);
337
+
338
+ if (isVisible && (text === selector || text.includes(selector) || selector.includes(text))) {
339
+ targetElement = el;
340
+ break;
341
+ }
342
+ } catch (e) {
343
+ // Skip this element
344
+ }
345
+ }
346
+ } catch (e) {
347
+ // Fallback: try as selector
348
+ targetElement = await browser.page.$(selector).catch(() => null);
349
+ }
350
+
351
+ if (!targetElement) {
352
+ return {
353
+ success: false,
354
+ error: `Element not found: ${selector}`,
355
+ duration: Date.now() - startTime
356
+ };
357
+ }
358
+
359
+ // Perform click
360
+ const initialUrl = browser.page.url();
361
+ try {
362
+ await browser.page.evaluate(el => el.click(), targetElement);
363
+ } catch (e) {
364
+ return {
365
+ success: false,
366
+ error: `Click failed: ${e.message}`,
367
+ duration: Date.now() - startTime
368
+ };
369
+ }
370
+
371
+ // Wait for navigation or content change
372
+ await new Promise(r => setTimeout(r, 500));
373
+
374
+ return {
375
+ success: true,
376
+ duration: Date.now() - startTime,
377
+ evidence: { clicked: selector, urlChanged: initialUrl !== browser.page.url() }
378
+ };
379
+ }
380
+
381
+ if (action.action === 'fill') {
382
+ const field = action.field;
383
+ const value = action.value;
384
+ const locator = browser.page.locator(`input[name="${field}"], textarea[name="${field}"]`);
385
+
386
+ const count = await locator.count();
387
+ if (count === 0) {
388
+ return {
389
+ success: false,
390
+ error: `Field not found: ${field}`,
391
+ duration: Date.now() - startTime
392
+ };
393
+ }
394
+
395
+ await locator.first().fill(value, { timeout });
396
+
397
+ return {
398
+ success: true,
399
+ duration: Date.now() - startTime,
400
+ evidence: { field, valueFilled: true }
401
+ };
402
+ }
403
+
404
+ if (action.action === 'submit') {
405
+ const locator = browser.page.locator(action.selector);
406
+ const count = await locator.count();
407
+
408
+ if (count === 0) {
409
+ return {
410
+ success: false,
411
+ error: `Submit button not found: ${action.selector}`,
412
+ duration: Date.now() - startTime
413
+ };
414
+ }
415
+
416
+ await locator.first().click({ timeout });
417
+
418
+ // Wait for response
419
+ await new Promise(r => setTimeout(r, 1000));
420
+
421
+ return {
422
+ success: true,
423
+ duration: Date.now() - startTime,
424
+ evidence: { submitted: true }
425
+ };
426
+ }
427
+
428
+ if (action.action === 'waitFor') {
429
+ const locator = browser.page.locator(action.selector);
430
+ await locator.waitFor({ timeout, state: 'visible' });
431
+
432
+ return {
433
+ success: true,
434
+ duration: Date.now() - startTime,
435
+ evidence: { elementVisible: action.selector }
436
+ };
437
+ }
438
+
439
+ if (action.action === 'assertVisible') {
440
+ const locator = browser.page.locator(action.selector);
441
+ const isVisible = await locator.isVisible();
442
+
443
+ if (!isVisible) {
444
+ return {
445
+ success: false,
446
+ error: `Assertion failed: element not visible: ${action.selector}`,
447
+ duration: Date.now() - startTime
448
+ };
449
+ }
450
+
451
+ return {
452
+ success: true,
453
+ duration: Date.now() - startTime,
454
+ evidence: { assertion: action.selector, visible: true }
455
+ };
456
+ }
457
+
458
+ return {
459
+ success: false,
460
+ error: `Unknown action: ${action.action}`,
461
+ duration: Date.now() - startTime
462
+ };
463
+ } catch (err) {
464
+ return {
465
+ success: false,
466
+ error: `Action error: ${err.message}`,
467
+ duration: Date.now() - startTime
468
+ };
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Evaluate if recipe goal was reached
474
+ */
475
+ async function evaluateRecipeGoal(browser, recipe, baseUrl) {
476
+ const url = browser.page.url();
477
+ const title = await browser.page.title();
478
+ const bodyText = await browser.page.textContent('body');
479
+
480
+ // Simple heuristics for goal validation
481
+ const goal = recipe.expectedGoal.toLowerCase();
482
+ const pageContent = `${url} ${title} ${bodyText}`.toLowerCase();
483
+
484
+ // Check for goal keywords in page
485
+ const keywordMatches = [
486
+ 'success', 'confirm', 'thank', 'order', 'dashboard',
487
+ 'welcome', 'complete', 'verified', 'registration',
488
+ 'payment', 'checkout'
489
+ ];
490
+
491
+ for (const keyword of keywordMatches) {
492
+ if (goal.includes(keyword) && pageContent.includes(keyword)) {
493
+ return true;
494
+ }
495
+ }
496
+
497
+ // Check for goal phrases directly in page
498
+ return pageContent.includes(goal);
499
+ }
500
+
501
+ module.exports = {
502
+ executeRecipeRuntime,
503
+ parseRecipeStep,
504
+ extractQuotedOrLastWord,
505
+ executeRecipeAction,
506
+ evaluateRecipeGoal
507
+ };