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