@odavl/guardian 0.1.0-rc1
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 +20 -0
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/bin/guardian.js +690 -0
- package/flows/example-login-flow.json +36 -0
- package/flows/example-signup-flow.json +44 -0
- package/guardian-contract-v1.md +149 -0
- package/guardian.config.json +54 -0
- package/guardian.policy.json +12 -0
- package/guardian.profile.docs.yaml +18 -0
- package/guardian.profile.ecommerce.yaml +17 -0
- package/guardian.profile.marketing.yaml +18 -0
- package/guardian.profile.saas.yaml +21 -0
- package/package.json +69 -0
- package/policies/enterprise.json +12 -0
- package/policies/saas.json +12 -0
- package/policies/startup.json +12 -0
- package/src/guardian/attempt-engine.js +454 -0
- package/src/guardian/attempt-registry.js +227 -0
- package/src/guardian/attempt-reporter.js +507 -0
- package/src/guardian/attempt.js +227 -0
- package/src/guardian/auto-attempt-builder.js +283 -0
- package/src/guardian/baseline-reporter.js +143 -0
- package/src/guardian/baseline-storage.js +285 -0
- package/src/guardian/baseline.js +492 -0
- package/src/guardian/behavioral-signals.js +261 -0
- package/src/guardian/breakage-intelligence.js +223 -0
- package/src/guardian/browser.js +92 -0
- package/src/guardian/cli-summary.js +141 -0
- package/src/guardian/crawler.js +142 -0
- package/src/guardian/discovery-engine.js +661 -0
- package/src/guardian/enhanced-html-reporter.js +305 -0
- package/src/guardian/failure-taxonomy.js +169 -0
- package/src/guardian/flow-executor.js +374 -0
- package/src/guardian/flow-registry.js +67 -0
- package/src/guardian/html-reporter.js +414 -0
- package/src/guardian/index.js +218 -0
- package/src/guardian/init-command.js +139 -0
- package/src/guardian/junit-reporter.js +264 -0
- package/src/guardian/market-criticality.js +335 -0
- package/src/guardian/market-reporter.js +305 -0
- package/src/guardian/network-trace.js +178 -0
- package/src/guardian/policy.js +357 -0
- package/src/guardian/preset-loader.js +148 -0
- package/src/guardian/reality.js +547 -0
- package/src/guardian/reporter.js +181 -0
- package/src/guardian/root-cause-analysis.js +171 -0
- package/src/guardian/safety.js +248 -0
- package/src/guardian/scan-presets.js +60 -0
- package/src/guardian/screenshot.js +152 -0
- package/src/guardian/sitemap.js +225 -0
- package/src/guardian/snapshot-schema.js +266 -0
- package/src/guardian/snapshot.js +327 -0
- package/src/guardian/validators.js +323 -0
- package/src/guardian/visual-diff.js +247 -0
- package/src/guardian/webhook.js +206 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Baseline Storage Management
|
|
3
|
+
* Handles persistent baseline storage with URL-safe paths and atomic writes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
|
|
10
|
+
const DEFAULT_STORAGE_DIR = '.odavl-guardian';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Convert URL to safe filename slug
|
|
14
|
+
* Example: https://example.com → example-com-<hash>
|
|
15
|
+
*/
|
|
16
|
+
function urlToSlug(url) {
|
|
17
|
+
try {
|
|
18
|
+
const parsed = new URL(url);
|
|
19
|
+
let slug = `${parsed.hostname || 'unknown'}`;
|
|
20
|
+
|
|
21
|
+
// Add port if non-standard
|
|
22
|
+
if (parsed.port && !['80', '443'].includes(parsed.port)) {
|
|
23
|
+
slug += `-${parsed.port}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Add path if present (sanitized)
|
|
27
|
+
if (parsed.pathname && parsed.pathname !== '/') {
|
|
28
|
+
const pathSegment = parsed.pathname
|
|
29
|
+
.replace(/\//g, '-')
|
|
30
|
+
.replace(/[^a-z0-9\-]/gi, '')
|
|
31
|
+
.substring(0, 30);
|
|
32
|
+
if (pathSegment) slug += `-${pathSegment}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Add hash of full URL for collision safety
|
|
36
|
+
const hash = crypto
|
|
37
|
+
.createHash('sha256')
|
|
38
|
+
.update(url)
|
|
39
|
+
.digest('hex')
|
|
40
|
+
.substring(0, 8);
|
|
41
|
+
|
|
42
|
+
slug += `-${hash}`;
|
|
43
|
+
return slug;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
// Fallback: just use hash
|
|
46
|
+
return crypto
|
|
47
|
+
.createHash('sha256')
|
|
48
|
+
.update(url)
|
|
49
|
+
.digest('hex')
|
|
50
|
+
.substring(0, 16);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get baseline storage directory for a URL
|
|
56
|
+
*/
|
|
57
|
+
function getBaselineStorageDir(url, storageDir = DEFAULT_STORAGE_DIR) {
|
|
58
|
+
const slug = urlToSlug(url);
|
|
59
|
+
return path.join(storageDir, 'baselines', slug);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get baseline file path for a URL
|
|
64
|
+
*/
|
|
65
|
+
function getBaselineFilePath(url, storageDir = DEFAULT_STORAGE_DIR) {
|
|
66
|
+
return path.join(getBaselineStorageDir(url, storageDir), 'baseline.json');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if baseline exists for URL
|
|
71
|
+
*/
|
|
72
|
+
function baselineExists(url, storageDir = DEFAULT_STORAGE_DIR) {
|
|
73
|
+
const filePath = getBaselineFilePath(url, storageDir);
|
|
74
|
+
return fs.existsSync(filePath);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Load baseline for URL
|
|
79
|
+
*/
|
|
80
|
+
function loadBaseline(url, storageDir = DEFAULT_STORAGE_DIR) {
|
|
81
|
+
const filePath = getBaselineFilePath(url, storageDir);
|
|
82
|
+
|
|
83
|
+
if (!fs.existsSync(filePath)) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const json = fs.readFileSync(filePath, 'utf8');
|
|
89
|
+
return JSON.parse(json);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
throw new Error(`Failed to load baseline from ${filePath}: ${err.message}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Save baseline for URL
|
|
97
|
+
* Uses atomic write: temp file + rename
|
|
98
|
+
*/
|
|
99
|
+
async function saveBaselineAtomic(url, baselineSnapshot, storageDir = DEFAULT_STORAGE_DIR) {
|
|
100
|
+
const filePath = getBaselineFilePath(url, storageDir);
|
|
101
|
+
const dir = path.dirname(filePath);
|
|
102
|
+
|
|
103
|
+
// Ensure directory exists
|
|
104
|
+
if (!fs.existsSync(dir)) {
|
|
105
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const tempPath = `${filePath}.tmp`;
|
|
109
|
+
const json = typeof baselineSnapshot === 'string'
|
|
110
|
+
? baselineSnapshot
|
|
111
|
+
: JSON.stringify(baselineSnapshot, null, 2);
|
|
112
|
+
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
fs.writeFile(tempPath, json, 'utf8', (err) => {
|
|
115
|
+
if (err) return reject(err);
|
|
116
|
+
|
|
117
|
+
fs.rename(tempPath, filePath, (err) => {
|
|
118
|
+
if (err) return reject(err);
|
|
119
|
+
resolve(filePath);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create a baseline snapshot from a market reality snapshot
|
|
127
|
+
*/
|
|
128
|
+
function createBaselineFromSnapshot(snapshot) {
|
|
129
|
+
if (!snapshot || !snapshot.attempts) {
|
|
130
|
+
throw new Error('Cannot create baseline from invalid snapshot');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Extract key data for baseline comparison
|
|
134
|
+
const perAttempt = {};
|
|
135
|
+
for (const attempt of snapshot.attempts) {
|
|
136
|
+
perAttempt[attempt.attemptId] = {
|
|
137
|
+
attemptId: attempt.attemptId,
|
|
138
|
+
attemptName: attempt.attemptName,
|
|
139
|
+
outcome: attempt.outcome,
|
|
140
|
+
totalDurationMs: attempt.totalDurationMs,
|
|
141
|
+
stepCount: attempt.stepCount,
|
|
142
|
+
friction: attempt.friction
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const perFlow = {};
|
|
147
|
+
for (const flow of snapshot.flows || []) {
|
|
148
|
+
perFlow[flow.flowId] = {
|
|
149
|
+
flowId: flow.flowId,
|
|
150
|
+
flowName: flow.flowName,
|
|
151
|
+
outcome: flow.outcome,
|
|
152
|
+
stepsExecuted: flow.stepsExecuted,
|
|
153
|
+
stepsTotal: flow.stepsTotal,
|
|
154
|
+
durationMs: flow.durationMs || 0,
|
|
155
|
+
error: flow.error || null
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
schemaVersion: 'v1',
|
|
161
|
+
createdAt: new Date().toISOString(),
|
|
162
|
+
url: snapshot.meta.url,
|
|
163
|
+
toolVersion: snapshot.meta.toolVersion,
|
|
164
|
+
perAttempt,
|
|
165
|
+
perFlow,
|
|
166
|
+
crawl: snapshot.crawl,
|
|
167
|
+
signals: snapshot.signals
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Compare current snapshot against baseline
|
|
173
|
+
* Returns: { regressions, improvements, attemptsDriftCount }
|
|
174
|
+
*/
|
|
175
|
+
function compareSnapshots(baselineSnapshot, currentSnapshot) {
|
|
176
|
+
const regressions = {};
|
|
177
|
+
const improvements = {};
|
|
178
|
+
let attemptsDriftCount = 0;
|
|
179
|
+
|
|
180
|
+
if (!baselineSnapshot || !baselineSnapshot.perAttempt) {
|
|
181
|
+
return { regressions: {}, improvements: {}, attemptsDriftCount: 0 };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const attempt of currentSnapshot.attempts) {
|
|
185
|
+
const attemptId = attempt.attemptId;
|
|
186
|
+
const baselineAttempt = baselineSnapshot.perAttempt[attemptId];
|
|
187
|
+
|
|
188
|
+
if (!baselineAttempt) {
|
|
189
|
+
// New attempt added to registry, not a regression
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Check outcome change
|
|
194
|
+
if (baselineAttempt.outcome !== attempt.outcome) {
|
|
195
|
+
attemptsDriftCount++;
|
|
196
|
+
|
|
197
|
+
if (
|
|
198
|
+
(baselineAttempt.outcome === 'SUCCESS' || baselineAttempt.outcome === 'FRICTION') &&
|
|
199
|
+
attempt.outcome === 'FAILURE'
|
|
200
|
+
) {
|
|
201
|
+
regressions[attemptId] = {
|
|
202
|
+
before: baselineAttempt.outcome,
|
|
203
|
+
after: attempt.outcome,
|
|
204
|
+
reason: `Outcome regressed from ${baselineAttempt.outcome} to FAILURE`
|
|
205
|
+
};
|
|
206
|
+
} else if (
|
|
207
|
+
baselineAttempt.outcome === 'FAILURE' &&
|
|
208
|
+
(attempt.outcome === 'SUCCESS' || attempt.outcome === 'FRICTION')
|
|
209
|
+
) {
|
|
210
|
+
improvements[attemptId] = {
|
|
211
|
+
before: baselineAttempt.outcome,
|
|
212
|
+
after: attempt.outcome,
|
|
213
|
+
reason: `Outcome improved from FAILURE to ${attempt.outcome}`
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Check duration increase (>20%)
|
|
219
|
+
const baseDuration = baselineAttempt.totalDurationMs || 0;
|
|
220
|
+
const currentDuration = attempt.totalDurationMs || 0;
|
|
221
|
+
|
|
222
|
+
if (baseDuration > 0) {
|
|
223
|
+
const pctChange = ((currentDuration - baseDuration) / baseDuration) * 100;
|
|
224
|
+
if (pctChange >= 20) {
|
|
225
|
+
if (regressions[attemptId]) {
|
|
226
|
+
regressions[attemptId].reason += `; duration increased by ${Math.round(pctChange)}%`;
|
|
227
|
+
} else {
|
|
228
|
+
regressions[attemptId] = {
|
|
229
|
+
before: baselineAttempt,
|
|
230
|
+
after: attempt,
|
|
231
|
+
reason: `Duration increased by ${Math.round(pctChange)}% (${baseDuration}ms → ${currentDuration}ms)`
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Compare flows
|
|
239
|
+
if (baselineSnapshot.perFlow) {
|
|
240
|
+
for (const flow of currentSnapshot.flows || []) {
|
|
241
|
+
const flowId = flow.flowId;
|
|
242
|
+
const baselineFlow = baselineSnapshot.perFlow[flowId];
|
|
243
|
+
|
|
244
|
+
if (!baselineFlow) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (baselineFlow.outcome !== flow.outcome) {
|
|
249
|
+
attemptsDriftCount++;
|
|
250
|
+
|
|
251
|
+
if (baselineFlow.outcome === 'SUCCESS' && flow.outcome === 'FAILURE') {
|
|
252
|
+
regressions[flowId] = {
|
|
253
|
+
before: baselineFlow.outcome,
|
|
254
|
+
after: flow.outcome,
|
|
255
|
+
reason: `Flow outcome regressed from ${baselineFlow.outcome} to FAILURE`
|
|
256
|
+
};
|
|
257
|
+
} else if (baselineFlow.outcome === 'FAILURE' && flow.outcome === 'SUCCESS') {
|
|
258
|
+
improvements[flowId] = {
|
|
259
|
+
before: baselineFlow.outcome,
|
|
260
|
+
after: flow.outcome,
|
|
261
|
+
reason: 'Flow outcome improved from FAILURE to SUCCESS'
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
regressions,
|
|
270
|
+
improvements,
|
|
271
|
+
attemptsDriftCount
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
module.exports = {
|
|
276
|
+
DEFAULT_STORAGE_DIR,
|
|
277
|
+
urlToSlug,
|
|
278
|
+
getBaselineStorageDir,
|
|
279
|
+
getBaselineFilePath,
|
|
280
|
+
baselineExists,
|
|
281
|
+
loadBaseline,
|
|
282
|
+
saveBaselineAtomic,
|
|
283
|
+
createBaselineFromSnapshot,
|
|
284
|
+
compareSnapshots
|
|
285
|
+
};
|