@odavl/guardian 0.2.0 → 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.
Files changed (84) hide show
  1. package/CHANGELOG.md +86 -2
  2. package/README.md +155 -97
  3. package/bin/guardian.js +1345 -60
  4. package/config/README.md +59 -0
  5. package/config/profiles/landing-demo.yaml +16 -0
  6. package/package.json +21 -11
  7. package/policies/landing-demo.json +22 -0
  8. package/src/enterprise/audit-logger.js +166 -0
  9. package/src/enterprise/pdf-exporter.js +267 -0
  10. package/src/enterprise/rbac-gate.js +142 -0
  11. package/src/enterprise/rbac.js +239 -0
  12. package/src/enterprise/site-manager.js +180 -0
  13. package/src/founder/feedback-system.js +156 -0
  14. package/src/founder/founder-tracker.js +213 -0
  15. package/src/founder/usage-signals.js +141 -0
  16. package/src/guardian/alert-ledger.js +121 -0
  17. package/src/guardian/attempt-engine.js +568 -7
  18. package/src/guardian/attempt-registry.js +42 -1
  19. package/src/guardian/attempt-relevance.js +106 -0
  20. package/src/guardian/attempt.js +24 -0
  21. package/src/guardian/baseline.js +12 -4
  22. package/src/guardian/breakage-intelligence.js +1 -0
  23. package/src/guardian/ci-cli.js +121 -0
  24. package/src/guardian/ci-output.js +4 -3
  25. package/src/guardian/cli-summary.js +79 -92
  26. package/src/guardian/config-loader.js +162 -0
  27. package/src/guardian/drift-detector.js +100 -0
  28. package/src/guardian/enhanced-html-reporter.js +221 -4
  29. package/src/guardian/env-guard.js +127 -0
  30. package/src/guardian/failure-intelligence.js +173 -0
  31. package/src/guardian/first-run-profile.js +89 -0
  32. package/src/guardian/first-run.js +6 -1
  33. package/src/guardian/flag-validator.js +17 -3
  34. package/src/guardian/html-reporter.js +2 -0
  35. package/src/guardian/human-reporter.js +431 -0
  36. package/src/guardian/index.js +22 -19
  37. package/src/guardian/init-command.js +9 -5
  38. package/src/guardian/intent-detector.js +146 -0
  39. package/src/guardian/journey-definitions.js +132 -0
  40. package/src/guardian/journey-scan-cli.js +145 -0
  41. package/src/guardian/journey-scanner.js +583 -0
  42. package/src/guardian/junit-reporter.js +18 -1
  43. package/src/guardian/live-cli.js +95 -0
  44. package/src/guardian/live-scheduler-runner.js +137 -0
  45. package/src/guardian/live-scheduler.js +146 -0
  46. package/src/guardian/market-reporter.js +341 -81
  47. package/src/guardian/pattern-analyzer.js +348 -0
  48. package/src/guardian/policy.js +80 -3
  49. package/src/guardian/preset-loader.js +9 -6
  50. package/src/guardian/reality.js +1278 -117
  51. package/src/guardian/reporter.js +27 -41
  52. package/src/guardian/run-artifacts.js +212 -0
  53. package/src/guardian/run-cleanup.js +207 -0
  54. package/src/guardian/run-latest.js +90 -0
  55. package/src/guardian/run-list.js +211 -0
  56. package/src/guardian/scan-presets.js +100 -11
  57. package/src/guardian/selector-fallbacks.js +394 -0
  58. package/src/guardian/semantic-contact-finder.js +2 -1
  59. package/src/guardian/site-introspection.js +257 -0
  60. package/src/guardian/smoke.js +2 -2
  61. package/src/guardian/snapshot-schema.js +25 -1
  62. package/src/guardian/snapshot.js +46 -2
  63. package/src/guardian/stability-scorer.js +169 -0
  64. package/src/guardian/template-command.js +184 -0
  65. package/src/guardian/text-formatters.js +426 -0
  66. package/src/guardian/verdict.js +320 -0
  67. package/src/guardian/verdicts.js +74 -0
  68. package/src/guardian/watch-runner.js +3 -7
  69. package/src/payments/stripe-checkout.js +169 -0
  70. package/src/plans/plan-definitions.js +148 -0
  71. package/src/plans/plan-manager.js +211 -0
  72. package/src/plans/usage-tracker.js +210 -0
  73. package/src/recipes/recipe-engine.js +188 -0
  74. package/src/recipes/recipe-failure-analysis.js +159 -0
  75. package/src/recipes/recipe-registry.js +134 -0
  76. package/src/recipes/recipe-runtime.js +507 -0
  77. package/src/recipes/recipe-store.js +410 -0
  78. package/guardian-contract-v1.md +0 -149
  79. /package/{guardian.config.json → config/guardian.config.json} +0 -0
  80. /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
  81. /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
  82. /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
  83. /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
  84. /package/{guardian.profile.saas.yaml → config/profiles/saas.yaml} +0 -0
@@ -25,39 +25,26 @@ class GuardianReporter {
25
25
 
26
26
  createReport(crawlResult, baseUrl) {
27
27
  const { visited, totalDiscovered, totalVisited } = crawlResult;
28
-
29
- const coverage = totalDiscovered > 0
28
+
29
+ const coverage = totalDiscovered > 0
30
30
  ? parseFloat(((totalVisited / totalDiscovered) * 100).toFixed(2))
31
31
  : 0;
32
-
33
- // Calculate confidence
34
- let confidenceLevel = 'LOW';
35
- if (coverage >= 85) {
36
- confidenceLevel = 'HIGH';
37
- } else if (coverage >= 60) {
38
- confidenceLevel = 'MEDIUM';
39
- }
40
-
41
- // Calculate decision
42
- let decision = 'READY';
43
- if (coverage < 30) {
44
- decision = 'DO_NOT_LAUNCH';
45
- } else if (coverage < 60) {
46
- decision = 'INSUFFICIENT_CONFIDENCE';
47
- }
48
-
49
- // Check for critical errors (server errors only, not 404s)
32
+
50
33
  const failedPages = visited.filter(p => p.status && p.status >= 500);
34
+ const observedPages = visited.filter(p => p.status && p.status < 500);
35
+
36
+ const reasons = [];
37
+ reasons.push(`Observed page reachability only: visited ${totalVisited} page(s), discovered ${totalDiscovered}, coverage ${coverage}%.`);
51
38
  if (failedPages.length > 0) {
52
- decision = 'DO_NOT_LAUNCH';
39
+ reasons.push(`${failedPages.length} page(s) returned server errors or navigation failures.`);
53
40
  }
54
-
55
- const reasons = [];
56
- if (coverage < 30) reasons.push(`Low coverage (${coverage}%)`);
57
- if (failedPages.length > 0) reasons.push(`${failedPages.length} pages failed to load`);
58
- if (coverage >= 60) reasons.push(`Coverage is ${coverage}%`);
59
- if (failedPages.length === 0 && coverage >= 60) reasons.push('All visited pages loaded successfully');
60
-
41
+ if (observedPages.length > 0) {
42
+ reasons.push(`HTTP responses observed for ${observedPages.length} page(s); no user flows or form submissions were executed.`);
43
+ }
44
+ reasons.push('No end-to-end user flows were validated; results are limited to link discovery and HTTP status observations.');
45
+
46
+ const decision = 'INSUFFICIENT_DATA';
47
+
61
48
  return {
62
49
  version: 'mvp-0.1',
63
50
  timestamp: new Date().toISOString(),
@@ -69,12 +56,12 @@ class GuardianReporter {
69
56
  failedPages: failedPages.length
70
57
  },
71
58
  confidence: {
72
- level: confidenceLevel,
73
- reasoning: `Coverage is ${coverage}% with ${failedPages.length} failed pages`
59
+ level: 'LOW',
60
+ reasoning: 'Only page reachability was observed; no user flows were confirmed.'
74
61
  },
75
62
  finalJudgment: {
76
63
  decision: decision,
77
- reasons: reasons.length > 0 ? reasons : ['All checks passed']
64
+ reasons: reasons
78
65
  },
79
66
  pages: visited.map((p, i) => ({
80
67
  index: i + 1,
@@ -100,17 +87,16 @@ class GuardianReporter {
100
87
  ? parseFloat(((stepsExecuted / stepsTotal) * 100).toFixed(2))
101
88
  : 0;
102
89
 
103
- // For flows: success = READY, failure = DO_NOT_LAUNCH
104
- const decision = success ? 'READY' : 'DO_NOT_LAUNCH';
105
- const confidenceLevel = success ? 'HIGH' : 'LOW';
106
-
90
+ const decision = success ? 'OBSERVED' : 'PARTIAL';
91
+ const confidenceLevel = success ? 'MEDIUM' : 'LOW';
92
+
107
93
  const reasons = [];
108
94
  if (success) {
109
- reasons.push(`Flow "${flowName}" completed successfully`);
110
- reasons.push(`All ${stepsTotal} steps executed`);
95
+ reasons.push(`Observed flow "${flowName}" end-to-end; ${stepsExecuted}/${stepsTotal} steps completed.`);
96
+ reasons.push('No critical failures detected in this flow.');
111
97
  } else {
112
- reasons.push(`Flow "${flowName}" failed at step ${failedStep}`);
113
- reasons.push(`Error: ${error}`);
98
+ reasons.push(`Flow "${flowName}" did not complete; stopped at step ${failedStep || 'unknown'} with error: ${error || 'unspecified failure'}.`);
99
+ reasons.push('This run observed only the partial flow execution above; other flows were not validated.');
114
100
  }
115
101
 
116
102
  return {
@@ -135,8 +121,8 @@ class GuardianReporter {
135
121
  confidence: {
136
122
  level: confidenceLevel,
137
123
  reasoning: success
138
- ? `Flow completed successfully (${stepsExecuted}/${stepsTotal} steps)`
139
- : `Flow failed at step ${failedStep}: ${error}`
124
+ ? `Observed single flow execution; steps completed ${stepsExecuted}/${stepsTotal}.`
125
+ : `Flow incomplete; failed at step ${failedStep || 'unknown'} with error: ${error || 'unspecified failure'}.`
140
126
  },
141
127
  finalJudgment: {
142
128
  decision: decision,
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Run Artifacts Naming & Metadata
3
+ * Deterministic naming and META.json generation for Guardian runs
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ /**
10
+ * Generate site slug from URL
11
+ *
12
+ * Rules:
13
+ * - Lowercase
14
+ * - Remove protocol (http/https)
15
+ * - Remove trailing slash
16
+ * - Replace non-alphanumeric with "-"
17
+ * - Collapse multiple "-" to one
18
+ * - Trim leading/trailing "-"
19
+ *
20
+ * @param {string} url - Original URL
21
+ * @returns {string} slug
22
+ */
23
+ function makeSiteSlug(url) {
24
+ if (!url) return 'unknown';
25
+
26
+ // Parse URL to get host + path
27
+ let normalized = url.toLowerCase();
28
+
29
+ // Remove protocol
30
+ normalized = normalized.replace(/^https?:\/\//i, '');
31
+
32
+ // Remove trailing slash
33
+ normalized = normalized.replace(/\/$/, '');
34
+
35
+ // Replace any non-alphanumeric character (including colon, slash, dot) with hyphen
36
+ normalized = normalized.replace(/[^a-z0-9\-]/g, '-');
37
+
38
+ // Collapse multiple hyphens
39
+ normalized = normalized.replace(/-+/g, '-');
40
+
41
+ // Trim leading/trailing hyphens
42
+ normalized = normalized.replace(/^-+|-+$/g, '');
43
+
44
+ return normalized || 'unknown';
45
+ }
46
+
47
+ /**
48
+ * Generate human-readable run directory name
49
+ *
50
+ * Format: YYYY-MM-DD_HH-MM-SS_<siteSlug>_<policy>_<RESULT>
51
+ * Example: 2025-12-24_01-31-11_localhost-8001_startup_FAILED
52
+ *
53
+ * @param {Object} opts
54
+ * @param {Date|string} opts.timestamp - Execution time
55
+ * @param {string} opts.url - Site URL
56
+ * @param {string} opts.policy - Policy/profile name (or 'default')
57
+ * @param {string} opts.result - Result: PASSED, FAILED, or WARN
58
+ * @returns {string} directory name
59
+ */
60
+ function makeRunDirName(opts) {
61
+ const { timestamp, url, policy, result } = opts;
62
+
63
+ // Parse timestamp
64
+ let time = timestamp instanceof Date ? timestamp : new Date(timestamp);
65
+
66
+ // Format: YYYY-MM-DD_HH-MM-SS
67
+ const year = time.getFullYear();
68
+ const month = String(time.getMonth() + 1).padStart(2, '0');
69
+ const day = String(time.getDate()).padStart(2, '0');
70
+ const hours = String(time.getHours()).padStart(2, '0');
71
+ const minutes = String(time.getMinutes()).padStart(2, '0');
72
+ const seconds = String(time.getSeconds()).padStart(2, '0');
73
+
74
+ const timeStr = `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`;
75
+
76
+ // Generate slug
77
+ const slug = makeSiteSlug(url);
78
+
79
+ // Normalize policy (extract preset name if needed)
80
+ let policyName = policy || 'default';
81
+ if (policyName.startsWith('preset:')) {
82
+ policyName = policyName.replace('preset:', '');
83
+ }
84
+
85
+ // Normalize result
86
+ const normalizedResult = (result || 'UNKNOWN').toUpperCase();
87
+
88
+ return `${timeStr}_${slug}_${policyName}_${normalizedResult}`;
89
+ }
90
+
91
+ /**
92
+ * Write META.json for a completed run
93
+ *
94
+ * @param {Object} opts
95
+ * @param {string} opts.runDir - Run directory path
96
+ * @param {string} opts.url - Original site URL
97
+ * @param {string} opts.siteSlug - Generated slug
98
+ * @param {string} opts.policy - Policy used
99
+ * @param {string} opts.result - PASSED|FAILED|WARN
100
+ * @param {number} opts.durationMs - Wall-clock duration
101
+ * @param {string} opts.profile - Detected site profile (ecommerce|saas|content|unknown)
102
+ * @param {Object} opts.attempts - Attempt statistics
103
+ * @param {number} opts.attempts.total
104
+ * @param {number} opts.attempts.executed
105
+ * @param {number} opts.attempts.successful
106
+ * @param {number} opts.attempts.failed
107
+ * @param {number} opts.attempts.skipped
108
+ * @param {Array} opts.attempts.skippedDetails - Array of {attempt, reason}
109
+ * @param {number} [opts.attempts.nearSuccess] - Count of near-success signals
110
+ * @param {Array} [opts.attempts.nearSuccessDetails] - Array of { attempt, reason }
111
+ * @throws {Error} if write fails
112
+ */
113
+ function writeMetaJson(opts) {
114
+ const {
115
+ runDir,
116
+ url,
117
+ siteSlug,
118
+ policy,
119
+ result,
120
+ durationMs,
121
+ profile,
122
+ attempts,
123
+ verdict
124
+ } = opts;
125
+
126
+ const meta = {
127
+ version: 1,
128
+ timestamp: new Date().toISOString(),
129
+ url,
130
+ siteSlug,
131
+ policy: policy || 'default',
132
+ result: (result || 'UNKNOWN').toUpperCase(),
133
+ durationMs: Math.round(durationMs || 0),
134
+ profile: profile || 'unknown',
135
+ attempts: {
136
+ total: attempts?.total || 0,
137
+ executed: attempts?.executed || 0,
138
+ successful: attempts?.successful || 0,
139
+ failed: attempts?.failed || 0,
140
+ skipped: attempts?.skipped || 0,
141
+ skippedDetails: attempts?.skippedDetails || [],
142
+ nearSuccess: attempts?.nearSuccess || 0,
143
+ nearSuccessDetails: attempts?.nearSuccessDetails || []
144
+ }
145
+ };
146
+ if (verdict) {
147
+ meta.verdict = {
148
+ verdict: verdict.verdict,
149
+ confidence: verdict.confidence,
150
+ why: verdict.why || ''
151
+ };
152
+ }
153
+
154
+ const metaPath = path.join(runDir, 'META.json');
155
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
156
+
157
+ return metaPath;
158
+ }
159
+
160
+ /**
161
+ * Read META.json from a run directory
162
+ *
163
+ * @param {string} runDir - Run directory path
164
+ * @returns {Object|null} parsed META.json or null if missing/invalid
165
+ */
166
+ function readMetaJson(runDir) {
167
+ const metaPath = path.join(runDir, 'META.json');
168
+
169
+ if (!fs.existsSync(metaPath)) {
170
+ return null;
171
+ }
172
+
173
+ try {
174
+ const content = fs.readFileSync(metaPath, 'utf8');
175
+ return JSON.parse(content);
176
+ } catch (e) {
177
+ return null;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Format duration for human-readable output
183
+ *
184
+ * @param {number} ms - Milliseconds
185
+ * @returns {string} formatted duration
186
+ */
187
+ function formatDuration(ms) {
188
+ if (ms < 1000) {
189
+ return `${ms}ms`;
190
+ }
191
+
192
+ const totalSecs = Math.floor(ms / 1000);
193
+ const hours = Math.floor(totalSecs / 3600);
194
+ const minutes = Math.floor((totalSecs % 3600) / 60);
195
+ const seconds = totalSecs % 60;
196
+
197
+ if (hours > 0) {
198
+ return `${hours}h ${minutes}m ${seconds}s`;
199
+ } else if (minutes > 0) {
200
+ return `${minutes}m ${seconds}s`;
201
+ } else {
202
+ return `${seconds}s`;
203
+ }
204
+ }
205
+
206
+ module.exports = {
207
+ makeSiteSlug,
208
+ makeRunDirName,
209
+ writeMetaJson,
210
+ readMetaJson,
211
+ formatDuration
212
+ };
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Run Cleanup & Management
3
+ * Delete old, excessive, or failed runs based on specified criteria
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const { readMetaJson } = require('./run-artifacts');
9
+
10
+ /**
11
+ * Parse duration string (e.g. "7d", "24h", "30m")
12
+ *
13
+ * @param {string} durationStr - Duration like "7d", "24h", "30m"
14
+ * @returns {number} milliseconds
15
+ */
16
+ function parseDuration(durationStr) {
17
+ const match = durationStr.match(/^(\d+)([dhm])$/);
18
+ if (!match) {
19
+ throw new Error(`Invalid duration format: ${durationStr}. Use Nd, Nh, or Nm (e.g., "7d", "24h", "30m")`);
20
+ }
21
+
22
+ const value = parseInt(match[1], 10);
23
+ const unit = match[2];
24
+
25
+ switch (unit) {
26
+ case 'd':
27
+ return value * 24 * 60 * 60 * 1000;
28
+ case 'h':
29
+ return value * 60 * 60 * 1000;
30
+ case 'm':
31
+ return value * 60 * 1000;
32
+ default:
33
+ throw new Error(`Unknown time unit: ${unit}`);
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Find all run directories
39
+ *
40
+ * @param {string} artifactsDir - Path to artifacts directory
41
+ * @returns {Array} array of { dirPath, dirName, meta }
42
+ */
43
+ function loadAllRuns(artifactsDir) {
44
+ if (!fs.existsSync(artifactsDir)) {
45
+ return [];
46
+ }
47
+
48
+ try {
49
+ const entries = fs.readdirSync(artifactsDir, { withFileTypes: true });
50
+ const runs = [];
51
+
52
+ for (const entry of entries) {
53
+ if (!entry.isDirectory()) continue;
54
+
55
+ const dirPath = path.join(artifactsDir, entry.name);
56
+ const meta = readMetaJson(dirPath);
57
+
58
+ // Skip runs without META.json
59
+ if (!meta) continue;
60
+
61
+ runs.push({
62
+ dirPath,
63
+ dirName: entry.name,
64
+ meta
65
+ });
66
+ }
67
+
68
+ return runs;
69
+ } catch (e) {
70
+ return [];
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Group runs by siteSlug
76
+ *
77
+ * @param {Array} runs - Array of run objects
78
+ * @returns {Object} map of siteSlug → array of runs
79
+ */
80
+ function groupBySite(runs) {
81
+ const grouped = {};
82
+ for (const run of runs) {
83
+ const slug = run.meta.siteSlug || 'unknown';
84
+ if (!grouped[slug]) {
85
+ grouped[slug] = [];
86
+ }
87
+ grouped[slug].push(run);
88
+ }
89
+ return grouped;
90
+ }
91
+
92
+ /**
93
+ * Sort runs by timestamp (newest first)
94
+ *
95
+ * @param {Array} runs - Array of run objects
96
+ * @returns {Array} sorted runs
97
+ */
98
+ function sortByTimestamp(runs) {
99
+ return runs.sort((a, b) => {
100
+ const timeA = new Date(a.meta.timestamp).getTime();
101
+ const timeB = new Date(b.meta.timestamp).getTime();
102
+ return timeB - timeA;
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Apply cleanup filters
108
+ *
109
+ * @param {Array} runs - Array of run objects
110
+ * @param {Object} opts - Options
111
+ * @param {string} opts.olderThan - Duration string (e.g., "7d")
112
+ * @param {number} opts.keepLatest - Number of latest runs to keep per site
113
+ * @param {boolean} opts.failedOnly - Only delete FAILED runs
114
+ * @returns {Array} runs to delete
115
+ */
116
+ function selectRunsForDeletion(runs, opts = {}) {
117
+ let toDelete = [...runs];
118
+ const now = Date.now();
119
+
120
+ // Filter by age
121
+ if (opts.olderThan) {
122
+ const ageMs = parseDuration(opts.olderThan);
123
+ const cutoffTime = now - ageMs;
124
+ toDelete = toDelete.filter(run => {
125
+ const runTime = new Date(run.meta.timestamp).getTime();
126
+ return runTime < cutoffTime;
127
+ });
128
+ }
129
+
130
+ // Filter by status
131
+ if (opts.failedOnly) {
132
+ toDelete = toDelete.filter(run => run.meta.result === 'FAILED');
133
+ }
134
+
135
+ // Apply keep-latest per site (only if we're not already filtering by age/status heavily)
136
+ if (opts.keepLatest && opts.keepLatest > 0) {
137
+ const grouped = groupBySite(toDelete);
138
+ const toKeepPaths = new Set();
139
+
140
+ for (const siteSlug in grouped) {
141
+ const siteRuns = sortByTimestamp(grouped[siteSlug]);
142
+ // Keep the latest N
143
+ for (let i = 0; i < Math.min(opts.keepLatest, siteRuns.length); i++) {
144
+ toKeepPaths.add(siteRuns[i].dirPath);
145
+ }
146
+ }
147
+
148
+ toDelete = toDelete.filter(run => !toKeepPaths.has(run.dirPath));
149
+ }
150
+
151
+ return toDelete;
152
+ }
153
+
154
+ /**
155
+ * Execute cleanup
156
+ *
157
+ * @param {string} artifactsDir - Path to artifacts directory
158
+ * @param {Object} opts - Options
159
+ * @returns {Object} cleanup result
160
+ */
161
+ function cleanup(artifactsDir = './artifacts', opts = {}) {
162
+ const allRuns = loadAllRuns(artifactsDir);
163
+
164
+ if (allRuns.length === 0) {
165
+ return {
166
+ deleted: 0,
167
+ kept: 0,
168
+ errors: []
169
+ };
170
+ }
171
+
172
+ // Determine which runs to delete
173
+ const toDelete = selectRunsForDeletion(allRuns, opts);
174
+ const deletePaths = new Set(toDelete.map(r => r.dirPath));
175
+
176
+ const errors = [];
177
+
178
+ // Delete run directories
179
+ for (const run of toDelete) {
180
+ try {
181
+ fs.rmSync(run.dirPath, { recursive: true, force: true });
182
+ } catch (e) {
183
+ errors.push(`Failed to delete ${run.dirName}: ${e.message}`);
184
+ }
185
+ }
186
+
187
+ return {
188
+ deleted: toDelete.length,
189
+ kept: allRuns.length - toDelete.length,
190
+ errors,
191
+ deletedRuns: toDelete.map(r => ({
192
+ dirName: r.dirName,
193
+ site: r.meta.siteSlug,
194
+ result: r.meta.result,
195
+ timestamp: r.meta.timestamp
196
+ }))
197
+ };
198
+ }
199
+
200
+ module.exports = {
201
+ cleanup,
202
+ parseDuration,
203
+ loadAllRuns,
204
+ groupBySite,
205
+ sortByTimestamp,
206
+ selectRunsForDeletion
207
+ };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Latest Run Pointers
3
+ * Maintains LATEST.json and site-specific latest pointers for quick access
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ /**
10
+ * Create a pointer file pointing to the latest run
11
+ *
12
+ * @param {string} pointerPath - Path where pointer file should be saved
13
+ * @param {Object} meta - META.json content from the run
14
+ * @param {string} runDirName - Name of the run directory
15
+ */
16
+ function writePointer(pointerPath, meta, runDirName) {
17
+ const pointer = {
18
+ version: 1,
19
+ timestamp: new Date().toISOString(),
20
+ pointedRun: runDirName,
21
+ pointedRunMeta: {
22
+ timestamp: meta.timestamp,
23
+ url: meta.url,
24
+ siteSlug: meta.siteSlug,
25
+ policy: meta.policy,
26
+ result: meta.result,
27
+ durationMs: meta.durationMs
28
+ }
29
+ };
30
+
31
+ // Create directory if needed
32
+ const dir = path.dirname(pointerPath);
33
+ fs.mkdirSync(dir, { recursive: true });
34
+
35
+ // Atomic write
36
+ fs.writeFileSync(pointerPath, JSON.stringify(pointer, null, 2));
37
+ }
38
+
39
+ /**
40
+ * Update LATEST.json (global latest run)
41
+ *
42
+ * @param {string} runDir - Full path to the run directory
43
+ * @param {string} runDirName - Name of the run directory
44
+ * @param {Object} meta - META.json content
45
+ * @param {string} artifactsDir - Artifacts directory path (for pointer location)
46
+ */
47
+ function updateLatestGlobal(runDir, runDirName, meta, artifactsDir = './artifacts') {
48
+ const pointerPath = path.join(artifactsDir, 'LATEST.json');
49
+ writePointer(pointerPath, meta, runDirName);
50
+ }
51
+
52
+ /**
53
+ * Update site-specific latest pointer
54
+ *
55
+ * @param {string} runDir - Full path to the run directory
56
+ * @param {string} runDirName - Name of the run directory
57
+ * @param {Object} meta - META.json content
58
+ * @param {string} artifactsDir - Artifacts directory path (for pointer location)
59
+ */
60
+ function updateLatestBySite(runDir, runDirName, meta, artifactsDir = './artifacts') {
61
+ const siteSlug = meta.siteSlug || 'unknown';
62
+ const pointerPath = path.join(artifactsDir, 'latest', `${siteSlug}.json`);
63
+ writePointer(pointerPath, meta, runDirName);
64
+ }
65
+
66
+ /**
67
+ * Read a pointer file
68
+ *
69
+ * @param {string} pointerPath - Path to pointer file
70
+ * @returns {Object|null} pointer content or null if missing
71
+ */
72
+ function readPointer(pointerPath) {
73
+ if (!fs.existsSync(pointerPath)) {
74
+ return null;
75
+ }
76
+
77
+ try {
78
+ const content = fs.readFileSync(pointerPath, 'utf8');
79
+ return JSON.parse(content);
80
+ } catch (e) {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ module.exports = {
86
+ updateLatestGlobal,
87
+ updateLatestBySite,
88
+ readPointer,
89
+ writePointer
90
+ };