@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +141 -0
  4. package/bin/guardian.js +690 -0
  5. package/flows/example-login-flow.json +36 -0
  6. package/flows/example-signup-flow.json +44 -0
  7. package/guardian-contract-v1.md +149 -0
  8. package/guardian.config.json +54 -0
  9. package/guardian.policy.json +12 -0
  10. package/guardian.profile.docs.yaml +18 -0
  11. package/guardian.profile.ecommerce.yaml +17 -0
  12. package/guardian.profile.marketing.yaml +18 -0
  13. package/guardian.profile.saas.yaml +21 -0
  14. package/package.json +69 -0
  15. package/policies/enterprise.json +12 -0
  16. package/policies/saas.json +12 -0
  17. package/policies/startup.json +12 -0
  18. package/src/guardian/attempt-engine.js +454 -0
  19. package/src/guardian/attempt-registry.js +227 -0
  20. package/src/guardian/attempt-reporter.js +507 -0
  21. package/src/guardian/attempt.js +227 -0
  22. package/src/guardian/auto-attempt-builder.js +283 -0
  23. package/src/guardian/baseline-reporter.js +143 -0
  24. package/src/guardian/baseline-storage.js +285 -0
  25. package/src/guardian/baseline.js +492 -0
  26. package/src/guardian/behavioral-signals.js +261 -0
  27. package/src/guardian/breakage-intelligence.js +223 -0
  28. package/src/guardian/browser.js +92 -0
  29. package/src/guardian/cli-summary.js +141 -0
  30. package/src/guardian/crawler.js +142 -0
  31. package/src/guardian/discovery-engine.js +661 -0
  32. package/src/guardian/enhanced-html-reporter.js +305 -0
  33. package/src/guardian/failure-taxonomy.js +169 -0
  34. package/src/guardian/flow-executor.js +374 -0
  35. package/src/guardian/flow-registry.js +67 -0
  36. package/src/guardian/html-reporter.js +414 -0
  37. package/src/guardian/index.js +218 -0
  38. package/src/guardian/init-command.js +139 -0
  39. package/src/guardian/junit-reporter.js +264 -0
  40. package/src/guardian/market-criticality.js +335 -0
  41. package/src/guardian/market-reporter.js +305 -0
  42. package/src/guardian/network-trace.js +178 -0
  43. package/src/guardian/policy.js +357 -0
  44. package/src/guardian/preset-loader.js +148 -0
  45. package/src/guardian/reality.js +547 -0
  46. package/src/guardian/reporter.js +181 -0
  47. package/src/guardian/root-cause-analysis.js +171 -0
  48. package/src/guardian/safety.js +248 -0
  49. package/src/guardian/scan-presets.js +60 -0
  50. package/src/guardian/screenshot.js +152 -0
  51. package/src/guardian/sitemap.js +225 -0
  52. package/src/guardian/snapshot-schema.js +266 -0
  53. package/src/guardian/snapshot.js +327 -0
  54. package/src/guardian/validators.js +323 -0
  55. package/src/guardian/visual-diff.js +247 -0
  56. 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
+ };