Skip to main content

keel_cli/scan/
python.rs

1//! The Python static scan: an `ast`-walker executed out-of-process via
2//! `python3 -`.
3//!
4//! Parsing Python with Python's own `ast` is exact where a regex would guess:
5//! it sees real imports and string-literal constants with true line numbers.
6//! The walker script is embedded and fed on stdin; it prints one JSON object.
7//! If `python3` is absent the pass yields nothing and reports
8//! [`available`](PyScan::available)`= false` so the caller can say so out loud
9//! rather than silently under-reporting coverage.
10
11use std::io::Write;
12use std::path::Path;
13use std::process::{Command, Stdio};
14
15use serde::Deserialize;
16
17use super::{
18    DepAverseFile, FunctionFacts, LangFindings, Sighting, SimplificationSighting,
19    SubprocessSighting, TransportClass,
20};
21
22/// The embedded `ast` walker. Deterministic: directories and files are visited
23/// in sorted order, output keys are sorted. Finds imports of the known effect
24/// libraries and URL/DSN string literals, each with `file:line` — and, for
25/// `keel flows suggest`, attributes effect / time / random / replay-unsafe
26/// calls to their enclosing **module-level** function defs (real AST
27/// containment: nested defs and lambdas inside a function count toward it;
28/// class methods are not flow entrypoints and are not attributed).
29const AST_WALKER: &str = r#"
30import ast, json, os, re, sys
31from urllib.parse import urlsplit
32
33HTTP_LIBS = {"httpx", "requests", "aiohttp", "urllib3", "urllib.request"}
34LLM_LIBS = {"openai", "anthropic", "google.genai"}
35OTHER_LIBS = {"psycopg", "boto3"}
36# The agent-framework packs (dx-spec agent-first-class work). pydantic_ai,
37# crewai, langgraph, and openai-agents (import name `agents`) are plain
38# top-level module names, classified exactly like HTTP_LIBS/LLM_LIBS/
39# OTHER_LIBS above. `mcp` is the SDK's own import name (mcp_pack) — a normal
40# adapter lib here, not special-cased. `google.adk` is handled separately via
41# dotted-prefix matching (see `import_entries`): bare `google` is a namespace
42# package shared with unrelated distributions (google-protobuf and friends)
43# and must record nothing.
44AGENT_LIBS = {"pydantic_ai", "crewai", "langgraph", "agents", "mcp"}
45KNOWN = HTTP_LIBS | LLM_LIBS | OTHER_LIBS | AGENT_LIBS | {"google.adk"}
46# Known Python resilience libraries — a `keel doctor` signal that a target may
47# already have its own retry/backoff, separate from KNOWN (which is about
48# libraries Keel *adapts*; these are libraries Keel never adapts, so mixing
49# them into KNOWN would misclassify them as an "invisible" coverage gap).
50RESILIENCE_LIBS = {"tenacity", "backoff", "retrying", "stamina"}
51TIME_LIBS = {"time", "datetime"}
52RANDOM_LIBS = {"random", "uuid", "secrets"}
53UNSAFE_LIBS = {"threading", "multiprocessing", "subprocess", "socket"}
54# Transport classification for `keel doctor`: a URL sighting is "tracked" when
55# a registry-adapted library is in reach in the same file — including stdlib
56# `urllib.request`, adapted since v0.3.0 (its dotted key lives in HTTP_LIBS;
57# see import_entries) — "untracked-known" when only an unadapted stdlib
58# transport is (http.client, or urllib without the request submodule), and
59# otherwise "unknown" — no reachable transport at all.
60STDLIB_TRANSPORTS = {"urllib", "http"}  # http.client / non-request urllib
61TRACKED_TRANSPORTS = HTTP_LIBS | {"psycopg", "boto3"}
62TRACKED = KNOWN | TIME_LIBS | RANDOM_LIBS | UNSAFE_LIBS | STDLIB_TRANSPORTS | {"os", "asyncio"}
63TIME_NAMES = {"time", "time_ns", "monotonic", "monotonic_ns",
64              "perf_counter", "perf_counter_ns", "gmtime", "localtime"}
65DT_NAMES = {"now", "utcnow", "today"}
66UUID_NAMES = {"uuid1", "uuid3", "uuid4", "uuid5"}
67OS_UNSAFE = {"system", "popen", "fork", "forkpty", "execv", "execve",
68             "execvp", "execvpe", "spawnl", "spawnv", "spawnvp"}
69# Subprocess-launching call names itemized (with literal argv where
70# extractable) as their own finer-grained signal, separate from the coarse
71# `unsafe_reasons` UNSAFE_LIBS/OS_UNSAFE already feed.
72SUBPROC_NAMES = {"run", "Popen", "call", "check_call", "check_output"}
73# Hand-rolled-resilience pattern detection (simplification leads). Sleep is
74# the common denominator: time.sleep / asyncio.sleep. BROAD_EXC is the
75# silent-swallow trigger — `except:` bare or Exception/BaseException; a
76# narrow exception tuple is deliberate error handling, not a swallow.
77SLEEP_LIBS = {"time", "asyncio"}
78BROAD_EXC = {"Exception", "BaseException"}
79# `sys.stdlib_module_names` exists on Python 3.10+ (keel's floor); the
80# `getattr` default degrades older interpreters to "never stdlib-only"
81# (an empty STDLIB) rather than crashing or false-positiving.
82STDLIB = set(getattr(sys, "stdlib_module_names", ()))
83DEP_AVERSE_RE = re.compile(r"(gate|guard|auth|valid|safety|risk|kill)", re.I)
84SKIP = {".keel", ".git", "__pycache__", "node_modules", ".venv", "venv",
85        ".mypy_cache", ".pytest_cache", "dist", "build", "target"}
86# The two `google` submodules Keel adapts. Bare `import google` (the
87# namespace package) records nothing — it also hosts unrelated distributions
88# (google-protobuf and friends) that are none of Keel's business.
89GOOGLE_SUBMODULES = {"adk", "genai"}
90
91
92def top(mod):
93    return mod.split(".", 1)[0] if mod else ""
94
95
96def import_entries(node):
97    """Yield (module_key, bound_name) for each name an Import/ImportFrom node
98    binds. Two dotted-prefix special cases resolve to a dotted key instead of
99    the top-level name: `google.adk`/`google.genai` (bare `google` is a
100    namespace package and records nothing) and `urllib.request` (the adapted
101    stdlib transport — `import urllib.request`, `from urllib import request`,
102    and `from urllib.request import ...` all resolve to `urllib.request`;
103    other urllib submodules keep the plain `urllib` key)."""
104    if isinstance(node, ast.Import):
105        for alias in node.names:
106            parts = alias.name.split(".")
107            if len(parts) >= 2 and parts[0] == "google" and parts[1] in GOOGLE_SUBMODULES:
108                key = "google." + parts[1]
109            elif len(parts) >= 2 and parts[0] == "urllib" and parts[1] == "request":
110                key = "urllib.request"
111            else:
112                key = parts[0]
113            yield key, (alias.asname or alias.name).split(".", 1)[0]
114    elif isinstance(node, ast.ImportFrom):
115        mod = node.module or ""
116        mparts = mod.split(".")
117        if len(mparts) >= 2 and mparts[0] == "google" and mparts[1] in GOOGLE_SUBMODULES:
118            key = "google." + mparts[1]
119            for alias in node.names:
120                yield key, alias.asname or alias.name
121        elif mod == "google":
122            for alias in node.names:
123                if alias.name in GOOGLE_SUBMODULES:
124                    yield "google." + alias.name, alias.asname or alias.name
125        elif len(mparts) >= 2 and mparts[0] == "urllib" and mparts[1] == "request":
126            for alias in node.names:
127                yield "urllib.request", alias.asname or alias.name
128        elif mod == "urllib":
129            for alias in node.names:
130                key = "urllib.request" if alias.name == "request" else "urllib"
131                yield key, alias.asname or alias.name
132        else:
133            key = top(mod)
134            for alias in node.names:
135                yield key, alias.asname or alias.name
136
137
138def host(s):
139    if "://" not in s:
140        return None
141    try:
142        parts = urlsplit(s.strip())
143    except ValueError:
144        return None
145    if not parts.scheme or not parts.hostname:
146        return None
147    return parts.hostname
148
149
150def call_root(f):
151    """The Name at the base of a call's attribute chain, or None. Deliberately
152    does NOT see through intermediate calls: in `httpx.get(u).json()` only the
153    inner `httpx.get(u)` has a root, so a chained method on a call result is
154    never double-counted as a second effect."""
155    while isinstance(f, ast.Attribute):
156        f = f.value
157    return f.id if isinstance(f, ast.Name) else None
158
159
160# Call names that construct a handle rather than perform an effect: CapWords
161# constructors (OpenAI(), Client()) plus the well-known factory methods.
162FACTORY_NAMES = {"client", "resource", "session", "connect"}
163
164
165def is_constructor(name):
166    return name is None or name[:1].isupper() or name in FACTORY_NAMES
167
168
169def aliases_of(tree):
170    """Binding name -> tracked module (top-level, or `google.adk`/
171    `google.genai` — see `import_entries`). Also follows one hop of
172    constructor assignment (client = OpenAI() -> client is an openai handle),
173    the dominant SDK-client pattern."""
174    a = {}
175    for node in ast.walk(tree):
176        if isinstance(node, (ast.Import, ast.ImportFrom)):
177            for key, bound in import_entries(node):
178                if key in TRACKED:
179                    a[bound] = key
180    for node in ast.walk(tree):
181        if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
182            lib = a.get(call_root(node.value.func))
183            if lib in KNOWN:
184                for tgt in node.targets:
185                    if isinstance(tgt, ast.Name):
186                        a[tgt.id] = lib
187    return a
188
189
190def url_consts_of(tree):
191    """Module-level NAME = "scheme://host/..." constants -> host, so a URL
192    hoisted to a constant still attributes to the functions that use it."""
193    consts = {}
194    for node in tree.body:
195        if (isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant)
196                and isinstance(node.value.value, str)):
197            h = host(node.value.value)
198            if h:
199                for tgt in node.targets:
200                    if isinstance(tgt, ast.Name):
201                        consts[tgt.id] = h
202    return consts
203
204
205def argv_text(call):
206    """The launched command as literal text, when statically extractable: a
207    bare string arg, or a list/tuple of string-literal elements (the argv
208    shape). Anything else (a variable, an f-string, a partial list) is
209    genuinely dynamic — report it as such rather than guessing."""
210    if call.args:
211        a = call.args[0]
212        if isinstance(a, ast.Constant) and isinstance(a.value, str):
213            return a.value
214        if isinstance(a, (ast.List, ast.Tuple)) and a.elts and all(
215                isinstance(e, ast.Constant) and isinstance(e.value, str)
216                for e in a.elts):
217            return " ".join(e.value for e in a.elts)
218    return "<dynamic>"
219
220
221def is_sleep(node, aliases):
222    if not isinstance(node, ast.Call):
223        return False
224    lib = aliases.get(call_root(node.func))
225    name = node.func.attr if isinstance(node.func, ast.Attribute) else call_root(node.func)
226    return lib in SLEEP_LIBS and name == "sleep"
227
228
229def is_broad_handler(h):
230    if h.type is None:
231        return True
232    names = h.type.elts if isinstance(h.type, ast.Tuple) else [h.type]
233    return any(isinstance(n, ast.Name) and n.id in BROAD_EXC for n in names)
234
235
236def is_default_return(stmt):
237    """`return`, `return None/<constant>`, or `return {}/[]/()` — the shapes
238    that silently substitute a default for a failure."""
239    if not isinstance(stmt, ast.Return):
240        return False
241    v = stmt.value
242    if v is None or isinstance(v, ast.Constant):
243        return True
244    if isinstance(v, ast.Dict):
245        return not v.keys
246    if isinstance(v, (ast.List, ast.Tuple)):
247        return not v.elts
248    return False
249
250
251def detect_simplifications(fn, aliases):
252    """The three WS3 patterns inside one function def, each anchored at the
253    construct to delete. Precedence inside a loop: a status-string comparison
254    makes it a poll (the stronger, WS5-pairing signal) even if an attempt
255    counter is also present. An except-handler sleep inside an
256    already-matched loop is the same construct, not a second finding."""
257    found = []
258    covered = set()
259    for node in ast.walk(fn):
260        if not isinstance(node, (ast.While, ast.For, ast.AsyncFor)):
261            continue
262        has_sleep = has_status_cmp = has_counter = False
263        for sub in ast.walk(node):
264            if is_sleep(sub, aliases):
265                has_sleep = True
266            elif isinstance(sub, ast.Compare) and any(
267                    isinstance(c, ast.Constant) and isinstance(c.value, str)
268                    for c in [sub.left] + list(sub.comparators)):
269                has_status_cmp = True
270            elif isinstance(sub, ast.AugAssign) and isinstance(sub.op, ast.Add):
271                has_counter = True
272            elif (isinstance(sub, ast.Assign) and isinstance(sub.value, ast.BinOp)
273                    and isinstance(sub.value.op, ast.Add)
274                    and isinstance(sub.value.left, ast.Name)
275                    and len(sub.targets) == 1
276                    and isinstance(sub.targets[0], ast.Name)
277                    and sub.targets[0].id == sub.value.left.id):
278                has_counter = True
279        if not has_sleep:
280            continue
281        if has_status_cmp:
282            kind = "hand-rolled-poll"
283        elif has_counter:
284            kind = "hand-rolled-retry"
285        else:
286            continue
287        found.append({"kind": kind, "line": node.lineno})
288        covered.update(id(sub) for sub in ast.walk(node))
289    for node in ast.walk(fn):
290        if (isinstance(node, ast.ExceptHandler) and id(node) not in covered
291                and any(is_sleep(sub, aliases) for sub in ast.walk(node))):
292            found.append({"kind": "hand-rolled-retry", "line": node.lineno})
293    for node in ast.walk(fn):
294        if not isinstance(node, ast.Try):
295            continue
296        calls_target = any(
297            isinstance(sub, ast.Call)
298            and aliases.get(call_root(sub.func)) in KNOWN | STDLIB_TRANSPORTS
299            for stmt in node.body for sub in ast.walk(stmt))
300        if not calls_target:
301            continue
302        for h in node.handlers:
303            if (is_broad_handler(h) and len(h.body) == 1
304                    and is_default_return(h.body[0])):
305                found.append({"kind": "silent-swallow", "line": h.lineno})
306    return found
307
308
309def fn_facts(fn, rel, mod, aliases, url_consts):
310    effects = unsafe_idem = t_reads = r_reads = 0
311    targets = set()
312    reasons = []
313    for node in ast.walk(fn):
314        if isinstance(node, ast.Name) and node.id in url_consts:
315            targets.add(url_consts[node.id])
316        elif isinstance(node, ast.Constant) and isinstance(node.value, str):
317            h = host(node.value)
318            if h:
319                targets.add(h)
320        if not isinstance(node, ast.Call):
321            continue
322        lib = aliases.get(call_root(node.func))
323        if lib is None:
324            continue
325        attr = node.func.attr if isinstance(node.func, ast.Attribute) else None
326        name = attr if attr is not None else call_root(node.func)
327        if lib in KNOWN:
328            if is_constructor(name):
329                continue  # a handle being built, not an effect performed
330            effects += 1
331            if name in {"post", "patch"}:
332                unsafe_idem += 1
333            if lib in LLM_LIBS:
334                targets.add("llm:" + lib)
335        elif lib == "time" and name in TIME_NAMES:
336            t_reads += 1
337        elif lib == "datetime" and name in DT_NAMES:
338            t_reads += 1
339        elif lib in {"random", "secrets"}:
340            r_reads += 1
341        elif lib == "uuid" and name in UUID_NAMES:
342            r_reads += 1
343        elif lib == "os" and name == "urandom":
344            r_reads += 1
345        elif lib in UNSAFE_LIBS:
346            reasons.append((node.lineno, "%s use at %s:%d" % (lib, rel, node.lineno)))
347        elif lib == "os" and name in OS_UNSAFE:
348            reasons.append((node.lineno, "os.%s at %s:%d" % (name, rel, node.lineno)))
349    return {"effects": effects, "file": rel, "idempotent_unsafe": unsafe_idem,
350            "line": fn.lineno, "module": mod, "name": fn.name,
351            "random_reads": r_reads, "targets": sorted(targets),
352            "time_reads": t_reads,
353            "unsafe_reasons": [t for _, t in sorted(reasons)]}
354
355
356root = sys.argv[1] if len(sys.argv) > 1 else "."
357imports = []
358urls = []
359subprocesses = []
360simplifications = []
361dependency_averse = []
362functions = []
363resilience_libs = set()
364files = 0
365for dirpath, dirnames, filenames in os.walk(root):
366    dirnames[:] = sorted(d for d in dirnames if d not in SKIP and not d.startswith("."))
367    for fn in sorted(filenames):
368        if not fn.endswith(".py"):
369            continue
370        path = os.path.join(dirpath, fn)
371        rel = os.path.relpath(path, root).replace(os.sep, "/")
372        try:
373            with open(path, "r", encoding="utf-8") as fh:
374                src = fh.read()
375            tree = ast.parse(src)
376        except (OSError, SyntaxError, UnicodeDecodeError, ValueError):
377            continue
378        files += 1
379        file_tracked = set()
380        file_stdlib = set()
381        file_urls = []
382        for node in ast.walk(tree):
383            if isinstance(node, (ast.Import, ast.ImportFrom)):
384                for key, _bound in import_entries(node):
385                    if key in KNOWN:
386                        imports.append({"lib": key, "file": rel, "line": node.lineno})
387                    elif key in RESILIENCE_LIBS:
388                        resilience_libs.add(key)
389                    if key in TRACKED_TRANSPORTS:
390                        file_tracked.add(key)
391                    elif key in STDLIB_TRANSPORTS:
392                        file_stdlib.add(key)
393            elif isinstance(node, ast.Constant) and isinstance(node.value, str):
394                h = host(node.value)
395                if h:
396                    file_urls.append({"host": h, "file": rel, "line": node.lineno})
397        for u in file_urls:
398            if file_tracked:
399                u["transport"], u["via"] = "tracked", sorted(file_tracked)[0]
400            elif file_stdlib:
401                u["transport"], u["via"] = "untracked-known", sorted(file_stdlib)[0]
402            else:
403                u["transport"], u["via"] = "unknown", None
404            urls.append(u)
405        mod = rel[:-3].replace("/", ".")
406        if mod.endswith(".__init__"):
407            mod = mod[: -len(".__init__")]
408        aliases = aliases_of(tree)
409        for node in ast.walk(tree):
410            if not isinstance(node, ast.Call):
411                continue
412            lib = aliases.get(call_root(node.func))
413            name = node.func.attr if isinstance(node.func, ast.Attribute) else call_root(node.func)
414            if lib == "subprocess" and name in SUBPROC_NAMES:
415                subprocesses.append({"file": rel, "line": node.lineno,
416                                     "launcher": "subprocess." + name,
417                                     "command": argv_text(node)})
418            elif lib == "os" and name in ("system", "popen"):
419                subprocesses.append({"file": rel, "line": node.lineno,
420                                     "launcher": "os." + name,
421                                     "command": argv_text(node)})
422        consts = url_consts_of(tree)
423        for node in tree.body:
424            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
425                facts = fn_facts(node, rel, mod, aliases, consts)
426                functions.append(facts)
427                # Conservative emission gate (WS3 spec): only functions that
428                # also reach a Keel-relevant target get simplification
429                # sightings — a sleep loop around purely local work is none
430                # of Keel's business.
431                if facts["targets"]:
432                    for hit in detect_simplifications(node, aliases):
433                        simplifications.append({
434                            "file": rel, "function": node.name,
435                            "kind": hit["kind"], "line": hit["line"],
436                            "targets": facts["targets"]})
437
438        # Dependency-averse detection: stdlib-only files with a risk/gate/
439        # guard/auth/valid/safety/kill name or docstring signal, or an
440        # explicit marker. Markers win in both directions: `# keel: exclude`
441        # forces the classification regardless of imports; `# keel: include`
442        # defeats the heuristic even if it would otherwise match. `node.level
443        # == 0` excludes relative imports (`from .x import y`) from counting
444        # as third-party — they are always project-internal.
445        third_party = False
446        for node in ast.walk(tree):
447            mods = []
448            if isinstance(node, ast.Import):
449                mods = [top(a.name) for a in node.names]
450            elif isinstance(node, ast.ImportFrom) and node.level == 0:
451                mods = [top(node.module or "")]
452            if any(m and m not in STDLIB for m in mods):
453                third_party = True
454                break
455        if '# keel: exclude' in src:
456            dependency_averse.append({"file": rel, "reason": "marker"})
457        elif '# keel: include' not in src and not third_party and STDLIB:
458            doc = ast.get_docstring(tree) or ""
459            m = DEP_AVERSE_RE.search(rel.rsplit("/", 1)[-1]) or DEP_AVERSE_RE.search(doc)
460            if m:
461                dependency_averse.append(
462                    {"file": rel,
463                     "reason": "stdlib-only + name/docstring signal: " + m.group(1).lower()})
464
465subprocesses.sort(key=lambda x: (x["file"], x["line"]))
466simplifications.sort(key=lambda x: (x["file"], x["line"], x["kind"]))
467dependency_averse.sort(key=lambda x: x["file"])
468print(json.dumps({"dependency_averse": dependency_averse, "files_scanned": files,
469                  "functions": functions, "imports": imports,
470                  "resilience_libs": sorted(resilience_libs),
471                  "simplifications": simplifications,
472                  "subprocesses": subprocesses, "urls": urls}, sort_keys=True))
473"#;
474
475/// One import finding from the walker.
476#[derive(Debug, Deserialize)]
477struct Import {
478    lib: String,
479    file: String,
480    line: u32,
481}
482
483/// One URL-literal finding from the walker.
484#[derive(Debug, Deserialize)]
485struct Url {
486    host: String,
487    file: String,
488    line: u32,
489    /// `"tracked"` / `"untracked-known"` / absent-or-anything-else = unknown.
490    /// The walker's `via` (which stdlib/tracked lib classified it) is not
491    /// consumed on the Rust side yet — only the class matters for
492    /// `host_transports` — so it is left for serde to ignore rather than
493    /// carried as an unread field.
494    #[serde(default)]
495    transport: Option<String>,
496}
497
498/// One module-level function's facts from the walker.
499#[derive(Debug, Deserialize)]
500struct PyFunction {
501    effects: u32,
502    file: String,
503    idempotent_unsafe: u32,
504    line: u32,
505    module: String,
506    name: String,
507    random_reads: u32,
508    targets: Vec<String>,
509    time_reads: u32,
510    unsafe_reasons: Vec<String>,
511}
512
513/// One subprocess/external-process launch from the walker.
514#[derive(Debug, Deserialize)]
515struct WalkerSubprocess {
516    file: String,
517    line: u32,
518    launcher: String,
519    command: String,
520}
521
522/// One simplification sighting from the walker (see
523/// [`SimplificationSighting`]).
524#[derive(Debug, Deserialize)]
525struct WalkerSimplification {
526    file: String,
527    function: String,
528    kind: String,
529    line: u32,
530    targets: Vec<String>,
531}
532
533/// One dependency-averse file from the walker (see [`DepAverseFile`]).
534#[derive(Debug, Deserialize)]
535struct WalkerDepAverse {
536    file: String,
537    reason: String,
538}
539
540/// The walker's JSON output, typed.
541#[derive(Debug, Deserialize)]
542struct WalkerOutput {
543    #[serde(default)]
544    dependency_averse: Vec<WalkerDepAverse>,
545    files_scanned: usize,
546    #[serde(default)]
547    functions: Vec<PyFunction>,
548    imports: Vec<Import>,
549    #[serde(default)]
550    resilience_libs: Vec<String>,
551    #[serde(default)]
552    simplifications: Vec<WalkerSimplification>,
553    #[serde(default)]
554    subprocesses: Vec<WalkerSubprocess>,
555    urls: Vec<Url>,
556}
557
558/// The Python pass result.
559#[derive(Debug, Clone, Default)]
560pub struct PyScan {
561    /// Whether `python3` ran the walker.
562    pub available: bool,
563    /// Files the walker parsed.
564    pub files_scanned: usize,
565    /// Findings, ready to merge.
566    pub findings: LangFindings,
567    /// Per-function attribution (module-level defs), for `keel flows suggest`.
568    pub functions: Vec<FunctionFacts>,
569}
570
571const HTTP_LIBS: &[&str] = &["httpx", "requests", "aiohttp", "urllib3", "urllib.request"];
572
573/// Normalize a walker-reported import key to the name `keel doctor`'s
574/// `REGISTRY` (see `crate::doctor`) keys its adapters by. The walker emits
575/// raw Python identifiers (`google.adk`, `pydantic_ai`, `agents`); doctor's
576/// registry — and the docs/findings that reference it — use the packs'
577/// PyPI-ish public names (`google-adk`, `pydantic-ai`, `openai-agents`).
578/// Everything else passes through unchanged (`crewai`, `langgraph`, `mcp`,
579/// `httpx`, `openai`, …).
580fn normalize_lib(lib: &str) -> String {
581    match lib {
582        "google.adk" => "google-adk".to_owned(),
583        "google.genai" => "google-genai".to_owned(),
584        "pydantic_ai" => "pydantic-ai".to_owned(),
585        "agents" => "openai-agents".to_owned(),
586        other => other.to_owned(),
587    }
588}
589
590/// Run the walker over `project`. A missing `python3`, or a walker that fails,
591/// yields an empty unavailable result — never a panic.
592pub fn scan(project: &Path) -> PyScan {
593    let Some(output) = run_walker(project) else {
594        return PyScan::default();
595    };
596    let mut findings = LangFindings::default();
597    for imp in &output.imports {
598        let lib = normalize_lib(&imp.lib);
599        findings.libs.insert(lib.clone());
600        let sighting = Sighting {
601            file: imp.file.clone(),
602            line: imp.line,
603        };
604        match lib.as_str() {
605            "openai" => findings.llm.push(("openai".to_owned(), sighting)),
606            "anthropic" => findings.llm.push(("anthropic".to_owned(), sighting)),
607            "google-genai" => findings.llm.push(("google-genai".to_owned(), sighting)),
608            lib if HTTP_LIBS.contains(&lib) => findings.http_in_use = true,
609            // psycopg/boto3/agent-pack libs: recorded as effect libraries via
610            // their DSN/URL literals (if any) or the doctor adapter registry;
611            // no synthetic host target from the import alone.
612            _ => {}
613        }
614    }
615    // A DSN literal (postgres://…) is itself evidence of an outbound call even
616    // without one of the HTTP libraries imported.
617    if !output.urls.is_empty() {
618        findings.http_in_use = true;
619    }
620    for lib in &output.resilience_libs {
621        findings.resilience_libs.insert(lib.clone());
622    }
623    for sub in output.subprocesses {
624        findings.subprocesses.push(SubprocessSighting {
625            file: sub.file,
626            line: sub.line,
627            launcher: sub.launcher,
628            command: sub.command,
629        });
630    }
631    for s in output.simplifications {
632        findings.simplifications.push(SimplificationSighting {
633            file: s.file,
634            line: s.line,
635            kind: s.kind,
636            function: s.function,
637            targets: s.targets,
638        });
639    }
640    for d in output.dependency_averse {
641        findings.dependency_averse.push(DepAverseFile {
642            file: d.file,
643            reason: d.reason,
644        });
645    }
646    for url in &output.urls {
647        // The walker already returned a bare hostname (urlsplit.hostname), so it
648        // is lowercased and port-stripped; normalize defensively.
649        let host = url.host.to_ascii_lowercase();
650        findings.hosts.push((
651            host.clone(),
652            Sighting {
653                file: url.file.clone(),
654                line: url.line,
655            },
656        ));
657        let class = match url.transport.as_deref() {
658            Some("tracked") => TransportClass::Tracked,
659            Some("untracked-known") => TransportClass::UntrackedKnown,
660            _ => TransportClass::Unknown,
661        };
662        findings
663            .host_transports
664            .entry(host)
665            .and_modify(|c| *c = (*c).min(class))
666            .or_insert(class);
667    }
668    let functions = output
669        .functions
670        .into_iter()
671        .map(|f| FunctionFacts {
672            entrypoint: format!("py:{}:{}", f.module, f.name),
673            file: f.file,
674            line: f.line,
675            effects: f.effects,
676            idempotent_unsafe: f.idempotent_unsafe,
677            time_reads: f.time_reads,
678            random_reads: f.random_reads,
679            unsafe_reasons: f.unsafe_reasons,
680            targets: f.targets.into_iter().collect(),
681        })
682        .collect();
683    PyScan {
684        available: true,
685        files_scanned: output.files_scanned,
686        findings,
687        functions,
688    }
689}
690
691/// Spawn `python3 - <root>`, feed the walker on stdin, parse its stdout.
692fn run_walker(project: &Path) -> Option<WalkerOutput> {
693    let mut child = Command::new("python3")
694        .arg("-")
695        .arg(project)
696        .stdin(Stdio::piped())
697        .stdout(Stdio::piped())
698        .stderr(Stdio::null())
699        .spawn()
700        .ok()?;
701    child.stdin.take()?.write_all(AST_WALKER.as_bytes()).ok()?;
702    let out = child.wait_with_output().ok()?;
703    if !out.status.success() {
704        return None;
705    }
706    serde_json::from_slice(&out.stdout).ok()
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712    use std::fs;
713    use tempfile::TempDir;
714
715    fn python3_present() -> bool {
716        Command::new("python3")
717            .arg("--version")
718            .stdout(Stdio::null())
719            .stderr(Stdio::null())
720            .status()
721            .is_ok_and(|s| s.success())
722    }
723
724    #[test]
725    fn walks_imports_and_url_literals() {
726        if !python3_present() {
727            eprintln!("skip: python3 not available");
728            return;
729        }
730        let dir = TempDir::new().unwrap();
731        fs::write(
732            dir.path().join("app.py"),
733            "import httpx\nfrom openai import OpenAI\n\nURL = \"https://api.example.com/v1\"\n",
734        )
735        .unwrap();
736        let scan = scan(dir.path());
737        assert!(scan.available);
738        assert_eq!(scan.files_scanned, 1);
739        assert!(scan.findings.http_in_use);
740        assert!(
741            scan.findings
742                .llm
743                .iter()
744                .any(|(p, s)| p == "openai" && s.file == "app.py" && s.line == 2)
745        );
746        assert!(
747            scan.findings
748                .hosts
749                .iter()
750                .any(|(h, s)| h == "api.example.com" && s.line == 4)
751        );
752    }
753
754    #[test]
755    fn urls_are_classified_by_transport() {
756        if !python3_present() {
757            eprintln!("skip: python3 not available");
758            return;
759        }
760        let dir = TempDir::new().unwrap();
761        // tracked: httpx imported in the same file.
762        fs::write(
763            dir.path().join("a.py"),
764            "import httpx\nU = \"https://api.tracked.com/v1\"\n",
765        )
766        .unwrap();
767        // tracked since WS4: urllib.request is an adapted transport.
768        fs::write(
769            dir.path().join("b.py"),
770            "import urllib.request\nU = \"https://api.stdlib.com/v1\"\n",
771        )
772        .unwrap();
773        // unknown: bare URL, no transport at all.
774        fs::write(
775            dir.path().join("c.py"),
776            "U = \"https://api.mystery.com/v1\"\n",
777        )
778        .unwrap();
779        // untracked-known: http.client is still unadapted.
780        fs::write(
781            dir.path().join("d.py"),
782            "import http.client\nU = \"https://api.lowlevel.com/v1\"\n",
783        )
784        .unwrap();
785        // untracked-known: bare urllib (parse only) is not the adapted
786        // submodule — still just "stdlib urllib in reach".
787        fs::write(
788            dir.path().join("e.py"),
789            "from urllib import parse\nU = \"https://api.parseonly.com/v1\"\n",
790        )
791        .unwrap();
792        let s = scan(dir.path());
793        let t = |h: &str| s.findings.host_transports.get(h).copied();
794        assert_eq!(t("api.tracked.com"), Some(TransportClass::Tracked));
795        assert_eq!(t("api.stdlib.com"), Some(TransportClass::Tracked));
796        assert_eq!(t("api.mystery.com"), Some(TransportClass::Unknown));
797        assert_eq!(t("api.lowlevel.com"), Some(TransportClass::UntrackedKnown));
798        assert_eq!(t("api.parseonly.com"), Some(TransportClass::UntrackedKnown));
799    }
800
801    /// WS4: every import form that puts `urllib.request` in reach resolves to
802    /// the dotted lib key `urllib.request` (the google.adk precedent) — it
803    /// lands in `libs` (so doctor's REGISTRY row reports detected) and makes
804    /// same-file URL sightings tracked.
805    #[test]
806    fn urllib_request_import_forms_resolve_to_the_dotted_key() {
807        if !python3_present() {
808            eprintln!("skip: python3 not available");
809            return;
810        }
811        let dir = TempDir::new().unwrap();
812        fs::write(
813            dir.path().join("f1.py"),
814            "import urllib.request\nU = \"https://one.example.com/v\"\n",
815        )
816        .unwrap();
817        fs::write(
818            dir.path().join("f2.py"),
819            "from urllib import request\nU = \"https://two.example.com/v\"\n",
820        )
821        .unwrap();
822        fs::write(
823            dir.path().join("f3.py"),
824            "from urllib.request import urlopen\nU = \"https://three.example.com/v\"\n",
825        )
826        .unwrap();
827        let s = scan(dir.path());
828        assert!(
829            s.findings.libs.contains("urllib.request"),
830            "{:?}",
831            s.findings.libs
832        );
833        for host in ["one.example.com", "two.example.com", "three.example.com"] {
834            assert_eq!(
835                s.findings.host_transports.get(host).copied(),
836                Some(TransportClass::Tracked),
837                "{host}"
838            );
839        }
840    }
841
842    #[test]
843    fn agent_pack_imports_and_the_google_dotted_prefix_are_classified() {
844        if !python3_present() {
845            eprintln!("skip: python3 not available");
846            return;
847        }
848        let dir = TempDir::new().unwrap();
849        fs::write(
850            dir.path().join("app.py"),
851            "import google\n\
852             import google.protobuf\n\
853             from google import protobuf\n\
854             import google.adk\n\
855             from google.genai import types\n\
856             from google import genai, adk\n\
857             from pydantic_ai import Agent\n\
858             import crewai\n\
859             from langgraph.graph import StateGraph\n\
860             from agents import Agent as OpenAIAgent\n\
861             import mcp\n\
862             from tenacity import retry\n",
863        )
864        .unwrap();
865        let scan = scan(dir.path());
866        assert!(scan.available);
867        // The six agent-framework packs + the two google submodules, all
868        // normalized to doctor's REGISTRY names.
869        for lib in [
870            "google-adk",
871            "google-genai",
872            "pydantic-ai",
873            "crewai",
874            "langgraph",
875            "openai-agents",
876            "mcp",
877        ] {
878            assert!(scan.findings.libs.contains(lib), "missing lib: {lib}");
879        }
880        // google.genai joins the llm findings, alongside openai/anthropic.
881        assert!(scan.findings.llm.iter().any(|(p, _)| p == "google-genai"));
882        // tenacity is resilience, not a coverage-relevant lib.
883        assert_eq!(
884            scan.findings.resilience_libs,
885            ["tenacity".to_owned()].into_iter().collect()
886        );
887        // Bare `google` (the namespace package) must record NOTHING — it
888        // also hosts unrelated distributions (google-protobuf and friends).
889        // Neither must `import google.protobuf` nor `from google import
890        // protobuf`: `protobuf` isn't one of Keel's two adapted submodules
891        // (`GOOGLE_SUBMODULES = {"adk", "genai"}`), so both forms must fall
892        // through to the same "namespace package, not evidence" handling —
893        // never surfacing as `google`, `google.protobuf`, or `protobuf`.
894        for leaked in ["google", "google.protobuf", "protobuf"] {
895            assert!(
896                !scan.findings.libs.contains(leaked),
897                "google.protobuf import must not record {leaked}"
898            );
899        }
900    }
901
902    #[test]
903    fn resilience_lib_imports_are_detected_separately_from_known_libs() {
904        if !python3_present() {
905            eprintln!("skip: python3 not available");
906            return;
907        }
908        let dir = TempDir::new().unwrap();
909        fs::write(
910            dir.path().join("app.py"),
911            "import httpx\nfrom tenacity import retry\nimport backoff\n",
912        )
913        .unwrap();
914        let scan = scan(dir.path());
915        assert_eq!(
916            scan.findings.resilience_libs,
917            ["backoff".to_owned(), "tenacity".to_owned()]
918                .into_iter()
919                .collect()
920        );
921        // Resilience libs never pollute `libs` (which feeds doctor's
922        // "invisible/unadapted effect library" coverage classification —
923        // tenacity/backoff were never adapter candidates).
924        assert!(!scan.findings.libs.contains("tenacity"));
925        assert!(!scan.findings.libs.contains("backoff"));
926        assert!(scan.findings.libs.contains("httpx"));
927    }
928
929    #[test]
930    fn attributes_effects_time_random_to_module_level_functions() {
931        if !python3_present() {
932            eprintln!("skip: python3 not available");
933            return;
934        }
935        let dir = TempDir::new().unwrap();
936        fs::write(
937            dir.path().join("pipeline.py"),
938            r#"import time
939import random
940import httpx
941from openai import OpenAI
942
943API = "https://api.example.com/v1/data"
944client = OpenAI()
945
946
947def main():
948    started = time.time()
949    seed = random.random()
950    data = httpx.get(API).json()
951    httpx.post(API, json=data)
952    client.responses.create(model="gpt-4.1", input="hi")
953    return started, seed
954
955
956def helper():
957    return 41 + 1
958"#,
959        )
960        .unwrap();
961        let s = scan(dir.path());
962        let main = s
963            .functions
964            .iter()
965            .find(|f| f.entrypoint == "py:pipeline:main")
966            .expect("main attributed");
967        // get + post + create — the chained .json() must NOT double-count.
968        assert_eq!(main.effects, 3);
969        assert_eq!(main.idempotent_unsafe, 1, "only the POST");
970        assert_eq!(main.time_reads, 1);
971        assert_eq!(main.random_reads, 1);
972        assert!(main.unsafe_reasons.is_empty());
973        assert!(main.targets.contains("api.example.com"), "URL via constant");
974        assert!(main.targets.contains("llm:openai"), "client = OpenAI() hop");
975        assert_eq!((main.file.as_str(), main.line), ("pipeline.py", 10));
976        let helper = s
977            .functions
978            .iter()
979            .find(|f| f.entrypoint == "py:pipeline:helper")
980            .expect("helper attributed");
981        assert_eq!(helper.effects, 0);
982    }
983
984    #[test]
985    fn threads_and_subprocess_defeat_the_replay_safe_estimate() {
986        if !python3_present() {
987            eprintln!("skip: python3 not available");
988            return;
989        }
990        let dir = TempDir::new().unwrap();
991        fs::write(
992            dir.path().join("jobs.py"),
993            r#"import subprocess
994import threading
995import requests
996
997
998def risky():
999    requests.post("https://api.example.com/v1/x")
1000    threading.Thread(target=print).start()
1001    subprocess.run(["ls"])
1002"#,
1003        )
1004        .unwrap();
1005        let s = scan(dir.path());
1006        let f = s
1007            .functions
1008            .iter()
1009            .find(|f| f.entrypoint == "py:jobs:risky")
1010            .expect("risky attributed");
1011        assert_eq!(f.effects, 1);
1012        assert_eq!(
1013            f.unsafe_reasons,
1014            vec![
1015                "threading use at jobs.py:8".to_owned(),
1016                "subprocess use at jobs.py:9".to_owned(),
1017            ]
1018        );
1019    }
1020
1021    #[test]
1022    fn subprocess_launches_are_itemized_with_literal_argv() {
1023        if !python3_present() {
1024            eprintln!("skip: python3 not available");
1025            return;
1026        }
1027        let dir = TempDir::new().unwrap();
1028        fs::write(
1029            dir.path().join("launch.py"),
1030            r#"import subprocess
1031import os
1032
1033def go(cmd):
1034    subprocess.run(["uvx", "alpaca-mcp-server"])
1035    subprocess.Popen(cmd)
1036    os.system("./scripts/kill_switch.sh")
1037"#,
1038        )
1039        .unwrap();
1040        let s = scan(dir.path());
1041        let items: Vec<(&str, &str)> = s
1042            .findings
1043            .subprocesses
1044            .iter()
1045            .map(|x| (x.launcher.as_str(), x.command.as_str()))
1046            .collect();
1047        assert_eq!(
1048            items,
1049            vec![
1050                ("subprocess.run", "uvx alpaca-mcp-server"),
1051                ("subprocess.Popen", "<dynamic>"),
1052                ("os.system", "./scripts/kill_switch.sh"),
1053            ]
1054        );
1055        assert!(
1056            s.findings
1057                .subprocesses
1058                .iter()
1059                .all(|x| x.file == "launch.py")
1060        );
1061    }
1062
1063    #[test]
1064    fn hand_rolled_retry_poll_and_swallow_are_detected() {
1065        if !python3_present() {
1066            eprintln!("skip: python3 not available");
1067            return;
1068        }
1069        let dir = TempDir::new().unwrap();
1070        // Poll: the fetch_short_metrics.py:79-95 shape — while + sleep +
1071        // status-string comparison. The `while` is on line 7 of this file.
1072        fs::write(
1073            dir.path().join("poll.py"),
1074            r#"import time
1075import urllib.request
1076
1077API = "https://api.tavily.com/research"
1078
1079def poller(request_id):
1080    while time.time() < 90:
1081        with urllib.request.urlopen(API) as r:
1082            data = r.read().decode()
1083        if data == "completed":
1084            return data
1085        time.sleep(10)
1086    return None
1087"#,
1088        )
1089        .unwrap();
1090        // Retry: loop + manually incremented attempt counter + sleep (the sleep
1091        // sits in the except handler INSIDE the loop — must yield ONE sighting
1092        // anchored at the loop, not a second one at the handler).
1093        fs::write(
1094            dir.path().join("retry.py"),
1095            r#"import time
1096import urllib.request
1097
1098API = "https://api.alpaca.example/v2"
1099
1100def retryer():
1101    attempts = 0
1102    while True:
1103        try:
1104            return urllib.request.urlopen(API)
1105        except Exception:
1106            attempts += 1
1107            time.sleep(1)
1108"#,
1109        )
1110        .unwrap();
1111        // Swallow: the screen.py:376-398 shape — broad except, single default
1112        // return, urlopen in the try body.
1113        fs::write(
1114            dir.path().join("swallow.py"),
1115            r#"import urllib.request
1116
1117BASE = "https://data.alpaca.example"
1118
1119def fetcher(path):
1120    try:
1121        with urllib.request.urlopen(BASE) as r:
1122            return r.read()
1123    except Exception:
1124        return None
1125"#,
1126        )
1127        .unwrap();
1128        let s = scan(dir.path());
1129        let got: Vec<(&str, &str, &str)> = s
1130            .findings
1131            .simplifications
1132            .iter()
1133            .map(|x| (x.file.as_str(), x.kind.as_str(), x.function.as_str()))
1134            .collect();
1135        assert_eq!(
1136            got,
1137            vec![
1138                ("poll.py", "hand-rolled-poll", "poller"),
1139                ("retry.py", "hand-rolled-retry", "retryer"),
1140                ("swallow.py", "silent-swallow", "fetcher"),
1141            ]
1142        );
1143        // Anchor line: the poll sighting points at the `while` (line 7), the
1144        // construct to delete — not the sleep inside it (line 12).
1145        let poll = &s.findings.simplifications[0];
1146        assert_eq!(poll.line, 7);
1147        assert_eq!(poll.targets, vec!["api.tavily.com".to_owned()]);
1148    }
1149
1150    #[test]
1151    fn simplification_detection_is_gated_on_target_attribution() {
1152        if !python3_present() {
1153            eprintln!("skip: python3 not available");
1154            return;
1155        }
1156        let dir = TempDir::new().unwrap();
1157        // Identical retry shape, but the function reaches NO target — the
1158        // conservative gate must suppress it (a sleep loop around local work is
1159        // none of Keel's business).
1160        fs::write(
1161            dir.path().join("local.py"),
1162            r"import time
1163
1164def churn():
1165    n = 0
1166    while True:
1167        n += 1
1168        if n > 3:
1169            return n
1170        time.sleep(1)
1171",
1172        )
1173        .unwrap();
1174        // Narrow except (specific exception tuple, the web_search.py shape) with
1175        // a target in reach — NOT a silent swallow.
1176        fs::write(
1177            dir.path().join("narrow.py"),
1178            r#"import urllib.request
1179import urllib.error
1180
1181API = "https://api.tavily.com/search"
1182
1183def lookup():
1184    try:
1185        return urllib.request.urlopen(API)
1186    except (urllib.error.URLError, ValueError):
1187        return []
1188"#,
1189        )
1190        .unwrap();
1191        let s = scan(dir.path());
1192        assert!(
1193            s.findings.simplifications.is_empty(),
1194            "{:?}",
1195            s.findings.simplifications
1196        );
1197    }
1198
1199    #[test]
1200    fn dependency_averse_files_are_detected_and_markers_win() {
1201        if !python3_present() {
1202            eprintln!("skip: python3 not available");
1203            return;
1204        }
1205        let dir = TempDir::new().unwrap();
1206        // stdlib-only + docstring signal -> detected.
1207        fs::write(
1208            dir.path().join("risk_gate.py"),
1209            "\"\"\"Deterministic risk gate. stdlib only.\"\"\"\nimport json, urllib.request\n",
1210        )
1211        .unwrap();
1212        // stdlib-only + name signal, but explicit include marker -> NOT detected.
1213        fs::write(
1214            dir.path().join("validate.py"),
1215            "# keel: include\nimport json\n",
1216        )
1217        .unwrap();
1218        // third-party import + no signal, but explicit exclude marker -> detected.
1219        fs::write(dir.path().join("app.py"), "# keel: exclude\nimport httpx\n").unwrap();
1220        // plain file -> not detected.
1221        fs::write(dir.path().join("util.py"), "import json\n").unwrap();
1222        let s = scan(dir.path());
1223        let files: Vec<&str> = s
1224            .findings
1225            .dependency_averse
1226            .iter()
1227            .map(|d| d.file.as_str())
1228            .collect();
1229        assert_eq!(files, vec!["app.py", "risk_gate.py"]);
1230        let app = &s.findings.dependency_averse[0];
1231        assert_eq!(app.reason, "marker");
1232        let gate = &s.findings.dependency_averse[1];
1233        assert!(
1234            gate.reason.contains("risk") || gate.reason.contains("gate"),
1235            "{}",
1236            gate.reason
1237        );
1238    }
1239
1240    #[test]
1241    fn syntax_error_file_is_skipped_not_fatal() {
1242        if !python3_present() {
1243            eprintln!("skip: python3 not available");
1244            return;
1245        }
1246        let dir = TempDir::new().unwrap();
1247        fs::write(dir.path().join("broken.py"), "def (:\n").unwrap();
1248        fs::write(dir.path().join("ok.py"), "import requests\n").unwrap();
1249        let scan = scan(dir.path());
1250        assert_eq!(scan.files_scanned, 1, "only the parseable file counts");
1251        assert!(scan.findings.http_in_use);
1252    }
1253}