@veraxhq/verax 0.1.0 → 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 (126) hide show
  1. package/README.md +123 -88
  2. package/bin/verax.js +11 -452
  3. package/package.json +14 -36
  4. package/src/cli/commands/default.js +523 -0
  5. package/src/cli/commands/doctor.js +165 -0
  6. package/src/cli/commands/inspect.js +109 -0
  7. package/src/cli/commands/run.js +402 -0
  8. package/src/cli/entry.js +196 -0
  9. package/src/cli/util/atomic-write.js +37 -0
  10. package/src/cli/util/detection-engine.js +296 -0
  11. package/src/cli/util/env-url.js +33 -0
  12. package/src/cli/util/errors.js +44 -0
  13. package/src/cli/util/events.js +34 -0
  14. package/src/cli/util/expectation-extractor.js +378 -0
  15. package/src/cli/util/findings-writer.js +31 -0
  16. package/src/cli/util/idgen.js +87 -0
  17. package/src/cli/util/learn-writer.js +39 -0
  18. package/src/cli/util/observation-engine.js +366 -0
  19. package/src/cli/util/observe-writer.js +25 -0
  20. package/src/cli/util/paths.js +29 -0
  21. package/src/cli/util/project-discovery.js +277 -0
  22. package/src/cli/util/project-writer.js +26 -0
  23. package/src/cli/util/redact.js +128 -0
  24. package/src/cli/util/run-id.js +30 -0
  25. package/src/cli/util/summary-writer.js +32 -0
  26. package/src/verax/cli/ci-summary.js +35 -0
  27. package/src/verax/cli/context-explanation.js +89 -0
  28. package/src/verax/cli/doctor.js +277 -0
  29. package/src/verax/cli/error-normalizer.js +154 -0
  30. package/src/verax/cli/explain-output.js +105 -0
  31. package/src/verax/cli/finding-explainer.js +130 -0
  32. package/src/verax/cli/init.js +237 -0
  33. package/src/verax/cli/run-overview.js +163 -0
  34. package/src/verax/cli/url-safety.js +101 -0
  35. package/src/verax/cli/wizard.js +98 -0
  36. package/src/verax/cli/zero-findings-explainer.js +57 -0
  37. package/src/verax/cli/zero-interaction-explainer.js +127 -0
  38. package/src/verax/core/action-classifier.js +86 -0
  39. package/src/verax/core/budget-engine.js +218 -0
  40. package/src/verax/core/canonical-outcomes.js +157 -0
  41. package/src/verax/core/decision-snapshot.js +335 -0
  42. package/src/verax/core/determinism-model.js +403 -0
  43. package/src/verax/core/incremental-store.js +237 -0
  44. package/src/verax/core/invariants.js +356 -0
  45. package/src/verax/core/promise-model.js +230 -0
  46. package/src/verax/core/replay-validator.js +350 -0
  47. package/src/verax/core/replay.js +222 -0
  48. package/src/verax/core/run-id.js +175 -0
  49. package/src/verax/core/run-manifest.js +99 -0
  50. package/src/verax/core/silence-impact.js +369 -0
  51. package/src/verax/core/silence-model.js +521 -0
  52. package/src/verax/detect/comparison.js +2 -34
  53. package/src/verax/detect/confidence-engine.js +764 -329
  54. package/src/verax/detect/detection-engine.js +293 -0
  55. package/src/verax/detect/evidence-index.js +177 -0
  56. package/src/verax/detect/expectation-model.js +194 -172
  57. package/src/verax/detect/explanation-helpers.js +187 -0
  58. package/src/verax/detect/finding-detector.js +450 -0
  59. package/src/verax/detect/findings-writer.js +44 -8
  60. package/src/verax/detect/flow-detector.js +366 -0
  61. package/src/verax/detect/index.js +172 -286
  62. package/src/verax/detect/interactive-findings.js +613 -0
  63. package/src/verax/detect/signal-mapper.js +308 -0
  64. package/src/verax/detect/verdict-engine.js +563 -0
  65. package/src/verax/evidence-index-writer.js +61 -0
  66. package/src/verax/index.js +90 -14
  67. package/src/verax/intel/effect-detector.js +368 -0
  68. package/src/verax/intel/handler-mapper.js +249 -0
  69. package/src/verax/intel/index.js +281 -0
  70. package/src/verax/intel/route-extractor.js +280 -0
  71. package/src/verax/intel/ts-program.js +256 -0
  72. package/src/verax/intel/vue-navigation-extractor.js +579 -0
  73. package/src/verax/intel/vue-router-extractor.js +323 -0
  74. package/src/verax/learn/action-contract-extractor.js +335 -101
  75. package/src/verax/learn/ast-contract-extractor.js +95 -5
  76. package/src/verax/learn/flow-extractor.js +172 -0
  77. package/src/verax/learn/manifest-writer.js +97 -47
  78. package/src/verax/learn/project-detector.js +40 -0
  79. package/src/verax/learn/route-extractor.js +27 -96
  80. package/src/verax/learn/state-extractor.js +212 -0
  81. package/src/verax/learn/static-extractor-navigation.js +114 -0
  82. package/src/verax/learn/static-extractor-validation.js +88 -0
  83. package/src/verax/learn/static-extractor.js +112 -4
  84. package/src/verax/learn/truth-assessor.js +24 -21
  85. package/src/verax/observe/aria-sensor.js +211 -0
  86. package/src/verax/observe/browser.js +10 -5
  87. package/src/verax/observe/console-sensor.js +1 -17
  88. package/src/verax/observe/domain-boundary.js +10 -1
  89. package/src/verax/observe/expectation-executor.js +512 -0
  90. package/src/verax/observe/flow-matcher.js +143 -0
  91. package/src/verax/observe/focus-sensor.js +196 -0
  92. package/src/verax/observe/human-driver.js +643 -275
  93. package/src/verax/observe/index.js +908 -27
  94. package/src/verax/observe/index.js.backup +1 -0
  95. package/src/verax/observe/interaction-discovery.js +365 -14
  96. package/src/verax/observe/interaction-runner.js +563 -198
  97. package/src/verax/observe/loading-sensor.js +139 -0
  98. package/src/verax/observe/navigation-sensor.js +255 -0
  99. package/src/verax/observe/network-sensor.js +55 -7
  100. package/src/verax/observe/observed-expectation-deriver.js +186 -0
  101. package/src/verax/observe/observed-expectation.js +305 -0
  102. package/src/verax/observe/page-frontier.js +234 -0
  103. package/src/verax/observe/settle.js +37 -17
  104. package/src/verax/observe/state-sensor.js +389 -0
  105. package/src/verax/observe/timing-sensor.js +228 -0
  106. package/src/verax/observe/traces-writer.js +61 -20
  107. package/src/verax/observe/ui-signal-sensor.js +136 -17
  108. package/src/verax/scan-summary-writer.js +77 -15
  109. package/src/verax/shared/artifact-manager.js +110 -8
  110. package/src/verax/shared/budget-profiles.js +136 -0
  111. package/src/verax/shared/ci-detection.js +39 -0
  112. package/src/verax/shared/config-loader.js +170 -0
  113. package/src/verax/shared/dynamic-route-utils.js +218 -0
  114. package/src/verax/shared/expectation-coverage.js +44 -0
  115. package/src/verax/shared/expectation-prover.js +81 -0
  116. package/src/verax/shared/expectation-tracker.js +201 -0
  117. package/src/verax/shared/expectations-writer.js +60 -0
  118. package/src/verax/shared/first-run.js +44 -0
  119. package/src/verax/shared/progress-reporter.js +171 -0
  120. package/src/verax/shared/retry-policy.js +14 -1
  121. package/src/verax/shared/root-artifacts.js +49 -0
  122. package/src/verax/shared/scan-budget.js +86 -0
  123. package/src/verax/shared/url-normalizer.js +162 -0
  124. package/src/verax/shared/zip-artifacts.js +65 -0
  125. package/src/verax/validate/context-validator.js +244 -0
  126. package/src/verax/validate/context-validator.js.bak +0 -0
@@ -0,0 +1,512 @@
1
+ /**
2
+ * EXPECTATION-DRIVEN EXECUTION ENGINE
3
+ *
4
+ * Executes every PROVEN expectation from the manifest.
5
+ * Each expectation must result in: VERIFIED, SILENT_FAILURE, or COVERAGE_GAP.
6
+ */
7
+
8
+ import { isProvenExpectation } from '../shared/expectation-prover.js';
9
+ import { getUrlPath } from '../detect/evidence-validator.js';
10
+ import { hasMeaningfulUrlChange, hasVisibleChange, hasDomChange } from '../detect/comparison.js';
11
+ import { runInteraction } from './interaction-runner.js';
12
+ import { captureScreenshot } from './evidence-capture.js';
13
+ import { getBaseOrigin } from './domain-boundary.js';
14
+ import { resolve, dirname } from 'path';
15
+
16
+ /**
17
+ * Execute a single PROVEN expectation.
18
+ * @param {Page} page - Playwright page
19
+ * @param {Object} expectation - PROVEN expectation from manifest
20
+ * @param {string} baseUrl - Base URL of the site
21
+ * @param {string} screenshotsDir - Directory for screenshots
22
+ * @param {number} timestamp - Timestamp for file naming
23
+ * @param {number} expectationIndex - Index of expectation in list
24
+ * @param {Object} scanBudget - Scan budget
25
+ * @param {number} startTime - Scan start time
26
+ * @param {string} projectDir - Project directory for path resolution
27
+ * @returns {Promise<Object>} Execution result: { outcome, reason?, evidence?, trace? }
28
+ */
29
+ async function executeExpectation(page, expectation, baseUrl, screenshotsDir, timestamp, expectationIndex, scanBudget, startTime, projectDir) {
30
+ const baseOrigin = getBaseOrigin(baseUrl);
31
+ const result = {
32
+ expectationId: expectation.id || `exp-${expectationIndex}`,
33
+ fromPath: expectation.fromPath,
34
+ type: expectation.type,
35
+ outcome: null,
36
+ reason: null,
37
+ evidence: {},
38
+ trace: null
39
+ };
40
+
41
+ // NOTE: Budget check is done at the loop level (executeProvenExpectations)
42
+ // Once execution starts here, we must complete with a real outcome:
43
+ // VERIFIED, SILENT_FAILURE, or COVERAGE_GAP (with real reason like element_not_found, etc.)
44
+ // Never return budget_exceeded from inside executeExpectation.
45
+
46
+ try {
47
+ // Step 1: Navigate to fromPath
48
+ let fromUrl;
49
+ try {
50
+ // Handle both absolute and relative paths
51
+ if (expectation.fromPath.startsWith('http://') || expectation.fromPath.startsWith('https://')) {
52
+ fromUrl = expectation.fromPath;
53
+ } else {
54
+ fromUrl = new URL(expectation.fromPath, baseUrl).href;
55
+ }
56
+ } catch (error) {
57
+ result.outcome = 'COVERAGE_GAP';
58
+ result.reason = 'invalid_from_path';
59
+ result.evidence = {
60
+ error: error.message,
61
+ fromPath: expectation.fromPath
62
+ };
63
+ return result;
64
+ }
65
+
66
+ let navigationSuccess = false;
67
+
68
+ try {
69
+ await page.goto(fromUrl, { waitUntil: 'networkidle', timeout: scanBudget.initialNavigationTimeoutMs });
70
+ await page.waitForTimeout(scanBudget.navigationStableWaitMs);
71
+ const currentPath = getUrlPath(page.url());
72
+ const normalizedFrom = expectation.fromPath.replace(/\/$/, '') || '/';
73
+ const normalizedCurrent = currentPath ? currentPath.replace(/\/$/, '') || '/' : '';
74
+
75
+ // Allow for SPA routing - if we're on a different path but same origin, continue
76
+ // Also allow if we're on the same origin (SPA may not change pathname)
77
+ // For file:// URLs, just check if we're on the same origin
78
+ if (normalizedCurrent === normalizedFrom || page.url().startsWith(baseOrigin)) {
79
+ navigationSuccess = true;
80
+ } else if (baseUrl.startsWith('file://') && page.url().startsWith('file://')) {
81
+ // For file:// URLs, navigation is considered successful if we're on file://
82
+ navigationSuccess = true;
83
+ }
84
+ } catch (error) {
85
+ result.outcome = 'COVERAGE_GAP';
86
+ result.reason = 'page_unreachable';
87
+ result.evidence = {
88
+ error: error.message,
89
+ attemptedUrl: fromUrl
90
+ };
91
+ return result;
92
+ }
93
+
94
+ if (!navigationSuccess) {
95
+ result.outcome = 'COVERAGE_GAP';
96
+ result.reason = 'page_unreachable';
97
+ result.evidence = {
98
+ attemptedUrl: fromUrl,
99
+ actualUrl: page.url()
100
+ };
101
+ return result;
102
+ }
103
+
104
+ // Step 2: Find target element
105
+ const selectorHint = expectation.evidence?.selectorHint || expectation.selectorHint || '';
106
+ const targetPath = expectation.targetPath || expectation.expectedTarget || '';
107
+ const interactionType = determineInteractionType(expectation.type);
108
+
109
+ let element = null;
110
+ let interaction = null;
111
+
112
+ if (selectorHint) {
113
+ // Try to find element by selector
114
+ try {
115
+ // Try exact selector first
116
+ let locator = page.locator(selectorHint).first();
117
+ let count = await locator.count();
118
+
119
+ if (count === 0) {
120
+ // Try without the tag prefix (e.g., if selectorHint is "a#id", try "#id")
121
+ const idMatch = selectorHint.match(/#[\w-]+$/);
122
+ if (idMatch) {
123
+ const idSelector = idMatch[0];
124
+ locator = page.locator(idSelector).first();
125
+ count = await locator.count();
126
+ if (count > 0) {
127
+ element = locator;
128
+ interaction = {
129
+ type: interactionType,
130
+ selector: idSelector,
131
+ label: await extractLabel(page, idSelector).catch(() => ''),
132
+ element: locator
133
+ };
134
+ }
135
+ }
136
+ } else {
137
+ element = locator;
138
+ interaction = {
139
+ type: interactionType,
140
+ selector: selectorHint,
141
+ label: await extractLabel(page, selectorHint).catch(() => ''),
142
+ element: locator
143
+ };
144
+ }
145
+ } catch (error) {
146
+ // Selector invalid or element not found
147
+ }
148
+ }
149
+
150
+ // Fallback: Try to find by href/action/targetPath
151
+ if (!element && targetPath) {
152
+ if (expectation.type === 'navigation' || expectation.type === 'spa_navigation') {
153
+ // Try to find link with matching href
154
+ try {
155
+ // Normalize targetPath for matching (remove leading slash if present, add .html if needed)
156
+ const normalizedTarget = targetPath.replace(/^\//, '');
157
+ const hrefSelectors = [
158
+ `a[href="${targetPath}"]`,
159
+ `a[href="/${normalizedTarget}"]`,
160
+ `a[href="${targetPath}/"]`,
161
+ `a[href*="${normalizedTarget}"]`,
162
+ `a[href*="${targetPath}"]`
163
+ ];
164
+
165
+ for (const selector of hrefSelectors) {
166
+ const locator = page.locator(selector).first();
167
+ const count = await locator.count();
168
+ if (count > 0) {
169
+ element = locator;
170
+ interaction = {
171
+ type: 'link',
172
+ selector: selector,
173
+ label: await extractLabel(page, selector).catch(() => ''),
174
+ element: locator
175
+ };
176
+ break;
177
+ }
178
+ }
179
+ } catch (error) {
180
+ // Fallback failed
181
+ }
182
+ } else if (expectation.type === 'form_submission') {
183
+ // Try to find form with matching action
184
+ try {
185
+ const actionSelectors = [
186
+ `form[action="${targetPath}"]`,
187
+ `form[action="${targetPath}/"]`,
188
+ `form[action*="${targetPath}"]`
189
+ ];
190
+
191
+ for (const selector of actionSelectors) {
192
+ const submitButton = page.locator(`${selector} button[type="submit"], ${selector} input[type="submit"]`).first();
193
+ const count = await submitButton.count();
194
+ if (count > 0) {
195
+ element = submitButton;
196
+ interaction = {
197
+ type: 'form',
198
+ selector: selector,
199
+ label: await extractLabel(page, selector).catch(() => ''),
200
+ element: submitButton
201
+ };
202
+ break;
203
+ }
204
+ }
205
+ } catch (error) {
206
+ // Fallback failed
207
+ }
208
+ }
209
+ }
210
+
211
+ // If element still not found, this is a coverage gap
212
+ if (!element || !interaction) {
213
+ result.outcome = 'COVERAGE_GAP';
214
+ result.reason = 'element_not_found';
215
+ result.evidence = {
216
+ selectorHint: selectorHint,
217
+ targetPath: targetPath,
218
+ fromPath: expectation.fromPath,
219
+ currentUrl: page.url()
220
+ };
221
+ return result;
222
+ }
223
+
224
+ // Step 3: Execute interaction
225
+ const beforeUrl = page.url();
226
+ const beforeScreenshot = resolve(screenshotsDir, `exp-${expectationIndex}-before.png`);
227
+ await captureScreenshot(page, beforeScreenshot);
228
+
229
+ const trace = await runInteraction(
230
+ page,
231
+ interaction,
232
+ timestamp,
233
+ expectationIndex,
234
+ screenshotsDir,
235
+ baseOrigin,
236
+ startTime,
237
+ scanBudget,
238
+ null // No flow context for expectation-driven execution
239
+ );
240
+
241
+ result.trace = trace;
242
+ // Update trace with expectation metadata
243
+ if (trace) {
244
+ trace.expectationDriven = true;
245
+ trace.expectationId = result.expectationId;
246
+ trace.expectationOutcome = result.outcome;
247
+ // Ensure before screenshot path is set correctly
248
+ if (!trace.before.screenshot) {
249
+ trace.before.screenshot = `screenshots/exp-${expectationIndex}-before.png`;
250
+ }
251
+ }
252
+ result.evidence = {
253
+ before: trace.before?.screenshot || `exp-${expectationIndex}-before.png`,
254
+ after: trace.after?.screenshot || null,
255
+ beforeUrl: beforeUrl,
256
+ afterUrl: trace.after?.url || page.url()
257
+ };
258
+
259
+ // Step 4: Evaluate outcome
260
+ const outcome = evaluateExpectationOutcome(expectation, trace, beforeUrl, projectDir);
261
+ result.outcome = outcome.outcome;
262
+ result.reason = outcome.reason;
263
+
264
+ if (outcome.evidence) {
265
+ result.evidence = { ...result.evidence, ...outcome.evidence };
266
+ }
267
+
268
+ return result;
269
+ } catch (error) {
270
+ result.outcome = 'COVERAGE_GAP';
271
+ result.reason = 'execution_error';
272
+ result.evidence = {
273
+ error: error.message,
274
+ errorStack: error.stack
275
+ };
276
+ return result;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Determine interaction type from expectation type.
282
+ */
283
+ function determineInteractionType(expectationType) {
284
+ if (expectationType === 'navigation' || expectationType === 'spa_navigation') {
285
+ return 'link';
286
+ }
287
+ if (expectationType === 'form_submission' || expectationType === 'validation_block') {
288
+ return 'form';
289
+ }
290
+ if (expectationType === 'network_action' || expectationType === 'state_action') {
291
+ return 'button';
292
+ }
293
+ return 'button'; // Default
294
+ }
295
+
296
+ /**
297
+ * Extract label from element.
298
+ */
299
+ async function extractLabel(page, selector) {
300
+ try {
301
+ const locator = page.locator(selector).first();
302
+ const text = await locator.textContent();
303
+ if (text) return text.trim().substring(0, 100);
304
+
305
+ const ariaLabel = await locator.getAttribute('aria-label');
306
+ if (ariaLabel) return ariaLabel.trim().substring(0, 100);
307
+
308
+ return '';
309
+ } catch (error) {
310
+ return '';
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Evaluate if expectation was VERIFIED or SILENT_FAILURE.
316
+ */
317
+ function evaluateExpectationOutcome(expectation, trace, beforeUrl, projectDir) {
318
+ const afterUrl = trace.after?.url || '';
319
+ const sensors = trace.sensors || {};
320
+
321
+ if (expectation.type === 'navigation' || expectation.type === 'spa_navigation') {
322
+ const targetPath = expectation.targetPath || expectation.expectedTarget || '';
323
+ const afterPath = getUrlPath(afterUrl);
324
+ const normalizedTarget = targetPath.replace(/\/$/, '') || '/';
325
+ const normalizedAfter = afterPath ? afterPath.replace(/\/$/, '') || '/' : '';
326
+
327
+ if (normalizedAfter === normalizedTarget) {
328
+ return { outcome: 'VERIFIED', reason: null };
329
+ }
330
+
331
+ // Check for UI feedback or DOM changes
332
+ const hasVisibleChangeResult = trace.before?.screenshot && trace.after?.screenshot ?
333
+ hasVisibleChange(trace.before.screenshot, trace.after.screenshot, projectDir) : false;
334
+ const hasDomChangeResult = hasDomChange(trace);
335
+ const hasUIFeedback = sensors.uiSignals?.diff?.changed === true;
336
+
337
+ if (hasVisibleChangeResult || hasDomChangeResult || hasUIFeedback) {
338
+ // Partial success - navigation didn't reach target but something changed
339
+ return { outcome: 'SILENT_FAILURE', reason: 'target_not_reached' };
340
+ }
341
+
342
+ return { outcome: 'SILENT_FAILURE', reason: 'no_effect' };
343
+ }
344
+
345
+ if (expectation.type === 'network_action') {
346
+ const networkData = sensors.network || {};
347
+ const hasRequest = networkData.totalRequests > 0;
348
+ const hasFailed = networkData.failedRequests > 0;
349
+ const hasUIFeedback = sensors.uiSignals?.diff?.changed === true;
350
+
351
+ if (!hasRequest) {
352
+ return { outcome: 'SILENT_FAILURE', reason: 'network_request_missing' };
353
+ }
354
+
355
+ if (hasFailed && !hasUIFeedback) {
356
+ return { outcome: 'SILENT_FAILURE', reason: 'network_failure_silent' };
357
+ }
358
+
359
+ if (hasRequest && !hasFailed) {
360
+ return { outcome: 'VERIFIED', reason: null };
361
+ }
362
+
363
+ return { outcome: 'SILENT_FAILURE', reason: 'network_error' };
364
+ }
365
+
366
+ if (expectation.type === 'validation_block') {
367
+ const networkData = sensors.network || {};
368
+ const uiSignals = sensors.uiSignals || {};
369
+ const validationFeedback = uiSignals.after?.validationFeedbackDetected === true;
370
+ const urlChanged = getUrlPath(beforeUrl) !== getUrlPath(afterUrl);
371
+ const hasNetworkRequest = networkData.totalRequests > 0;
372
+
373
+ // Validation block should prevent submission
374
+ if (!urlChanged && !hasNetworkRequest) {
375
+ // Submission was blocked - check for feedback
376
+ if (validationFeedback) {
377
+ return { outcome: 'VERIFIED', reason: null };
378
+ } else {
379
+ return { outcome: 'SILENT_FAILURE', reason: 'validation_feedback_missing' };
380
+ }
381
+ }
382
+
383
+ // If submission occurred, validation didn't block (might be a different issue)
384
+ return { outcome: 'SILENT_FAILURE', reason: 'validation_did_not_block' };
385
+ }
386
+
387
+ if (expectation.type === 'state_action') {
388
+ const stateDiff = sensors.state || {};
389
+ const expectedKey = expectation.expectedTarget;
390
+ const stateChanged = stateDiff.available && stateDiff.changed.length > 0;
391
+ const expectedKeyChanged = stateChanged && stateDiff.changed.includes(expectedKey);
392
+ const hasUIFeedback = sensors.uiSignals?.diff?.changed === true;
393
+
394
+ if (expectedKeyChanged && hasUIFeedback) {
395
+ return { outcome: 'VERIFIED', reason: null };
396
+ }
397
+
398
+ if (!stateChanged || !expectedKeyChanged) {
399
+ return { outcome: 'SILENT_FAILURE', reason: 'state_not_updated' };
400
+ }
401
+
402
+ if (expectedKeyChanged && !hasUIFeedback) {
403
+ return { outcome: 'SILENT_FAILURE', reason: 'state_updated_no_ui_feedback' };
404
+ }
405
+
406
+ return { outcome: 'SILENT_FAILURE', reason: 'state_update_failed' };
407
+ }
408
+
409
+ // Default: check for any visible effect
410
+ const hasUIFeedback = sensors.uiSignals?.diff?.changed === true;
411
+ const hasDomChangeResult = hasDomChange(trace);
412
+ const urlChanged = hasMeaningfulUrlChange(beforeUrl, afterUrl);
413
+ const hasVisibleChangeResult = trace.before?.screenshot && trace.after?.screenshot ?
414
+ hasVisibleChange(trace.before.screenshot, trace.after.screenshot, projectDir) : false;
415
+
416
+ if (hasUIFeedback || hasDomChangeResult || urlChanged || hasVisibleChangeResult) {
417
+ return { outcome: 'VERIFIED', reason: null };
418
+ }
419
+
420
+ return { outcome: 'SILENT_FAILURE', reason: 'no_effect' };
421
+ }
422
+
423
+ /**
424
+ * Execute all PROVEN expectations from manifest.
425
+ * @param {Page} page - Playwright page
426
+ * @param {Object} manifest - Manifest with staticExpectations
427
+ * @param {string} baseUrl - Base URL
428
+ * @param {string} screenshotsDir - Screenshots directory
429
+ * @param {Object} scanBudget - Scan budget
430
+ * @param {number} startTime - Scan start time
431
+ * @param {string} projectDir - Project directory
432
+ * @returns {Promise<Object>} { results: [], executedCount, coverageGaps: [] }
433
+ */
434
+ export async function executeProvenExpectations(page, manifest, baseUrl, screenshotsDir, scanBudget, startTime, projectDir) {
435
+ const provenExpectations = (manifest.staticExpectations || []).filter(exp => isProvenExpectation(exp));
436
+ const results = [];
437
+ const coverageGaps = [];
438
+
439
+ const timestamp = Date.now();
440
+
441
+ for (let i = 0; i < provenExpectations.length; i++) {
442
+ const expectation = provenExpectations[i];
443
+
444
+ // Check budget before each expectation
445
+ if (Date.now() - startTime > scanBudget.maxScanDurationMs) {
446
+ // Mark remaining expectations as coverage gaps
447
+ for (let j = i; j < provenExpectations.length; j++) {
448
+ coverageGaps.push({
449
+ expectationId: provenExpectations[j].id || `exp-${j}`,
450
+ type: provenExpectations[j].type,
451
+ reason: 'budget_exceeded',
452
+ fromPath: provenExpectations[j].fromPath,
453
+ source: provenExpectations[j].sourceRef || provenExpectations[j].evidence?.source || null
454
+ });
455
+ }
456
+ break;
457
+ }
458
+
459
+ const result = await executeExpectation(
460
+ page,
461
+ expectation,
462
+ baseUrl,
463
+ screenshotsDir,
464
+ timestamp,
465
+ i,
466
+ scanBudget,
467
+ startTime,
468
+ projectDir
469
+ );
470
+
471
+ results.push(result);
472
+
473
+ // If outcome is COVERAGE_GAP, add to coverageGaps array
474
+ // NOTE: budget_exceeded is NEVER in results - only unattempted expectations get budget_exceeded
475
+ // All results here are executed expectations with real outcomes
476
+ if (result.outcome === 'COVERAGE_GAP') {
477
+ coverageGaps.push({
478
+ expectationId: result.expectationId,
479
+ type: result.type,
480
+ reason: result.reason,
481
+ fromPath: result.fromPath,
482
+ source: expectation.sourceRef || expectation.evidence?.source || null,
483
+ evidence: result.evidence
484
+ });
485
+ }
486
+ }
487
+
488
+ // FINAL ASSERTION: Every PROVEN expectation must have been accounted for
489
+ // Results array contains ALL expectations that were attempted (with real outcomes: VERIFIED/SILENT_FAILURE/COVERAGE_GAP)
490
+ // Coverage gaps array contains:
491
+ // - COVERAGE_GAP outcomes from executed expectations (element_not_found, page_unreachable, etc.)
492
+ // - budget_exceeded outcomes from unattempted expectations (only from loop break)
493
+ // Budget-exceeded expectations are NEVER in results - they are only in coverageGaps and were never attempted
494
+ const budgetExceededCount = coverageGaps.filter(cg => cg.reason === 'budget_exceeded').length;
495
+ const totalAccounted = results.length + budgetExceededCount;
496
+
497
+ if (totalAccounted !== provenExpectations.length) {
498
+ throw new Error(
499
+ `CORRECTNESS VIOLATION: Expected ${provenExpectations.length} PROVEN expectations to be accounted for, ` +
500
+ `but got ${results.length} executed + ${budgetExceededCount} budget-exceeded (not attempted) = ${totalAccounted} total. ` +
501
+ `Missing ${provenExpectations.length - totalAccounted} expectations.`
502
+ );
503
+ }
504
+
505
+ return {
506
+ results,
507
+ executedCount: results.length,
508
+ coverageGaps,
509
+ totalProvenExpectations: provenExpectations.length
510
+ };
511
+ }
512
+
@@ -0,0 +1,143 @@
1
+ /**
2
+ * FLOW INTELLIGENCE v1 — Flow Matcher
3
+ *
4
+ * Matches discovered interactions to PROVEN flow steps.
5
+ * NO HEURISTICS: Only executes flows with explicit PROVEN expectations.
6
+ */
7
+
8
+ /**
9
+ * Match an interaction to a flow step expectation.
10
+ * Uses same logic as expectation-model but for flow context.
11
+ */
12
+ function matchesFlowStep(interaction, step) {
13
+ // Navigation: match by type
14
+ if (step.expectationType === 'navigation' && interaction.type === 'link') {
15
+ return true;
16
+ }
17
+
18
+ // Network action: match form or button
19
+ if (step.expectationType === 'network_action') {
20
+ if (interaction.type === 'form' || interaction.type === 'button') {
21
+ return true;
22
+ }
23
+ }
24
+
25
+ // State action: match button
26
+ if (step.expectationType === 'state_action' && interaction.type === 'button') {
27
+ return true;
28
+ }
29
+
30
+ return false;
31
+ }
32
+
33
+ /**
34
+ * Find which flow a set of interactions matches.
35
+ * Returns the best matching flow or null.
36
+ */
37
+ function findMatchingFlow(interactions, flows) {
38
+ if (!flows || flows.length === 0) return null;
39
+ if (!interactions || interactions.length < 2) return null;
40
+
41
+ // Try to match each flow
42
+ for (const flow of flows) {
43
+ let matchedSteps = 0;
44
+
45
+ // Check if we have enough interactions for this flow
46
+ if (interactions.length < flow.stepCount) continue;
47
+
48
+ // Try to match flow steps to interactions in order
49
+ for (let i = 0; i < flow.stepCount; i++) {
50
+ const step = flow.steps[i];
51
+ const interaction = interactions[i];
52
+
53
+ if (matchesFlowStep(interaction, step)) {
54
+ matchedSteps++;
55
+ }
56
+ }
57
+
58
+ // If all steps matched, return this flow
59
+ if (matchedSteps === flow.stepCount) {
60
+ return flow;
61
+ }
62
+ }
63
+
64
+ return null;
65
+ }
66
+
67
+ /**
68
+ * Build flow-aware execution plan from discovered interactions and PROVEN flows.
69
+ *
70
+ * @param {Array} interactions - Discovered interactions
71
+ * @param {Array} flows - PROVEN flows from manifest
72
+ * @returns {Array} Array of flow execution plans
73
+ */
74
+ export function buildFlowExecutionPlan(interactions, flows = []) {
75
+ const executionPlan = [];
76
+
77
+ // If no PROVEN flows, fall back to simple grouping (original behavior)
78
+ if (!flows || flows.length === 0) {
79
+ let cursor = 0;
80
+ while (cursor < interactions.length && executionPlan.length < 3) {
81
+ const remaining = interactions.length - cursor;
82
+ const size = Math.min(Math.max(2, Math.min(5, remaining)), remaining);
83
+ const flowInteractions = interactions.slice(cursor, cursor + size);
84
+ executionPlan.push({
85
+ flowId: `flow-${executionPlan.length + 1}`,
86
+ stepCount: flowInteractions.length,
87
+ interactions: flowInteractions,
88
+ proven: false
89
+ });
90
+ cursor += size;
91
+ }
92
+ return executionPlan;
93
+ }
94
+
95
+ // PROVEN FLOW EXECUTION: Match interactions to PROVEN flows
96
+ const usedInteractions = new Set();
97
+
98
+ for (const flow of flows) {
99
+ if (executionPlan.length >= 3) break; // Max 3 flows per run
100
+
101
+ // Find unused interactions that match this flow
102
+ const availableInteractions = interactions.filter((_, idx) => !usedInteractions.has(idx));
103
+
104
+ if (availableInteractions.length < flow.stepCount) continue;
105
+
106
+ const matchingFlow = findMatchingFlow(availableInteractions, [flow]);
107
+
108
+ if (matchingFlow) {
109
+ const flowInteractions = availableInteractions.slice(0, flow.stepCount);
110
+
111
+ // Mark these interactions as used
112
+ flowInteractions.forEach((interaction) => {
113
+ const idx = interactions.indexOf(interaction);
114
+ if (idx >= 0) usedInteractions.add(idx);
115
+ });
116
+
117
+ executionPlan.push({
118
+ flowId: flow.flowId,
119
+ stepCount: flow.stepCount,
120
+ interactions: flowInteractions,
121
+ proven: true,
122
+ provenFlow: flow
123
+ });
124
+ }
125
+ }
126
+
127
+ // If no PROVEN flows matched, execute remaining interactions as fallback flows
128
+ if (executionPlan.length === 0) {
129
+ const unusedInteractions = interactions.filter((_, idx) => !usedInteractions.has(idx));
130
+ if (unusedInteractions.length >= 2) {
131
+ const size = Math.min(5, unusedInteractions.length);
132
+ const flowInteractions = unusedInteractions.slice(0, size);
133
+ executionPlan.push({
134
+ flowId: `flow-fallback-1`,
135
+ stepCount: flowInteractions.length,
136
+ interactions: flowInteractions,
137
+ proven: false
138
+ });
139
+ }
140
+ }
141
+
142
+ return executionPlan;
143
+ }