@veraxhq/verax 0.3.0 → 0.4.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 (191) hide show
  1. package/README.md +28 -20
  2. package/bin/verax.js +11 -18
  3. package/package.json +28 -7
  4. package/src/cli/commands/baseline.js +1 -2
  5. package/src/cli/commands/default.js +72 -81
  6. package/src/cli/commands/doctor.js +29 -0
  7. package/src/cli/commands/ga.js +3 -0
  8. package/src/cli/commands/gates.js +1 -1
  9. package/src/cli/commands/inspect.js +6 -133
  10. package/src/cli/commands/release-check.js +2 -0
  11. package/src/cli/commands/run.js +74 -246
  12. package/src/cli/commands/security-check.js +2 -1
  13. package/src/cli/commands/truth.js +0 -1
  14. package/src/cli/entry.js +82 -309
  15. package/src/cli/util/angular-component-extractor.js +2 -2
  16. package/src/cli/util/angular-navigation-detector.js +2 -2
  17. package/src/cli/util/ast-interactive-detector.js +4 -6
  18. package/src/cli/util/ast-network-detector.js +3 -3
  19. package/src/cli/util/ast-promise-extractor.js +581 -0
  20. package/src/cli/util/ast-usestate-detector.js +3 -3
  21. package/src/cli/util/atomic-write.js +12 -1
  22. package/src/cli/util/console-reporter.js +72 -0
  23. package/src/cli/util/detection-engine.js +105 -41
  24. package/src/cli/util/determinism-runner.js +2 -1
  25. package/src/cli/util/determinism-writer.js +1 -1
  26. package/src/cli/util/digest-engine.js +359 -0
  27. package/src/cli/util/dom-diff.js +226 -0
  28. package/src/cli/util/env-url.js +0 -4
  29. package/src/cli/util/evidence-engine.js +287 -0
  30. package/src/cli/util/expectation-extractor.js +217 -367
  31. package/src/cli/util/findings-writer.js +19 -126
  32. package/src/cli/util/framework-detector.js +572 -0
  33. package/src/cli/util/idgen.js +1 -1
  34. package/src/cli/util/interaction-planner.js +529 -0
  35. package/src/cli/util/learn-writer.js +2 -2
  36. package/src/cli/util/ledger-writer.js +110 -0
  37. package/src/cli/util/monorepo-resolver.js +162 -0
  38. package/src/cli/util/observation-engine.js +127 -278
  39. package/src/cli/util/observe-writer.js +2 -2
  40. package/src/cli/util/paths.js +12 -3
  41. package/src/cli/util/project-discovery.js +284 -3
  42. package/src/cli/util/project-writer.js +2 -2
  43. package/src/cli/util/run-id.js +23 -27
  44. package/src/cli/util/run-result.js +778 -0
  45. package/src/cli/util/selector-resolver.js +235 -0
  46. package/src/cli/util/summary-writer.js +2 -1
  47. package/src/cli/util/svelte-navigation-detector.js +3 -3
  48. package/src/cli/util/svelte-sfc-extractor.js +0 -1
  49. package/src/cli/util/svelte-state-detector.js +1 -2
  50. package/src/cli/util/trust-activation-integration.js +496 -0
  51. package/src/cli/util/trust-activation-wrapper.js +85 -0
  52. package/src/cli/util/trust-integration-hooks.js +164 -0
  53. package/src/cli/util/types.js +153 -0
  54. package/src/cli/util/url-validation.js +40 -0
  55. package/src/cli/util/vue-navigation-detector.js +4 -3
  56. package/src/cli/util/vue-sfc-extractor.js +1 -2
  57. package/src/cli/util/vue-state-detector.js +1 -1
  58. package/src/types/fs-augment.d.ts +23 -0
  59. package/src/types/global.d.ts +137 -0
  60. package/src/types/internal-types.d.ts +35 -0
  61. package/src/verax/cli/finding-explainer.js +3 -56
  62. package/src/verax/cli/init.js +4 -18
  63. package/src/verax/core/action-classifier.js +4 -3
  64. package/src/verax/core/artifacts/registry.js +0 -15
  65. package/src/verax/core/artifacts/verifier.js +18 -8
  66. package/src/verax/core/baseline/baseline.snapshot.js +2 -0
  67. package/src/verax/core/capabilities/gates.js +7 -1
  68. package/src/verax/core/confidence/confidence-compute.js +14 -7
  69. package/src/verax/core/confidence/confidence.loader.js +1 -0
  70. package/src/verax/core/confidence-engine-refactor.js +8 -3
  71. package/src/verax/core/confidence-engine.js +162 -23
  72. package/src/verax/core/contracts/types.js +1 -0
  73. package/src/verax/core/contracts/validators.js +79 -4
  74. package/src/verax/core/decision-snapshot.js +3 -30
  75. package/src/verax/core/decisions/decision.trace.js +2 -0
  76. package/src/verax/core/determinism/contract-writer.js +2 -2
  77. package/src/verax/core/determinism/contract.js +1 -1
  78. package/src/verax/core/determinism/diff.js +42 -1
  79. package/src/verax/core/determinism/engine.js +7 -6
  80. package/src/verax/core/determinism/finding-identity.js +3 -2
  81. package/src/verax/core/determinism/normalize.js +32 -4
  82. package/src/verax/core/determinism/report-writer.js +1 -0
  83. package/src/verax/core/determinism/run-fingerprint.js +7 -2
  84. package/src/verax/core/dynamic-route-intelligence.js +8 -7
  85. package/src/verax/core/evidence/evidence-capture-service.js +1 -0
  86. package/src/verax/core/evidence/evidence-intent-ledger.js +2 -1
  87. package/src/verax/core/evidence-builder.js +2 -2
  88. package/src/verax/core/execution-mode-context.js +1 -1
  89. package/src/verax/core/execution-mode-detector.js +5 -3
  90. package/src/verax/core/failures/exit-codes.js +39 -37
  91. package/src/verax/core/failures/failure-summary.js +1 -1
  92. package/src/verax/core/failures/failure.factory.js +3 -3
  93. package/src/verax/core/failures/failure.ledger.js +3 -2
  94. package/src/verax/core/ga/ga.artifact.js +1 -1
  95. package/src/verax/core/ga/ga.contract.js +3 -2
  96. package/src/verax/core/ga/ga.enforcer.js +1 -0
  97. package/src/verax/core/guardrails/policy.loader.js +1 -0
  98. package/src/verax/core/guardrails/truth-reconciliation.js +1 -1
  99. package/src/verax/core/guardrails-engine.js +2 -2
  100. package/src/verax/core/incremental-store.js +1 -0
  101. package/src/verax/core/integrity/budget.js +138 -0
  102. package/src/verax/core/integrity/determinism.js +342 -0
  103. package/src/verax/core/integrity/integrity.js +208 -0
  104. package/src/verax/core/integrity/poisoning.js +108 -0
  105. package/src/verax/core/integrity/transaction.js +140 -0
  106. package/src/verax/core/observe/run-timeline.js +2 -0
  107. package/src/verax/core/perf/perf.report.js +2 -0
  108. package/src/verax/core/pipeline-tracker.js +5 -0
  109. package/src/verax/core/release/provenance.builder.js +73 -214
  110. package/src/verax/core/release/release.enforcer.js +14 -9
  111. package/src/verax/core/release/reproducibility.check.js +1 -0
  112. package/src/verax/core/release/sbom.builder.js +32 -23
  113. package/src/verax/core/replay-validator.js +2 -0
  114. package/src/verax/core/replay.js +4 -0
  115. package/src/verax/core/report/cross-index.js +6 -3
  116. package/src/verax/core/report/human-summary.js +141 -1
  117. package/src/verax/core/route-intelligence.js +4 -3
  118. package/src/verax/core/run-id.js +6 -3
  119. package/src/verax/core/run-manifest.js +4 -3
  120. package/src/verax/core/security/secrets.scan.js +10 -7
  121. package/src/verax/core/security/security.enforcer.js +4 -0
  122. package/src/verax/core/security/supplychain.policy.js +9 -1
  123. package/src/verax/core/security/vuln.scan.js +2 -2
  124. package/src/verax/core/truth/truth.certificate.js +3 -1
  125. package/src/verax/core/ui-feedback-intelligence.js +12 -46
  126. package/src/verax/detect/conditional-ui-silent-failure.js +84 -0
  127. package/src/verax/detect/confidence-engine.js +100 -660
  128. package/src/verax/detect/confidence-helper.js +1 -0
  129. package/src/verax/detect/detection-engine.js +1 -18
  130. package/src/verax/detect/dynamic-route-findings.js +17 -14
  131. package/src/verax/detect/expectation-chain-detector.js +1 -1
  132. package/src/verax/detect/expectation-model.js +3 -5
  133. package/src/verax/detect/failure-cause-inference.js +293 -0
  134. package/src/verax/detect/findings-writer.js +126 -166
  135. package/src/verax/detect/flow-detector.js +2 -2
  136. package/src/verax/detect/form-silent-failure.js +98 -0
  137. package/src/verax/detect/index.js +51 -234
  138. package/src/verax/detect/invariants-enforcer.js +147 -0
  139. package/src/verax/detect/journey-stall-detector.js +4 -4
  140. package/src/verax/detect/navigation-silent-failure.js +82 -0
  141. package/src/verax/detect/problem-aggregator.js +361 -0
  142. package/src/verax/detect/route-findings.js +7 -6
  143. package/src/verax/detect/summary-writer.js +477 -0
  144. package/src/verax/detect/test-failure-cause-inference.js +314 -0
  145. package/src/verax/detect/ui-feedback-findings.js +18 -18
  146. package/src/verax/detect/verdict-engine.js +3 -57
  147. package/src/verax/detect/view-switch-correlator.js +2 -2
  148. package/src/verax/flow/flow-engine.js +2 -1
  149. package/src/verax/flow/flow-spec.js +0 -6
  150. package/src/verax/index.js +48 -412
  151. package/src/verax/intel/ts-program.js +1 -0
  152. package/src/verax/intel/vue-navigation-extractor.js +3 -0
  153. package/src/verax/learn/action-contract-extractor.js +67 -682
  154. package/src/verax/learn/ast-contract-extractor.js +1 -1
  155. package/src/verax/learn/flow-extractor.js +1 -0
  156. package/src/verax/learn/project-detector.js +5 -0
  157. package/src/verax/learn/react-router-extractor.js +2 -0
  158. package/src/verax/learn/route-validator.js +1 -4
  159. package/src/verax/learn/source-instrumenter.js +1 -0
  160. package/src/verax/learn/state-extractor.js +2 -1
  161. package/src/verax/learn/static-extractor.js +1 -0
  162. package/src/verax/observe/coverage-gaps.js +132 -0
  163. package/src/verax/observe/expectation-handler.js +126 -0
  164. package/src/verax/observe/incremental-skip.js +46 -0
  165. package/src/verax/observe/index.js +735 -84
  166. package/src/verax/observe/interaction-executor.js +192 -0
  167. package/src/verax/observe/interaction-runner.js +782 -530
  168. package/src/verax/observe/network-firewall.js +86 -0
  169. package/src/verax/observe/observation-builder.js +169 -0
  170. package/src/verax/observe/observe-context.js +1 -1
  171. package/src/verax/observe/observe-helpers.js +2 -1
  172. package/src/verax/observe/observe-runner.js +28 -24
  173. package/src/verax/observe/observers/budget-observer.js +3 -3
  174. package/src/verax/observe/observers/console-observer.js +4 -4
  175. package/src/verax/observe/observers/coverage-observer.js +4 -4
  176. package/src/verax/observe/observers/interaction-observer.js +3 -3
  177. package/src/verax/observe/observers/navigation-observer.js +4 -4
  178. package/src/verax/observe/observers/network-observer.js +4 -4
  179. package/src/verax/observe/observers/safety-observer.js +1 -1
  180. package/src/verax/observe/observers/ui-feedback-observer.js +4 -4
  181. package/src/verax/observe/page-traversal.js +138 -0
  182. package/src/verax/observe/snapshot-ops.js +94 -0
  183. package/src/verax/observe/ui-signal-sensor.js +2 -148
  184. package/src/verax/scan-summary-writer.js +10 -42
  185. package/src/verax/shared/artifact-manager.js +30 -13
  186. package/src/verax/shared/caching.js +1 -0
  187. package/src/verax/shared/expectation-tracker.js +1 -0
  188. package/src/verax/shared/zip-artifacts.js +6 -0
  189. package/src/verax/core/confidence-engine.js.backup +0 -471
  190. package/src/verax/shared/config-loader.js +0 -169
  191. /package/src/verax/shared/{expectation-proof.js → expectation-validation.js} +0 -0
@@ -1,13 +1,33 @@
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 { discoverAllInteractions } from './interaction-discovery.js';
4
5
  import { captureScreenshot } from './evidence-capture.js';
5
- import { getBaseOrigin } from './domain-boundary.js';
6
+ import { runInteraction } from './interaction-runner.js';
7
+ import { writeTraces } from './traces-writer.js';
8
+ import { getBaseOrigin, isExternalUrl } from './domain-boundary.js';
9
+
10
+ // STAGE D1: Extracted modules for observe orchestration
11
+ import { setupNetworkFirewall } from './network-firewall.js';
12
+
13
+ // STAGE D2.1: Extracted modules for snapshot operations
14
+ import { initializeSnapshot, finalizeSnapshot } from './snapshot-ops.js';
15
+
16
+ // STAGE D2.2: Extracted modules for coverage gap accumulation and warnings
17
+ import { accumulateCoverageGaps, buildCoverageObject, generateCoverageWarnings } from './coverage-gaps.js';
18
+
19
+ // STAGE D2.4: Extracted modules for incremental skip handling
20
+ import { buildIncrementalPhantomTrace } from './incremental-skip.js';
21
+
6
22
  import { DEFAULT_SCAN_BUDGET } from '../shared/scan-budget.js';
23
+ import { executeProvenExpectations } from './expectation-executor.js';
24
+ import { isProvenExpectation } from '../shared/expectation-prover.js';
7
25
  import { PageFrontier } from './page-frontier.js';
26
+ import { deriveObservedExpectation, shouldAttemptRepeatObservedExpectation, evaluateObservedExpectation } from './observed-expectation.js';
27
+ import { computeRouteBudget } from '../core/budget-engine.js';
28
+ import { shouldSkipInteractionIncremental } from '../core/incremental-store.js';
8
29
  import SilenceTracker from '../core/silence-model.js';
9
- import { DecisionRecorder, recordBudgetProfile, recordTimeoutConfig, recordEnvironment } from '../core/determinism-model.js';
10
- import { setupManifestAndExpectations, processTraversalResults, writeDeterminismArtifacts } from './observe-helpers.js';
30
+ import { DecisionRecorder, recordBudgetProfile, recordTimeoutConfig, recordTruncation, recordEnvironment } from '../core/determinism-model.js';
11
31
 
12
32
  /**
13
33
  * OBSERVE PHASE - Execute interactions and capture runtime behavior
@@ -24,8 +44,8 @@ import { setupManifestAndExpectations, processTraversalResults, writeDeterminism
24
44
  * All silence is explicit in output - no silent success.
25
45
  *
26
46
  * PHASE 4: Safety mode enabled by default
27
- * - Blocks risky/write actions unless flags allow
28
- * - Blocks POST/PUT/PATCH/DELETE unless --allow-writes
47
+ * - Blocks risky/write actions unconditionally
48
+ * - Blocks POST/PUT/PATCH/DELETE (read-only mode enforced)
29
49
  * - Blocks cross-origin unless --allow-cross-origin
30
50
  *
31
51
  * PHASE 5: Deterministic artifact paths
@@ -50,51 +70,79 @@ export async function observe(url, manifestPath = null, scanBudgetOverride = nul
50
70
  recordEnvironment(decisionRecorder, { browserType: 'chromium', viewport: { width: 1280, height: 720 } });
51
71
 
52
72
  // Phase 5: Detect projectDir if not provided (for backwards compatibility with tests)
53
- // Use a local variable to avoid reassigning function parameter
54
- let resolvedProjectDir = projectDir;
55
- if (!resolvedProjectDir) {
56
- resolvedProjectDir = process.cwd();
73
+ if (!projectDir) {
74
+ projectDir = process.cwd();
57
75
  }
58
-
76
+
59
77
  // Phase 4: Extract safety flags
60
- const { allowWrites = false, allowRiskyActions = false, allowCrossOrigin = false } = safetyFlags;
78
+ const { allowRiskyActions = false, allowCrossOrigin = false } = safetyFlags;
61
79
  let blockedNetworkWrites = [];
62
80
  let blockedCrossOrigin = [];
63
-
64
- // PHASE 21.3: Setup network interception using safety observer
65
- const { setupNetworkInterception } = await import('./observers/safety-observer.js');
66
- // Create temporary context for network interception setup
67
- const tempContext = {
68
- page,
69
- baseOrigin,
70
- safetyFlags: { allowWrites, allowCrossOrigin },
71
- silenceTracker,
72
- blockedNetworkWrites,
73
- blockedCrossOrigin
74
- };
75
- await setupNetworkInterception(tempContext);
81
+
82
+ // Phase 4: Setup network interception firewall (STAGE D1: moved to module)
83
+ const firewallResult = await setupNetworkFirewall(page, baseOrigin, allowCrossOrigin, silenceTracker);
84
+ blockedNetworkWrites = firewallResult.blockedNetworkWrites;
85
+ blockedCrossOrigin = firewallResult.blockedCrossOrigin;
76
86
 
77
87
  try {
78
88
  await navigateToUrl(page, url, scanBudget);
79
89
 
80
- // If manifestPath is provided, derive projectDir from it (override default)
81
- if (manifestPath) {
82
- resolvedProjectDir = dirname(dirname(dirname(manifestPath)));
83
- }
90
+ const projectDir = manifestPath ? dirname(dirname(dirname(manifestPath))) : process.cwd();
84
91
  if (!runId) {
85
92
  throw new Error('runId is required');
86
93
  }
87
94
  const { getScreenshotDir } = await import('../core/run-id.js');
88
- const screenshotsDir = getScreenshotDir(resolvedProjectDir, runId);
95
+ const screenshotsDir = getScreenshotDir(projectDir, runId);
89
96
  mkdirSync(screenshotsDir, { recursive: true });
90
97
 
91
98
  const timestamp = Date.now();
92
99
  const initialScreenshot = resolve(screenshotsDir, `initial-${timestamp}.png`);
93
100
  await captureScreenshot(page, initialScreenshot);
94
101
 
95
- // PHASE 21.3: Setup manifest and expectations using helper
96
- const { manifest, expectationResults, expectationCoverageGaps, incrementalMode, snapshotDiff, oldSnapshot } =
97
- await setupManifestAndExpectations(manifestPath, resolvedProjectDir, page, url, screenshotsDir, scanBudget, startTime, silenceTracker);
102
+ // 1) Execute PROVEN expectations first (if manifest exists)
103
+ let manifest = null;
104
+ let expectationResults = null;
105
+ let expectationCoverageGaps = [];
106
+ let incrementalMode = false;
107
+ let snapshotDiff = null;
108
+ let oldSnapshot = null;
109
+
110
+ if (manifestPath && existsSync(manifestPath)) {
111
+ try {
112
+ const manifestContent = readFileSync(manifestPath, 'utf-8');
113
+ // @ts-expect-error - readFileSync with encoding returns string
114
+ manifest = JSON.parse(manifestContent);
115
+
116
+ // STAGE D2.1: Snapshot initialization (moved to snapshot-ops module)
117
+ const snapshotResult = await initializeSnapshot(projectDir, manifest);
118
+ oldSnapshot = snapshotResult.oldSnapshot;
119
+ snapshotDiff = snapshotResult.snapshotDiff;
120
+ incrementalMode = snapshotResult.incrementalMode;
121
+
122
+ const provenCount = (manifest.staticExpectations || []).filter(exp => isProvenExpectation(exp)).length;
123
+ if (provenCount > 0) {
124
+ expectationResults = await executeProvenExpectations(
125
+ page,
126
+ manifest,
127
+ url,
128
+ screenshotsDir,
129
+ scanBudget,
130
+ startTime,
131
+ projectDir
132
+ );
133
+ expectationCoverageGaps = expectationResults.coverageGaps || [];
134
+ }
135
+ } catch (err) {
136
+ // Record manifest load/expectation execution failure as silence
137
+ silenceTracker.record({
138
+ scope: 'discovery',
139
+ reason: 'discovery_error',
140
+ description: 'Manifest load or expectation execution failed',
141
+ context: { error: err?.message },
142
+ impact: 'incomplete_check'
143
+ });
144
+ }
145
+ }
98
146
 
99
147
  // Reset to start URL before traversal
100
148
  await navigateToUrl(page, url, scanBudget);
@@ -108,72 +156,623 @@ export async function observe(url, manifestPath = null, scanBudgetOverride = nul
108
156
  let totalInteractionsExecuted = 0;
109
157
  let remainingInteractionsGaps = [];
110
158
 
111
- // PHASE 21.3: Run main traversal loop
112
- const { runTraversalLoop } = await import('./observe-runner.js');
113
- const traversalResult = await runTraversalLoop({
114
- page,
115
- url,
116
- baseOrigin,
159
+ let nextPageUrl = frontier.getNextUrl();
160
+
161
+ while (nextPageUrl && Date.now() - startTime < scanBudget.maxScanDurationMs) {
162
+ if (frontier.isPageLimitExceeded()) {
163
+ // PHASE 6: Record truncation decision
164
+ recordTruncation(decisionRecorder, 'pages', {
165
+ limit: scanBudget.maxPages,
166
+ reached: frontier.pagesVisited
167
+ });
168
+
169
+ silenceTracker.record({
170
+ scope: 'page',
171
+ reason: 'page_limit_exceeded',
172
+ description: `Reached maximum of ${scanBudget.maxPages} pages visited`,
173
+ context: { pagesVisited: frontier.pagesVisited, maxPages: scanBudget.maxPages },
174
+ impact: 'blocks_nav'
175
+ });
176
+ break;
177
+ }
178
+
179
+ // Check if we're already on the target page (from navigation via link click)
180
+ const currentUrl = page.url();
181
+ const normalizedNext = frontier.normalizeUrl(nextPageUrl);
182
+ const normalizedCurrent = frontier.normalizeUrl(currentUrl);
183
+ const alreadyOnPage = normalizedCurrent === normalizedNext;
184
+
185
+ if (!alreadyOnPage) {
186
+ // Navigate to next page
187
+ try {
188
+ await navigateToUrl(page, nextPageUrl, scanBudget);
189
+ } catch (error) {
190
+ // Record navigation failure as silence and skip
191
+ silenceTracker.record({
192
+ scope: 'navigation',
193
+ reason: 'navigation_timeout',
194
+ description: 'Navigation to page failed',
195
+ context: { targetUrl: nextPageUrl },
196
+ impact: 'blocks_nav'
197
+ });
198
+ const normalizedFailed = frontier.normalizeUrl(nextPageUrl);
199
+ if (!frontier.visited.has(normalizedFailed)) {
200
+ frontier.visited.add(normalizedFailed);
201
+ frontier.markVisited();
202
+ }
203
+ nextPageUrl = frontier.getNextUrl();
204
+ continue;
205
+ }
206
+ }
207
+
208
+ // Mark as visited and increment counter
209
+ // getNextUrl() marks as visited in the set but doesn't call markVisited() to increment counter
210
+ // So we need to call markVisited() here when we process a page
211
+ if (!alreadyOnPage) {
212
+ // We navigated via getNextUrl() - it already marked in visited set, now increment counter
213
+ // (getNextUrl() marks in visited set but doesn't increment pagesVisited)
214
+ frontier.markVisited();
215
+ } else {
216
+ // We navigated via link click (alreadyOnPage=true) - mark as visited and increment
217
+ if (!frontier.visited.has(normalizedNext)) {
218
+ frontier.visited.add(normalizedNext);
219
+ frontier.markVisited();
220
+ } else {
221
+ // Already marked as visited, but still increment counter since we're processing it
222
+ frontier.markVisited();
223
+ }
224
+ }
225
+
226
+ // Discover ALL links on this page and add to frontier BEFORE executing interactions
227
+ try {
228
+ const currentLinks = await page.locator('a[href]').all();
229
+ for (const link of currentLinks) {
230
+ try {
231
+ const href = await link.getAttribute('href');
232
+ if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
233
+ const resolvedUrl = href.startsWith('http') ? href : new URL(href, page.url()).href;
234
+ if (!isExternalUrl(resolvedUrl, baseOrigin)) {
235
+ frontier.addUrl(resolvedUrl);
236
+ }
237
+ }
238
+ } catch (error) {
239
+ // Record invalid URL discovery as silence
240
+ silenceTracker.record({
241
+ scope: 'discovery',
242
+ reason: 'discovery_error',
243
+ description: 'Invalid or unreadable link during discovery',
244
+ context: { pageUrl: page.url() },
245
+ impact: 'incomplete_check'
246
+ });
247
+ }
248
+ }
249
+ } catch (error) {
250
+ // Record link discovery failure as silence
251
+ silenceTracker.record({
252
+ scope: 'discovery',
253
+ reason: 'discovery_error',
254
+ description: 'Link discovery failed on page',
255
+ context: { pageUrl: page.url() },
256
+ impact: 'incomplete_check'
257
+ });
258
+ }
259
+
260
+ // SCALE INTELLIGENCE: Compute adaptive budget for this route
261
+ // Reuse currentUrl from above (already captured at line 95)
262
+ const routeBudget = manifest ? computeRouteBudget(manifest, currentUrl, scanBudget) : scanBudget;
263
+
264
+ // Discover ALL interactions on this page
265
+ // Note: discoverAllInteractions already returns sorted interactions deterministically
266
+ const { interactions } = await discoverAllInteractions(page, baseOrigin, routeBudget);
267
+ totalInteractionsDiscovered += interactions.length;
268
+
269
+ // SCALE INTELLIGENCE: Apply adaptive budget cap (interactions are already sorted deterministically)
270
+ // Stable sorting ensures determinism: same interactions → same order
271
+ const sortedInteractions = interactions.slice(0, routeBudget.maxInteractionsPerPage);
272
+
273
+ // Track if we navigated during interaction execution
274
+ let navigatedToNewPage = false;
275
+ let navigatedPageUrl = null;
276
+ let remainingInteractionsStartIndex = 0;
277
+
278
+ // Execute discovered interactions on this page (sorted for determinism)
279
+ for (let i = 0; i < sortedInteractions.length; i++) {
280
+ if (Date.now() - startTime > scanBudget.maxScanDurationMs) {
281
+ // PHASE 6: Record truncation decision
282
+ recordTruncation(decisionRecorder, 'time', {
283
+ limit: scanBudget.maxScanDurationMs,
284
+ elapsed: Date.now() - startTime
285
+ });
286
+
287
+ // Mark remaining interactions as COVERAGE_GAP
288
+ silenceTracker.record({
289
+ scope: 'interaction',
290
+ reason: 'scan_time_exceeded',
291
+ description: `Scan time limit (${scanBudget.maxScanDurationMs}ms) exceeded`,
292
+ context: {
293
+ elapsed: Date.now() - startTime,
294
+ maxDuration: scanBudget.maxScanDurationMs,
295
+ remainingInteractions: sortedInteractions.length - i
296
+ },
297
+ impact: 'blocks_nav',
298
+ count: sortedInteractions.length - i
299
+ });
300
+ remainingInteractionsStartIndex = i;
301
+ break;
302
+ }
303
+
304
+ if (totalInteractionsExecuted >= routeBudget.maxInteractionsPerPage) {
305
+ // PHASE 6: Record truncation decision
306
+ recordTruncation(decisionRecorder, 'interactions', {
307
+ limit: routeBudget.maxInteractionsPerPage,
308
+ reached: totalInteractionsExecuted,
309
+ scope: 'per_page'
310
+ });
311
+
312
+ // Route-specific budget exceeded
313
+ silenceTracker.record({
314
+ scope: 'interaction',
315
+ reason: 'route_interaction_limit_exceeded',
316
+ description: `Reached max ${routeBudget.maxInteractionsPerPage} interactions per page`,
317
+ context: {
318
+ currentPage: page.url(),
319
+ executed: totalInteractionsExecuted,
320
+ maxPerPage: routeBudget.maxInteractionsPerPage,
321
+ remainingInteractions: sortedInteractions.length - i
322
+ },
323
+ impact: 'affects_expectations',
324
+ count: sortedInteractions.length - i
325
+ });
326
+ remainingInteractionsStartIndex = i;
327
+ break;
328
+ }
329
+
330
+ if (totalInteractionsExecuted >= scanBudget.maxTotalInteractions) {
331
+ // PHASE 6: Record truncation decision
332
+ recordTruncation(decisionRecorder, 'interactions', {
333
+ limit: scanBudget.maxTotalInteractions,
334
+ reached: totalInteractionsExecuted,
335
+ scope: 'total'
336
+ });
337
+
338
+ // Mark remaining interactions as COVERAGE_GAP with reason 'budget_exceeded'
339
+ silenceTracker.record({
340
+ scope: 'interaction',
341
+ reason: 'interaction_limit_exceeded',
342
+ description: `Reached max ${scanBudget.maxTotalInteractions} total interactions`,
343
+ context: {
344
+ executed: totalInteractionsExecuted,
345
+ maxTotal: scanBudget.maxTotalInteractions,
346
+ remainingInteractions: sortedInteractions.length - i
347
+ },
348
+ impact: 'blocks_nav',
349
+ count: sortedInteractions.length - i
350
+ });
351
+ remainingInteractionsStartIndex = i;
352
+ break;
353
+ }
354
+
355
+ const interaction = sortedInteractions[i];
356
+
357
+ // SCALE INTELLIGENCE: Check if interaction should be skipped in incremental mode
358
+ if (incrementalMode && manifest && oldSnapshot && snapshotDiff) {
359
+ const shouldSkip = shouldSkipInteractionIncremental(interaction, currentUrl, oldSnapshot, snapshotDiff);
360
+ if (shouldSkip) {
361
+ // Create a trace for skipped interaction (marked as incremental - will not produce findings)
362
+ const skippedTrace = buildIncrementalPhantomTrace({ interaction, currentUrl });
363
+ traces.push(skippedTrace);
364
+
365
+ // Track incremental skip as silence
366
+ silenceTracker.record({
367
+ scope: 'interaction',
368
+ reason: 'incremental_unchanged',
369
+ description: `Skipped re-observation (unchanged in incremental mode): ${interaction.label}`,
370
+ context: {
371
+ currentPage: currentUrl,
372
+ selector: interaction.selector,
373
+ interactionLabel: interaction.label,
374
+ type: interaction.type
375
+ },
376
+ impact: 'affects_expectations'
377
+ });
378
+
379
+ skippedInteractions.push({
380
+ interaction: {
381
+ type: interaction.type,
382
+ selector: interaction.selector,
383
+ label: interaction.label,
384
+ text: interaction.text
385
+ },
386
+ outcome: 'SKIPPED',
387
+ reason: 'incremental_unchanged',
388
+ url: currentUrl,
389
+ evidence: {
390
+ selector: interaction.selector,
391
+ label: interaction.label,
392
+ incremental: true
393
+ }
394
+ });
395
+ continue;
396
+ }
397
+ }
398
+
399
+ // Skip dangerous interactions (logout, delete, etc.) with explicit reason
400
+ const skipCheck = frontier.shouldSkipInteraction(interaction);
401
+ if (skipCheck.skip) {
402
+ // Track safety skip as silence
403
+ silenceTracker.record({
404
+ scope: 'interaction',
405
+ reason: skipCheck.reason === 'destructive' ? 'destructive_text' : 'unsafe_pattern',
406
+ description: `Skipped potentially dangerous interaction: ${interaction.label}`,
407
+ context: {
408
+ currentPage: page.url(),
409
+ selector: interaction.selector,
410
+ interactionLabel: interaction.label,
411
+ text: interaction.text,
412
+ skipReason: skipCheck.reason,
413
+ skipMessage: skipCheck.message
414
+ },
415
+ impact: 'unknown_behavior'
416
+ });
417
+
418
+ skippedInteractions.push({
419
+ interaction: {
420
+ type: interaction.type,
421
+ selector: interaction.selector,
422
+ label: interaction.label,
423
+ text: interaction.text
424
+ },
425
+ outcome: 'SKIPPED',
426
+ reason: skipCheck.reason || 'safety_policy',
427
+ url: page.url(),
428
+ evidence: {
429
+ selector: interaction.selector,
430
+ label: interaction.label,
431
+ text: interaction.text,
432
+ sourcePage: page.url()
433
+ }
434
+ });
435
+ continue;
436
+ }
437
+
438
+ // Phase 4: Check action classification and safety mode
439
+ const { shouldBlockAction } = await import('../core/action-classifier.js');
440
+ const allowWrites = allowRiskyActions;
441
+ const blockCheck = shouldBlockAction(interaction, { allowWrites, allowRiskyActions });
442
+
443
+ if (blockCheck.shouldBlock) {
444
+ // Track blocked action as silence
445
+ silenceTracker.record({
446
+ scope: 'safety',
447
+ reason: 'blocked_action',
448
+ description: `Action blocked by safety mode: ${interaction.label} (${blockCheck.classification})`,
449
+ context: {
450
+ currentPage: page.url(),
451
+ selector: interaction.selector,
452
+ interactionLabel: interaction.label,
453
+ text: interaction.text,
454
+ classification: blockCheck.classification,
455
+ blockReason: blockCheck.reason
456
+ },
457
+ impact: 'action_blocked'
458
+ });
459
+
460
+ skippedInteractions.push({
461
+ interaction: {
462
+ type: interaction.type,
463
+ selector: interaction.selector,
464
+ label: interaction.label,
465
+ text: interaction.text
466
+ },
467
+ outcome: 'BLOCKED',
468
+ reason: 'safety_mode',
469
+ classification: blockCheck.classification,
470
+ url: page.url(),
471
+ evidence: {
472
+ selector: interaction.selector,
473
+ label: interaction.label,
474
+ text: interaction.text,
475
+ classification: blockCheck.classification,
476
+ sourcePage: page.url()
477
+ }
478
+ });
479
+
480
+ // Create a minimal trace for blocked interactions so they appear in output
481
+ const blockedTrace = {
482
+ interaction: {
483
+ type: interaction.type,
484
+ selector: interaction.selector,
485
+ label: interaction.label,
486
+ text: interaction.text
487
+ },
488
+ before: {
489
+ url: page.url(),
490
+ screenshot: null
491
+ },
492
+ after: {
493
+ url: page.url(),
494
+ screenshot: null
495
+ },
496
+ policy: {
497
+ actionBlocked: true,
498
+ classification: blockCheck.classification,
499
+ reason: blockCheck.reason
500
+ },
501
+ outcome: 'BLOCKED_BY_SAFETY_MODE',
502
+ timestamp: Date.now()
503
+ };
504
+ traces.push(blockedTrace);
505
+
506
+ continue;
507
+ }
508
+
509
+ const beforeUrl = page.url();
510
+ const interactionIndex = totalInteractionsExecuted;
511
+ const trace = await runInteraction(
512
+ page,
513
+ interaction,
514
+ timestamp,
515
+ interactionIndex,
516
+ screenshotsDir,
517
+ baseOrigin,
518
+ startTime,
519
+ routeBudget, // Use route-specific budget
520
+ null,
521
+ silenceTracker // Pass silence tracker
522
+ );
523
+
524
+ // Mark trace with incremental flag if applicable
525
+ if (incrementalMode && trace) {
526
+ trace.incremental = false; // This interaction was executed, not skipped
527
+ }
528
+
529
+ let repeatTrace = null;
530
+
531
+ if (trace) {
532
+ const matchingExpectation = expectationResults?.results?.find(r => r.trace?.interaction?.selector === trace.interaction.selector);
533
+ if (matchingExpectation) {
534
+ trace.expectationDriven = true;
535
+ trace.expectationId = matchingExpectation.expectationId;
536
+ trace.expectationOutcome = matchingExpectation.outcome;
537
+ } else {
538
+ const observedExpectation = deriveObservedExpectation(interaction, trace, baseOrigin);
539
+ if (observedExpectation) {
540
+ trace.observedExpectation = observedExpectation;
541
+ trace.resultType = 'OBSERVED_EXPECTATION';
542
+ observedExpectations.push(observedExpectation);
543
+
544
+ const repeatEligible = shouldAttemptRepeatObservedExpectation(observedExpectation, trace);
545
+ const budgetAllowsRepeat = repeatEligible &&
546
+ (Date.now() - startTime) < scanBudget.maxScanDurationMs &&
547
+ (totalInteractionsExecuted + 1) < scanBudget.maxTotalInteractions;
548
+
549
+ if (budgetAllowsRepeat) {
550
+ const repeatIndex = totalInteractionsExecuted + 1;
551
+ const repeatResult = await repeatObservedInteraction(
552
+ page,
553
+ interaction,
554
+ observedExpectation,
555
+ timestamp,
556
+ repeatIndex,
557
+ screenshotsDir,
558
+ baseOrigin,
559
+ startTime,
560
+ scanBudget
561
+ );
562
+
563
+ if (repeatResult) {
564
+ const repeatEvaluation = repeatResult.repeatEvaluation;
565
+ trace.observedExpectation.repeatAttempted = true;
566
+ trace.observedExpectation.repeated = repeatEvaluation.outcome === 'VERIFIED';
567
+ trace.observedExpectation.repeatOutcome = repeatEvaluation.outcome;
568
+ trace.observedExpectation.repeatReason = repeatEvaluation.reason;
569
+
570
+ if (repeatEvaluation.outcome === 'OBSERVED_BREAK') {
571
+ trace.observedExpectation.outcome = 'OBSERVED_BREAK';
572
+ trace.observedExpectation.reason = 'inconsistent_on_repeat';
573
+ trace.observedExpectation.confidenceLevel = 'LOW';
574
+ } else if (trace.observedExpectation.repeated && trace.observedExpectation.outcome === 'VERIFIED') {
575
+ trace.observedExpectation.confidenceLevel = 'MEDIUM';
576
+ }
577
+
578
+ repeatTrace = repeatResult.repeatTrace;
579
+ }
580
+ }
581
+ } else {
582
+ trace.unprovenResult = true;
583
+ trace.resultType = 'UNPROVEN_RESULT';
584
+ }
585
+ }
586
+
587
+ traces.push(trace);
588
+ totalInteractionsExecuted++;
589
+
590
+ if (repeatTrace) {
591
+ traces.push(repeatTrace);
592
+ totalInteractionsExecuted++;
593
+ }
594
+
595
+ const afterUrl = trace.after?.url || page.url();
596
+ const navigatedSameOrigin = afterUrl && afterUrl !== beforeUrl && !isExternalUrl(afterUrl, baseOrigin);
597
+ if (navigatedSameOrigin && interaction.type === 'link') {
598
+ // Link navigation - add new page to frontier (if not already visited)
599
+ const normalizedAfter = frontier.normalizeUrl(afterUrl);
600
+ const wasAlreadyVisited = frontier.visited.has(normalizedAfter);
601
+ if (!wasAlreadyVisited) {
602
+ const added = frontier.addUrl(afterUrl);
603
+ // If frontier was capped, record coverage gap
604
+ if (!added && frontier.frontierCapped) {
605
+ remainingInteractionsGaps.push({
606
+ interaction: {
607
+ type: 'link',
608
+ selector: interaction.selector,
609
+ label: interaction.label
610
+ },
611
+ reason: 'frontier_capped',
612
+ url: afterUrl
613
+ });
614
+ }
615
+ }
616
+
617
+ // Stay on the new page and continue executing interactions there
618
+ navigatedToNewPage = true;
619
+ navigatedPageUrl = afterUrl;
620
+
621
+ // Discover links on the new page immediately
622
+ try {
623
+ const newPageLinks = await page.locator('a[href]').all();
624
+ for (const link of newPageLinks) {
625
+ try {
626
+ const href = await link.getAttribute('href');
627
+ if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
628
+ const resolvedUrl = href.startsWith('http') ? href : new URL(href, page.url()).href;
629
+ if (!isExternalUrl(resolvedUrl, baseOrigin)) {
630
+ frontier.addUrl(resolvedUrl);
631
+ }
632
+ }
633
+ } catch (error) {
634
+ // Record invalid URL discovery as silence
635
+ silenceTracker.record({
636
+ scope: 'discovery',
637
+ reason: 'discovery_error',
638
+ description: 'Invalid or unreadable link during discovery',
639
+ context: { pageUrl: page.url() },
640
+ impact: 'incomplete_check'
641
+ });
642
+ }
643
+ }
644
+ } catch (error) {
645
+ // Record link discovery failure as silence
646
+ silenceTracker.record({
647
+ scope: 'discovery',
648
+ reason: 'discovery_error',
649
+ description: 'Link discovery failed on page',
650
+ context: { pageUrl: page.url() },
651
+ impact: 'incomplete_check'
652
+ });
653
+ }
654
+
655
+ // Break to restart loop on new page
656
+ break;
657
+ }
658
+ }
659
+ }
660
+
661
+ // Mark remaining interactions as COVERAGE_GAP if we stopped early
662
+ if (remainingInteractionsStartIndex > 0 && remainingInteractionsStartIndex < sortedInteractions.length && !navigatedToNewPage) {
663
+ for (let j = remainingInteractionsStartIndex; j < sortedInteractions.length; j++) {
664
+ const reason = totalInteractionsExecuted >= scanBudget.maxTotalInteractions ? 'budget_exceeded' :
665
+ (totalInteractionsExecuted >= routeBudget.maxInteractionsPerPage ? 'route_budget_exceeded' : 'budget_exceeded');
666
+ remainingInteractionsGaps.push({
667
+ interaction: {
668
+ type: sortedInteractions[j].type,
669
+ selector: sortedInteractions[j].selector,
670
+ label: sortedInteractions[j].label
671
+ },
672
+ reason: reason,
673
+ url: currentUrl
674
+ });
675
+ }
676
+ }
677
+
678
+ // If we navigated to a new page, stay on it and continue (next iteration will handle it)
679
+ if (navigatedToNewPage && navigatedPageUrl) {
680
+ // Don't mark as visited yet - we'll do it at the start of next iteration
681
+ // This ensures the page counter is incremented when we process the page
682
+ // Set nextPageUrl to the navigated page so we process it in the next iteration
683
+ nextPageUrl = navigatedPageUrl;
684
+ continue;
685
+ }
686
+
687
+ // After executing all interactions on current page, move to next page in frontier
688
+ nextPageUrl = frontier.getNextUrl();
689
+ }
690
+
691
+ // Combine all coverage gaps
692
+ expectationCoverageGaps.push(...accumulateCoverageGaps(remainingInteractionsGaps, frontier, page.url(), scanBudget));
693
+
694
+ // Build coverage object matching writeTraces expected format
695
+ const coverage = buildCoverageObject(
696
+ totalInteractionsDiscovered,
697
+ totalInteractionsExecuted,
117
698
  scanBudget,
118
- startTime,
119
699
  frontier,
120
- manifest,
121
- expectationResults,
122
- incrementalMode,
123
- oldSnapshot,
124
- snapshotDiff,
125
- currentUrl: page.url(),
126
- screenshotsDir,
127
- timestamp,
128
- decisionRecorder,
129
- silenceTracker,
130
- traces,
131
700
  skippedInteractions,
132
- observedExpectations,
133
- totalInteractionsDiscovered,
134
- totalInteractionsExecuted,
135
- remainingInteractionsGaps,
136
- allowWrites,
137
- allowRiskyActions
138
- });
139
-
140
- // PHASE 21.3: Process traversal results using helper
141
- const finalTraces = [...traversalResult.traces];
142
- const finalSkippedInteractions = [...traversalResult.skippedInteractions];
143
- const finalObservedExpectations = [...traversalResult.observedExpectations];
144
- remainingInteractionsGaps = [...traversalResult.remainingInteractionsGaps];
701
+ remainingInteractionsGaps
702
+ );
145
703
 
146
- const observation = await processTraversalResults(
147
- traversalResult,
148
- expectationResults,
149
- expectationCoverageGaps,
150
- remainingInteractionsGaps,
151
- frontier,
152
- scanBudget,
153
- page,
154
- url,
155
- finalTraces,
156
- finalSkippedInteractions,
157
- finalObservedExpectations,
158
- silenceTracker,
704
+ // Ensure we increment pagesVisited when we navigate via getNextUrl()
705
+ // getNextUrl() marks as visited but doesn't increment counter - we do it here
706
+ // BUT: when alreadyOnPage is true, we've already marked it, so don't double-count
707
+
708
+ // Record warnings
709
+ observeWarnings.push(...generateCoverageWarnings(coverage, skippedInteractions));
710
+
711
+ // Append expectation traces for completeness
712
+ if (expectationResults && expectationResults.results) {
713
+ for (const result of expectationResults.results) {
714
+ if (result.trace) {
715
+ result.trace.expectationDriven = true;
716
+ result.trace.expectationId = result.expectationId;
717
+ result.trace.expectationOutcome = result.outcome;
718
+ traces.push(result.trace);
719
+ }
720
+ }
721
+ }
722
+
723
+ const observation = writeTraces(projectDir, url, traces, coverage, observeWarnings, observedExpectations, silenceTracker, runId);
724
+
725
+ // Add silence tracking to observation result
726
+ observation.silences = silenceTracker.getDetailedSummary();
727
+
728
+ if (expectationResults) {
729
+ observation.expectationExecution = {
730
+ totalProvenExpectations: expectationResults.totalProvenExpectations,
731
+ executedCount: expectationResults.executedCount,
732
+ coverageGapsCount: expectationCoverageGaps.length,
733
+ results: expectationResults.results.map(r => ({
734
+ expectationId: r.expectationId,
735
+ type: r.type,
736
+ fromPath: r.fromPath,
737
+ outcome: r.outcome,
738
+ reason: r.reason
739
+ }))
740
+ };
741
+ observation.expectationCoverageGaps = expectationCoverageGaps;
742
+ }
743
+
744
+ // STAGE D2.1: Snapshot finalization (moved to snapshot-ops module)
745
+ const incrementalMetadata = await finalizeSnapshot(
159
746
  manifest,
747
+ traces,
748
+ skippedInteractions,
160
749
  incrementalMode,
161
750
  snapshotDiff,
162
- resolvedProjectDir,
163
- runId
751
+ projectDir,
752
+ runId,
753
+ url
164
754
  );
755
+ if (incrementalMetadata) {
756
+ observation.incremental = incrementalMetadata;
757
+ }
165
758
 
166
759
  await closeBrowser(browser);
167
760
 
168
- // PHASE 21.3: Write determinism artifacts using helper
169
- await writeDeterminismArtifacts(resolvedProjectDir, runId, decisionRecorder);
761
+ // PHASE 6: Export determinism decisions to run directory
762
+ if (runId && projectDir) {
763
+ const runsDir = resolve(projectDir, '.verax', 'runs', runId);
764
+ mkdirSync(runsDir, { recursive: true });
765
+ const decisionsPath = resolve(runsDir, 'decisions.json');
766
+ const decisionsData = JSON.stringify(decisionRecorder.export(), null, 2);
767
+ writeFileSync(decisionsPath, decisionsData, 'utf-8');
768
+ }
170
769
 
171
770
  // Phase 4: Add safety mode statistics
172
771
  const safetyBlocks = {
173
- actionsBlocked: finalSkippedInteractions.filter(s => s.reason === 'safety_mode').length,
772
+ actionsBlocked: skippedInteractions.filter(s => s.reason === 'safety_mode').length,
174
773
  networkWritesBlocked: blockedNetworkWrites.length,
175
774
  crossOriginBlocked: blockedCrossOrigin.length,
176
- blockedActions: finalSkippedInteractions.filter(s => s.reason === 'safety_mode').map(s => ({
775
+ blockedActions: skippedInteractions.filter(s => s.reason === 'safety_mode').map(s => ({
177
776
  label: s.interaction.label,
178
777
  classification: s.classification,
179
778
  url: s.url
@@ -193,4 +792,56 @@ export async function observe(url, manifestPath = null, scanBudgetOverride = nul
193
792
  }
194
793
  }
195
794
 
196
- // PHASE 21.3: repeatObservedInteraction moved to observe-runner.js
795
+ async function repeatObservedInteraction(
796
+ page,
797
+ interaction,
798
+ observedExpectation,
799
+ timestamp,
800
+ interactionIndex,
801
+ screenshotsDir,
802
+ baseOrigin,
803
+ startTime,
804
+ scanBudget
805
+ ) {
806
+ const selector = observedExpectation.evidence?.selector || interaction.selector;
807
+ if (!selector) return null;
808
+
809
+ const locator = page.locator(selector).first();
810
+ const count = await locator.count();
811
+ if (count === 0) {
812
+ return null;
813
+ }
814
+
815
+ const repeatInteraction = {
816
+ ...interaction,
817
+ element: locator
818
+ };
819
+
820
+ const repeatTrace = await runInteraction(
821
+ page,
822
+ repeatInteraction,
823
+ timestamp,
824
+ interactionIndex,
825
+ screenshotsDir,
826
+ baseOrigin,
827
+ startTime,
828
+ scanBudget,
829
+ null,
830
+ null // No silence tracker for repeat executions (not counted as new silence)
831
+ );
832
+
833
+ if (!repeatTrace) {
834
+ return null;
835
+ }
836
+
837
+ repeatTrace.repeatExecution = true;
838
+ repeatTrace.repeatOfObservedExpectationId = observedExpectation.id;
839
+ repeatTrace.resultType = 'OBSERVED_EXPECTATION_REPEAT';
840
+
841
+ const repeatEvaluation = evaluateObservedExpectation(observedExpectation, repeatTrace);
842
+
843
+ return {
844
+ repeatTrace,
845
+ repeatEvaluation
846
+ };
847
+ }