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