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;
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/// One effect call site with enclosing-function attribution — an internal
33/// detail of the JS/TS pass ([`js`]), which uses it to verify its real
34/// scope-chain tracking (dotted paths like `Class.method`) independently of
35/// the coarser top-level-only [`FunctionFacts`] attribution `keel flows
36/// suggest` consumes. Not exposed on [`ScanResult`]. Field order is the sort
37/// order (file, then line).
38#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
39pub struct CallSite {
40    /// Project-relative path with `/` separators.
41    pub file: String,
42    /// 1-based line of the call expression.
43    pub line: u32,
44    /// What is called, rooted at the effect library where the receiver is
45    /// known (`fetch`, `undici.request`, `openai.chat.completions.create`).
46    pub callee: String,
47    /// Dotted enclosing-scope path (`Class.method`, `outer.inner`), or `None`
48    /// at module top level. Anonymous scopes inherit the nearest named scope.
49    pub function: Option<String>,
50}
51
52/// One place a target was seen: a project-relative path and 1-based line.
53#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
54pub struct Sighting {
55    /// Project-relative path with `/` separators.
56    pub file: String,
57    /// 1-based line number.
58    pub line: u32,
59}
60
61impl Sighting {
62    /// Render as the `file:line` token used in evidence comments.
63    pub fn label(&self) -> String {
64        format!("{}:{}", self.file, self.line)
65    }
66}
67
68/// A target and everywhere the static scan saw it, deduplicated and ordered.
69#[derive(Debug, Clone)]
70pub struct TargetEvidence {
71    /// The target's class.
72    pub class: TargetClass,
73    /// Sorted, unique sightings.
74    pub sightings: BTreeSet<Sighting>,
75}
76
77/// Per-function effect attribution — the evidence behind `keel flows suggest`.
78///
79/// Each language pass attributes what it finds *inside* a function definition
80/// to that function: intercepted-effect call sites, calls that read time or
81/// randomness (virtualized under Tier 2 replay), and constructs that defeat
82/// replay outright (threads, subprocesses, raw sockets). Both passes
83/// attribute by real containment: the Python walker via `ast` module-level
84/// def bodies, the JS/TS pass via a real oxc scope walk (see [`js`]) — an
85/// entry opens only for a function bound directly at module top level; class
86/// methods and nested/inner functions roll up into the enclosing top-level
87/// entry rather than opening their own.
88#[derive(Debug, Clone, Default, PartialEq, Eq)]
89pub struct FunctionFacts {
90    /// The full flow-entrypoint ref this function would be designated as —
91    /// `py:pipeline.ingest:main` or `ts:jobs/nightly.ts#run` (the `ts:`
92    /// namespace covers all JS/TS files).
93    pub entrypoint: String,
94    /// Project-relative path of the defining file.
95    pub file: String,
96    /// 1-based line of the `def`/`function`.
97    pub line: u32,
98    /// Intercepted-effect call sites (HTTP / LLM / DSN-bearing libraries).
99    pub effects: u32,
100    /// Effect calls that are not idempotent-safe to re-send (POST/PATCH-shaped)
101    /// and carry no idempotency evidence.
102    pub idempotent_unsafe: u32,
103    /// Wall-clock reads (`time.time`, `datetime.now`, `Date.now`, …) — these
104    /// are virtualized (journaled + replayed) under Tier 2.
105    pub time_reads: u32,
106    /// Randomness reads (`random.*`, `uuid4`, `Math.random`, …) — also
107    /// virtualized under Tier 2.
108    pub random_reads: u32,
109    /// Why replay would be unsafe (empty = the replay-safe estimate holds).
110    /// Each reason cites `what at file:line`; sorted, deterministic.
111    pub unsafe_reasons: Vec<String>,
112    /// Targets referenced inside the function (hosts from URL literals,
113    /// `llm:<provider>` from SDK calls) — the join key into `.keel/discovery.db`.
114    pub targets: BTreeSet<String>,
115}
116
117/// The merged output of both scanners.
118#[derive(Debug, Clone, Default)]
119pub struct ScanResult {
120    /// Number of source files parsed (Python + JS/TS) — the header's "N static
121    /// scans".
122    pub files_scanned: usize,
123    /// Whether `python3` was available for the Python pass. When false, Python
124    /// files could not be scanned; `keel init` notes this on stderr rather than
125    /// letting it silently narrow coverage.
126    pub python_available: bool,
127    /// Discovered targets, keyed by target string, ordered.
128    pub targets: BTreeMap<String, TargetEvidence>,
129    /// Effect-library names detected across the project (e.g. `httpx`,
130    /// `openai`, `boto3`, `fetch`). `keel doctor` cross-references these against
131    /// its adapter registry to classify coverage.
132    pub libs: BTreeSet<String>,
133    /// Per-function attribution (see [`FunctionFacts`]), sorted by
134    /// `(file, line)` — deterministic across runs.
135    pub functions: Vec<FunctionFacts>,
136}
137
138impl ScanResult {
139    fn add(&mut self, target: String, class: TargetClass, file: String, line: u32) {
140        self.targets
141            .entry(target)
142            .or_insert_with(|| TargetEvidence {
143                class,
144                sightings: BTreeSet::new(),
145            })
146            .sightings
147            .insert(Sighting { file, line });
148    }
149}
150
151/// Scan `project` with both scanners and merge. Host targets are only emitted
152/// when the language pass also saw an HTTP client in use (a bare URL in a
153/// non-networked file is not evidence of an outbound call), keeping the output
154/// honest.
155pub fn scan(project: &Path) -> ScanResult {
156    let mut result = ScanResult::default();
157
158    let py = python::scan(project);
159    result.python_available = py.available;
160    result.files_scanned += py.files_scanned;
161    merge_lang(&mut result, &py.findings);
162    result.functions.extend(py.functions);
163
164    let js = js::scan(project);
165    result.files_scanned += js.files_scanned;
166    merge_lang(&mut result, &js.findings);
167    result.functions.extend(js.functions);
168
169    result
170        .functions
171        .sort_by(|a, b| (&a.file, a.line, &a.entrypoint).cmp(&(&b.file, b.line, &b.entrypoint)));
172    result
173}
174
175/// One language scanner's raw findings before host-gating.
176#[derive(Debug, Clone, Default)]
177pub struct LangFindings {
178    /// Provider SDK imports → `llm:*` targets.
179    pub llm: Vec<(String, Sighting)>,
180    /// URL/DSN host literals → host targets (gated on `http_in_use`).
181    pub hosts: Vec<(String, Sighting)>,
182    /// Whether an HTTP client (http lib / fetch / undici) was seen at all.
183    pub http_in_use: bool,
184    /// Effect-library names detected (for `keel doctor`'s registry cross-check).
185    pub libs: BTreeSet<String>,
186    /// Effect call sites with enclosing-function attribution.
187    pub call_sites: Vec<CallSite>,
188}
189
190fn merge_lang(result: &mut ScanResult, f: &LangFindings) {
191    for (provider, s) in &f.llm {
192        result.add(
193            format!("llm:{provider}"),
194            TargetClass::Llm,
195            s.file.clone(),
196            s.line,
197        );
198    }
199    if f.http_in_use {
200        for (host, s) in &f.hosts {
201            result.add(host.clone(), TargetClass::Host, s.file.clone(), s.line);
202        }
203    }
204    for lib in &f.libs {
205        result.libs.insert(lib.clone());
206    }
207}
208
209/// Directory names never descended into during a scan.
210pub(crate) const SKIP_DIRS: &[&str] = &[
211    ".keel",
212    ".git",
213    "__pycache__",
214    "node_modules",
215    ".venv",
216    "venv",
217    ".mypy_cache",
218    ".pytest_cache",
219    "dist",
220    "build",
221    "target",
222];
223
224/// Extract the host from a `scheme://host[:port][/…]` literal, lowercased and
225/// without port/userinfo/path. Returns `None` for non-URL strings. Shared by
226/// both scanners so Python and JS agree on what a host is.
227pub(crate) fn host_from_url(s: &str) -> Option<String> {
228    let s = s.trim();
229    let (scheme, rest) = s.split_once("://")?;
230    if scheme.is_empty()
231        || !scheme
232            .chars()
233            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
234        || !scheme
235            .chars()
236            .next()
237            .is_some_and(|c| c.is_ascii_alphabetic())
238    {
239        return None;
240    }
241    // authority ends at the first '/', '?', or '#'.
242    let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
243    // strip userinfo, then port.
244    let host_port = authority.rsplit('@').next().unwrap_or(authority);
245    let host = host_port.split(':').next().unwrap_or(host_port);
246    if host.is_empty() || host.contains(|c: char| c.is_whitespace()) {
247        return None;
248    }
249    Some(host.to_ascii_lowercase())
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn host_extraction_strips_port_userinfo_and_path() {
258        assert_eq!(
259            host_from_url("https://api.stripe.com/v1/x").as_deref(),
260            Some("api.stripe.com")
261        );
262        assert_eq!(
263            host_from_url("postgres://u:p@db.internal:5432/app").as_deref(),
264            Some("db.internal")
265        );
266        assert_eq!(host_from_url("HTTPS://API.X").as_deref(), Some("api.x"));
267        assert_eq!(host_from_url("not a url"), None);
268        assert_eq!(host_from_url("://nohost"), None);
269        assert_eq!(host_from_url("1bad://x"), None);
270    }
271}