use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::Path;
use keel_journal::TargetStats;
use serde::Serialize;
use crate::flows::soft_error;
use crate::flows_add::existing_entrypoints;
use crate::init::{has_python_files, plural};
use crate::render::to_json;
use crate::scan::{self, FunctionFacts, ScanResult};
use crate::{Rendered, evidence};
const JS_ATTRIBUTION_NOTE: &str = "JS/TS attribution is a real AST parse (oxc): effects, time/\
random reads, and unsafe constructs are attributed by real scope containment, not a line \
heuristic. Only top-level named functions are tracked as flow candidates — effects inside \
class methods, object-literal methods, or nested functions/callbacks roll up to the \
enclosing top-level function (or are not tracked, for methods). Python attribution is the \
same real containment, over Python's own AST.";
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct Candidate {
pub already_designated: bool,
pub calls_observed: i64,
pub display: String,
pub effects: u32,
pub entrypoint: String,
pub file: String,
pub idempotent_unsafe: u32,
pub line: u32,
pub random_reads: u32,
pub replay_safe: bool,
pub time_reads: u32,
pub unsafe_reasons: Vec<String>,
}
#[derive(Debug, Serialize)]
struct SuggestReport {
candidates: Vec<Candidate>,
count: usize,
js_attribution_note: &'static str,
python_available: bool,
replay_safe_count: usize,
}
pub fn run(project: &Path) -> Rendered {
let scan = scan::scan(project);
let discovery = match evidence::read_discovery(project) {
Ok(d) => d,
Err(e) => return soft_error(&e),
};
let existing = read_existing(project);
let report = build_report(&scan, &discovery, &existing);
let human = human(&report, project);
Rendered::ok(human, to_json(&report))
}
fn read_existing(project: &Path) -> Vec<String> {
let text = std::fs::read_to_string(evidence::keel_toml(project)).ok();
existing_entrypoints(text.as_deref()).unwrap_or_default()
}
fn build_report(
scan: &ScanResult,
discovery: &[TargetStats],
existing: &[String],
) -> SuggestReport {
let calls_by_target: BTreeMap<&str, i64> = discovery
.iter()
.map(|s| (s.target.as_str(), s.calls))
.collect();
let mut candidates: Vec<Candidate> = scan
.functions
.iter()
.filter(|f| f.effects > 0)
.map(|f| to_candidate(f, &calls_by_target, existing))
.collect();
candidates.sort_by(|a, b| {
b.calls_observed
.cmp(&a.calls_observed)
.then_with(|| b.effects.cmp(&a.effects))
.then_with(|| a.file.cmp(&b.file))
.then_with(|| a.line.cmp(&b.line))
});
let replay_safe_count = candidates.iter().filter(|c| c.replay_safe).count();
SuggestReport {
count: candidates.len(),
replay_safe_count,
candidates,
js_attribution_note: JS_ATTRIBUTION_NOTE,
python_available: scan.python_available,
}
}
fn to_candidate(
f: &FunctionFacts,
calls_by_target: &BTreeMap<&str, i64>,
existing: &[String],
) -> Candidate {
let calls_observed = f
.targets
.iter()
.filter_map(|t| calls_by_target.get(t.as_str()))
.sum();
Candidate {
already_designated: existing.iter().any(|e| e == &f.entrypoint),
calls_observed,
display: bare_display(&f.entrypoint),
effects: f.effects,
entrypoint: f.entrypoint.clone(),
file: f.file.clone(),
idempotent_unsafe: f.idempotent_unsafe,
line: f.line,
random_reads: f.random_reads,
replay_safe: f.unsafe_reasons.is_empty(),
time_reads: f.time_reads,
unsafe_reasons: f.unsafe_reasons.clone(),
}
}
fn bare_display(entrypoint: &str) -> String {
entrypoint
.strip_prefix("py:")
.or_else(|| entrypoint.strip_prefix("ts:"))
.unwrap_or(entrypoint)
.to_owned()
}
fn human(report: &SuggestReport, project: &Path) -> String {
let python_note = (!report.python_available && has_python_files(project))
.then_some("\nkeel \u{25b8} note: python3 was not found; Python files were not scanned.\n");
if report.candidates.is_empty() {
return format!(
"keel \u{25b8} no candidate flow entrypoints found (no function calls an intercepted effect).{}",
python_note.unwrap_or_default()
);
}
let mut lines = vec![format!(
"keel \u{25b8} {} candidate flow entrypoint{}, {} replay-safe:\n",
report.count,
plural(report.count),
report.replay_safe_count,
)];
for c in &report.candidates {
lines.push(format!(" {}\n", candidate_line(c)));
}
if let Some(note) = python_note {
lines.push(note.to_owned());
}
lines.push(format!(
"\nkeel \u{25b8} note: {}\n",
report.js_attribution_note
));
lines.push("\nkeel \u{25b8} designate one with `keel flows add <entrypoint>`.\n".to_owned());
lines.concat()
}
fn candidate_line(c: &Candidate) -> String {
let mut line = format!(
"{:<28}{} effect{}",
c.display,
c.effects,
plural(c.effects as usize)
);
if c.idempotent_unsafe > 0 {
let _ = write!(line, ", {} idempotent-unsafe", c.idempotent_unsafe);
}
let _ = write!(
line,
", est. replay-safe: {}",
if c.replay_safe { "YES" } else { "NO" }
);
if c.replay_safe {
let mut virtualized = Vec::new();
if c.time_reads > 0 {
virtualized.push(format!(
"{} time read{}",
c.time_reads,
plural(c.time_reads as usize)
));
}
if c.random_reads > 0 {
virtualized.push(format!(
"{} random read{}",
c.random_reads,
plural(c.random_reads as usize)
));
}
if !virtualized.is_empty() {
let _ = write!(line, " ({} will be virtualized)", virtualized.join(", "));
}
} else {
let _ = write!(line, " \u{2014} {}", c.unsafe_reasons.join("; "));
}
if c.calls_observed > 0 {
let _ = write!(
line,
" [observed {} call{}]",
c.calls_observed,
plural(usize::try_from(c.calls_observed).unwrap_or(usize::MAX))
);
}
if c.already_designated {
line.push_str(" [already a flow]");
}
line
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
fn facts(entrypoint: &str, file: &str, line: u32) -> FunctionFacts {
FunctionFacts {
entrypoint: entrypoint.to_owned(),
file: file.to_owned(),
line,
..FunctionFacts::default()
}
}
fn scan_with(functions: Vec<FunctionFacts>) -> ScanResult {
ScanResult {
python_available: true,
functions,
..ScanResult::default()
}
}
#[test]
fn pure_functions_are_not_candidates() {
let scan = scan_with(vec![facts("py:app:helper", "app.py", 1)]);
let report = build_report(&scan, &[], &[]);
assert_eq!(report.count, 0);
assert!(report.candidates.is_empty());
}
#[test]
fn a_function_with_effects_is_a_candidate_and_defaults_replay_safe() {
let mut f = facts("py:pipeline.ingest:main", "pipeline/ingest.py", 10);
f.effects = 12;
f.idempotent_unsafe = 3;
let scan = scan_with(vec![f]);
let report = build_report(&scan, &[], &[]);
assert_eq!(report.count, 1);
assert_eq!(report.replay_safe_count, 1);
let c = &report.candidates[0];
assert_eq!(c.display, "pipeline.ingest:main");
assert_eq!(c.entrypoint, "py:pipeline.ingest:main");
assert!(c.replay_safe);
assert!(!c.already_designated);
}
#[test]
fn unsafe_reasons_flip_the_verdict_but_idempotent_unsafe_alone_does_not() {
let mut safe = facts("py:app:a", "app.py", 1);
safe.effects = 1;
safe.idempotent_unsafe = 5;
let mut unsafe_fn = facts("py:app:b", "app.py", 20);
unsafe_fn.effects = 1;
unsafe_fn.unsafe_reasons = vec!["subprocess use at app.py:22".to_owned()];
let scan = scan_with(vec![safe, unsafe_fn]);
let report = build_report(&scan, &[], &[]);
assert_eq!(report.replay_safe_count, 1);
let a = report
.candidates
.iter()
.find(|c| c.entrypoint.ends_with(":a"))
.unwrap();
assert!(
a.replay_safe,
"idempotent-unsafe alone does not block replay-safety"
);
let b = report
.candidates
.iter()
.find(|c| c.entrypoint.ends_with(":b"))
.unwrap();
assert!(!b.replay_safe);
assert_eq!(
b.unsafe_reasons,
vec!["subprocess use at app.py:22".to_owned()]
);
}
#[test]
fn already_designated_entrypoints_are_flagged() {
let mut f = facts("py:pipeline.ingest:main", "pipeline/ingest.py", 10);
f.effects = 1;
let scan = scan_with(vec![f]);
let report = build_report(&scan, &[], &["py:pipeline.ingest:main".to_owned()]);
assert!(report.candidates[0].already_designated);
}
#[test]
fn discovery_calls_join_on_targets_and_drive_ranking() {
let mut quiet = facts("py:app:quiet", "app.py", 1);
quiet.effects = 10;
quiet.targets = BTreeSet::from(["api.quiet.example".to_owned()]);
let mut busy = facts("py:app:busy", "app.py", 30);
busy.effects = 2;
busy.targets = BTreeSet::from(["api.busy.example".to_owned()]);
let scan = scan_with(vec![quiet.clone(), busy.clone()]);
let discovery = vec![
TargetStats {
target: "api.busy.example".to_owned(),
calls: 500,
..zero_stats()
},
TargetStats {
target: "api.quiet.example".to_owned(),
calls: 1,
..zero_stats()
},
];
let report = build_report(&scan, &discovery, &[]);
assert_eq!(report.candidates[0].display, "app:busy");
assert_eq!(report.candidates[0].calls_observed, 500);
assert_eq!(report.candidates[1].display, "app:quiet");
assert_eq!(report.candidates[1].calls_observed, 1);
}
#[test]
fn ordering_is_deterministic_by_file_and_line_when_traffic_and_effects_tie() {
let mut b = facts("py:z:second", "z.py", 5);
b.effects = 1;
let mut a = facts("py:a:first", "a.py", 1);
a.effects = 1;
let scan = scan_with(vec![b, a]);
let report = build_report(&scan, &[], &[]);
assert_eq!(report.candidates[0].file, "a.py");
assert_eq!(report.candidates[1].file, "z.py");
}
#[test]
fn human_line_matches_the_spec_shape() {
let mut f = facts("py:pipeline.ingest:main", "pipeline/ingest.py", 10);
f.effects = 12;
f.idempotent_unsafe = 3;
let c = to_candidate(&f, &BTreeMap::new(), &[]);
let line = candidate_line(&c);
assert!(line.contains("pipeline.ingest:main"));
assert!(line.contains("12 effects"));
assert!(line.contains("3 idempotent-unsafe"));
assert!(line.contains("est. replay-safe: YES"));
}
#[test]
fn human_line_notes_virtualized_reads_when_replay_safe() {
let mut f = facts("py:jobs.nightly:run", "jobs/nightly.py", 4);
f.effects = 31;
f.time_reads = 2;
let c = to_candidate(&f, &BTreeMap::new(), &[]);
let line = candidate_line(&c);
assert!(line.contains("31 effects"));
assert!(!line.contains("idempotent-unsafe"));
assert!(line.contains("est. replay-safe: YES"));
assert!(line.contains("2 time reads will be virtualized"));
}
#[test]
fn human_line_singular_read_is_not_pluralized() {
let mut f = facts("py:app:one_read", "app.py", 1);
f.effects = 1;
f.time_reads = 1;
let c = to_candidate(&f, &BTreeMap::new(), &[]);
assert!(candidate_line(&c).contains("1 time read will be virtualized"));
}
#[test]
fn human_output_lists_no_candidates_when_scan_finds_none() {
let report = build_report(&scan_with(vec![]), &[], &[]);
let out = human(&report, Path::new("."));
assert!(out.contains("no candidate flow entrypoints found"));
}
#[test]
fn json_report_carries_the_js_attribution_honesty_note() {
let report = build_report(&scan_with(vec![]), &[], &[]);
let json = to_json(&report);
assert!(
json["js_attribution_note"]
.as_str()
.unwrap()
.contains("real AST parse")
);
}
fn zero_stats() -> TargetStats {
TargetStats {
target: String::new(),
calls: 0,
attempts: 0,
retries: 0,
successes: 0,
failures: 0,
cache_hits: 0,
throttled: 0,
breaker_opens: 0,
not_retried: 0,
unwrapped_calls: 0,
total_latency_ms: 0,
max_latency_ms: 0,
first_seen_ms: 0,
last_seen_ms: 0,
last_error_class: None,
last_error_status: None,
}
}
}