Skip to main content

nyx_scanner/
engine_notes.rs

1//! Provenance notes attached to findings when the engine has hit an
2//! internal budget, widening, or lowering cap.
3//!
4//! Each note carries a [`LossDirection`] classification.
5//! [`crate::evidence::compute_confidence`] caps confidence at `Medium`
6//! for `OverReport`/`Bail` notes, and [`crate::rank`] applies a
7//! direction-aware completeness penalty.
8
9use serde::{Deserialize, Serialize};
10use smallvec::SmallVec;
11
12/// Why a fix-point loop hit its safety cap. Distinguishes "raise the
13/// cap" cases from non-monotonicity bugs in cap-hit telemetry.
14/// Serialized as a tagged snake_case enum for SARIF/JSON consumers.
15#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(tag = "kind", rename_all = "snake_case")]
17pub enum CapHitReason {
18    /// Change-set still decreasing when the cap fired. Safe to raise
19    /// the cap; the SCC is just larger than budget.
20    MonotoneShrinking { trajectory: SmallVec<[u32; 4]> },
21    /// Change-set held steady at a non-zero value for ≥2 iterations.
22    /// Same keys updating back and forth, investigate.
23    Plateau { delta: u32 },
24    /// Period-2 oscillation detected. Non-monotone; raising the cap
25    /// will not help. File a bug.
26    SuspectedOscillation {
27        period: u8,
28        trajectory: SmallVec<[u32; 4]>,
29    },
30    /// No trajectory recorded (e.g. cap fired after a single iteration).
31    #[default]
32    Unknown,
33}
34
35impl CapHitReason {
36    /// Classify a trajectory of per-iteration change-set sizes
37    /// (most recent last). Rules: <2 samples → `Unknown`; a,b,a,b with
38    /// a≠b → `SuspectedOscillation`; last two equal non-zero →
39    /// `Plateau`; strictly decreasing tail → `MonotoneShrinking`;
40    /// otherwise `Unknown`.
41    pub fn classify(deltas: &[u32]) -> CapHitReason {
42        if deltas.len() < 2 {
43            return CapHitReason::Unknown;
44        }
45
46        // Detect period-2 oscillation: last 4 samples as (a,b,a,b) with a ≠ b.
47        if deltas.len() >= 4 {
48            let n = deltas.len();
49            let (a0, b0, a1, b1) = (deltas[n - 4], deltas[n - 3], deltas[n - 2], deltas[n - 1]);
50            if a0 == a1 && b0 == b1 && a0 != b0 {
51                let tail = deltas
52                    .iter()
53                    .rev()
54                    .take(4)
55                    .rev()
56                    .copied()
57                    .collect::<SmallVec<[u32; 4]>>();
58                return CapHitReason::SuspectedOscillation {
59                    period: 2,
60                    trajectory: tail,
61                };
62            }
63        }
64
65        let last = deltas[deltas.len() - 1];
66        let prev = deltas[deltas.len() - 2];
67
68        // Plateau: change-set size stuck at the same non-zero value.
69        if last == prev && last > 0 {
70            return CapHitReason::Plateau { delta: last };
71        }
72
73        // Monotone shrinking: strictly decreasing over the full
74        // recorded tail.  (Equal-zero at the end would have meant
75        // convergence, so the cap wouldn't have fired.)
76        let mut monotone = true;
77        for w in deltas.windows(2) {
78            if w[1] > w[0] {
79                monotone = false;
80                break;
81            }
82        }
83        if monotone {
84            let tail = deltas
85                .iter()
86                .rev()
87                .take(4)
88                .rev()
89                .copied()
90                .collect::<SmallVec<[u32; 4]>>();
91            return CapHitReason::MonotoneShrinking { trajectory: tail };
92        }
93
94        CapHitReason::Unknown
95    }
96
97    /// Stable snake-case tag for log/diag consumption.
98    pub fn tag(&self) -> &'static str {
99        match self {
100            CapHitReason::MonotoneShrinking { .. } => "monotone_shrinking",
101            CapHitReason::Plateau { .. } => "plateau",
102            CapHitReason::SuspectedOscillation { .. } => "suspected_oscillation",
103            CapHitReason::Unknown => "unknown",
104        }
105    }
106}
107
108/// Direction of precision loss encoded by an [`EngineNote`].
109/// Variants are ordered by worsening credibility impact;
110/// [`combine`](Self::combine) takes the max.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum LossDirection {
114    /// Analysis converged; the note records a harmless event.
115    Informational,
116    /// Analysis may have missed findings (worklist was capped). Reported
117    /// findings remain sound, the result set is a lower bound.
118    UnderReport,
119    /// Analysis may have reported a spurious finding (e.g. predicate
120    /// state widened to top, dropping a guard). Likely FP.
121    OverReport,
122    /// Analysis aborted before producing a trustworthy result.
123    /// Treat the finding as a starting point, not a confirmed flow.
124    Bail,
125}
126
127impl LossDirection {
128    /// Merge by taking the worse (later in `Ord`).
129    pub fn combine(self, other: LossDirection) -> LossDirection {
130        self.max(other)
131    }
132
133    /// Snake-case tag used in console output and JSON properties.
134    pub fn tag(self) -> &'static str {
135        match self {
136            LossDirection::Informational => "informational",
137            LossDirection::UnderReport => "under-report",
138            LossDirection::OverReport => "over-report",
139            LossDirection::Bail => "bail",
140        }
141    }
142}
143
144/// A single provenance event recorded during analysis.
145#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
146#[serde(tag = "kind", rename_all = "snake_case")]
147pub enum EngineNote {
148    /// Taint worklist hit its iteration budget. UnderReport.
149    WorklistCapped { iterations: u32 },
150    /// Per-value origin set truncated to `analysis.engine.max_origins`
151    /// (default 32). UnderReport, dropped origins correspond to real
152    /// source flows whose findings won't emit.
153    OriginsTruncated { dropped: u32 },
154    /// JS/TS pass-2 in-file global propagation hit its cap. UnderReport.
155    InFileFixpointCapped {
156        iterations: u32,
157        #[serde(default)]
158        reason: CapHitReason,
159    },
160    /// Cross-file SCC fixpoint hit `SCC_FIXPOINT_SAFETY_CAP`. UnderReport.
161    CrossFileFixpointCapped {
162        iterations: u32,
163        #[serde(default)]
164        reason: CapHitReason,
165    },
166    /// SSA lowering produced an empty body. Bail.
167    SsaLoweringBailed { reason: String },
168    /// Tree-sitter parse exceeded the timeout. Bail.
169    ParseTimeout { timeout_ms: u32 },
170    /// Predicate state widened to top to keep the lattice monotone.
171    /// OverReport, guards may have been lost.
172    PredicateStateWidened,
173    /// Path-environment constraints widened to top. OverReport.
174    PathEnvCapped,
175    /// Inline cache reused a cached body. Informational.
176    InlineCacheReused,
177    /// Points-to set truncated to `analysis.engine.max_pointsto`
178    /// (default 32). UnderReport.
179    PointsToTruncated { dropped: u32 },
180}
181
182impl EngineNote {
183    /// Direction of precision loss for this note. New variants must
184    /// declare one explicitly.
185    pub fn direction(&self) -> LossDirection {
186        match self {
187            EngineNote::WorklistCapped { .. } => LossDirection::UnderReport,
188            EngineNote::OriginsTruncated { .. } => LossDirection::UnderReport,
189            EngineNote::InFileFixpointCapped { .. } => LossDirection::UnderReport,
190            EngineNote::CrossFileFixpointCapped { .. } => LossDirection::UnderReport,
191            EngineNote::SsaLoweringBailed { .. } => LossDirection::Bail,
192            EngineNote::ParseTimeout { .. } => LossDirection::Bail,
193            EngineNote::PredicateStateWidened => LossDirection::OverReport,
194            EngineNote::PathEnvCapped => LossDirection::OverReport,
195            EngineNote::InlineCacheReused => LossDirection::Informational,
196            EngineNote::PointsToTruncated { .. } => LossDirection::UnderReport,
197        }
198    }
199
200    /// True for any non-informational direction. Drives the
201    /// `confidence_capped` SARIF property.
202    pub fn lowers_confidence(&self) -> bool {
203        self.direction() != LossDirection::Informational
204    }
205}
206
207/// Worst non-informational direction across a slice of notes, or
208/// `None` if the slice is empty or only carries informational notes.
209pub fn worst_direction(notes: &[EngineNote]) -> Option<LossDirection> {
210    let mut worst: Option<LossDirection> = None;
211    for note in notes {
212        let dir = note.direction();
213        if dir == LossDirection::Informational {
214            continue;
215        }
216        worst = Some(match worst {
217            Some(w) => w.combine(dir),
218            None => dir,
219        });
220    }
221    worst
222}
223
224/// Push-if-not-present.
225pub fn push_unique(notes: &mut smallvec::SmallVec<[EngineNote; 2]>, note: EngineNote) {
226    if !notes.iter().any(|n| n == &note) {
227        notes.push(note);
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn worklist_capped_lowers_confidence() {
237        assert!(EngineNote::WorklistCapped { iterations: 10 }.lowers_confidence());
238    }
239
240    #[test]
241    fn inline_cache_reused_does_not_lower_confidence() {
242        assert!(!EngineNote::InlineCacheReused.lowers_confidence());
243    }
244
245    #[test]
246    fn serialization_uses_snake_case_tag() {
247        let note = EngineNote::WorklistCapped { iterations: 7 };
248        let s = serde_json::to_string(&note).unwrap();
249        assert!(s.contains("\"kind\":\"worklist_capped\""));
250        assert!(s.contains("\"iterations\":7"));
251    }
252
253    #[test]
254    fn push_unique_deduplicates() {
255        let mut v = smallvec::SmallVec::<[EngineNote; 2]>::new();
256        push_unique(&mut v, EngineNote::WorklistCapped { iterations: 1 });
257        push_unique(&mut v, EngineNote::WorklistCapped { iterations: 1 });
258        push_unique(&mut v, EngineNote::OriginsTruncated { dropped: 2 });
259        assert_eq!(v.len(), 2);
260    }
261
262    #[test]
263    fn direction_classification_is_exhaustive() {
264        // Budget caps ⇒ under-report: fixpoint aborted, results still sound.
265        assert_eq!(
266            EngineNote::WorklistCapped { iterations: 1 }.direction(),
267            LossDirection::UnderReport
268        );
269        assert_eq!(
270            EngineNote::OriginsTruncated { dropped: 1 }.direction(),
271            LossDirection::UnderReport
272        );
273        assert_eq!(
274            EngineNote::InFileFixpointCapped {
275                iterations: 1,
276                reason: CapHitReason::Unknown,
277            }
278            .direction(),
279            LossDirection::UnderReport
280        );
281        assert_eq!(
282            EngineNote::CrossFileFixpointCapped {
283                iterations: 1,
284                reason: CapHitReason::Unknown,
285            }
286            .direction(),
287            LossDirection::UnderReport
288        );
289        assert_eq!(
290            EngineNote::PointsToTruncated { dropped: 1 }.direction(),
291            LossDirection::UnderReport
292        );
293
294        // Widening ⇒ over-report: validation guards may have been lost.
295        assert_eq!(
296            EngineNote::PredicateStateWidened.direction(),
297            LossDirection::OverReport
298        );
299        assert_eq!(
300            EngineNote::PathEnvCapped.direction(),
301            LossDirection::OverReport
302        );
303
304        // Hard aborts ⇒ bail: IR or parse failed.
305        assert_eq!(
306            EngineNote::SsaLoweringBailed { reason: "x".into() }.direction(),
307            LossDirection::Bail
308        );
309        assert_eq!(
310            EngineNote::ParseTimeout { timeout_ms: 1 }.direction(),
311            LossDirection::Bail
312        );
313
314        // Informational ⇒ no credibility impact.
315        assert_eq!(
316            EngineNote::InlineCacheReused.direction(),
317            LossDirection::Informational
318        );
319    }
320
321    #[test]
322    fn loss_direction_order_is_worst_last() {
323        // combine() takes the max, so Bail must dominate OverReport must
324        // dominate UnderReport must dominate Informational.
325        assert!(LossDirection::Bail > LossDirection::OverReport);
326        assert!(LossDirection::OverReport > LossDirection::UnderReport);
327        assert!(LossDirection::UnderReport > LossDirection::Informational);
328    }
329
330    #[test]
331    fn combine_takes_the_worse_direction() {
332        assert_eq!(
333            LossDirection::UnderReport.combine(LossDirection::OverReport),
334            LossDirection::OverReport
335        );
336        assert_eq!(
337            LossDirection::OverReport.combine(LossDirection::UnderReport),
338            LossDirection::OverReport
339        );
340        assert_eq!(
341            LossDirection::Bail.combine(LossDirection::OverReport),
342            LossDirection::Bail
343        );
344        assert_eq!(
345            LossDirection::Informational.combine(LossDirection::Informational),
346            LossDirection::Informational
347        );
348    }
349
350    #[test]
351    fn worst_direction_empty_is_none() {
352        let notes: Vec<EngineNote> = vec![];
353        assert_eq!(worst_direction(&notes), None);
354    }
355
356    #[test]
357    fn worst_direction_informational_only_is_none() {
358        let notes = vec![EngineNote::InlineCacheReused, EngineNote::InlineCacheReused];
359        assert_eq!(worst_direction(&notes), None);
360    }
361
362    #[test]
363    fn worst_direction_mixed_picks_worst() {
364        let notes = vec![
365            EngineNote::InlineCacheReused,
366            EngineNote::WorklistCapped { iterations: 1 },
367            EngineNote::PredicateStateWidened,
368        ];
369        assert_eq!(worst_direction(&notes), Some(LossDirection::OverReport));
370    }
371
372    #[test]
373    fn worst_direction_bail_dominates() {
374        let notes = vec![
375            EngineNote::PredicateStateWidened,
376            EngineNote::ParseTimeout { timeout_ms: 100 },
377        ];
378        assert_eq!(worst_direction(&notes), Some(LossDirection::Bail));
379    }
380
381    #[test]
382    fn cap_hit_reason_too_few_samples_unknown() {
383        assert_eq!(CapHitReason::classify(&[]), CapHitReason::Unknown);
384        assert_eq!(CapHitReason::classify(&[5]), CapHitReason::Unknown);
385    }
386
387    #[test]
388    fn cap_hit_reason_detects_period_2_oscillation() {
389        let result = CapHitReason::classify(&[3, 7, 3, 7]);
390        match result {
391            CapHitReason::SuspectedOscillation { period, .. } => assert_eq!(period, 2),
392            other => panic!("expected SuspectedOscillation; got {other:?}"),
393        }
394    }
395
396    #[test]
397    fn cap_hit_reason_detects_plateau() {
398        let result = CapHitReason::classify(&[10, 5, 5]);
399        assert_eq!(result, CapHitReason::Plateau { delta: 5 });
400    }
401
402    #[test]
403    fn cap_hit_reason_plateau_at_zero_is_not_a_plateau() {
404        // Zero-delta means we converged; classifier should not flag.
405        let result = CapHitReason::classify(&[3, 0, 0]);
406        // Strictly decreasing tail → monotone-shrinking; not plateau.
407        match result {
408            CapHitReason::MonotoneShrinking { .. } => {}
409            other => panic!("expected MonotoneShrinking; got {other:?}"),
410        }
411    }
412
413    #[test]
414    fn cap_hit_reason_detects_monotone_shrinking() {
415        let result = CapHitReason::classify(&[10, 7, 4, 2]);
416        match result {
417            CapHitReason::MonotoneShrinking { trajectory } => {
418                assert_eq!(trajectory.as_slice(), &[10, 7, 4, 2]);
419            }
420            other => panic!("expected MonotoneShrinking; got {other:?}"),
421        }
422    }
423
424    #[test]
425    fn cap_hit_reason_non_monotone_non_oscillating_is_unknown() {
426        // Goes up then down without a clean period-2 pattern.
427        let result = CapHitReason::classify(&[3, 8, 2]);
428        assert_eq!(result, CapHitReason::Unknown);
429    }
430
431    #[test]
432    fn cap_hit_reason_serializes_snake_case_tag() {
433        let r = CapHitReason::Plateau { delta: 4 };
434        let s = serde_json::to_string(&r).unwrap();
435        assert!(s.contains("\"kind\":\"plateau\""), "got {s}");
436        assert!(s.contains("\"delta\":4"), "got {s}");
437    }
438
439    #[test]
440    fn in_file_fixpoint_capped_serde_backcompat() {
441        // Older serialized notes without the `reason` field must still
442        // deserialize (serde(default) → CapHitReason::Unknown).
443        let legacy = r#"{"kind":"in_file_fixpoint_capped","iterations":7}"#;
444        let parsed: EngineNote = serde_json::from_str(legacy).unwrap();
445        match parsed {
446            EngineNote::InFileFixpointCapped { iterations, reason } => {
447                assert_eq!(iterations, 7);
448                assert_eq!(reason, CapHitReason::Unknown);
449            }
450            other => panic!("expected InFileFixpointCapped; got {other:?}"),
451        }
452    }
453
454    #[test]
455    fn cross_file_fixpoint_capped_serde_backcompat() {
456        let legacy = r#"{"kind":"cross_file_fixpoint_capped","iterations":64}"#;
457        let parsed: EngineNote = serde_json::from_str(legacy).unwrap();
458        match parsed {
459            EngineNote::CrossFileFixpointCapped { iterations, reason } => {
460                assert_eq!(iterations, 64);
461                assert_eq!(reason, CapHitReason::Unknown);
462            }
463            other => panic!("expected CrossFileFixpointCapped; got {other:?}"),
464        }
465    }
466
467    #[test]
468    fn loss_direction_tag_stable() {
469        assert_eq!(LossDirection::UnderReport.tag(), "under-report");
470        assert_eq!(LossDirection::OverReport.tag(), "over-report");
471        assert_eq!(LossDirection::Bail.tag(), "bail");
472        assert_eq!(LossDirection::Informational.tag(), "informational");
473    }
474}