@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
@@ -0,0 +1,471 @@
1
+ /**
2
+ * PHASE 21.3 — Interaction Observer
3
+ *
4
+ * Extracted interaction-related logic from observe-runner.js
5
+ * Handles interaction discovery, execution, and processing
6
+ *
7
+ * NO file I/O - all artifacts written by caller
8
+ * NO loop control - caller controls iteration
9
+ */
10
+
11
+ import { discoverAllInteractions } from '../interaction-discovery.js';
12
+ import { runInteraction } from '../interaction-runner.js';
13
+ import { isExternalUrl } from '../domain-boundary.js';
14
+ import { computeRouteBudget } from '../../core/budget-engine.js';
15
+ import { shouldSkipInteractionIncremental } from '../../core/incremental-store.js';
16
+ import { deriveObservedExpectation, shouldAttemptRepeatObservedExpectation, evaluateObservedExpectation } from '../observed-expectation.js';
17
+ import { discoverPageLinks } from './navigation-observer.js';
18
+
19
+ /**
20
+ * Discover interactions on current page
21
+ *
22
+ * @param {Object} context
23
+ * @param {import('playwright').Page} context.page
24
+ * @param {string} context.baseOrigin
25
+ * @param {Object} context.scanBudget
26
+ * @param {Object|null} context.manifest
27
+ * @param {string} context.currentUrl
28
+ * @returns {Promise<Object>} { interactions, routeBudget, totalDiscovered }
29
+ */
30
+ export async function discoverInteractions(context) {
31
+ const { page, baseOrigin, scanBudget, manifest, currentUrl } = context;
32
+
33
+ // SCALE INTELLIGENCE: Compute adaptive budget for this route
34
+ const routeBudget = manifest ? computeRouteBudget(manifest, currentUrl, scanBudget) : scanBudget;
35
+
36
+ // Discover ALL interactions on this page
37
+ // Note: discoverAllInteractions already returns sorted interactions deterministically
38
+ const { interactions } = await discoverAllInteractions(page, baseOrigin, routeBudget);
39
+
40
+ // SCALE INTELLIGENCE: Apply adaptive budget cap (interactions are already sorted deterministically)
41
+ // Stable sorting ensures determinism: same interactions → same order
42
+ const sortedInteractions = interactions.slice(0, routeBudget.maxInteractionsPerPage);
43
+
44
+ return {
45
+ interactions: sortedInteractions,
46
+ routeBudget,
47
+ totalDiscovered: interactions.length
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Check if interaction should be skipped and handle skip logic
53
+ *
54
+ * @param {Object} context
55
+ * @param {Object} interaction
56
+ * @param {string} currentUrl
57
+ * @param {Array} traces - Mutable array to push skipped traces
58
+ * @param {Array} skippedInteractions - Mutable array to push skipped interactions
59
+ * @param {SilenceTracker} silenceTracker
60
+ * @param {PageFrontier} frontier
61
+ * @param {boolean} incrementalMode
62
+ * @param {Object|null} manifest
63
+ * @param {Object|null} oldSnapshot
64
+ * @param {Object|null} snapshotDiff
65
+ * @param {boolean} allowWrites
66
+ * @param {boolean} allowRiskyActions
67
+ * @returns {Promise<Object>} { skip: boolean, reason?: string, trace?: Object }
68
+ */
69
+ export async function checkAndSkipInteraction(
70
+ context,
71
+ interaction,
72
+ currentUrl,
73
+ traces,
74
+ skippedInteractions,
75
+ silenceTracker,
76
+ frontier,
77
+ incrementalMode,
78
+ manifest,
79
+ oldSnapshot,
80
+ snapshotDiff,
81
+ allowWrites,
82
+ allowRiskyActions
83
+ ) {
84
+ // SCALE INTELLIGENCE: Check if interaction should be skipped in incremental mode
85
+ if (incrementalMode && manifest && oldSnapshot && snapshotDiff) {
86
+ const shouldSkip = shouldSkipInteractionIncremental(interaction, currentUrl, oldSnapshot, snapshotDiff);
87
+ if (shouldSkip) {
88
+ // Create a trace for skipped interaction (marked as incremental - will not produce findings)
89
+ const skippedTrace = {
90
+ interaction: {
91
+ type: interaction.type,
92
+ selector: interaction.selector,
93
+ label: interaction.label
94
+ },
95
+ before: { url: currentUrl },
96
+ after: { url: currentUrl },
97
+ incremental: true, // Mark as skipped in incremental mode - detect phase will skip this
98
+ resultType: 'INCREMENTAL_SKIP'
99
+ };
100
+ traces.push(skippedTrace);
101
+
102
+ // Track incremental skip as silence
103
+ silenceTracker.record({
104
+ scope: 'interaction',
105
+ reason: 'incremental_unchanged',
106
+ description: `Skipped re-observation (unchanged in incremental mode): ${interaction.label}`,
107
+ context: {
108
+ currentPage: currentUrl,
109
+ selector: interaction.selector,
110
+ interactionLabel: interaction.label,
111
+ type: interaction.type
112
+ },
113
+ impact: 'affects_expectations'
114
+ });
115
+
116
+ skippedInteractions.push({
117
+ interaction: {
118
+ type: interaction.type,
119
+ selector: interaction.selector,
120
+ label: interaction.label,
121
+ text: interaction.text
122
+ },
123
+ outcome: 'SKIPPED',
124
+ reason: 'incremental_unchanged',
125
+ url: currentUrl,
126
+ evidence: {
127
+ selector: interaction.selector,
128
+ label: interaction.label,
129
+ incremental: true
130
+ }
131
+ });
132
+ return { skip: true, reason: 'incremental_unchanged' };
133
+ }
134
+ }
135
+
136
+ // Skip dangerous interactions (logout, delete, etc.) with explicit reason
137
+ const skipCheck = frontier.shouldSkipInteraction(interaction);
138
+ if (skipCheck.skip) {
139
+ // Track safety skip as silence
140
+ silenceTracker.record({
141
+ scope: 'interaction',
142
+ reason: skipCheck.reason === 'destructive' ? 'destructive_text' : 'unsafe_pattern',
143
+ description: `Skipped potentially dangerous interaction: ${interaction.label}`,
144
+ context: {
145
+ currentPage: context.page.url(),
146
+ selector: interaction.selector,
147
+ interactionLabel: interaction.label,
148
+ text: interaction.text,
149
+ skipReason: skipCheck.reason,
150
+ skipMessage: skipCheck.message
151
+ },
152
+ impact: 'unknown_behavior'
153
+ });
154
+
155
+ skippedInteractions.push({
156
+ interaction: {
157
+ type: interaction.type,
158
+ selector: interaction.selector,
159
+ label: interaction.label,
160
+ text: interaction.text
161
+ },
162
+ outcome: 'SKIPPED',
163
+ reason: skipCheck.reason || 'safety_policy',
164
+ url: context.page.url(),
165
+ evidence: {
166
+ selector: interaction.selector,
167
+ label: interaction.label,
168
+ text: interaction.text,
169
+ sourcePage: context.page.url()
170
+ }
171
+ });
172
+ return { skip: true, reason: skipCheck.reason || 'safety_policy' };
173
+ }
174
+
175
+ // Phase 4: Check action classification and safety mode
176
+ const { shouldBlockAction } = await import('../../core/action-classifier.js');
177
+ const blockCheck = shouldBlockAction(interaction, { allowWrites, allowRiskyActions });
178
+
179
+ if (blockCheck.shouldBlock) {
180
+ // Track blocked action as silence
181
+ silenceTracker.record({
182
+ scope: 'safety',
183
+ reason: 'blocked_action',
184
+ description: `Action blocked by safety mode: ${interaction.label} (${blockCheck.classification})`,
185
+ context: {
186
+ currentPage: context.page.url(),
187
+ selector: interaction.selector,
188
+ interactionLabel: interaction.label,
189
+ text: interaction.text,
190
+ classification: blockCheck.classification,
191
+ blockReason: blockCheck.reason
192
+ },
193
+ impact: 'action_blocked'
194
+ });
195
+
196
+ skippedInteractions.push({
197
+ interaction: {
198
+ type: interaction.type,
199
+ selector: interaction.selector,
200
+ label: interaction.label,
201
+ text: interaction.text
202
+ },
203
+ outcome: 'BLOCKED',
204
+ reason: 'safety_mode',
205
+ classification: blockCheck.classification,
206
+ url: context.page.url(),
207
+ evidence: {
208
+ selector: interaction.selector,
209
+ label: interaction.label,
210
+ text: interaction.text,
211
+ classification: blockCheck.classification,
212
+ sourcePage: context.page.url()
213
+ }
214
+ });
215
+
216
+ // Create a minimal trace for blocked interactions so they appear in output
217
+ const blockedTrace = {
218
+ interaction: {
219
+ type: interaction.type,
220
+ selector: interaction.selector,
221
+ label: interaction.label,
222
+ text: interaction.text
223
+ },
224
+ before: {
225
+ url: context.page.url(),
226
+ screenshot: null
227
+ },
228
+ after: {
229
+ url: context.page.url(),
230
+ screenshot: null
231
+ },
232
+ policy: {
233
+ actionBlocked: true,
234
+ classification: blockCheck.classification,
235
+ reason: blockCheck.reason
236
+ },
237
+ outcome: 'BLOCKED_BY_SAFETY_MODE',
238
+ timestamp: Date.now()
239
+ };
240
+ traces.push(blockedTrace);
241
+
242
+ return { skip: true, reason: 'safety_mode', trace: blockedTrace };
243
+ }
244
+
245
+ return { skip: false };
246
+ }
247
+
248
+ /**
249
+ * Execute interaction and process results
250
+ *
251
+ * @param {Object} context
252
+ * @param {Object} interaction
253
+ * @param {number} interactionIndex
254
+ * @param {string} beforeUrl
255
+ * @param {Array} traces - Mutable array to push traces
256
+ * @param {Array} observedExpectations - Mutable array to push observed expectations
257
+ * @param {Array} remainingInteractionsGaps - Mutable array to push gaps
258
+ * @param {PageFrontier} frontier
259
+ * @param {string} baseOrigin
260
+ * @param {boolean} incrementalMode
261
+ * @param {Object|null} expectationResults
262
+ * @param {number} startTime
263
+ * @param {Object} scanBudget
264
+ * @returns {Promise<Object>} { trace, repeatTrace, navigated, navigatedUrl, frontierCapped }
265
+ */
266
+ export async function executeInteraction(
267
+ context,
268
+ interaction,
269
+ interactionIndex,
270
+ beforeUrl,
271
+ traces,
272
+ observedExpectations,
273
+ remainingInteractionsGaps,
274
+ frontier,
275
+ baseOrigin,
276
+ incrementalMode,
277
+ expectationResults,
278
+ startTime,
279
+ scanBudget
280
+ ) {
281
+ const {
282
+ page,
283
+ timestamp,
284
+ screenshotsDir,
285
+ routeBudget,
286
+ silenceTracker
287
+ } = context;
288
+
289
+ const trace = await runInteraction(
290
+ page,
291
+ interaction,
292
+ timestamp,
293
+ interactionIndex,
294
+ screenshotsDir,
295
+ baseOrigin,
296
+ startTime,
297
+ routeBudget, // Use route-specific budget
298
+ null,
299
+ silenceTracker // Pass silence tracker
300
+ );
301
+
302
+ // Mark trace with incremental flag if applicable
303
+ if (incrementalMode && trace) {
304
+ trace.incremental = false; // This interaction was executed, not skipped
305
+ }
306
+
307
+ let repeatTrace = null;
308
+
309
+ if (trace) {
310
+ const matchingExpectation = expectationResults?.results?.find(r => r.trace?.interaction?.selector === trace.interaction.selector);
311
+ if (matchingExpectation) {
312
+ trace.expectationDriven = true;
313
+ trace.expectationId = matchingExpectation.expectationId;
314
+ trace.expectationOutcome = matchingExpectation.outcome;
315
+ } else {
316
+ const observedExpectation = deriveObservedExpectation(interaction, trace, baseOrigin);
317
+ if (observedExpectation) {
318
+ trace.observedExpectation = observedExpectation;
319
+ trace.resultType = 'OBSERVED_EXPECTATION';
320
+ observedExpectations.push(observedExpectation);
321
+
322
+ const repeatEligible = shouldAttemptRepeatObservedExpectation(observedExpectation, trace);
323
+ const budgetAllowsRepeat = repeatEligible &&
324
+ (Date.now() - startTime) < scanBudget.maxScanDurationMs &&
325
+ (interactionIndex + 1) < scanBudget.maxTotalInteractions;
326
+
327
+ if (budgetAllowsRepeat) {
328
+ const repeatIndex = interactionIndex + 1;
329
+ const repeatResult = await repeatObservedInteraction(
330
+ page,
331
+ interaction,
332
+ observedExpectation,
333
+ timestamp,
334
+ repeatIndex,
335
+ screenshotsDir,
336
+ baseOrigin,
337
+ startTime,
338
+ scanBudget
339
+ );
340
+
341
+ if (repeatResult) {
342
+ const repeatEvaluation = repeatResult.repeatEvaluation;
343
+ trace.observedExpectation.repeatAttempted = true;
344
+ trace.observedExpectation.repeated = repeatEvaluation.outcome === 'VERIFIED';
345
+ trace.observedExpectation.repeatOutcome = repeatEvaluation.outcome;
346
+ trace.observedExpectation.repeatReason = repeatEvaluation.reason;
347
+
348
+ if (repeatEvaluation.outcome === 'OBSERVED_BREAK') {
349
+ trace.observedExpectation.outcome = 'OBSERVED_BREAK';
350
+ trace.observedExpectation.reason = 'inconsistent_on_repeat';
351
+ trace.observedExpectation.confidenceLevel = 'LOW';
352
+ } else if (trace.observedExpectation.repeated && trace.observedExpectation.outcome === 'VERIFIED') {
353
+ trace.observedExpectation.confidenceLevel = 'MEDIUM';
354
+ }
355
+
356
+ repeatTrace = repeatResult.repeatTrace;
357
+ }
358
+ }
359
+ } else {
360
+ trace.unprovenResult = true;
361
+ trace.resultType = 'UNPROVEN_RESULT';
362
+ }
363
+ }
364
+
365
+ traces.push(trace);
366
+
367
+ if (repeatTrace) {
368
+ traces.push(repeatTrace);
369
+ }
370
+
371
+ const afterUrl = trace.after?.url || page.url();
372
+ const navigatedSameOrigin = afterUrl && afterUrl !== beforeUrl && !isExternalUrl(afterUrl, baseOrigin);
373
+ if (navigatedSameOrigin && interaction.type === 'link') {
374
+ // Link navigation - add new page to frontier (if not already visited)
375
+ const normalizedAfter = frontier.normalizeUrl(afterUrl);
376
+ const wasAlreadyVisited = frontier.visited.has(normalizedAfter);
377
+ let frontierCapped = false;
378
+
379
+ if (!wasAlreadyVisited) {
380
+ const added = frontier.addUrl(afterUrl);
381
+ // If frontier was capped, record coverage gap
382
+ if (!added && frontier.frontierCapped) {
383
+ remainingInteractionsGaps.push({
384
+ interaction: {
385
+ type: 'link',
386
+ selector: interaction.selector,
387
+ label: interaction.label
388
+ },
389
+ reason: 'frontier_capped',
390
+ url: afterUrl
391
+ });
392
+ frontierCapped = true;
393
+ }
394
+ }
395
+
396
+ // Discover links on the new page immediately
397
+ await discoverPageLinks({ page, baseOrigin: baseOrigin, frontier, silenceTracker });
398
+
399
+ return {
400
+ trace,
401
+ repeatTrace,
402
+ navigated: true,
403
+ navigatedUrl: afterUrl,
404
+ frontierCapped
405
+ };
406
+ }
407
+ }
408
+
409
+ return {
410
+ trace,
411
+ repeatTrace,
412
+ navigated: false
413
+ };
414
+ }
415
+
416
+ /**
417
+ * Repeat observed interaction (helper function)
418
+ */
419
+ async function repeatObservedInteraction(
420
+ page,
421
+ interaction,
422
+ observedExpectation,
423
+ timestamp,
424
+ interactionIndex,
425
+ screenshotsDir,
426
+ baseOrigin,
427
+ startTime,
428
+ scanBudget
429
+ ) {
430
+ const selector = observedExpectation.evidence?.selector || interaction.selector;
431
+ if (!selector) return null;
432
+
433
+ const locator = page.locator(selector).first();
434
+ const count = await locator.count();
435
+ if (count === 0) {
436
+ return null;
437
+ }
438
+
439
+ const repeatInteraction = {
440
+ ...interaction,
441
+ element: locator
442
+ };
443
+
444
+ const repeatTrace = await runInteraction(
445
+ page,
446
+ repeatInteraction,
447
+ timestamp,
448
+ interactionIndex,
449
+ screenshotsDir,
450
+ baseOrigin,
451
+ startTime,
452
+ scanBudget,
453
+ null,
454
+ null // No silence tracker for repeat executions (not counted as new silence)
455
+ );
456
+
457
+ if (!repeatTrace) {
458
+ return null;
459
+ }
460
+
461
+ repeatTrace.repeatExecution = true;
462
+ repeatTrace.repeatOfObservedExpectationId = observedExpectation.id;
463
+ repeatTrace.resultType = 'OBSERVED_EXPECTATION_REPEAT';
464
+
465
+ const repeatEvaluation = evaluateObservedExpectation(observedExpectation, repeatTrace);
466
+
467
+ return {
468
+ repeatTrace,
469
+ repeatEvaluation
470
+ };
471
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * PHASE 21.3 — Navigation Observer
3
+ *
4
+ * Responsibilities:
5
+ * - Page navigation
6
+ * - Link discovery
7
+ * - Frontier management
8
+ * - NO file I/O
9
+ * - NO side effects outside its scope
10
+ */
11
+
12
+ import { navigateToUrl } from '../browser.js';
13
+ import { isExternalUrl } from '../domain-boundary.js';
14
+
15
+ /**
16
+ * Navigate to a URL
17
+ *
18
+ * @param {ObserveContext} context - Observe context
19
+ * @param {string} targetUrl - URL to navigate to
20
+ * @returns {Promise<boolean>} True if navigation succeeded, false if failed
21
+ */
22
+ export async function navigateToPage(context, targetUrl) {
23
+ const { page, scanBudget, frontier, silenceTracker } = context;
24
+
25
+ try {
26
+ await navigateToUrl(page, targetUrl, scanBudget);
27
+ return true;
28
+ } catch (error) {
29
+ // Record navigation failure as silence and skip
30
+ silenceTracker.record({
31
+ scope: 'navigation',
32
+ reason: 'navigation_timeout',
33
+ description: 'Navigation to page failed',
34
+ context: { targetUrl },
35
+ impact: 'blocks_nav'
36
+ });
37
+
38
+ const normalizedFailed = frontier.normalizeUrl(targetUrl);
39
+ if (!frontier.visited.has(normalizedFailed)) {
40
+ frontier.visited.add(normalizedFailed);
41
+ frontier.markVisited();
42
+ }
43
+
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Discover links on current page and add to frontier
50
+ *
51
+ * @param {ObserveContext} context - Observe context
52
+ * @returns {Promise<void>}
53
+ */
54
+ export async function discoverPageLinks(context) {
55
+ const { page, baseOrigin, frontier, silenceTracker } = context;
56
+
57
+ try {
58
+ const currentLinks = await page.locator('a[href]').all();
59
+ for (const link of currentLinks) {
60
+ try {
61
+ const href = await link.getAttribute('href');
62
+ if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
63
+ const resolvedUrl = href.startsWith('http') ? href : new URL(href, page.url()).href;
64
+ if (!isExternalUrl(resolvedUrl, baseOrigin)) {
65
+ frontier.addUrl(resolvedUrl);
66
+ }
67
+ }
68
+ } catch (error) {
69
+ // Record invalid URL discovery as silence
70
+ silenceTracker.record({
71
+ scope: 'discovery',
72
+ reason: 'discovery_error',
73
+ description: 'Invalid or unreadable link during discovery',
74
+ context: { pageUrl: page.url() },
75
+ impact: 'incomplete_check'
76
+ });
77
+ }
78
+ }
79
+ } catch (error) {
80
+ // Record link discovery failure as silence
81
+ silenceTracker.record({
82
+ scope: 'discovery',
83
+ reason: 'discovery_error',
84
+ description: 'Link discovery failed on page',
85
+ context: { pageUrl: page.url() },
86
+ impact: 'incomplete_check'
87
+ });
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Check if we're already on the target page
93
+ *
94
+ * @param {ObserveContext} context - Observe context
95
+ * @param {string} targetUrl - Target URL
96
+ * @returns {boolean} True if already on page
97
+ */
98
+ export function isAlreadyOnPage(context, targetUrl) {
99
+ const { page, frontier } = context;
100
+ const currentUrl = page.url();
101
+ const normalizedNext = frontier.normalizeUrl(targetUrl);
102
+ const normalizedCurrent = frontier.normalizeUrl(currentUrl);
103
+ return normalizedCurrent === normalizedNext;
104
+ }
105
+
106
+ /**
107
+ * Mark page as visited in frontier
108
+ *
109
+ * @param {ObserveContext} context - Observe context
110
+ * @param {string} targetUrl - Target URL
111
+ * @param {boolean} alreadyOnPage - Whether we're already on the page
112
+ * @returns {void}
113
+ */
114
+ export function markPageVisited(context, targetUrl, alreadyOnPage) {
115
+ const { frontier } = context;
116
+ const normalizedNext = frontier.normalizeUrl(targetUrl);
117
+
118
+ if (!alreadyOnPage) {
119
+ // We navigated via getNextUrl() - it already marked in visited set, now increment counter
120
+ frontier.markVisited();
121
+ } else {
122
+ // We navigated via link click (alreadyOnPage=true) - mark as visited and increment
123
+ if (!frontier.visited.has(normalizedNext)) {
124
+ frontier.visited.add(normalizedNext);
125
+ frontier.markVisited();
126
+ } else {
127
+ // Already marked as visited, but still increment counter since we're processing it
128
+ frontier.markVisited();
129
+ }
130
+ }
131
+ }
132
+
@@ -0,0 +1,87 @@
1
+ /**
2
+ * PHASE 21.3 — Network Observer
3
+ *
4
+ * Responsibilities:
5
+ * - Network idle tracking
6
+ * - In-flight request tracking
7
+ * - Network-related observation signals
8
+ *
9
+ * MUST NOT intercept requests (safety-observer owns that)
10
+ * NO file I/O
11
+ * NO side effects outside its scope
12
+ */
13
+
14
+ import { NetworkSensor } from '../network-sensor.js';
15
+
16
+ /**
17
+ * Observe network state on current page
18
+ *
19
+ * @param {ObserveContext} context - Observe context
20
+ * @param {RunState} runState - Current run state
21
+ * @returns {Promise<Array<Observation>>} Array of network observations
22
+ */
23
+ export async function observe(context, runState) {
24
+ const { page, currentUrl, timestamp } = context;
25
+ const observations = [];
26
+
27
+ try {
28
+ // Create a network sensor to observe current state
29
+ const networkSensor = new NetworkSensor();
30
+ const windowId = networkSensor.startWindow(page);
31
+
32
+ // Wait a short time to capture any in-flight requests
33
+ await page.waitForTimeout(100);
34
+
35
+ // Stop monitoring and get summary
36
+ const summary = networkSensor.stopWindow(windowId);
37
+
38
+ // Create observation for network state
39
+ observations.push({
40
+ type: 'network_state',
41
+ scope: 'page',
42
+ data: {
43
+ totalRequests: summary.totalRequests,
44
+ failedRequests: summary.failedRequests,
45
+ successfulRequests: summary.successfulRequests,
46
+ unfinishedCount: summary.unfinishedCount,
47
+ hasNetworkActivity: summary.hasNetworkActivity,
48
+ slowRequestsCount: summary.slowRequestsCount,
49
+ failedByStatus: summary.failedByStatus
50
+ },
51
+ timestamp,
52
+ url: currentUrl
53
+ });
54
+
55
+ // If there are in-flight requests, create an observation
56
+ if (summary.unfinishedCount > 0) {
57
+ observations.push({
58
+ type: 'network_in_flight',
59
+ scope: 'page',
60
+ data: {
61
+ inFlightCount: summary.unfinishedCount
62
+ },
63
+ timestamp,
64
+ url: currentUrl
65
+ });
66
+ }
67
+
68
+ // If network is idle (no requests), create observation
69
+ if (!summary.hasNetworkActivity && summary.unfinishedCount === 0) {
70
+ observations.push({
71
+ type: 'network_idle',
72
+ scope: 'page',
73
+ data: {
74
+ idle: true
75
+ },
76
+ timestamp,
77
+ url: currentUrl
78
+ });
79
+ }
80
+ } catch (error) {
81
+ // Propagate error - no silent catch
82
+ throw new Error(`Network observer failed: ${error.message}`);
83
+ }
84
+
85
+ return observations;
86
+ }
87
+