@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
@@ -1,67 +1,948 @@
1
1
  import { resolve, dirname } from 'path';
2
- import { mkdirSync } from 'fs';
2
+ import { mkdirSync, readFileSync, existsSync } from 'fs';
3
3
  import { createBrowser, navigateToUrl, closeBrowser } from './browser.js';
4
- import { discoverInteractions } from './interaction-discovery.js';
4
+ import { discoverAllInteractions } from './interaction-discovery.js';
5
5
  import { captureScreenshot } from './evidence-capture.js';
6
6
  import { runInteraction } from './interaction-runner.js';
7
7
  import { writeTraces } from './traces-writer.js';
8
- import { getBaseOrigin } from './domain-boundary.js';
8
+ import { getBaseOrigin, isExternalUrl } from './domain-boundary.js';
9
9
 
10
- const MAX_SCAN_DURATION_MS = 60000;
10
+ import { DEFAULT_SCAN_BUDGET } from '../shared/scan-budget.js';
11
+ import { executeProvenExpectations } from './expectation-executor.js';
12
+ import { isProvenExpectation } from '../shared/expectation-prover.js';
13
+ import { PageFrontier } from './page-frontier.js';
14
+ import { deriveObservedExpectation, shouldAttemptRepeatObservedExpectation, evaluateObservedExpectation } from './observed-expectation.js';
15
+ import { computeRouteBudget } from '../core/budget-engine.js';
16
+ import { loadPreviousSnapshot, saveSnapshot, buildSnapshot, compareSnapshots, shouldSkipInteractionIncremental } from '../core/incremental-store.js';
17
+ import SilenceTracker from '../core/silence-model.js';
18
+ import { DecisionRecorder, recordBudgetProfile, recordTimeoutConfig, recordAdaptiveStabilization, recordTruncation, recordEnvironment } from '../core/determinism-model.js';
11
19
 
12
- export async function observe(url, manifestPath = null, artifactPaths = null) {
20
+ /**
21
+ * OBSERVE PHASE - Execute interactions and capture runtime behavior
22
+ *
23
+ * SILENCE TRACKING: Every skip, timeout, cap, and drop is recorded.
24
+ * Nothing unobserved is allowed to disappear.
25
+ *
26
+ * - Incremental skips: Tracked as silence (reused previous data)
27
+ * - Safety skips: Tracked as silence (destructive/unsafe actions)
28
+ * - Budget caps: Tracked as silence (unevaluated interactions)
29
+ * - Timeouts: Tracked as silence (unknown outcomes)
30
+ * - Sensor failures: Tracked as silence (missing data)
31
+ *
32
+ * All silence is explicit in output - no silent success.
33
+ *
34
+ * PHASE 4: Safety mode enabled by default
35
+ * - Blocks risky/write actions unless flags allow
36
+ * - Blocks POST/PUT/PATCH/DELETE unless --allow-writes
37
+ * - Blocks cross-origin unless --allow-cross-origin
38
+ *
39
+ * PHASE 5: Deterministic artifact paths
40
+ * - All artifacts written to .verax/runs/<runId>/
41
+ */
42
+ export async function observe(url, manifestPath = null, scanBudgetOverride = null, safetyFlags = {}, projectDir = null, runId = null) {
43
+ const scanBudget = scanBudgetOverride || DEFAULT_SCAN_BUDGET;
13
44
  const { browser, page } = await createBrowser();
14
45
  const startTime = Date.now();
15
46
  const baseOrigin = getBaseOrigin(url);
47
+ const silenceTracker = new SilenceTracker();
16
48
 
17
- try {
18
- await navigateToUrl(page, url);
49
+ // PHASE 6: Record all adaptive decisions for determinism tracking
50
+ const decisionRecorder = new DecisionRecorder(runId);
51
+
52
+ // Record budget and timeout configuration decisions
53
+ const profileName = scanBudgetOverride ? 'custom' : 'standard';
54
+ recordBudgetProfile(decisionRecorder, profileName, scanBudget);
55
+ recordTimeoutConfig(decisionRecorder, scanBudget);
56
+
57
+ // Record environment decisions
58
+ recordEnvironment(decisionRecorder, { browserType: 'chromium', viewport: { width: 1280, height: 720 } });
59
+
60
+ // Phase 5: Detect projectDir if not provided (for backwards compatibility with tests)
61
+ if (!projectDir) {
62
+ projectDir = process.cwd();
63
+ }
64
+
65
+ // Phase 4: Extract safety flags
66
+ const { allowWrites = false, allowRiskyActions = false, allowCrossOrigin = false } = safetyFlags;
67
+ let blockedNetworkWrites = [];
68
+ let blockedCrossOrigin = [];
69
+
70
+ // Phase 4: Setup network interception firewall
71
+ await page.route('**/*', (route) => {
72
+ const request = route.request();
73
+ const method = request.method();
74
+ const requestUrl = request.url();
75
+ const resourceType = request.resourceType();
19
76
 
20
- const projectDir = manifestPath ? dirname(dirname(dirname(manifestPath))) : process.cwd();
21
- let screenshotsDir;
22
- if (artifactPaths) {
23
- screenshotsDir = resolve(artifactPaths.evidence, 'screenshots');
24
- } else {
25
- const observeDir = resolve(projectDir, '.veraxverax', 'observe');
26
- screenshotsDir = resolve(observeDir, 'screenshots');
77
+ // Check cross-origin blocking (skip for file:// URLs)
78
+ if (!allowCrossOrigin && !requestUrl.startsWith('file://')) {
79
+ try {
80
+ const reqOrigin = new URL(requestUrl).origin;
81
+ if (reqOrigin !== baseOrigin) {
82
+ blockedCrossOrigin.push({
83
+ url: requestUrl,
84
+ origin: reqOrigin,
85
+ method,
86
+ resourceType,
87
+ timestamp: Date.now()
88
+ });
89
+
90
+ silenceTracker.record({
91
+ scope: 'safety',
92
+ reason: 'cross_origin_blocked',
93
+ description: `Cross-origin request blocked: ${method} ${requestUrl}`,
94
+ context: { url: requestUrl, origin: reqOrigin, method, baseOrigin },
95
+ impact: 'request_blocked'
96
+ });
97
+
98
+ return route.abort('blockedbyclient');
99
+ }
100
+ } catch (e) {
101
+ // Invalid URL, allow and let browser handle
102
+ }
103
+ }
104
+
105
+ // Check write method blocking
106
+ if (!allowWrites && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
107
+ // Check if it's a GraphQL mutation (best-effort)
108
+ const isGraphQLMutation = requestUrl.includes('/graphql') && method === 'POST';
109
+
110
+ blockedNetworkWrites.push({
111
+ url: requestUrl,
112
+ method,
113
+ resourceType,
114
+ isGraphQLMutation,
115
+ timestamp: Date.now()
116
+ });
117
+
118
+ silenceTracker.record({
119
+ scope: 'safety',
120
+ reason: 'blocked_network_write',
121
+ description: `Network write blocked: ${method} ${requestUrl}${isGraphQLMutation ? ' (GraphQL mutation)' : ''}`,
122
+ context: { url: requestUrl, method, resourceType, isGraphQLMutation },
123
+ impact: 'write_blocked'
124
+ });
125
+
126
+ return route.abort('blockedbyclient');
27
127
  }
128
+
129
+ // Allow request
130
+ route.continue();
131
+ });
132
+
133
+ try {
134
+ await navigateToUrl(page, url, scanBudget);
135
+
136
+ const projectDir = manifestPath ? dirname(dirname(dirname(manifestPath))) : process.cwd();
137
+ const observeDir = resolve(projectDir, '.veraxverax', 'observe');
138
+ const screenshotsDir = resolve(observeDir, 'screenshots');
28
139
  mkdirSync(screenshotsDir, { recursive: true });
29
140
 
30
141
  const timestamp = Date.now();
31
142
  const initialScreenshot = resolve(screenshotsDir, `initial-${timestamp}.png`);
32
143
  await captureScreenshot(page, initialScreenshot);
33
144
 
34
- const { interactions, coverage } = await discoverInteractions(page, baseOrigin);
145
+ // 1) Execute PROVEN expectations first (if manifest exists)
146
+ let manifest = null;
147
+ let expectationResults = null;
148
+ let expectationCoverageGaps = [];
149
+ let incrementalMode = false;
150
+ let snapshotDiff = null;
151
+ let oldSnapshot = null;
152
+
153
+ if (manifestPath && existsSync(manifestPath)) {
154
+ try {
155
+ const manifestContent = readFileSync(manifestPath, 'utf-8');
156
+ manifest = JSON.parse(manifestContent);
157
+
158
+ // SCALE INTELLIGENCE: Load previous snapshot for incremental mode
159
+ oldSnapshot = loadPreviousSnapshot(projectDir);
160
+ if (oldSnapshot) {
161
+ const currentSnapshot = buildSnapshot(manifest, []);
162
+ snapshotDiff = compareSnapshots(oldSnapshot, currentSnapshot);
163
+ incrementalMode = !snapshotDiff.hasChanges; // Use incremental if nothing changed
164
+ }
165
+
166
+ const provenCount = (manifest.staticExpectations || []).filter(exp => isProvenExpectation(exp)).length;
167
+ if (provenCount > 0) {
168
+ expectationResults = await executeProvenExpectations(
169
+ page,
170
+ manifest,
171
+ url,
172
+ screenshotsDir,
173
+ scanBudget,
174
+ startTime,
175
+ projectDir
176
+ );
177
+ expectationCoverageGaps = expectationResults.coverageGaps || [];
178
+ }
179
+ } catch (err) {
180
+ // Record manifest load/expectation execution failure as silence
181
+ silenceTracker.record({
182
+ scope: 'discovery',
183
+ reason: 'discovery_error',
184
+ description: 'Manifest load or expectation execution failed',
185
+ context: { error: err?.message },
186
+ impact: 'incomplete_check'
187
+ });
188
+ }
189
+ }
190
+
191
+ // Reset to start URL before traversal
192
+ await navigateToUrl(page, url, scanBudget);
193
+
194
+ const frontier = new PageFrontier(url, baseOrigin, scanBudget, startTime);
35
195
  const traces = [];
36
196
  const observeWarnings = [];
37
- if (coverage && coverage.capped) {
197
+ const skippedInteractions = [];
198
+ const observedExpectations = []; // Store observed expectations for runtime report
199
+ let totalInteractionsDiscovered = 0;
200
+ let totalInteractionsExecuted = 0;
201
+ let remainingInteractionsGaps = [];
202
+
203
+ let nextPageUrl = frontier.getNextUrl();
204
+
205
+ while (nextPageUrl && Date.now() - startTime < scanBudget.maxScanDurationMs) {
206
+ if (frontier.isPageLimitExceeded()) {
207
+ // PHASE 6: Record truncation decision
208
+ recordTruncation(decisionRecorder, 'pages', {
209
+ limit: scanBudget.maxPages,
210
+ reached: frontier.pagesVisited
211
+ });
212
+
213
+ silenceTracker.record({
214
+ scope: 'page',
215
+ reason: 'page_limit_exceeded',
216
+ description: `Reached maximum of ${scanBudget.maxPages} pages visited`,
217
+ context: { pagesVisited: frontier.pagesVisited, maxPages: scanBudget.maxPages },
218
+ impact: 'blocks_nav'
219
+ });
220
+ break;
221
+ }
222
+
223
+ // Check if we're already on the target page (from navigation via link click)
224
+ const currentUrl = page.url();
225
+ const normalizedNext = frontier.normalizeUrl(nextPageUrl);
226
+ const normalizedCurrent = frontier.normalizeUrl(currentUrl);
227
+ const alreadyOnPage = normalizedCurrent === normalizedNext;
228
+
229
+ if (!alreadyOnPage) {
230
+ // Navigate to next page
231
+ try {
232
+ await navigateToUrl(page, nextPageUrl, scanBudget);
233
+ } catch (error) {
234
+ // Record navigation failure as silence and skip
235
+ silenceTracker.record({
236
+ scope: 'navigation',
237
+ reason: 'navigation_timeout',
238
+ description: 'Navigation to page failed',
239
+ context: { targetUrl: nextPageUrl },
240
+ impact: 'blocks_nav'
241
+ });
242
+ const normalizedFailed = frontier.normalizeUrl(nextPageUrl);
243
+ if (!frontier.visited.has(normalizedFailed)) {
244
+ frontier.visited.add(normalizedFailed);
245
+ frontier.markVisited();
246
+ }
247
+ nextPageUrl = frontier.getNextUrl();
248
+ continue;
249
+ }
250
+ }
251
+
252
+ // Mark as visited and increment counter
253
+ // getNextUrl() marks as visited in the set but doesn't call markVisited() to increment counter
254
+ // So we need to call markVisited() here when we process a page
255
+ if (!alreadyOnPage) {
256
+ // We navigated via getNextUrl() - it already marked in visited set, now increment counter
257
+ // (getNextUrl() marks in visited set but doesn't increment pagesVisited)
258
+ frontier.markVisited();
259
+ } else {
260
+ // We navigated via link click (alreadyOnPage=true) - mark as visited and increment
261
+ if (!frontier.visited.has(normalizedNext)) {
262
+ frontier.visited.add(normalizedNext);
263
+ frontier.markVisited();
264
+ } else {
265
+ // Already marked as visited, but still increment counter since we're processing it
266
+ frontier.markVisited();
267
+ }
268
+ }
269
+
270
+ // Discover ALL links on this page and add to frontier BEFORE executing interactions
271
+ try {
272
+ const currentLinks = await page.locator('a[href]').all();
273
+ for (const link of currentLinks) {
274
+ try {
275
+ const href = await link.getAttribute('href');
276
+ if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
277
+ const resolvedUrl = href.startsWith('http') ? href : new URL(href, page.url()).href;
278
+ if (!isExternalUrl(resolvedUrl, baseOrigin)) {
279
+ frontier.addUrl(resolvedUrl);
280
+ }
281
+ }
282
+ } catch (error) {
283
+ // Record invalid URL discovery as silence
284
+ silenceTracker.record({
285
+ scope: 'discovery',
286
+ reason: 'discovery_error',
287
+ description: 'Invalid or unreadable link during discovery',
288
+ context: { pageUrl: page.url() },
289
+ impact: 'incomplete_check'
290
+ });
291
+ }
292
+ }
293
+ } catch (error) {
294
+ // Record link discovery failure as silence
295
+ silenceTracker.record({
296
+ scope: 'discovery',
297
+ reason: 'discovery_error',
298
+ description: 'Link discovery failed on page',
299
+ context: { pageUrl: page.url() },
300
+ impact: 'incomplete_check'
301
+ });
302
+ }
303
+
304
+ // SCALE INTELLIGENCE: Compute adaptive budget for this route
305
+ // Reuse currentUrl from above (already captured at line 95)
306
+ const routeBudget = manifest ? computeRouteBudget(manifest, currentUrl, scanBudget) : scanBudget;
307
+
308
+ // Discover ALL interactions on this page
309
+ // Note: discoverAllInteractions already returns sorted interactions deterministically
310
+ const { interactions, coverage: pageCoverage } = await discoverAllInteractions(page, baseOrigin, routeBudget);
311
+ totalInteractionsDiscovered += interactions.length;
312
+
313
+ // SCALE INTELLIGENCE: Apply adaptive budget cap (interactions are already sorted deterministically)
314
+ // Stable sorting ensures determinism: same interactions → same order
315
+ const sortedInteractions = interactions.slice(0, routeBudget.maxInteractionsPerPage);
316
+
317
+ // Track if we navigated during interaction execution
318
+ let navigatedToNewPage = false;
319
+ let navigatedPageUrl = null;
320
+ let remainingInteractionsStartIndex = 0;
321
+
322
+ // Execute discovered interactions on this page (sorted for determinism)
323
+ for (let i = 0; i < sortedInteractions.length; i++) {
324
+ if (Date.now() - startTime > scanBudget.maxScanDurationMs) {
325
+ // PHASE 6: Record truncation decision
326
+ recordTruncation(decisionRecorder, 'time', {
327
+ limit: scanBudget.maxScanDurationMs,
328
+ elapsed: Date.now() - startTime
329
+ });
330
+
331
+ // Mark remaining interactions as COVERAGE_GAP
332
+ silenceTracker.record({
333
+ scope: 'interaction',
334
+ reason: 'scan_time_exceeded',
335
+ description: `Scan time limit (${scanBudget.maxScanDurationMs}ms) exceeded`,
336
+ context: {
337
+ elapsed: Date.now() - startTime,
338
+ maxDuration: scanBudget.maxScanDurationMs,
339
+ remainingInteractions: sortedInteractions.length - i
340
+ },
341
+ impact: 'blocks_nav',
342
+ count: sortedInteractions.length - i
343
+ });
344
+ remainingInteractionsStartIndex = i;
345
+ break;
346
+ }
347
+
348
+ if (totalInteractionsExecuted >= routeBudget.maxInteractionsPerPage) {
349
+ // PHASE 6: Record truncation decision
350
+ recordTruncation(decisionRecorder, 'interactions', {
351
+ limit: routeBudget.maxInteractionsPerPage,
352
+ reached: totalInteractionsExecuted,
353
+ scope: 'per_page'
354
+ });
355
+
356
+ // Route-specific budget exceeded
357
+ silenceTracker.record({
358
+ scope: 'interaction',
359
+ reason: 'route_interaction_limit_exceeded',
360
+ description: `Reached max ${routeBudget.maxInteractionsPerPage} interactions per page`,
361
+ context: {
362
+ currentPage: page.url(),
363
+ executed: totalInteractionsExecuted,
364
+ maxPerPage: routeBudget.maxInteractionsPerPage,
365
+ remainingInteractions: sortedInteractions.length - i
366
+ },
367
+ impact: 'affects_expectations',
368
+ count: sortedInteractions.length - i
369
+ });
370
+ remainingInteractionsStartIndex = i;
371
+ break;
372
+ }
373
+
374
+ if (totalInteractionsExecuted >= scanBudget.maxTotalInteractions) {
375
+ // PHASE 6: Record truncation decision
376
+ recordTruncation(decisionRecorder, 'interactions', {
377
+ limit: scanBudget.maxTotalInteractions,
378
+ reached: totalInteractionsExecuted,
379
+ scope: 'total'
380
+ });
381
+
382
+ // Mark remaining interactions as COVERAGE_GAP with reason 'budget_exceeded'
383
+ silenceTracker.record({
384
+ scope: 'interaction',
385
+ reason: 'interaction_limit_exceeded',
386
+ description: `Reached max ${scanBudget.maxTotalInteractions} total interactions`,
387
+ context: {
388
+ executed: totalInteractionsExecuted,
389
+ maxTotal: scanBudget.maxTotalInteractions,
390
+ remainingInteractions: sortedInteractions.length - i
391
+ },
392
+ impact: 'blocks_nav',
393
+ count: sortedInteractions.length - i
394
+ });
395
+ remainingInteractionsStartIndex = i;
396
+ break;
397
+ }
398
+
399
+ const interaction = sortedInteractions[i];
400
+
401
+ // SCALE INTELLIGENCE: Check if interaction should be skipped in incremental mode
402
+ if (incrementalMode && manifest && oldSnapshot && snapshotDiff) {
403
+ const shouldSkip = shouldSkipInteractionIncremental(interaction, currentUrl, oldSnapshot, snapshotDiff);
404
+ if (shouldSkip) {
405
+ // Create a trace for skipped interaction (marked as incremental - will not produce findings)
406
+ const skippedTrace = {
407
+ interaction: {
408
+ type: interaction.type,
409
+ selector: interaction.selector,
410
+ label: interaction.label
411
+ },
412
+ before: { url: currentUrl },
413
+ after: { url: currentUrl },
414
+ incremental: true, // Mark as skipped in incremental mode - detect phase will skip this
415
+ resultType: 'INCREMENTAL_SKIP'
416
+ };
417
+ traces.push(skippedTrace);
418
+
419
+ // Track incremental skip as silence
420
+ silenceTracker.record({
421
+ scope: 'interaction',
422
+ reason: 'incremental_unchanged',
423
+ description: `Skipped re-observation (unchanged in incremental mode): ${interaction.label}`,
424
+ context: {
425
+ currentPage: currentUrl,
426
+ selector: interaction.selector,
427
+ interactionLabel: interaction.label,
428
+ type: interaction.type
429
+ },
430
+ impact: 'affects_expectations'
431
+ });
432
+
433
+ skippedInteractions.push({
434
+ interaction: {
435
+ type: interaction.type,
436
+ selector: interaction.selector,
437
+ label: interaction.label,
438
+ text: interaction.text
439
+ },
440
+ outcome: 'SKIPPED',
441
+ reason: 'incremental_unchanged',
442
+ url: currentUrl,
443
+ evidence: {
444
+ selector: interaction.selector,
445
+ label: interaction.label,
446
+ incremental: true
447
+ }
448
+ });
449
+ continue;
450
+ }
451
+ }
452
+
453
+ // Skip dangerous interactions (logout, delete, etc.) with explicit reason
454
+ const skipCheck = frontier.shouldSkipInteraction(interaction);
455
+ if (skipCheck.skip) {
456
+ // Track safety skip as silence
457
+ silenceTracker.record({
458
+ scope: 'interaction',
459
+ reason: skipCheck.reason === 'destructive' ? 'destructive_text' : 'unsafe_pattern',
460
+ description: `Skipped potentially dangerous interaction: ${interaction.label}`,
461
+ context: {
462
+ currentPage: page.url(),
463
+ selector: interaction.selector,
464
+ interactionLabel: interaction.label,
465
+ text: interaction.text,
466
+ skipReason: skipCheck.reason,
467
+ skipMessage: skipCheck.message
468
+ },
469
+ impact: 'unknown_behavior'
470
+ });
471
+
472
+ skippedInteractions.push({
473
+ interaction: {
474
+ type: interaction.type,
475
+ selector: interaction.selector,
476
+ label: interaction.label,
477
+ text: interaction.text
478
+ },
479
+ outcome: 'SKIPPED',
480
+ reason: skipCheck.reason || 'safety_policy',
481
+ url: page.url(),
482
+ evidence: {
483
+ selector: interaction.selector,
484
+ label: interaction.label,
485
+ text: interaction.text,
486
+ sourcePage: page.url()
487
+ }
488
+ });
489
+ continue;
490
+ }
491
+
492
+ // Phase 4: Check action classification and safety mode
493
+ const { shouldBlockAction } = await import('../core/action-classifier.js');
494
+ const blockCheck = shouldBlockAction(interaction, { allowWrites, allowRiskyActions });
495
+
496
+ if (blockCheck.shouldBlock) {
497
+ // Track blocked action as silence
498
+ silenceTracker.record({
499
+ scope: 'safety',
500
+ reason: 'blocked_action',
501
+ description: `Action blocked by safety mode: ${interaction.label} (${blockCheck.classification})`,
502
+ context: {
503
+ currentPage: page.url(),
504
+ selector: interaction.selector,
505
+ interactionLabel: interaction.label,
506
+ text: interaction.text,
507
+ classification: blockCheck.classification,
508
+ blockReason: blockCheck.reason
509
+ },
510
+ impact: 'action_blocked'
511
+ });
512
+
513
+ skippedInteractions.push({
514
+ interaction: {
515
+ type: interaction.type,
516
+ selector: interaction.selector,
517
+ label: interaction.label,
518
+ text: interaction.text
519
+ },
520
+ outcome: 'BLOCKED',
521
+ reason: 'safety_mode',
522
+ classification: blockCheck.classification,
523
+ url: page.url(),
524
+ evidence: {
525
+ selector: interaction.selector,
526
+ label: interaction.label,
527
+ text: interaction.text,
528
+ classification: blockCheck.classification,
529
+ sourcePage: page.url()
530
+ }
531
+ });
532
+
533
+ // Create a minimal trace for blocked interactions so they appear in output
534
+ const blockedTrace = {
535
+ interaction: {
536
+ type: interaction.type,
537
+ selector: interaction.selector,
538
+ label: interaction.label,
539
+ text: interaction.text
540
+ },
541
+ before: {
542
+ url: page.url(),
543
+ screenshot: null
544
+ },
545
+ after: {
546
+ url: page.url(),
547
+ screenshot: null
548
+ },
549
+ policy: {
550
+ actionBlocked: true,
551
+ classification: blockCheck.classification,
552
+ reason: blockCheck.reason
553
+ },
554
+ outcome: 'BLOCKED_BY_SAFETY_MODE',
555
+ timestamp: Date.now()
556
+ };
557
+ traces.push(blockedTrace);
558
+
559
+ continue;
560
+ }
561
+
562
+ const beforeUrl = page.url();
563
+ const interactionIndex = totalInteractionsExecuted;
564
+ const trace = await runInteraction(
565
+ page,
566
+ interaction,
567
+ timestamp,
568
+ interactionIndex,
569
+ screenshotsDir,
570
+ baseOrigin,
571
+ startTime,
572
+ routeBudget, // Use route-specific budget
573
+ null,
574
+ silenceTracker // Pass silence tracker
575
+ );
576
+
577
+ // Mark trace with incremental flag if applicable
578
+ if (incrementalMode && trace) {
579
+ trace.incremental = false; // This interaction was executed, not skipped
580
+ }
581
+
582
+ let repeatTrace = null;
583
+
584
+ if (trace) {
585
+ const matchingExpectation = expectationResults?.results?.find(r => r.trace?.interaction?.selector === trace.interaction.selector);
586
+ if (matchingExpectation) {
587
+ trace.expectationDriven = true;
588
+ trace.expectationId = matchingExpectation.expectationId;
589
+ trace.expectationOutcome = matchingExpectation.outcome;
590
+ } else {
591
+ const observedExpectation = deriveObservedExpectation(interaction, trace, baseOrigin);
592
+ if (observedExpectation) {
593
+ trace.observedExpectation = observedExpectation;
594
+ trace.resultType = 'OBSERVED_EXPECTATION';
595
+ observedExpectations.push(observedExpectation);
596
+
597
+ const repeatEligible = shouldAttemptRepeatObservedExpectation(observedExpectation, trace);
598
+ const budgetAllowsRepeat = repeatEligible &&
599
+ (Date.now() - startTime) < scanBudget.maxScanDurationMs &&
600
+ (totalInteractionsExecuted + 1) < scanBudget.maxTotalInteractions;
601
+
602
+ if (budgetAllowsRepeat) {
603
+ const repeatIndex = totalInteractionsExecuted + 1;
604
+ const repeatResult = await repeatObservedInteraction(
605
+ page,
606
+ interaction,
607
+ observedExpectation,
608
+ timestamp,
609
+ repeatIndex,
610
+ screenshotsDir,
611
+ baseOrigin,
612
+ startTime,
613
+ scanBudget
614
+ );
615
+
616
+ if (repeatResult) {
617
+ const repeatEvaluation = repeatResult.repeatEvaluation;
618
+ trace.observedExpectation.repeatAttempted = true;
619
+ trace.observedExpectation.repeated = repeatEvaluation.outcome === 'VERIFIED';
620
+ trace.observedExpectation.repeatOutcome = repeatEvaluation.outcome;
621
+ trace.observedExpectation.repeatReason = repeatEvaluation.reason;
622
+
623
+ if (repeatEvaluation.outcome === 'OBSERVED_BREAK') {
624
+ trace.observedExpectation.outcome = 'OBSERVED_BREAK';
625
+ trace.observedExpectation.reason = 'inconsistent_on_repeat';
626
+ trace.observedExpectation.confidenceLevel = 'LOW';
627
+ } else if (trace.observedExpectation.repeated && trace.observedExpectation.outcome === 'VERIFIED') {
628
+ trace.observedExpectation.confidenceLevel = 'MEDIUM';
629
+ }
630
+
631
+ repeatTrace = repeatResult.repeatTrace;
632
+ }
633
+ }
634
+ } else {
635
+ trace.unprovenResult = true;
636
+ trace.resultType = 'UNPROVEN_RESULT';
637
+ }
638
+ }
639
+
640
+ traces.push(trace);
641
+ totalInteractionsExecuted++;
642
+
643
+ if (repeatTrace) {
644
+ traces.push(repeatTrace);
645
+ totalInteractionsExecuted++;
646
+ }
647
+
648
+ const afterUrl = trace.after?.url || page.url();
649
+ const navigatedSameOrigin = afterUrl && afterUrl !== beforeUrl && !isExternalUrl(afterUrl, baseOrigin);
650
+ if (navigatedSameOrigin && interaction.type === 'link') {
651
+ // Link navigation - add new page to frontier (if not already visited)
652
+ const normalizedAfter = frontier.normalizeUrl(afterUrl);
653
+ const wasAlreadyVisited = frontier.visited.has(normalizedAfter);
654
+ if (!wasAlreadyVisited) {
655
+ const added = frontier.addUrl(afterUrl);
656
+ // If frontier was capped, record coverage gap
657
+ if (!added && frontier.frontierCapped) {
658
+ remainingInteractionsGaps.push({
659
+ interaction: {
660
+ type: 'link',
661
+ selector: interaction.selector,
662
+ label: interaction.label
663
+ },
664
+ reason: 'frontier_capped',
665
+ url: afterUrl
666
+ });
667
+ }
668
+ }
669
+
670
+ // Stay on the new page and continue executing interactions there
671
+ navigatedToNewPage = true;
672
+ navigatedPageUrl = afterUrl;
673
+
674
+ // Discover links on the new page immediately
675
+ try {
676
+ const newPageLinks = await page.locator('a[href]').all();
677
+ for (const link of newPageLinks) {
678
+ try {
679
+ const href = await link.getAttribute('href');
680
+ if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
681
+ const resolvedUrl = href.startsWith('http') ? href : new URL(href, page.url()).href;
682
+ if (!isExternalUrl(resolvedUrl, baseOrigin)) {
683
+ frontier.addUrl(resolvedUrl);
684
+ }
685
+ }
686
+ } catch (error) {
687
+ // Record invalid URL discovery as silence
688
+ silenceTracker.record({
689
+ scope: 'discovery',
690
+ reason: 'discovery_error',
691
+ description: 'Invalid or unreadable link during discovery',
692
+ context: { pageUrl: page.url() },
693
+ impact: 'incomplete_check'
694
+ });
695
+ }
696
+ }
697
+ } catch (error) {
698
+ // Record link discovery failure as silence
699
+ silenceTracker.record({
700
+ scope: 'discovery',
701
+ reason: 'discovery_error',
702
+ description: 'Link discovery failed on page',
703
+ context: { pageUrl: page.url() },
704
+ impact: 'incomplete_check'
705
+ });
706
+ }
707
+
708
+ // Break to restart loop on new page
709
+ break;
710
+ }
711
+ }
712
+ }
713
+
714
+ // Mark remaining interactions as COVERAGE_GAP if we stopped early
715
+ if (remainingInteractionsStartIndex > 0 && remainingInteractionsStartIndex < sortedInteractions.length && !navigatedToNewPage) {
716
+ for (let j = remainingInteractionsStartIndex; j < sortedInteractions.length; j++) {
717
+ const reason = totalInteractionsExecuted >= scanBudget.maxTotalInteractions ? 'budget_exceeded' :
718
+ (totalInteractionsExecuted >= routeBudget.maxInteractionsPerPage ? 'route_budget_exceeded' : 'budget_exceeded');
719
+ remainingInteractionsGaps.push({
720
+ interaction: {
721
+ type: sortedInteractions[j].type,
722
+ selector: sortedInteractions[j].selector,
723
+ label: sortedInteractions[j].label
724
+ },
725
+ reason: reason,
726
+ url: currentUrl
727
+ });
728
+ }
729
+ }
730
+
731
+ // If we navigated to a new page, stay on it and continue (next iteration will handle it)
732
+ if (navigatedToNewPage && navigatedPageUrl) {
733
+ // Don't mark as visited yet - we'll do it at the start of next iteration
734
+ // This ensures the page counter is incremented when we process the page
735
+ // Set nextPageUrl to the navigated page so we process it in the next iteration
736
+ nextPageUrl = navigatedPageUrl;
737
+ continue;
738
+ }
739
+
740
+ // After executing all interactions on current page, move to next page in frontier
741
+ nextPageUrl = frontier.getNextUrl();
742
+ }
743
+
744
+ // Combine all coverage gaps
745
+ if (remainingInteractionsGaps.length > 0) {
746
+ expectationCoverageGaps.push(...remainingInteractionsGaps.map(gap => ({
747
+ expectationId: null,
748
+ type: gap.interaction.type,
749
+ reason: gap.reason,
750
+ fromPath: gap.url,
751
+ source: null,
752
+ evidence: {
753
+ interaction: gap.interaction
754
+ }
755
+ })));
756
+ }
757
+
758
+ // Record frontier capping as coverage gap if it occurred
759
+ if (frontier.frontierCapped) {
760
+ expectationCoverageGaps.push({
761
+ expectationId: null,
762
+ type: 'navigation',
763
+ reason: 'frontier_capped',
764
+ fromPath: page.url(),
765
+ source: null,
766
+ evidence: {
767
+ message: `Frontier capped at ${scanBudget.maxUniqueUrls || 'unlimited'} unique URLs`
768
+ }
769
+ });
770
+ }
771
+
772
+ // Build coverage object matching writeTraces expected format
773
+ const coverage = {
774
+ candidatesDiscovered: totalInteractionsDiscovered,
775
+ candidatesSelected: totalInteractionsExecuted,
776
+ cap: scanBudget.maxTotalInteractions,
777
+ capped: totalInteractionsExecuted >= scanBudget.maxTotalInteractions || remainingInteractionsGaps.length > 0,
778
+ pagesVisited: frontier.pagesVisited,
779
+ pagesDiscovered: frontier.pagesDiscovered,
780
+ skippedInteractions: skippedInteractions.length,
781
+ interactionsDiscovered: totalInteractionsDiscovered,
782
+ interactionsExecuted: totalInteractionsExecuted
783
+ };
784
+
785
+ // Ensure we increment pagesVisited when we navigate via getNextUrl()
786
+ // getNextUrl() marks as visited but doesn't increment counter - we do it here
787
+ // BUT: when alreadyOnPage is true, we've already marked it, so don't double-count
788
+
789
+ // Record warnings
790
+ if (coverage.capped) {
38
791
  observeWarnings.push({
39
792
  code: 'INTERACTIONS_CAPPED',
40
- message: 'Interaction discovery reached the cap (30). Scan coverage is incomplete.'
793
+ message: `Interaction execution capped. Visited ${coverage.pagesVisited} pages, discovered ${coverage.pagesDiscovered}, executed ${coverage.candidatesSelected} of ${coverage.candidatesDiscovered} interactions. Coverage incomplete.`
41
794
  });
42
795
  }
43
796
 
44
- for (let i = 0; i < interactions.length; i++) {
45
- if (Date.now() - startTime > MAX_SCAN_DURATION_MS) {
46
- break;
797
+ if (skippedInteractions.length > 0) {
798
+ observeWarnings.push({
799
+ code: 'INTERACTIONS_SKIPPED',
800
+ message: `Skipped ${skippedInteractions.length} dangerous interactions`,
801
+ details: skippedInteractions
802
+ });
803
+ }
804
+
805
+ // Append expectation traces for completeness
806
+ if (expectationResults && expectationResults.results) {
807
+ for (const result of expectationResults.results) {
808
+ if (result.trace) {
809
+ result.trace.expectationDriven = true;
810
+ result.trace.expectationId = result.expectationId;
811
+ result.trace.expectationOutcome = result.outcome;
812
+ traces.push(result.trace);
813
+ }
47
814
  }
815
+ }
816
+
817
+ const observation = writeTraces(projectDir, url, traces, coverage, observeWarnings, observedExpectations, silenceTracker, runId);
818
+
819
+ // Add silence tracking to observation result
820
+ observation.silences = silenceTracker.getDetailedSummary();
821
+
822
+ if (expectationResults) {
823
+ observation.expectationExecution = {
824
+ totalProvenExpectations: expectationResults.totalProvenExpectations,
825
+ executedCount: expectationResults.executedCount,
826
+ coverageGapsCount: expectationCoverageGaps.length,
827
+ results: expectationResults.results.map(r => ({
828
+ expectationId: r.expectationId,
829
+ type: r.type,
830
+ fromPath: r.fromPath,
831
+ outcome: r.outcome,
832
+ reason: r.reason
833
+ }))
834
+ };
835
+ observation.expectationCoverageGaps = expectationCoverageGaps;
836
+ }
837
+
838
+ // SCALE INTELLIGENCE: Save snapshot for next incremental run
839
+ if (manifest) {
840
+ // Build snapshot from current run (extract interactions from traces)
841
+ const observedInteractions = traces
842
+ .filter(t => t.interaction && !t.incremental)
843
+ .map(t => ({
844
+ type: t.interaction?.type,
845
+ selector: t.interaction?.selector,
846
+ url: t.before?.url || url
847
+ }));
48
848
 
49
- const trace = await runInteraction(page, interactions[i], timestamp, i, screenshotsDir, baseOrigin, startTime, MAX_SCAN_DURATION_MS);
50
- if (trace) {
51
- traces.push(trace);
52
- }
849
+ const currentSnapshot = buildSnapshot(manifest, observedInteractions);
850
+ saveSnapshot(projectDir, currentSnapshot);
851
+
852
+ // Add incremental mode metadata to observation
853
+ observation.incremental = {
854
+ enabled: incrementalMode,
855
+ snapshotDiff: snapshotDiff,
856
+ skippedInteractionsCount: skippedInteractions.filter(s => s.reason === 'incremental_unchanged').length
857
+ };
53
858
  }
859
+
860
+ await closeBrowser(browser);
54
861
 
55
- const observation = writeTraces(projectDir, url, traces, coverage, observeWarnings, artifactPaths);
862
+ // PHASE 6: Export determinism decisions to run directory
863
+ if (runId && projectDir) {
864
+ const runsDir = resolve(projectDir, '.verax', 'runs', runId);
865
+ mkdirSync(runsDir, { recursive: true });
866
+ const decisionsPath = resolve(runsDir, 'decisions.json');
867
+ const decisionsData = JSON.stringify(decisionRecorder.export(), null, 2);
868
+ require('fs').writeFileSync(decisionsPath, decisionsData, 'utf-8');
869
+ }
56
870
 
57
- await closeBrowser(browser);
871
+ // Phase 4: Add safety mode statistics
872
+ const safetyBlocks = {
873
+ actionsBlocked: skippedInteractions.filter(s => s.reason === 'safety_mode').length,
874
+ networkWritesBlocked: blockedNetworkWrites.length,
875
+ crossOriginBlocked: blockedCrossOrigin.length,
876
+ blockedActions: skippedInteractions.filter(s => s.reason === 'safety_mode').map(s => ({
877
+ label: s.interaction.label,
878
+ classification: s.classification,
879
+ url: s.url
880
+ })),
881
+ blockedNetworkWrites: blockedNetworkWrites.slice(0, 5), // Top 5
882
+ blockedCrossOrigin: blockedCrossOrigin.slice(0, 5) // Top 5
883
+ };
58
884
 
59
885
  return {
60
886
  ...observation,
61
- screenshotsDir: screenshotsDir
887
+ screenshotsDir: screenshotsDir,
888
+ safetyBlocks
62
889
  };
63
890
  } catch (error) {
64
891
  await closeBrowser(browser);
65
892
  throw error;
66
893
  }
67
894
  }
895
+
896
+ async function repeatObservedInteraction(
897
+ page,
898
+ interaction,
899
+ observedExpectation,
900
+ timestamp,
901
+ interactionIndex,
902
+ screenshotsDir,
903
+ baseOrigin,
904
+ startTime,
905
+ scanBudget
906
+ ) {
907
+ const selector = observedExpectation.evidence?.selector || interaction.selector;
908
+ if (!selector) return null;
909
+
910
+ const locator = page.locator(selector).first();
911
+ const count = await locator.count();
912
+ if (count === 0) {
913
+ return null;
914
+ }
915
+
916
+ const repeatInteraction = {
917
+ ...interaction,
918
+ element: locator
919
+ };
920
+
921
+ const repeatTrace = await runInteraction(
922
+ page,
923
+ repeatInteraction,
924
+ timestamp,
925
+ interactionIndex,
926
+ screenshotsDir,
927
+ baseOrigin,
928
+ startTime,
929
+ scanBudget,
930
+ null,
931
+ null // No silence tracker for repeat executions (not counted as new silence)
932
+ );
933
+
934
+ if (!repeatTrace) {
935
+ return null;
936
+ }
937
+
938
+ repeatTrace.repeatExecution = true;
939
+ repeatTrace.repeatOfObservedExpectationId = observedExpectation.id;
940
+ repeatTrace.resultType = 'OBSERVED_EXPECTATION_REPEAT';
941
+
942
+ const repeatEvaluation = evaluateObservedExpectation(observedExpectation, repeatTrace);
943
+
944
+ return {
945
+ repeatTrace,
946
+ repeatEvaluation
947
+ };
948
+ }