Skip to main content

keel_cli/scan/
mod.rs

1//! Static scanning — the first of the three evidence sources behind `keel init`
2//! and `keel doctor` (dx-spec §2). No code runs: we read the project's source
3//! and find where effects enter.
4//!
5//! Two scanners, one merged result:
6//! - [`python`] shells an `ast`-walker out to `python3 -` for precise Python
7//!   parsing (imports of known effect libraries, URL/DSN string literals).
8//! - [`js`] parses JS/TS/JSX in-process with oxc (no Node toolchain needed)
9//!   for `fetch`/`undici`/`node:http` usage, provider-SDK imports, effect-lib
10//!   call sites, and URL literals.
11//!
12//! Both label every finding with `file:line`, so the generated `keel.toml` can
13//! cite where each target was found and trust stays inspectable.
14
15pub mod js;
16pub mod python;
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::path::{Path, PathBuf};
20
21/// What kind of target a sighting resolves to. Governs the policy block
22/// `keel init` writes (an `llm:*` target gets the LLM pack; a host gets the
23/// outbound pack).
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum TargetClass {
26    /// A network host, e.g. `api.stripe.com` — from a URL/DSN literal.
27    Host,
28    /// A semantic `llm:<provider>` target — from a provider SDK import.
29    Llm,
30}
31
32/// How a sighted host's traffic is dispatched, best-known across sightings.
33/// Ordering is meaningful: `Tracked < UntrackedKnown < Unknown`, so merging
34/// (`min`) always keeps the most favorable class seen for a host across every
35/// file/language that sighted it. This is what `keel doctor` (a later
36/// program task) uses to say honestly what Keel can and cannot see: a host
37/// is `Tracked` if some sighting reached it through a registry-adapted
38/// library, `UntrackedKnown` if the best reach was a known-but-unadapted
39/// transport (`http.client`, or urllib without `urllib.request`; Python's
40/// `urllib.request` itself is adapted), and `Unknown` if no transport
41/// evidence was found near any sighting at all.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
43pub enum TransportClass {
44    /// A registry-adapted library is in reach — Keel can wrap this.
45    Tracked,
46    /// A known transport Keel does not adapt (http.client, or urllib without
47    /// `urllib.request`; Python's `urllib.request` itself is adapted).
48    UntrackedKnown,
49    /// A URL literal with no recognizable transport nearby.
50    Unknown,
51}
52
53/// One effect call site with enclosing-function attribution — an internal
54/// detail of the JS/TS pass ([`js`]), which uses it to verify its real
55/// scope-chain tracking (dotted paths like `Class.method`) independently of
56/// the coarser top-level-only [`FunctionFacts`] attribution `keel flows
57/// suggest` consumes. Not exposed on [`ScanResult`]. Field order is the sort
58/// order (file, then line).
59#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
60pub struct CallSite {
61    /// Project-relative path with `/` separators.
62    pub file: String,
63    /// 1-based line of the call expression.
64    pub line: u32,
65    /// What is called, rooted at the effect library where the receiver is
66    /// known (`fetch`, `undici.request`, `openai.chat.completions.create`).
67    pub callee: String,
68    /// Dotted enclosing-scope path (`Class.method`, `outer.inner`), or `None`
69    /// at module top level. Anonymous scopes inherit the nearest named scope.
70    pub function: Option<String>,
71}
72
73/// One externally-launched process the scan saw — traffic inside it is
74/// outside Keel's visibility regardless of policy. Field order is the sort
75/// order (file, then line).
76#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
77pub struct SubprocessSighting {
78    /// Project-relative path with `/` separators.
79    pub file: String,
80    /// 1-based line of the launching call.
81    pub line: u32,
82    /// The launching call, e.g. `subprocess.run`, `os.system`,
83    /// `child_process.spawn`.
84    pub launcher: String,
85    /// The literal argv/command line when statically extractable
86    /// (a bare string, or a list/tuple of string-literal elements); otherwise
87    /// `"<dynamic>"`.
88    pub command: String,
89    /// The literal argv as a positional vector, when the call is the
90    /// "list/tuple of string literals, no shell string" shape a `cmd:`
91    /// interceptor can ever match (issue #41) — `None` for a bare-string
92    /// command, a dynamic call, `shell=True`, or a launcher no runtime pack
93    /// currently intercepts at all (`os.system`/`os.popen`; Node's scanner
94    /// doesn't sight the launchers `child-process.mjs` intercepts, so it
95    /// always reports `None` here too, pending its own scanner work). `None`
96    /// means "never a `[flows.match."cmd:*"]` match candidate", independent
97    /// of what `command`'s text happens to look like.
98    pub argv: Option<Vec<String>>,
99}
100
101/// One hand-rolled resilience pattern sighted inside a function that also
102/// reaches a Keel-relevant target — a simplification lead: once the target
103/// is wrapped, the pattern becomes redundant. `kind` is a closed set:
104/// "hand-rolled-retry" | "hand-rolled-poll" | "silent-swallow". `line`
105/// anchors the construct to delete (the loop / the `except` line), not the
106/// sleep call inside it. Python-only as of this build (JS pattern parity is
107/// a spec'd follow-on program).
108#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
109pub struct SimplificationSighting {
110    pub file: String,
111    pub line: u32,
112    pub kind: String,
113    pub function: String,
114    pub targets: Vec<String>,
115}
116
117/// One file the scan judged dependency-averse: stdlib-only imports plus a
118/// risk/gate/guard/auth/valid/safety/kill name or docstring signal, or an
119/// explicit `# keel: exclude` marker. Markers win in both directions: an
120/// exclude marker forces this classification regardless of imports, and an
121/// include marker defeats the heuristic even where it would otherwise match.
122/// `keel doctor`/`keel init` (a later program task) use this to honestly
123/// exclude hosts seen only in such files from proposed policy. Field order
124/// is the sort order (by file).
125#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
126pub struct DepAverseFile {
127    /// Project-relative path with `/` separators.
128    pub file: String,
129    /// `"marker"` for an explicit `# keel: exclude`, or
130    /// `"stdlib-only + name/docstring signal: <word>"`.
131    pub reason: String,
132}
133
134/// One place a target was seen: a project-relative path and 1-based line.
135#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
136pub struct Sighting {
137    /// Project-relative path with `/` separators.
138    pub file: String,
139    /// 1-based line number.
140    pub line: u32,
141}
142
143impl Sighting {
144    /// Render as the `file:line` token used in evidence comments.
145    pub fn label(&self) -> String {
146        format!("{}:{}", self.file, self.line)
147    }
148}
149
150/// A target and everywhere the static scan saw it, deduplicated and ordered.
151#[derive(Debug, Clone)]
152pub struct TargetEvidence {
153    /// The target's class.
154    pub class: TargetClass,
155    /// Sorted, unique sightings.
156    pub sightings: BTreeSet<Sighting>,
157}
158
159/// Per-function effect attribution — the evidence behind `keel flows suggest`.
160///
161/// Each language pass attributes what it finds *inside* a function definition
162/// to that function: intercepted-effect call sites, calls that read time or
163/// randomness (virtualized under Tier 2 replay), and constructs that defeat
164/// replay outright (threads, subprocesses, raw sockets). Both passes
165/// attribute by real containment: the Python walker via `ast` module-level
166/// def bodies, the JS/TS pass via a real oxc scope walk (see [`js`]) — an
167/// entry opens only for a function bound directly at module top level; class
168/// methods and nested/inner functions roll up into the enclosing top-level
169/// entry rather than opening their own.
170#[derive(Debug, Clone, Default, PartialEq, Eq)]
171pub struct FunctionFacts {
172    /// The full flow-entrypoint ref this function would be designated as —
173    /// `py:pipeline.ingest:main` or `ts:jobs/nightly.ts#run` (the `ts:`
174    /// namespace covers all JS/TS files).
175    pub entrypoint: String,
176    /// Project-relative path of the defining file.
177    pub file: String,
178    /// 1-based line of the `def`/`function`.
179    pub line: u32,
180    /// Intercepted-effect call sites (HTTP / LLM / DSN-bearing libraries).
181    pub effects: u32,
182    /// Effect calls that are not idempotent-safe to re-send (POST/PATCH-shaped)
183    /// and carry no idempotency evidence.
184    pub idempotent_unsafe: u32,
185    /// Wall-clock reads (`time.time`, `datetime.now`, `Date.now`, …) — these
186    /// are virtualized (journaled + replayed) under Tier 2.
187    pub time_reads: u32,
188    /// Randomness reads (`random.*`, `uuid4`, `Math.random`, …) — also
189    /// virtualized under Tier 2.
190    pub random_reads: u32,
191    /// Why replay would be unsafe (empty = the replay-safe estimate holds).
192    /// Each reason cites `what at file:line`; sorted, deterministic.
193    pub unsafe_reasons: Vec<String>,
194    /// Targets referenced inside the function (hosts from URL literals,
195    /// `llm:<provider>` from SDK calls) — the join key into `.keel/discovery.db`.
196    pub targets: BTreeSet<String>,
197}
198
199/// The merged output of both scanners.
200#[derive(Debug, Clone, Default)]
201pub struct ScanResult {
202    /// Number of source files parsed (Python + JS/TS) — the header's "N static
203    /// scans".
204    pub files_scanned: usize,
205    /// Whether `python3` was available for the Python pass. When false, Python
206    /// files could not be scanned; `keel init` notes this on stderr rather than
207    /// letting it silently narrow coverage.
208    pub python_available: bool,
209    /// Discovered targets, keyed by target string, ordered.
210    pub targets: BTreeMap<String, TargetEvidence>,
211    /// Effect-library names detected across the project (e.g. `httpx`,
212    /// `openai`, `boto3`, `fetch`). `keel doctor` cross-references these against
213    /// its adapter registry to classify coverage.
214    pub libs: BTreeSet<String>,
215    /// Per-function attribution (see [`FunctionFacts`]), sorted by
216    /// `(file, line)` — deterministic across runs.
217    pub functions: Vec<FunctionFacts>,
218    /// Known resilience-library names detected, across both languages (see
219    /// [`LangFindings::resilience_libs`]).
220    pub resilience_libs: BTreeSet<String>,
221    /// Best-known [`TransportClass`] per sighted host, merged across every
222    /// file and language that sighted it (minimum = best class wins). Unlike
223    /// `targets`, this is never gated on `http_in_use` — it exists precisely
224    /// to let `keel doctor` report on hosts Keel cannot see at all.
225    pub host_transports: BTreeMap<String, TransportClass>,
226    /// Every externally-launched process the scan saw, sorted by
227    /// `(file, line)` — deterministic across runs. `keel doctor` uses this to
228    /// call out where Keel's visibility ends at a process boundary.
229    pub subprocesses: Vec<SubprocessSighting>,
230    /// Files judged dependency-averse across the project, sorted by file —
231    /// see [`DepAverseFile`]. Python-only as of this build (see
232    /// [`LangFindings::dependency_averse`]).
233    pub dependency_averse: Vec<DepAverseFile>,
234    /// Hand-rolled resilience patterns sighted in functions with target
235    /// attribution, sorted by (file, line, kind) — deterministic across
236    /// runs.
237    pub simplifications: Vec<SimplificationSighting>,
238}
239
240impl ScanResult {
241    fn add(&mut self, target: String, class: TargetClass, file: String, line: u32) {
242        self.targets
243            .entry(target)
244            .or_insert_with(|| TargetEvidence {
245                class,
246                sightings: BTreeSet::new(),
247            })
248            .sightings
249            .insert(Sighting { file, line });
250    }
251}
252
253/// Scan `project` with both scanners and merge. Host targets are only emitted
254/// when the language pass also saw an HTTP client in use (a bare URL in a
255/// non-networked file is not evidence of an outbound call), keeping the output
256/// honest.
257pub fn scan(project: &Path) -> ScanResult {
258    let mut result = ScanResult::default();
259
260    let py = python::scan(project);
261    result.python_available = py.available;
262    result.files_scanned += py.files_scanned;
263    merge_lang(&mut result, &py.findings);
264    result.functions.extend(py.functions);
265
266    let js = js::scan(project);
267    result.files_scanned += js.files_scanned;
268    merge_lang(&mut result, &js.findings);
269    result.functions.extend(js.functions);
270
271    result
272        .functions
273        .sort_by(|a, b| (&a.file, a.line, &a.entrypoint).cmp(&(&b.file, b.line, &b.entrypoint)));
274    result.subprocesses.sort();
275    result.dependency_averse.sort();
276    result.simplifications.sort();
277    result
278}
279
280/// One language scanner's raw findings before host-gating.
281#[derive(Debug, Clone, Default)]
282pub struct LangFindings {
283    /// Provider SDK imports → `llm:*` targets.
284    pub llm: Vec<(String, Sighting)>,
285    /// URL/DSN host literals → host targets (gated on `http_in_use`).
286    pub hosts: Vec<(String, Sighting)>,
287    /// Whether an HTTP client (http lib / fetch / undici) was seen at all.
288    pub http_in_use: bool,
289    /// Effect-library names detected (for `keel doctor`'s registry cross-check).
290    pub libs: BTreeSet<String>,
291    /// Effect call sites with enclosing-function attribution.
292    pub call_sites: Vec<CallSite>,
293    /// Known resilience-library names detected (e.g. `tenacity`, `backoff`)
294    /// — a `keel doctor` signal for pre-existing retry/backoff that might
295    /// now silently compound with Keel's own. Deliberately separate from
296    /// `libs`: these are libraries Keel never adapts, so merging them in
297    /// would misclassify them as an "invisible" coverage gap.
298    pub resilience_libs: BTreeSet<String>,
299    /// Per-sighting [`TransportClass`] for every host this language pass saw,
300    /// keyed by host. Never gated on `http_in_use` — a bare URL literal with
301    /// no reachable transport is exactly the `Unknown` case `keel doctor`
302    /// needs to report honestly.
303    pub host_transports: BTreeMap<String, TransportClass>,
304    /// Externally-launched processes this language pass saw (see
305    /// [`SubprocessSighting`]).
306    pub subprocesses: Vec<SubprocessSighting>,
307    /// Files this language pass judged dependency-averse (see
308    /// [`DepAverseFile`]).
309    pub dependency_averse: Vec<DepAverseFile>,
310    /// Hand-rolled resilience patterns this language pass saw (see
311    /// [`SimplificationSighting`]). Python-only as of this build.
312    pub simplifications: Vec<SimplificationSighting>,
313}
314
315fn merge_lang(result: &mut ScanResult, f: &LangFindings) {
316    for (provider, s) in &f.llm {
317        result.add(
318            format!("llm:{provider}"),
319            TargetClass::Llm,
320            s.file.clone(),
321            s.line,
322        );
323    }
324    if f.http_in_use {
325        for (host, s) in &f.hosts {
326            result.add(host.clone(), TargetClass::Host, s.file.clone(), s.line);
327        }
328    }
329    for lib in &f.libs {
330        result.libs.insert(lib.clone());
331    }
332    for lib in &f.resilience_libs {
333        result.resilience_libs.insert(lib.clone());
334    }
335    for (host, class) in &f.host_transports {
336        result
337            .host_transports
338            .entry(host.clone())
339            .and_modify(|c| *c = (*c).min(*class))
340            .or_insert(*class);
341    }
342    result.subprocesses.extend(f.subprocesses.iter().cloned());
343    result
344        .dependency_averse
345        .extend(f.dependency_averse.iter().cloned());
346    result
347        .simplifications
348        .extend(f.simplifications.iter().cloned());
349}
350
351/// Directory names never descended into during a filesystem walk — scans,
352/// `keel init`'s Python-file check, and `keel flows resume`'s module search
353/// all share this one list (previously three drifted copies; see the
354/// 2026-07-14 fast-follow that consolidated them).
355pub(crate) const SKIP_DIRS: &[&str] = &[
356    ".keel",
357    ".git",
358    ".hg",
359    ".svn",
360    "__pycache__",
361    "node_modules",
362    ".venv",
363    "venv",
364    ".mypy_cache",
365    ".pytest_cache",
366    "dist",
367    "build",
368    "target",
369];
370
371/// Recursively collect files under `dir` whose extension is one of
372/// `extensions`, skipping [`SKIP_DIRS`] and dot-prefixed directories. The
373/// one walker for "find source files by extension" in this crate — shared
374/// by the JS/TS scanner ([`js`]), `keel init`'s Python-file check, and
375/// `keel run`'s directory-entry resolution.
376pub(crate) fn collect_files(dir: &Path, extensions: &[&str], out: &mut Vec<PathBuf>) {
377    let Ok(entries) = std::fs::read_dir(dir) else {
378        return;
379    };
380    for entry in entries.flatten() {
381        let path = entry.path();
382        let name = entry.file_name();
383        let name = name.to_string_lossy();
384        if path.is_dir() {
385            if SKIP_DIRS.contains(&name.as_ref()) || name.starts_with('.') {
386                continue;
387            }
388            collect_files(&path, extensions, out);
389        } else if path
390            .extension()
391            .and_then(|e| e.to_str())
392            .is_some_and(|e| extensions.contains(&e))
393        {
394            out.push(path);
395        }
396    }
397}
398
399/// Extract the host from a `scheme://host[:port][/…]` literal, lowercased and
400/// without port/userinfo/path. Returns `None` for non-URL strings. Shared by
401/// both scanners so Python and JS agree on what a host is.
402pub(crate) fn host_from_url(s: &str) -> Option<String> {
403    let s = s.trim();
404    let (scheme, rest) = s.split_once("://")?;
405    if scheme.is_empty()
406        || !scheme
407            .chars()
408            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
409        || !scheme
410            .chars()
411            .next()
412            .is_some_and(|c| c.is_ascii_alphabetic())
413    {
414        return None;
415    }
416    // authority ends at the first '/', '?', or '#'.
417    let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
418    // strip userinfo, then port.
419    let host_port = authority.rsplit('@').next().unwrap_or(authority);
420    let host = host_port.split(':').next().unwrap_or(host_port);
421    if host.is_empty() || host.contains(|c: char| c.is_whitespace()) {
422        return None;
423    }
424    Some(host.to_ascii_lowercase())
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn host_extraction_strips_port_userinfo_and_path() {
433        assert_eq!(
434            host_from_url("https://api.stripe.com/v1/x").as_deref(),
435            Some("api.stripe.com")
436        );
437        assert_eq!(
438            host_from_url("postgres://u:p@db.internal:5432/app").as_deref(),
439            Some("db.internal")
440        );
441        assert_eq!(host_from_url("HTTPS://API.X").as_deref(), Some("api.x"));
442        assert_eq!(host_from_url("not a url"), None);
443        assert_eq!(host_from_url("://nohost"), None);
444        assert_eq!(host_from_url("1bad://x"), None);
445    }
446}