Skip to main content

lean_ctx/core/code_health/
naming.rs

1//! Naming-quality heuristic.
2//!
3//! Cryptic identifiers force agents (and humans) into exhaustive search instead
4//! of targeted lookups — the article's `normalize_query` vs `_xfm_q2` example.
5//! This check is **deliberately conservative**: only clearly non-descriptive
6//! function names are reported, keeping the signal high and false positives near
7//! zero. Pure + deterministic so it is safe for read-time annotation (#498).
8
9use serde::Serialize;
10
11/// A function whose name is judged cryptic, with a human-readable reason.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
13pub struct NamingFinding {
14    pub name: String,
15    /// 1-based start line of the function.
16    pub line: usize,
17    pub message: String,
18}
19
20/// Report cryptic function names in `source` of the given file `extension`.
21/// Returns `None` when tree-sitter is disabled or the extension is unsupported.
22pub fn naming_findings(source: &str, extension: &str) -> Option<Vec<NamingFinding>> {
23    #[cfg(feature = "tree-sitter")]
24    {
25        let mut out: Vec<NamingFinding> = Vec::new();
26        super::astutil::for_each_function(source, extension, |fn_node, name, _src| {
27            if let Some(message) = cryptic_reason(name) {
28                let line = fn_node.start_position().row.saturating_add(1);
29                out.push(NamingFinding {
30                    name: name.to_string(),
31                    line,
32                    message,
33                });
34            }
35        })?;
36        out.sort_by(|a, b| a.line.cmp(&b.line).then_with(|| a.name.cmp(&b.name)));
37        out.dedup();
38        Some(out)
39    }
40    #[cfg(not(feature = "tree-sitter"))]
41    {
42        let _ = (source, extension);
43        None
44    }
45}
46
47/// Returns a reason string if `name` is cryptic, else `None`. Pure and
48/// unit-tested; this is the single source of truth for the heuristic.
49pub fn cryptic_reason(name: &str) -> Option<String> {
50    let core = name.trim_start_matches('_');
51    if core.is_empty() || core == "<anonymous>" {
52        return None;
53    }
54    if is_allowed(core) {
55        return None;
56    }
57    let len = core.chars().count();
58    if len <= 2 {
59        return Some(format!("name `{name}` is too short to convey intent"));
60    }
61    if !has_vowel(core) && !is_known_acronym(core) {
62        return Some(format!(
63            "name `{name}` has no vowels — likely a cryptic abbreviation"
64        ));
65    }
66    None
67}
68
69/// Short identifiers that are idiomatic enough to never flag.
70fn is_allowed(core: &str) -> bool {
71    matches!(
72        core.to_ascii_lowercase().as_str(),
73        "id" | "ok" | "io" | "db" | "ui" | "os" | "vm" | "fn" | "go" | "rx" | "tx" | "fd" | "ip"
74    )
75}
76
77/// Common consonant-only acronyms that are clear despite lacking vowels.
78fn is_known_acronym(core: &str) -> bool {
79    matches!(
80        core.to_ascii_lowercase().as_str(),
81        "db" | "js"
82            | "ts"
83            | "css"
84            | "html"
85            | "http"
86            | "https"
87            | "url"
88            | "uri"
89            | "sql"
90            | "xml"
91            | "json"
92            | "jwt"
93            | "rpc"
94            | "grpc"
95            | "tcp"
96            | "udp"
97            | "ip"
98            | "dns"
99            | "fs"
100            | "os"
101            | "vm"
102            | "csv"
103            | "pdf"
104            | "png"
105            | "jpg"
106            | "svg"
107            | "md5"
108            | "sha"
109            | "crc"
110    )
111}
112
113fn has_vowel(s: &str) -> bool {
114    s.chars()
115        .any(|c| matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y'))
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn flags_vowelless_abbreviation() {
124        assert!(cryptic_reason("_xfm_q2").is_some());
125        assert!(cryptic_reason("qstr").is_some());
126    }
127
128    #[test]
129    fn flags_too_short() {
130        assert!(cryptic_reason("zq").is_some());
131        assert!(cryptic_reason("x").is_some());
132    }
133
134    #[test]
135    fn accepts_descriptive_names() {
136        assert!(cryptic_reason("normalize_query").is_none());
137        assert!(cryptic_reason("parse").is_none());
138        assert!(cryptic_reason("handleRequest").is_none());
139    }
140
141    #[test]
142    fn accepts_known_short_and_acronyms() {
143        assert!(cryptic_reason("id").is_none());
144        assert!(cryptic_reason("db").is_none());
145        assert!(cryptic_reason("to_json").is_none());
146        assert!(cryptic_reason("http").is_none());
147    }
148
149    #[cfg(feature = "tree-sitter")]
150    #[test]
151    fn finds_cryptic_function_in_source() {
152        let src = "fn _xfm_q2(a: i32) -> i32 { a }\nfn normalize_query(b: i32) -> i32 { b }\n";
153        let findings = naming_findings(src, "rs").unwrap();
154        assert_eq!(findings.len(), 1);
155        assert_eq!(findings[0].name, "_xfm_q2");
156    }
157
158    #[cfg(feature = "tree-sitter")]
159    #[test]
160    fn deterministic_across_runs() {
161        let src = "fn zq() {}\nfn ab() {}\n";
162        assert_eq!(naming_findings(src, "rs"), naming_findings(src, "rs"));
163    }
164}