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