@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.
- package/README.md +123 -88
- package/bin/verax.js +11 -452
- package/package.json +14 -36
- package/src/cli/commands/default.js +523 -0
- package/src/cli/commands/doctor.js +165 -0
- package/src/cli/commands/inspect.js +109 -0
- package/src/cli/commands/run.js +402 -0
- package/src/cli/entry.js +196 -0
- package/src/cli/util/atomic-write.js +37 -0
- package/src/cli/util/detection-engine.js +296 -0
- package/src/cli/util/env-url.js +33 -0
- package/src/cli/util/errors.js +44 -0
- package/src/cli/util/events.js +34 -0
- package/src/cli/util/expectation-extractor.js +378 -0
- package/src/cli/util/findings-writer.js +31 -0
- package/src/cli/util/idgen.js +87 -0
- package/src/cli/util/learn-writer.js +39 -0
- package/src/cli/util/observation-engine.js +366 -0
- package/src/cli/util/observe-writer.js +25 -0
- package/src/cli/util/paths.js +29 -0
- package/src/cli/util/project-discovery.js +277 -0
- package/src/cli/util/project-writer.js +26 -0
- package/src/cli/util/redact.js +128 -0
- package/src/cli/util/run-id.js +30 -0
- package/src/cli/util/summary-writer.js +32 -0
- package/src/verax/cli/ci-summary.js +35 -0
- package/src/verax/cli/context-explanation.js +89 -0
- package/src/verax/cli/doctor.js +277 -0
- package/src/verax/cli/error-normalizer.js +154 -0
- package/src/verax/cli/explain-output.js +105 -0
- package/src/verax/cli/finding-explainer.js +130 -0
- package/src/verax/cli/init.js +237 -0
- package/src/verax/cli/run-overview.js +163 -0
- package/src/verax/cli/url-safety.js +101 -0
- package/src/verax/cli/wizard.js +98 -0
- package/src/verax/cli/zero-findings-explainer.js +57 -0
- package/src/verax/cli/zero-interaction-explainer.js +127 -0
- package/src/verax/core/action-classifier.js +86 -0
- package/src/verax/core/budget-engine.js +218 -0
- package/src/verax/core/canonical-outcomes.js +157 -0
- package/src/verax/core/decision-snapshot.js +335 -0
- package/src/verax/core/determinism-model.js +403 -0
- package/src/verax/core/incremental-store.js +237 -0
- package/src/verax/core/invariants.js +356 -0
- package/src/verax/core/promise-model.js +230 -0
- package/src/verax/core/replay-validator.js +350 -0
- package/src/verax/core/replay.js +222 -0
- package/src/verax/core/run-id.js +175 -0
- package/src/verax/core/run-manifest.js +99 -0
- package/src/verax/core/silence-impact.js +369 -0
- package/src/verax/core/silence-model.js +521 -0
- package/src/verax/detect/comparison.js +2 -34
- package/src/verax/detect/confidence-engine.js +764 -329
- package/src/verax/detect/detection-engine.js +293 -0
- package/src/verax/detect/evidence-index.js +177 -0
- package/src/verax/detect/expectation-model.js +194 -172
- package/src/verax/detect/explanation-helpers.js +187 -0
- package/src/verax/detect/finding-detector.js +450 -0
- package/src/verax/detect/findings-writer.js +44 -8
- package/src/verax/detect/flow-detector.js +366 -0
- package/src/verax/detect/index.js +172 -286
- package/src/verax/detect/interactive-findings.js +613 -0
- package/src/verax/detect/signal-mapper.js +308 -0
- package/src/verax/detect/verdict-engine.js +563 -0
- package/src/verax/evidence-index-writer.js +61 -0
- package/src/verax/index.js +90 -14
- package/src/verax/intel/effect-detector.js +368 -0
- package/src/verax/intel/handler-mapper.js +249 -0
- package/src/verax/intel/index.js +281 -0
- package/src/verax/intel/route-extractor.js +280 -0
- package/src/verax/intel/ts-program.js +256 -0
- package/src/verax/intel/vue-navigation-extractor.js +579 -0
- package/src/verax/intel/vue-router-extractor.js +323 -0
- package/src/verax/learn/action-contract-extractor.js +335 -101
- package/src/verax/learn/ast-contract-extractor.js +95 -5
- package/src/verax/learn/flow-extractor.js +172 -0
- package/src/verax/learn/manifest-writer.js +97 -47
- package/src/verax/learn/project-detector.js +40 -0
- package/src/verax/learn/route-extractor.js +27 -96
- package/src/verax/learn/state-extractor.js +212 -0
- package/src/verax/learn/static-extractor-navigation.js +114 -0
- package/src/verax/learn/static-extractor-validation.js +88 -0
- package/src/verax/learn/static-extractor.js +112 -4
- package/src/verax/learn/truth-assessor.js +24 -21
- package/src/verax/observe/aria-sensor.js +211 -0
- package/src/verax/observe/browser.js +10 -5
- package/src/verax/observe/console-sensor.js +1 -17
- package/src/verax/observe/domain-boundary.js +10 -1
- package/src/verax/observe/expectation-executor.js +512 -0
- package/src/verax/observe/flow-matcher.js +143 -0
- package/src/verax/observe/focus-sensor.js +196 -0
- package/src/verax/observe/human-driver.js +643 -275
- package/src/verax/observe/index.js +908 -27
- package/src/verax/observe/index.js.backup +1 -0
- package/src/verax/observe/interaction-discovery.js +365 -14
- package/src/verax/observe/interaction-runner.js +563 -198
- package/src/verax/observe/loading-sensor.js +139 -0
- package/src/verax/observe/navigation-sensor.js +255 -0
- package/src/verax/observe/network-sensor.js +55 -7
- package/src/verax/observe/observed-expectation-deriver.js +186 -0
- package/src/verax/observe/observed-expectation.js +305 -0
- package/src/verax/observe/page-frontier.js +234 -0
- package/src/verax/observe/settle.js +37 -17
- package/src/verax/observe/state-sensor.js +389 -0
- package/src/verax/observe/timing-sensor.js +228 -0
- package/src/verax/observe/traces-writer.js +61 -20
- package/src/verax/observe/ui-signal-sensor.js +136 -17
- package/src/verax/scan-summary-writer.js +77 -15
- package/src/verax/shared/artifact-manager.js +110 -8
- package/src/verax/shared/budget-profiles.js +136 -0
- package/src/verax/shared/ci-detection.js +39 -0
- package/src/verax/shared/config-loader.js +170 -0
- package/src/verax/shared/dynamic-route-utils.js +218 -0
- package/src/verax/shared/expectation-coverage.js +44 -0
- package/src/verax/shared/expectation-prover.js +81 -0
- package/src/verax/shared/expectation-tracker.js +201 -0
- package/src/verax/shared/expectations-writer.js +60 -0
- package/src/verax/shared/first-run.js +44 -0
- package/src/verax/shared/progress-reporter.js +171 -0
- package/src/verax/shared/retry-policy.js +14 -1
- package/src/verax/shared/root-artifacts.js +49 -0
- package/src/verax/shared/scan-budget.js +86 -0
- package/src/verax/shared/url-normalizer.js +162 -0
- package/src/verax/shared/zip-artifacts.js +65 -0
- package/src/verax/validate/context-validator.js +244 -0
- 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
|
+
}
|