@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,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Verdict Builder
|
|
3
|
+
* Deterministic, explainable verdict and confidence scoring
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function clamp01(x) { return Math.max(0, Math.min(1, x)); }
|
|
7
|
+
|
|
8
|
+
const isExecutedAttempt = (attempt) => attempt && attempt.outcome !== 'SKIPPED';
|
|
9
|
+
|
|
10
|
+
function mapRunResultToVerdict(runResult) {
|
|
11
|
+
const r = (runResult || '').toUpperCase();
|
|
12
|
+
if (r === 'PASSED') return 'READY';
|
|
13
|
+
if (r === 'FAILED') return 'DO_NOT_LAUNCH';
|
|
14
|
+
if (r === 'INSUFFICIENT_EVIDENCE') return 'INSUFFICIENT_EVIDENCE';
|
|
15
|
+
return 'FRICTION'; // WARN or anything else
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function computeCoverageScore(snapshot) {
|
|
19
|
+
const disc = snapshot.discovery || {};
|
|
20
|
+
if (disc.interactionsDiscovered && disc.interactionsDiscovered > 0) {
|
|
21
|
+
return clamp01((disc.interactionsExecuted || 0) / (disc.interactionsDiscovered || 1));
|
|
22
|
+
}
|
|
23
|
+
const attempts = snapshot.attempts || [];
|
|
24
|
+
const executedAttempts = attempts.filter(isExecutedAttempt);
|
|
25
|
+
const executed = executedAttempts.length;
|
|
26
|
+
return executed > 0 ? 1 : 0; // if we have executed attempts, consider coverage adequate
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function computeEvidenceScore(snapshot) {
|
|
30
|
+
const e = snapshot.evidence || {};
|
|
31
|
+
const features = [
|
|
32
|
+
!!e.marketReportJson,
|
|
33
|
+
!!e.marketReportHtml,
|
|
34
|
+
!!e.traceZip,
|
|
35
|
+
!!(e.attemptArtifacts && Object.keys(e.attemptArtifacts).length > 0)
|
|
36
|
+
];
|
|
37
|
+
const present = features.filter(Boolean).length;
|
|
38
|
+
return clamp01(present / features.length);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function computePenalty(snapshot) {
|
|
42
|
+
const attempts = snapshot.attempts || [];
|
|
43
|
+
const failures = attempts.filter(a => a.outcome === 'FAILURE').length;
|
|
44
|
+
const frictions = attempts.filter(a => a.outcome === 'FRICTION').length;
|
|
45
|
+
const perFailure = 0.15;
|
|
46
|
+
const perFriction = 0.1;
|
|
47
|
+
const raw = failures * perFailure + frictions * perFriction;
|
|
48
|
+
return Math.min(0.5, raw);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function levelFromScore(score) {
|
|
52
|
+
if (score >= 0.66) return 'high';
|
|
53
|
+
if (score >= 0.33) return 'medium';
|
|
54
|
+
return 'low';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function collectKeyFindings(snapshot) {
|
|
58
|
+
const findings = [];
|
|
59
|
+
const attempts = snapshot.attempts || [];
|
|
60
|
+
const executedAttempts = attempts.filter(a => a.outcome !== 'SKIPPED');
|
|
61
|
+
const skippedAttempts = attempts.filter(a => a.outcome === 'SKIPPED');
|
|
62
|
+
const successful = executedAttempts.filter(a => a.outcome === 'SUCCESS').length;
|
|
63
|
+
const failed = executedAttempts.filter(a => a.outcome === 'FAILURE').length;
|
|
64
|
+
const friction = executedAttempts.filter(a => a.outcome === 'FRICTION').length;
|
|
65
|
+
|
|
66
|
+
// Attempt outcomes
|
|
67
|
+
if (executedAttempts.length > 0) {
|
|
68
|
+
findings.push(`${successful} of ${executedAttempts.length} executed attempts completed`);
|
|
69
|
+
if (failed > 0) findings.push(`${failed} attempt did not complete`);
|
|
70
|
+
if (friction > 0) findings.push(`${friction} attempt showed friction (slower or rougher than baseline)`);
|
|
71
|
+
if (skippedAttempts.length > 0) {
|
|
72
|
+
findings.push(`${skippedAttempts.length} attempt was not executed`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Market impact
|
|
77
|
+
const mi = snapshot.marketImpactSummary || {};
|
|
78
|
+
const counts = mi.countsBySeverity || { CRITICAL: 0, WARNING: 0, INFO: 0 };
|
|
79
|
+
if (counts.CRITICAL > 0 || counts.WARNING > 0) {
|
|
80
|
+
findings.push(`${counts.CRITICAL} critical risk and ${counts.WARNING} warning risk identified`);
|
|
81
|
+
} else {
|
|
82
|
+
findings.push(`No critical or warning risks identified`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Discovery coverage
|
|
86
|
+
const disc = snapshot.discovery || {};
|
|
87
|
+
if ((disc.interactionsExecuted || 0) > 0) {
|
|
88
|
+
findings.push(`${disc.interactionsExecuted}/${disc.interactionsDiscovered || 0} discovered interactions executed`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Evidence
|
|
92
|
+
const e = snapshot.evidence || {};
|
|
93
|
+
const hasReports = !!(e.marketReportJson && e.marketReportHtml);
|
|
94
|
+
const hasTraces = !!e.traceZip;
|
|
95
|
+
const hasScreenshots = !!(e.attemptArtifacts && Object.keys(e.attemptArtifacts).length > 0);
|
|
96
|
+
const evidenceCount = [hasReports, hasTraces, hasScreenshots].filter(Boolean).length;
|
|
97
|
+
if (evidenceCount === 3) {
|
|
98
|
+
findings.push(`Complete evidence: reports, traces, and screenshots captured`);
|
|
99
|
+
} else if (evidenceCount > 0) {
|
|
100
|
+
findings.push(`Partial evidence: ${[hasReports && 'reports', hasTraces && 'traces', hasScreenshots && 'screenshots'].filter(Boolean).join(', ')}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return findings.slice(0, 7);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function collectEvidenceRefs(snapshot) {
|
|
107
|
+
const e = snapshot.evidence || {};
|
|
108
|
+
const reportPaths = [];
|
|
109
|
+
if (e.marketReportJson) reportPaths.push(e.marketReportJson);
|
|
110
|
+
if (e.marketReportHtml) reportPaths.push(e.marketReportHtml);
|
|
111
|
+
const traces = e.traceZip ? [e.traceZip] : [];
|
|
112
|
+
const screenshots = [];
|
|
113
|
+
if (e.attemptArtifacts) {
|
|
114
|
+
Object.values(e.attemptArtifacts).forEach(a => {
|
|
115
|
+
if (a.screenshotDir) screenshots.push(a.screenshotDir);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const affectedPages = (snapshot.discovery?.pagesVisited || []).slice(0, 10);
|
|
119
|
+
return { reportPaths, traces, screenshots, affectedPages };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function defaultLimits() {
|
|
123
|
+
return [
|
|
124
|
+
'Live-site variability may affect signals',
|
|
125
|
+
'Browser/viewport differences can change outcomes',
|
|
126
|
+
'Coverage limited to configured attempts and discovered interactions'
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function situationalLimits(snapshot) {
|
|
131
|
+
const limits = [];
|
|
132
|
+
const attempts = snapshot.attempts || [];
|
|
133
|
+
const skippedCount = attempts.filter(a => a.outcome === 'SKIPPED').length;
|
|
134
|
+
const disc = snapshot.discovery || {};
|
|
135
|
+
|
|
136
|
+
// Skipped attempts
|
|
137
|
+
if (skippedCount > 0) {
|
|
138
|
+
limits.push(`${skippedCount} additional attempt was not executed; verdict is therefore limited to fewer paths`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Discovery coverage gaps
|
|
142
|
+
const discovered = disc.interactionsDiscovered || 0;
|
|
143
|
+
const executed = disc.interactionsExecuted || 0;
|
|
144
|
+
if (discovered > 0 && executed < discovered) {
|
|
145
|
+
limits.push(`${discovered - executed} of ${discovered} discovered interactions were not tested`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Evidence gaps
|
|
149
|
+
const e = snapshot.evidence || {};
|
|
150
|
+
if (!e.traceZip) {
|
|
151
|
+
limits.push(`Detailed timing traces were not captured; timing-related issues may not be visible`);
|
|
152
|
+
}
|
|
153
|
+
if (!e.marketReportJson) {
|
|
154
|
+
limits.push(`Market risk analysis was not run; business impact is unassessed`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Environment variability
|
|
158
|
+
limits.push(`Live site behavior shifts over time; this verdict reflects this moment`);
|
|
159
|
+
|
|
160
|
+
// Browser/environment
|
|
161
|
+
limits.push(`Results are specific to this browser and viewport; other browsers may differ`);
|
|
162
|
+
|
|
163
|
+
return limits.slice(0, 6);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build unified verdict from snapshot
|
|
168
|
+
*/
|
|
169
|
+
function deriveRunResult(snapshot) {
|
|
170
|
+
const meta = snapshot.meta || {};
|
|
171
|
+
if (meta.result) return meta.result;
|
|
172
|
+
const attempts = snapshot.attempts || [];
|
|
173
|
+
|
|
174
|
+
// Count different outcome types
|
|
175
|
+
const executedAttempts = attempts.filter(a => a.outcome !== 'SKIPPED' && a.outcome !== 'NOT_APPLICABLE');
|
|
176
|
+
const discoveryFailedAttempts = attempts.filter(a => a.outcome === 'DISCOVERY_FAILED');
|
|
177
|
+
const notApplicableAttempts = attempts.filter(a => a.outcome === 'NOT_APPLICABLE');
|
|
178
|
+
const skippedCount = attempts.filter(a => a.outcome === 'SKIPPED').length;
|
|
179
|
+
|
|
180
|
+
const executed = executedAttempts.length;
|
|
181
|
+
const successful = executedAttempts.filter(a => a.outcome === 'SUCCESS').length;
|
|
182
|
+
const failed = executedAttempts.filter(a => a.outcome === 'FAILURE').length;
|
|
183
|
+
|
|
184
|
+
// If NOTHING executed (all skipped, not applicable, or discovery failed), it's insufficient
|
|
185
|
+
if (executed === 0 && (discoveryFailedAttempts.length > 0 || skippedCount > 0)) {
|
|
186
|
+
return 'INSUFFICIENT_EVIDENCE';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (executed === 0) return 'WARN';
|
|
190
|
+
if (failed === 0 && successful === executed) return 'PASSED';
|
|
191
|
+
if (failed === executed) return 'FAILED';
|
|
192
|
+
return 'WARN';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildVerdictExplanation(snapshot, verdictStr, executedCount, successCount, failureCount, skippedCount, evidenceScore, penaltyApplied) {
|
|
196
|
+
let why = '';
|
|
197
|
+
|
|
198
|
+
if (verdictStr === 'READY') {
|
|
199
|
+
why = `All ${executedCount} executed attempts completed successfully without critical issues. `;
|
|
200
|
+
if (skippedCount > 0) {
|
|
201
|
+
why += `${skippedCount} additional attempt was not executed. `;
|
|
202
|
+
}
|
|
203
|
+
why += `Evidence is ${evidenceScore >= 0.75 ? 'complete' : 'sufficient'}. `;
|
|
204
|
+
why += `This differs from FRICTION because all attempts succeeded, and from DO_NOT_LAUNCH because no critical issues were found.`;
|
|
205
|
+
} else if (verdictStr === 'DO_NOT_LAUNCH') {
|
|
206
|
+
why = `${failureCount} critical issue was found, preventing safe launch. `;
|
|
207
|
+
why += `This differs from READY because critical issues must be resolved, and from FRICTION because severity exceeds acceptable limits.`;
|
|
208
|
+
} else if (verdictStr === 'INSUFFICIENT_EVIDENCE') {
|
|
209
|
+
why = `No meaningful attempts executed on this site. Most attempts were skipped (not applicable to site) or element discovery failed. `;
|
|
210
|
+
why += `This site may not match the tested user journeys, or it may be uninstrumented (missing expected UI elements/patterns). `;
|
|
211
|
+
why += `Verdict cannot be determined without successful execution of at least one relevant journey.`;
|
|
212
|
+
} else {
|
|
213
|
+
why = `Results show ${successCount} of ${executedCount} attempted success with some roughness. `;
|
|
214
|
+
if (failureCount > 0) {
|
|
215
|
+
why += `${failureCount} attempt did not complete. `;
|
|
216
|
+
}
|
|
217
|
+
why += `This differs from READY because outcomes were mixed, and from DO_NOT_LAUNCH because no critical failure was found.`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return why;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function deriveNextRunHint(snapshot, confidenceLevel) {
|
|
224
|
+
// Non-intrusive guard: suppress when confidence is high and limits are empty
|
|
225
|
+
const limits = situationalLimits(snapshot) || [];
|
|
226
|
+
if (confidenceLevel === 'high' && limits.length === 0) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// (a) Evidence completeness
|
|
231
|
+
const e = snapshot.evidence || {};
|
|
232
|
+
const hasReports = !!(e.marketReportJson && e.marketReportHtml);
|
|
233
|
+
const hasTrace = !!e.traceZip;
|
|
234
|
+
const hasScreenshots = !!(e.attemptArtifacts && Object.keys(e.attemptArtifacts).length > 0);
|
|
235
|
+
if (!hasTrace || !hasScreenshots) {
|
|
236
|
+
return 'Capture additional evidence (e.g., traces) to strengthen timing and flow visibility.';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// (b) Coverage completeness
|
|
240
|
+
const disc = snapshot.discovery || {};
|
|
241
|
+
const discovered = disc.interactionsDiscovered || 0;
|
|
242
|
+
const executed = disc.interactionsExecuted || 0;
|
|
243
|
+
if (discovered > 0 && executed < discovered) {
|
|
244
|
+
return 'Expand coverage to include additional discovered interactions.';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// (c) Recurring timeouts/flakiness within this run
|
|
248
|
+
const attempts = snapshot.attempts || [];
|
|
249
|
+
const flows = snapshot.flows || [];
|
|
250
|
+
const timeoutRegex = /(timeout|timed out)/i;
|
|
251
|
+
const timeoutCount = (
|
|
252
|
+
attempts.filter(a => typeof a.error === 'string' && timeoutRegex.test(a.error)).length +
|
|
253
|
+
flows.filter(f => typeof f.error === 'string' && timeoutRegex.test(f.error)).length
|
|
254
|
+
);
|
|
255
|
+
const frictionCount = attempts.filter(a => a.friction && a.friction.isFriction).length;
|
|
256
|
+
if (timeoutCount >= 2 || frictionCount >= 2) {
|
|
257
|
+
return 'Use a conservative timeout profile to reduce flakiness.';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// (d) Otherwise: no hint
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function buildVerdict(snapshot) {
|
|
265
|
+
const runRes = deriveRunResult(snapshot);
|
|
266
|
+
const verdictStr = mapRunResultToVerdict(runRes || 'WARN');
|
|
267
|
+
|
|
268
|
+
const outcomeScore = verdictStr === 'READY' ? 1 : verdictStr === 'DO_NOT_LAUNCH' ? 0.2 : 0.5;
|
|
269
|
+
const coverageScore = computeCoverageScore(snapshot); // 0..1
|
|
270
|
+
const evidenceScore = computeEvidenceScore(snapshot); // 0..1
|
|
271
|
+
const penalty = computePenalty(snapshot); // 0..0.5
|
|
272
|
+
|
|
273
|
+
const raw = 0.5 * outcomeScore + 0.2 * coverageScore + 0.3 * evidenceScore - penalty;
|
|
274
|
+
const score = clamp01(raw);
|
|
275
|
+
const level = levelFromScore(score);
|
|
276
|
+
|
|
277
|
+
// Detailed metrics for explanation
|
|
278
|
+
const attempts = snapshot.attempts || [];
|
|
279
|
+
const executedAttempts = attempts.filter(a => a.outcome !== 'SKIPPED');
|
|
280
|
+
const executedCount = executedAttempts.length;
|
|
281
|
+
const successCount = executedAttempts.filter(a => a.outcome === 'SUCCESS').length;
|
|
282
|
+
const failureCount = executedAttempts.filter(a => a.outcome === 'FAILURE').length;
|
|
283
|
+
const skippedCount = attempts.filter(a => a.outcome === 'SKIPPED').length;
|
|
284
|
+
|
|
285
|
+
const reasons = [];
|
|
286
|
+
if (successCount === executedCount && executedCount > 0) {
|
|
287
|
+
reasons.push(`All ${executedCount} executed attempts completed`);
|
|
288
|
+
} else if (successCount > 0) {
|
|
289
|
+
reasons.push(`${successCount} of ${executedCount} executed attempts completed`);
|
|
290
|
+
}
|
|
291
|
+
if (coverageScore > 0.66) reasons.push('Good coverage from discovered interactions');
|
|
292
|
+
if (evidenceScore >= 0.75) reasons.push('Complete evidence captured (reports, traces, screenshots)');
|
|
293
|
+
if (penalty > 0) reasons.push(`${failureCount > 0 ? 'Incomplete attempts' : 'Friction signals'} observed (${(penalty * 100).toFixed(0)}% impact)`);
|
|
294
|
+
// Do not include 'not executed' in drivers; it appears in findings/limits
|
|
295
|
+
|
|
296
|
+
const why = buildVerdictExplanation(
|
|
297
|
+
snapshot,
|
|
298
|
+
verdictStr,
|
|
299
|
+
executedCount,
|
|
300
|
+
successCount,
|
|
301
|
+
failureCount,
|
|
302
|
+
skippedCount,
|
|
303
|
+
evidenceScore,
|
|
304
|
+
penalty > 0
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const nextRunHint = deriveNextRunHint(snapshot, level);
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
verdict: verdictStr,
|
|
311
|
+
confidence: { level, score, reasons },
|
|
312
|
+
why,
|
|
313
|
+
keyFindings: collectKeyFindings(snapshot),
|
|
314
|
+
evidence: collectEvidenceRefs(snapshot),
|
|
315
|
+
limits: situationalLimits(snapshot),
|
|
316
|
+
nextRunHint
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
module.exports = { buildVerdict };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verdict mapping utilities
|
|
3
|
+
* Canonical user-facing verdicts: READY, FRICTION, DO_NOT_LAUNCH
|
|
4
|
+
* Internal engine verdicts (legacy): OBSERVED, PARTIAL, INSUFFICIENT_DATA
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
function toCanonicalVerdict(internalVerdict) {
|
|
8
|
+
switch (String(internalVerdict || '').toUpperCase()) {
|
|
9
|
+
// Internal engine verdicts
|
|
10
|
+
case 'OBSERVED':
|
|
11
|
+
return 'READY';
|
|
12
|
+
case 'PARTIAL':
|
|
13
|
+
return 'FRICTION';
|
|
14
|
+
case 'INSUFFICIENT_DATA':
|
|
15
|
+
return 'DO_NOT_LAUNCH';
|
|
16
|
+
// Synonyms from other components
|
|
17
|
+
case 'PASS':
|
|
18
|
+
case 'SUCCESS':
|
|
19
|
+
return 'READY';
|
|
20
|
+
case 'WARN':
|
|
21
|
+
case 'WARNING':
|
|
22
|
+
case 'FRICTION':
|
|
23
|
+
return 'FRICTION';
|
|
24
|
+
case 'FAIL':
|
|
25
|
+
case 'FAILURE':
|
|
26
|
+
case 'DO_NOT_LAUNCH':
|
|
27
|
+
return 'DO_NOT_LAUNCH';
|
|
28
|
+
default:
|
|
29
|
+
return 'DO_NOT_LAUNCH';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toInternalVerdict(canonicalVerdict) {
|
|
34
|
+
switch (String(canonicalVerdict || '').toUpperCase()) {
|
|
35
|
+
case 'READY':
|
|
36
|
+
return 'OBSERVED';
|
|
37
|
+
case 'FRICTION':
|
|
38
|
+
return 'PARTIAL';
|
|
39
|
+
case 'DO_NOT_LAUNCH':
|
|
40
|
+
return 'INSUFFICIENT_DATA';
|
|
41
|
+
default:
|
|
42
|
+
return 'INSUFFICIENT_DATA';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function mapExitCodeFromCanonical(canonicalVerdict) {
|
|
47
|
+
switch (String(canonicalVerdict || '').toUpperCase()) {
|
|
48
|
+
case 'READY':
|
|
49
|
+
return 0;
|
|
50
|
+
case 'FRICTION':
|
|
51
|
+
return 1;
|
|
52
|
+
case 'DO_NOT_LAUNCH':
|
|
53
|
+
default:
|
|
54
|
+
return 2;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { toCanonicalVerdict, toInternalVerdict, mapExitCodeFromCanonical };
|
|
59
|
+
|
|
60
|
+
// Journey scan mapping (SAFE/RISK/DO_NOT_LAUNCH → canonical)
|
|
61
|
+
function toCanonicalJourneyVerdict(journeyVerdict) {
|
|
62
|
+
switch (String(journeyVerdict || '').toUpperCase()) {
|
|
63
|
+
case 'SAFE':
|
|
64
|
+
return 'READY';
|
|
65
|
+
case 'RISK':
|
|
66
|
+
return 'FRICTION';
|
|
67
|
+
case 'DO_NOT_LAUNCH':
|
|
68
|
+
return 'DO_NOT_LAUNCH';
|
|
69
|
+
default:
|
|
70
|
+
return 'DO_NOT_LAUNCH';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports.toCanonicalJourneyVerdict = toCanonicalJourneyVerdict;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal-based wait helper for actions.
|
|
3
|
+
* Prefers navigation/network/DOM quiet signals; falls back to bounded timeout.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_WAIT = 3500; // bounded cap
|
|
7
|
+
const QUIET_WINDOW_MS = 300; // DOM must be quiet for this window
|
|
8
|
+
|
|
9
|
+
async function waitForOutcome(page, options = {}) {
|
|
10
|
+
const {
|
|
11
|
+
actionType = 'click',
|
|
12
|
+
baseOrigin = null,
|
|
13
|
+
initialUrl = null,
|
|
14
|
+
expectNavigation = false,
|
|
15
|
+
includeUrlChange = true,
|
|
16
|
+
maxWait = DEFAULT_MAX_WAIT,
|
|
17
|
+
} = options;
|
|
18
|
+
|
|
19
|
+
let resolved = false;
|
|
20
|
+
const cleanupFns = [];
|
|
21
|
+
|
|
22
|
+
const resolveWith = (reason, details = {}) => {
|
|
23
|
+
if (resolved) return;
|
|
24
|
+
resolved = true;
|
|
25
|
+
cleanupFns.forEach((fn) => {
|
|
26
|
+
try { fn(); } catch {}
|
|
27
|
+
});
|
|
28
|
+
return { reason, details };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const tasks = [];
|
|
32
|
+
const start = Date.now();
|
|
33
|
+
|
|
34
|
+
// Navigation signal
|
|
35
|
+
if (expectNavigation) {
|
|
36
|
+
const navPromise = page.waitForNavigation({ timeout: maxWait }).then(() => resolveWith('navigation')).catch(() => {});
|
|
37
|
+
tasks.push(navPromise);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// URL change signal (even without full nav event)
|
|
41
|
+
if (expectNavigation && includeUrlChange && initialUrl) {
|
|
42
|
+
const urlPromise = page.waitForURL((u) => u !== initialUrl, { timeout: maxWait }).then(() => resolveWith('url-change')).catch(() => {});
|
|
43
|
+
tasks.push(urlPromise);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Network signal: watch for POST/PUT same-origin
|
|
47
|
+
const networkPromise = new Promise((res) => {
|
|
48
|
+
const handler = (response) => {
|
|
49
|
+
try {
|
|
50
|
+
const req = response.request();
|
|
51
|
+
const method = (req.method() || '').toUpperCase();
|
|
52
|
+
const url = req.url();
|
|
53
|
+
const status = response.status();
|
|
54
|
+
const originOk = baseOrigin ? new URL(url).origin === baseOrigin : true;
|
|
55
|
+
if ((method === 'POST' || method === 'PUT') && originOk) {
|
|
56
|
+
const out = resolveWith('network', { method, status, url });
|
|
57
|
+
if (out) res(out);
|
|
58
|
+
}
|
|
59
|
+
} catch {}
|
|
60
|
+
};
|
|
61
|
+
page.on('response', handler);
|
|
62
|
+
cleanupFns.push(() => page.off('response', handler));
|
|
63
|
+
});
|
|
64
|
+
tasks.push(networkPromise);
|
|
65
|
+
|
|
66
|
+
// DOM quiet signal via MutationObserver
|
|
67
|
+
const domPromise = new Promise((res) => {
|
|
68
|
+
let lastMutation = Date.now();
|
|
69
|
+
let hasMutated = false;
|
|
70
|
+
const quietCheck = () => {
|
|
71
|
+
if (!hasMutated) return;
|
|
72
|
+
if (Date.now() - lastMutation >= QUIET_WINDOW_MS) {
|
|
73
|
+
const out = resolveWith('dom-quiet');
|
|
74
|
+
if (out) res(out);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const interval = setInterval(quietCheck, 80);
|
|
78
|
+
cleanupFns.push(() => clearInterval(interval));
|
|
79
|
+
page.evaluate(() => {
|
|
80
|
+
window.__guardianLastMutation = Date.now();
|
|
81
|
+
window.__guardianHasMutated = false;
|
|
82
|
+
if (window.__guardianDomObserver) {
|
|
83
|
+
try { window.__guardianDomObserver.disconnect(); } catch (e) {}
|
|
84
|
+
}
|
|
85
|
+
const obs = new MutationObserver(() => {
|
|
86
|
+
window.__guardianLastMutation = Date.now();
|
|
87
|
+
window.__guardianHasMutated = true;
|
|
88
|
+
});
|
|
89
|
+
obs.observe(document.documentElement || document.body, { childList: true, subtree: true, attributes: true, characterData: true });
|
|
90
|
+
window.__guardianDomObserver = obs;
|
|
91
|
+
}).catch(() => {});
|
|
92
|
+
const pollMutation = setInterval(async () => {
|
|
93
|
+
try {
|
|
94
|
+
const data = await page.evaluate(() => ({
|
|
95
|
+
last: window.__guardianLastMutation || Date.now(),
|
|
96
|
+
mutated: !!window.__guardianHasMutated
|
|
97
|
+
}));
|
|
98
|
+
lastMutation = data.last;
|
|
99
|
+
if (data.mutated) hasMutated = true;
|
|
100
|
+
} catch {}
|
|
101
|
+
}, 120);
|
|
102
|
+
cleanupFns.push(() => clearInterval(pollMutation));
|
|
103
|
+
});
|
|
104
|
+
tasks.push(domPromise);
|
|
105
|
+
|
|
106
|
+
// Bounded fallback
|
|
107
|
+
const timeoutPromise = new Promise((res) => {
|
|
108
|
+
const t = setTimeout(() => {
|
|
109
|
+
const out = resolveWith('timeout', { elapsedMs: Date.now() - start });
|
|
110
|
+
if (out) res(out);
|
|
111
|
+
}, maxWait);
|
|
112
|
+
cleanupFns.push(() => clearTimeout(t));
|
|
113
|
+
});
|
|
114
|
+
tasks.push(timeoutPromise);
|
|
115
|
+
|
|
116
|
+
const first = await Promise.race(tasks);
|
|
117
|
+
return first || { reason: 'timeout', details: { elapsedMs: maxWait } };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { waitForOutcome };
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { isCiMode } = require('./ci-mode');
|
|
4
|
+
const { formatRunSummary } = require('./run-summary');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_DEBOUNCE_MS = 400;
|
|
7
|
+
const DEFAULT_MAX_FILES = 5;
|
|
8
|
+
|
|
9
|
+
function isIgnored(filePath, artifactsDir = './artifacts') {
|
|
10
|
+
const normalized = path.normalize(filePath);
|
|
11
|
+
const parts = normalized.split(path.sep).filter(Boolean);
|
|
12
|
+
const ignorePrefixes = [
|
|
13
|
+
'node_modules',
|
|
14
|
+
'.git',
|
|
15
|
+
'.odavl-guardian',
|
|
16
|
+
'dist',
|
|
17
|
+
'build'
|
|
18
|
+
];
|
|
19
|
+
if (artifactsDir) {
|
|
20
|
+
ignorePrefixes.push(path.normalize(artifactsDir));
|
|
21
|
+
}
|
|
22
|
+
// Ignore any changes within artifacts directory (run outputs)
|
|
23
|
+
return parts.some(p => ignorePrefixes.includes(p));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function collectWatchPaths(config) {
|
|
27
|
+
const roots = [
|
|
28
|
+
'config',
|
|
29
|
+
'flows',
|
|
30
|
+
'policies',
|
|
31
|
+
'data',
|
|
32
|
+
'scripts',
|
|
33
|
+
'test'
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
const paths = [];
|
|
38
|
+
roots.forEach(p => {
|
|
39
|
+
const full = path.resolve(p);
|
|
40
|
+
if (seen.has(full)) return;
|
|
41
|
+
if (fs.existsSync(full)) {
|
|
42
|
+
seen.add(full);
|
|
43
|
+
paths.push(full);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return paths;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatChangedFiles(changed) {
|
|
51
|
+
if (!changed.length) return 'unknown';
|
|
52
|
+
const unique = Array.from(new Set(changed));
|
|
53
|
+
const shown = unique.slice(0, DEFAULT_MAX_FILES).map(f => path.relative(process.cwd(), f));
|
|
54
|
+
const more = unique.length - shown.length;
|
|
55
|
+
if (more > 0) {
|
|
56
|
+
shown.push(`… ${more} more`);
|
|
57
|
+
}
|
|
58
|
+
return shown.join(', ');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function startWatchMode(config, deps = {}) {
|
|
62
|
+
const runReality = deps.runReality || require('./reality').executeReality;
|
|
63
|
+
const watchFactory = deps.watchFactory || fs.watch;
|
|
64
|
+
const debounceMs = deps.debounceMs || config.watchDebounceMs || DEFAULT_DEBOUNCE_MS;
|
|
65
|
+
const log = deps.log || console.log;
|
|
66
|
+
const warn = deps.warn || console.warn;
|
|
67
|
+
const exitFn = deps.exit || process.exit.bind(process);
|
|
68
|
+
const isCi = deps.isCi != null ? deps.isCi : isCiMode();
|
|
69
|
+
const onRunComplete = deps.onRunComplete || null;
|
|
70
|
+
const onWatcher = deps.onWatcher || null;
|
|
71
|
+
const bindSignal = deps.bindSignal || process.on.bind(process);
|
|
72
|
+
|
|
73
|
+
const quietConfig = {
|
|
74
|
+
...config,
|
|
75
|
+
quiet: true,
|
|
76
|
+
flowOptions: { ...(config.flowOptions || {}), quiet: true }
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (isCi) {
|
|
80
|
+
warn('CI detected; --watch ignored');
|
|
81
|
+
const once = await runReality({ ...config, watch: false });
|
|
82
|
+
return { watchStarted: false, exitCode: once.exitCode };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let running = false;
|
|
86
|
+
let pending = false;
|
|
87
|
+
let stopped = false;
|
|
88
|
+
let debounceTimer = null;
|
|
89
|
+
let changedFiles = [];
|
|
90
|
+
let lastExitCode = 0;
|
|
91
|
+
const watchers = [];
|
|
92
|
+
|
|
93
|
+
const watchPaths = collectWatchPaths(config);
|
|
94
|
+
|
|
95
|
+
const runOnce = async (label) => {
|
|
96
|
+
if (stopped) return { exitCode: lastExitCode };
|
|
97
|
+
running = true;
|
|
98
|
+
const result = await runReality(quietConfig);
|
|
99
|
+
lastExitCode = result.exitCode || 0;
|
|
100
|
+
log(formatRunSummary({
|
|
101
|
+
flowResults: result.flowResults || [],
|
|
102
|
+
diffResult: result.diffResult || null,
|
|
103
|
+
baselineCreated: result.baselineCreated || false,
|
|
104
|
+
exitCode: lastExitCode
|
|
105
|
+
}, { label: 'Summary' }));
|
|
106
|
+
running = false;
|
|
107
|
+
if (onRunComplete) {
|
|
108
|
+
onRunComplete(result);
|
|
109
|
+
}
|
|
110
|
+
if (pending && !stopped) {
|
|
111
|
+
pending = false;
|
|
112
|
+
scheduleRun();
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const scheduleRun = () => {
|
|
118
|
+
if (running) {
|
|
119
|
+
pending = true;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (debounceTimer) {
|
|
123
|
+
clearTimeout(debounceTimer);
|
|
124
|
+
}
|
|
125
|
+
debounceTimer = setTimeout(() => {
|
|
126
|
+
const filesLabel = formatChangedFiles(changedFiles);
|
|
127
|
+
changedFiles = [];
|
|
128
|
+
log(`Change detected: ${filesLabel}`);
|
|
129
|
+
runOnce('change');
|
|
130
|
+
}, debounceMs);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const handleFsEvent = (basePath) => (event, filename) => {
|
|
134
|
+
if (stopped) return;
|
|
135
|
+
const candidate = filename ? path.join(basePath, filename) : basePath;
|
|
136
|
+
if (isIgnored(candidate, config.artifactsDir)) return;
|
|
137
|
+
changedFiles.push(candidate);
|
|
138
|
+
scheduleRun();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
for (const p of watchPaths) {
|
|
142
|
+
try {
|
|
143
|
+
const watcher = watchFactory(p, { recursive: true }, handleFsEvent(p));
|
|
144
|
+
watchers.push(watcher);
|
|
145
|
+
if (onWatcher) onWatcher(watcher);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
warn(`Watch attach failed for ${p}: ${err.message}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
log('WATCH MODE: ON');
|
|
152
|
+
runOnce('initial').catch(err => {
|
|
153
|
+
warn(`Watch run failed: ${err.message}`);
|
|
154
|
+
running = false;
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const stop = () => {
|
|
158
|
+
stopped = true;
|
|
159
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
160
|
+
watchers.forEach(w => {
|
|
161
|
+
try { if (w && typeof w.close === 'function') w.close(); } catch {}
|
|
162
|
+
});
|
|
163
|
+
return lastExitCode;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
bindSignal('SIGINT', () => {
|
|
167
|
+
const code = stop();
|
|
168
|
+
exitFn(code);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
watchStarted: true,
|
|
173
|
+
stop,
|
|
174
|
+
getLastExitCode: () => lastExitCode,
|
|
175
|
+
simulateChange: (filePath) => handleFsEvent(path.dirname(filePath) || '.')("change", path.basename(filePath)),
|
|
176
|
+
isRunning: () => running,
|
|
177
|
+
hasPending: () => pending
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = { startWatchMode, collectWatchPaths, isIgnored };
|