prview 0.4.0

PR Review & Artifact Generator - cross-language PR analysis tool
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
//! Previous-run deltas, run history and flaky-check scoring.

use super::*;

/// Load coverage ratio and untested count from the previous run's report.json.
///
/// Uses the `latest` symlink to find the previous run. Falls back through:
/// 1. `regression.tests.coverage_ratio` (preferred — our own output)
/// 2. `quality.coverage.heuristic_ratio` (fallback for pre-regression runs)
/// 3. Sorted sibling timestamp dirs if `latest` points to current run
///
/// Returns `(base_coverage_ratio, base_untested_critical_count)`.
/// Completely defensive: any error returns `(None, None)`.
pub(crate) fn load_previous_coverage(out_dir: &Path) -> (Option<f64>, Option<usize>) {
    let parent = match out_dir.parent() {
        Some(p) => p,
        None => return (None, None),
    };

    // Try `latest` symlink first
    let prev_report = find_previous_report(parent, out_dir);
    let json = match prev_report {
        Some(content) => content,
        None => return (None, None),
    };

    // Extract coverage_ratio: regression.tests preferred, quality.coverage fallback
    let coverage_ratio = json
        .pointer("/regression/tests/coverage_ratio")
        .and_then(|v| v.as_f64())
        .or_else(|| {
            json.pointer("/quality/coverage/heuristic_ratio")
                .and_then(|v| v.as_f64())
        });

    // Extract untested count — try code-only first, fallback to critical
    let untested_count = json
        .pointer("/regression/tests/untested_code_count")
        .and_then(|v| v.as_u64())
        .or_else(|| {
            json.pointer("/regression/tests/untested_critical_count")
                .and_then(|v| v.as_u64())
        })
        .map(|v| v as usize);

    (coverage_ratio, untested_count)
}

/// Find and parse report.json from the previous run.
///
/// Strategy:
/// 1. Follow `latest` symlink — if it doesn't point to current run, use it
/// 2. Otherwise, sort sibling timestamp dirs and pick the one before current
pub(crate) fn find_previous_report(
    branch_dir: &Path,
    current_dir: &Path,
) -> Option<serde_json::Value> {
    let latest = branch_dir.join("latest");

    // Try latest symlink first (fallthrough on any error to sort-based fallback)
    if latest.is_symlink()
        && let Ok(resolved) = fs::read_link(&latest)
    {
        let resolved_full = if resolved.is_relative() {
            branch_dir.join(&resolved)
        } else {
            resolved
        };
        if resolved_full != *current_dir
            && let Ok(content) = fs::read_to_string(resolved_full.join("report.json"))
            && let Ok(json) = serde_json::from_str(&content)
        {
            return Some(json);
        }
    }

    // Fallback: sort sibling timestamp dirs, pick the one before current
    let current_name = current_dir.file_name()?.to_str()?;
    let mut dirs: Vec<String> = fs::read_dir(branch_dir)
        .ok()?
        .filter_map(|e| e.ok())
        .filter(|e| {
            let name = e.file_name().to_string_lossy().to_string();
            e.path().is_dir() && name != "latest" && name != current_name
        })
        .map(|e| e.file_name().to_string_lossy().to_string())
        .collect();

    dirs.sort();

    // Find the largest timestamp that is less than current
    let prev_name = dirs.iter().rev().find(|d| d.as_str() < current_name)?;
    let prev_dir = branch_dir.join(prev_name);
    let content = fs::read_to_string(prev_dir.join("report.json")).ok()?;
    serde_json::from_str(&content).ok()
}

/// Load delta from a previous run's report.json via the `latest` symlink.
/// Returns `None` on any error — completely defensive.
pub(crate) fn load_previous_run_delta(out_dir: &Path) -> Option<PreviousRunDelta> {
    // out_dir is e.g. ~/.prview/runs/<repo>/<branch>/20260301-120000/
    // latest symlink lives in the parent: ~/.prview/runs/<repo>/<branch>/latest
    let parent = out_dir.parent()?;
    let latest = parent.join("latest");

    // Only follow existing symlinks (not the current run dir)
    if !latest.is_symlink() {
        return None;
    }

    // Resolve the symlink target and ensure it's not pointing to current out_dir
    let resolved = fs::read_link(&latest).ok()?;
    let resolved_full = if resolved.is_relative() {
        parent.join(&resolved)
    } else {
        resolved
    };
    if resolved_full == *out_dir {
        return None;
    }

    let report_path = resolved_full.join("report.json");
    let content = fs::read_to_string(&report_path).ok()?;
    let json: serde_json::Value = serde_json::from_str(&content).ok()?;

    // Extract fields defensively
    let gate = json.get("gate")?;
    let quality_pass_before = gate.get("quality_pass")?.as_bool()?;

    let checks_arr = json.get("checks")?.as_array()?;
    let checks_passed_before = checks_arr
        .iter()
        .filter(|c| c.get("status").and_then(|s| s.as_str()) == Some("PASS"))
        .count();
    let checks_failed_before = checks_arr
        .iter()
        .filter(|c| {
            let s = c.get("status").and_then(|s| s.as_str()).unwrap_or("");
            s == "FAIL" || s == "ERROR"
        })
        .count();

    let findings_before = json
        .get("quality")
        .and_then(|q| q.get("sarif"))
        .and_then(|s| s.get("findings_count"))
        .and_then(|v| v.as_u64())
        .unwrap_or(0) as usize;

    // PRV-P2-3: Breaking changes delta.
    // report.json schema (v1) stores `quality.breaking_changes.removed_public_symbols_count`
    // and `quality.breaking_changes.new_env_vars_count`. If these fields are missing
    // (e.g. report.json from a version before these fields existed), we fall back to 0.
    // This means the delta may show "+N new breaking changes" even if the previous run
    // had the same count — known limitation for pre-v1 reports.
    let breaking_section = json.get("quality").and_then(|q| q.get("breaking_changes"));
    let removed = breaking_section
        .and_then(|b| b.get("removed_public_symbols_count"))
        .and_then(|v| v.as_u64())
        .unwrap_or(0);
    let env = breaking_section
        .and_then(|b| b.get("new_env_vars_count"))
        .and_then(|v| v.as_u64())
        .unwrap_or(0);
    let breaking_before = (removed + env) as usize;

    Some(PreviousRunDelta {
        checks_passed_before,
        checks_failed_before,
        findings_before,
        breaking_before,
        quality_pass_before,
    })
}

/// Load historical run data from sibling timestamp directories.
///
/// `out_dir` is e.g. `~/.prview/runs/<repo>/<branch>/20260302-143022/`.
/// We scan the parent (`~/.prview/runs/<repo>/<branch>/`) for all timestamped
/// subdirectories, read each `report.json`, and extract summary metrics.
/// Returns newest-first, capped at `max_runs`. Completely defensive:
/// any parse error or missing file causes that run to be skipped silently.
pub(crate) fn load_run_history(out_dir: &Path, max_runs: usize) -> Vec<HistoricalRun> {
    let parent = match out_dir.parent() {
        Some(p) => p,
        None => return Vec::new(),
    };

    // List all subdirectories (excluding `latest` symlink and current run)
    let entries = match fs::read_dir(parent) {
        Ok(e) => e,
        Err(_) => return Vec::new(),
    };

    let mut dirs: Vec<String> = entries
        .filter_map(|e| e.ok())
        .filter(|e| {
            let name = e.file_name();
            let name_str = name.to_string_lossy();
            // Skip `latest` symlink and non-directories
            name_str != "latest"
                && e.path().is_dir()
                // Must look like a timestamp (starts with digit)
                && name_str.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)
        })
        .map(|e| e.file_name().to_string_lossy().to_string())
        .collect();

    // Sort lexicographically (timestamps sort chronologically) and take last N
    dirs.sort();
    if dirs.len() > max_runs {
        dirs = dirs.split_off(dirs.len() - max_runs);
    }

    // Parse each report.json, skip on any error
    let mut history: Vec<HistoricalRun> = dirs
        .iter()
        .filter_map(|dir_name| {
            let report_path = parent.join(dir_name).join("report.json");
            let content = fs::read_to_string(&report_path).ok()?;
            let json: serde_json::Value = serde_json::from_str(&content).ok()?;

            let gate = json.get("gate")?;
            let quality_pass = gate.get("quality_pass")?.as_bool()?;

            let checks_arr = json.get("checks")?.as_array()?;
            let checks_passed = checks_arr
                .iter()
                .filter(|c| c.get("status").and_then(|s| s.as_str()) == Some("PASS"))
                .count();
            let checks_failed = checks_arr
                .iter()
                .filter(|c| {
                    let s = c.get("status").and_then(|s| s.as_str()).unwrap_or("");
                    s == "FAIL" || s == "ERROR"
                })
                .count();
            let checks_warned = checks_arr
                .iter()
                .filter(|c| c.get("status").and_then(|s| s.as_str()) == Some("WARN"))
                .count();

            let findings_count = json
                .get("quality")
                .and_then(|q| q.get("sarif"))
                .and_then(|s| s.get("findings_count"))
                .and_then(|v| v.as_u64())
                .unwrap_or(0) as usize;

            Some(HistoricalRun {
                timestamp: dir_name.clone(),
                checks_passed,
                checks_failed,
                checks_warned,
                quality_pass,
                findings_count,
            })
        })
        .collect();

    // Reverse to newest-first
    history.reverse();
    history
}

/// PRV-204: Compute per-check flaky scores from historical report.json files.
///
/// Reads the `checks` array from each historical run's report.json and tracks
/// status transitions per check ID across runs. A check is "flaky" if its
/// status flips between runs. Returns only checks with flaky_score > 0,
/// sorted by flaky_score descending.
///
/// Completely defensive: parse errors or missing fields cause individual runs
/// to be skipped. Requires at least 2 historical runs to produce any output.
pub(crate) fn compute_flaky_scores(out_dir: &Path, max_runs: usize) -> Vec<FlakyCheckScore> {
    let parent = match out_dir.parent() {
        Some(p) => p,
        None => return Vec::new(),
    };

    let entries = match fs::read_dir(parent) {
        Ok(e) => e,
        Err(_) => return Vec::new(),
    };

    let mut dirs: Vec<String> = entries
        .filter_map(|e| e.ok())
        .filter(|e| {
            let name = e.file_name();
            let name_str = name.to_string_lossy();
            name_str != "latest"
                && e.path().is_dir()
                && name_str
                    .chars()
                    .next()
                    .map(|c| c.is_ascii_digit())
                    .unwrap_or(false)
        })
        .map(|e| e.file_name().to_string_lossy().to_string())
        .collect();

    dirs.sort();
    if dirs.len() > max_runs {
        dirs = dirs.split_off(dirs.len() - max_runs);
    }

    if dirs.len() < 2 {
        return Vec::new();
    }

    // Collect per-check statuses across runs (oldest first = chronological order)
    // Key: check_id, Value: Vec<(check_name, status_str)>
    let mut check_history: std::collections::BTreeMap<String, Vec<(String, String)>> =
        std::collections::BTreeMap::new();

    for dir_name in &dirs {
        let report_path = parent.join(dir_name).join("report.json");
        let content = match fs::read_to_string(&report_path) {
            Ok(c) => c,
            Err(_) => continue,
        };
        let json: serde_json::Value = match serde_json::from_str(&content) {
            Ok(v) => v,
            Err(_) => continue,
        };

        let checks_arr = match json.get("checks").and_then(|c| c.as_array()) {
            Some(arr) => arr,
            None => continue,
        };

        for check in checks_arr {
            let id = match check.get("id").and_then(|v| v.as_str()) {
                Some(s) => s.to_string(),
                None => continue,
            };
            let status = match check.get("status").and_then(|v| v.as_str()) {
                Some(s) => s.to_string(),
                None => continue,
            };
            let name = check
                .get("name")
                .and_then(|v| v.as_str())
                .unwrap_or(&id)
                .to_string();

            check_history.entry(id).or_default().push((name, status));
        }
    }

    // Compute flaky scores
    let mut scores: Vec<FlakyCheckScore> = check_history
        .into_iter()
        .filter_map(|(check_id, entries)| {
            let total_runs = entries.len();
            if total_runs < 2 {
                return None;
            }

            // Count transitions
            let statuses: Vec<&str> = entries.iter().map(|(_, s)| s.as_str()).collect();
            let transitions = statuses.windows(2).filter(|w| w[0] != w[1]).count();

            if transitions == 0 {
                return None;
            }

            let flaky_score = transitions as f64 / (total_runs - 1) as f64;

            // Last statuses for sparkline (newest first)
            let last_statuses: Vec<String> = entries
                .iter()
                .rev()
                .take(10)
                .map(|(_, s)| s.clone())
                .collect();

            // Use the most recent name for the check
            let check_name = entries.last().map(|(n, _)| n.clone()).unwrap_or_default();

            let confidence = match total_runs {
                0..=3 => "low",
                4..=7 => "medium",
                _ => "high",
            };

            Some(FlakyCheckScore {
                check_id,
                check_name,
                flaky_score,
                total_runs,
                transitions,
                last_statuses,
                confidence,
            })
        })
        .collect();

    // Sort by flaky_score descending (most flaky first)
    scores.sort_by(|a, b| {
        b.flaky_score
            .partial_cmp(&a.flaky_score)
            .unwrap_or(std::cmp::Ordering::Equal)
    });

    scores
}