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
13#[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
21pub 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 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 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 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 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 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}