@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,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 10: Founder Tracker
|
|
3
|
+
* Detects and tracks first 100 users (Founding Users)
|
|
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 FOUNDER_DIR = path.join(os.homedir(), '.odavl-guardian', 'founder');
|
|
12
|
+
const FOUNDER_FILE = path.join(FOUNDER_DIR, 'status.json');
|
|
13
|
+
const GLOBAL_COUNTER_FILE = path.join(FOUNDER_DIR, 'global-counter.json');
|
|
14
|
+
const FOUNDER_LIMIT = 100;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Ensure founder directory exists
|
|
18
|
+
*/
|
|
19
|
+
function ensureFounderDir() {
|
|
20
|
+
if (!fs.existsSync(FOUNDER_DIR)) {
|
|
21
|
+
fs.mkdirSync(FOUNDER_DIR, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Generate unique machine ID (stable across runs)
|
|
27
|
+
*/
|
|
28
|
+
function getMachineId() {
|
|
29
|
+
const machineFile = path.join(FOUNDER_DIR, 'machine-id.txt');
|
|
30
|
+
ensureFounderDir();
|
|
31
|
+
|
|
32
|
+
if (fs.existsSync(machineFile)) {
|
|
33
|
+
return fs.readFileSync(machineFile, 'utf-8').trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Generate new machine ID
|
|
37
|
+
const id = crypto.randomBytes(16).toString('hex');
|
|
38
|
+
fs.writeFileSync(machineFile, id, 'utf-8');
|
|
39
|
+
return id;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get global founder counter
|
|
44
|
+
*/
|
|
45
|
+
function getGlobalCounter() {
|
|
46
|
+
ensureFounderDir();
|
|
47
|
+
|
|
48
|
+
if (!fs.existsSync(GLOBAL_COUNTER_FILE)) {
|
|
49
|
+
return { count: 0, users: [] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(fs.readFileSync(GLOBAL_COUNTER_FILE, 'utf-8'));
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return { count: 0, users: [] };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Save global founder counter
|
|
61
|
+
*/
|
|
62
|
+
function saveGlobalCounter(counter) {
|
|
63
|
+
ensureFounderDir();
|
|
64
|
+
fs.writeFileSync(GLOBAL_COUNTER_FILE, JSON.stringify(counter, null, 2), 'utf-8');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get founder status for current user
|
|
69
|
+
*/
|
|
70
|
+
function getFounderStatus() {
|
|
71
|
+
ensureFounderDir();
|
|
72
|
+
|
|
73
|
+
if (!fs.existsSync(FOUNDER_FILE)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(fs.readFileSync(FOUNDER_FILE, 'utf-8'));
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Save founder status for current user
|
|
86
|
+
*/
|
|
87
|
+
function saveFounderStatus(status) {
|
|
88
|
+
ensureFounderDir();
|
|
89
|
+
fs.writeFileSync(FOUNDER_FILE, JSON.stringify(status, null, 2), 'utf-8');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Check if user is a founding user
|
|
94
|
+
*/
|
|
95
|
+
function isFoundingUser() {
|
|
96
|
+
const status = getFounderStatus();
|
|
97
|
+
return status && status.isFounder === true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Register current user (call on first scan)
|
|
102
|
+
*/
|
|
103
|
+
function registerUser() {
|
|
104
|
+
// Check if already registered
|
|
105
|
+
const existingStatus = getFounderStatus();
|
|
106
|
+
if (existingStatus) {
|
|
107
|
+
return existingStatus;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const machineId = getMachineId();
|
|
111
|
+
const counter = getGlobalCounter();
|
|
112
|
+
|
|
113
|
+
// Check if machine ID already registered
|
|
114
|
+
const alreadyRegistered = counter.users.includes(machineId);
|
|
115
|
+
|
|
116
|
+
if (alreadyRegistered) {
|
|
117
|
+
// Find their position
|
|
118
|
+
const position = counter.users.indexOf(machineId) + 1;
|
|
119
|
+
const status = {
|
|
120
|
+
isFounder: position <= FOUNDER_LIMIT,
|
|
121
|
+
registeredAt: new Date().toISOString(),
|
|
122
|
+
founderNumber: position <= FOUNDER_LIMIT ? position : null,
|
|
123
|
+
machineId,
|
|
124
|
+
};
|
|
125
|
+
saveFounderStatus(status);
|
|
126
|
+
return status;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// New user - add to global counter
|
|
130
|
+
counter.count += 1;
|
|
131
|
+
counter.users.push(machineId);
|
|
132
|
+
saveGlobalCounter(counter);
|
|
133
|
+
|
|
134
|
+
const isFounder = counter.count <= FOUNDER_LIMIT;
|
|
135
|
+
const status = {
|
|
136
|
+
isFounder,
|
|
137
|
+
registeredAt: new Date().toISOString(),
|
|
138
|
+
founderNumber: isFounder ? counter.count : null,
|
|
139
|
+
machineId,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
saveFounderStatus(status);
|
|
143
|
+
return status;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Get founder message for display
|
|
148
|
+
*/
|
|
149
|
+
function getFounderMessage() {
|
|
150
|
+
const status = getFounderStatus();
|
|
151
|
+
|
|
152
|
+
if (!status) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (status.isFounder) {
|
|
157
|
+
return `🌟 You're Founding User #${status.founderNumber} — thank you for helping shape Guardian.`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Get founder badge for reports (HTML)
|
|
165
|
+
*/
|
|
166
|
+
function getFounderBadgeHTML() {
|
|
167
|
+
const status = getFounderStatus();
|
|
168
|
+
|
|
169
|
+
if (!status || !status.isFounder) {
|
|
170
|
+
return '';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return `
|
|
174
|
+
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 12px 20px; border-radius: 8px; margin: 16px 0; text-align: center; font-weight: 600; box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);">
|
|
175
|
+
🌟 Founding User #${status.founderNumber}
|
|
176
|
+
<div style="font-size: 0.85em; opacity: 0.9; margin-top: 4px;">Thank you for being an early supporter</div>
|
|
177
|
+
</div>
|
|
178
|
+
`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get total registered users count
|
|
183
|
+
*/
|
|
184
|
+
function getTotalUsers() {
|
|
185
|
+
const counter = getGlobalCounter();
|
|
186
|
+
return counter.count;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Reset founder data (for testing only)
|
|
191
|
+
*/
|
|
192
|
+
function resetFounderData() {
|
|
193
|
+
if (fs.existsSync(FOUNDER_FILE)) {
|
|
194
|
+
fs.unlinkSync(FOUNDER_FILE);
|
|
195
|
+
}
|
|
196
|
+
if (fs.existsSync(GLOBAL_COUNTER_FILE)) {
|
|
197
|
+
fs.unlinkSync(GLOBAL_COUNTER_FILE);
|
|
198
|
+
}
|
|
199
|
+
const machineFile = path.join(FOUNDER_DIR, 'machine-id.txt');
|
|
200
|
+
if (fs.existsSync(machineFile)) {
|
|
201
|
+
fs.unlinkSync(machineFile);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = {
|
|
206
|
+
registerUser,
|
|
207
|
+
isFoundingUser,
|
|
208
|
+
getFounderStatus,
|
|
209
|
+
getFounderMessage,
|
|
210
|
+
getFounderBadgeHTML,
|
|
211
|
+
getTotalUsers,
|
|
212
|
+
resetFounderData,
|
|
213
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 10: Usage Signals Tracker
|
|
3
|
+
* Minimal local tracking of key milestones
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
const SIGNALS_DIR = path.join(os.homedir(), '.odavl-guardian');
|
|
11
|
+
const SIGNALS_FILE = path.join(SIGNALS_DIR, 'signals.json');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ensure signals directory exists
|
|
15
|
+
*/
|
|
16
|
+
function ensureSignalsDir() {
|
|
17
|
+
if (!fs.existsSync(SIGNALS_DIR)) {
|
|
18
|
+
fs.mkdirSync(SIGNALS_DIR, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get current signals
|
|
24
|
+
*/
|
|
25
|
+
function getSignals() {
|
|
26
|
+
ensureSignalsDir();
|
|
27
|
+
|
|
28
|
+
if (!fs.existsSync(SIGNALS_FILE)) {
|
|
29
|
+
return {
|
|
30
|
+
firstScanAt: null,
|
|
31
|
+
firstLiveAt: null,
|
|
32
|
+
firstUpgradeAt: null,
|
|
33
|
+
totalScans: 0,
|
|
34
|
+
totalLiveSessions: 0,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(fs.readFileSync(SIGNALS_FILE, 'utf-8'));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return {
|
|
42
|
+
firstScanAt: null,
|
|
43
|
+
firstLiveAt: null,
|
|
44
|
+
firstUpgradeAt: null,
|
|
45
|
+
totalScans: 0,
|
|
46
|
+
totalLiveSessions: 0,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Save signals
|
|
53
|
+
*/
|
|
54
|
+
function saveSignals(signals) {
|
|
55
|
+
ensureSignalsDir();
|
|
56
|
+
fs.writeFileSync(SIGNALS_FILE, JSON.stringify(signals, null, 2), 'utf-8');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Record first scan
|
|
61
|
+
*/
|
|
62
|
+
function recordFirstScan() {
|
|
63
|
+
const signals = getSignals();
|
|
64
|
+
|
|
65
|
+
if (!signals.firstScanAt) {
|
|
66
|
+
signals.firstScanAt = new Date().toISOString();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
signals.totalScans += 1;
|
|
70
|
+
saveSignals(signals);
|
|
71
|
+
|
|
72
|
+
return signals;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Record first live guardian session
|
|
77
|
+
*/
|
|
78
|
+
function recordFirstLive() {
|
|
79
|
+
const signals = getSignals();
|
|
80
|
+
|
|
81
|
+
if (!signals.firstLiveAt) {
|
|
82
|
+
signals.firstLiveAt = new Date().toISOString();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
signals.totalLiveSessions += 1;
|
|
86
|
+
saveSignals(signals);
|
|
87
|
+
|
|
88
|
+
return signals;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Record first upgrade
|
|
93
|
+
*/
|
|
94
|
+
function recordFirstUpgrade(planId) {
|
|
95
|
+
const signals = getSignals();
|
|
96
|
+
|
|
97
|
+
if (!signals.firstUpgradeAt) {
|
|
98
|
+
signals.firstUpgradeAt = new Date().toISOString();
|
|
99
|
+
signals.firstUpgradePlan = planId;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
saveSignals(signals);
|
|
103
|
+
|
|
104
|
+
return signals;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get usage summary
|
|
109
|
+
*/
|
|
110
|
+
function getUsageSummary() {
|
|
111
|
+
const signals = getSignals();
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
hasScanned: signals.firstScanAt !== null,
|
|
115
|
+
hasUsedLive: signals.firstLiveAt !== null,
|
|
116
|
+
hasUpgraded: signals.firstUpgradeAt !== null,
|
|
117
|
+
totalScans: signals.totalScans,
|
|
118
|
+
totalLiveSessions: signals.totalLiveSessions,
|
|
119
|
+
daysSinceFirstScan: signals.firstScanAt
|
|
120
|
+
? Math.floor((Date.now() - new Date(signals.firstScanAt).getTime()) / (1000 * 60 * 60 * 24))
|
|
121
|
+
: 0,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Reset signals (for testing)
|
|
127
|
+
*/
|
|
128
|
+
function resetSignals() {
|
|
129
|
+
if (fs.existsSync(SIGNALS_FILE)) {
|
|
130
|
+
fs.unlinkSync(SIGNALS_FILE);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = {
|
|
135
|
+
recordFirstScan,
|
|
136
|
+
recordFirstLive,
|
|
137
|
+
recordFirstUpgrade,
|
|
138
|
+
getSignals,
|
|
139
|
+
getUsageSummary,
|
|
140
|
+
resetSignals,
|
|
141
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alert Ledger - Deduplication and Cooldown
|
|
3
|
+
* Tracks emitted alerts to prevent spam
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Compute deterministic signature from drift reasons
|
|
12
|
+
* @param {string[]} reasons - Array of drift reasons
|
|
13
|
+
* @returns {string} - SHA256 hash (hex)
|
|
14
|
+
*/
|
|
15
|
+
function computeAlertSignature(reasons) {
|
|
16
|
+
if (!Array.isArray(reasons) || reasons.length === 0) return null;
|
|
17
|
+
|
|
18
|
+
// Normalize: sort and stringify
|
|
19
|
+
const normalized = reasons.slice().sort();
|
|
20
|
+
const str = JSON.stringify(normalized);
|
|
21
|
+
return crypto.createHash('sha256').update(str).digest('hex');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getLedgerPath(outDir) {
|
|
25
|
+
const alertDir = path.join(outDir, 'alerts');
|
|
26
|
+
if (!fs.existsSync(alertDir)) fs.mkdirSync(alertDir, { recursive: true });
|
|
27
|
+
return path.join(alertDir, 'ledger.json');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadLedger(outDir) {
|
|
31
|
+
const ledgerPath = getLedgerPath(outDir);
|
|
32
|
+
if (!fs.existsSync(ledgerPath)) return { alerts: [] };
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(fs.readFileSync(ledgerPath, 'utf8'));
|
|
35
|
+
} catch {
|
|
36
|
+
return { alerts: [] };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function saveLedger(outDir, ledger) {
|
|
41
|
+
const ledgerPath = getLedgerPath(outDir);
|
|
42
|
+
fs.writeFileSync(ledgerPath, JSON.stringify(ledger, null, 2), 'utf8');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Determine if alert should be emitted based on deduplication and cooldown
|
|
47
|
+
* @param {string[]} reasons - Drift reasons
|
|
48
|
+
* @param {string} severity - 'CRITICAL' or 'WARN'
|
|
49
|
+
* @param {string} outDir - Artifacts directory
|
|
50
|
+
* @param {number} cooldownMinutes - Cooldown period (default 60)
|
|
51
|
+
* @returns {object} - {emit: boolean, reason: string, signature: string, severity: string}
|
|
52
|
+
*/
|
|
53
|
+
function shouldEmitAlert(reasons, severity, outDir, cooldownMinutes = 60) {
|
|
54
|
+
const signature = computeAlertSignature(reasons);
|
|
55
|
+
if (!signature) return { emit: false, reason: 'no drift reasons', signature: null, severity };
|
|
56
|
+
|
|
57
|
+
const ledger = loadLedger(outDir);
|
|
58
|
+
|
|
59
|
+
// First alert ever
|
|
60
|
+
if (ledger.alerts.length === 0) {
|
|
61
|
+
return { emit: true, reason: 'first alert (no ledger)', signature, severity };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Find existing alert with same signature
|
|
65
|
+
const existingAlert = ledger.alerts.find(a => a.signature === signature);
|
|
66
|
+
|
|
67
|
+
if (!existingAlert) {
|
|
68
|
+
// New drift pattern
|
|
69
|
+
return { emit: true, reason: 'new drift pattern', signature, severity };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Duplicate signature - check cooldown
|
|
73
|
+
const lastTime = new Date(existingAlert.timestamp).getTime();
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const elapsedMinutes = (now - lastTime) / (60 * 1000);
|
|
76
|
+
|
|
77
|
+
if (elapsedMinutes < cooldownMinutes) {
|
|
78
|
+
// Check severity escalation
|
|
79
|
+
if (existingAlert.severity === 'WARN' && severity === 'CRITICAL') {
|
|
80
|
+
return { emit: true, reason: 'severity escalated from WARN to CRITICAL', signature, severity };
|
|
81
|
+
}
|
|
82
|
+
// Suppressed by cooldown
|
|
83
|
+
return { emit: false, reason: `duplicate alert (cooldown active: ${Math.round(elapsedMinutes)}/${cooldownMinutes}min)`, signature, severity };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Cooldown expired
|
|
87
|
+
return { emit: true, reason: `cooldown expired (${Math.round(elapsedMinutes)}min)`, signature, severity };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Record emitted alert to ledger
|
|
92
|
+
* @param {string} signature - Alert signature
|
|
93
|
+
* @param {string} severity - 'CRITICAL' or 'WARN'
|
|
94
|
+
* @param {string} outDir - Artifacts directory
|
|
95
|
+
*/
|
|
96
|
+
function recordAlert(signature, severity, outDir) {
|
|
97
|
+
const ledger = loadLedger(outDir);
|
|
98
|
+
|
|
99
|
+
const alert = {
|
|
100
|
+
signature,
|
|
101
|
+
severity,
|
|
102
|
+
timestamp: new Date().toISOString()
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Update or add alert
|
|
106
|
+
const existingIndex = ledger.alerts.findIndex(a => a.signature === signature);
|
|
107
|
+
if (existingIndex >= 0) {
|
|
108
|
+
ledger.alerts[existingIndex] = alert;
|
|
109
|
+
} else {
|
|
110
|
+
ledger.alerts.push(alert);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
saveLedger(outDir, ledger);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
computeAlertSignature,
|
|
118
|
+
shouldEmitAlert,
|
|
119
|
+
recordAlert,
|
|
120
|
+
loadLedger
|
|
121
|
+
};
|