Skip to main content

ironcontext_core/
report.rs

1//! Top-level orchestrator: glue the parser, rule engine, RIS, and (optionally)
2//! the optimizer into one `Report`.
3//!
4//! Two entry points:
5//! * [`Report::build_security`]  — parser + rules + RIS only. This is the
6//!   `<10ms` static-analysis hot path; the optimization pass is skipped so the
7//!   `scan` and `bench` subcommands stay snappy.
8//! * [`Report::build_full`] / [`Report::build_full_with`] — adds the
9//!   description-optimization pass. Used by `ironcontext optimize`.
10
11use serde::{Deserialize, Serialize};
12
13use crate::manifest::Manifest;
14use crate::optimizer::{DescriptionOptimizer, HeuristicOptimizer, OptimizationOutcome};
15use crate::ris::{score_manifest, RisScore};
16use crate::rules::{run_all, Finding, Severity};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Report {
20    pub schema_version: u32,
21    pub server_name: String,
22    pub server_version: String,
23    pub findings: Vec<Finding>,
24    pub tools: Vec<ToolReport>,
25    pub summary: Summary,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ToolReport {
30    pub name: String,
31    pub ris: RisScore,
32    /// `None` for security-only scans; `Some(_)` when the optimizer pass ran.
33    #[serde(skip_serializing_if = "Option::is_none", default)]
34    pub optimization: Option<OptimizationOutcome>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Summary {
39    pub total_tools: usize,
40    pub total_findings: usize,
41    pub findings_by_severity: std::collections::BTreeMap<String, usize>,
42    pub mean_ris: f32,
43    /// `None` when the optimizer was skipped.
44    #[serde(skip_serializing_if = "Option::is_none", default)]
45    pub mean_token_reduction_pct: Option<f32>,
46}
47
48impl Report {
49    /// Build the security-only report (parser + rules + RIS). The hot path
50    /// that meets the `<10ms` latency target.
51    pub fn build_security(manifest: &Manifest) -> Self {
52        Self::assemble(manifest, None::<&HeuristicOptimizer>)
53    }
54
55    /// Build the full report including the optimization pass with the default
56    /// heuristic optimizer.
57    pub fn build_full(manifest: &Manifest) -> Self {
58        Self::assemble(manifest, Some(&HeuristicOptimizer::default()))
59    }
60
61    /// Build a full report using a caller-supplied optimizer (e.g. an LLM-backed
62    /// implementation of [`DescriptionOptimizer`]).
63    pub fn build_full_with<O: DescriptionOptimizer>(manifest: &Manifest, opt: &O) -> Self {
64        Self::assemble(manifest, Some(opt))
65    }
66
67    /// Backward-compatible alias for the security-only path.
68    pub fn build(manifest: &Manifest) -> Self {
69        Self::build_security(manifest)
70    }
71
72    fn assemble<O: DescriptionOptimizer>(manifest: &Manifest, opt: Option<&O>) -> Self {
73        let findings = run_all(manifest);
74        let ris_scores = score_manifest(manifest);
75
76        let tools: Vec<ToolReport> = manifest
77            .tools
78            .iter()
79            .zip(ris_scores.into_iter())
80            .map(|(t, r)| ToolReport {
81                name: t.name.clone(),
82                ris: r,
83                optimization: opt.map(|o| o.rewrite(t)),
84            })
85            .collect();
86
87        let total_tools = manifest.tools.len();
88        let total_findings = findings.len();
89
90        let mut by_sev: std::collections::BTreeMap<String, usize> = Default::default();
91        for f in &findings {
92            *by_sev.entry(severity_key(f.severity).to_string()).or_default() += 1;
93        }
94
95        let mean_ris = if tools.is_empty() {
96            0.0
97        } else {
98            tools.iter().map(|t| t.ris.score as f32).sum::<f32>() / tools.len() as f32
99        };
100        let mean_reduction = if opt.is_some() && !tools.is_empty() {
101            Some(
102                tools
103                    .iter()
104                    .filter_map(|t| t.optimization.as_ref().map(|o| o.reduction_pct))
105                    .sum::<f32>()
106                    / tools.len() as f32,
107            )
108        } else {
109            None
110        };
111
112        Report {
113            schema_version: 1,
114            server_name: manifest.server.name.clone(),
115            server_version: manifest.server.version.clone(),
116            findings,
117            tools,
118            summary: Summary {
119                total_tools,
120                total_findings,
121                findings_by_severity: by_sev,
122                mean_ris,
123                mean_token_reduction_pct: mean_reduction,
124            },
125        }
126    }
127
128    /// Exit code suitable for CI: 0 if clean, non-zero if any high+ findings.
129    pub fn exit_code(&self) -> i32 {
130        let bad = self
131            .findings
132            .iter()
133            .any(|f| matches!(f.severity, Severity::High | Severity::Critical));
134        if bad {
135            1
136        } else {
137            0
138        }
139    }
140}
141
142fn severity_key(s: Severity) -> &'static str {
143    match s {
144        Severity::Info => "info",
145        Severity::Low => "low",
146        Severity::Medium => "medium",
147        Severity::High => "high",
148        Severity::Critical => "critical",
149    }
150}