Skip to main content

comindly_core/
preflight.rs

1//! Preflight: before a change lands, what the team already knows about the code it touches —
2//! recorded constraints and dead ends, contracts other services rely on, blast radius, and
3//! directories that usually change together but are missing from the change.
4
5use std::collections::{BTreeSet, HashMap};
6use std::fmt::Write as _;
7
8use crate::conventions::Convention;
9use crate::graph::{CodeGraph, PROVIDES};
10use crate::ledger::Entry;
11use crate::model::{Edge, Symbol, SymbolKind};
12
13/// A changed file in a repo, with changed line ranges (empty = whole file / unknown).
14#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
15pub struct Change {
16    pub repo: String,
17    pub path: String,
18    pub lines: Vec<(u32, u32)>,
19}
20
21/// The innermost symbols each change touches (files fall back to their file symbol).
22pub fn touched(symbols: &[Symbol], changes: &[Change]) -> Vec<String> {
23    let mut out = Vec::new();
24    for c in changes {
25        let in_file: Vec<&Symbol> = symbols
26            .iter()
27            .filter(|s| s.repo.0 == c.repo && s.file_path == c.path)
28            .collect();
29        let defs: Vec<&&Symbol> = in_file
30            .iter()
31            .filter(|s| s.kind != SymbolKind::File)
32            .collect();
33        let mut hit = false;
34        for (a, b) in &c.lines {
35            // Innermost: the narrowest definition overlapping the range.
36            if let Some(s) = defs
37                .iter()
38                .filter(|s| s.range.start.line <= *b && *a <= s.range.end.line)
39                .min_by_key(|s| s.range.end.line - s.range.start.line)
40            {
41                out.push(s.id.render());
42                hit = true;
43            }
44        }
45        if !hit {
46            if let Some(f) = in_file.iter().find(|s| s.kind == SymbolKind::File) {
47                out.push(f.id.render());
48            }
49        }
50    }
51    out.sort();
52    out.dedup();
53    out
54}
55
56pub fn report(
57    graph: &CodeGraph,
58    symbols: &[Symbol],
59    edges: &[Edge],
60    entries: &[Entry],
61    recipes: &[Convention],
62    findings: &[crate::checks::Finding],
63    changes: &[Change],
64) -> String {
65    let ids = touched(symbols, changes);
66    let by_id: HashMap<String, &Symbol> = symbols.iter().map(|s| (s.id.render(), s)).collect();
67    let files: BTreeSet<(&str, &str)> = changes
68        .iter()
69        .map(|c| (c.repo.as_str(), c.path.as_str()))
70        .collect();
71    let mut o = format!(
72        "## Preflight — {} file(s), {} symbol(s) touched\n",
73        files.len(),
74        ids.len()
75    );
76
77    // Recorded reasoning about the touched code and its containers.
78    let mut scope: Vec<String> = Vec::new();
79    for id in &ids {
80        if let Some((_, s)) = graph.scope_of(id) {
81            scope.extend(s);
82        }
83    }
84    let known = crate::ledger::about(entries, &scope);
85    let live: Vec<&&Entry> = known
86        .iter()
87        .filter(|e| {
88            !matches!(
89                e.status,
90                crate::ledger::Status::Retracted | crate::ledger::Status::Superseded
91            )
92        })
93        .collect();
94    o.push_str("\n### Recorded constraints & dead ends\n");
95    if live.is_empty() {
96        o.push_str("_none recorded for this code_\n");
97    }
98    for e in live {
99        let _ = writeln!(o, "- {}", crate::ledger::line(e));
100    }
101
102    let changed: Vec<String> = files.iter().map(|(r, p)| format!("{r}/{p}")).collect();
103    let checks = crate::checks::markdown(findings, Some(&changed));
104    if !checks.is_empty() {
105        o.push_str("\n### Constraint checks failing in changed files\n");
106        o.push_str(&checks);
107    }
108
109    // Contracts provided by touched symbols, with their consumers.
110    o.push_str("\n### Contracts you are changing\n");
111    let mut any = false;
112    for e in edges.iter().filter(|e| PROVIDES.contains(&e.kind)) {
113        let src = e.src.render();
114        if !ids.contains(&src) {
115            continue;
116        }
117        let Some(c) = by_id.get(&e.dst.render()) else {
118            continue;
119        };
120        let users: BTreeSet<String> = edges
121            .iter()
122            .filter(|u| u.dst == e.dst && !PROVIDES.contains(&u.kind))
123            .filter_map(|u| by_id.get(&u.src.render()))
124            .map(|s| format!("{}:{}", s.repo.0, s.name))
125            .collect();
126        any = true;
127        let _ = writeln!(
128            o,
129            "- `{}` ({} by `{}`) — used by {}",
130            c.name,
131            e.kind,
132            by_id.get(&src).map_or("?", |s| s.name.as_str()),
133            if users.is_empty() {
134                "nothing indexed".to_string()
135            } else {
136                users.into_iter().collect::<Vec<_>>().join(", ")
137            }
138        );
139    }
140    if !any {
141        o.push_str("_none_\n");
142    }
143
144    // Blast radius.
145    o.push_str("\n### Blast radius\n");
146    let mut lines = Vec::new();
147    for id in &ids {
148        let Some(idx) = graph.lookup(id) else {
149            continue;
150        };
151        let hops = graph.ripple(idx, 3);
152        if hops.is_empty() {
153            continue;
154        }
155        let me = by_id.get(id).map_or("", |s| s.repo.0.as_str());
156        let repos: BTreeSet<&str> = hops
157            .iter()
158            .map(|h| h.node.repo.as_str())
159            .filter(|r| *r != me && *r != crate::resolve::CONTRACTS)
160            .collect();
161        lines.push((
162            hops.len(),
163            format!(
164                "- `{}`: {} dependents{}",
165                by_id.get(id).map_or(id.as_str(), |s| s.name.as_str()),
166                hops.len(),
167                if repos.is_empty() {
168                    String::new()
169                } else {
170                    format!(
171                        " — other repos: {}",
172                        repos.into_iter().collect::<Vec<_>>().join(", ")
173                    )
174                }
175            ),
176        ));
177    }
178    lines.sort_by_key(|(n, _)| std::cmp::Reverse(*n));
179    if lines.is_empty() {
180        o.push_str("_no indexed dependents_\n");
181    }
182    for (_, l) in lines.into_iter().take(10) {
183        let _ = writeln!(o, "{l}");
184    }
185
186    // Recipes: companions this kind of change usually has, missing here.
187    let dirs: BTreeSet<(String, String)> = changes
188        .iter()
189        .map(|c| {
190            (
191                c.repo.clone(),
192                c.path.rsplit_once('/').map_or(".", |(d, _)| d).to_string(),
193            )
194        })
195        .collect();
196    let mut hints = Vec::new();
197    for r in recipes.iter().filter(|r| r.dimension == "recipes") {
198        let Some(anchor) = r.key.rsplit(':').next() else {
199            continue;
200        };
201        if !dirs.contains(&(r.repo.clone(), anchor.to_string())) {
202            continue;
203        }
204        let missing: Vec<&str> = r
205            .generic
206            .split('`')
207            .skip(3)
208            .step_by(2)
209            .map(|d| d.trim_end_matches('/'))
210            .filter(|d| !dirs.iter().any(|(repo, x)| repo == &r.repo && x == d))
211            .collect();
212        if !missing.is_empty() {
213            hints.push(format!(
214                "- changes in `{anchor}/` usually also touch {} — not in this change (e.g. {})",
215                missing
216                    .iter()
217                    .map(|d| format!("`{d}/`"))
218                    .collect::<Vec<_>>()
219                    .join(", "),
220                r.exemplars.first().cloned().unwrap_or_default()
221            ));
222        }
223    }
224    o.push_str("\n### Usually changed together\n");
225    if hints.is_empty() {
226        o.push_str("_nothing missing_\n");
227    }
228    for h in hints {
229        let _ = writeln!(o, "{h}");
230    }
231    o
232}