1use 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
22const 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#[derive(Debug, Deserialize)]
477struct Import {
478 lib: String,
479 file: String,
480 line: u32,
481}
482
483#[derive(Debug, Deserialize)]
485struct Url {
486 host: String,
487 file: String,
488 line: u32,
489 #[serde(default)]
495 transport: Option<String>,
496}
497
498#[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#[derive(Debug, Deserialize)]
515struct WalkerSubprocess {
516 file: String,
517 line: u32,
518 launcher: String,
519 command: String,
520}
521
522#[derive(Debug, Deserialize)]
525struct WalkerSimplification {
526 file: String,
527 function: String,
528 kind: String,
529 line: u32,
530 targets: Vec<String>,
531}
532
533#[derive(Debug, Deserialize)]
535struct WalkerDepAverse {
536 file: String,
537 reason: String,
538}
539
540#[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#[derive(Debug, Clone, Default)]
560pub struct PyScan {
561 pub available: bool,
563 pub files_scanned: usize,
565 pub findings: LangFindings,
567 pub functions: Vec<FunctionFacts>,
569}
570
571const HTTP_LIBS: &[&str] = &["httpx", "requests", "aiohttp", "urllib3", "urllib.request"];
572
573fn 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
590pub 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 _ => {}
613 }
614 }
615 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 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
691fn 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 fs::write(
763 dir.path().join("a.py"),
764 "import httpx\nU = \"https://api.tracked.com/v1\"\n",
765 )
766 .unwrap();
767 fs::write(
769 dir.path().join("b.py"),
770 "import urllib.request\nU = \"https://api.stdlib.com/v1\"\n",
771 )
772 .unwrap();
773 fs::write(
775 dir.path().join("c.py"),
776 "U = \"https://api.mystery.com/v1\"\n",
777 )
778 .unwrap();
779 fs::write(
781 dir.path().join("d.py"),
782 "import http.client\nU = \"https://api.lowlevel.com/v1\"\n",
783 )
784 .unwrap();
785 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 #[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 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 assert!(scan.findings.llm.iter().any(|(p, _)| p == "google-genai"));
882 assert_eq!(
884 scan.findings.resilience_libs,
885 ["tenacity".to_owned()].into_iter().collect()
886 );
887 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 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 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 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 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 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 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 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 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 fs::write(
1208 dir.path().join("risk_gate.py"),
1209 "\"\"\"Deterministic risk gate. stdlib only.\"\"\"\nimport json, urllib.request\n",
1210 )
1211 .unwrap();
1212 fs::write(
1214 dir.path().join("validate.py"),
1215 "# keel: include\nimport json\n",
1216 )
1217 .unwrap();
1218 fs::write(dir.path().join("app.py"), "# keel: exclude\nimport httpx\n").unwrap();
1220 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}