1use std::collections::BTreeMap;
36use std::fmt::Write as _;
37use std::path::Path;
38
39use keel_journal::TargetStats;
40use serde::Serialize;
41
42use crate::flows::soft_error;
43use crate::flows_add::existing_entrypoints;
44use crate::init::{has_python_files, plural};
45use crate::render::to_json;
46use crate::scan::{self, FunctionFacts, ScanResult};
47use crate::{Rendered, evidence};
48
49const JS_ATTRIBUTION_NOTE: &str = "JS/TS attribution is a real AST parse (oxc): effects, time/\
53 random reads, and unsafe constructs are attributed by real scope containment, not a line \
54 heuristic. Only top-level named functions are tracked as flow candidates — effects inside \
55 class methods, object-literal methods, or nested functions/callbacks roll up to the \
56 enclosing top-level function (or are not tracked, for methods). Python attribution is the \
57 same real containment, over Python's own AST.";
58
59#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
62pub struct Candidate {
63 pub already_designated: bool,
65 pub calls_observed: i64,
69 pub display: String,
73 pub effects: u32,
75 pub entrypoint: String,
78 pub file: String,
80 pub idempotent_unsafe: u32,
82 pub line: u32,
84 pub random_reads: u32,
86 pub replay_safe: bool,
88 pub time_reads: u32,
90 pub unsafe_reasons: Vec<String>,
93}
94
95#[derive(Debug, Serialize)]
97struct SuggestReport {
98 candidates: Vec<Candidate>,
99 count: usize,
100 js_attribution_note: &'static str,
101 python_available: bool,
102 replay_safe_count: usize,
103}
104
105pub fn run(project: &Path) -> Rendered {
107 let scan = scan::scan(project);
108 let discovery = match evidence::read_discovery(project) {
109 Ok(d) => d,
110 Err(e) => return soft_error(&e),
111 };
112 let existing = read_existing(project);
113 let report = build_report(&scan, &discovery, &existing);
114 let human = human(&report, project);
115 Rendered::ok(human, to_json(&report))
116}
117
118fn read_existing(project: &Path) -> Vec<String> {
123 let text = std::fs::read_to_string(evidence::keel_toml(project)).ok();
124 existing_entrypoints(text.as_deref()).unwrap_or_default()
125}
126
127fn build_report(
130 scan: &ScanResult,
131 discovery: &[TargetStats],
132 existing: &[String],
133) -> SuggestReport {
134 let calls_by_target: BTreeMap<&str, i64> = discovery
135 .iter()
136 .map(|s| (s.target.as_str(), s.calls))
137 .collect();
138 let mut candidates: Vec<Candidate> = scan
139 .functions
140 .iter()
141 .filter(|f| f.effects > 0)
142 .map(|f| to_candidate(f, &calls_by_target, existing))
143 .collect();
144 candidates.sort_by(|a, b| {
147 b.calls_observed
148 .cmp(&a.calls_observed)
149 .then_with(|| b.effects.cmp(&a.effects))
150 .then_with(|| a.file.cmp(&b.file))
151 .then_with(|| a.line.cmp(&b.line))
152 });
153 let replay_safe_count = candidates.iter().filter(|c| c.replay_safe).count();
154 SuggestReport {
155 count: candidates.len(),
156 replay_safe_count,
157 candidates,
158 js_attribution_note: JS_ATTRIBUTION_NOTE,
159 python_available: scan.python_available,
160 }
161}
162
163fn to_candidate(
164 f: &FunctionFacts,
165 calls_by_target: &BTreeMap<&str, i64>,
166 existing: &[String],
167) -> Candidate {
168 let calls_observed = f
169 .targets
170 .iter()
171 .filter_map(|t| calls_by_target.get(t.as_str()))
172 .sum();
173 Candidate {
174 already_designated: existing.iter().any(|e| e == &f.entrypoint),
175 calls_observed,
176 display: bare_display(&f.entrypoint),
177 effects: f.effects,
178 entrypoint: f.entrypoint.clone(),
179 file: f.file.clone(),
180 idempotent_unsafe: f.idempotent_unsafe,
181 line: f.line,
182 random_reads: f.random_reads,
183 replay_safe: f.unsafe_reasons.is_empty(),
184 time_reads: f.time_reads,
185 unsafe_reasons: f.unsafe_reasons.clone(),
186 }
187}
188
189fn bare_display(entrypoint: &str) -> String {
192 entrypoint
193 .strip_prefix("py:")
194 .or_else(|| entrypoint.strip_prefix("ts:"))
195 .unwrap_or(entrypoint)
196 .to_owned()
197}
198
199fn human(report: &SuggestReport, project: &Path) -> String {
202 let python_note = (!report.python_available && has_python_files(project))
203 .then_some("\nkeel \u{25b8} note: python3 was not found; Python files were not scanned.\n");
204 if report.candidates.is_empty() {
205 return format!(
206 "keel \u{25b8} no candidate flow entrypoints found (no function calls an intercepted effect).{}",
207 python_note.unwrap_or_default()
208 );
209 }
210 let mut lines = vec![format!(
211 "keel \u{25b8} {} candidate flow entrypoint{}, {} replay-safe:\n",
212 report.count,
213 plural(report.count),
214 report.replay_safe_count,
215 )];
216 for c in &report.candidates {
217 lines.push(format!(" {}\n", candidate_line(c)));
218 }
219 if let Some(note) = python_note {
220 lines.push(note.to_owned());
221 }
222 lines.push(format!(
223 "\nkeel \u{25b8} note: {}\n",
224 report.js_attribution_note
225 ));
226 lines.push("\nkeel \u{25b8} designate one with `keel flows add <entrypoint>`.\n".to_owned());
227 lines.concat()
228}
229
230fn candidate_line(c: &Candidate) -> String {
233 let mut line = format!(
234 "{:<28}{} effect{}",
235 c.display,
236 c.effects,
237 plural(c.effects as usize)
238 );
239 if c.idempotent_unsafe > 0 {
240 let _ = write!(line, ", {} idempotent-unsafe", c.idempotent_unsafe);
241 }
242 let _ = write!(
243 line,
244 ", est. replay-safe: {}",
245 if c.replay_safe { "YES" } else { "NO" }
246 );
247 if c.replay_safe {
248 let mut virtualized = Vec::new();
249 if c.time_reads > 0 {
250 virtualized.push(format!(
251 "{} time read{}",
252 c.time_reads,
253 plural(c.time_reads as usize)
254 ));
255 }
256 if c.random_reads > 0 {
257 virtualized.push(format!(
258 "{} random read{}",
259 c.random_reads,
260 plural(c.random_reads as usize)
261 ));
262 }
263 if !virtualized.is_empty() {
264 let _ = write!(line, " ({} will be virtualized)", virtualized.join(", "));
265 }
266 } else {
267 let _ = write!(line, " \u{2014} {}", c.unsafe_reasons.join("; "));
268 }
269 if c.calls_observed > 0 {
270 let _ = write!(
271 line,
272 " [observed {} call{}]",
273 c.calls_observed,
274 plural(usize::try_from(c.calls_observed).unwrap_or(usize::MAX))
275 );
276 }
277 if c.already_designated {
278 line.push_str(" [already a flow]");
279 }
280 line
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use std::collections::BTreeSet;
287
288 fn facts(entrypoint: &str, file: &str, line: u32) -> FunctionFacts {
289 FunctionFacts {
290 entrypoint: entrypoint.to_owned(),
291 file: file.to_owned(),
292 line,
293 ..FunctionFacts::default()
294 }
295 }
296
297 fn scan_with(functions: Vec<FunctionFacts>) -> ScanResult {
298 ScanResult {
299 python_available: true,
300 functions,
301 ..ScanResult::default()
302 }
303 }
304
305 #[test]
306 fn pure_functions_are_not_candidates() {
307 let scan = scan_with(vec![facts("py:app:helper", "app.py", 1)]);
308 let report = build_report(&scan, &[], &[]);
309 assert_eq!(report.count, 0);
310 assert!(report.candidates.is_empty());
311 }
312
313 #[test]
314 fn a_function_with_effects_is_a_candidate_and_defaults_replay_safe() {
315 let mut f = facts("py:pipeline.ingest:main", "pipeline/ingest.py", 10);
316 f.effects = 12;
317 f.idempotent_unsafe = 3;
318 let scan = scan_with(vec![f]);
319 let report = build_report(&scan, &[], &[]);
320 assert_eq!(report.count, 1);
321 assert_eq!(report.replay_safe_count, 1);
322 let c = &report.candidates[0];
323 assert_eq!(c.display, "pipeline.ingest:main");
324 assert_eq!(c.entrypoint, "py:pipeline.ingest:main");
325 assert!(c.replay_safe);
326 assert!(!c.already_designated);
327 }
328
329 #[test]
330 fn unsafe_reasons_flip_the_verdict_but_idempotent_unsafe_alone_does_not() {
331 let mut safe = facts("py:app:a", "app.py", 1);
332 safe.effects = 1;
333 safe.idempotent_unsafe = 5;
334 let mut unsafe_fn = facts("py:app:b", "app.py", 20);
335 unsafe_fn.effects = 1;
336 unsafe_fn.unsafe_reasons = vec!["subprocess use at app.py:22".to_owned()];
337 let scan = scan_with(vec![safe, unsafe_fn]);
338 let report = build_report(&scan, &[], &[]);
339 assert_eq!(report.replay_safe_count, 1);
340 let a = report
341 .candidates
342 .iter()
343 .find(|c| c.entrypoint.ends_with(":a"))
344 .unwrap();
345 assert!(
346 a.replay_safe,
347 "idempotent-unsafe alone does not block replay-safety"
348 );
349 let b = report
350 .candidates
351 .iter()
352 .find(|c| c.entrypoint.ends_with(":b"))
353 .unwrap();
354 assert!(!b.replay_safe);
355 assert_eq!(
356 b.unsafe_reasons,
357 vec!["subprocess use at app.py:22".to_owned()]
358 );
359 }
360
361 #[test]
362 fn already_designated_entrypoints_are_flagged() {
363 let mut f = facts("py:pipeline.ingest:main", "pipeline/ingest.py", 10);
364 f.effects = 1;
365 let scan = scan_with(vec![f]);
366 let report = build_report(&scan, &[], &["py:pipeline.ingest:main".to_owned()]);
367 assert!(report.candidates[0].already_designated);
368 }
369
370 #[test]
371 fn discovery_calls_join_on_targets_and_drive_ranking() {
372 let mut quiet = facts("py:app:quiet", "app.py", 1);
373 quiet.effects = 10;
374 quiet.targets = BTreeSet::from(["api.quiet.example".to_owned()]);
375 let mut busy = facts("py:app:busy", "app.py", 30);
376 busy.effects = 2;
377 busy.targets = BTreeSet::from(["api.busy.example".to_owned()]);
378 let scan = scan_with(vec![quiet.clone(), busy.clone()]);
379 let discovery = vec![
380 TargetStats {
381 target: "api.busy.example".to_owned(),
382 calls: 500,
383 ..zero_stats()
384 },
385 TargetStats {
386 target: "api.quiet.example".to_owned(),
387 calls: 1,
388 ..zero_stats()
389 },
390 ];
391 let report = build_report(&scan, &discovery, &[]);
392 assert_eq!(report.candidates[0].display, "app:busy");
394 assert_eq!(report.candidates[0].calls_observed, 500);
395 assert_eq!(report.candidates[1].display, "app:quiet");
396 assert_eq!(report.candidates[1].calls_observed, 1);
397 }
398
399 #[test]
400 fn ordering_is_deterministic_by_file_and_line_when_traffic_and_effects_tie() {
401 let mut b = facts("py:z:second", "z.py", 5);
402 b.effects = 1;
403 let mut a = facts("py:a:first", "a.py", 1);
404 a.effects = 1;
405 let scan = scan_with(vec![b, a]);
406 let report = build_report(&scan, &[], &[]);
407 assert_eq!(report.candidates[0].file, "a.py");
408 assert_eq!(report.candidates[1].file, "z.py");
409 }
410
411 #[test]
412 fn human_line_matches_the_spec_shape() {
413 let mut f = facts("py:pipeline.ingest:main", "pipeline/ingest.py", 10);
414 f.effects = 12;
415 f.idempotent_unsafe = 3;
416 let c = to_candidate(&f, &BTreeMap::new(), &[]);
417 let line = candidate_line(&c);
418 assert!(line.contains("pipeline.ingest:main"));
419 assert!(line.contains("12 effects"));
420 assert!(line.contains("3 idempotent-unsafe"));
421 assert!(line.contains("est. replay-safe: YES"));
422 }
423
424 #[test]
425 fn human_line_notes_virtualized_reads_when_replay_safe() {
426 let mut f = facts("py:jobs.nightly:run", "jobs/nightly.py", 4);
427 f.effects = 31;
428 f.time_reads = 2;
429 let c = to_candidate(&f, &BTreeMap::new(), &[]);
430 let line = candidate_line(&c);
431 assert!(line.contains("31 effects"));
432 assert!(!line.contains("idempotent-unsafe"));
433 assert!(line.contains("est. replay-safe: YES"));
434 assert!(line.contains("2 time reads will be virtualized"));
435 }
436
437 #[test]
438 fn human_line_singular_read_is_not_pluralized() {
439 let mut f = facts("py:app:one_read", "app.py", 1);
440 f.effects = 1;
441 f.time_reads = 1;
442 let c = to_candidate(&f, &BTreeMap::new(), &[]);
443 assert!(candidate_line(&c).contains("1 time read will be virtualized"));
444 }
445
446 #[test]
447 fn human_output_lists_no_candidates_when_scan_finds_none() {
448 let report = build_report(&scan_with(vec![]), &[], &[]);
449 let out = human(&report, Path::new("."));
450 assert!(out.contains("no candidate flow entrypoints found"));
451 }
452
453 #[test]
454 fn json_report_carries_the_js_attribution_honesty_note() {
455 let report = build_report(&scan_with(vec![]), &[], &[]);
456 let json = to_json(&report);
457 assert!(
458 json["js_attribution_note"]
459 .as_str()
460 .unwrap()
461 .contains("real AST parse")
462 );
463 }
464
465 fn zero_stats() -> TargetStats {
466 TargetStats {
467 target: String::new(),
468 calls: 0,
469 attempts: 0,
470 retries: 0,
471 successes: 0,
472 failures: 0,
473 cache_hits: 0,
474 throttled: 0,
475 breaker_opens: 0,
476 not_retried: 0,
477 unwrapped_calls: 0,
478 total_latency_ms: 0,
479 max_latency_ms: 0,
480 first_seen_ms: 0,
481 last_seen_ms: 0,
482 last_error_class: None,
483 last_error_status: None,
484 }
485 }
486}