Skip to main content

wallfacer_core/
coverage.rs

1//! Phase Q — pack-coverage analyser.
2//!
3//! Static analysis of which (pack, tool) pairs *would* be exercised
4//! by a given run, computed without actually invoking the server.
5//!
6//! The signal is computed from three sources:
7//! - the parsed [`crate::property::dsl::InvariantFile`] of every
8//!   pack the operator wants to consider (their `invariants`,
9//!   `for_each_tool`, and `sequences` blocks),
10//! - the live `client.list_tools()` result,
11//! - the destructive classifier (so the matrix surfaces tools that
12//!   would be skipped at runtime as a separate "blocked" cell
13//!   rather than "not exercised").
14//!
15//! The output [`CoverageMatrix`] feeds the `wallfacer coverage` CLI,
16//! the v0.5 HTML report, and a `--strict` CI gate that fails when
17//! any tool falls into the `Uncovered` bucket.
18
19use std::collections::{BTreeMap, BTreeSet};
20
21use rmcp::model::Tool;
22use serde::Serialize;
23
24use crate::{
25    property::dsl::{Assertion, InvariantFile, ToolMatch},
26    run::{DestructiveDetector, ToolClassification},
27};
28
29/// Per-cell verdict for a `(tool, pack)` pair.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31#[serde(rename_all = "snake_case")]
32pub enum CoverageCell {
33    /// At least one invariant or sequence in the pack would target
34    /// this tool at runtime.
35    Covered,
36    /// The tool would be touched, but the destructive detector
37    /// refuses to invoke it without an allowlist match.
38    Blocked,
39    /// No invariant in the pack targets this tool name and no
40    /// `for_each_tool` filter matches its annotations / name. The
41    /// tool is silently un-exercised by this pack.
42    Uncovered,
43}
44
45/// Aggregate coverage view: one row per tool, one column per pack.
46#[derive(Debug, Clone, Serialize)]
47pub struct CoverageMatrix {
48    /// Tool names, in the order returned by `list_tools`.
49    pub tools: Vec<String>,
50    /// Pack names, in the order they were passed in.
51    pub packs: Vec<String>,
52    /// `cells[tool][pack]` — guaranteed to be present for every
53    /// declared `(tool, pack)` pair.
54    pub cells: BTreeMap<String, BTreeMap<String, CoverageCell>>,
55    /// Tools that no pack covers. Equivalent to
56    /// `tools.iter().filter(|t| every cell is Uncovered)`. Surfaced
57    /// here for `--strict` exit-code logic.
58    pub uncovered_tools: Vec<String>,
59}
60
61impl CoverageMatrix {
62    /// Builds a matrix from the parsed packs and a live tool list.
63    /// Each (pack_name, file) tuple is one entry of the result; the
64    /// caller is responsible for resolving `extends` and applying
65    /// parameter overrides before passing the file in.
66    pub fn build(
67        packs: &[(String, InvariantFile)],
68        tools: &[Tool],
69        detector: &DestructiveDetector,
70    ) -> Self {
71        let pack_names: Vec<String> = packs.iter().map(|(n, _)| n.clone()).collect();
72        let tool_names: Vec<String> = tools.iter().map(|t| t.name.to_string()).collect();
73        let tool_index: BTreeMap<String, &Tool> =
74            tools.iter().map(|t| (t.name.to_string(), t)).collect();
75
76        let mut cells: BTreeMap<String, BTreeMap<String, CoverageCell>> = BTreeMap::new();
77        for tool_name in &tool_names {
78            cells.insert(tool_name.clone(), BTreeMap::new());
79        }
80
81        for (pack_name, file) in packs {
82            // Walk every invariant + for_each_tool + sequence step
83            // and collect the set of tool names this pack would
84            // exercise for *this* live tool list.
85            let exercised = collect_exercised_tools(file, tools);
86
87            for tool_name in &tool_names {
88                let cell = if !exercised.contains(tool_name) {
89                    CoverageCell::Uncovered
90                } else if let Some(tool) = tool_index.get(tool_name) {
91                    if matches!(
92                        detector.classify(tool),
93                        ToolClassification::Destructive { .. }
94                    ) {
95                        CoverageCell::Blocked
96                    } else {
97                        CoverageCell::Covered
98                    }
99                } else {
100                    CoverageCell::Uncovered
101                };
102                if let Some(row) = cells.get_mut(tool_name) {
103                    row.insert(pack_name.clone(), cell);
104                }
105            }
106        }
107
108        let uncovered_tools = tool_names
109            .iter()
110            .filter(|tool| {
111                cells
112                    .get(*tool)
113                    .map(|row| row.values().all(|c| matches!(c, CoverageCell::Uncovered)))
114                    .unwrap_or(true)
115            })
116            .cloned()
117            .collect();
118
119        Self {
120            tools: tool_names,
121            packs: pack_names,
122            cells,
123            uncovered_tools,
124        }
125    }
126
127    /// Number of `(tool, pack)` cells that are `Covered`.
128    pub fn covered_cells(&self) -> usize {
129        self.cells
130            .values()
131            .map(|row| {
132                row.values()
133                    .filter(|c| matches!(c, CoverageCell::Covered))
134                    .count()
135            })
136            .sum()
137    }
138
139    /// Total number of `(tool, pack)` cells.
140    pub fn total_cells(&self) -> usize {
141        self.cells.values().map(|row| row.len()).sum()
142    }
143}
144
145/// Walks the pack's invariants + for_each_tool + sequences and
146/// returns the set of live tool names that at least one of those
147/// blocks would exercise.
148fn collect_exercised_tools(file: &InvariantFile, tools: &[Tool]) -> BTreeSet<String> {
149    let live_names: BTreeSet<String> = tools.iter().map(|t| t.name.to_string()).collect();
150    let mut out: BTreeSet<String> = BTreeSet::new();
151
152    for invariant in &file.invariants {
153        // A literal tool name. Only counts if it actually matches a
154        // live tool — otherwise the property runner skips it (Phase
155        // L missing-tool handling) and there's no real coverage.
156        if live_names.contains(&invariant.tool) {
157            out.insert(invariant.tool.clone());
158        }
159    }
160
161    for block in &file.for_each_tool {
162        // Compile the where-clause regexes once per block.
163        let name_re = block
164            .matches
165            .name_matches
166            .as_deref()
167            .and_then(|p| regex::Regex::new(p).ok());
168        let description_re = block
169            .matches
170            .description_matches
171            .as_deref()
172            .and_then(|p| regex::Regex::new(p).ok());
173        for tool in tools {
174            if matches_filter(
175                &block.matches,
176                tool,
177                name_re.as_ref(),
178                description_re.as_ref(),
179            ) {
180                out.insert(tool.name.to_string());
181            }
182        }
183    }
184
185    for sequence in &file.sequences {
186        // A sequence "covers" every tool it would actually call. We
187        // intentionally count the sequence as covering all of its
188        // declared steps — a partial sequence (one step missing on
189        // the target) doesn't get split into per-step credit because
190        // the runner refuses to half-run it (Phase L pre-flight
191        // check).
192        let all_steps_present = sequence
193            .steps
194            .iter()
195            .all(|step| live_names.contains(&step.call));
196        if !all_steps_present {
197            continue;
198        }
199        for step in &sequence.steps {
200            out.insert(step.call.clone());
201        }
202    }
203
204    // Walk inline `matches_schema` + `for_each` assertions inside
205    // invariants for completeness — those don't bind to a tool name
206    // directly so nothing extra to surface here, but we keep the
207    // structure in case future DSL extensions add sources.
208    let _: &dyn Fn(&[Assertion]) = &|_| {};
209
210    out
211}
212
213fn matches_filter(
214    filter: &ToolMatch,
215    tool: &Tool,
216    name_re: Option<&regex::Regex>,
217    description_re: Option<&regex::Regex>,
218) -> bool {
219    filter.matches(tool, name_re, description_re)
220}
221
222#[cfg(test)]
223#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
224mod tests {
225    use super::*;
226    use crate::{
227        property::dsl::parse,
228        target::{AllowDestructiveConfig, DestructiveConfig},
229    };
230    use rmcp::model::Tool;
231    use std::sync::Arc;
232
233    fn tool(name: &str) -> Tool {
234        Tool::new(name.to_string(), "", Arc::new(serde_json::Map::new()))
235    }
236
237    fn pack(name: &str, source: &str) -> (String, InvariantFile) {
238        (name.to_string(), parse(source).expect("parse"))
239    }
240
241    fn detector() -> DestructiveDetector {
242        DestructiveDetector::from_config(
243            &DestructiveConfig::default(),
244            &AllowDestructiveConfig::default(),
245        )
246        .expect("detector")
247    }
248
249    #[test]
250    fn invariant_targeting_a_live_tool_marks_cell_covered() {
251        let p = pack(
252            "tiny",
253            r#"
254version: 2
255invariants:
256  - name: foo
257    tool: alpha
258    fixed: {}
259    assert:
260      - kind: equals
261        lhs: { path: "$.response.isError" }
262        rhs: { value: false }
263"#,
264        );
265        let tools = vec![tool("alpha"), tool("beta")];
266        let m = CoverageMatrix::build(&[p], &tools, &detector());
267        assert_eq!(m.cells["alpha"]["tiny"], CoverageCell::Covered);
268        assert_eq!(m.cells["beta"]["tiny"], CoverageCell::Uncovered);
269        assert_eq!(m.uncovered_tools, vec!["beta".to_string()]);
270    }
271
272    #[test]
273    fn for_each_tool_block_with_no_filter_covers_every_tool() {
274        let p = pack(
275            "all-tools",
276            r#"
277version: 3
278metadata:
279  name: all-tools
280invariants: []
281for_each_tool:
282  - name: "all.{{tool_name}}"
283    apply:
284      input: schema_valid
285      assert:
286        - kind: equals
287          lhs: { path: "$.response.isError" }
288          rhs: { value: false }
289"#,
290        );
291        let tools = vec![tool("alpha"), tool("beta")];
292        let m = CoverageMatrix::build(&[p], &tools, &detector());
293        assert_eq!(m.cells["alpha"]["all-tools"], CoverageCell::Covered);
294        assert_eq!(m.cells["beta"]["all-tools"], CoverageCell::Covered);
295        assert!(m.uncovered_tools.is_empty());
296    }
297
298    #[test]
299    fn sequence_with_missing_step_does_not_count_as_coverage() {
300        let p = pack(
301            "stateful-like",
302            r#"
303version: 3
304metadata:
305  name: stateful-like
306invariants: []
307sequences:
308  - name: "test.create_then_read"
309    steps:
310      - call: create_thing
311      - call: read_thing
312"#,
313        );
314        let only_create = vec![tool("create_thing")];
315        let m = CoverageMatrix::build(&[p], &only_create, &detector());
316        // `read_thing` isn't in the live tool list, so the sequence
317        // would refuse to run — neither tool counts as covered.
318        assert_eq!(
319            m.cells["create_thing"]["stateful-like"],
320            CoverageCell::Uncovered
321        );
322    }
323
324    #[test]
325    fn coverage_summary_counts_covered_cells() {
326        let p = pack(
327            "single",
328            r#"
329version: 2
330invariants:
331  - name: foo
332    tool: alpha
333    fixed: {}
334    assert:
335      - kind: equals
336        lhs: { path: "$.response.isError" }
337        rhs: { value: false }
338"#,
339        );
340        let tools = vec![tool("alpha"), tool("beta")];
341        let m = CoverageMatrix::build(&[p], &tools, &detector());
342        assert_eq!(m.covered_cells(), 1);
343        assert_eq!(m.total_cells(), 2);
344    }
345}