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