@veraxhq/verax 0.2.0 → 0.3.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 (217) hide show
  1. package/README.md +14 -18
  2. package/bin/verax.js +7 -0
  3. package/package.json +15 -5
  4. package/src/cli/commands/baseline.js +104 -0
  5. package/src/cli/commands/default.js +323 -111
  6. package/src/cli/commands/doctor.js +36 -4
  7. package/src/cli/commands/ga.js +243 -0
  8. package/src/cli/commands/gates.js +95 -0
  9. package/src/cli/commands/inspect.js +131 -2
  10. package/src/cli/commands/release-check.js +213 -0
  11. package/src/cli/commands/run.js +498 -103
  12. package/src/cli/commands/security-check.js +211 -0
  13. package/src/cli/commands/truth.js +114 -0
  14. package/src/cli/entry.js +305 -68
  15. package/src/cli/util/angular-component-extractor.js +179 -0
  16. package/src/cli/util/angular-navigation-detector.js +141 -0
  17. package/src/cli/util/angular-network-detector.js +161 -0
  18. package/src/cli/util/angular-state-detector.js +162 -0
  19. package/src/cli/util/ast-interactive-detector.js +546 -0
  20. package/src/cli/util/ast-network-detector.js +603 -0
  21. package/src/cli/util/ast-usestate-detector.js +602 -0
  22. package/src/cli/util/bootstrap-guard.js +86 -0
  23. package/src/cli/util/detection-engine.js +4 -3
  24. package/src/cli/util/determinism-runner.js +123 -0
  25. package/src/cli/util/determinism-writer.js +129 -0
  26. package/src/cli/util/env-url.js +4 -0
  27. package/src/cli/util/events.js +76 -0
  28. package/src/cli/util/expectation-extractor.js +380 -74
  29. package/src/cli/util/findings-writer.js +126 -15
  30. package/src/cli/util/learn-writer.js +3 -1
  31. package/src/cli/util/observation-engine.js +69 -23
  32. package/src/cli/util/observe-writer.js +3 -1
  33. package/src/cli/util/paths.js +6 -14
  34. package/src/cli/util/project-discovery.js +23 -0
  35. package/src/cli/util/project-writer.js +3 -1
  36. package/src/cli/util/redact.js +2 -2
  37. package/src/cli/util/run-resolver.js +64 -0
  38. package/src/cli/util/runtime-budget.js +147 -0
  39. package/src/cli/util/source-requirement.js +55 -0
  40. package/src/cli/util/summary-writer.js +13 -1
  41. package/src/cli/util/svelte-navigation-detector.js +163 -0
  42. package/src/cli/util/svelte-network-detector.js +80 -0
  43. package/src/cli/util/svelte-sfc-extractor.js +147 -0
  44. package/src/cli/util/svelte-state-detector.js +243 -0
  45. package/src/cli/util/vue-navigation-detector.js +177 -0
  46. package/src/cli/util/vue-sfc-extractor.js +162 -0
  47. package/src/cli/util/vue-state-detector.js +215 -0
  48. package/src/types/global.d.ts +28 -0
  49. package/src/types/ts-ast.d.ts +24 -0
  50. package/src/verax/cli/doctor.js +2 -2
  51. package/src/verax/cli/finding-explainer.js +56 -3
  52. package/src/verax/cli/init.js +1 -1
  53. package/src/verax/cli/url-safety.js +12 -2
  54. package/src/verax/cli/wizard.js +13 -2
  55. package/src/verax/core/artifacts/registry.js +154 -0
  56. package/src/verax/core/artifacts/verifier.js +980 -0
  57. package/src/verax/core/baseline/baseline.enforcer.js +137 -0
  58. package/src/verax/core/baseline/baseline.snapshot.js +231 -0
  59. package/src/verax/core/budget-engine.js +1 -1
  60. package/src/verax/core/capabilities/gates.js +499 -0
  61. package/src/verax/core/capabilities/registry.js +475 -0
  62. package/src/verax/core/confidence/confidence-compute.js +137 -0
  63. package/src/verax/core/confidence/confidence-invariants.js +234 -0
  64. package/src/verax/core/confidence/confidence-report-writer.js +112 -0
  65. package/src/verax/core/confidence/confidence-weights.js +44 -0
  66. package/src/verax/core/confidence/confidence.defaults.js +65 -0
  67. package/src/verax/core/confidence/confidence.loader.js +79 -0
  68. package/src/verax/core/confidence/confidence.schema.js +94 -0
  69. package/src/verax/core/confidence-engine-refactor.js +484 -0
  70. package/src/verax/core/confidence-engine.js +486 -0
  71. package/src/verax/core/confidence-engine.js.backup +471 -0
  72. package/src/verax/core/contracts/index.js +29 -0
  73. package/src/verax/core/contracts/types.js +185 -0
  74. package/src/verax/core/contracts/validators.js +381 -0
  75. package/src/verax/core/decision-snapshot.js +31 -4
  76. package/src/verax/core/decisions/decision.trace.js +276 -0
  77. package/src/verax/core/determinism/contract-writer.js +89 -0
  78. package/src/verax/core/determinism/contract.js +139 -0
  79. package/src/verax/core/determinism/diff.js +364 -0
  80. package/src/verax/core/determinism/engine.js +221 -0
  81. package/src/verax/core/determinism/finding-identity.js +148 -0
  82. package/src/verax/core/determinism/normalize.js +438 -0
  83. package/src/verax/core/determinism/report-writer.js +92 -0
  84. package/src/verax/core/determinism/run-fingerprint.js +118 -0
  85. package/src/verax/core/determinism-model.js +35 -6
  86. package/src/verax/core/dynamic-route-intelligence.js +528 -0
  87. package/src/verax/core/evidence/evidence-capture-service.js +307 -0
  88. package/src/verax/core/evidence/evidence-intent-ledger.js +165 -0
  89. package/src/verax/core/evidence-builder.js +487 -0
  90. package/src/verax/core/execution-mode-context.js +77 -0
  91. package/src/verax/core/execution-mode-detector.js +190 -0
  92. package/src/verax/core/failures/exit-codes.js +86 -0
  93. package/src/verax/core/failures/failure-summary.js +76 -0
  94. package/src/verax/core/failures/failure.factory.js +225 -0
  95. package/src/verax/core/failures/failure.ledger.js +132 -0
  96. package/src/verax/core/failures/failure.types.js +196 -0
  97. package/src/verax/core/failures/index.js +10 -0
  98. package/src/verax/core/ga/ga-report-writer.js +43 -0
  99. package/src/verax/core/ga/ga.artifact.js +49 -0
  100. package/src/verax/core/ga/ga.contract.js +434 -0
  101. package/src/verax/core/ga/ga.enforcer.js +86 -0
  102. package/src/verax/core/guardrails/guardrails-report-writer.js +109 -0
  103. package/src/verax/core/guardrails/policy.defaults.js +210 -0
  104. package/src/verax/core/guardrails/policy.loader.js +83 -0
  105. package/src/verax/core/guardrails/policy.schema.js +110 -0
  106. package/src/verax/core/guardrails/truth-reconciliation.js +136 -0
  107. package/src/verax/core/guardrails-engine.js +505 -0
  108. package/src/verax/core/incremental-store.js +15 -7
  109. package/src/verax/core/observe/run-timeline.js +316 -0
  110. package/src/verax/core/perf/perf.contract.js +186 -0
  111. package/src/verax/core/perf/perf.display.js +65 -0
  112. package/src/verax/core/perf/perf.enforcer.js +91 -0
  113. package/src/verax/core/perf/perf.monitor.js +209 -0
  114. package/src/verax/core/perf/perf.report.js +198 -0
  115. package/src/verax/core/pipeline-tracker.js +238 -0
  116. package/src/verax/core/product-definition.js +127 -0
  117. package/src/verax/core/release/provenance.builder.js +271 -0
  118. package/src/verax/core/release/release-report-writer.js +40 -0
  119. package/src/verax/core/release/release.enforcer.js +159 -0
  120. package/src/verax/core/release/reproducibility.check.js +221 -0
  121. package/src/verax/core/release/sbom.builder.js +283 -0
  122. package/src/verax/core/replay-validator.js +4 -4
  123. package/src/verax/core/replay.js +1 -1
  124. package/src/verax/core/report/cross-index.js +192 -0
  125. package/src/verax/core/report/human-summary.js +222 -0
  126. package/src/verax/core/route-intelligence.js +419 -0
  127. package/src/verax/core/security/secrets.scan.js +326 -0
  128. package/src/verax/core/security/security-report.js +50 -0
  129. package/src/verax/core/security/security.enforcer.js +124 -0
  130. package/src/verax/core/security/supplychain.defaults.json +38 -0
  131. package/src/verax/core/security/supplychain.policy.js +326 -0
  132. package/src/verax/core/security/vuln.scan.js +265 -0
  133. package/src/verax/core/silence-impact.js +1 -1
  134. package/src/verax/core/silence-model.js +9 -7
  135. package/src/verax/core/truth/truth.certificate.js +250 -0
  136. package/src/verax/core/ui-feedback-intelligence.js +515 -0
  137. package/src/verax/detect/comparison.js +8 -3
  138. package/src/verax/detect/confidence-engine.js +645 -57
  139. package/src/verax/detect/confidence-helper.js +33 -0
  140. package/src/verax/detect/detection-engine.js +19 -2
  141. package/src/verax/detect/dynamic-route-findings.js +335 -0
  142. package/src/verax/detect/evidence-index.js +15 -65
  143. package/src/verax/detect/expectation-chain-detector.js +417 -0
  144. package/src/verax/detect/expectation-model.js +56 -3
  145. package/src/verax/detect/explanation-helpers.js +1 -1
  146. package/src/verax/detect/finding-detector.js +2 -2
  147. package/src/verax/detect/findings-writer.js +149 -20
  148. package/src/verax/detect/flow-detector.js +4 -4
  149. package/src/verax/detect/index.js +265 -15
  150. package/src/verax/detect/interactive-findings.js +3 -4
  151. package/src/verax/detect/journey-stall-detector.js +558 -0
  152. package/src/verax/detect/route-findings.js +218 -0
  153. package/src/verax/detect/signal-mapper.js +2 -2
  154. package/src/verax/detect/skip-classifier.js +4 -4
  155. package/src/verax/detect/ui-feedback-findings.js +207 -0
  156. package/src/verax/detect/verdict-engine.js +61 -9
  157. package/src/verax/detect/view-switch-correlator.js +242 -0
  158. package/src/verax/flow/flow-engine.js +3 -2
  159. package/src/verax/flow/flow-spec.js +1 -2
  160. package/src/verax/index.js +413 -33
  161. package/src/verax/intel/effect-detector.js +1 -1
  162. package/src/verax/intel/index.js +2 -2
  163. package/src/verax/intel/route-extractor.js +3 -3
  164. package/src/verax/intel/vue-navigation-extractor.js +81 -18
  165. package/src/verax/intel/vue-router-extractor.js +4 -2
  166. package/src/verax/learn/action-contract-extractor.js +684 -66
  167. package/src/verax/learn/ast-contract-extractor.js +53 -1
  168. package/src/verax/learn/index.js +36 -2
  169. package/src/verax/learn/manifest-writer.js +28 -14
  170. package/src/verax/learn/route-extractor.js +1 -1
  171. package/src/verax/learn/route-validator.js +12 -8
  172. package/src/verax/learn/state-extractor.js +1 -1
  173. package/src/verax/learn/static-extractor-navigation.js +1 -1
  174. package/src/verax/learn/static-extractor-validation.js +2 -2
  175. package/src/verax/learn/static-extractor.js +8 -7
  176. package/src/verax/learn/ts-contract-resolver.js +14 -12
  177. package/src/verax/observe/browser.js +22 -3
  178. package/src/verax/observe/console-sensor.js +2 -2
  179. package/src/verax/observe/expectation-executor.js +2 -1
  180. package/src/verax/observe/focus-sensor.js +1 -1
  181. package/src/verax/observe/human-driver.js +29 -10
  182. package/src/verax/observe/index.js +92 -844
  183. package/src/verax/observe/interaction-discovery.js +27 -15
  184. package/src/verax/observe/interaction-runner.js +31 -14
  185. package/src/verax/observe/loading-sensor.js +6 -0
  186. package/src/verax/observe/navigation-sensor.js +1 -1
  187. package/src/verax/observe/observe-context.js +205 -0
  188. package/src/verax/observe/observe-helpers.js +191 -0
  189. package/src/verax/observe/observe-runner.js +226 -0
  190. package/src/verax/observe/observers/budget-observer.js +185 -0
  191. package/src/verax/observe/observers/console-observer.js +102 -0
  192. package/src/verax/observe/observers/coverage-observer.js +107 -0
  193. package/src/verax/observe/observers/interaction-observer.js +471 -0
  194. package/src/verax/observe/observers/navigation-observer.js +132 -0
  195. package/src/verax/observe/observers/network-observer.js +87 -0
  196. package/src/verax/observe/observers/safety-observer.js +82 -0
  197. package/src/verax/observe/observers/ui-feedback-observer.js +99 -0
  198. package/src/verax/observe/settle.js +1 -0
  199. package/src/verax/observe/state-sensor.js +8 -4
  200. package/src/verax/observe/state-ui-sensor.js +7 -1
  201. package/src/verax/observe/traces-writer.js +27 -16
  202. package/src/verax/observe/ui-feedback-detector.js +742 -0
  203. package/src/verax/observe/ui-signal-sensor.js +155 -2
  204. package/src/verax/scan-summary-writer.js +46 -9
  205. package/src/verax/shared/artifact-manager.js +9 -6
  206. package/src/verax/shared/budget-profiles.js +2 -2
  207. package/src/verax/shared/caching.js +1 -1
  208. package/src/verax/shared/config-loader.js +1 -2
  209. package/src/verax/shared/css-spinner-rules.js +204 -0
  210. package/src/verax/shared/dynamic-route-utils.js +12 -6
  211. package/src/verax/shared/retry-policy.js +1 -6
  212. package/src/verax/shared/root-artifacts.js +1 -1
  213. package/src/verax/shared/view-switch-rules.js +208 -0
  214. package/src/verax/shared/zip-artifacts.js +1 -0
  215. package/src/verax/validate/context-validator.js +1 -1
  216. package/src/verax/observe/index.js.backup +0 -1
  217. package/src/verax/validate/context-validator.js.bak +0 -0
@@ -1,21 +1,13 @@
1
1
  import { resolve, dirname } from 'path';
2
- import { mkdirSync, readFileSync, existsSync } from 'fs';
2
+ import { mkdirSync } from 'fs';
3
3
  import { createBrowser, navigateToUrl, closeBrowser } from './browser.js';
4
- import { discoverAllInteractions } from './interaction-discovery.js';
5
4
  import { captureScreenshot } from './evidence-capture.js';
6
- import { runInteraction } from './interaction-runner.js';
7
- import { writeTraces } from './traces-writer.js';
8
- import { getBaseOrigin, isExternalUrl } from './domain-boundary.js';
9
-
5
+ import { getBaseOrigin } from './domain-boundary.js';
10
6
  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
7
  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
8
  import SilenceTracker from '../core/silence-model.js';
18
- import { DecisionRecorder, recordBudgetProfile, recordTimeoutConfig, recordAdaptiveStabilization, recordTruncation, recordEnvironment } from '../core/determinism-model.js';
9
+ import { DecisionRecorder, recordBudgetProfile, recordTimeoutConfig, recordEnvironment } from '../core/determinism-model.js';
10
+ import { setupManifestAndExpectations, processTraversalResults, writeDeterminismArtifacts } from './observe-helpers.js';
19
11
 
20
12
  /**
21
13
  * OBSERVE PHASE - Execute interactions and capture runtime behavior
@@ -58,135 +50,51 @@ export async function observe(url, manifestPath = null, scanBudgetOverride = nul
58
50
  recordEnvironment(decisionRecorder, { browserType: 'chromium', viewport: { width: 1280, height: 720 } });
59
51
 
60
52
  // Phase 5: Detect projectDir if not provided (for backwards compatibility with tests)
61
- if (!projectDir) {
62
- projectDir = process.cwd();
53
+ // Use a local variable to avoid reassigning function parameter
54
+ let resolvedProjectDir = projectDir;
55
+ if (!resolvedProjectDir) {
56
+ resolvedProjectDir = process.cwd();
63
57
  }
64
-
58
+
65
59
  // Phase 4: Extract safety flags
66
60
  const { allowWrites = false, allowRiskyActions = false, allowCrossOrigin = false } = safetyFlags;
67
61
  let blockedNetworkWrites = [];
68
62
  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
- }
128
-
129
- // Allow request
130
- route.continue();
131
- });
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);
132
76
 
133
77
  try {
134
78
  await navigateToUrl(page, url, scanBudget);
135
79
 
136
- const projectDir = manifestPath ? dirname(dirname(dirname(manifestPath))) : process.cwd();
137
- const observeDir = resolve(projectDir, '.veraxverax', 'observe');
138
- const screenshotsDir = resolve(observeDir, 'screenshots');
80
+ // If manifestPath is provided, derive projectDir from it (override default)
81
+ if (manifestPath) {
82
+ resolvedProjectDir = dirname(dirname(dirname(manifestPath)));
83
+ }
84
+ if (!runId) {
85
+ throw new Error('runId is required');
86
+ }
87
+ const { getScreenshotDir } = await import('../core/run-id.js');
88
+ const screenshotsDir = getScreenshotDir(resolvedProjectDir, runId);
139
89
  mkdirSync(screenshotsDir, { recursive: true });
140
90
 
141
91
  const timestamp = Date.now();
142
92
  const initialScreenshot = resolve(screenshotsDir, `initial-${timestamp}.png`);
143
93
  await captureScreenshot(page, initialScreenshot);
144
94
 
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
- }
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);
190
98
 
191
99
  // Reset to start URL before traversal
192
100
  await navigateToUrl(page, url, scanBudget);
@@ -200,680 +108,72 @@ export async function observe(url, manifestPath = null, scanBudgetOverride = nul
200
108
  let totalInteractionsExecuted = 0;
201
109
  let remainingInteractionsGaps = [];
202
110
 
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) {
791
- observeWarnings.push({
792
- code: 'INTERACTIONS_CAPPED',
793
- message: `Interaction execution capped. Visited ${coverage.pagesVisited} pages, discovered ${coverage.pagesDiscovered}, executed ${coverage.candidatesSelected} of ${coverage.candidatesDiscovered} interactions. Coverage incomplete.`
794
- });
795
- }
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,
117
+ scanBudget,
118
+ startTime,
119
+ 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
+ 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];
796
145
 
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
- }
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
- }));
848
-
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
- };
858
- }
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,
159
+ manifest,
160
+ incrementalMode,
161
+ snapshotDiff,
162
+ resolvedProjectDir,
163
+ runId
164
+ );
859
165
 
860
166
  await closeBrowser(browser);
861
167
 
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
- }
168
+ // PHASE 21.3: Write determinism artifacts using helper
169
+ await writeDeterminismArtifacts(resolvedProjectDir, runId, decisionRecorder);
870
170
 
871
171
  // Phase 4: Add safety mode statistics
872
172
  const safetyBlocks = {
873
- actionsBlocked: skippedInteractions.filter(s => s.reason === 'safety_mode').length,
173
+ actionsBlocked: finalSkippedInteractions.filter(s => s.reason === 'safety_mode').length,
874
174
  networkWritesBlocked: blockedNetworkWrites.length,
875
175
  crossOriginBlocked: blockedCrossOrigin.length,
876
- blockedActions: skippedInteractions.filter(s => s.reason === 'safety_mode').map(s => ({
176
+ blockedActions: finalSkippedInteractions.filter(s => s.reason === 'safety_mode').map(s => ({
877
177
  label: s.interaction.label,
878
178
  classification: s.classification,
879
179
  url: s.url
@@ -893,56 +193,4 @@ export async function observe(url, manifestPath = null, scanBudgetOverride = nul
893
193
  }
894
194
  }
895
195
 
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
- }
196
+ // PHASE 21.3: repeatObservedInteraction moved to observe-runner.js