Skip to main content

keel_cli/
flows_suggest.rs

1//! `keel flows suggest` — replay-safety analysis over candidate flow
2//! entrypoints (dx-spec §1 Level 2: "Durability, still config-only").
3//!
4//! Combines two evidence sources exactly like `keel init` does (dx-spec §2):
5//! the static scan's per-function attribution ([`scan::FunctionFacts`] —
6//! intercepted-effect call sites, idempotent-unsafe effects, time/random
7//! reads Tier 2 would virtualize, and constructs that defeat replay outright)
8//! and `.keel/discovery.db`'s observed call counts, joined on the targets a
9//! function's effects reference. A function is a **candidate** when it
10//! performs at least one intercepted effect — a pure helper has nothing to
11//! make durable.
12//!
13//! **Verdict.** `replay-safe: NO` only when the scan found a construct that
14//! defeats replay outright (`unsafe_reasons`: threads, subprocesses, raw
15//! sockets, `child_process`/`worker_threads`). Idempotent-unsafe effects and
16//! time/random reads do **not** flip the verdict — Tier 2 injects an
17//! idempotency key into retried effects automatically (CCR-2,
18//! `contracts/adapter-pack.md`) and virtualizes time/random reads on replay;
19//! they are surfaced as context for the reviewer, not blockers.
20//!
21//! **Ranking.** Candidates are ordered by observed traffic first (the
22//! functions actually being exercised are the best flow candidates), then by
23//! effect count, then by `(file, line)` as the final deterministic tie-break
24//! (dx-spec §5) — never by insertion order alone, so a re-run against
25//! unchanged evidence reproduces byte-identical `--json` output.
26//!
27//! **Honesty about coverage.** The JS/TS pass attributes by a brace-depth
28//! line heuristic, not a parse (`scan::js` module docs) — it cannot see class
29//! methods, object-literal methods, or a function whose opening `{` sits on
30//! its own line. Python attribution is exact (real `ast` containment). Both
31//! `python_available` and a fixed note about the JS/TS limitation ride along
32//! in the report so `--json` output never overclaims precision it does not
33//! have.
34
35use 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
49/// A fixed, honest note about the JS/TS scanner's attribution limits, carried
50/// in the report itself so a `--json` consumer learns about it without
51/// reading Rust source.
52const 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/// One candidate flow entrypoint — the machine twin of one `keel flows
60/// suggest` line.
61#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
62pub struct Candidate {
63    /// True when `entrypoint` is already in `[flows] entrypoints`.
64    pub already_designated: bool,
65    /// Calls observed (summed across the function's referenced targets) in
66    /// `.keel/discovery.db`. `0` when no traffic was recorded, which may mean
67    /// no targets were statically identified either.
68    pub calls_observed: i64,
69    /// The bare display form `keel flows add` also accepts back
70    /// (`pipeline.ingest:main`, `jobs/nightly.ts#run`) — the `py:`/`ts:`
71    /// namespace stripped.
72    pub display: String,
73    /// Intercepted-effect call sites.
74    pub effects: u32,
75    /// The full `py:`/`ts:` entrypoint ref — pass this (or `display`) to
76    /// `keel flows add`.
77    pub entrypoint: String,
78    /// Project-relative defining file.
79    pub file: String,
80    /// Effect calls that are POST/PATCH-shaped with no idempotency evidence.
81    pub idempotent_unsafe: u32,
82    /// 1-based line of the function definition.
83    pub line: u32,
84    /// Randomness reads that Tier 2 would virtualize on replay.
85    pub random_reads: u32,
86    /// `false` only when `unsafe_reasons` is non-empty.
87    pub replay_safe: bool,
88    /// Wall-clock reads that Tier 2 would virtualize on replay.
89    pub time_reads: u32,
90    /// Why `replay_safe` is `false` (empty when it is `true`); sorted,
91    /// deterministic (inherited from the scan).
92    pub unsafe_reasons: Vec<String>,
93}
94
95/// The `keel flows suggest` report.
96#[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
105/// `keel flows suggest` for `project`.
106pub 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
118/// The `[flows] entrypoints` already designated, or empty when `keel.toml` is
119/// absent, unreadable, or invalid — `suggest` is a report, not a validator; a
120/// broken policy just means it cannot say which candidates are already flows
121/// (`keel doctor` carries policy validity separately).
122fn 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
127/// Build the report from a scan, observed traffic, and the designated
128/// entrypoints. Pure — unit-tested without touching the filesystem.
129fn 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    // Rank by observed traffic, then effect count, then the scan's own
145    // deterministic (file, line) order — never insertion order alone.
146    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
189/// Strip the `py:`/`ts:` namespace for display — the same bare form `keel
190/// flows add` infers back (`crate::flows_add::normalize_entrypoint`).
191fn 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
199/// The human report, derived entirely from [`SuggestReport`] (plus a
200/// filesystem probe for the python3-not-found note, exactly like `init`'s).
201fn 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
230/// One candidate's line, in the spec's shape (dx-spec §1 Level 2):
231/// `pipeline.ingest:main      12 effects, 3 idempotent-unsafe, est. replay-safe: YES`.
232fn 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        // Observed traffic outranks a higher static effect count.
393        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}