@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,109 @@
1
+ /**
2
+ * Wave 3 — Interactive Wizard
3
+ *
4
+ * Guides users through VERAX configuration with friendly prompts.
5
+ */
6
+
7
+ /**
8
+ * @typedef {Object} ReadlineInterface
9
+ * @property {function(string): Promise<string>} question - Prompt user with question
10
+ * @property {function(): void} close - Close the readline interface
11
+ */
12
+
13
+ /**
14
+ * Create a readline interface (can be injected for testing)
15
+ * @returns {Promise<ReadlineInterface>}
16
+ */
17
+ async function createReadlineInterface(input = process.stdin, output = process.stdout) {
18
+ const readline = await import('readline/promises');
19
+ return readline.default.createInterface({
20
+ input,
21
+ output,
22
+ terminal: true
23
+ });
24
+ }
25
+
26
+ /**
27
+ * @typedef {Object} WizardOptions
28
+ * @property {ReadlineInterface} [readlineInterface] - Readline interface (injectable for testing)
29
+ */
30
+
31
+ /**
32
+ * Run interactive wizard
33
+ * @param {WizardOptions} [options={}] - Options for wizard
34
+ * @returns {Promise<Object>} Wizard results
35
+ */
36
+ export async function runWizard(options = {}) {
37
+ const rl = options.readlineInterface || await createReadlineInterface();
38
+
39
+ try {
40
+ console.log('VERAX — Silent Failure Detection\n');
41
+ console.log('This wizard will guide you through setting up a VERAX scan.\n');
42
+
43
+ // 1. Mode selection (Wave 7: add init and doctor)
44
+ const modeAnswer = await rl.question('Mode: (s)can, (f)low, (i)nit, or (d)octor? [s]: ');
45
+ const modeInput = (modeAnswer.trim().toLowerCase() || 's');
46
+ let mode;
47
+ if (modeInput.startsWith('i')) {
48
+ mode = 'init';
49
+ } else if (modeInput.startsWith('d')) {
50
+ mode = 'doctor';
51
+ } else if (modeInput.startsWith('f')) {
52
+ mode = 'flow';
53
+ } else {
54
+ mode = 'scan';
55
+ }
56
+
57
+ // For init and doctor modes, return early
58
+ if (mode === 'init' || mode === 'doctor') {
59
+ return { mode };
60
+ }
61
+
62
+ // 2. URL
63
+ const urlAnswer = await rl.question('URL to scan [http://localhost:3000]: ');
64
+ let url = urlAnswer.trim();
65
+ if (!url) {
66
+ url = 'http://localhost:3000';
67
+ }
68
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
69
+ url = 'http://' + url;
70
+ }
71
+
72
+ // 3. Project root (for scan) or flow file (for flow)
73
+ let projectRoot = process.cwd();
74
+ let flowPath = null;
75
+
76
+ if (mode === 'scan') {
77
+ const projectRootAnswer = await rl.question(`Project root [${projectRoot}]: `);
78
+ if (projectRootAnswer.trim()) {
79
+ projectRoot = projectRootAnswer.trim();
80
+ }
81
+ } else {
82
+ const flowPathAnswer = await rl.question('Flow file path: ');
83
+ flowPath = flowPathAnswer.trim();
84
+ if (!flowPath) {
85
+ throw new Error('Flow file path is required');
86
+ }
87
+ }
88
+
89
+ // 4. JSON output
90
+ const jsonAnswer = await rl.question('JSON output? (y/N) [N]: ');
91
+ const jsonOutput = (jsonAnswer.trim().toLowerCase() || 'n').startsWith('y');
92
+
93
+ // 5. Output directory
94
+ const outDirAnswer = await rl.question(`Output directory [.verax/runs]: `);
95
+ const outDir = outDirAnswer.trim() || '.verax/runs';
96
+
97
+ return {
98
+ mode,
99
+ url,
100
+ projectRoot,
101
+ flowPath,
102
+ json: jsonOutput,
103
+ out: outDir
104
+ };
105
+ } finally {
106
+ rl.close();
107
+ }
108
+ }
109
+
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Zero Findings Explanation
3
+ *
4
+ * Observational explanation of zero findings (not a judgment).
5
+ */
6
+
7
+ /**
8
+ * Generate explanation for zero findings
9
+ * @param {Object} context - Context information
10
+ * @returns {Array<string>} Explanation lines
11
+ */
12
+ export function explainZeroFindings(context = {}) {
13
+ const {
14
+ expectationsCount = 0,
15
+ interactionsObserved = 0,
16
+ discrepanciesObserved = 0
17
+ } = context;
18
+
19
+ const lines = [
20
+ '────────────────────────────────────────────────────────────',
21
+ 'OBSERVATION: No Discrepancies Detected',
22
+ '────────────────────────────────────────────────────────────',
23
+ '',
24
+ `VERAX analyzed ${expectationsCount} code-derived expectations`,
25
+ `Observed ${interactionsObserved} user interactions`,
26
+ `Discrepancies observed: ${discrepanciesObserved}`,
27
+ '',
28
+ 'What this means:',
29
+ ' • During this scan, no discrepancies were observed between code promises and runtime behavior',
30
+ ' • This does NOT guarantee safety, correctness, or completeness',
31
+ ' • Some expectations may not have been evaluated (see gaps below)',
32
+ '',
33
+ 'What was not observed:',
34
+ ];
35
+
36
+ if (expectationsCount === 0) {
37
+ lines.push(' • No expectations found in code - no evaluation was possible');
38
+ lines.push(' • VERAX requires PROVEN expectations (static patterns) to observe behavior');
39
+ } else {
40
+ lines.push(' • Check coverage gaps to see what was not evaluated');
41
+ }
42
+
43
+ return lines;
44
+ }
45
+
46
+ /**
47
+ * Print zero findings explanation
48
+ * @param {Object} context - Context
49
+ */
50
+ export function printZeroFindingsExplanation(context = {}) {
51
+ const lines = explainZeroFindings(context);
52
+ lines.forEach(line => {
53
+ console.error(line);
54
+ });
55
+ console.error('');
56
+ }
57
+
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Wave 6 — Zero Interaction Explanation
3
+ *
4
+ * Explains why no interactions were executed and what could/could not be validated.
5
+ */
6
+
7
+ /**
8
+ * Explain why no interactions were executed
9
+ * @param {Object} context - Scan context
10
+ * @returns {Array<string>} Explanation lines
11
+ */
12
+ export function explainZeroInteractions(context = {}) {
13
+ const {
14
+ verdict = 'UNKNOWN',
15
+ contextCheck = null,
16
+ interactionsObserved = 0,
17
+ expectationsTotal = 0,
18
+ observation = null
19
+ } = context;
20
+
21
+ // If interactions were actually observed, return empty
22
+ if (interactionsObserved > 0) {
23
+ return [];
24
+ }
25
+
26
+ // If no expectations, different explanation
27
+ if (expectationsTotal === 0) {
28
+ return [
29
+ 'No interactions executed because no expectations were found.',
30
+ 'VERAX needs code-derived expectations to know what to test.'
31
+ ];
32
+ }
33
+
34
+ const reasons = [];
35
+
36
+ // Context validation stopped early
37
+ if (verdict === 'INVALID_CONTEXT' && contextCheck && !contextCheck.forced) {
38
+ reasons.push([
39
+ '⚠️ No interactions executed because context validation failed.',
40
+ '',
41
+ 'VERAX stopped the scan early to prevent analyzing the wrong site.',
42
+ '',
43
+ 'What VERAX could validate:',
44
+ ` ✓ Discovered ${expectationsTotal} expectations from your code`,
45
+ ' ✗ Could not validate interactions (scan stopped)',
46
+ '',
47
+ 'What to do:',
48
+ ' • Use --force to scan anyway, or',
49
+ ' • Ensure your URL matches the project being analyzed'
50
+ ]);
51
+ } else if (verdict === 'INVALID_CONTEXT_FORCED') {
52
+ reasons.push([
53
+ '⚠️ Limited interactions executed due to context mismatch.',
54
+ '',
55
+ 'VERAX continued with --force but may not have found expected routes.',
56
+ '',
57
+ 'What VERAX validated:',
58
+ ` ✓ ${expectationsTotal} expectations found`,
59
+ ' ⚠ Interactions may not match expectations (context mismatch)',
60
+ '',
61
+ 'What to do:',
62
+ ' • Verify URL matches project deployment',
63
+ ' • Check that routes exist on the live site'
64
+ ]);
65
+ } else {
66
+ // No interactions discovered or executed
67
+ const observationDetails = observation?.observeTruth || {};
68
+
69
+ if (observationDetails.interactionsObserved === 0) {
70
+ reasons.push([
71
+ '⚠️ No interactions executed.',
72
+ '',
73
+ 'VERAX could not discover any interactive elements on the page.',
74
+ '',
75
+ 'What VERAX could validate:',
76
+ ` ✓ Discovered ${expectationsTotal} expectations from your code`,
77
+ ' ✗ Could not test interactions (no discoverable elements)',
78
+ '',
79
+ 'Possible reasons:',
80
+ ' • Page has no clickable links, buttons, or forms',
81
+ ' • Elements are hidden or not rendered',
82
+ ' • JavaScript errors prevented interaction discovery',
83
+ '',
84
+ 'What to do:',
85
+ ' • Verify the page loads correctly',
86
+ ' • Check browser console for errors',
87
+ ' • Ensure interactive elements are visible and accessible'
88
+ ]);
89
+ } else {
90
+ // Interactions discovered but none executed
91
+ reasons.push([
92
+ '⚠️ No interactions executed despite discoveries.',
93
+ '',
94
+ 'VERAX discovered interactions but could not execute them.',
95
+ '',
96
+ 'What VERAX could validate:',
97
+ ` ✓ Discovered ${expectationsTotal} expectations from your code`,
98
+ ' ✗ Could not test interactions (execution failed)',
99
+ '',
100
+ 'What to do:',
101
+ ' • Check network connectivity',
102
+ ' • Verify page is accessible',
103
+ ' • Review error logs for details'
104
+ ]);
105
+ }
106
+ }
107
+
108
+ return reasons.flat();
109
+ }
110
+
111
+ /**
112
+ * Print zero interaction explanation
113
+ * @param {Object} context - Scan context
114
+ */
115
+ export function printZeroInteractionExplanation(context) {
116
+ const explanation = explainZeroInteractions(context);
117
+ if (explanation.length > 0) {
118
+ console.error('\n' + '─'.repeat(60));
119
+ console.error('Interaction Status');
120
+ console.error('─'.repeat(60));
121
+ explanation.forEach(line => {
122
+ console.error(line);
123
+ });
124
+ console.error('─'.repeat(60) + '\n');
125
+ }
126
+ }
127
+
@@ -0,0 +1,86 @@
1
+ /**
2
+ * ACTION CLASSIFIER
3
+ *
4
+ * Classifies user interactions by safety level for misuse resistance.
5
+ * NO JUDGMENT SEMANTICS - only risk classification for protection.
6
+ *
7
+ * Classifications:
8
+ * - SAFE_READONLY: Navigation, focus, hover, non-submitting clicks
9
+ * - RISKY: Destructive actions (delete/remove/clear data), logout, admin
10
+ * - WRITE_INTENT: State-changing actions (submit, file upload, checkout/pay)
11
+ *
12
+ * Phase 4: Default safe mode blocks RISKY and WRITE_INTENT unless explicitly allowed.
13
+ */
14
+
15
+ /**
16
+ * Classify an interaction's safety level
17
+ * @param {Object} interaction - Interaction object with type, text, label, selector
18
+ * @returns {Object} { classification: string, reason: string }
19
+ */
20
+ export function classifyAction(interaction) {
21
+ const type = (interaction.type || '').toLowerCase();
22
+ const text = (interaction.text || '').toLowerCase().trim();
23
+ const label = (interaction.label || '').toLowerCase().trim();
24
+ const ariaLabel = (interaction.ariaLabel || '').toLowerCase().trim();
25
+ const selector = (interaction.selector || '').toLowerCase();
26
+ const combined = `${text} ${label} ${ariaLabel}`.trim();
27
+
28
+ // RISKY patterns - destructive/dangerous actions
29
+ const riskyKeywords = /\b(delete|remove|erase|wipe|destroy|drop|clear\s+(data|all|history|cache|account|database|storage)|reset\s+(account|data|database)|deactivate|terminate|uninstall|unsubscribe)\b/i;
30
+ const adminKeywords = /\b(admin|administrator|sudo|root|settings|config|preferences)\b/i;
31
+
32
+ if (riskyKeywords.test(combined)) {
33
+ return { classification: 'RISKY', reason: 'destructive_keyword' };
34
+ }
35
+
36
+ // Logout/signout treated as risky (state loss)
37
+ if (/\b(logout|sign\s*out|log\s*out)\b/i.test(combined)) {
38
+ return { classification: 'RISKY', reason: 'auth_logout' };
39
+ }
40
+
41
+ // Admin/config actions risky
42
+ if (adminKeywords.test(combined) && !/view|read|show/.test(combined)) {
43
+ return { classification: 'RISKY', reason: 'admin_action' };
44
+ }
45
+
46
+ // WRITE_INTENT patterns - state-changing actions
47
+ const writeKeywords = /\b(submit|send|post|save|update|create|add|checkout|purchase|pay|buy|order|upload|attach|edit|modify)\b/i;
48
+
49
+ if (writeKeywords.test(combined)) {
50
+ return { classification: 'WRITE_INTENT', reason: 'write_keyword' };
51
+ }
52
+
53
+ // Form submissions are write intent
54
+ if (type === 'submit' || selector.includes('type="submit"') || selector.includes('[type=submit]')) {
55
+ return { classification: 'WRITE_INTENT', reason: 'form_submit' };
56
+ }
57
+
58
+ // File inputs are write intent
59
+ if (type === 'file' || selector.includes('type="file"') || selector.includes('input[type=file]')) {
60
+ return { classification: 'WRITE_INTENT', reason: 'file_input' };
61
+ }
62
+
63
+ // Default: safe readonly (navigation, clicks, focus, hover)
64
+ return { classification: 'SAFE_READONLY', reason: 'default_safe' };
65
+ }
66
+
67
+ /**
68
+ * Check if action should be blocked based on safety mode and flags
69
+ * @param {Object} interaction - Interaction to check
70
+ * @param {Object} flags - Safety flags { allowWrites: boolean, allowRiskyActions: boolean }
71
+ * @returns {Object} { shouldBlock: boolean, classification: string, reason: string }
72
+ */
73
+ export function shouldBlockAction(interaction, flags = {}) {
74
+ const { allowWrites = false, allowRiskyActions = false } = flags;
75
+ const { classification, reason } = classifyAction(interaction);
76
+
77
+ if (classification === 'RISKY' && !allowRiskyActions) {
78
+ return { shouldBlock: true, classification, reason };
79
+ }
80
+
81
+ if (classification === 'WRITE_INTENT' && !allowWrites) {
82
+ return { shouldBlock: true, classification, reason };
83
+ }
84
+
85
+ return { shouldBlock: false, classification, reason };
86
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Budget Engine
3
+ * Allocates adaptive interaction budgets per route/page based on:
4
+ * - Route criticality (has expectations or not)
5
+ * - Number of routes in project
6
+ * - Number of expectations per route
7
+ *
8
+ * Deterministic and reproducible.
9
+ */
10
+
11
+ export class BudgetEngine {
12
+ constructor(options = {}) {
13
+ this.baseBudgetPerRoute = options.baseBudgetPerRoute || 30;
14
+ this.criticalRouteMultiplier = options.criticalRouteMultiplier || 2.0;
15
+ this.nonCriticalRouteMultiplier = options.nonCriticalRouteMultiplier || 0.5;
16
+ this.expectationMultiplier = options.expectationMultiplier || 1.5;
17
+ this.minBudget = options.minBudget || 5;
18
+ this.maxBudget = options.maxBudget || 100;
19
+ }
20
+
21
+ /**
22
+ * Allocate budget for a single route
23
+ * @param {Object} route - Route object with url, interactions
24
+ * @param {Array} expectations - Expectations for this route
25
+ * @return {Object} Budget allocation { budget, isCritical, reason }
26
+ */
27
+ allocateBudgetForRoute(route, expectations = []) {
28
+ const isCritical = expectations.length > 0;
29
+ let budget = this.baseBudgetPerRoute;
30
+
31
+ if (isCritical) {
32
+ // Critical routes with expectations get higher budget
33
+ budget = Math.floor(budget * this.criticalRouteMultiplier);
34
+
35
+ // Additional budget boost for routes with many expectations
36
+ if (expectations.length > 3) {
37
+ budget = Math.floor(budget * this.expectationMultiplier);
38
+ }
39
+ } else {
40
+ // Non-critical routes get reduced budget
41
+ budget = Math.floor(budget * this.nonCriticalRouteMultiplier);
42
+ }
43
+
44
+ // Clamp to min/max
45
+ budget = Math.max(this.minBudget, Math.min(this.maxBudget, budget));
46
+
47
+ return {
48
+ budget,
49
+ isCritical,
50
+ reason: isCritical
51
+ ? `critical_route_${expectations.length}_expectations`
52
+ : 'non_critical_route',
53
+ routeUrl: route.url || route.path || 'unknown'
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Allocate budgets for all routes deterministically
59
+ * @param {Array} routes - Array of route objects
60
+ * @param {Array} allExpectations - All expectations across routes
61
+ * @return {Array} Array of budget allocations sorted by URL for stability
62
+ */
63
+ allocateBudgets(routes, allExpectations = []) {
64
+ // Group expectations by route
65
+ const expectationsByRoute = new Map();
66
+ for (const exp of allExpectations) {
67
+ const routeUrl = exp.expectedRoute || exp.fromPath || '/';
68
+ if (!expectationsByRoute.has(routeUrl)) {
69
+ expectationsByRoute.set(routeUrl, []);
70
+ }
71
+ expectationsByRoute.get(routeUrl).push(exp);
72
+ }
73
+
74
+ // Allocate budget for each route
75
+ const allocations = [];
76
+ for (const route of routes) {
77
+ const routeUrl = route.url || route.path || '/';
78
+ const expectations = expectationsByRoute.get(routeUrl) || [];
79
+ const allocation = this.allocateBudgetForRoute(route, expectations);
80
+ allocations.push({
81
+ ...allocation,
82
+ routeUrl: routeUrl
83
+ });
84
+ }
85
+
86
+ // Sort deterministically by routeUrl for stable ordering
87
+ allocations.sort((a, b) => {
88
+ return (a.routeUrl || '').localeCompare(b.routeUrl || '');
89
+ });
90
+
91
+ return allocations;
92
+ }
93
+
94
+ /**
95
+ * Compute total budget across all routes
96
+ * @param {Array} routes - Array of route objects
97
+ * @param {Array} allExpectations - All expectations across routes
98
+ * @return {Object} Total budget stats
99
+ */
100
+ computeTotalBudget(routes, allExpectations = []) {
101
+ const allocations = this.allocateBudgets(routes, allExpectations);
102
+
103
+ const totalBudget = allocations.reduce((sum, alloc) => sum + alloc.budget, 0);
104
+ const criticalRoutes = allocations.filter(a => a.isCritical).length;
105
+ const nonCriticalRoutes = allocations.filter(a => !a.isCritical).length;
106
+
107
+ return {
108
+ totalBudget,
109
+ totalRoutes: routes.length,
110
+ criticalRoutes,
111
+ nonCriticalRoutes,
112
+ averageBudgetPerRoute: routes.length > 0 ? Math.round(totalBudget / routes.length) : 0,
113
+ allocations
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Get budget for a specific route URL
119
+ * @param {string} routeUrl - Route URL to query
120
+ * @param {Array} allocations - Pre-computed allocations
121
+ * @return {number|null} Budget or null if not found
122
+ */
123
+ getBudgetForRoute(routeUrl, allocations) {
124
+ const allocation = allocations.find(a => a.routeUrl === routeUrl);
125
+ return allocation ? allocation.budget : null;
126
+ }
127
+ }
128
+
129
+ // Legacy functional API for backwards compatibility
130
+ export function computeRouteBudget(manifest, currentUrl, baseBudget) {
131
+ const routes = manifest.routes || [];
132
+ const expectations = manifest.staticExpectations || [];
133
+ const totalRoutes = routes.length;
134
+ // const totalExpectations = expectations.length; // Reserved for future use
135
+
136
+ // Count expectations per route
137
+ const routeExpectationCount = new Map();
138
+ for (const exp of expectations) {
139
+ const routePath = exp.fromPath || '*';
140
+ routeExpectationCount.set(routePath, (routeExpectationCount.get(routePath) || 0) + 1);
141
+ }
142
+
143
+ // Find matching route for current URL
144
+ const urlPath = extractPathFromUrl(currentUrl);
145
+ const urlPathNormalized = normalizePath(urlPath);
146
+
147
+ // Try exact match first, then prefix match (but not root '/')
148
+ const matchingRoute = routes.find(r => {
149
+ const routePath = normalizePath(r.path);
150
+ if (routePath === '*' || routePath === urlPathNormalized) {
151
+ return true;
152
+ }
153
+ // Prefix match: only if routePath is not '/' and urlPath starts with routePath + '/'
154
+ if (routePath !== '/' && urlPathNormalized.startsWith(routePath + '/')) {
155
+ return true;
156
+ }
157
+ return false;
158
+ });
159
+
160
+ const routePath = matchingRoute?.path || urlPath || '*';
161
+ const expectationsForRoute = routeExpectationCount.get(routePath) || 0;
162
+
163
+ // Deterministic budget allocation:
164
+ // - Base: baseBudget.maxInteractionsPerPage
165
+ // - Critical routes (with expectations) get 1.5x budget
166
+ // - Non-critical routes get 0.7x budget
167
+ // - If total routes > 50, reduce all budgets proportionally
168
+
169
+ let interactionBudget = baseBudget.maxInteractionsPerPage || 30;
170
+
171
+ if (expectationsForRoute > 0) {
172
+ // Critical route: has expectations
173
+ interactionBudget = Math.floor(interactionBudget * 1.5);
174
+ } else if (totalRoutes > 10) {
175
+ // Non-critical route in large project: reduce budget
176
+ interactionBudget = Math.floor(interactionBudget * 0.7);
177
+ }
178
+
179
+ // Scale down if project is very large
180
+ if (totalRoutes > 50) {
181
+ const scaleFactor = Math.max(0.6, 50 / totalRoutes);
182
+ interactionBudget = Math.floor(interactionBudget * scaleFactor);
183
+ }
184
+
185
+ // Ensure minimum budget
186
+ interactionBudget = Math.max(5, interactionBudget);
187
+
188
+ // Ensure maximum budget cap
189
+ interactionBudget = Math.min(100, interactionBudget);
190
+
191
+ return {
192
+ ...baseBudget,
193
+ maxInteractionsPerPage: interactionBudget,
194
+ routePath: routePath,
195
+ expectationsForRoute: expectationsForRoute,
196
+ budgetReason: expectationsForRoute > 0 ? 'critical_route' : (totalRoutes > 10 ? 'non_critical_large_project' : 'default')
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Extract path from URL
202
+ */
203
+ function extractPathFromUrl(url) {
204
+ try {
205
+ const urlObj = new URL(url);
206
+ return urlObj.pathname;
207
+ } catch {
208
+ return url;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Normalize path for comparison
214
+ */
215
+ function normalizePath(path) {
216
+ if (!path) return '/';
217
+ return path.replace(/\/$/, '') || '/';
218
+ }