Skip to main content

code_ranker_graph/
registry.rs

1//! Declarative metric registry — the data-driven home for tier-2+ formulas.
2//!
3//! A [`MetricDef`] pairs a CEL `formula_cel` with its spec (label / direction / …).
4//! The default registry ships built-in derived metrics; a user adds their own by
5//! editing config (e.g. `comment_ratio = "sloc > 0.0 ? cloc / sloc * 100.0 :
6//! 0.0"`). The [`Engine`] compiles each formula once, topologically orders them
7//! by inter-metric dependencies, and evaluates them per node over the node's
8//! numeric attributes — the same engine will serve file and function units.
9//!
10//! Scope: today the engine evaluates **node-scope** metrics and is used to
11//! compute *extra* metrics on top of the built-in Rust derivation, so the
12//! default pipeline (and its goldens) are untouched until this is wired in.
13//! Graph-scope aggregates (`scope = graph`, percentiles, reducers) come later.
14
15use crate::attrs::num_attr;
16use cel::{Context, Program};
17use code_ranker_plugin_api::{attrs::AttrValue, node::Node};
18use std::collections::BTreeMap;
19
20/// Registry data types (extracted to a dependency-free leaf so `eval` can build
21/// on them without a module cycle). Re-exported so existing call sites and the
22/// `use super::*` test modules compile unchanged.
23mod model;
24pub use model::{MetricDef, RegistryError, Scope};
25// `builtin.rs` reaches these serde-default fns via `crate::registry::…`.
26pub(crate) use model::{default_omit_at, default_value_type};
27
28/// Pure numeric / formula evaluation helpers + [`Populations`] (extracted to keep
29/// this file's aggregate complexity in check). Re-exported so existing call sites
30/// and the `use super::*` test modules compile unchanged.
31mod eval;
32pub use eval::Populations;
33use eval::{exec_f64, register_agg, topo_order};
34pub(crate) use eval::{references, register_math};
35// Used only by the `use super::*` test submodules below.
36#[cfg(test)]
37use eval::{percentile, reduce};
38
39/// Apply the node-scope engine to one node: read its numeric attributes, compute
40/// every registry metric, and write the results back, omitting each at its
41/// `omit_at` exactly like [`crate::write_metrics`]. Built-in attributes already
42/// present are used as inputs.
43pub fn apply_to_node(node: &mut Node, defs: &BTreeMap<String, MetricDef>, engine: &Engine) {
44    let mut attrs: BTreeMap<String, f64> = BTreeMap::new();
45    let mut strings: BTreeMap<String, String> = BTreeMap::new();
46    for (k, v) in &node.attrs {
47        match v {
48            AttrValue::Int(i) => {
49                attrs.insert(k.clone(), *i as f64);
50            }
51            AttrValue::Float(f) => {
52                attrs.insert(k.clone(), *f);
53            }
54            AttrValue::Str(s) => {
55                strings.insert(k.clone(), s.clone());
56            }
57            AttrValue::Bool(_) => {}
58        }
59    }
60    // Derived path fields (`path`/`name`/`stem`/`ext`/`dir`) so a formula can
61    // branch on the file's location, the same vars `[rules.checks]` sees.
62    for (k, v) in crate::nodepath::path_fields(node) {
63        strings.insert(k.to_string(), v);
64    }
65    for (key, value) in engine.eval_node(&attrs, &strings) {
66        let omit = defs.get(&key).map(|d| d.omit_at).unwrap_or(0.0);
67        let a = num_attr(value);
68        if a == num_attr(omit) {
69            node.attrs.remove(&key);
70        } else {
71            node.attrs.insert(key, a);
72        }
73    }
74}
75
76/// A compiled, topologically ordered set of metric programs, split by scope:
77/// node-scope (per node) and graph-scope (once over the whole node set, via the
78/// `agg(key, reducer, population)` reducer function).
79pub struct Engine {
80    /// `(key, compiled program)` in dependency order (inputs before dependents).
81    node_programs: Vec<(String, Program)>,
82    graph_programs: Vec<(String, Program)>,
83}
84
85impl Engine {
86    /// Compile and order both scopes of a registry. Detects formula parse errors
87    /// and dependency cycles (within each scope) up front.
88    pub fn compile(defs: &BTreeMap<String, MetricDef>) -> Result<Engine, RegistryError> {
89        Ok(Engine {
90            node_programs: compile_scope(defs, Scope::Node)?,
91            graph_programs: compile_scope(defs, Scope::Graph)?,
92        })
93    }
94
95    /// Does this registry declare any graph-scope (aggregate) metrics?
96    pub fn has_graph_metrics(&self) -> bool {
97        !self.graph_programs.is_empty()
98    }
99
100    /// Evaluate every node-scope metric over `attrs` (a node's numeric values)
101    /// plus `strings` (its string values + derived path fields, so a formula can
102    /// branch on `path`/`name`/… e.g. `path.contains("/generated/") ? 0.0 : hk`).
103    /// Returns only the **newly computed** keys. A formula that errors or yields a
104    /// non-finite value contributes nothing (the metric is omitted for that node),
105    /// mirroring the viewer's `evalCalc` and `omit_at` semantics.
106    pub fn eval_node(
107        &self,
108        attrs: &BTreeMap<String, f64>,
109        strings: &BTreeMap<String, String>,
110    ) -> BTreeMap<String, f64> {
111        // One context per node: the host functions ([`register_math`]) and the
112        // node's inputs are set up once, then each computed metric is fed back
113        // into the SAME context so later (dependency-ordered) formulas read it —
114        // no per-formula context rebuild or function re-registration. The compiled
115        // `Program`s are reused as-is (see [`Engine::compile`]).
116        let mut ctx = Context::default();
117        register_math(&mut ctx);
118        for (k, v) in attrs {
119            let _ = ctx.add_variable(k.as_str(), *v);
120        }
121        for (k, v) in strings {
122            let _ = ctx.add_variable(k.as_str(), v.clone());
123        }
124        let mut produced: BTreeMap<String, f64> = BTreeMap::new();
125        for (key, program) in &self.node_programs {
126            if let Some(v) = exec_f64(program, &ctx) {
127                let _ = ctx.add_variable(key.as_str(), v); // feed-forward to dependents
128                produced.insert(key.clone(), v);
129            }
130        }
131        produced
132    }
133
134    /// Evaluate the graph-scope (aggregate) metrics once over the whole node set,
135    /// captured as [`Populations`]. Formulas use the `agg(key, reducer,
136    /// population)` reducer function (e.g. `agg('cyclomatic', 'p90',
137    /// 'not_empty')`) and may reference earlier graph metrics by name. Empty /
138    /// non-finite results are omitted. Returns the produced aggregate values.
139    pub fn eval_graph(&self, pops: &Populations) -> BTreeMap<String, f64> {
140        // One context for the whole graph pass: host functions + the population
141        // reducer (`agg`) are registered once, and each aggregate is fed back so
142        // a later aggregate can reference it. Programs are compiled once.
143        let mut ctx = Context::default();
144        register_math(&mut ctx);
145        register_agg(&mut ctx, std::sync::Arc::new(pops.clone()));
146        let mut produced: BTreeMap<String, f64> = BTreeMap::new();
147        for (key, program) in &self.graph_programs {
148            if let Some(v) = exec_f64(program, &ctx) {
149                let _ = ctx.add_variable(key.as_str(), v); // feed-forward to dependents
150                produced.insert(key.clone(), v);
151            }
152        }
153        produced
154    }
155}
156
157/// Compile and topologically order the metrics of one scope.
158fn compile_scope(
159    defs: &BTreeMap<String, MetricDef>,
160    scope: Scope,
161) -> Result<Vec<(String, Program)>, RegistryError> {
162    let scoped: Vec<(&String, &MetricDef)> =
163        defs.iter().filter(|(_, d)| d.scope == scope).collect();
164    let keys: Vec<String> = scoped.iter().map(|(k, _)| (*k).clone()).collect();
165    let order = topo_order(&scoped, &keys)?;
166    let mut programs = Vec::with_capacity(order.len());
167    for key in order {
168        let def = defs.get(&key).expect("key from defs");
169        let program = Program::compile(&def.formula_cel).map_err(|e| RegistryError::Parse {
170            key: key.clone(),
171            message: e.to_string(),
172        })?;
173        programs.push((key, program));
174    }
175    Ok(programs)
176}
177
178#[cfg(test)]
179#[path = "registry_test.rs"]
180mod tests;
181
182#[cfg(test)]
183#[path = "registry_cover_test.rs"]
184mod cover_tests;