1use std::collections::{BTreeMap, BTreeSet};
22
23use serde::Serialize;
24
25use crate::detect::{Finding, FindingType, Severity};
26use crate::report::{PerEndpointIoOps, Report, Warning};
27
28type 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 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
61fn 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#[derive(Debug, Clone, Serialize)]
76pub struct DiffReport {
77 pub new_findings: Vec<Finding>,
79 pub resolved_findings: Vec<Finding>,
81 pub severity_changes: Vec<SeverityChange>,
84 pub endpoint_metric_deltas: Vec<EndpointDelta>,
87 #[serde(skip_serializing_if = "Vec::is_empty")]
91 pub warning_details: Vec<Warning>,
92 #[serde(skip_serializing_if = "Vec::is_empty")]
98 pub mutated_findings: Vec<MutatedFinding>,
99}
100
101#[derive(Debug, Clone, Serialize)]
104pub struct MutatedFinding {
105 pub before: Finding,
107 pub after: Finding,
109}
110
111#[derive(Debug, Clone, Serialize)]
113pub struct SeverityChange {
114 pub finding: Finding,
116 pub before_severity: Severity,
117 pub after_severity: Severity,
118}
119
120impl SeverityChange {
121 #[must_use]
128 pub fn is_regression(&self) -> bool {
129 self.after_severity < self.before_severity
130 }
131}
132
133#[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 pub delta: i64,
142}
143
144#[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 crate::detect::sort_findings(&mut new_findings);
184 crate::detect::sort_findings(&mut resolved_findings);
185 let mutated_findings = pair_mutations(&mut resolved_findings, &mut new_findings, with_grouping);
189 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
214type 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
223fn 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 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
279fn 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
312fn 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
319fn 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
330fn 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 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
377fn 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 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 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 #[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 #[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 #[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 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 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 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 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 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 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}