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;