Skip to main content

lean_ctx/core/code_health/
mod.rs

1//! Native code-health engine — clean code as a token-cost lever.
2//!
3//! Computes the structural signals the Sonar study links to agent token cost —
4//! **cognitive complexity** (S3776-style), **naming quality**, and **module
5//! coupling** — once during indexing, then fans them out across the data fabric
6//! and every agent surface (see the Code Health Engine plan).
7//!
8//! Naming note: this is distinct from [`crate::core::quality`] (compression
9//! fidelity) and from [`crate::core::gain`]'s usage-quality component; this
10//! module scores the *source code's* navigability.
11
12pub mod annotate;
13#[cfg(feature = "tree-sitter")]
14pub mod astutil;
15#[allow(dead_code)]
16pub mod cognitive;
17pub mod delta;
18pub mod fabric;
19pub mod gate;
20pub mod naming;
21pub mod persist;
22pub mod report;
23pub mod scan;
24pub mod score;
25
26pub use cognitive::{FunctionCognitive, cognitive_per_function};
27pub use delta::{cognitive_delta, format_gate_notice, worst_regression};
28pub use naming::{NamingFinding, naming_findings};
29pub use scan::{ProjectHealth, scan_project};
30pub use score::{Hotspot, NavigabilityInputs, NavigabilityScore, grade, navigability};
31
32use serde::Serialize;
33
34/// Default cognitive-complexity threshold (SonarQube S3776 "HIGH" default).
35/// A function at or below this is considered navigable. Mirrored by
36/// `CodeHealthConfig::default().cognitive_threshold`.
37pub const DEFAULT_COGNITIVE_THRESHOLD: u32 = 15;
38
39/// Edit-gate behavior when an edit increases cognitive complexity.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum GateMode {
42    /// Never emit a code-health gate notice.
43    Off,
44    /// Append an advisory `[CODE HEALTH]` notice (default).
45    #[default]
46    Warn,
47    /// Refuse edits that push a clean function over the threshold.
48    Block,
49}
50
51impl GateMode {
52    /// Parse a config string; unknown values fall back to [`GateMode::Warn`].
53    pub fn parse(value: &str) -> Self {
54        match value.trim().to_ascii_lowercase().as_str() {
55            "off" | "false" | "none" | "disabled" => GateMode::Off,
56            "block" | "hard" | "error" => GateMode::Block,
57            _ => GateMode::Warn,
58        }
59    }
60}
61
62/// Combined per-file health: cognitive scores plus naming findings. This is the
63/// single entry point used by the edit-gate, read annotations, and the
64/// `ctx_quality` tool.
65#[derive(Debug, Clone, Default, PartialEq, Serialize)]
66pub struct FileHealth {
67    pub functions: Vec<FunctionCognitive>,
68    pub naming: Vec<NamingFinding>,
69}
70
71impl FileHealth {
72    /// Functions whose cognitive complexity exceeds `threshold`.
73    pub fn over_threshold(&self, threshold: u32) -> impl Iterator<Item = &FunctionCognitive> {
74        self.functions
75            .iter()
76            .filter(move |f| f.cognitive > threshold)
77    }
78
79    /// The single worst cognitive complexity in the file (0 if none).
80    pub fn worst_cognitive(&self) -> u32 {
81        self.functions
82            .iter()
83            .map(|f| f.cognitive)
84            .max()
85            .unwrap_or(0)
86    }
87}
88
89/// Analyze one file's `source` for the given file `extension`.
90///
91/// Returns `None` only when tree-sitter is disabled or the extension is
92/// unsupported (i.e. no functions could be parsed). Naming findings default to
93/// empty when the language has no analyzable identifiers.
94pub fn analyze_file(source: &str, extension: &str) -> Option<FileHealth> {
95    let functions = cognitive_per_function(source, extension)?;
96    let naming = naming_findings(source, extension).unwrap_or_default();
97    Some(FileHealth { functions, naming })
98}
99
100#[cfg(all(test, feature = "tree-sitter"))]
101pub mod tests {
102    use super::*;
103
104    #[test]
105    fn analyze_file_combines_signals() {
106        let src = "fn _xfm_q2(a: bool, b: bool) { if a { if b {} } }\n";
107        let health = analyze_file(src, "rs").unwrap();
108        assert_eq!(health.functions.len(), 1);
109        assert_eq!(health.worst_cognitive(), 3);
110        assert_eq!(health.naming.len(), 1, "cryptic name flagged");
111    }
112
113    #[test]
114    fn analyze_file_unsupported_ext_is_none() {
115        assert!(analyze_file("plain text", "txt").is_none());
116    }
117
118    #[test]
119    fn over_threshold_filters() {
120        let src = "fn deep(a: bool) { if a { if a { if a { if a {} } } } }\n";
121        let health = analyze_file(src, "rs").unwrap();
122        // 1+2+3+4 = 10 cognitive; above 5, below 15.
123        assert_eq!(health.over_threshold(5).count(), 1);
124        assert_eq!(health.over_threshold(15).count(), 0);
125    }
126}