comindly_core/
preflight.rs1use 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
15pub 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 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
50pub 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 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 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 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 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}