Skip to main content

cgx_engine/
bisect.rs

1//! Declarative graph predicates evaluated against a freshly-indexed repo.
2//!
3//! The intended usage is `git bisect run cgx bisect-script` — at each commit
4//! bisect visits, `cgx analyze` re-indexes the repo and this module evaluates
5//! the predicates defined in `.cgx/bisect.toml`. The runner exits with `0` if
6//! the commit is "good" (all predicates pass), `1` if "bad", and `125` if cgx
7//! couldn't determine an answer (which `git bisect` interprets as "skip").
8//!
9//! Example `.cgx/bisect.toml`:
10//!
11//! ```toml
12//! # All predicates must hold for the commit to be "good".
13//! node_count_min  = 700
14//! node_count_max  = 2000
15//! nodes_exist     = ["fn:crates/cgx-engine/src/parser.rs:parse"]
16//! nodes_missing   = []
17//! nodes_alive     = ["fn:crates/cgx-engine/src/graph.rs:get_file_summary"]
18//! rule_violations_max = 0
19//! ```
20
21use std::path::{Path, PathBuf};
22
23use serde::{Deserialize, Serialize};
24
25use crate::graph::GraphDb;
26
27/// The set of predicates the user wrote in `.cgx/bisect.toml`.
28///
29/// Empty fields are treated as "no constraint". All present fields must hold
30/// for a commit to be considered "good".
31#[derive(Debug, Clone, Default, Serialize, Deserialize)]
32pub struct BisectPredicates {
33    /// Graph must have at least this many nodes.
34    #[serde(default)]
35    pub node_count_min: Option<u64>,
36    /// Graph must have at most this many nodes.
37    #[serde(default)]
38    pub node_count_max: Option<u64>,
39    /// Every node ID in this list must exist.
40    #[serde(default)]
41    pub nodes_exist: Vec<String>,
42    /// No node ID in this list may exist.
43    #[serde(default)]
44    pub nodes_missing: Vec<String>,
45    /// Every node ID in this list must exist AND not be flagged as a dead-code candidate.
46    #[serde(default)]
47    pub nodes_alive: Vec<String>,
48    /// Total rule violations (per `cgx rules check`) must be ≤ this number.
49    #[serde(default)]
50    pub rule_violations_max: Option<u64>,
51}
52
53/// Result of a single predicate evaluation. Implements `Display` for nice CLI output.
54#[derive(Debug, Clone)]
55pub struct PredicateOutcome {
56    pub predicate: String,
57    pub passed: bool,
58    pub detail: String,
59}
60
61#[derive(Debug, Clone)]
62pub struct BisectReport {
63    pub outcomes: Vec<PredicateOutcome>,
64}
65
66impl BisectReport {
67    pub fn passed(&self) -> bool {
68        self.outcomes.iter().all(|o| o.passed)
69    }
70}
71
72impl BisectPredicates {
73    /// Read predicates from `.cgx/bisect.toml` (or the supplied custom path).
74    pub fn load(repo_path: &Path, custom: Option<&Path>) -> anyhow::Result<Self> {
75        let target = match custom {
76            Some(p) => p.to_path_buf(),
77            None => repo_path.join(".cgx").join("bisect.toml"),
78        };
79        if !target.exists() {
80            anyhow::bail!(
81                "no predicate file at {}. Create `.cgx/bisect.toml` first — see `cgx bisect-script --example` for the schema.",
82                target.display()
83            );
84        }
85        let content = std::fs::read_to_string(&target)?;
86        let parsed: BisectPredicates = toml::from_str(&content)?;
87        Ok(parsed)
88    }
89
90    /// Path the bisect script writes to by default.
91    pub fn default_path(repo_path: &Path) -> PathBuf {
92        repo_path.join(".cgx").join("bisect.toml")
93    }
94
95    /// Evaluate every populated predicate against the live graph.
96    pub fn evaluate(&self, db: &GraphDb) -> anyhow::Result<BisectReport> {
97        let mut outcomes: Vec<PredicateOutcome> = Vec::new();
98
99        if let Some(min) = self.node_count_min {
100            let actual = db.node_count().unwrap_or(0);
101            outcomes.push(PredicateOutcome {
102                predicate: "node_count_min".to_string(),
103                passed: actual >= min,
104                detail: format!("required ≥ {}, got {}", min, actual),
105            });
106        }
107        if let Some(max) = self.node_count_max {
108            let actual = db.node_count().unwrap_or(0);
109            outcomes.push(PredicateOutcome {
110                predicate: "node_count_max".to_string(),
111                passed: actual <= max,
112                detail: format!("required ≤ {}, got {}", max, actual),
113            });
114        }
115
116        for id in &self.nodes_exist {
117            let exists = db.get_node(id).map(|n| n.is_some()).unwrap_or(false);
118            outcomes.push(PredicateOutcome {
119                predicate: format!("nodes_exist:{}", id),
120                passed: exists,
121                detail: if exists {
122                    "found".to_string()
123                } else {
124                    "missing".to_string()
125                },
126            });
127        }
128
129        for id in &self.nodes_missing {
130            let exists = db.get_node(id).map(|n| n.is_some()).unwrap_or(false);
131            outcomes.push(PredicateOutcome {
132                predicate: format!("nodes_missing:{}", id),
133                passed: !exists,
134                detail: if exists {
135                    "still present".to_string()
136                } else {
137                    "absent".to_string()
138                },
139            });
140        }
141
142        for id in &self.nodes_alive {
143            let node = db.get_node(id).ok().flatten();
144            let (alive, detail) = match &node {
145                Some(n) if n.is_dead_candidate => (false, "flagged dead".to_string()),
146                Some(_) => (true, "alive".to_string()),
147                None => (false, "missing".to_string()),
148            };
149            outcomes.push(PredicateOutcome {
150                predicate: format!("nodes_alive:{}", id),
151                passed: alive,
152                detail,
153            });
154        }
155
156        if let Some(_max) = self.rule_violations_max {
157            // Rule evaluation lives in the `rules` module; we surface a placeholder
158            // outcome rather than re-implementing the rule engine here. Callers can
159            // separately run `cgx rules check` and feed the count back in via
160            // `--rule-violations N` on the CLI.
161            outcomes.push(PredicateOutcome {
162                predicate: "rule_violations_max".to_string(),
163                passed: true,
164                detail: "skipped (run `cgx rules check` separately and use --rule-violations)"
165                    .to_string(),
166            });
167        }
168
169        Ok(BisectReport { outcomes })
170    }
171}
172
173/// Sample TOML emitted by `cgx bisect-script --example`. Keep in sync with the
174/// `BisectPredicates` struct.
175pub const EXAMPLE_TOML: &str = r#"# .cgx/bisect.toml — declarative predicates for `git bisect run cgx bisect-script`.
176# All populated predicates must pass for the commit to be considered "good".
177
178# Graph size bounds — useful for catching mass deletions or runaway code growth.
179node_count_min = 100
180# node_count_max = 5000
181
182# Node IDs that must exist (e.g. a public function you accidentally deleted).
183nodes_exist = [
184    # "fn:src/auth.rs:authenticate",
185]
186
187# Node IDs that must NOT exist (e.g. a symbol you renamed away that shouldn't come back).
188nodes_missing = [
189    # "fn:src/auth.rs:old_authenticate",
190]
191
192# Node IDs that must exist AND not be flagged as dead-code candidates.
193nodes_alive = [
194    # "fn:src/api.rs:start_server",
195]
196
197# Maximum number of architecture-rule violations allowed (run `cgx rules check`
198# separately and feed the count via `--rule-violations N`).
199# rule_violations_max = 0
200"#;