Skip to main content

layer_conform_core/
explain.rs

1//! Build a full scoring matrix for the `why` command.
2//!
3//! Mirrors `pipeline::detect_deviations` but emits *every* (rule, function,
4//! golden) score — including matches above threshold and ignored functions —
5//! so callers can render the full reasoning behind a deviation/conform call.
6
7use std::path::Path;
8
9use compact_str::CompactString;
10
11use crate::matcher;
12use crate::pipeline::{score_pair, ExtractedFiles};
13use crate::rule::{GoldenSelector, Rule};
14use crate::similarity::{SimilarityScore, Weights};
15use crate::FunctionRef;
16
17const DEFAULT_THRESHOLD: f64 = 0.7;
18
19#[derive(Clone, Debug)]
20pub struct WhyReport {
21    pub file: String,
22    pub entries: Vec<WhyEntry>,
23}
24
25#[derive(Clone, Debug)]
26pub enum WhyEntry {
27    /// Function carried a `layer-conform-ignore` directive and was skipped.
28    Skipped { rule_id: String, symbol: CompactString },
29    /// Function was scored against every resolvable golden of the rule.
30    Scored {
31        rule_id: String,
32        symbol: CompactString,
33        threshold: f64,
34        matches: Vec<WhyMatch>,
35    },
36}
37
38#[derive(Clone, Debug)]
39pub struct WhyMatch {
40    pub golden: GoldenSelector,
41    pub similarity: SimilarityScore,
42}
43
44#[derive(Debug, thiserror::Error)]
45pub enum ExplainError {
46    #[error("{file} did not extract to any function (was it walked?)")]
47    FileNotWalked { file: String },
48}
49
50/// Build the per-(rule, func, golden) scoring matrix for `target_file`.
51/// `entries` is empty when no rule's `applyTo` glob matched the file —
52/// callers should treat that as the "no rule matches" case.
53pub fn build_why_report(
54    rules: &[Rule],
55    files: &ExtractedFiles,
56    target_file: &str,
57) -> Result<WhyReport, ExplainError> {
58    let funcs_in_file = files
59        .get(target_file)
60        .ok_or_else(|| ExplainError::FileNotWalked { file: target_file.to_string() })?;
61
62    let matching = matcher::matching_rules(Path::new(target_file), rules);
63    let mut entries: Vec<WhyEntry> = Vec::new();
64    if matching.is_empty() {
65        return Ok(WhyReport { file: target_file.to_string(), entries });
66    }
67
68    let weights = Weights::default();
69    for rule in matching {
70        let goldens = resolve_goldens(rule, files);
71        let threshold = rule.threshold.unwrap_or(DEFAULT_THRESHOLD);
72        for func in funcs_in_file {
73            if func.ignore.is_some() {
74                entries.push(WhyEntry::Skipped {
75                    rule_id: rule.id.clone(),
76                    symbol: func.symbol.clone(),
77                });
78                continue;
79            }
80            let matches: Vec<WhyMatch> = goldens
81                .iter()
82                .map(|(gsel, golden_func)| WhyMatch {
83                    golden: gsel.clone(),
84                    similarity: score_pair(func, golden_func, weights),
85                })
86                .collect();
87            entries.push(WhyEntry::Scored {
88                rule_id: rule.id.clone(),
89                symbol: func.symbol.clone(),
90                threshold,
91                matches,
92            });
93        }
94    }
95
96    Ok(WhyReport { file: target_file.to_string(), entries })
97}
98
99fn resolve_goldens<'f>(
100    rule: &Rule,
101    files: &'f ExtractedFiles,
102) -> Vec<(GoldenSelector, &'f FunctionRef)> {
103    rule.goldens
104        .iter()
105        .filter_map(|g| {
106            files.get(&g.file).and_then(|funcs| {
107                funcs
108                    .iter()
109                    .find(|f| f.symbol.as_str() == g.symbol)
110                    .map(|f| (g.clone(), f))
111            })
112        })
113        .collect()
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::tree::TreeNode;
120    use crate::{FunctionKind, Signature};
121    use globset::{Glob, GlobSet, GlobSetBuilder};
122    use std::collections::HashMap;
123
124    fn glob_set(patterns: &[&str]) -> GlobSet {
125        let mut b = GlobSetBuilder::new();
126        for p in patterns {
127            b.add(Glob::new(p).expect("valid glob"));
128        }
129        b.build().expect("build glob set")
130    }
131
132    fn func(symbol: &str) -> FunctionRef {
133        let mut tree = TreeNode::branch(crate::tree::NodeKind::Block, vec![]);
134        tree.finalize();
135        let ast_hash = tree.canonical_hash();
136        FunctionRef {
137            symbol: symbol.into(),
138            kind: FunctionKind::FunctionDeclaration,
139            start_line: 0,
140            end_line: 0,
141            byte_range: (0, 0),
142            tree,
143            signature: Signature { param_count: 0 },
144            calls: vec![],
145            imports: vec![],
146            ast_hash,
147            ignore: None,
148        }
149    }
150
151    #[test]
152    fn missing_target_file_returns_error() {
153        let rules: Vec<Rule> = vec![];
154        let files: ExtractedFiles = HashMap::new();
155        let err = build_why_report(&rules, &files, "missing.ts").unwrap_err();
156        match err {
157            ExplainError::FileNotWalked { file } => assert_eq!(file, "missing.ts"),
158        }
159    }
160
161    #[test]
162    fn no_rule_match_yields_empty_entries() {
163        let mut files: ExtractedFiles = HashMap::new();
164        files.insert("src/x.ts".into(), vec![func("foo")]);
165        let rule = Rule {
166            id: "repos".into(),
167            goldens: vec![],
168            apply_to: glob_set(&["src/repos/**/*.ts"]),
169            ignore: glob_set(&[]),
170            threshold: None,
171            disabled: false,
172        };
173        let r = build_why_report(&[rule], &files, "src/x.ts").unwrap();
174        assert!(r.entries.is_empty());
175    }
176
177    #[test]
178    fn scored_entry_per_function_with_one_match_per_golden() {
179        let mut files: ExtractedFiles = HashMap::new();
180        files.insert("src/x.ts".into(), vec![func("a"), func("b")]);
181        files.insert("src/g.ts".into(), vec![func("g")]);
182        let rule = Rule {
183            id: "r".into(),
184            goldens: vec![GoldenSelector { file: "src/g.ts".into(), symbol: "g".into() }],
185            apply_to: glob_set(&["src/x.ts"]),
186            ignore: glob_set(&[]),
187            threshold: Some(0.5),
188            disabled: false,
189        };
190        let r = build_why_report(&[rule], &files, "src/x.ts").unwrap();
191        assert_eq!(r.entries.len(), 2);
192        for e in &r.entries {
193            match e {
194                WhyEntry::Scored { matches, threshold, .. } => {
195                    assert_eq!(matches.len(), 1);
196                    assert!((threshold - 0.5).abs() < f64::EPSILON);
197                }
198                WhyEntry::Skipped { .. } => panic!("unexpected skipped"),
199            }
200        }
201    }
202}