Skip to main content

sentinel_core/
diff.rs

1//! Diff stage: compare two analysis reports and produce a delta.
2//!
3//! Primary use case: PR CI integration. Run `analyze` on the base
4//! branch's traces, run it again on the PR branch's traces, then compare
5//! the two reports to surface regressions (new findings, severity
6//! escalations, per-endpoint I/O op increases) and improvements
7//! (resolved findings, severity de-escalations, per-endpoint I/O op
8//! decreases).
9//!
10//! Finding identity for stable comparison is the tuple
11//! `(finding_type, service, source_endpoint, pattern.template)`. The
12//! template is already normalized at detection time so direct equality
13//! suffices, no re-normalization at diff time.
14//!
15//! A template mutation (schema change, renamed column, host move) shifts
16//! that identity, which used to surface as one resolved plus one new
17//! finding. A second pass pairs those leftovers when the match is
18//! unambiguous and reports them as `mutated_findings` instead, so a
19//! refactor does not read as a regression.
20
21use std::collections::{BTreeMap, BTreeSet};
22
23use serde::Serialize;
24
25use crate::detect::{Finding, FindingType, Severity};
26use crate::report::{PerEndpointIoOps, Report, Warning};
27
28/// Stable identity tuple for matching findings between two runs.
29///
30/// Two findings with the same `IdentityKey` are considered "the same
31/// anti-pattern" across runs. If multiple findings in one run share a
32/// key (e.g. the same N+1 template fired in two traces), the diff
33/// engine collapses them to one entry by keeping the worst-severity
34/// finding for that key.
35type IdentityKey = (
36    FindingType,
37    String,
38    Option<(String, String)>,
39    String,
40    String,
41);
42
43fn identity_of(finding: &Finding, with_grouping: bool) -> IdentityKey {
44    (
45        finding.finding_type.clone(),
46        finding.service.clone(),
47        // Without the grouping, the same problem in two deployments folds
48        // into one row with summed occurrences.
49        with_grouping
50            .then(|| {
51                finding
52                    .effective_grouping()
53                    .map(|g| (g.key.to_string(), g.value.to_string()))
54            })
55            .flatten(),
56        finding.source_endpoint.clone(),
57        finding.pattern.template.clone(),
58    )
59}
60
61/// A baseline written before 0.11.0 carries no grouping, so keying on it
62/// would make every current finding new and every baseline one resolved: a
63/// total false regression on the first run after upgrade. Detected rather
64/// than versioned, so an operator who genuinely runs an ungrouped fleet is
65/// unaffected (nothing to separate on either side anyway).
66fn grouping_is_comparable(before: &[Finding], after: &[Finding]) -> bool {
67    before.iter().any(|f| f.effective_grouping().is_some())
68        || after.iter().all(|f| f.effective_grouping().is_none())
69}
70
71/// Delta between two analysis runs.
72///
73/// Stable JSON shape. Field names will not be renamed or removed in a
74/// minor release; new optional fields may be added.
75#[derive(Debug, Clone, Serialize)]
76pub struct DiffReport {
77    /// Findings present in `after` but absent from `before`.
78    pub new_findings: Vec<Finding>,
79    /// Findings present in `before` but absent from `after`.
80    pub resolved_findings: Vec<Finding>,
81    /// Findings present in both runs whose worst severity differs.
82    /// Ordered with regressions (worse-after) first, then improvements.
83    pub severity_changes: Vec<SeverityChange>,
84    /// Per-endpoint I/O op deltas. Ordered with the largest regressions
85    /// first (most positive delta), then improvements last.
86    pub endpoint_metric_deltas: Vec<EndpointDelta>,
87    /// Warnings carried over from the `after` run (e.g.
88    /// `unmatched_acknowledgment`), so a diff-only CI surface does not
89    /// drop them. Additive, absent when empty.
90    #[serde(skip_serializing_if = "Vec::is_empty")]
91    pub warning_details: Vec<Warning>,
92    /// Resolved/new pairs that differ only by their normalized template:
93    /// the same detector on the same service and endpoint, before and
94    /// after a template mutation. Counted neither as new nor as resolved,
95    /// so a schema refactor does not fail a CI gate fed by
96    /// `new_findings`. Additive, absent when empty.
97    #[serde(skip_serializing_if = "Vec::is_empty")]
98    pub mutated_findings: Vec<MutatedFinding>,
99}
100
101/// A finding whose normalized template changed between the two runs
102/// while its detector, service, endpoint, and grouping stayed the same.
103#[derive(Debug, Clone, Serialize)]
104pub struct MutatedFinding {
105    /// The finding as it appeared in the `before` run (old template).
106    pub before: Finding,
107    /// The finding as it appears in the `after` run (new template).
108    pub after: Finding,
109}
110
111/// A finding whose worst severity changed between the two runs.
112#[derive(Debug, Clone, Serialize)]
113pub struct SeverityChange {
114    /// The "after" version of the finding (same identity, latest data).
115    pub finding: Finding,
116    pub before_severity: Severity,
117    pub after_severity: Severity,
118}
119
120impl SeverityChange {
121    /// `true` when the after severity is worse than the before severity.
122    /// Used to sort regressions ahead of improvements in the output and
123    /// reused by the CLI text renderer to color regressions red.
124    ///
125    /// Severity derives `Ord` with declaration order (Critical < Warning
126    /// < Info). "Worse" means numerically lower.
127    #[must_use]
128    pub fn is_regression(&self) -> bool {
129        self.after_severity < self.before_severity
130    }
131}
132
133/// Per-endpoint I/O op count delta between two runs.
134#[derive(Debug, Clone, Serialize)]
135pub struct EndpointDelta {
136    pub service: String,
137    pub endpoint: String,
138    pub before_io_ops: usize,
139    pub after_io_ops: usize,
140    /// `after - before`. Positive = regression, negative = improvement.
141    pub delta: i64,
142}
143
144/// Compare two analysis reports.
145///
146/// Both reports are expected to come from `pipeline::analyze` runs on
147/// their respective trace sets, with the same `Config` (otherwise the
148/// per-endpoint counts and severity assignments may not be comparable).
149///
150/// Pure function: takes references and returns owned data. No I/O.
151#[must_use]
152pub fn diff_runs(before: &Report, after: &Report) -> DiffReport {
153    let with_grouping = grouping_is_comparable(&before.findings, &after.findings);
154    let before_map = build_identity_map(&before.findings, with_grouping);
155    let after_map = build_identity_map(&after.findings, with_grouping);
156
157    let mut new_findings: Vec<Finding> = Vec::new();
158    let mut resolved_findings: Vec<Finding> = Vec::new();
159    let mut severity_changes: Vec<SeverityChange> = Vec::new();
160
161    for (key, after_finding) in &after_map {
162        match before_map.get(key) {
163            None => new_findings.push(after_finding.clone()),
164            Some(before_finding) if before_finding.severity != after_finding.severity => {
165                severity_changes.push(SeverityChange {
166                    finding: after_finding.clone(),
167                    before_severity: before_finding.severity.clone(),
168                    after_severity: after_finding.severity.clone(),
169                });
170            }
171            Some(_) => {}
172        }
173    }
174    for (key, before_finding) in &before_map {
175        if !after_map.contains_key(key) {
176            resolved_findings.push(before_finding.clone());
177        }
178    }
179
180    // Stable, deterministic ordering for the two finding lists. Reuses
181    // the same ordering rule as `analyze` so a reader's mental model
182    // is identical between the two outputs.
183    crate::detect::sort_findings(&mut new_findings);
184    crate::detect::sort_findings(&mut resolved_findings);
185    // Pair template mutations out of the two sorted lists. Sorting first
186    // keeps the pairing deterministic, and `Vec::retain` preserves the
187    // order of whatever stays.
188    let mutated_findings = pair_mutations(&mut resolved_findings, &mut new_findings, with_grouping);
189    // Severity changes: regressions (worse-after) first, then
190    // improvements. Within each group, sort by the same finding-order
191    // rule as the regular output for predictability.
192    severity_changes.sort_by(|a, b| {
193        b.is_regression()
194            .cmp(&a.is_regression())
195            .then_with(|| a.finding.finding_type.cmp(&b.finding.finding_type))
196            .then_with(|| a.finding.service.cmp(&b.finding.service))
197            .then_with(|| a.finding.source_endpoint.cmp(&b.finding.source_endpoint))
198            .then_with(|| a.finding.pattern.template.cmp(&b.finding.pattern.template))
199    });
200
201    let endpoint_metric_deltas =
202        diff_per_endpoint_io_ops(&before.per_endpoint_io_ops, &after.per_endpoint_io_ops);
203
204    DiffReport {
205        new_findings,
206        resolved_findings,
207        severity_changes,
208        endpoint_metric_deltas,
209        warning_details: after.warning_details.clone(),
210        mutated_findings,
211    }
212}
213
214/// `IdentityKey` without the template: what stays stable when a template
215/// mutates. Two leftovers sharing it are mutation candidates.
216type ReducedKey = (FindingType, String, Option<(String, String)>, String);
217
218fn reduced_key_of(finding: &Finding, with_grouping: bool) -> ReducedKey {
219    let (finding_type, service, grouping, endpoint, _) = identity_of(finding, with_grouping);
220    (finding_type, service, grouping, endpoint)
221}
222
223/// Pair resolved/new leftovers that differ only by template.
224///
225/// Conservative by design: a pair is emitted only when it is unambiguous,
226/// either a single candidate on each side of the reduced key, or exactly
227/// one candidate per side sharing the same `code_location` anchor.
228/// Anything ambiguous stays reported as resolved plus new. Never guesses:
229/// an identity is also the acknowledgment boundary, and a wrong merge
230/// would hide a genuinely new problem.
231fn pair_mutations(
232    resolved: &mut Vec<Finding>,
233    new: &mut Vec<Finding>,
234    with_grouping: bool,
235) -> Vec<MutatedFinding> {
236    let mut resolved_by_key: BTreeMap<ReducedKey, Vec<usize>> = BTreeMap::new();
237    for (idx, finding) in resolved.iter().enumerate() {
238        resolved_by_key
239            .entry(reduced_key_of(finding, with_grouping))
240            .or_default()
241            .push(idx);
242    }
243    let mut new_by_key: BTreeMap<ReducedKey, Vec<usize>> = BTreeMap::new();
244    for (idx, finding) in new.iter().enumerate() {
245        new_by_key
246            .entry(reduced_key_of(finding, with_grouping))
247            .or_default()
248            .push(idx);
249    }
250
251    let mut pairs: Vec<(usize, usize)> = Vec::new();
252    for (key, resolved_idxs) in &resolved_by_key {
253        if let Some(new_idxs) = new_by_key.get(key) {
254            pairs.extend(pairs_for_key(resolved_idxs, new_idxs, resolved, new));
255        }
256    }
257
258    let mut mutated: Vec<MutatedFinding> = pairs
259        .iter()
260        .map(|&(r, n)| MutatedFinding {
261            before: resolved[r].clone(),
262            after: new[n].clone(),
263        })
264        .collect();
265    remove_indices(resolved, &pairs.iter().map(|&(r, _)| r).collect());
266    remove_indices(new, &pairs.iter().map(|&(_, n)| n).collect());
267    // Same ordering rule as the finding lists, keyed on the after side.
268    mutated.sort_by(|a, b| {
269        a.after
270            .finding_type
271            .cmp(&b.after.finding_type)
272            .then_with(|| a.after.service.cmp(&b.after.service))
273            .then_with(|| a.after.source_endpoint.cmp(&b.after.source_endpoint))
274            .then_with(|| a.after.pattern.template.cmp(&b.after.pattern.template))
275    });
276    mutated
277}
278
279/// Unambiguous pairs within one reduced key. One candidate per side pairs
280/// directly. With several, the `code_location` anchor (filepath plus
281/// function) arbitrates: an anchor carrying exactly one finding on each
282/// side pairs them, every other combination stays unpaired.
283fn pairs_for_key(
284    resolved_idxs: &[usize],
285    new_idxs: &[usize],
286    resolved: &[Finding],
287    new: &[Finding],
288) -> Vec<(usize, usize)> {
289    if let (&[r], &[n]) = (resolved_idxs, new_idxs) {
290        return vec![(r, n)];
291    }
292    let mut by_anchor: BTreeMap<(String, String), (Vec<usize>, Vec<usize>)> = BTreeMap::new();
293    for &r in resolved_idxs {
294        if let Some(anchor) = code_anchor(&resolved[r]) {
295            by_anchor.entry(anchor).or_default().0.push(r);
296        }
297    }
298    for &n in new_idxs {
299        if let Some(anchor) = code_anchor(&new[n]) {
300            by_anchor.entry(anchor).or_default().1.push(n);
301        }
302    }
303    by_anchor
304        .into_values()
305        .filter_map(|(r, n)| match (r.as_slice(), n.as_slice()) {
306            (&[r], &[n]) => Some((r, n)),
307            _ => None,
308        })
309        .collect()
310}
311
312/// The stable code anchor of a finding, when its instrumentation emitted
313/// one: `(filepath, function)` from the `OTel` `code.*` span attributes.
314fn code_anchor(finding: &Finding) -> Option<(String, String)> {
315    let location = finding.code_location.as_ref()?;
316    Some((location.filepath.clone()?, location.function.clone()?))
317}
318
319/// Drop the findings at the given indices, preserving the order of the
320/// rest.
321fn remove_indices(list: &mut Vec<Finding>, remove: &BTreeSet<usize>) {
322    let mut idx = 0;
323    list.retain(|_| {
324        let keep = !remove.contains(&idx);
325        idx += 1;
326        keep
327    });
328}
329
330/// Build an identity-keyed map of findings, collapsing duplicates by
331/// keeping the worst severity AND summing `pattern.occurrences` across
332/// all duplicates. Used as the canonical view of each side before
333/// computing the diff.
334///
335/// Returning owned `Finding` values (not borrowed) lets us aggregate
336/// occurrences without mutating the input slice. The aggregated
337/// occurrences mean the user sees the total amplitude of the pattern
338/// across the trace set, not just one trace's count, so a regression
339/// from "6 occurrences in trace A" to "60 occurrences in trace A AND 60
340/// in trace B" surfaces as a meaningful endpoint-delta plus (when
341/// severity escalates) a `severity_change`.
342///
343/// Tie-break for the kept Finding template: the first finding inserted
344/// at a key wins for `trace_id` / `first_timestamp` / `code_location` /
345/// `suggested_fix`. Since `pipeline::analyze` calls `sort_findings`
346/// before returning, this is deterministic.
347fn build_identity_map(findings: &[Finding], with_grouping: bool) -> BTreeMap<IdentityKey, Finding> {
348    let mut map: BTreeMap<IdentityKey, Finding> = BTreeMap::new();
349    for finding in findings {
350        let key = identity_of(finding, with_grouping);
351        match map.get_mut(&key) {
352            None => {
353                map.insert(key, finding.clone());
354            }
355            Some(existing) => {
356                // Severity derives Ord with declaration order:
357                // Critical < Warning < Info. Worst = min.
358                if finding.severity < existing.severity {
359                    let summed = existing
360                        .pattern
361                        .occurrences
362                        .saturating_add(finding.pattern.occurrences);
363                    *existing = finding.clone();
364                    existing.pattern.occurrences = summed;
365                } else {
366                    existing.pattern.occurrences = existing
367                        .pattern
368                        .occurrences
369                        .saturating_add(finding.pattern.occurrences);
370                }
371            }
372        }
373    }
374    map
375}
376
377/// Pair up `before` and `after` per-endpoint I/O op counts and emit one
378/// `EndpointDelta` per `(service, endpoint)` whose count differs.
379/// Endpoints absent from one side are treated as `0` on that side.
380/// Sorted regressions-first, then improvements (largest absolute delta
381/// inside each group last for the improvements direction).
382fn diff_per_endpoint_io_ops(
383    before: &[PerEndpointIoOps],
384    after: &[PerEndpointIoOps],
385) -> Vec<EndpointDelta> {
386    let mut before_map: BTreeMap<(&str, &str), usize> = BTreeMap::new();
387    for entry in before {
388        before_map.insert((&entry.service, &entry.endpoint), entry.io_ops);
389    }
390    let mut after_map: BTreeMap<(&str, &str), usize> = BTreeMap::new();
391    for entry in after {
392        after_map.insert((&entry.service, &entry.endpoint), entry.io_ops);
393    }
394
395    let mut keys: BTreeSet<(&str, &str)> = BTreeSet::new();
396    keys.extend(before_map.keys().copied());
397    keys.extend(after_map.keys().copied());
398
399    let mut deltas: Vec<EndpointDelta> = keys
400        .iter()
401        .filter_map(|(service, endpoint)| {
402            let before_io = before_map.get(&(*service, *endpoint)).copied().unwrap_or(0);
403            let after_io = after_map.get(&(*service, *endpoint)).copied().unwrap_or(0);
404            if before_io == after_io {
405                return None;
406            }
407            // Cast through i128 to handle the worst case (`usize::MAX`
408            // on either side) without panicking. Any plausible counts
409            // fit comfortably in i64; if a future workload pushes past
410            // `i64::MAX` we clamp and warn rather than overflow silently.
411            let delta = i128::from(after_io as u64) - i128::from(before_io as u64);
412            let delta_i64 = i64::try_from(delta).unwrap_or_else(|_| {
413                tracing::warn!(
414                    target: "perf_sentinel::diff",
415                    service = %service,
416                    endpoint = %endpoint,
417                    before_io = before_io,
418                    after_io = after_io,
419                    "endpoint I/O op delta overflows i64, clamping for output"
420                );
421                if delta > 0 { i64::MAX } else { i64::MIN }
422            });
423            Some(EndpointDelta {
424                service: (*service).to_string(),
425                endpoint: (*endpoint).to_string(),
426                before_io_ops: before_io,
427                after_io_ops: after_io,
428                delta: delta_i64,
429            })
430        })
431        .collect();
432
433    // Regressions first: positive deltas before negative ones, then by
434    // descending magnitude inside each group. Tie-break on `(service,
435    // endpoint)` so the ordering is stable across runs even if
436    // `sort_by`'s internal stability guarantee changes.
437    deltas.sort_by(|a, b| {
438        b.delta
439            .cmp(&a.delta)
440            .then_with(|| a.service.cmp(&b.service))
441            .then_with(|| a.endpoint.cmp(&b.endpoint))
442    });
443    deltas
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use crate::detect::{Confidence, Finding, FindingType, Pattern, Severity};
450    use crate::report::{Analysis, GreenSummary, PerEndpointIoOps, QualityGate, Report};
451
452    fn make_report(findings: Vec<Finding>, per_endpoint: Vec<PerEndpointIoOps>) -> Report {
453        Report {
454            analysis: Analysis {
455                duration_ms: 0,
456                events_processed: 0,
457                traces_analyzed: 0,
458                ingest: None,
459            },
460            findings,
461            green_summary: GreenSummary::disabled(0),
462            quality_gate: QualityGate {
463                passed: true,
464                rules: vec![],
465            },
466            per_endpoint_io_ops: per_endpoint,
467            correlations: vec![],
468            embedded_traces: vec![],
469            warnings: vec![],
470            warning_details: vec![],
471            acknowledged_findings: vec![],
472            binary_version: String::new(),
473            detection_config: None,
474            disclosure_waste: None,
475        }
476    }
477
478    fn finding(
479        ft: FindingType,
480        sev: Severity,
481        service: &str,
482        endpoint: &str,
483        template: &str,
484    ) -> Finding {
485        Finding {
486            finding_type: ft,
487            severity: sev,
488            trace_id: "trace-1".to_string(),
489            service: service.to_string(),
490            grouping: Vec::new(),
491            source_endpoint: endpoint.to_string(),
492            pattern: Pattern {
493                template: template.to_string(),
494                occurrences: 6,
495                window_ms: 200,
496                distinct_params: 6,
497                ..Default::default()
498            },
499            suggestion: "batch".to_string(),
500            first_timestamp: "2025-07-10T14:32:01.000Z".to_string(),
501            last_timestamp: "2025-07-10T14:32:01.250Z".to_string(),
502            green_impact: None,
503            confidence: Confidence::default(),
504            classification_method: None,
505            code_location: None,
506            instrumentation_scopes: Vec::new(),
507            suggested_fix: None,
508            signature: String::new(),
509        }
510    }
511
512    #[test]
513    fn identity_separates_the_same_problem_in_two_namespaces() {
514        let mut prod = finding(
515            FindingType::NPlusOneSql,
516            Severity::Warning,
517            "order-svc",
518            "GET /api/orders",
519            "SELECT * FROM t WHERE id = ?",
520        );
521        prod.grouping = crate::test_helpers::k8s_grouping("prod-eu");
522        let mut staging = prod.clone();
523        staging.grouping = crate::test_helpers::k8s_grouping("staging");
524
525        let before = make_report(vec![prod.clone()], vec![]);
526        let after = make_report(vec![prod, staging], vec![]);
527        let report = diff_runs(&before, &after);
528
529        assert_eq!(report.new_findings.len(), 1);
530        assert_eq!(
531            report.new_findings[0].grouping_value(),
532            Some("staging"),
533            "the staging deployment must not fold into the prod row"
534        );
535    }
536
537    #[test]
538    fn identity_separates_equal_values_from_different_grouping_keys() {
539        let mut tenant = finding(
540            FindingType::NPlusOneSql,
541            Severity::Warning,
542            "order-svc",
543            "GET /api/orders",
544            "SELECT * FROM t WHERE id = ?",
545        );
546        tenant.grouping = crate::test_helpers::grouping("tenant.id", "prod");
547        let mut namespace = tenant.clone();
548        namespace.grouping = crate::test_helpers::grouping("k8s.namespace.name", "prod");
549
550        let before = make_report(vec![tenant.clone()], vec![]);
551        let after = make_report(vec![tenant, namespace], vec![]);
552        let report = diff_runs(&before, &after);
553
554        assert_eq!(report.new_findings.len(), 1);
555        assert_eq!(
556            report.new_findings[0]
557                .effective_grouping()
558                .map(|g| g.key.as_ref()),
559            Some("k8s.namespace.name")
560        );
561    }
562
563    /// A baseline written before 0.11.0 has no grouping. Keying on it would
564    /// report every finding as new and every baseline one as resolved, a
565    /// total false regression on the first run after upgrade.
566    #[test]
567    fn a_baseline_without_grouping_does_not_diff_as_a_total_regression() {
568        let legacy = finding(
569            FindingType::NPlusOneSql,
570            Severity::Warning,
571            "order-svc",
572            "GET /api/orders",
573            "SELECT * FROM t WHERE id = ?",
574        );
575        let mut current = legacy.clone();
576        current.grouping = crate::test_helpers::k8s_grouping("prod-eu");
577
578        let report = diff_runs(
579            &make_report(vec![legacy], vec![]),
580            &make_report(vec![current], vec![]),
581        );
582
583        assert!(report.new_findings.is_empty(), "{:?}", report.new_findings);
584        assert!(
585            report.resolved_findings.is_empty(),
586            "{:?}",
587            report.resolved_findings
588        );
589    }
590
591    /// Once the baseline itself carries groupings, the separation is back on.
592    #[test]
593    fn a_grouped_baseline_still_separates_deployments() {
594        let mut prod = finding(
595            FindingType::NPlusOneSql,
596            Severity::Warning,
597            "order-svc",
598            "GET /api/orders",
599            "SELECT * FROM t WHERE id = ?",
600        );
601        prod.grouping = crate::test_helpers::k8s_grouping("prod-eu");
602        let mut staging = prod.clone();
603        staging.grouping = crate::test_helpers::k8s_grouping("staging");
604
605        let report = diff_runs(
606            &make_report(vec![prod.clone()], vec![]),
607            &make_report(vec![prod, staging], vec![]),
608        );
609
610        assert_eq!(report.new_findings.len(), 1);
611        assert_eq!(report.new_findings[0].grouping_value(), Some("staging"));
612    }
613
614    fn endpoint(service: &str, ep: &str, ops: usize) -> PerEndpointIoOps {
615        PerEndpointIoOps {
616            service: service.to_string(),
617            endpoint: ep.to_string(),
618            io_ops: ops,
619        }
620    }
621
622    /// The after run's warnings (e.g. `unmatched_acknowledgment`) must
623    /// survive into the diff, which is the only output a diff-only CI
624    /// surface reads. The before run's warnings describe the baseline
625    /// and stay out.
626    #[test]
627    fn diff_carries_the_after_runs_warnings() {
628        let mut before = make_report(vec![], vec![]);
629        before.warning_details = vec![Warning::new("cold_start", "stale baseline warning")];
630        let mut after = make_report(vec![], vec![]);
631        after.warning_details = vec![Warning::new(
632            "unmatched_acknowledgment",
633            "acknowledgment deadbeef matched no finding in this run",
634        )];
635        let diff = diff_runs(&before, &after);
636        assert_eq!(diff.warning_details, after.warning_details);
637    }
638
639    #[test]
640    fn identical_runs_produce_empty_diff() {
641        let f = finding(
642            FindingType::NPlusOneSql,
643            Severity::Warning,
644            "svc",
645            "POST /api",
646            "SELECT *",
647        );
648        let before = make_report(vec![f.clone()], vec![endpoint("svc", "POST /api", 6)]);
649        let after = make_report(vec![f], vec![endpoint("svc", "POST /api", 6)]);
650        let diff = diff_runs(&before, &after);
651        assert!(diff.new_findings.is_empty());
652        assert!(diff.resolved_findings.is_empty());
653        assert!(diff.severity_changes.is_empty());
654        assert!(diff.endpoint_metric_deltas.is_empty());
655    }
656
657    #[test]
658    fn finding_present_only_in_after_is_new() {
659        let before = make_report(vec![], vec![]);
660        let after = make_report(
661            vec![finding(
662                FindingType::NPlusOneSql,
663                Severity::Warning,
664                "svc",
665                "POST /api",
666                "SELECT *",
667            )],
668            vec![],
669        );
670        let diff = diff_runs(&before, &after);
671        assert_eq!(diff.new_findings.len(), 1);
672        assert!(diff.resolved_findings.is_empty());
673        assert!(diff.severity_changes.is_empty());
674    }
675
676    #[test]
677    fn finding_present_only_in_before_is_resolved() {
678        let before = make_report(
679            vec![finding(
680                FindingType::NPlusOneSql,
681                Severity::Warning,
682                "svc",
683                "POST /api",
684                "SELECT *",
685            )],
686            vec![],
687        );
688        let after = make_report(vec![], vec![]);
689        let diff = diff_runs(&before, &after);
690        assert!(diff.new_findings.is_empty());
691        assert_eq!(diff.resolved_findings.len(), 1);
692        assert!(diff.severity_changes.is_empty());
693    }
694
695    #[test]
696    fn same_identity_with_different_severity_is_severity_change() {
697        let f_warn = finding(
698            FindingType::NPlusOneSql,
699            Severity::Warning,
700            "svc",
701            "POST /api",
702            "SELECT *",
703        );
704        let mut f_crit = f_warn.clone();
705        f_crit.severity = Severity::Critical;
706        let before = make_report(vec![f_warn], vec![]);
707        let after = make_report(vec![f_crit], vec![]);
708        let diff = diff_runs(&before, &after);
709        assert!(diff.new_findings.is_empty());
710        assert!(diff.resolved_findings.is_empty());
711        assert_eq!(diff.severity_changes.len(), 1);
712        let change = &diff.severity_changes[0];
713        assert_eq!(change.before_severity, Severity::Warning);
714        assert_eq!(change.after_severity, Severity::Critical);
715        assert!(
716            change.is_regression(),
717            "warning -> critical is a regression"
718        );
719    }
720
721    #[test]
722    fn severity_changes_sorted_regressions_first() {
723        // After: one regression (warning -> critical), one improvement (critical -> warning).
724        let before = make_report(
725            vec![
726                finding(
727                    FindingType::NPlusOneSql,
728                    Severity::Warning,
729                    "svc-a",
730                    "POST /a",
731                    "SELECT a",
732                ),
733                finding(
734                    FindingType::NPlusOneSql,
735                    Severity::Critical,
736                    "svc-b",
737                    "POST /b",
738                    "SELECT b",
739                ),
740            ],
741            vec![],
742        );
743        let after = make_report(
744            vec![
745                finding(
746                    FindingType::NPlusOneSql,
747                    Severity::Critical,
748                    "svc-a",
749                    "POST /a",
750                    "SELECT a",
751                ),
752                finding(
753                    FindingType::NPlusOneSql,
754                    Severity::Warning,
755                    "svc-b",
756                    "POST /b",
757                    "SELECT b",
758                ),
759            ],
760            vec![],
761        );
762        let diff = diff_runs(&before, &after);
763        assert_eq!(diff.severity_changes.len(), 2);
764        assert!(
765            diff.severity_changes[0].is_regression(),
766            "regression must come first"
767        );
768        assert!(
769            !diff.severity_changes[1].is_regression(),
770            "improvement must come last"
771        );
772    }
773
774    #[test]
775    fn duplicate_identity_in_one_run_is_collapsed_to_worst_severity() {
776        // Two findings with the same identity tuple in `after`, one at
777        // Warning and one at Critical. They should collapse to a single
778        // "after" finding at Critical, and the diff should not interpret
779        // the count difference as a severity change.
780        let before = make_report(
781            vec![finding(
782                FindingType::NPlusOneSql,
783                Severity::Critical,
784                "svc",
785                "POST /api",
786                "SELECT *",
787            )],
788            vec![],
789        );
790        let f_warn = finding(
791            FindingType::NPlusOneSql,
792            Severity::Warning,
793            "svc",
794            "POST /api",
795            "SELECT *",
796        );
797        let mut f_crit = f_warn.clone();
798        f_crit.severity = Severity::Critical;
799        let after = make_report(vec![f_warn, f_crit], vec![]);
800        let diff = diff_runs(&before, &after);
801        assert!(
802            diff.new_findings.is_empty(),
803            "no new findings when identity is shared"
804        );
805        assert!(
806            diff.resolved_findings.is_empty(),
807            "no resolved when identity is shared"
808        );
809        assert!(
810            diff.severity_changes.is_empty(),
811            "worst-severity dedupe should make this a no-op (Critical == Critical)"
812        );
813    }
814
815    #[test]
816    fn endpoint_io_ops_increase_is_a_positive_delta() {
817        let before = make_report(vec![], vec![endpoint("svc", "POST /api/users", 10)]);
818        let after = make_report(vec![], vec![endpoint("svc", "POST /api/users", 20)]);
819        let diff = diff_runs(&before, &after);
820        assert_eq!(diff.endpoint_metric_deltas.len(), 1);
821        let d = &diff.endpoint_metric_deltas[0];
822        assert_eq!(d.service, "svc");
823        assert_eq!(d.endpoint, "POST /api/users");
824        assert_eq!(d.before_io_ops, 10);
825        assert_eq!(d.after_io_ops, 20);
826        assert_eq!(d.delta, 10);
827    }
828
829    #[test]
830    fn endpoint_absent_from_before_is_a_full_addition() {
831        let before = make_report(vec![], vec![]);
832        let after = make_report(vec![], vec![endpoint("svc", "POST /api", 7)]);
833        let diff = diff_runs(&before, &after);
834        assert_eq!(diff.endpoint_metric_deltas.len(), 1);
835        let d = &diff.endpoint_metric_deltas[0];
836        assert_eq!(d.before_io_ops, 0);
837        assert_eq!(d.after_io_ops, 7);
838        assert_eq!(d.delta, 7);
839    }
840
841    #[test]
842    fn endpoint_absent_from_after_is_a_full_removal() {
843        let before = make_report(vec![], vec![endpoint("svc", "POST /api", 5)]);
844        let after = make_report(vec![], vec![]);
845        let diff = diff_runs(&before, &after);
846        assert_eq!(diff.endpoint_metric_deltas.len(), 1);
847        let d = &diff.endpoint_metric_deltas[0];
848        assert_eq!(d.before_io_ops, 5);
849        assert_eq!(d.after_io_ops, 0);
850        assert_eq!(d.delta, -5);
851    }
852
853    #[test]
854    fn endpoint_deltas_sorted_regressions_first() {
855        let before = make_report(
856            vec![],
857            vec![
858                endpoint("svc", "POST /improve", 10),
859                endpoint("svc", "POST /regress", 5),
860                endpoint("svc", "POST /steady", 7),
861            ],
862        );
863        let after = make_report(
864            vec![],
865            vec![
866                endpoint("svc", "POST /improve", 2),
867                endpoint("svc", "POST /regress", 50),
868                endpoint("svc", "POST /steady", 7),
869            ],
870        );
871        let diff = diff_runs(&before, &after);
872        assert_eq!(diff.endpoint_metric_deltas.len(), 2);
873        assert_eq!(diff.endpoint_metric_deltas[0].endpoint, "POST /regress");
874        assert_eq!(diff.endpoint_metric_deltas[0].delta, 45);
875        assert_eq!(diff.endpoint_metric_deltas[1].endpoint, "POST /improve");
876        assert_eq!(diff.endpoint_metric_deltas[1].delta, -8);
877    }
878
879    #[test]
880    fn equal_severity_in_both_runs_is_not_a_severity_change() {
881        // Guard against a future refactor that compares with `<` or
882        // `<=` instead of `!=` and silently classifies equal-severity
883        // findings as severity changes.
884        let f = finding(
885            FindingType::NPlusOneSql,
886            Severity::Critical,
887            "svc",
888            "POST /api",
889            "SELECT *",
890        );
891        let before = make_report(vec![f.clone()], vec![]);
892        let after = make_report(vec![f], vec![]);
893        let diff = diff_runs(&before, &after);
894        assert!(diff.severity_changes.is_empty());
895    }
896
897    #[test]
898    fn same_identity_different_trace_id_is_treated_as_one_finding() {
899        // Two findings with identical (type, service, endpoint, template)
900        // but different trace_ids are conceptually "the same anti-pattern"
901        // observed twice. The diff collapses them and sums occurrences.
902        let mut f_a = finding(
903            FindingType::NPlusOneSql,
904            Severity::Warning,
905            "svc",
906            "POST /api",
907            "SELECT *",
908        );
909        f_a.trace_id = "trace-a".to_string();
910        f_a.pattern.occurrences = 6;
911        let mut f_b = f_a.clone();
912        f_b.trace_id = "trace-b".to_string();
913        f_b.pattern.occurrences = 12;
914
915        let before = make_report(vec![], vec![]);
916        let after = make_report(vec![f_a, f_b], vec![]);
917        let diff = diff_runs(&before, &after);
918        assert_eq!(diff.new_findings.len(), 1, "two duplicates collapse to one");
919        assert_eq!(
920            diff.new_findings[0].pattern.occurrences, 18,
921            "occurrences from both findings sum on collapse"
922        );
923    }
924
925    #[test]
926    fn duplicate_identity_collapse_sums_occurrences() {
927        // Direct test of `build_identity_map` summing semantic.
928        let mut f_a = finding(
929            FindingType::NPlusOneSql,
930            Severity::Warning,
931            "svc",
932            "POST /api",
933            "SELECT *",
934        );
935        f_a.pattern.occurrences = 6;
936        let mut f_b = f_a.clone();
937        f_b.pattern.occurrences = 60;
938
939        let before = make_report(vec![], vec![]);
940        let after = make_report(vec![f_a, f_b], vec![]);
941        let diff = diff_runs(&before, &after);
942        assert_eq!(diff.new_findings.len(), 1);
943        assert_eq!(diff.new_findings[0].pattern.occurrences, 66);
944    }
945
946    #[test]
947    fn diff_sarif_emits_one_result_per_new_finding() {
948        // Smoke test for the public `findings_to_sarif` re-use path
949        // exercised by `perf-sentinel diff --format sarif`.
950        let f = finding(
951            FindingType::NPlusOneSql,
952            Severity::Warning,
953            "svc",
954            "POST /api",
955            "SELECT *",
956        );
957        let before = make_report(vec![], vec![]);
958        let after = make_report(vec![f], vec![]);
959        let diff = diff_runs(&before, &after);
960        assert_eq!(diff.new_findings.len(), 1);
961        let sarif = crate::report::sarif::findings_to_sarif(&diff.new_findings);
962        assert_eq!(
963            sarif.runs[0].results.len(),
964            diff.new_findings.len(),
965            "SARIF results count must match new_findings count"
966        );
967    }
968
969    fn anchored(mut f: Finding, filepath: &str, function: &str) -> Finding {
970        f.code_location = Some(crate::event::CodeLocation {
971            function: Some(function.to_string()),
972            filepath: Some(filepath.to_string()),
973            lineno: Some(42),
974            namespace: None,
975        });
976        f
977    }
978
979    #[test]
980    fn template_mutation_pairs_instead_of_resolved_plus_new() {
981        let before = make_report(
982            vec![finding(
983                FindingType::NPlusOneSql,
984                Severity::Warning,
985                "order-svc",
986                "GET /api/orders",
987                "SELECT * FROM orders WHERE id = ?",
988            )],
989            vec![],
990        );
991        let after = make_report(
992            vec![finding(
993                FindingType::NPlusOneSql,
994                Severity::Warning,
995                "order-svc",
996                "GET /api/orders",
997                "SELECT * FROM orders WHERE id = ? AND tenant = ?",
998            )],
999            vec![],
1000        );
1001        let report = diff_runs(&before, &after);
1002
1003        assert!(report.new_findings.is_empty());
1004        assert!(report.resolved_findings.is_empty());
1005        assert_eq!(report.mutated_findings.len(), 1);
1006        let pair = &report.mutated_findings[0];
1007        assert_eq!(
1008            pair.before.pattern.template,
1009            "SELECT * FROM orders WHERE id = ?"
1010        );
1011        assert_eq!(
1012            pair.after.pattern.template,
1013            "SELECT * FROM orders WHERE id = ? AND tenant = ?"
1014        );
1015    }
1016
1017    #[test]
1018    fn ambiguous_mutation_stays_resolved_plus_new() {
1019        let mk = |template: &str| {
1020            finding(
1021                FindingType::NPlusOneSql,
1022                Severity::Warning,
1023                "order-svc",
1024                "GET /api/orders",
1025                template,
1026            )
1027        };
1028        let before = make_report(
1029            vec![
1030                mk("SELECT a FROM t WHERE id = ?"),
1031                mk("SELECT b FROM t WHERE id = ?"),
1032            ],
1033            vec![],
1034        );
1035        let after = make_report(
1036            vec![
1037                mk("SELECT c FROM t WHERE id = ?"),
1038                mk("SELECT d FROM t WHERE id = ?"),
1039            ],
1040            vec![],
1041        );
1042        let report = diff_runs(&before, &after);
1043
1044        assert!(report.mutated_findings.is_empty());
1045        assert_eq!(report.resolved_findings.len(), 2);
1046        assert_eq!(report.new_findings.len(), 2);
1047    }
1048
1049    #[test]
1050    fn code_anchor_arbitrates_ambiguous_mutations() {
1051        let mk = |template: &str| {
1052            finding(
1053                FindingType::NPlusOneSql,
1054                Severity::Warning,
1055                "order-svc",
1056                "GET /api/orders",
1057                template,
1058            )
1059        };
1060        let before = make_report(
1061            vec![
1062                anchored(
1063                    mk("SELECT a FROM t WHERE id = ?"),
1064                    "src/orders.py",
1065                    "list_orders",
1066                ),
1067                anchored(
1068                    mk("SELECT b FROM t WHERE id = ?"),
1069                    "src/items.py",
1070                    "list_items",
1071                ),
1072            ],
1073            vec![],
1074        );
1075        let after = make_report(
1076            vec![
1077                anchored(
1078                    mk("SELECT a2 FROM t WHERE id = ?"),
1079                    "src/orders.py",
1080                    "list_orders",
1081                ),
1082                anchored(
1083                    mk("SELECT b2 FROM t WHERE id = ?"),
1084                    "src/items.py",
1085                    "list_items",
1086                ),
1087            ],
1088            vec![],
1089        );
1090        let report = diff_runs(&before, &after);
1091
1092        assert!(report.new_findings.is_empty());
1093        assert!(report.resolved_findings.is_empty());
1094        assert_eq!(report.mutated_findings.len(), 2);
1095        for pair in &report.mutated_findings {
1096            assert_eq!(pair.before.code_location, pair.after.code_location);
1097        }
1098    }
1099
1100    #[test]
1101    fn different_endpoint_is_not_a_mutation() {
1102        let before = make_report(
1103            vec![finding(
1104                FindingType::NPlusOneSql,
1105                Severity::Warning,
1106                "order-svc",
1107                "GET /api/orders",
1108                "SELECT a FROM t WHERE id = ?",
1109            )],
1110            vec![],
1111        );
1112        let after = make_report(
1113            vec![finding(
1114                FindingType::NPlusOneSql,
1115                Severity::Warning,
1116                "order-svc",
1117                "GET /api/items",
1118                "SELECT b FROM t WHERE id = ?",
1119            )],
1120            vec![],
1121        );
1122        let report = diff_runs(&before, &after);
1123
1124        assert!(report.mutated_findings.is_empty());
1125        assert_eq!(report.resolved_findings.len(), 1);
1126        assert_eq!(report.new_findings.len(), 1);
1127    }
1128
1129    #[test]
1130    fn mutated_findings_absent_from_json_when_empty() {
1131        let before = make_report(vec![], vec![]);
1132        let after = make_report(vec![], vec![]);
1133        let report = diff_runs(&before, &after);
1134        let json = serde_json::to_value(&report).expect("serialize");
1135        assert!(
1136            json.get("mutated_findings").is_none(),
1137            "empty mutated_findings must not appear, existing consumers parse the old shape"
1138        );
1139    }
1140}