@veraxhq/verax 0.1.0 → 0.2.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 (126) hide show
  1. package/README.md +123 -88
  2. package/bin/verax.js +11 -452
  3. package/package.json +14 -36
  4. package/src/cli/commands/default.js +523 -0
  5. package/src/cli/commands/doctor.js +165 -0
  6. package/src/cli/commands/inspect.js +109 -0
  7. package/src/cli/commands/run.js +402 -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 +296 -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 +34 -0
  14. package/src/cli/util/expectation-extractor.js +378 -0
  15. package/src/cli/util/findings-writer.js +31 -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 +366 -0
  19. package/src/cli/util/observe-writer.js +25 -0
  20. package/src/cli/util/paths.js +29 -0
  21. package/src/cli/util/project-discovery.js +277 -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/summary-writer.js +32 -0
  26. package/src/verax/cli/ci-summary.js +35 -0
  27. package/src/verax/cli/context-explanation.js +89 -0
  28. package/src/verax/cli/doctor.js +277 -0
  29. package/src/verax/cli/error-normalizer.js +154 -0
  30. package/src/verax/cli/explain-output.js +105 -0
  31. package/src/verax/cli/finding-explainer.js +130 -0
  32. package/src/verax/cli/init.js +237 -0
  33. package/src/verax/cli/run-overview.js +163 -0
  34. package/src/verax/cli/url-safety.js +101 -0
  35. package/src/verax/cli/wizard.js +98 -0
  36. package/src/verax/cli/zero-findings-explainer.js +57 -0
  37. package/src/verax/cli/zero-interaction-explainer.js +127 -0
  38. package/src/verax/core/action-classifier.js +86 -0
  39. package/src/verax/core/budget-engine.js +218 -0
  40. package/src/verax/core/canonical-outcomes.js +157 -0
  41. package/src/verax/core/decision-snapshot.js +335 -0
  42. package/src/verax/core/determinism-model.js +403 -0
  43. package/src/verax/core/incremental-store.js +237 -0
  44. package/src/verax/core/invariants.js +356 -0
  45. package/src/verax/core/promise-model.js +230 -0
  46. package/src/verax/core/replay-validator.js +350 -0
  47. package/src/verax/core/replay.js +222 -0
  48. package/src/verax/core/run-id.js +175 -0
  49. package/src/verax/core/run-manifest.js +99 -0
  50. package/src/verax/core/silence-impact.js +369 -0
  51. package/src/verax/core/silence-model.js +521 -0
  52. package/src/verax/detect/comparison.js +2 -34
  53. package/src/verax/detect/confidence-engine.js +764 -329
  54. package/src/verax/detect/detection-engine.js +293 -0
  55. package/src/verax/detect/evidence-index.js +177 -0
  56. package/src/verax/detect/expectation-model.js +194 -172
  57. package/src/verax/detect/explanation-helpers.js +187 -0
  58. package/src/verax/detect/finding-detector.js +450 -0
  59. package/src/verax/detect/findings-writer.js +44 -8
  60. package/src/verax/detect/flow-detector.js +366 -0
  61. package/src/verax/detect/index.js +172 -286
  62. package/src/verax/detect/interactive-findings.js +613 -0
  63. package/src/verax/detect/signal-mapper.js +308 -0
  64. package/src/verax/detect/verdict-engine.js +563 -0
  65. package/src/verax/evidence-index-writer.js +61 -0
  66. package/src/verax/index.js +90 -14
  67. package/src/verax/intel/effect-detector.js +368 -0
  68. package/src/verax/intel/handler-mapper.js +249 -0
  69. package/src/verax/intel/index.js +281 -0
  70. package/src/verax/intel/route-extractor.js +280 -0
  71. package/src/verax/intel/ts-program.js +256 -0
  72. package/src/verax/intel/vue-navigation-extractor.js +579 -0
  73. package/src/verax/intel/vue-router-extractor.js +323 -0
  74. package/src/verax/learn/action-contract-extractor.js +335 -101
  75. package/src/verax/learn/ast-contract-extractor.js +95 -5
  76. package/src/verax/learn/flow-extractor.js +172 -0
  77. package/src/verax/learn/manifest-writer.js +97 -47
  78. package/src/verax/learn/project-detector.js +40 -0
  79. package/src/verax/learn/route-extractor.js +27 -96
  80. package/src/verax/learn/state-extractor.js +212 -0
  81. package/src/verax/learn/static-extractor-navigation.js +114 -0
  82. package/src/verax/learn/static-extractor-validation.js +88 -0
  83. package/src/verax/learn/static-extractor.js +112 -4
  84. package/src/verax/learn/truth-assessor.js +24 -21
  85. package/src/verax/observe/aria-sensor.js +211 -0
  86. package/src/verax/observe/browser.js +10 -5
  87. package/src/verax/observe/console-sensor.js +1 -17
  88. package/src/verax/observe/domain-boundary.js +10 -1
  89. package/src/verax/observe/expectation-executor.js +512 -0
  90. package/src/verax/observe/flow-matcher.js +143 -0
  91. package/src/verax/observe/focus-sensor.js +196 -0
  92. package/src/verax/observe/human-driver.js +643 -275
  93. package/src/verax/observe/index.js +908 -27
  94. package/src/verax/observe/index.js.backup +1 -0
  95. package/src/verax/observe/interaction-discovery.js +365 -14
  96. package/src/verax/observe/interaction-runner.js +563 -198
  97. package/src/verax/observe/loading-sensor.js +139 -0
  98. package/src/verax/observe/navigation-sensor.js +255 -0
  99. package/src/verax/observe/network-sensor.js +55 -7
  100. package/src/verax/observe/observed-expectation-deriver.js +186 -0
  101. package/src/verax/observe/observed-expectation.js +305 -0
  102. package/src/verax/observe/page-frontier.js +234 -0
  103. package/src/verax/observe/settle.js +37 -17
  104. package/src/verax/observe/state-sensor.js +389 -0
  105. package/src/verax/observe/timing-sensor.js +228 -0
  106. package/src/verax/observe/traces-writer.js +61 -20
  107. package/src/verax/observe/ui-signal-sensor.js +136 -17
  108. package/src/verax/scan-summary-writer.js +77 -15
  109. package/src/verax/shared/artifact-manager.js +110 -8
  110. package/src/verax/shared/budget-profiles.js +136 -0
  111. package/src/verax/shared/ci-detection.js +39 -0
  112. package/src/verax/shared/config-loader.js +170 -0
  113. package/src/verax/shared/dynamic-route-utils.js +218 -0
  114. package/src/verax/shared/expectation-coverage.js +44 -0
  115. package/src/verax/shared/expectation-prover.js +81 -0
  116. package/src/verax/shared/expectation-tracker.js +201 -0
  117. package/src/verax/shared/expectations-writer.js +60 -0
  118. package/src/verax/shared/first-run.js +44 -0
  119. package/src/verax/shared/progress-reporter.js +171 -0
  120. package/src/verax/shared/retry-policy.js +14 -1
  121. package/src/verax/shared/root-artifacts.js +49 -0
  122. package/src/verax/shared/scan-budget.js +86 -0
  123. package/src/verax/shared/url-normalizer.js +162 -0
  124. package/src/verax/shared/zip-artifacts.js +65 -0
  125. package/src/verax/validate/context-validator.js +244 -0
  126. package/src/verax/validate/context-validator.js.bak +0 -0
@@ -0,0 +1,403 @@
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
+ recorder.recordBatch([
226
+ {
227
+ decision_id: DECISION_IDS.BUDGET_PROFILE_SELECTED,
228
+ category: 'BUDGET',
229
+ inputs: { env_var: process.env.VERAX_BUDGET_PROFILE || 'STANDARD' },
230
+ chosen_value: profileName,
231
+ reason: `Budget profile selected: ${profileName}`
232
+ },
233
+ {
234
+ decision_id: DECISION_IDS.BUDGET_MAX_INTERACTIONS,
235
+ category: 'BUDGET',
236
+ inputs: { profile: profileName },
237
+ chosen_value: budget.maxInteractionsPerPage,
238
+ reason: `Max interactions per page from ${profileName} profile`
239
+ },
240
+ {
241
+ decision_id: DECISION_IDS.BUDGET_MAX_PAGES,
242
+ category: 'BUDGET',
243
+ inputs: { profile: profileName },
244
+ chosen_value: budget.maxPages,
245
+ reason: `Max pages from ${profileName} profile`
246
+ },
247
+ {
248
+ decision_id: DECISION_IDS.BUDGET_SCAN_DURATION,
249
+ category: 'BUDGET',
250
+ inputs: { profile: profileName },
251
+ chosen_value: budget.maxScanDurationMs,
252
+ reason: `Scan duration limit from ${profileName} profile`
253
+ }
254
+ ]);
255
+ }
256
+
257
+ /**
258
+ * Record timeout configuration
259
+ * @param {DecisionRecorder} recorder
260
+ * @param {Object} budget
261
+ */
262
+ export function recordTimeoutConfig(recorder, budget) {
263
+ recorder.recordBatch([
264
+ {
265
+ decision_id: DECISION_IDS.TIMEOUT_NAVIGATION,
266
+ category: 'TIMEOUT',
267
+ inputs: { budget_config: true },
268
+ chosen_value: budget.navigationTimeoutMs,
269
+ reason: 'Navigation timeout from budget configuration'
270
+ },
271
+ {
272
+ decision_id: DECISION_IDS.TIMEOUT_INTERACTION,
273
+ category: 'TIMEOUT',
274
+ inputs: { budget_config: true },
275
+ chosen_value: budget.interactionTimeoutMs,
276
+ reason: 'Interaction timeout from budget configuration'
277
+ },
278
+ {
279
+ decision_id: DECISION_IDS.TIMEOUT_SETTLE,
280
+ category: 'TIMEOUT',
281
+ inputs: { budget_config: true },
282
+ chosen_value: budget.settleTimeoutMs,
283
+ reason: 'Settle timeout from budget configuration'
284
+ },
285
+ {
286
+ decision_id: DECISION_IDS.TIMEOUT_STABILIZATION,
287
+ category: 'TIMEOUT',
288
+ inputs: { budget_config: true },
289
+ chosen_value: budget.stabilizationWindowMs,
290
+ reason: 'Stabilization window from budget configuration'
291
+ }
292
+ ]);
293
+ }
294
+
295
+ /**
296
+ * Record adaptive stabilization decision
297
+ * @param {DecisionRecorder} recorder
298
+ * @param {boolean} enabled
299
+ * @param {boolean} wasExtended
300
+ * @param {number} extensionMs
301
+ * @param {string} reason
302
+ */
303
+ export function recordAdaptiveStabilization(recorder, enabled, wasExtended = false, extensionMs = 0, reason = '') {
304
+ recorder.record({
305
+ decision_id: DECISION_IDS.ADAPTIVE_STABILIZATION_ENABLED,
306
+ category: 'ADAPTIVE_STABILIZATION',
307
+ inputs: { budget_config: true },
308
+ chosen_value: enabled,
309
+ reason: enabled ? 'Adaptive stabilization enabled by budget profile' : 'Adaptive stabilization disabled'
310
+ });
311
+
312
+ if (wasExtended) {
313
+ recorder.record({
314
+ decision_id: DECISION_IDS.ADAPTIVE_STABILIZATION_EXTENDED,
315
+ category: 'ADAPTIVE_STABILIZATION',
316
+ inputs: { dom_changing: true, network_active: true },
317
+ chosen_value: extensionMs,
318
+ reason: reason || `Extended stabilization by ${extensionMs}ms due to ongoing changes`
319
+ });
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Record retry attempt
325
+ * @param {DecisionRecorder} recorder
326
+ * @param {string} operationType - 'navigation' | 'interaction'
327
+ * @param {number} attemptNumber
328
+ * @param {number} delayMs
329
+ * @param {string} errorType
330
+ */
331
+ export function recordRetryAttempt(recorder, operationType, attemptNumber, delayMs, errorType) {
332
+ const decisionId = operationType === 'navigation' ?
333
+ DECISION_IDS.RETRY_NAVIGATION_ATTEMPTED :
334
+ DECISION_IDS.RETRY_INTERACTION_ATTEMPTED;
335
+
336
+ recorder.recordBatch([
337
+ {
338
+ decision_id: decisionId,
339
+ category: 'RETRY',
340
+ inputs: { attempt: attemptNumber, error_type: errorType },
341
+ chosen_value: true,
342
+ reason: `Retry attempt ${attemptNumber} for ${operationType} due to ${errorType}`
343
+ },
344
+ {
345
+ decision_id: DECISION_IDS.RETRY_BACKOFF_DELAY,
346
+ category: 'RETRY',
347
+ inputs: { attempt: attemptNumber },
348
+ chosen_value: delayMs,
349
+ reason: `Exponential backoff delay: ${delayMs}ms`
350
+ }
351
+ ]);
352
+ }
353
+
354
+ /**
355
+ * Record budget truncation
356
+ * @param {DecisionRecorder} recorder
357
+ * @param {string} truncationType - 'interactions' | 'pages' | 'scan_time'
358
+ * @param {number} limit
359
+ * @param {number} actual
360
+ */
361
+ export function recordTruncation(recorder, truncationType, limit, actual) {
362
+ const decisionIdMap = {
363
+ interactions: DECISION_IDS.TRUNCATION_INTERACTIONS_CAPPED,
364
+ pages: DECISION_IDS.TRUNCATION_PAGES_CAPPED,
365
+ scan_time: DECISION_IDS.TRUNCATION_SCAN_TIME_EXCEEDED
366
+ };
367
+
368
+ recorder.record({
369
+ decision_id: decisionIdMap[truncationType] || DECISION_IDS.TRUNCATION_BUDGET_EXCEEDED,
370
+ category: 'TRUNCATION',
371
+ inputs: { limit, actual },
372
+ chosen_value: actual,
373
+ reason: `Budget exceeded: ${truncationType} capped at ${limit} (attempted ${actual})`
374
+ });
375
+ }
376
+
377
+ /**
378
+ * Record environment detection
379
+ * @param {DecisionRecorder} recorder
380
+ * @param {Object} environment - Environment config with browserType and viewport
381
+ */
382
+ export function recordEnvironment(recorder, environment) {
383
+ const { browserType = 'unknown', viewport = { width: 1280, height: 720 } } = environment;
384
+
385
+ recorder.recordBatch([
386
+ {
387
+ decision_id: DECISION_IDS.ENV_BROWSER_DETECTED,
388
+ category: 'ENVIRONMENT',
389
+ inputs: { detected: true },
390
+ chosen_value: browserType,
391
+ reason: `Browser type: ${browserType}`
392
+ },
393
+ {
394
+ decision_id: DECISION_IDS.ENV_VIEWPORT_SIZE,
395
+ category: 'ENVIRONMENT',
396
+ inputs: { default_viewport: true },
397
+ chosen_value: viewport,
398
+ reason: `Viewport size: ${viewport.width}x${viewport.height}`
399
+ }
400
+ ]);
401
+ }
402
+
403
+ export default DecisionRecorder;
@@ -0,0 +1,237 @@
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, dirname } from 'path';
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
14
+
15
+ /**
16
+ * Compute deterministic hash/signature for a route
17
+ */
18
+ export function computeRouteSignature(route) {
19
+ const key = `${route.path || ''}|${route.sourceRef || ''}|${JSON.stringify(route.isDynamic || false)}|${route.examplePath || ''}`;
20
+ return createHash('sha256').update(key).digest('hex').slice(0, 16);
21
+ }
22
+
23
+ /**
24
+ * Compute deterministic hash/signature for an expectation
25
+ */
26
+ export function computeExpectationSignature(expectation) {
27
+ const key = `${expectation.type || ''}|${expectation.fromPath || ''}|${expectation.targetPath || ''}|${expectation.sourceRef || ''}|${expectation.proof?.sourceRef || ''}`;
28
+ return createHash('sha256').update(key).digest('hex').slice(0, 16);
29
+ }
30
+
31
+ /**
32
+ * Compute deterministic hash/signature for an interaction
33
+ */
34
+ export function computeInteractionSignature(interaction, url) {
35
+ const key = `${interaction.type || ''}|${interaction.selector || ''}|${url || ''}`;
36
+ return createHash('sha256').update(key).digest('hex').slice(0, 16);
37
+ }
38
+
39
+ /**
40
+ * Load previous snapshot if it exists
41
+ */
42
+ export function loadPreviousSnapshot(projectDir) {
43
+ const snapshotPath = resolve(projectDir, '.veraxverax', 'incremental-snapshot.json');
44
+ if (!existsSync(snapshotPath)) {
45
+ return null;
46
+ }
47
+
48
+ try {
49
+ const content = readFileSync(snapshotPath, 'utf-8');
50
+ return JSON.parse(content);
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Save current snapshot
58
+ */
59
+ export function saveSnapshot(projectDir, snapshot) {
60
+ const snapshotDir = resolve(projectDir, '.veraxverax');
61
+ mkdirSync(snapshotDir, { recursive: true });
62
+ const snapshotPath = resolve(snapshotDir, 'incremental-snapshot.json');
63
+
64
+ writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
65
+ }
66
+
67
+ /**
68
+ * Build snapshot from manifest and observed interactions
69
+ */
70
+ export function buildSnapshot(manifest, observedInteractions = []) {
71
+ const routes = manifest.routes || [];
72
+ const expectations = manifest.staticExpectations || [];
73
+
74
+ const routeSignatures = routes.map(r => ({
75
+ path: r.path,
76
+ signature: computeRouteSignature(r),
77
+ sourceRef: r.sourceRef
78
+ }));
79
+
80
+ const expectationSignatures = expectations.map(e => ({
81
+ type: e.type,
82
+ fromPath: e.fromPath,
83
+ targetPath: e.targetPath,
84
+ signature: computeExpectationSignature(e),
85
+ sourceRef: e.sourceRef
86
+ }));
87
+
88
+ // Group interaction signatures by URL
89
+ const interactionSignaturesByUrl = {};
90
+ for (const interaction of observedInteractions) {
91
+ const url = interaction.url || '*';
92
+ if (!interactionSignaturesByUrl[url]) {
93
+ interactionSignaturesByUrl[url] = [];
94
+ }
95
+ interactionSignaturesByUrl[url].push({
96
+ type: interaction.type,
97
+ selector: interaction.selector,
98
+ signature: computeInteractionSignature(interaction, url)
99
+ });
100
+ }
101
+
102
+ return {
103
+ timestamp: Date.now(),
104
+ routes: routeSignatures,
105
+ expectations: expectationSignatures,
106
+ interactions: interactionSignaturesByUrl,
107
+ manifestVersion: manifest.learnTruth?.version || '1.0'
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Compare old and new snapshots to detect changes
113
+ */
114
+ export function compareSnapshots(oldSnapshot, newSnapshot) {
115
+ if (!oldSnapshot) {
116
+ return {
117
+ hasChanges: true,
118
+ changedRoutes: [],
119
+ changedExpectations: [],
120
+ unchangedRoutes: [],
121
+ unchangedExpectations: []
122
+ };
123
+ }
124
+
125
+ // Compare routes
126
+ const oldRouteSigs = new Map(oldSnapshot.routes.map(r => [r.path, r.signature]));
127
+ const newRouteSigs = new Map(newSnapshot.routes.map(r => [r.path, r.signature]));
128
+
129
+ const changedRoutes = [];
130
+ const unchangedRoutes = [];
131
+
132
+ for (const newRoute of newSnapshot.routes) {
133
+ const oldSig = oldRouteSigs.get(newRoute.path);
134
+ if (oldSig !== newRoute.signature) {
135
+ changedRoutes.push(newRoute.path);
136
+ } else {
137
+ unchangedRoutes.push(newRoute.path);
138
+ }
139
+ }
140
+
141
+ // Check for removed routes
142
+ for (const oldRoute of oldSnapshot.routes) {
143
+ if (!newRouteSigs.has(oldRoute.path)) {
144
+ changedRoutes.push(oldRoute.path); // Removed route triggers re-scan
145
+ }
146
+ }
147
+
148
+ // Compare expectations
149
+ const oldExpSigs = new Set(oldSnapshot.expectations.map(e => e.signature));
150
+ const newExpSigs = new Set(newSnapshot.expectations.map(e => e.signature));
151
+
152
+ const changedExpectations = [];
153
+ const unchangedExpectations = [];
154
+
155
+ for (const newExp of newSnapshot.expectations) {
156
+ if (oldExpSigs.has(newExp.signature)) {
157
+ unchangedExpectations.push(newExp.signature);
158
+ } else {
159
+ changedExpectations.push(newExp.signature);
160
+ }
161
+ }
162
+
163
+ // Check for removed expectations
164
+ for (const oldExp of oldSnapshot.expectations) {
165
+ if (!newExpSigs.has(oldExp.signature)) {
166
+ changedExpectations.push(oldExp.signature);
167
+ }
168
+ }
169
+
170
+ const hasChanges = changedRoutes.length > 0 || changedExpectations.length > 0;
171
+
172
+ return {
173
+ hasChanges,
174
+ changedRoutes,
175
+ changedExpectations,
176
+ unchangedRoutes,
177
+ unchangedExpectations
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Check if interaction should be skipped based on incremental snapshot
183
+ */
184
+ export function shouldSkipInteractionIncremental(interaction, url, oldSnapshot, snapshotDiff) {
185
+ if (!oldSnapshot || !snapshotDiff) {
186
+ return false; // No snapshot, don't skip
187
+ }
188
+
189
+ // If route changed, don't skip (re-scan everything on that route)
190
+ const urlPath = extractPathFromUrl(url);
191
+ const routeChanged = snapshotDiff.changedRoutes.some(routePath => {
192
+ const normalizedRoute = normalizePath(routePath);
193
+ const normalizedUrl = normalizePath(urlPath);
194
+ return normalizedRoute === normalizedUrl || normalizedUrl.startsWith(normalizedRoute);
195
+ });
196
+
197
+ if (routeChanged) {
198
+ return false; // Route changed, re-scan
199
+ }
200
+
201
+ // If expectations changed, don't skip (may affect this interaction)
202
+ if (snapshotDiff.changedExpectations.length > 0) {
203
+ return false; // Expectations changed, re-scan
204
+ }
205
+
206
+ // Check if this exact interaction was seen before
207
+ const interactionSig = computeInteractionSignature(interaction, url);
208
+ const oldInteractions = oldSnapshot.interactions[url] || oldSnapshot.interactions['*'] || [];
209
+ const wasSeenBefore = oldInteractions.some(i => i.signature === interactionSig);
210
+
211
+ // Only skip if route unchanged AND expectations unchanged AND interaction was seen
212
+ if (wasSeenBefore && snapshotDiff.unchangedRoutes.length > 0) {
213
+ return true; // Unchanged route, unchanged expectations, seen interaction
214
+ }
215
+
216
+ return false; // Conservative: don't skip if uncertain
217
+ }
218
+
219
+ /**
220
+ * Extract path from URL
221
+ */
222
+ function extractPathFromUrl(url) {
223
+ try {
224
+ const urlObj = new URL(url);
225
+ return urlObj.pathname;
226
+ } catch {
227
+ return url || '*';
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Normalize path for comparison
233
+ */
234
+ function normalizePath(path) {
235
+ if (!path) return '/';
236
+ return path.replace(/\/$/, '') || '/';
237
+ }