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