@odavl/guardian 0.1.0-rc1 → 1.0.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/CHANGELOG.md +146 -0
- package/README.md +155 -97
- package/bin/guardian.js +1544 -55
- package/config/README.md +59 -0
- package/config/profiles/landing-demo.yaml +16 -0
- package/package.json +26 -11
- package/policies/landing-demo.json +22 -0
- package/src/enterprise/audit-logger.js +166 -0
- package/src/enterprise/pdf-exporter.js +267 -0
- package/src/enterprise/rbac-gate.js +142 -0
- package/src/enterprise/rbac.js +239 -0
- package/src/enterprise/site-manager.js +180 -0
- package/src/founder/feedback-system.js +156 -0
- package/src/founder/founder-tracker.js +213 -0
- package/src/founder/usage-signals.js +141 -0
- package/src/guardian/alert-ledger.js +121 -0
- package/src/guardian/attempt-engine.js +587 -12
- package/src/guardian/attempt-registry.js +42 -1
- package/src/guardian/attempt-relevance.js +106 -0
- package/src/guardian/attempt.js +85 -39
- package/src/guardian/attempts-filter.js +63 -0
- package/src/guardian/baseline.js +50 -8
- package/src/guardian/breakage-intelligence.js +1 -0
- package/src/guardian/browser-pool.js +131 -0
- package/src/guardian/browser.js +28 -1
- package/src/guardian/ci-cli.js +121 -0
- package/src/guardian/ci-mode.js +15 -0
- package/src/guardian/ci-output.js +38 -0
- package/src/guardian/cli-summary.js +167 -67
- package/src/guardian/config-loader.js +162 -0
- package/src/guardian/data-guardian-detector.js +189 -0
- package/src/guardian/detection-layers.js +271 -0
- package/src/guardian/drift-detector.js +100 -0
- package/src/guardian/enhanced-html-reporter.js +221 -4
- package/src/guardian/env-guard.js +127 -0
- package/src/guardian/failure-intelligence.js +173 -0
- package/src/guardian/first-run-profile.js +89 -0
- package/src/guardian/first-run.js +54 -0
- package/src/guardian/flag-validator.js +111 -0
- package/src/guardian/flow-executor.js +309 -44
- package/src/guardian/html-reporter.js +2 -0
- package/src/guardian/human-reporter.js +431 -0
- package/src/guardian/index.js +22 -19
- package/src/guardian/init-command.js +9 -5
- package/src/guardian/intent-detector.js +146 -0
- package/src/guardian/journey-definitions.js +132 -0
- package/src/guardian/journey-scan-cli.js +145 -0
- package/src/guardian/journey-scanner.js +583 -0
- package/src/guardian/junit-reporter.js +18 -1
- package/src/guardian/language-detection.js +99 -0
- package/src/guardian/live-cli.js +95 -0
- package/src/guardian/live-scheduler-runner.js +137 -0
- package/src/guardian/live-scheduler.js +146 -0
- package/src/guardian/market-reporter.js +357 -82
- package/src/guardian/parallel-executor.js +116 -0
- package/src/guardian/pattern-analyzer.js +348 -0
- package/src/guardian/policy.js +80 -3
- package/src/guardian/prerequisite-checker.js +101 -0
- package/src/guardian/preset-loader.js +27 -18
- package/src/guardian/profile-loader.js +96 -0
- package/src/guardian/reality.js +1612 -115
- package/src/guardian/reporter.js +27 -41
- package/src/guardian/run-artifacts.js +212 -0
- package/src/guardian/run-cleanup.js +207 -0
- package/src/guardian/run-latest.js +90 -0
- package/src/guardian/run-list.js +211 -0
- package/src/guardian/run-summary.js +20 -0
- package/src/guardian/scan-presets.js +100 -11
- package/src/guardian/selector-fallbacks.js +394 -0
- package/src/guardian/semantic-contact-detection.js +255 -0
- package/src/guardian/semantic-contact-finder.js +201 -0
- package/src/guardian/semantic-targets.js +234 -0
- package/src/guardian/site-introspection.js +257 -0
- package/src/guardian/smoke.js +258 -0
- package/src/guardian/snapshot-schema.js +25 -1
- package/src/guardian/snapshot.js +69 -3
- package/src/guardian/stability-scorer.js +169 -0
- package/src/guardian/success-evaluator.js +214 -0
- package/src/guardian/template-command.js +184 -0
- package/src/guardian/text-formatters.js +426 -0
- package/src/guardian/timeout-profiles.js +57 -0
- package/src/guardian/verdict.js +320 -0
- package/src/guardian/verdicts.js +74 -0
- package/src/guardian/wait-for-outcome.js +120 -0
- package/src/guardian/watch-runner.js +181 -0
- package/src/payments/stripe-checkout.js +169 -0
- package/src/plans/plan-definitions.js +148 -0
- package/src/plans/plan-manager.js +211 -0
- package/src/plans/usage-tracker.js +210 -0
- package/src/recipes/recipe-engine.js +188 -0
- package/src/recipes/recipe-failure-analysis.js +159 -0
- package/src/recipes/recipe-registry.js +134 -0
- package/src/recipes/recipe-runtime.js +507 -0
- package/src/recipes/recipe-store.js +410 -0
- package/guardian-contract-v1.md +0 -149
- /package/{guardian.config.json → config/guardian.config.json} +0 -0
- /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
- /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
- /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
- /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
- /package/{guardian.profile.saas.yaml → config/profiles/saas.yaml} +0 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ODAVL Guardian Usage Tracker
|
|
3
|
+
* Tracks scans per month and enforces plan limits
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
const USAGE_DIR = path.join(os.homedir(), '.odavl-guardian', 'usage');
|
|
11
|
+
const USAGE_FILE = path.join(USAGE_DIR, 'usage.json');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ensure usage directory exists
|
|
15
|
+
*/
|
|
16
|
+
function ensureUsageDir() {
|
|
17
|
+
if (!fs.existsSync(USAGE_DIR)) {
|
|
18
|
+
fs.mkdirSync(USAGE_DIR, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get current month key (YYYY-MM)
|
|
24
|
+
*/
|
|
25
|
+
function getCurrentMonthKey() {
|
|
26
|
+
const now = new Date();
|
|
27
|
+
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load usage data
|
|
32
|
+
*/
|
|
33
|
+
function loadUsage() {
|
|
34
|
+
ensureUsageDir();
|
|
35
|
+
|
|
36
|
+
if (!fs.existsSync(USAGE_FILE)) {
|
|
37
|
+
return {
|
|
38
|
+
currentMonth: getCurrentMonthKey(),
|
|
39
|
+
scansThisMonth: 0,
|
|
40
|
+
sites: [],
|
|
41
|
+
totalScans: 0,
|
|
42
|
+
history: {},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const data = JSON.parse(fs.readFileSync(USAGE_FILE, 'utf-8'));
|
|
48
|
+
|
|
49
|
+
// Reset if new month
|
|
50
|
+
const currentMonth = getCurrentMonthKey();
|
|
51
|
+
if (data.currentMonth !== currentMonth) {
|
|
52
|
+
// Archive old month
|
|
53
|
+
data.history[data.currentMonth] = {
|
|
54
|
+
scans: data.scansThisMonth,
|
|
55
|
+
sites: data.sites.length,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Reset for new month
|
|
59
|
+
data.currentMonth = currentMonth;
|
|
60
|
+
data.scansThisMonth = 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return data;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error('Error loading usage data:', error.message);
|
|
66
|
+
return {
|
|
67
|
+
currentMonth: getCurrentMonthKey(),
|
|
68
|
+
scansThisMonth: 0,
|
|
69
|
+
sites: [],
|
|
70
|
+
totalScans: 0,
|
|
71
|
+
history: {},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Save usage data
|
|
78
|
+
*/
|
|
79
|
+
function saveUsage(usage) {
|
|
80
|
+
ensureUsageDir();
|
|
81
|
+
fs.writeFileSync(USAGE_FILE, JSON.stringify(usage, null, 2), 'utf-8');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Record a scan
|
|
86
|
+
*/
|
|
87
|
+
function recordScan(url) {
|
|
88
|
+
const usage = loadUsage();
|
|
89
|
+
|
|
90
|
+
// Extract domain from URL
|
|
91
|
+
let domain;
|
|
92
|
+
try {
|
|
93
|
+
const urlObj = new URL(url);
|
|
94
|
+
domain = urlObj.hostname;
|
|
95
|
+
} catch {
|
|
96
|
+
domain = url;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Increment scan count
|
|
100
|
+
usage.scansThisMonth += 1;
|
|
101
|
+
usage.totalScans += 1;
|
|
102
|
+
|
|
103
|
+
// Track unique site
|
|
104
|
+
if (!usage.sites.includes(domain)) {
|
|
105
|
+
usage.sites.push(domain);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Record timestamp
|
|
109
|
+
if (!usage.scanHistory) {
|
|
110
|
+
usage.scanHistory = [];
|
|
111
|
+
}
|
|
112
|
+
usage.scanHistory.push({
|
|
113
|
+
url,
|
|
114
|
+
domain,
|
|
115
|
+
timestamp: new Date().toISOString(),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Keep only last 100 scans in history
|
|
119
|
+
if (usage.scanHistory.length > 100) {
|
|
120
|
+
usage.scanHistory = usage.scanHistory.slice(-100);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
saveUsage(usage);
|
|
124
|
+
return usage;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get current usage stats
|
|
129
|
+
*/
|
|
130
|
+
function getUsageStats() {
|
|
131
|
+
return loadUsage();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get scans remaining for current plan
|
|
136
|
+
*/
|
|
137
|
+
function getScansRemaining(maxScans) {
|
|
138
|
+
const usage = loadUsage();
|
|
139
|
+
if (maxScans === -1) return 'Unlimited';
|
|
140
|
+
return Math.max(0, maxScans - usage.scansThisMonth);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Check if can perform scan
|
|
145
|
+
*/
|
|
146
|
+
function canPerformScan(maxScans) {
|
|
147
|
+
if (maxScans === -1) return true; // unlimited
|
|
148
|
+
const usage = loadUsage();
|
|
149
|
+
return usage.scansThisMonth < maxScans;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Check if can add site
|
|
154
|
+
*/
|
|
155
|
+
function canAddSite(url, maxSites) {
|
|
156
|
+
if (maxSites === -1) return true; // unlimited
|
|
157
|
+
|
|
158
|
+
const usage = loadUsage();
|
|
159
|
+
|
|
160
|
+
// Extract domain
|
|
161
|
+
let domain;
|
|
162
|
+
try {
|
|
163
|
+
const urlObj = new URL(url);
|
|
164
|
+
domain = urlObj.hostname;
|
|
165
|
+
} catch {
|
|
166
|
+
domain = url;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// If site already tracked, it's OK
|
|
170
|
+
if (usage.sites.includes(domain)) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Check if under limit
|
|
175
|
+
return usage.sites.length < maxSites;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Reset usage (for testing or manual reset)
|
|
180
|
+
*/
|
|
181
|
+
function resetUsage() {
|
|
182
|
+
ensureUsageDir();
|
|
183
|
+
const usage = {
|
|
184
|
+
currentMonth: getCurrentMonthKey(),
|
|
185
|
+
scansThisMonth: 0,
|
|
186
|
+
sites: [],
|
|
187
|
+
totalScans: 0,
|
|
188
|
+
history: {},
|
|
189
|
+
scanHistory: [],
|
|
190
|
+
};
|
|
191
|
+
saveUsage(usage);
|
|
192
|
+
return usage;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get usage file path (for debugging)
|
|
197
|
+
*/
|
|
198
|
+
function getUsageFilePath() {
|
|
199
|
+
return USAGE_FILE;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = {
|
|
203
|
+
recordScan,
|
|
204
|
+
getUsageStats,
|
|
205
|
+
getScansRemaining,
|
|
206
|
+
canPerformScan,
|
|
207
|
+
canAddSite,
|
|
208
|
+
resetUsage,
|
|
209
|
+
getUsageFilePath,
|
|
210
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 12.1: Recipe Engine
|
|
3
|
+
* Core recipe execution and validation system
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validate recipe schema
|
|
11
|
+
*/
|
|
12
|
+
function validateRecipe(recipe) {
|
|
13
|
+
const errors = [];
|
|
14
|
+
|
|
15
|
+
if (!recipe.id || typeof recipe.id !== 'string') {
|
|
16
|
+
errors.push('Recipe must have a string id');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!recipe.name || typeof recipe.name !== 'string') {
|
|
20
|
+
errors.push('Recipe must have a string name');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!recipe.platform || typeof recipe.platform !== 'string') {
|
|
24
|
+
errors.push('Recipe must have a platform (shopify|saas|landing)');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!['shopify', 'saas', 'landing'].includes(recipe.platform)) {
|
|
28
|
+
errors.push(`Invalid platform: ${recipe.platform}. Must be shopify, saas, or landing`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!recipe.intent || typeof recipe.intent !== 'string') {
|
|
32
|
+
errors.push('Recipe must have an intent string');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!Array.isArray(recipe.steps) || recipe.steps.length === 0) {
|
|
36
|
+
errors.push('Recipe must have at least one step');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (recipe.steps && !recipe.steps.every(s => typeof s === 'string')) {
|
|
40
|
+
errors.push('All recipe steps must be strings');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!recipe.expectedGoal || typeof recipe.expectedGoal !== 'string') {
|
|
44
|
+
errors.push('Recipe must have an expectedGoal');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
valid: errors.length === 0,
|
|
49
|
+
errors
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get recipe complexity score
|
|
55
|
+
*/
|
|
56
|
+
function getComplexityScore(recipe) {
|
|
57
|
+
const stepCount = recipe.steps.length;
|
|
58
|
+
|
|
59
|
+
if (stepCount <= 3) return 'simple';
|
|
60
|
+
if (stepCount <= 7) return 'moderate';
|
|
61
|
+
return 'complex';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get estimated execution time (seconds)
|
|
66
|
+
*/
|
|
67
|
+
function getEstimatedTime(recipe) {
|
|
68
|
+
const stepCount = recipe.steps.length;
|
|
69
|
+
const complexity = getComplexityScore(recipe);
|
|
70
|
+
|
|
71
|
+
// Base time per step
|
|
72
|
+
let baseTime = 5;
|
|
73
|
+
|
|
74
|
+
if (complexity === 'complex') {
|
|
75
|
+
baseTime = 8;
|
|
76
|
+
} else if (complexity === 'moderate') {
|
|
77
|
+
baseTime = 6;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return stepCount * baseTime;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Create recipe execution context
|
|
85
|
+
*/
|
|
86
|
+
function createExecutionContext(recipe, baseUrl, options = {}) {
|
|
87
|
+
return {
|
|
88
|
+
recipe,
|
|
89
|
+
baseUrl,
|
|
90
|
+
startedAt: new Date(),
|
|
91
|
+
steps: [],
|
|
92
|
+
state: {
|
|
93
|
+
currentUrl: baseUrl,
|
|
94
|
+
pageTitle: null,
|
|
95
|
+
pageUrl: null,
|
|
96
|
+
lastElement: null,
|
|
97
|
+
data: options.data || {}
|
|
98
|
+
},
|
|
99
|
+
options,
|
|
100
|
+
success: false,
|
|
101
|
+
error: null,
|
|
102
|
+
completedAt: null,
|
|
103
|
+
duration: 0
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Record step execution
|
|
109
|
+
*/
|
|
110
|
+
function recordStep(context, stepIndex, action, result) {
|
|
111
|
+
const step = {
|
|
112
|
+
index: stepIndex,
|
|
113
|
+
action,
|
|
114
|
+
result,
|
|
115
|
+
timestamp: new Date().toISOString(),
|
|
116
|
+
duration: 0
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
context.steps.push(step);
|
|
120
|
+
return step;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Mark execution complete
|
|
125
|
+
*/
|
|
126
|
+
function markComplete(context, success, error = null) {
|
|
127
|
+
context.success = success;
|
|
128
|
+
context.error = error;
|
|
129
|
+
context.completedAt = new Date();
|
|
130
|
+
context.duration = Math.round(
|
|
131
|
+
(context.completedAt - context.startedAt) / 1000
|
|
132
|
+
);
|
|
133
|
+
return context;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Generate human-readable recipe summary
|
|
138
|
+
*/
|
|
139
|
+
function getRecipeSummary(recipe) {
|
|
140
|
+
return {
|
|
141
|
+
id: recipe.id,
|
|
142
|
+
name: recipe.name,
|
|
143
|
+
platform: recipe.platform,
|
|
144
|
+
intent: recipe.intent,
|
|
145
|
+
stepCount: recipe.steps.length,
|
|
146
|
+
complexity: getComplexityScore(recipe),
|
|
147
|
+
estimatedTime: getEstimatedTime(recipe),
|
|
148
|
+
expectedGoal: recipe.expectedGoal
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Format recipe for display
|
|
154
|
+
*/
|
|
155
|
+
function formatRecipe(recipe) {
|
|
156
|
+
const summary = getRecipeSummary(recipe);
|
|
157
|
+
|
|
158
|
+
let output = '';
|
|
159
|
+
output += `📋 ${summary.name}\n`;
|
|
160
|
+
output += ` Platform: ${summary.platform}\n`;
|
|
161
|
+
output += ` Intent: ${summary.intent}\n`;
|
|
162
|
+
output += ` Complexity: ${summary.complexity}\n`;
|
|
163
|
+
output += ` Steps: ${summary.stepCount}\n`;
|
|
164
|
+
output += ` Estimated Time: ${summary.estimatedTime}s\n`;
|
|
165
|
+
output += ` Expected Goal: ${summary.expectedGoal}\n`;
|
|
166
|
+
|
|
167
|
+
if (recipe.notes) {
|
|
168
|
+
output += ` Notes: ${recipe.notes}\n`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
output += `\n Steps:\n`;
|
|
172
|
+
recipe.steps.forEach((step, i) => {
|
|
173
|
+
output += ` ${i + 1}. ${step}\n`;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return output;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = {
|
|
180
|
+
validateRecipe,
|
|
181
|
+
getComplexityScore,
|
|
182
|
+
getEstimatedTime,
|
|
183
|
+
createExecutionContext,
|
|
184
|
+
recordStep,
|
|
185
|
+
markComplete,
|
|
186
|
+
getRecipeSummary,
|
|
187
|
+
formatRecipe
|
|
188
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe Failure Analysis & Reporting
|
|
3
|
+
*
|
|
4
|
+
* Integrates recipe failures into the decision engine and breakage intelligence.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Analyze a recipe execution failure
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} recipeResult - Result from executeRecipeRuntime
|
|
11
|
+
* @returns {Object} Intelligence object for reporting
|
|
12
|
+
*/
|
|
13
|
+
function analyzeRecipeFailure(recipeResult) {
|
|
14
|
+
if (recipeResult.success) {
|
|
15
|
+
return null; // No failure to analyze
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { failedStep, failureReason, steps, recipe, recipeName, baseUrl } = recipeResult;
|
|
19
|
+
|
|
20
|
+
// Categorize failure type
|
|
21
|
+
let failureType = 'EXECUTION';
|
|
22
|
+
let severity = 'CRITICAL';
|
|
23
|
+
|
|
24
|
+
if (failureReason.includes('not found')) {
|
|
25
|
+
failureType = 'ELEMENT_NOT_FOUND';
|
|
26
|
+
} else if (failureReason.includes('not visible')) {
|
|
27
|
+
failureType = 'ELEMENT_NOT_VISIBLE';
|
|
28
|
+
} else if (failureReason.includes('Goal not reached')) {
|
|
29
|
+
failureType = 'GOAL_FAILURE';
|
|
30
|
+
} else if (failureReason.includes('Runtime error')) {
|
|
31
|
+
failureType = 'RUNTIME_ERROR';
|
|
32
|
+
severity = 'CRITICAL';
|
|
33
|
+
} else if (failureReason.includes('Recipe not found')) {
|
|
34
|
+
failureType = 'RECIPE_NOT_FOUND';
|
|
35
|
+
severity = 'INFO';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const stepsFailed = steps.filter(s => !s.success).length;
|
|
39
|
+
const stepsTotal = steps.length;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
id: `recipe-${recipe}`,
|
|
43
|
+
name: `Recipe: ${recipeName}`,
|
|
44
|
+
outcome: 'FAILURE',
|
|
45
|
+
failureType,
|
|
46
|
+
severity,
|
|
47
|
+
baseUrl,
|
|
48
|
+
recipeId: recipe,
|
|
49
|
+
recipeName,
|
|
50
|
+
failedStepId: failedStep,
|
|
51
|
+
failedStepNumber: failedStep ? parseInt(failedStep.split('-').pop(), 10) : null,
|
|
52
|
+
failureReason,
|
|
53
|
+
stepProgress: {
|
|
54
|
+
completed: stepsTotal - stepsFailed,
|
|
55
|
+
total: stepsTotal,
|
|
56
|
+
percentage: Math.round(((stepsTotal - stepsFailed) / stepsTotal) * 100)
|
|
57
|
+
},
|
|
58
|
+
humanReadableError: formatRecipeError(recipeName, failedStep, failureReason, stepsTotal),
|
|
59
|
+
source: 'recipe-runtime',
|
|
60
|
+
behavioralSignals: [
|
|
61
|
+
{
|
|
62
|
+
signal: 'RECIPE_EXECUTION_FAILED',
|
|
63
|
+
message: failureReason,
|
|
64
|
+
severity: severity
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Format recipe error for human readability
|
|
72
|
+
*/
|
|
73
|
+
function formatRecipeError(recipeName, failedStep, failureReason, totalSteps) {
|
|
74
|
+
const stepNum = failedStep ? parseInt(failedStep.split('-').pop(), 10) + 1 : 0;
|
|
75
|
+
|
|
76
|
+
return `Recipe '${recipeName}' failed at step ${stepNum} of ${totalSteps}: ${failureReason}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Convert recipe failure to attempt-like structure for reporting compatibility
|
|
81
|
+
*/
|
|
82
|
+
function recipeFailureToAttempt(recipeResult) {
|
|
83
|
+
const intelligence = analyzeRecipeFailure(recipeResult);
|
|
84
|
+
if (!intelligence) return null;
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
attemptId: intelligence.id,
|
|
88
|
+
attemptName: intelligence.name,
|
|
89
|
+
outcome: 'FAILURE',
|
|
90
|
+
error: intelligence.failureReason,
|
|
91
|
+
failureType: intelligence.failureType,
|
|
92
|
+
severity: intelligence.severity,
|
|
93
|
+
baseUrl: intelligence.baseUrl,
|
|
94
|
+
riskCategory: 'TRUST', // Recipes are trust/integrity checks
|
|
95
|
+
source: 'recipe',
|
|
96
|
+
validators: [
|
|
97
|
+
{
|
|
98
|
+
id: 'recipe-step-validation',
|
|
99
|
+
name: 'Recipe Step Execution',
|
|
100
|
+
passed: false,
|
|
101
|
+
evidence: recipeResult.evidence
|
|
102
|
+
}
|
|
103
|
+
],
|
|
104
|
+
behavioralSignals: intelligence.behavioralSignals
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Integrate recipe failures into market impact analysis
|
|
110
|
+
*/
|
|
111
|
+
function assessRecipeImpact(recipeResult) {
|
|
112
|
+
if (recipeResult.success) {
|
|
113
|
+
return {
|
|
114
|
+
hasRisk: false,
|
|
115
|
+
riskScore: 0,
|
|
116
|
+
severity: 'INFO',
|
|
117
|
+
message: `Recipe '${recipeResult.recipeName}' passed successfully`
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const intelligence = analyzeRecipeFailure(recipeResult);
|
|
122
|
+
|
|
123
|
+
// Recipe failures always elevate risk
|
|
124
|
+
let riskScore = 70; // Base critical risk
|
|
125
|
+
let severity = 'CRITICAL';
|
|
126
|
+
|
|
127
|
+
// Adjust based on step completion
|
|
128
|
+
const progress = intelligence.stepProgress.percentage;
|
|
129
|
+
if (progress === 0) {
|
|
130
|
+
riskScore = 90; // Failed on first step — very bad
|
|
131
|
+
} else if (progress < 50) {
|
|
132
|
+
riskScore = 80; // Failed early
|
|
133
|
+
} else {
|
|
134
|
+
riskScore = 60; // Failed late but still indicates issue
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Type-specific scoring
|
|
138
|
+
if (intelligence.failureType === 'GOAL_FAILURE') {
|
|
139
|
+
riskScore = Math.max(riskScore - 10, 50); // Goal failure less severe than execution error
|
|
140
|
+
severity = 'WARNING';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
hasRisk: true,
|
|
145
|
+
riskScore,
|
|
146
|
+
severity,
|
|
147
|
+
message: intelligence.humanReadableError,
|
|
148
|
+
recipe: intelligence.recipeId,
|
|
149
|
+
failureType: intelligence.failureType,
|
|
150
|
+
stepProgress: intelligence.stepProgress
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = {
|
|
155
|
+
analyzeRecipeFailure,
|
|
156
|
+
formatRecipeError,
|
|
157
|
+
recipeFailureToAttempt,
|
|
158
|
+
assessRecipeImpact
|
|
159
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 12.2: Recipe Registry
|
|
3
|
+
* Local-first registry tracking provenance and integrity of recipes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
|
|
11
|
+
const RECIPES_DIR = path.join(os.homedir(), '.odavl-guardian', 'recipes');
|
|
12
|
+
const REGISTRY_FILE = path.join(RECIPES_DIR, 'registry.json');
|
|
13
|
+
|
|
14
|
+
function ensureRecipesDir() {
|
|
15
|
+
if (!fs.existsSync(RECIPES_DIR)) {
|
|
16
|
+
fs.mkdirSync(RECIPES_DIR, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function stableStringify(value) {
|
|
21
|
+
if (value === null || typeof value !== 'object') {
|
|
22
|
+
return JSON.stringify(value);
|
|
23
|
+
}
|
|
24
|
+
if (Array.isArray(value)) {
|
|
25
|
+
return '[' + value.map(v => stableStringify(v)).join(',') + ']';
|
|
26
|
+
}
|
|
27
|
+
const keys = Object.keys(value).sort();
|
|
28
|
+
const entries = keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`);
|
|
29
|
+
return '{' + entries.join(',') + '}';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function computeRecipeChecksum(recipe) {
|
|
33
|
+
const normalized = stableStringify(recipe);
|
|
34
|
+
return crypto.createHash('sha256').update(normalized).digest('hex');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function loadRegistry() {
|
|
38
|
+
ensureRecipesDir();
|
|
39
|
+
if (!fs.existsSync(REGISTRY_FILE)) {
|
|
40
|
+
return { entries: [], updatedAt: null };
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(fs.readFileSync(REGISTRY_FILE, 'utf-8'));
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return { entries: [], updatedAt: null };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function saveRegistry(registry) {
|
|
50
|
+
ensureRecipesDir();
|
|
51
|
+
const data = {
|
|
52
|
+
entries: registry.entries || [],
|
|
53
|
+
updatedAt: new Date().toISOString(),
|
|
54
|
+
};
|
|
55
|
+
fs.writeFileSync(REGISTRY_FILE, JSON.stringify(data, null, 2), 'utf-8');
|
|
56
|
+
return data;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ensureBuiltInRegistry(builtIns) {
|
|
60
|
+
const registry = loadRegistry();
|
|
61
|
+
let changed = false;
|
|
62
|
+
for (const recipe of builtIns) {
|
|
63
|
+
if (!registry.entries.some(e => e.id === recipe.id)) {
|
|
64
|
+
registry.entries.push({
|
|
65
|
+
id: recipe.id,
|
|
66
|
+
name: recipe.name,
|
|
67
|
+
platform: recipe.platform,
|
|
68
|
+
version: recipe.version || '1.0.0',
|
|
69
|
+
source: 'builtin',
|
|
70
|
+
checksum: computeRecipeChecksum(recipe),
|
|
71
|
+
addedAt: new Date().toISOString(),
|
|
72
|
+
});
|
|
73
|
+
changed = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (changed) {
|
|
77
|
+
saveRegistry(registry);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function registerRecipe({ id, name, platform, version, source, checksum }) {
|
|
82
|
+
const registry = loadRegistry();
|
|
83
|
+
const existing = registry.entries.find(e => e.id === id);
|
|
84
|
+
if (existing) {
|
|
85
|
+
Object.assign(existing, { name, platform, version, source, checksum });
|
|
86
|
+
} else {
|
|
87
|
+
registry.entries.push({
|
|
88
|
+
id,
|
|
89
|
+
name,
|
|
90
|
+
platform,
|
|
91
|
+
version: version || '1.0.0',
|
|
92
|
+
source: source || 'imported',
|
|
93
|
+
checksum,
|
|
94
|
+
addedAt: new Date().toISOString(),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return saveRegistry(registry);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function removeRegistryEntry(id) {
|
|
101
|
+
const registry = loadRegistry();
|
|
102
|
+
const next = registry.entries.filter(e => e.id !== id);
|
|
103
|
+
registry.entries = next;
|
|
104
|
+
return saveRegistry(registry);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getRegistryEntry(id) {
|
|
108
|
+
const registry = loadRegistry();
|
|
109
|
+
return registry.entries.find(e => e.id === id);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function listRegistryEntries() {
|
|
113
|
+
const registry = loadRegistry();
|
|
114
|
+
return registry.entries;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function resetRegistry() {
|
|
118
|
+
ensureRecipesDir();
|
|
119
|
+
if (fs.existsSync(REGISTRY_FILE)) {
|
|
120
|
+
fs.unlinkSync(REGISTRY_FILE);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
computeRecipeChecksum,
|
|
126
|
+
loadRegistry,
|
|
127
|
+
saveRegistry,
|
|
128
|
+
registerRecipe,
|
|
129
|
+
ensureBuiltInRegistry,
|
|
130
|
+
getRegistryEntry,
|
|
131
|
+
listRegistryEntries,
|
|
132
|
+
removeRegistryEntry,
|
|
133
|
+
resetRegistry,
|
|
134
|
+
};
|