@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,152 @@
1
+ /**
2
+ * Guardian Screenshot Module
3
+ * Captures and saves screenshots of visited pages
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ class GuardianScreenshot {
10
+ constructor(options = {}) {
11
+ this.quality = options.quality || 80; // JPEG quality 0-100
12
+ this.fullPage = options.fullPage !== false; // Capture full page by default
13
+ this.type = options.type || 'jpeg'; // jpeg or png
14
+ this.normalizedViewport = options.normalizedViewport || { width: 1280, height: 720 }; // Phase 5: Consistent viewport
15
+ }
16
+
17
+ /**
18
+ * Normalize browser viewport for consistent screenshots (Phase 5)
19
+ * @param {Page} page - Playwright page object
20
+ * @returns {Promise<void>}
21
+ */
22
+ async normalizeViewport(page) {
23
+ try {
24
+ await page.setViewportSize({
25
+ width: this.normalizedViewport.width,
26
+ height: this.normalizedViewport.height
27
+ });
28
+ // Wait for layout to settle
29
+ await page.waitForTimeout(100);
30
+ } catch (err) {
31
+ console.warn(`⚠️ Viewport normalization failed: ${err.message}`);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Capture screenshot of current page
37
+ * @param {Page} page - Playwright page object
38
+ * @param {string} outputPath - Where to save the screenshot
39
+ * @param {object} options - Additional screenshot options
40
+ * @returns {Promise<boolean>} Success status
41
+ */
42
+ async capture(page, outputPath, options = {}) {
43
+ try {
44
+ // Phase 5: Normalize viewport for consistent visuals
45
+ if (options.normalize !== false) {
46
+ await this.normalizeViewport(page);
47
+ }
48
+
49
+ const screenshotOptions = {
50
+ path: outputPath,
51
+ type: this.type,
52
+ fullPage: options.fullPage !== undefined ? options.fullPage : this.fullPage,
53
+ };
54
+
55
+ // Add quality for JPEG
56
+ if (this.type === 'jpeg') {
57
+ screenshotOptions.quality = this.quality;
58
+ }
59
+
60
+ await page.screenshot(screenshotOptions);
61
+ return true;
62
+ } catch (error) {
63
+ console.error(`❌ Screenshot failed: ${error.message}`);
64
+ return false;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Generate filename for screenshot based on URL
70
+ * @param {string} url - Page URL
71
+ * @param {number} index - Page index
72
+ * @returns {string} Safe filename
73
+ */
74
+ generateFilename(url, index) {
75
+ try {
76
+ const urlObj = new URL(url);
77
+ let pathname = urlObj.pathname;
78
+
79
+ // Root path
80
+ if (pathname === '/' || pathname === '') {
81
+ return `page-${index}-home.${this.type}`;
82
+ }
83
+
84
+ // Clean pathname for filename
85
+ let safeName = pathname
86
+ .replace(/^\//, '') // Remove leading slash
87
+ .replace(/\/$/, '') // Remove trailing slash
88
+ .replace(/\//g, '-') // Replace slashes with dashes
89
+ .replace(/[^a-zA-Z0-9\-_.]/g, '_') // Replace unsafe chars
90
+ .substring(0, 100); // Limit length
91
+
92
+ return `page-${index}-${safeName}.${this.type}`;
93
+ } catch (error) {
94
+ // Fallback for invalid URLs
95
+ return `page-${index}-unknown.${this.type}`;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Save screenshot during crawl
101
+ * @param {Page} page - Playwright page
102
+ * @param {string} url - Current URL
103
+ * @param {number} index - Page index
104
+ * @param {string} artifactsDir - Artifacts directory
105
+ * @returns {Promise<string|null>} Path to saved screenshot or null
106
+ */
107
+ async captureForCrawl(page, url, index, artifactsDir) {
108
+ try {
109
+ // Create pages subdirectory
110
+ const pagesDir = path.join(artifactsDir, 'pages');
111
+ if (!fs.existsSync(pagesDir)) {
112
+ fs.mkdirSync(pagesDir, { recursive: true });
113
+ }
114
+
115
+ // Generate filename and full path
116
+ const filename = this.generateFilename(url, index);
117
+ const outputPath = path.join(pagesDir, filename);
118
+
119
+ // Capture screenshot
120
+ const success = await this.capture(page, outputPath);
121
+
122
+ if (success) {
123
+ return filename; // Return relative filename
124
+ }
125
+ return null;
126
+ } catch (error) {
127
+ console.error(`❌ Failed to capture screenshot for ${url}: ${error.message}`);
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Validate that screenshot file exists and has reasonable size
134
+ * @param {string} filepath - Path to screenshot file
135
+ * @returns {boolean} True if valid
136
+ */
137
+ validateScreenshot(filepath) {
138
+ try {
139
+ if (!fs.existsSync(filepath)) {
140
+ return false;
141
+ }
142
+
143
+ const stats = fs.statSync(filepath);
144
+ // Screenshot should be at least 1KB
145
+ return stats.size > 1024;
146
+ } catch (error) {
147
+ return false;
148
+ }
149
+ }
150
+ }
151
+
152
+ module.exports = GuardianScreenshot;
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Guardian Sitemap Discovery Module
3
+ * Discovers URLs from robots.txt and sitemap.xml
4
+ */
5
+
6
+ const https = require('https');
7
+ const http = require('http');
8
+
9
+ class GuardianSitemap {
10
+ constructor(options = {}) {
11
+ this.timeout = options.timeout || 10000; // 10 seconds timeout
12
+ this.maxUrls = options.maxUrls || 200; // Maximum URLs to extract
13
+ }
14
+
15
+ /**
16
+ * Fetch content from URL
17
+ * @param {string} url - URL to fetch
18
+ * @returns {Promise<string|null>} Content or null if failed
19
+ */
20
+ async fetch(url) {
21
+ return new Promise((resolve) => {
22
+ try {
23
+ const urlObj = new URL(url);
24
+ const client = urlObj.protocol === 'https:' ? https : http;
25
+
26
+ const request = client.get(url, { timeout: this.timeout }, (response) => {
27
+ // Follow redirects
28
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
29
+ return this.fetch(response.headers.location).then(resolve);
30
+ }
31
+
32
+ if (response.statusCode !== 200) {
33
+ return resolve(null);
34
+ }
35
+
36
+ let data = '';
37
+ response.on('data', (chunk) => { data += chunk; });
38
+ response.on('end', () => resolve(data));
39
+ });
40
+
41
+ request.on('error', () => resolve(null));
42
+ request.on('timeout', () => {
43
+ request.destroy();
44
+ resolve(null);
45
+ });
46
+ } catch (error) {
47
+ resolve(null);
48
+ }
49
+ });
50
+ }
51
+
52
+ /**
53
+ * Discover sitemap URLs from robots.txt
54
+ * @param {string} baseUrl - Base URL of the website
55
+ * @returns {Promise<string[]>} Array of sitemap URLs
56
+ */
57
+ async discoverFromRobots(baseUrl) {
58
+ try {
59
+ const robotsUrl = new URL('/robots.txt', baseUrl).href;
60
+ const content = await this.fetch(robotsUrl);
61
+
62
+ if (!content) {
63
+ return [];
64
+ }
65
+
66
+ const sitemaps = [];
67
+ const lines = content.split('\n');
68
+
69
+ for (const line of lines) {
70
+ const trimmed = line.trim();
71
+ if (trimmed.toLowerCase().startsWith('sitemap:')) {
72
+ const sitemapUrl = trimmed.substring(8).trim();
73
+ if (sitemapUrl) {
74
+ sitemaps.push(sitemapUrl);
75
+ }
76
+ }
77
+ }
78
+
79
+ return sitemaps;
80
+ } catch (error) {
81
+ console.error(`❌ Failed to fetch robots.txt: ${error.message}`);
82
+ return [];
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Parse sitemap XML and extract URLs
88
+ * @param {string} xml - Sitemap XML content
89
+ * @returns {string[]} Array of URLs
90
+ */
91
+ parseSitemap(xml) {
92
+ try {
93
+ const urls = [];
94
+
95
+ // Simple regex to extract <loc> tags (works for most sitemaps)
96
+ const locRegex = /<loc>(.*?)<\/loc>/gi;
97
+ let match;
98
+
99
+ while ((match = locRegex.exec(xml)) !== null && urls.length < this.maxUrls) {
100
+ const url = match[1].trim();
101
+ if (url) {
102
+ urls.push(url);
103
+ }
104
+ }
105
+
106
+ return urls;
107
+ } catch (error) {
108
+ console.error(`❌ Failed to parse sitemap: ${error.message}`);
109
+ return [];
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Check if URL is a sitemap index (contains other sitemaps)
115
+ * @param {string} xml - XML content
116
+ * @returns {boolean} True if sitemap index
117
+ */
118
+ isSitemapIndex(xml) {
119
+ return xml.includes('<sitemapindex') || xml.includes('</sitemapindex>');
120
+ }
121
+
122
+ /**
123
+ * Discover all URLs from base URL (robots.txt + sitemaps)
124
+ * @param {string} baseUrl - Base URL of the website
125
+ * @returns {Promise<object>} Object with discovered URLs and stats
126
+ */
127
+ async discover(baseUrl) {
128
+ const result = {
129
+ urls: [],
130
+ sitemapsChecked: 0,
131
+ source: 'none',
132
+ };
133
+
134
+ try {
135
+ // Step 1: Check robots.txt for sitemap URLs
136
+ console.log('🗺️ Checking robots.txt for sitemaps...');
137
+ const sitemapUrls = await this.discoverFromRobots(baseUrl);
138
+
139
+ if (sitemapUrls.length === 0) {
140
+ // Try default sitemap.xml location
141
+ console.log('🗺️ Trying default sitemap.xml...');
142
+ sitemapUrls.push(new URL('/sitemap.xml', baseUrl).href);
143
+ }
144
+
145
+ // Step 2: Fetch and parse each sitemap
146
+ for (const sitemapUrl of sitemapUrls) {
147
+ if (result.urls.length >= this.maxUrls) {
148
+ break;
149
+ }
150
+
151
+ console.log(`🗺️ Fetching sitemap: ${sitemapUrl}`);
152
+ const xml = await this.fetch(sitemapUrl);
153
+
154
+ if (!xml) {
155
+ continue;
156
+ }
157
+
158
+ result.sitemapsChecked++;
159
+
160
+ // Check if it's a sitemap index
161
+ if (this.isSitemapIndex(xml)) {
162
+ const childSitemaps = this.parseSitemap(xml);
163
+ console.log(`🗺️ Found sitemap index with ${childSitemaps.length} child sitemaps`);
164
+
165
+ // Fetch child sitemaps
166
+ for (const childUrl of childSitemaps) {
167
+ if (result.urls.length >= this.maxUrls) {
168
+ break;
169
+ }
170
+
171
+ const childXml = await this.fetch(childUrl);
172
+ if (childXml) {
173
+ const childUrls = this.parseSitemap(childXml);
174
+ result.urls.push(...childUrls.slice(0, this.maxUrls - result.urls.length));
175
+ result.sitemapsChecked++;
176
+ }
177
+ }
178
+ } else {
179
+ // Regular sitemap
180
+ const urls = this.parseSitemap(xml);
181
+ result.urls.push(...urls.slice(0, this.maxUrls - result.urls.length));
182
+ }
183
+ }
184
+
185
+ // Deduplicate URLs
186
+ result.urls = [...new Set(result.urls)];
187
+
188
+ if (result.urls.length > 0) {
189
+ result.source = 'sitemap';
190
+ console.log(`✅ Discovered ${result.urls.length} URLs from ${result.sitemapsChecked} sitemap(s)`);
191
+ } else {
192
+ console.log('⚠️ No URLs found in sitemaps');
193
+ }
194
+
195
+ return result;
196
+ } catch (error) {
197
+ console.error(`❌ Sitemap discovery failed: ${error.message}`);
198
+ return result;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Filter URLs to same origin only
204
+ * @param {string[]} urls - Array of URLs
205
+ * @param {string} baseUrl - Base URL to compare against
206
+ * @returns {string[]} Filtered URLs
207
+ */
208
+ filterSameOrigin(urls, baseUrl) {
209
+ try {
210
+ const baseOrigin = new URL(baseUrl).origin;
211
+
212
+ return urls.filter(url => {
213
+ try {
214
+ return new URL(url).origin === baseOrigin;
215
+ } catch {
216
+ return false;
217
+ }
218
+ });
219
+ } catch (error) {
220
+ return [];
221
+ }
222
+ }
223
+ }
224
+
225
+ module.exports = GuardianSitemap;
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Market Reality Snapshot v1 Schema Definition
3
+ *
4
+ * A snapshot captures a complete market reality test run:
5
+ * - what was discovered (crawl)
6
+ * - what was attempted (attempts)
7
+ * - what was observed (evidence: screenshots, traces, reports)
8
+ * - what signals were detected (friction, failures, regressions)
9
+ * - what the baseline was and how current differs
10
+ */
11
+
12
+ const SNAPSHOT_SCHEMA_VERSION = 'v1';
13
+
14
+ /**
15
+ * @typedef {Object} SnapshotMeta
16
+ * @property {string} schemaVersion - always 'v1'
17
+ * @property {string} createdAt - ISO timestamp
18
+ * @property {string} toolVersion - package.json version
19
+ * @property {string} url - base URL tested
20
+ * @property {string} runId - unique run identifier
21
+ * @property {string} [environment] - optional deployment environment
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} CrawlResult
26
+ * @property {string[]} discoveredUrls - all unique URLs found
27
+ * @property {number} visitedCount - pages successfully loaded
28
+ * @property {number} failedCount - pages that failed to load
29
+ * @property {number} safetyBlockedCount - pages blocked by safety rules
30
+ * @property {Array<{url: string, statusCode: number, error: string}>} [httpFailures] - detailed failures
31
+ * @property {string} [notes] - human-readable summary
32
+ */
33
+
34
+ /**
35
+ * @typedef {Object} ValidatorResult
36
+ * @property {string} id - unique validator ID
37
+ * @property {string} type - validator type (urlIncludes, elementVisible, etc)
38
+ * @property {string} status - 'PASS', 'FAIL', or 'WARN'
39
+ * @property {string} message - human readable result
40
+ * @property {Object} [evidence] - supporting data (selector, url, snippet, etc)
41
+ */
42
+
43
+ /**
44
+ * @typedef {Object} AttemptResult
45
+ * @property {string} attemptId - unique attempt identifier
46
+ * @property {string} attemptName - human-readable name
47
+ * @property {string} goal - what the user tried to achieve
48
+ * @property {string} outcome - 'SUCCESS', 'FAILURE', or 'FRICTION'
49
+ * @property {number} totalDurationMs - elapsed time
50
+ * @property {number} stepCount - how many steps executed
51
+ * @property {number} failedStepIndex - index of first failed step, or -1 if all succeeded
52
+ * @property {Object} friction - friction signals for this attempt
53
+ * @property {ValidatorResult[]} [validators] - soft failure detectors (Phase 2)
54
+ * @property {number} [softFailureCount] - count of failed validators
55
+ * @property {string} [riskCategory] - 'LEAD', 'REVENUE', 'TRUST/UX' (Phase 2)
56
+ */
57
+
58
+ /**
59
+ * @typedef {Object} Evidence
60
+ * @property {string} artifactDir - root directory where all artifacts were saved
61
+ * @property {string} [marketReportJson] - path to market-report.json
62
+ * @property {string} [marketReportHtml] - path to market-report.html
63
+ * @property {string} [traceZip] - path to trace.zip if enabled
64
+ * @property {Object<string, string>} [attemptArtifacts] - { attemptId => { reportJson, reportHtml, screenshotDir } }
65
+ */
66
+
67
+ /**
68
+ * @typedef {Object} Signal
69
+ * @property {string} id - unique signal ID
70
+ * @property {string} severity - 'low', 'medium', 'high', 'critical'
71
+ * @property {string} type - 'friction', 'failure', 'regression', 'timeout', 'missing_element', 'soft_failure'
72
+ * @property {string} description - human readable
73
+ * @property {string} [affectedAttemptId] - if specific to an attempt
74
+ */
75
+
76
+ /**
77
+ * @typedef {Object} BaselineInfo
78
+ * @property {boolean} baselineFound - whether a baseline was loaded
79
+ * @property {boolean} baselineCreatedThisRun - true if baseline was auto-created in this run
80
+ * @property {string} [baselineCreatedAt] - ISO timestamp when baseline was first created
81
+ * @property {string} [baselinePath] - file system path to baseline
82
+ * @property {Object} [diff] - comparison result if baseline exists
83
+ * @property {Object} [diff.regressions] - { attemptId => {before, after, reason} }
84
+ * @property {Object} [diff.improvements] - { attemptId => {before, after, reason} }
85
+ * @property {number} [diff.attemptsDriftCount] - how many attempts changed outcome
86
+ * @property {Array} [diff.validatorsChanged] - validator regression details (Phase 2)
87
+ */
88
+
89
+ /**
90
+ * @typedef {Object} MarketRisk
91
+ * @property {string} attemptId - which attempt
92
+ * @property {string} validatorId - which validator or friction signal
93
+ * @property {string} category - REVENUE|LEAD|TRUST|UX
94
+ * @property {string} severity - CRITICAL|WARNING|INFO
95
+ * @property {number} impactScore - 0-100 deterministic score
96
+ * @property {string} humanReadableReason - explanation
97
+ */
98
+
99
+ /**
100
+ * @typedef {Object} MarketImpactSummary
101
+ * @property {string} highestSeverity - CRITICAL|WARNING|INFO
102
+ * @property {number} totalRiskCount - total number of identified risks
103
+ * @property {Object} countsBySeverity - { CRITICAL: N, WARNING: N, INFO: N }
104
+ * @property {MarketRisk[]} topRisks - top 10 risks, sorted by impact score
105
+ */
106
+
107
+ /**
108
+ * @typedef {Object} InteractionResult
109
+ * @property {string} interactionId - unique ID
110
+ * @property {string} pageUrl - URL where found
111
+ * @property {string} type - NAVIGATE|CLICK|FORM_FILL
112
+ * @property {string} selector - CSS selector to find element
113
+ * @property {string} outcome - SUCCESS|FAILURE|FRICTION
114
+ * @property {string} [notes] - details (target URL, error, etc)
115
+ * @property {number} [durationMs] - execution time
116
+ * @property {string} [errorMessage] - if FAILURE
117
+ * @property {string} [evidencePath] - path to screenshot
118
+ */
119
+
120
+ /**
121
+ * @typedef {Object} DiscoverySummary
122
+ * @property {string[]} pagesVisited - URLs crawled
123
+ * @property {number} pagesVisitedCount - total pages
124
+ * @property {number} interactionsDiscovered - total candidates found
125
+ * @property {number} interactionsExecuted - candidates executed
126
+ * @property {Object} interactionsByType - { NAVIGATE: N, CLICK: N, FORM_FILL: N }
127
+ * @property {Object} interactionsByRisk - { safe: N, risky: N }
128
+ * @property {InteractionResult[]} results - execution results (failures + top successes)
129
+ * @property {string} [summary] - human readable summary
130
+ */
131
+
132
+ /**
133
+ * @typedef {Object} MarketRealitySnapshot
134
+ * @property {string} schemaVersion - always 'v1'
135
+ * @property {SnapshotMeta} meta
136
+ * @property {CrawlResult} [crawl]
137
+ * @property {AttemptResult[]} attempts
138
+ * @property {Array} flows
139
+ * @property {Signal[]} signals
140
+ * @property {Object} [riskSummary] - market risk analysis (Phase 2)
141
+ * @property {MarketImpactSummary} [marketImpactSummary] - market criticality (Phase 3)
142
+ * @property {DiscoverySummary} [discovery] - auto-discovered interactions (Phase 4)
143
+ * @property {Evidence} evidence
144
+ * @property {BaselineInfo} baseline
145
+ */
146
+
147
+ function createEmptySnapshot(baseUrl, runId, toolVersion) {
148
+ return {
149
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
150
+ meta: {
151
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
152
+ createdAt: new Date().toISOString(),
153
+ toolVersion,
154
+ url: baseUrl,
155
+ runId,
156
+ environment: process.env.GUARDIAN_ENV || 'production'
157
+ },
158
+ crawl: {
159
+ discoveredUrls: [],
160
+ visitedCount: 0,
161
+ failedCount: 0,
162
+ safetyBlockedCount: 0,
163
+ httpFailures: [],
164
+ notes: ''
165
+ },
166
+ attempts: [],
167
+ flows: [],
168
+ signals: [],
169
+ riskSummary: {
170
+ totalSoftFailures: 0,
171
+ totalFriction: 0,
172
+ failuresByCategory: {},
173
+ topRisks: []
174
+ },
175
+ marketImpactSummary: {
176
+ highestSeverity: 'INFO',
177
+ totalRiskCount: 0,
178
+ countsBySeverity: {
179
+ CRITICAL: 0,
180
+ WARNING: 0,
181
+ INFO: 0
182
+ },
183
+ topRisks: []
184
+ },
185
+ discovery: {
186
+ pagesVisited: [],
187
+ pagesVisitedCount: 0,
188
+ interactionsDiscovered: 0,
189
+ interactionsExecuted: 0,
190
+ interactionsByType: {
191
+ NAVIGATE: 0,
192
+ CLICK: 0,
193
+ FORM_FILL: 0
194
+ },
195
+ interactionsByRisk: {
196
+ safe: 0,
197
+ risky: 0
198
+ },
199
+ results: [],
200
+ summary: ''
201
+ },
202
+ evidence: {
203
+ artifactDir: '',
204
+ attemptArtifacts: {},
205
+ flowArtifacts: {}
206
+ },
207
+ intelligence: {
208
+ totalFailures: 0,
209
+ failures: [],
210
+ byDomain: {},
211
+ bySeverity: {},
212
+ escalationSignals: [],
213
+ summary: ''
214
+ },
215
+ baseline: {
216
+ baselineFound: false,
217
+ baselineCreatedThisRun: false,
218
+ baselineCreatedAt: null,
219
+ baselinePath: null,
220
+ diff: null
221
+ }
222
+ };
223
+ }
224
+
225
+ function validateSnapshot(snapshot) {
226
+ const errors = [];
227
+
228
+ if (!snapshot.schemaVersion || snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION) {
229
+ errors.push('Missing or invalid schemaVersion');
230
+ }
231
+
232
+ if (!snapshot.meta || !snapshot.meta.createdAt || !snapshot.meta.url || !snapshot.meta.runId) {
233
+ errors.push('Missing required meta fields: createdAt, url, runId');
234
+ }
235
+
236
+ if (!Array.isArray(snapshot.attempts)) {
237
+ errors.push('attempts must be an array');
238
+ }
239
+
240
+ if (!Array.isArray(snapshot.signals)) {
241
+ errors.push('signals must be an array');
242
+ }
243
+
244
+ if (!Array.isArray(snapshot.flows)) {
245
+ errors.push('flows must be an array');
246
+ }
247
+
248
+ if (!snapshot.evidence || !snapshot.evidence.artifactDir) {
249
+ errors.push('Missing evidence.artifactDir');
250
+ }
251
+
252
+ if (!snapshot.baseline) {
253
+ errors.push('Missing baseline section');
254
+ }
255
+
256
+ return {
257
+ valid: errors.length === 0,
258
+ errors
259
+ };
260
+ }
261
+
262
+ module.exports = {
263
+ SNAPSHOT_SCHEMA_VERSION,
264
+ createEmptySnapshot,
265
+ validateSnapshot
266
+ };