@veraxhq/verax 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +123 -88
  2. package/bin/verax.js +11 -452
  3. package/package.json +24 -36
  4. package/src/cli/commands/default.js +681 -0
  5. package/src/cli/commands/doctor.js +197 -0
  6. package/src/cli/commands/inspect.js +109 -0
  7. package/src/cli/commands/run.js +586 -0
  8. package/src/cli/entry.js +196 -0
  9. package/src/cli/util/atomic-write.js +37 -0
  10. package/src/cli/util/detection-engine.js +297 -0
  11. package/src/cli/util/env-url.js +33 -0
  12. package/src/cli/util/errors.js +44 -0
  13. package/src/cli/util/events.js +110 -0
  14. package/src/cli/util/expectation-extractor.js +388 -0
  15. package/src/cli/util/findings-writer.js +32 -0
  16. package/src/cli/util/idgen.js +87 -0
  17. package/src/cli/util/learn-writer.js +39 -0
  18. package/src/cli/util/observation-engine.js +412 -0
  19. package/src/cli/util/observe-writer.js +25 -0
  20. package/src/cli/util/paths.js +30 -0
  21. package/src/cli/util/project-discovery.js +297 -0
  22. package/src/cli/util/project-writer.js +26 -0
  23. package/src/cli/util/redact.js +128 -0
  24. package/src/cli/util/run-id.js +30 -0
  25. package/src/cli/util/runtime-budget.js +147 -0
  26. package/src/cli/util/summary-writer.js +43 -0
  27. package/src/types/global.d.ts +28 -0
  28. package/src/types/ts-ast.d.ts +24 -0
  29. package/src/verax/cli/ci-summary.js +35 -0
  30. package/src/verax/cli/context-explanation.js +89 -0
  31. package/src/verax/cli/doctor.js +277 -0
  32. package/src/verax/cli/error-normalizer.js +154 -0
  33. package/src/verax/cli/explain-output.js +105 -0
  34. package/src/verax/cli/finding-explainer.js +130 -0
  35. package/src/verax/cli/init.js +237 -0
  36. package/src/verax/cli/run-overview.js +163 -0
  37. package/src/verax/cli/url-safety.js +111 -0
  38. package/src/verax/cli/wizard.js +109 -0
  39. package/src/verax/cli/zero-findings-explainer.js +57 -0
  40. package/src/verax/cli/zero-interaction-explainer.js +127 -0
  41. package/src/verax/core/action-classifier.js +86 -0
  42. package/src/verax/core/budget-engine.js +218 -0
  43. package/src/verax/core/canonical-outcomes.js +157 -0
  44. package/src/verax/core/decision-snapshot.js +335 -0
  45. package/src/verax/core/determinism-model.js +432 -0
  46. package/src/verax/core/incremental-store.js +245 -0
  47. package/src/verax/core/invariants.js +356 -0
  48. package/src/verax/core/promise-model.js +230 -0
  49. package/src/verax/core/replay-validator.js +350 -0
  50. package/src/verax/core/replay.js +222 -0
  51. package/src/verax/core/run-id.js +175 -0
  52. package/src/verax/core/run-manifest.js +99 -0
  53. package/src/verax/core/silence-impact.js +369 -0
  54. package/src/verax/core/silence-model.js +523 -0
  55. package/src/verax/detect/comparison.js +7 -34
  56. package/src/verax/detect/confidence-engine.js +764 -329
  57. package/src/verax/detect/detection-engine.js +293 -0
  58. package/src/verax/detect/evidence-index.js +127 -0
  59. package/src/verax/detect/expectation-model.js +241 -168
  60. package/src/verax/detect/explanation-helpers.js +187 -0
  61. package/src/verax/detect/finding-detector.js +450 -0
  62. package/src/verax/detect/findings-writer.js +41 -12
  63. package/src/verax/detect/flow-detector.js +366 -0
  64. package/src/verax/detect/index.js +200 -288
  65. package/src/verax/detect/interactive-findings.js +612 -0
  66. package/src/verax/detect/signal-mapper.js +308 -0
  67. package/src/verax/detect/skip-classifier.js +4 -4
  68. package/src/verax/detect/verdict-engine.js +561 -0
  69. package/src/verax/evidence-index-writer.js +61 -0
  70. package/src/verax/flow/flow-engine.js +3 -2
  71. package/src/verax/flow/flow-spec.js +1 -2
  72. package/src/verax/index.js +103 -15
  73. package/src/verax/intel/effect-detector.js +368 -0
  74. package/src/verax/intel/handler-mapper.js +249 -0
  75. package/src/verax/intel/index.js +281 -0
  76. package/src/verax/intel/route-extractor.js +280 -0
  77. package/src/verax/intel/ts-program.js +256 -0
  78. package/src/verax/intel/vue-navigation-extractor.js +642 -0
  79. package/src/verax/intel/vue-router-extractor.js +325 -0
  80. package/src/verax/learn/action-contract-extractor.js +338 -104
  81. package/src/verax/learn/ast-contract-extractor.js +148 -6
  82. package/src/verax/learn/flow-extractor.js +172 -0
  83. package/src/verax/learn/index.js +36 -2
  84. package/src/verax/learn/manifest-writer.js +122 -58
  85. package/src/verax/learn/project-detector.js +40 -0
  86. package/src/verax/learn/route-extractor.js +28 -97
  87. package/src/verax/learn/route-validator.js +8 -7
  88. package/src/verax/learn/state-extractor.js +212 -0
  89. package/src/verax/learn/static-extractor-navigation.js +114 -0
  90. package/src/verax/learn/static-extractor-validation.js +88 -0
  91. package/src/verax/learn/static-extractor.js +119 -10
  92. package/src/verax/learn/truth-assessor.js +24 -21
  93. package/src/verax/learn/ts-contract-resolver.js +14 -12
  94. package/src/verax/observe/aria-sensor.js +211 -0
  95. package/src/verax/observe/browser.js +30 -6
  96. package/src/verax/observe/console-sensor.js +2 -18
  97. package/src/verax/observe/domain-boundary.js +10 -1
  98. package/src/verax/observe/expectation-executor.js +513 -0
  99. package/src/verax/observe/flow-matcher.js +143 -0
  100. package/src/verax/observe/focus-sensor.js +196 -0
  101. package/src/verax/observe/human-driver.js +660 -273
  102. package/src/verax/observe/index.js +910 -26
  103. package/src/verax/observe/interaction-discovery.js +378 -15
  104. package/src/verax/observe/interaction-runner.js +562 -197
  105. package/src/verax/observe/loading-sensor.js +145 -0
  106. package/src/verax/observe/navigation-sensor.js +255 -0
  107. package/src/verax/observe/network-sensor.js +55 -7
  108. package/src/verax/observe/observed-expectation-deriver.js +186 -0
  109. package/src/verax/observe/observed-expectation.js +305 -0
  110. package/src/verax/observe/page-frontier.js +234 -0
  111. package/src/verax/observe/settle.js +38 -17
  112. package/src/verax/observe/state-sensor.js +393 -0
  113. package/src/verax/observe/state-ui-sensor.js +7 -1
  114. package/src/verax/observe/timing-sensor.js +228 -0
  115. package/src/verax/observe/traces-writer.js +73 -21
  116. package/src/verax/observe/ui-signal-sensor.js +143 -17
  117. package/src/verax/scan-summary-writer.js +80 -15
  118. package/src/verax/shared/artifact-manager.js +111 -9
  119. package/src/verax/shared/budget-profiles.js +136 -0
  120. package/src/verax/shared/caching.js +1 -1
  121. package/src/verax/shared/ci-detection.js +39 -0
  122. package/src/verax/shared/config-loader.js +169 -0
  123. package/src/verax/shared/dynamic-route-utils.js +224 -0
  124. package/src/verax/shared/expectation-coverage.js +44 -0
  125. package/src/verax/shared/expectation-prover.js +81 -0
  126. package/src/verax/shared/expectation-tracker.js +201 -0
  127. package/src/verax/shared/expectations-writer.js +60 -0
  128. package/src/verax/shared/first-run.js +44 -0
  129. package/src/verax/shared/progress-reporter.js +171 -0
  130. package/src/verax/shared/retry-policy.js +9 -1
  131. package/src/verax/shared/root-artifacts.js +49 -0
  132. package/src/verax/shared/scan-budget.js +86 -0
  133. package/src/verax/shared/url-normalizer.js +162 -0
  134. package/src/verax/shared/zip-artifacts.js +66 -0
  135. package/src/verax/validate/context-validator.js +244 -0
@@ -0,0 +1,432 @@
1
+ /**
2
+ * DETERMINISM MODEL — PHASE 6
3
+ *
4
+ * Records all adaptive/variable decisions made during a VERAX run.
5
+ * Makes implicit choices explicit and enables replay trust validation.
6
+ *
7
+ * PRINCIPLES:
8
+ * 1. Every decision that varies by environment/timing/budget is recorded
9
+ * 2. Decisions are factual: what was chosen, what inputs were considered
10
+ * 3. No heuristics, no guessing — only recording reality
11
+ * 4. Replay can verify same decisions or explain deviations
12
+ *
13
+ * DECISION CATEGORIES:
14
+ * - BUDGET: Limits chosen (time, interactions, pages)
15
+ * - TIMEOUT: Timing windows (navigation, settle, stabilization)
16
+ * - RETRY: Retry attempts and backoff
17
+ * - ADAPTIVE_STABILIZATION: Dynamic settle extensions
18
+ * - TRUNCATION: Early termination due to budget
19
+ * - ENVIRONMENT: Browser, OS, network-dependent behavior
20
+ */
21
+
22
+ /**
23
+ * @typedef {Object} AdaptiveDecision
24
+ * @property {string} decision_id - Unique ID: <category>_<specific_id>
25
+ * @property {string} category - BUDGET | TIMEOUT | RETRY | ADAPTIVE_STABILIZATION | TRUNCATION | ENVIRONMENT
26
+ * @property {number} timestamp - When decision was made (ms since epoch)
27
+ * @property {Object} inputs - What was considered (environment, state, triggers)
28
+ * @property {*} chosen_value - What was chosen
29
+ * @property {string} reason - Technical, factual explanation
30
+ * @property {string} [context] - Additional context (page URL, interaction ID, etc.)
31
+ */
32
+
33
+ /**
34
+ * Decision IDs — Enumeration of all adaptive decision points
35
+ */
36
+ export const DECISION_IDS = {
37
+ // Budget decisions
38
+ BUDGET_PROFILE_SELECTED: 'BUDGET_profile_selected',
39
+ BUDGET_MAX_INTERACTIONS: 'BUDGET_max_interactions',
40
+ BUDGET_MAX_PAGES: 'BUDGET_max_pages',
41
+ BUDGET_SCAN_DURATION: 'BUDGET_scan_duration_ms',
42
+ BUDGET_MAX_FLOWS: 'BUDGET_max_flows',
43
+
44
+ // Timeout decisions
45
+ TIMEOUT_NAVIGATION: 'TIMEOUT_navigation_ms',
46
+ TIMEOUT_INTERACTION: 'TIMEOUT_interaction_ms',
47
+ TIMEOUT_SETTLE: 'TIMEOUT_settle_ms',
48
+ TIMEOUT_STABILIZATION: 'TIMEOUT_stabilization_window_ms',
49
+
50
+ // Adaptive stabilization decisions
51
+ ADAPTIVE_STABILIZATION_ENABLED: 'ADAPTIVE_STABILIZATION_enabled',
52
+ ADAPTIVE_STABILIZATION_EXTENDED: 'ADAPTIVE_STABILIZATION_extended',
53
+
54
+ // Retry decisions
55
+ RETRY_NAVIGATION_ATTEMPTED: 'RETRY_navigation_attempted',
56
+ RETRY_INTERACTION_ATTEMPTED: 'RETRY_interaction_attempted',
57
+ RETRY_BACKOFF_DELAY: 'RETRY_backoff_delay_ms',
58
+
59
+ // Truncation decisions
60
+ TRUNCATION_BUDGET_EXCEEDED: 'TRUNCATION_budget_exceeded',
61
+ TRUNCATION_INTERACTIONS_CAPPED: 'TRUNCATION_interactions_capped',
62
+ TRUNCATION_PAGES_CAPPED: 'TRUNCATION_pages_capped',
63
+ TRUNCATION_SCAN_TIME_EXCEEDED: 'TRUNCATION_scan_time_exceeded',
64
+
65
+ // Environment decisions
66
+ ENV_BROWSER_DETECTED: 'ENV_browser_detected',
67
+ ENV_NETWORK_SPEED: 'ENV_network_speed_class',
68
+ ENV_VIEWPORT_SIZE: 'ENV_viewport_size'
69
+ };
70
+
71
+ /**
72
+ * DecisionRecorder — Captures all adaptive decisions during a run
73
+ */
74
+ export class DecisionRecorder {
75
+ constructor(runId = null) {
76
+ this.runId = runId;
77
+ this.decisions = [];
78
+ this.decisionIndex = {}; // decision_id -> decision for quick lookup
79
+ }
80
+
81
+ /**
82
+ * Record a single adaptive decision
83
+ * @param {AdaptiveDecision} decision
84
+ */
85
+ record(decision) {
86
+ if (!decision.decision_id || !decision.category) {
87
+ throw new Error(`Invalid decision: missing decision_id or category. Got: ${JSON.stringify(decision)}`);
88
+ }
89
+
90
+ // Add timestamp if not provided
91
+ if (!decision.timestamp) {
92
+ decision.timestamp = Date.now();
93
+ }
94
+
95
+ this.decisions.push(decision);
96
+
97
+ // Index by decision_id for replay lookup
98
+ this.decisionIndex[decision.decision_id] = decision;
99
+ }
100
+
101
+ /**
102
+ * Record batch of decisions
103
+ * @param {AdaptiveDecision[]} decisions
104
+ */
105
+ recordBatch(decisions) {
106
+ decisions.forEach(d => this.record(d));
107
+ }
108
+
109
+ /**
110
+ * Get all recorded decisions
111
+ * @returns {AdaptiveDecision[]}
112
+ */
113
+ getAll() {
114
+ return this.decisions;
115
+ }
116
+
117
+ /**
118
+ * Get decisions by category
119
+ * @param {string} category
120
+ * @returns {AdaptiveDecision[]}
121
+ */
122
+ getByCategory(category) {
123
+ return this.decisions.filter(d => d.category === category);
124
+ }
125
+
126
+ /**
127
+ * Get decision by ID (most recent if multiple)
128
+ * @param {string} decisionId
129
+ * @returns {AdaptiveDecision|null}
130
+ */
131
+ getById(decisionId) {
132
+ return this.decisionIndex[decisionId] || null;
133
+ }
134
+
135
+ /**
136
+ * Get summary statistics
137
+ * @returns {Object}
138
+ */
139
+ getSummary() {
140
+ const byCategory = {};
141
+ const categories = ['BUDGET', 'TIMEOUT', 'RETRY', 'ADAPTIVE_STABILIZATION', 'TRUNCATION', 'ENVIRONMENT'];
142
+
143
+ categories.forEach(cat => {
144
+ byCategory[cat] = this.getByCategory(cat).length;
145
+ });
146
+
147
+ return {
148
+ total: this.decisions.length,
149
+ byCategory,
150
+ deterministic: this._isDeterministic()
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Determine if run was fully deterministic
156
+ * @returns {boolean}
157
+ * @private
158
+ */
159
+ _isDeterministic() {
160
+ // A run is deterministic if:
161
+ // 1. No truncations occurred (budget not exceeded)
162
+ // 2. No retries occurred (no transient failures)
163
+ // 3. No adaptive stabilization extensions (timing was predictable)
164
+
165
+ const truncations = this.getByCategory('TRUNCATION');
166
+ const retries = this.getByCategory('RETRY');
167
+ const adaptiveExtensions = this.decisions.filter(d =>
168
+ d.decision_id === DECISION_IDS.ADAPTIVE_STABILIZATION_EXTENDED
169
+ );
170
+
171
+ return truncations.length === 0 &&
172
+ retries.length === 0 &&
173
+ adaptiveExtensions.length === 0;
174
+ }
175
+
176
+ /**
177
+ * Export decisions for serialization
178
+ * @returns {Object}
179
+ */
180
+ export() {
181
+ return {
182
+ runId: this.runId,
183
+ recordedAt: new Date().toISOString(),
184
+ total: this.decisions.length,
185
+ decisions: this.decisions.map(d => ({
186
+ ...d,
187
+ timestamp: new Date(d.timestamp).toISOString() // Convert to ISO string for readability
188
+ })),
189
+ summary: this.getSummary()
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Load decisions from exported format (for replay)
195
+ * @param {Object} exported
196
+ * @returns {DecisionRecorder}
197
+ */
198
+ static fromExport(exported) {
199
+ const recorder = new DecisionRecorder(exported.runId);
200
+
201
+ if (exported.decisions) {
202
+ exported.decisions.forEach(d => {
203
+ recorder.record({
204
+ ...d,
205
+ timestamp: new Date(d.timestamp).getTime() // Convert ISO string back to ms
206
+ });
207
+ });
208
+ }
209
+
210
+ return recorder;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Create helper functions for common decision recording patterns
216
+ */
217
+
218
+ /**
219
+ * Record budget profile selection
220
+ * @param {DecisionRecorder} recorder
221
+ * @param {string} profileName
222
+ * @param {Object} budget
223
+ */
224
+ export function recordBudgetProfile(recorder, profileName, budget) {
225
+ const now = Date.now();
226
+ recorder.recordBatch([
227
+ {
228
+ decision_id: DECISION_IDS.BUDGET_PROFILE_SELECTED,
229
+ category: 'BUDGET',
230
+ timestamp: now,
231
+ inputs: { env_var: process.env.VERAX_BUDGET_PROFILE || 'STANDARD' },
232
+ chosen_value: profileName,
233
+ reason: `Budget profile selected: ${profileName}`
234
+ },
235
+ {
236
+ decision_id: DECISION_IDS.BUDGET_MAX_INTERACTIONS,
237
+ category: 'BUDGET',
238
+ timestamp: now,
239
+ inputs: { profile: profileName },
240
+ chosen_value: budget.maxInteractionsPerPage,
241
+ reason: `Max interactions per page from ${profileName} profile`
242
+ },
243
+ {
244
+ decision_id: DECISION_IDS.BUDGET_MAX_PAGES,
245
+ category: 'BUDGET',
246
+ timestamp: now,
247
+ inputs: { profile: profileName },
248
+ chosen_value: budget.maxPages,
249
+ reason: `Max pages from ${profileName} profile`
250
+ },
251
+ {
252
+ decision_id: DECISION_IDS.BUDGET_SCAN_DURATION,
253
+ category: 'BUDGET',
254
+ timestamp: now,
255
+ inputs: { profile: profileName },
256
+ chosen_value: budget.maxScanDurationMs,
257
+ reason: `Scan duration limit from ${profileName} profile`
258
+ }
259
+ ]);
260
+ }
261
+
262
+ /**
263
+ * Record timeout configuration
264
+ * @param {DecisionRecorder} recorder
265
+ * @param {Object} budget
266
+ */
267
+ export function recordTimeoutConfig(recorder, budget) {
268
+ const now = Date.now();
269
+ recorder.recordBatch([
270
+ {
271
+ decision_id: DECISION_IDS.TIMEOUT_NAVIGATION,
272
+ category: 'TIMEOUT',
273
+ timestamp: now,
274
+ inputs: { budget_config: true },
275
+ chosen_value: budget.navigationTimeoutMs,
276
+ reason: 'Navigation timeout from budget configuration'
277
+ },
278
+ {
279
+ decision_id: DECISION_IDS.TIMEOUT_INTERACTION,
280
+ category: 'TIMEOUT',
281
+ timestamp: now,
282
+ inputs: { budget_config: true },
283
+ chosen_value: budget.interactionTimeoutMs,
284
+ reason: 'Interaction timeout from budget configuration'
285
+ },
286
+ {
287
+ decision_id: DECISION_IDS.TIMEOUT_SETTLE,
288
+ category: 'TIMEOUT',
289
+ timestamp: now,
290
+ inputs: { budget_config: true },
291
+ chosen_value: budget.settleTimeoutMs,
292
+ reason: 'Settle timeout from budget configuration'
293
+ },
294
+ {
295
+ decision_id: DECISION_IDS.TIMEOUT_STABILIZATION,
296
+ category: 'TIMEOUT',
297
+ timestamp: now,
298
+ inputs: { budget_config: true },
299
+ chosen_value: budget.stabilizationWindowMs,
300
+ reason: 'Stabilization window from budget configuration'
301
+ }
302
+ ]);
303
+ }
304
+
305
+ /**
306
+ * Record adaptive stabilization decision
307
+ * @param {DecisionRecorder} recorder
308
+ * @param {boolean} enabled
309
+ * @param {boolean} wasExtended
310
+ * @param {number} extensionMs
311
+ * @param {string} reason
312
+ */
313
+ export function recordAdaptiveStabilization(recorder, enabled, wasExtended = false, extensionMs = 0, reason = '') {
314
+ const now = Date.now();
315
+ recorder.record({
316
+ decision_id: DECISION_IDS.ADAPTIVE_STABILIZATION_ENABLED,
317
+ category: 'ADAPTIVE_STABILIZATION',
318
+ timestamp: now,
319
+ inputs: { budget_config: true },
320
+ chosen_value: enabled,
321
+ reason: enabled ? 'Adaptive stabilization enabled by budget profile' : 'Adaptive stabilization disabled'
322
+ });
323
+
324
+ if (wasExtended) {
325
+ recorder.record({
326
+ decision_id: DECISION_IDS.ADAPTIVE_STABILIZATION_EXTENDED,
327
+ category: 'ADAPTIVE_STABILIZATION',
328
+ timestamp: now,
329
+ inputs: { dom_changing: true, network_active: true },
330
+ chosen_value: extensionMs,
331
+ reason: reason || `Extended stabilization by ${extensionMs}ms due to ongoing changes`
332
+ });
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Record retry attempt
338
+ * @param {DecisionRecorder} recorder
339
+ * @param {string} operationType - 'navigation' | 'interaction'
340
+ * @param {number} attemptNumber
341
+ * @param {number} delayMs
342
+ * @param {string} errorType
343
+ */
344
+ export function recordRetryAttempt(recorder, operationType, attemptNumber, delayMs, errorType) {
345
+ const decisionId = operationType === 'navigation' ?
346
+ DECISION_IDS.RETRY_NAVIGATION_ATTEMPTED :
347
+ DECISION_IDS.RETRY_INTERACTION_ATTEMPTED;
348
+
349
+ const now = Date.now();
350
+ recorder.recordBatch([
351
+ {
352
+ decision_id: decisionId,
353
+ category: 'RETRY',
354
+ timestamp: now,
355
+ inputs: { attempt: attemptNumber, error_type: errorType },
356
+ chosen_value: true,
357
+ reason: `Retry attempt ${attemptNumber} for ${operationType} due to ${errorType}`
358
+ },
359
+ {
360
+ decision_id: DECISION_IDS.RETRY_BACKOFF_DELAY,
361
+ category: 'RETRY',
362
+ timestamp: now,
363
+ inputs: { attempt: attemptNumber },
364
+ chosen_value: delayMs,
365
+ reason: `Exponential backoff delay: ${delayMs}ms`
366
+ }
367
+ ]);
368
+ }
369
+
370
+ /**
371
+ * Record budget truncation
372
+ * @param {DecisionRecorder} recorder
373
+ * @param {string} truncationType - 'interactions' | 'pages' | 'scan_time'
374
+ * @param {Object|number} limitOrOptions - Either a number (limit) or object with {limit, reached/elapsed, scope?}
375
+ * @param {number} [actual] - Actual value (only used if limitOrOptions is a number)
376
+ */
377
+ export function recordTruncation(recorder, truncationType, limitOrOptions, actual = null) {
378
+ const decisionIdMap = {
379
+ interactions: DECISION_IDS.TRUNCATION_INTERACTIONS_CAPPED,
380
+ pages: DECISION_IDS.TRUNCATION_PAGES_CAPPED,
381
+ scan_time: DECISION_IDS.TRUNCATION_SCAN_TIME_EXCEEDED
382
+ };
383
+
384
+ let limit, actualValue;
385
+ if (typeof limitOrOptions === 'object' && limitOrOptions !== null) {
386
+ limit = limitOrOptions.limit;
387
+ actualValue = limitOrOptions.reached || limitOrOptions.elapsed || limitOrOptions.actual || 0;
388
+ } else {
389
+ limit = limitOrOptions;
390
+ actualValue = actual || 0;
391
+ }
392
+
393
+ recorder.record({
394
+ decision_id: decisionIdMap[truncationType] || DECISION_IDS.TRUNCATION_BUDGET_EXCEEDED,
395
+ category: 'TRUNCATION',
396
+ timestamp: Date.now(),
397
+ inputs: { limit, actual: actualValue },
398
+ chosen_value: actualValue,
399
+ reason: `Budget exceeded: ${truncationType} capped at ${limit} (attempted ${actualValue})`
400
+ });
401
+ }
402
+
403
+ /**
404
+ * Record environment detection
405
+ * @param {DecisionRecorder} recorder
406
+ * @param {Object} environment - Environment config with browserType and viewport
407
+ */
408
+ export function recordEnvironment(recorder, environment) {
409
+ const { browserType = 'unknown', viewport = { width: 1280, height: 720 } } = environment;
410
+ const now = Date.now();
411
+
412
+ recorder.recordBatch([
413
+ {
414
+ decision_id: DECISION_IDS.ENV_BROWSER_DETECTED,
415
+ category: 'ENVIRONMENT',
416
+ timestamp: now,
417
+ inputs: { detected: true },
418
+ chosen_value: browserType,
419
+ reason: `Browser type: ${browserType}`
420
+ },
421
+ {
422
+ decision_id: DECISION_IDS.ENV_VIEWPORT_SIZE,
423
+ category: 'ENVIRONMENT',
424
+ timestamp: now,
425
+ inputs: { default_viewport: true },
426
+ chosen_value: viewport,
427
+ reason: `Viewport size: ${viewport.width}x${viewport.height}`
428
+ }
429
+ ]);
430
+ }
431
+
432
+ export default DecisionRecorder;
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Incremental Store
3
+ * Stores lightweight snapshots from previous runs for incremental execution.
4
+ *
5
+ * Snapshot contains:
6
+ * - Route signatures (hash of route path + sourceRef)
7
+ * - Expectation signatures (hash of expectation properties)
8
+ * - Interaction signatures (hash of interaction selector + type)
9
+ */
10
+
11
+ import { createHash } from 'crypto';
12
+ import { resolve } from 'path';
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
14
+ import { getRunArtifactDir } from './run-id.js';
15
+
16
+ /**
17
+ * Compute deterministic hash/signature for a route
18
+ */
19
+ export function computeRouteSignature(route) {
20
+ const key = `${route.path || ''}|${route.sourceRef || ''}|${JSON.stringify(route.isDynamic || false)}|${route.examplePath || ''}`;
21
+ return createHash('sha256').update(key).digest('hex').slice(0, 16);
22
+ }
23
+
24
+ /**
25
+ * Compute deterministic hash/signature for an expectation
26
+ */
27
+ export function computeExpectationSignature(expectation) {
28
+ const key = `${expectation.type || ''}|${expectation.fromPath || ''}|${expectation.targetPath || ''}|${expectation.sourceRef || ''}|${expectation.proof?.sourceRef || ''}`;
29
+ return createHash('sha256').update(key).digest('hex').slice(0, 16);
30
+ }
31
+
32
+ /**
33
+ * Compute deterministic hash/signature for an interaction
34
+ */
35
+ export function computeInteractionSignature(interaction, url) {
36
+ const key = `${interaction.type || ''}|${interaction.selector || ''}|${url || ''}`;
37
+ return createHash('sha256').update(key).digest('hex').slice(0, 16);
38
+ }
39
+
40
+ /**
41
+ * Load previous snapshot if it exists
42
+ */
43
+ export function loadPreviousSnapshot(projectDir, runId) {
44
+ if (!runId) {
45
+ return null; // No runId, no snapshot
46
+ }
47
+ const runDir = getRunArtifactDir(projectDir, runId);
48
+ const snapshotPath = resolve(runDir, 'incremental-snapshot.json');
49
+ if (!existsSync(snapshotPath)) {
50
+ return null;
51
+ }
52
+
53
+ try {
54
+ const content = readFileSync(snapshotPath, 'utf-8');
55
+ return JSON.parse(content);
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Save current snapshot
63
+ */
64
+ export function saveSnapshot(projectDir, snapshot, runId) {
65
+ if (!runId) {
66
+ throw new Error('runId is required for saveSnapshot');
67
+ }
68
+ const runDir = getRunArtifactDir(projectDir, runId);
69
+ mkdirSync(runDir, { recursive: true });
70
+ const snapshotPath = resolve(runDir, 'incremental-snapshot.json');
71
+
72
+ writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
73
+ }
74
+
75
+ /**
76
+ * Build snapshot from manifest and observed interactions
77
+ */
78
+ export function buildSnapshot(manifest, observedInteractions = []) {
79
+ const routes = manifest.routes || [];
80
+ const expectations = manifest.staticExpectations || [];
81
+
82
+ const routeSignatures = routes.map(r => ({
83
+ path: r.path,
84
+ signature: computeRouteSignature(r),
85
+ sourceRef: r.sourceRef
86
+ }));
87
+
88
+ const expectationSignatures = expectations.map(e => ({
89
+ type: e.type,
90
+ fromPath: e.fromPath,
91
+ targetPath: e.targetPath,
92
+ signature: computeExpectationSignature(e),
93
+ sourceRef: e.sourceRef
94
+ }));
95
+
96
+ // Group interaction signatures by URL
97
+ const interactionSignaturesByUrl = {};
98
+ for (const interaction of observedInteractions) {
99
+ const url = interaction.url || '*';
100
+ if (!interactionSignaturesByUrl[url]) {
101
+ interactionSignaturesByUrl[url] = [];
102
+ }
103
+ interactionSignaturesByUrl[url].push({
104
+ type: interaction.type,
105
+ selector: interaction.selector,
106
+ signature: computeInteractionSignature(interaction, url)
107
+ });
108
+ }
109
+
110
+ return {
111
+ timestamp: Date.now(),
112
+ routes: routeSignatures,
113
+ expectations: expectationSignatures,
114
+ interactions: interactionSignaturesByUrl,
115
+ manifestVersion: manifest.learnTruth?.version || '1.0'
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Compare old and new snapshots to detect changes
121
+ */
122
+ export function compareSnapshots(oldSnapshot, newSnapshot) {
123
+ if (!oldSnapshot) {
124
+ return {
125
+ hasChanges: true,
126
+ changedRoutes: [],
127
+ changedExpectations: [],
128
+ unchangedRoutes: [],
129
+ unchangedExpectations: []
130
+ };
131
+ }
132
+
133
+ // Compare routes
134
+ const oldRouteSigs = new Map(oldSnapshot.routes.map(r => [r.path, r.signature]));
135
+ const newRouteSigs = new Map(newSnapshot.routes.map(r => [r.path, r.signature]));
136
+
137
+ const changedRoutes = [];
138
+ const unchangedRoutes = [];
139
+
140
+ for (const newRoute of newSnapshot.routes) {
141
+ const oldSig = oldRouteSigs.get(newRoute.path);
142
+ if (oldSig !== newRoute.signature) {
143
+ changedRoutes.push(newRoute.path);
144
+ } else {
145
+ unchangedRoutes.push(newRoute.path);
146
+ }
147
+ }
148
+
149
+ // Check for removed routes
150
+ for (const oldRoute of oldSnapshot.routes) {
151
+ if (!newRouteSigs.has(oldRoute.path)) {
152
+ changedRoutes.push(oldRoute.path); // Removed route triggers re-scan
153
+ }
154
+ }
155
+
156
+ // Compare expectations
157
+ const oldExpSigs = new Set(oldSnapshot.expectations.map(e => e.signature));
158
+ const newExpSigs = new Set(newSnapshot.expectations.map(e => e.signature));
159
+
160
+ const changedExpectations = [];
161
+ const unchangedExpectations = [];
162
+
163
+ for (const newExp of newSnapshot.expectations) {
164
+ if (oldExpSigs.has(newExp.signature)) {
165
+ unchangedExpectations.push(newExp.signature);
166
+ } else {
167
+ changedExpectations.push(newExp.signature);
168
+ }
169
+ }
170
+
171
+ // Check for removed expectations
172
+ for (const oldExp of oldSnapshot.expectations) {
173
+ if (!newExpSigs.has(oldExp.signature)) {
174
+ changedExpectations.push(oldExp.signature);
175
+ }
176
+ }
177
+
178
+ const hasChanges = changedRoutes.length > 0 || changedExpectations.length > 0;
179
+
180
+ return {
181
+ hasChanges,
182
+ changedRoutes,
183
+ changedExpectations,
184
+ unchangedRoutes,
185
+ unchangedExpectations
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Check if interaction should be skipped based on incremental snapshot
191
+ */
192
+ export function shouldSkipInteractionIncremental(interaction, url, oldSnapshot, snapshotDiff) {
193
+ if (!oldSnapshot || !snapshotDiff) {
194
+ return false; // No snapshot, don't skip
195
+ }
196
+
197
+ // If route changed, don't skip (re-scan everything on that route)
198
+ const urlPath = extractPathFromUrl(url);
199
+ const routeChanged = snapshotDiff.changedRoutes.some(routePath => {
200
+ const normalizedRoute = normalizePath(routePath);
201
+ const normalizedUrl = normalizePath(urlPath);
202
+ return normalizedRoute === normalizedUrl || normalizedUrl.startsWith(normalizedRoute);
203
+ });
204
+
205
+ if (routeChanged) {
206
+ return false; // Route changed, re-scan
207
+ }
208
+
209
+ // If expectations changed, don't skip (may affect this interaction)
210
+ if (snapshotDiff.changedExpectations.length > 0) {
211
+ return false; // Expectations changed, re-scan
212
+ }
213
+
214
+ // Check if this exact interaction was seen before
215
+ const interactionSig = computeInteractionSignature(interaction, url);
216
+ const oldInteractions = oldSnapshot.interactions[url] || oldSnapshot.interactions['*'] || [];
217
+ const wasSeenBefore = oldInteractions.some(i => i.signature === interactionSig);
218
+
219
+ // Only skip if route unchanged AND expectations unchanged AND interaction was seen
220
+ if (wasSeenBefore && snapshotDiff.unchangedRoutes.length > 0) {
221
+ return true; // Unchanged route, unchanged expectations, seen interaction
222
+ }
223
+
224
+ return false; // Conservative: don't skip if uncertain
225
+ }
226
+
227
+ /**
228
+ * Extract path from URL
229
+ */
230
+ function extractPathFromUrl(url) {
231
+ try {
232
+ const urlObj = new URL(url);
233
+ return urlObj.pathname;
234
+ } catch {
235
+ return url || '*';
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Normalize path for comparison
241
+ */
242
+ function normalizePath(path) {
243
+ if (!path) return '/';
244
+ return path.replace(/\/$/, '') || '/';
245
+ }