@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.
- package/README.md +123 -88
- package/bin/verax.js +11 -452
- package/package.json +24 -36
- package/src/cli/commands/default.js +681 -0
- package/src/cli/commands/doctor.js +197 -0
- package/src/cli/commands/inspect.js +109 -0
- package/src/cli/commands/run.js +586 -0
- package/src/cli/entry.js +196 -0
- package/src/cli/util/atomic-write.js +37 -0
- package/src/cli/util/detection-engine.js +297 -0
- package/src/cli/util/env-url.js +33 -0
- package/src/cli/util/errors.js +44 -0
- package/src/cli/util/events.js +110 -0
- package/src/cli/util/expectation-extractor.js +388 -0
- package/src/cli/util/findings-writer.js +32 -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 +412 -0
- package/src/cli/util/observe-writer.js +25 -0
- package/src/cli/util/paths.js +30 -0
- package/src/cli/util/project-discovery.js +297 -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/runtime-budget.js +147 -0
- package/src/cli/util/summary-writer.js +43 -0
- package/src/types/global.d.ts +28 -0
- package/src/types/ts-ast.d.ts +24 -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 +111 -0
- package/src/verax/cli/wizard.js +109 -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 +432 -0
- package/src/verax/core/incremental-store.js +245 -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 +523 -0
- package/src/verax/detect/comparison.js +7 -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 +127 -0
- package/src/verax/detect/expectation-model.js +241 -168
- 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 +41 -12
- package/src/verax/detect/flow-detector.js +366 -0
- package/src/verax/detect/index.js +200 -288
- package/src/verax/detect/interactive-findings.js +612 -0
- package/src/verax/detect/signal-mapper.js +308 -0
- package/src/verax/detect/skip-classifier.js +4 -4
- package/src/verax/detect/verdict-engine.js +561 -0
- package/src/verax/evidence-index-writer.js +61 -0
- package/src/verax/flow/flow-engine.js +3 -2
- package/src/verax/flow/flow-spec.js +1 -2
- package/src/verax/index.js +103 -15
- 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 +642 -0
- package/src/verax/intel/vue-router-extractor.js +325 -0
- package/src/verax/learn/action-contract-extractor.js +338 -104
- package/src/verax/learn/ast-contract-extractor.js +148 -6
- package/src/verax/learn/flow-extractor.js +172 -0
- package/src/verax/learn/index.js +36 -2
- package/src/verax/learn/manifest-writer.js +122 -58
- package/src/verax/learn/project-detector.js +40 -0
- package/src/verax/learn/route-extractor.js +28 -97
- package/src/verax/learn/route-validator.js +8 -7
- 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 +119 -10
- package/src/verax/learn/truth-assessor.js +24 -21
- package/src/verax/learn/ts-contract-resolver.js +14 -12
- package/src/verax/observe/aria-sensor.js +211 -0
- package/src/verax/observe/browser.js +30 -6
- package/src/verax/observe/console-sensor.js +2 -18
- package/src/verax/observe/domain-boundary.js +10 -1
- package/src/verax/observe/expectation-executor.js +513 -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 +660 -273
- package/src/verax/observe/index.js +910 -26
- package/src/verax/observe/interaction-discovery.js +378 -15
- package/src/verax/observe/interaction-runner.js +562 -197
- package/src/verax/observe/loading-sensor.js +145 -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 +38 -17
- package/src/verax/observe/state-sensor.js +393 -0
- package/src/verax/observe/state-ui-sensor.js +7 -1
- package/src/verax/observe/timing-sensor.js +228 -0
- package/src/verax/observe/traces-writer.js +73 -21
- package/src/verax/observe/ui-signal-sensor.js +143 -17
- package/src/verax/scan-summary-writer.js +80 -15
- package/src/verax/shared/artifact-manager.js +111 -9
- package/src/verax/shared/budget-profiles.js +136 -0
- package/src/verax/shared/caching.js +1 -1
- package/src/verax/shared/ci-detection.js +39 -0
- package/src/verax/shared/config-loader.js +169 -0
- package/src/verax/shared/dynamic-route-utils.js +224 -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 +9 -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 +66 -0
- 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
|
+
}
|