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. A later assignment that rebinds such a
174 name to anything that is NOT another known-library handle (a literal, an
175 unrelated constructor, any other call) invalidates it, so a generic name
176 (`client`, `session`, `resp`) bound to a library in one place and reused
177 for something else elsewhere in the file is not misattributed to the first
178 library. Import bindings themselves are never invalidated — module names
179 like `os`/`time`/`subprocess` are effectively never rebound, and the
180 file-wide subprocess/sleep passes rely on them staying put. Known misses of
181 this flat, unscoped, single-snapshot dict (no scoping/branch modeling here,
182 not worth chasing): two functions binding one name to *different* known
183 libs still collide last-write-wins; invalidation only fires when the known
184 binding precedes the reuse in traversal order; and invalidating a shared
185 name also drops the original binder's own handle (conservative — favor a
186 missed attribution over a wrong one)."""
187 a = {}
188 imported = set()
189 for node in ast.walk(tree):
190 if isinstance(node, (ast.Import, ast.ImportFrom)):
191 for key, bound in import_entries(node):
192 if key in TRACKED:
193 a[bound] = key
194 imported.add(bound)
195 for node in ast.walk(tree):
196 if not isinstance(node, ast.Assign):
197 continue
198 lib = a.get(call_root(node.value.func)) if isinstance(node.value, ast.Call) else None
199 for tgt in node.targets:
200 if not isinstance(tgt, ast.Name):
201 continue
202 if lib in KNOWN:
203 a[tgt.id] = lib
204 elif tgt.id not in imported:
205 a.pop(tgt.id, None)
206 return a
207
208
209def url_consts_of(tree):
210 """Module-level NAME = "scheme://host/..." constants -> host, so a URL
211 hoisted to a constant still attributes to the functions that use it."""
212 consts = {}
213 for node in tree.body:
214 if (isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant)
215 and isinstance(node.value.value, str)):
216 h = host(node.value.value)
217 if h:
218 for tgt in node.targets:
219 if isinstance(tgt, ast.Name):
220 consts[tgt.id] = h
221 return consts
222
223
224def argv_text(call):
225 """The launched command as literal text, when statically extractable: a
226 bare string arg, or a list/tuple of string-literal elements (the argv
227 shape). Anything else (a variable, an f-string, a partial list) is
228 genuinely dynamic — report it as such rather than guessing."""
229 if call.args:
230 a = call.args[0]
231 if isinstance(a, ast.Constant) and isinstance(a.value, str):
232 return a.value
233 if isinstance(a, (ast.List, ast.Tuple)) and a.elts and all(
234 isinstance(e, ast.Constant) and isinstance(e.value, str)
235 for e in a.elts):
236 return " ".join(e.value for e in a.elts)
237 return "<dynamic>"
238
239
240def argv_list(call):
241 """The launched argv as a genuine positional list, or ``None`` (issue
242 #41). Only a list/tuple of string-literal elements, with no ``shell=True``
243 keyword, is this shape — the exact condition
244 ``subprocess_pack.py``'s runtime interceptor requires to consider a call
245 for ``[flows.match."cmd:*"]`` dispatch (``_str_argv``/the ``shell``
246 check). A bare string command (``os.system``, ``shell=True``) is a
247 DIFFERENT launch shape the runtime pack never matches; ``None`` here is
248 doctor's signal that this sighting can never be a match candidate,
249 regardless of what its text happens to look like."""
250 for kw in call.keywords:
251 if kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
252 return None
253 if call.args:
254 a = call.args[0]
255 if isinstance(a, (ast.List, ast.Tuple)) and a.elts and all(
256 isinstance(e, ast.Constant) and isinstance(e.value, str)
257 for e in a.elts):
258 return [e.value for e in a.elts]
259 return None
260
261
262def is_sleep(node, aliases):
263 """Known conservative miss: `from time import sleep as pause` binds
264 `pause` to the "time" MODULE in `aliases` (`import_entries` only tracks
265 module -> binding, never the specific imported symbol name), so a bare
266 `pause(...)` call falls into the `ast.Name` branch below with
267 `name = "pause"` — never `"sleep"` — and is missed. Renaming the sleep
268 call itself (as opposed to renaming the time/asyncio module, which IS
269 tracked via `aliases`) defeats detection. Not worth chasing for a
270 synthetic edge case."""
271 if not isinstance(node, ast.Call):
272 return False
273 lib = aliases.get(call_root(node.func))
274 name = node.func.attr if isinstance(node.func, ast.Attribute) else call_root(node.func)
275 return lib in SLEEP_LIBS and name == "sleep"
276
277
278def is_broad_handler(h):
279 if h.type is None:
280 return True
281 names = h.type.elts if isinstance(h.type, ast.Tuple) else [h.type]
282 return any(isinstance(n, ast.Name) and n.id in BROAD_EXC for n in names)
283
284
285def is_default_return(stmt):
286 """`return`, `return None/<constant>`, or `return {}/[]/()` — the shapes
287 that silently substitute a default for a failure."""
288 if not isinstance(stmt, ast.Return):
289 return False
290 v = stmt.value
291 if v is None or isinstance(v, ast.Constant):
292 return True
293 if isinstance(v, ast.Dict):
294 return not v.keys
295 if isinstance(v, (ast.List, ast.Tuple)):
296 return not v.elts
297 return False
298
299
300def _test_has_string_compare(test):
301 """Does an expression subtree (an `if`/`while` test) contain a
302 comparison against a string constant?"""
303 return any(
304 isinstance(n, ast.Compare) and any(
305 isinstance(c, ast.Constant) and isinstance(c.value, str)
306 for c in [n.left] + list(n.comparators))
307 for n in ast.walk(test))
308
309
310def _governs_status_cmp(node):
311 """A string-comparison compare that actually governs whether the loop
312 exits: the loop's own `while` condition, or a nested `if` whose body
313 breaks or returns. Restricting to these (rather than ANY string compare
314 found anywhere inside the loop) avoids mislabeling a hand-rolled retry
315 loop as a poll loop merely because some unrelated string comparison
316 happens to sit deeper in the body — the wrong recommended fix (`poll`
317 policy instead of retry/backoff) would follow from that mislabel."""
318 if isinstance(node, ast.While) and _test_has_string_compare(node.test):
319 return True
320 return any(
321 isinstance(sub, ast.If)
322 and any(isinstance(s, (ast.Break, ast.Return)) for s in sub.body)
323 and _test_has_string_compare(sub.test)
324 for sub in ast.walk(node))
325
326
327def detect_simplifications(fn, aliases):
328 """The three WS3 patterns inside one function def, each anchored at the
329 construct to delete. Precedence inside a loop: a status-string comparison
330 makes it a poll (the stronger, WS5-pairing signal) even if an attempt
331 counter is also present. An except-handler sleep inside an
332 already-matched loop is the same construct, not a second finding."""
333 found = []
334 covered = set()
335 for node in ast.walk(fn):
336 if not isinstance(node, (ast.While, ast.For, ast.AsyncFor)):
337 continue
338 has_sleep = has_counter = False
339 for sub in ast.walk(node):
340 if is_sleep(sub, aliases):
341 has_sleep = True
342 elif isinstance(sub, ast.AugAssign) and isinstance(sub.op, ast.Add):
343 has_counter = True
344 elif (isinstance(sub, ast.Assign) and isinstance(sub.value, ast.BinOp)
345 and isinstance(sub.value.op, ast.Add)
346 and isinstance(sub.value.left, ast.Name)
347 and len(sub.targets) == 1
348 and isinstance(sub.targets[0], ast.Name)
349 and sub.targets[0].id == sub.value.left.id):
350 has_counter = True
351 if not has_sleep:
352 continue
353 if _governs_status_cmp(node):
354 kind = "hand-rolled-poll"
355 elif has_counter:
356 kind = "hand-rolled-retry"
357 else:
358 continue
359 found.append({"kind": kind, "line": node.lineno})
360 covered.update(id(sub) for sub in ast.walk(node))
361 for node in ast.walk(fn):
362 if (isinstance(node, ast.ExceptHandler) and id(node) not in covered
363 and any(is_sleep(sub, aliases) for sub in ast.walk(node))):
364 found.append({"kind": "hand-rolled-retry", "line": node.lineno})
365 for node in ast.walk(fn):
366 if not isinstance(node, ast.Try):
367 continue
368 calls_target = any(
369 isinstance(sub, ast.Call)
370 and aliases.get(call_root(sub.func)) in KNOWN | STDLIB_TRANSPORTS
371 for stmt in node.body for sub in ast.walk(stmt))
372 if not calls_target:
373 continue
374 for h in node.handlers:
375 if (is_broad_handler(h) and len(h.body) == 1
376 and is_default_return(h.body[0])):
377 found.append({"kind": "silent-swallow", "line": h.lineno})
378 return found
379
380
381def fn_facts(fn, rel, mod, aliases, url_consts):
382 effects = unsafe_idem = t_reads = r_reads = 0
383 targets = set()
384 reasons = []
385 for node in ast.walk(fn):
386 if isinstance(node, ast.Name) and node.id in url_consts:
387 targets.add(url_consts[node.id])
388 elif isinstance(node, ast.Constant) and isinstance(node.value, str):
389 h = host(node.value)
390 if h:
391 targets.add(h)
392 if not isinstance(node, ast.Call):
393 continue
394 lib = aliases.get(call_root(node.func))
395 if lib is None:
396 continue
397 attr = node.func.attr if isinstance(node.func, ast.Attribute) else None
398 name = attr if attr is not None else call_root(node.func)
399 if lib in KNOWN:
400 if is_constructor(name):
401 continue # a handle being built, not an effect performed
402 effects += 1
403 if name in {"post", "patch"}:
404 unsafe_idem += 1
405 if lib in LLM_LIBS:
406 targets.add("llm:" + lib)
407 elif lib == "time" and name in TIME_NAMES:
408 t_reads += 1
409 elif lib == "datetime" and name in DT_NAMES:
410 t_reads += 1
411 elif lib in {"random", "secrets"}:
412 r_reads += 1
413 elif lib == "uuid" and name in UUID_NAMES:
414 r_reads += 1
415 elif lib == "os" and name == "urandom":
416 r_reads += 1
417 elif lib in UNSAFE_LIBS:
418 reasons.append((node.lineno, "%s use at %s:%d" % (lib, rel, node.lineno)))
419 elif lib == "os" and name in OS_UNSAFE:
420 reasons.append((node.lineno, "os.%s at %s:%d" % (name, rel, node.lineno)))
421 return {"effects": effects, "file": rel, "idempotent_unsafe": unsafe_idem,
422 "line": fn.lineno, "module": mod, "name": fn.name,
423 "random_reads": r_reads, "targets": sorted(targets),
424 "time_reads": t_reads,
425 "unsafe_reasons": [t for _, t in sorted(reasons)]}
426
427
428root = sys.argv[1] if len(sys.argv) > 1 else "."
429imports = []
430urls = []
431subprocesses = []
432simplifications = []
433dependency_averse = []
434functions = []
435resilience_libs = set()
436files = 0
437for dirpath, dirnames, filenames in os.walk(root):
438 dirnames[:] = sorted(d for d in dirnames if d not in SKIP and not d.startswith("."))
439 for fn in sorted(filenames):
440 if not fn.endswith(".py"):
441 continue
442 path = os.path.join(dirpath, fn)
443 rel = os.path.relpath(path, root).replace(os.sep, "/")
444 try:
445 with open(path, "r", encoding="utf-8") as fh:
446 src = fh.read()
447 tree = ast.parse(src)
448 except (OSError, SyntaxError, UnicodeDecodeError, ValueError):
449 continue
450 files += 1
451 file_tracked = set()
452 file_stdlib = set()
453 file_urls = []
454 for node in ast.walk(tree):
455 if isinstance(node, (ast.Import, ast.ImportFrom)):
456 for key, _bound in import_entries(node):
457 if key in KNOWN:
458 imports.append({"lib": key, "file": rel, "line": node.lineno})
459 elif key in RESILIENCE_LIBS:
460 resilience_libs.add(key)
461 if key in TRACKED_TRANSPORTS:
462 file_tracked.add(key)
463 elif key in STDLIB_TRANSPORTS:
464 file_stdlib.add(key)
465 elif isinstance(node, ast.Constant) and isinstance(node.value, str):
466 h = host(node.value)
467 if h:
468 file_urls.append({"host": h, "file": rel, "line": node.lineno})
469 for u in file_urls:
470 if file_tracked:
471 u["transport"], u["via"] = "tracked", sorted(file_tracked)[0]
472 elif file_stdlib:
473 u["transport"], u["via"] = "untracked-known", sorted(file_stdlib)[0]
474 else:
475 u["transport"], u["via"] = "unknown", None
476 urls.append(u)
477 mod = rel[:-3].replace("/", ".")
478 if mod.endswith(".__init__"):
479 mod = mod[: -len(".__init__")]
480 aliases = aliases_of(tree)
481 for node in ast.walk(tree):
482 if not isinstance(node, ast.Call):
483 continue
484 lib = aliases.get(call_root(node.func))
485 name = node.func.attr if isinstance(node.func, ast.Attribute) else call_root(node.func)
486 if lib == "subprocess" and name in SUBPROC_NAMES:
487 subprocesses.append({"file": rel, "line": node.lineno,
488 "launcher": "subprocess." + name,
489 "command": argv_text(node),
490 "argv": argv_list(node)})
491 elif lib == "os" and name in ("system", "popen"):
492 subprocesses.append({"file": rel, "line": node.lineno,
493 "launcher": "os." + name,
494 "command": argv_text(node),
495 "argv": None})
496 consts = url_consts_of(tree)
497 for node in tree.body:
498 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
499 facts = fn_facts(node, rel, mod, aliases, consts)
500 functions.append(facts)
501 # Conservative emission gate (WS3 spec): only functions that
502 # also reach a Keel-relevant target get simplification
503 # sightings — a sleep loop around purely local work is none
504 # of Keel's business.
505 if facts["targets"]:
506 for hit in detect_simplifications(node, aliases):
507 simplifications.append({
508 "file": rel, "function": node.name,
509 "kind": hit["kind"], "line": hit["line"],
510 "targets": facts["targets"]})
511
512 # Dependency-averse detection: stdlib-only files with a risk/gate/
513 # guard/auth/valid/safety/kill name or docstring signal, or an
514 # explicit marker. Markers win in both directions: `# keel: exclude`
515 # forces the classification regardless of imports; `# keel: include`
516 # defeats the heuristic even if it would otherwise match. `node.level
517 # == 0` excludes relative imports (`from .x import y`) from counting
518 # as third-party — they are always project-internal.
519 third_party = False
520 for node in ast.walk(tree):
521 mods = []
522 if isinstance(node, ast.Import):
523 mods = [top(a.name) for a in node.names]
524 elif isinstance(node, ast.ImportFrom) and node.level == 0:
525 mods = [top(node.module or "")]
526 if any(m and m not in STDLIB for m in mods):
527 third_party = True
528 break
529 if '# keel: exclude' in src:
530 dependency_averse.append({"file": rel, "reason": "marker"})
531 elif '# keel: include' not in src and not third_party and STDLIB:
532 doc = ast.get_docstring(tree) or ""
533 m = DEP_AVERSE_RE.search(rel.rsplit("/", 1)[-1]) or DEP_AVERSE_RE.search(doc)
534 if m:
535 dependency_averse.append(
536 {"file": rel,
537 "reason": "stdlib-only + name/docstring signal: " + m.group(1).lower()})
538
539subprocesses.sort(key=lambda x: (x["file"], x["line"]))
540simplifications.sort(key=lambda x: (x["file"], x["line"], x["kind"]))
541dependency_averse.sort(key=lambda x: x["file"])
542print(json.dumps({"dependency_averse": dependency_averse, "files_scanned": files,
543 "functions": functions, "imports": imports,
544 "resilience_libs": sorted(resilience_libs),
545 "simplifications": simplifications,
546 "subprocesses": subprocesses, "urls": urls}, sort_keys=True))
547"#;
548
549#[derive(Debug, Deserialize)]
551struct Import {
552 lib: String,
553 file: String,
554 line: u32,
555}
556
557#[derive(Debug, Deserialize)]
559struct Url {
560 host: String,
561 file: String,
562 line: u32,
563 #[serde(default)]
569 transport: Option<String>,
570}
571
572#[derive(Debug, Deserialize)]
574struct PyFunction {
575 effects: u32,
576 file: String,
577 idempotent_unsafe: u32,
578 line: u32,
579 module: String,
580 name: String,
581 random_reads: u32,
582 targets: Vec<String>,
583 time_reads: u32,
584 unsafe_reasons: Vec<String>,
585}
586
587#[derive(Debug, Deserialize)]
589struct WalkerSubprocess {
590 file: String,
591 line: u32,
592 launcher: String,
593 command: String,
594 #[serde(default)]
600 argv: Option<Vec<String>>,
601}
602
603#[derive(Debug, Deserialize)]
606struct WalkerSimplification {
607 file: String,
608 function: String,
609 kind: String,
610 line: u32,
611 targets: Vec<String>,
612}
613
614#[derive(Debug, Deserialize)]
616struct WalkerDepAverse {
617 file: String,
618 reason: String,
619}
620
621#[derive(Debug, Deserialize)]
623struct WalkerOutput {
624 #[serde(default)]
625 dependency_averse: Vec<WalkerDepAverse>,
626 files_scanned: usize,
627 #[serde(default)]
628 functions: Vec<PyFunction>,
629 imports: Vec<Import>,
630 #[serde(default)]
631 resilience_libs: Vec<String>,
632 #[serde(default)]
633 simplifications: Vec<WalkerSimplification>,
634 #[serde(default)]
635 subprocesses: Vec<WalkerSubprocess>,
636 urls: Vec<Url>,
637}
638
639#[derive(Debug, Clone, Default)]
641pub struct PyScan {
642 pub available: bool,
644 pub files_scanned: usize,
646 pub findings: LangFindings,
648 pub functions: Vec<FunctionFacts>,
650}
651
652const HTTP_LIBS: &[&str] = &["httpx", "requests", "aiohttp", "urllib3", "urllib.request"];
653
654fn normalize_lib(lib: &str) -> String {
662 match lib {
663 "google.adk" => "google-adk".to_owned(),
664 "google.genai" => "google-genai".to_owned(),
665 "pydantic_ai" => "pydantic-ai".to_owned(),
666 "agents" => "openai-agents".to_owned(),
667 other => other.to_owned(),
668 }
669}
670
671pub fn scan(project: &Path) -> PyScan {
674 let Some(output) = run_walker(project) else {
675 return PyScan::default();
676 };
677 let mut findings = LangFindings::default();
678 for imp in &output.imports {
679 let lib = normalize_lib(&imp.lib);
680 findings.libs.insert(lib.clone());
681 let sighting = Sighting {
682 file: imp.file.clone(),
683 line: imp.line,
684 };
685 match lib.as_str() {
686 "openai" => findings.llm.push(("openai".to_owned(), sighting)),
687 "anthropic" => findings.llm.push(("anthropic".to_owned(), sighting)),
688 "google-genai" => findings.llm.push(("google-genai".to_owned(), sighting)),
689 lib if HTTP_LIBS.contains(&lib) => findings.http_in_use = true,
690 _ => {}
694 }
695 }
696 if !output.urls.is_empty() {
699 findings.http_in_use = true;
700 }
701 for lib in &output.resilience_libs {
702 findings.resilience_libs.insert(lib.clone());
703 }
704 for sub in output.subprocesses {
705 findings.subprocesses.push(SubprocessSighting {
706 file: sub.file,
707 line: sub.line,
708 launcher: sub.launcher,
709 command: sub.command,
710 argv: sub.argv,
711 });
712 }
713 for s in output.simplifications {
714 findings.simplifications.push(SimplificationSighting {
715 file: s.file,
716 line: s.line,
717 kind: s.kind,
718 function: s.function,
719 targets: s.targets,
720 });
721 }
722 for d in output.dependency_averse {
723 findings.dependency_averse.push(DepAverseFile {
724 file: d.file,
725 reason: d.reason,
726 });
727 }
728 for url in &output.urls {
729 let host = url.host.to_ascii_lowercase();
732 findings.hosts.push((
733 host.clone(),
734 Sighting {
735 file: url.file.clone(),
736 line: url.line,
737 },
738 ));
739 let class = match url.transport.as_deref() {
740 Some("tracked") => TransportClass::Tracked,
741 Some("untracked-known") => TransportClass::UntrackedKnown,
742 _ => TransportClass::Unknown,
743 };
744 findings
745 .host_transports
746 .entry(host)
747 .and_modify(|c| *c = (*c).min(class))
748 .or_insert(class);
749 }
750 let functions = output
751 .functions
752 .into_iter()
753 .map(|f| FunctionFacts {
754 entrypoint: format!("py:{}:{}", f.module, f.name),
755 file: f.file,
756 line: f.line,
757 effects: f.effects,
758 idempotent_unsafe: f.idempotent_unsafe,
759 time_reads: f.time_reads,
760 random_reads: f.random_reads,
761 unsafe_reasons: f.unsafe_reasons,
762 targets: f.targets.into_iter().collect(),
763 })
764 .collect();
765 PyScan {
766 available: true,
767 files_scanned: output.files_scanned,
768 findings,
769 functions,
770 }
771}
772
773fn run_walker(project: &Path) -> Option<WalkerOutput> {
775 let mut child = Command::new("python3")
776 .arg("-")
777 .arg(project)
778 .stdin(Stdio::piped())
779 .stdout(Stdio::piped())
780 .stderr(Stdio::null())
781 .spawn()
782 .ok()?;
783 child.stdin.take()?.write_all(AST_WALKER.as_bytes()).ok()?;
784 let out = child.wait_with_output().ok()?;
785 if !out.status.success() {
786 return None;
787 }
788 serde_json::from_slice(&out.stdout).ok()
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794 use std::fs;
795 use tempfile::TempDir;
796
797 fn python3_present() -> bool {
798 Command::new("python3")
799 .arg("--version")
800 .stdout(Stdio::null())
801 .stderr(Stdio::null())
802 .status()
803 .is_ok_and(|s| s.success())
804 }
805
806 #[test]
817 fn rust_and_walker_http_libs_stay_in_sync() {
818 let line = AST_WALKER
819 .lines()
820 .find(|l| l.trim_start().starts_with("HTTP_LIBS = {"))
821 .expect("walker defines HTTP_LIBS as a set literal");
822 let inner = line
823 .split_once('{')
824 .and_then(|(_, rest)| rest.split_once('}'))
825 .map(|(names, _)| names)
826 .expect("HTTP_LIBS set-literal braces");
827 let walker: std::collections::BTreeSet<&str> = inner
828 .split(',')
829 .map(|s| s.trim().trim_matches('"'))
830 .filter(|s| !s.is_empty())
831 .collect();
832 let rust: std::collections::BTreeSet<&str> = HTTP_LIBS.iter().copied().collect();
833 assert_eq!(
834 walker, rust,
835 "Rust HTTP_LIBS and the embedded walker HTTP_LIBS have drifted (issue #29)"
836 );
837 }
838
839 #[test]
840 fn walks_imports_and_url_literals() {
841 if !python3_present() {
842 eprintln!("skip: python3 not available");
843 return;
844 }
845 let dir = TempDir::new().unwrap();
846 fs::write(
847 dir.path().join("app.py"),
848 "import httpx\nfrom openai import OpenAI\n\nURL = \"https://api.example.com/v1\"\n",
849 )
850 .unwrap();
851 let scan = scan(dir.path());
852 assert!(scan.available);
853 assert_eq!(scan.files_scanned, 1);
854 assert!(scan.findings.http_in_use);
855 assert!(
856 scan.findings
857 .llm
858 .iter()
859 .any(|(p, s)| p == "openai" && s.file == "app.py" && s.line == 2)
860 );
861 assert!(
862 scan.findings
863 .hosts
864 .iter()
865 .any(|(h, s)| h == "api.example.com" && s.line == 4)
866 );
867 }
868
869 #[test]
870 fn urls_are_classified_by_transport() {
871 if !python3_present() {
872 eprintln!("skip: python3 not available");
873 return;
874 }
875 let dir = TempDir::new().unwrap();
876 fs::write(
878 dir.path().join("a.py"),
879 "import httpx\nU = \"https://api.tracked.com/v1\"\n",
880 )
881 .unwrap();
882 fs::write(
884 dir.path().join("b.py"),
885 "import urllib.request\nU = \"https://api.stdlib.com/v1\"\n",
886 )
887 .unwrap();
888 fs::write(
890 dir.path().join("c.py"),
891 "U = \"https://api.mystery.com/v1\"\n",
892 )
893 .unwrap();
894 fs::write(
896 dir.path().join("d.py"),
897 "import http.client\nU = \"https://api.lowlevel.com/v1\"\n",
898 )
899 .unwrap();
900 fs::write(
903 dir.path().join("e.py"),
904 "from urllib import parse\nU = \"https://api.parseonly.com/v1\"\n",
905 )
906 .unwrap();
907 let s = scan(dir.path());
908 let t = |h: &str| s.findings.host_transports.get(h).copied();
909 assert_eq!(t("api.tracked.com"), Some(TransportClass::Tracked));
910 assert_eq!(t("api.stdlib.com"), Some(TransportClass::Tracked));
911 assert_eq!(t("api.mystery.com"), Some(TransportClass::Unknown));
912 assert_eq!(t("api.lowlevel.com"), Some(TransportClass::UntrackedKnown));
913 assert_eq!(t("api.parseonly.com"), Some(TransportClass::UntrackedKnown));
914 }
915
916 #[test]
921 fn urllib_request_import_forms_resolve_to_the_dotted_key() {
922 if !python3_present() {
923 eprintln!("skip: python3 not available");
924 return;
925 }
926 let dir = TempDir::new().unwrap();
927 fs::write(
928 dir.path().join("f1.py"),
929 "import urllib.request\nU = \"https://one.example.com/v\"\n",
930 )
931 .unwrap();
932 fs::write(
933 dir.path().join("f2.py"),
934 "from urllib import request\nU = \"https://two.example.com/v\"\n",
935 )
936 .unwrap();
937 fs::write(
938 dir.path().join("f3.py"),
939 "from urllib.request import urlopen\nU = \"https://three.example.com/v\"\n",
940 )
941 .unwrap();
942 let s = scan(dir.path());
943 assert!(
944 s.findings.libs.contains("urllib.request"),
945 "{:?}",
946 s.findings.libs
947 );
948 for host in ["one.example.com", "two.example.com", "three.example.com"] {
949 assert_eq!(
950 s.findings.host_transports.get(host).copied(),
951 Some(TransportClass::Tracked),
952 "{host}"
953 );
954 }
955 }
956
957 #[test]
958 fn agent_pack_imports_and_the_google_dotted_prefix_are_classified() {
959 if !python3_present() {
960 eprintln!("skip: python3 not available");
961 return;
962 }
963 let dir = TempDir::new().unwrap();
964 fs::write(
965 dir.path().join("app.py"),
966 "import google\n\
967 import google.protobuf\n\
968 from google import protobuf\n\
969 import google.adk\n\
970 from google.genai import types\n\
971 from google import genai, adk\n\
972 from pydantic_ai import Agent\n\
973 import crewai\n\
974 from langgraph.graph import StateGraph\n\
975 from agents import Agent as OpenAIAgent\n\
976 import mcp\n\
977 from tenacity import retry\n",
978 )
979 .unwrap();
980 let scan = scan(dir.path());
981 assert!(scan.available);
982 for lib in [
985 "google-adk",
986 "google-genai",
987 "pydantic-ai",
988 "crewai",
989 "langgraph",
990 "openai-agents",
991 "mcp",
992 ] {
993 assert!(scan.findings.libs.contains(lib), "missing lib: {lib}");
994 }
995 assert!(scan.findings.llm.iter().any(|(p, _)| p == "google-genai"));
997 assert_eq!(
999 scan.findings.resilience_libs,
1000 ["tenacity".to_owned()].into_iter().collect()
1001 );
1002 for leaked in ["google", "google.protobuf", "protobuf"] {
1010 assert!(
1011 !scan.findings.libs.contains(leaked),
1012 "google.protobuf import must not record {leaked}"
1013 );
1014 }
1015 }
1016
1017 #[test]
1018 fn resilience_lib_imports_are_detected_separately_from_known_libs() {
1019 if !python3_present() {
1020 eprintln!("skip: python3 not available");
1021 return;
1022 }
1023 let dir = TempDir::new().unwrap();
1024 fs::write(
1025 dir.path().join("app.py"),
1026 "import httpx\nfrom tenacity import retry\nimport backoff\n",
1027 )
1028 .unwrap();
1029 let scan = scan(dir.path());
1030 assert_eq!(
1031 scan.findings.resilience_libs,
1032 ["backoff".to_owned(), "tenacity".to_owned()]
1033 .into_iter()
1034 .collect()
1035 );
1036 assert!(!scan.findings.libs.contains("tenacity"));
1040 assert!(!scan.findings.libs.contains("backoff"));
1041 assert!(scan.findings.libs.contains("httpx"));
1042 }
1043
1044 #[test]
1045 fn attributes_effects_time_random_to_module_level_functions() {
1046 if !python3_present() {
1047 eprintln!("skip: python3 not available");
1048 return;
1049 }
1050 let dir = TempDir::new().unwrap();
1051 fs::write(
1052 dir.path().join("pipeline.py"),
1053 r#"import time
1054import random
1055import httpx
1056from openai import OpenAI
1057
1058API = "https://api.example.com/v1/data"
1059client = OpenAI()
1060
1061
1062def main():
1063 started = time.time()
1064 seed = random.random()
1065 data = httpx.get(API).json()
1066 httpx.post(API, json=data)
1067 client.responses.create(model="gpt-4.1", input="hi")
1068 return started, seed
1069
1070
1071def helper():
1072 return 41 + 1
1073"#,
1074 )
1075 .unwrap();
1076 let s = scan(dir.path());
1077 let main = s
1078 .functions
1079 .iter()
1080 .find(|f| f.entrypoint == "py:pipeline:main")
1081 .expect("main attributed");
1082 assert_eq!(main.effects, 3);
1084 assert_eq!(main.idempotent_unsafe, 1, "only the POST");
1085 assert_eq!(main.time_reads, 1);
1086 assert_eq!(main.random_reads, 1);
1087 assert!(main.unsafe_reasons.is_empty());
1088 assert!(main.targets.contains("api.example.com"), "URL via constant");
1089 assert!(main.targets.contains("llm:openai"), "client = OpenAI() hop");
1090 assert_eq!((main.file.as_str(), main.line), ("pipeline.py", 10));
1091 let helper = s
1092 .functions
1093 .iter()
1094 .find(|f| f.entrypoint == "py:pipeline:helper")
1095 .expect("helper attributed");
1096 assert_eq!(helper.effects, 0);
1097 }
1098
1099 #[test]
1106 fn reused_handle_name_does_not_leak_its_alias_across_functions() {
1107 if !python3_present() {
1108 eprintln!("skip: python3 not available");
1109 return;
1110 }
1111 let dir = TempDir::new().unwrap();
1112 fs::write(
1113 dir.path().join("clients.py"),
1114 r#"import openai
1115
1116
1117def build():
1118 client = openai.OpenAI()
1119 return client.responses.create(model="gpt-4.1", input="hi")
1120
1121
1122def unrelated():
1123 client = LocalThing()
1124 client.process()
1125 return client
1126
1127
1128def direct():
1129 return openai.responses.create(model="gpt-4.1", input="hi")
1130"#,
1131 )
1132 .unwrap();
1133 let s = scan(dir.path());
1134 let f = |name: &str| {
1135 s.functions
1136 .iter()
1137 .find(|f| f.entrypoint == format!("py:clients:{name}"))
1138 .unwrap_or_else(|| panic!("{name} attributed"))
1139 };
1140 let unrelated = f("unrelated");
1144 assert_eq!(unrelated.effects, 0, "reused name misattributed an effect");
1145 assert!(
1146 !unrelated.targets.contains("llm:openai"),
1147 "reused name leaked the openai target: {:?}",
1148 unrelated.targets
1149 );
1150 let direct = f("direct");
1153 assert_eq!(direct.effects, 1);
1154 assert!(direct.targets.contains("llm:openai"));
1155 assert_eq!(f("build").effects, 0);
1159 }
1160
1161 #[test]
1162 fn threads_and_subprocess_defeat_the_replay_safe_estimate() {
1163 if !python3_present() {
1164 eprintln!("skip: python3 not available");
1165 return;
1166 }
1167 let dir = TempDir::new().unwrap();
1168 fs::write(
1169 dir.path().join("jobs.py"),
1170 r#"import subprocess
1171import threading
1172import requests
1173
1174
1175def risky():
1176 requests.post("https://api.example.com/v1/x")
1177 threading.Thread(target=print).start()
1178 subprocess.run(["ls"])
1179"#,
1180 )
1181 .unwrap();
1182 let s = scan(dir.path());
1183 let f = s
1184 .functions
1185 .iter()
1186 .find(|f| f.entrypoint == "py:jobs:risky")
1187 .expect("risky attributed");
1188 assert_eq!(f.effects, 1);
1189 assert_eq!(
1190 f.unsafe_reasons,
1191 vec![
1192 "threading use at jobs.py:8".to_owned(),
1193 "subprocess use at jobs.py:9".to_owned(),
1194 ]
1195 );
1196 }
1197
1198 #[test]
1199 fn subprocess_launches_are_itemized_with_literal_argv() {
1200 if !python3_present() {
1201 eprintln!("skip: python3 not available");
1202 return;
1203 }
1204 let dir = TempDir::new().unwrap();
1205 fs::write(
1206 dir.path().join("launch.py"),
1207 r#"import subprocess
1208import os
1209
1210def go(cmd):
1211 subprocess.run(["uvx", "alpaca-mcp-server"])
1212 subprocess.Popen(cmd)
1213 os.system("./scripts/kill_switch.sh")
1214"#,
1215 )
1216 .unwrap();
1217 let s = scan(dir.path());
1218 let items: Vec<(&str, &str)> = s
1219 .findings
1220 .subprocesses
1221 .iter()
1222 .map(|x| (x.launcher.as_str(), x.command.as_str()))
1223 .collect();
1224 assert_eq!(
1225 items,
1226 vec![
1227 ("subprocess.run", "uvx alpaca-mcp-server"),
1228 ("subprocess.Popen", "<dynamic>"),
1229 ("os.system", "./scripts/kill_switch.sh"),
1230 ]
1231 );
1232 assert!(
1233 s.findings
1234 .subprocesses
1235 .iter()
1236 .all(|x| x.file == "launch.py")
1237 );
1238 }
1239
1240 #[test]
1241 fn hand_rolled_retry_poll_and_swallow_are_detected() {
1242 if !python3_present() {
1243 eprintln!("skip: python3 not available");
1244 return;
1245 }
1246 let dir = TempDir::new().unwrap();
1247 fs::write(
1250 dir.path().join("poll.py"),
1251 r#"import time
1252import urllib.request
1253
1254API = "https://api.tavily.com/research"
1255
1256def poller(request_id):
1257 while time.time() < 90:
1258 with urllib.request.urlopen(API) as r:
1259 data = r.read().decode()
1260 if data == "completed":
1261 return data
1262 time.sleep(10)
1263 return None
1264"#,
1265 )
1266 .unwrap();
1267 fs::write(
1271 dir.path().join("retry.py"),
1272 r#"import time
1273import urllib.request
1274
1275API = "https://api.alpaca.example/v2"
1276
1277def retryer():
1278 attempts = 0
1279 while True:
1280 try:
1281 return urllib.request.urlopen(API)
1282 except Exception:
1283 attempts += 1
1284 time.sleep(1)
1285"#,
1286 )
1287 .unwrap();
1288 fs::write(
1291 dir.path().join("swallow.py"),
1292 r#"import urllib.request
1293
1294BASE = "https://data.alpaca.example"
1295
1296def fetcher(path):
1297 try:
1298 with urllib.request.urlopen(BASE) as r:
1299 return r.read()
1300 except Exception:
1301 return None
1302"#,
1303 )
1304 .unwrap();
1305 let s = scan(dir.path());
1306 let got: Vec<(&str, &str, &str)> = s
1307 .findings
1308 .simplifications
1309 .iter()
1310 .map(|x| (x.file.as_str(), x.kind.as_str(), x.function.as_str()))
1311 .collect();
1312 assert_eq!(
1313 got,
1314 vec![
1315 ("poll.py", "hand-rolled-poll", "poller"),
1316 ("retry.py", "hand-rolled-retry", "retryer"),
1317 ("swallow.py", "silent-swallow", "fetcher"),
1318 ]
1319 );
1320 let poll = &s.findings.simplifications[0];
1323 assert_eq!(poll.line, 7);
1324 assert_eq!(poll.targets, vec!["api.tavily.com".to_owned()]);
1325 }
1326
1327 #[test]
1328 fn simplification_detection_is_gated_on_target_attribution() {
1329 if !python3_present() {
1330 eprintln!("skip: python3 not available");
1331 return;
1332 }
1333 let dir = TempDir::new().unwrap();
1334 fs::write(
1338 dir.path().join("local.py"),
1339 r"import time
1340
1341def churn():
1342 n = 0
1343 while True:
1344 n += 1
1345 if n > 3:
1346 return n
1347 time.sleep(1)
1348",
1349 )
1350 .unwrap();
1351 fs::write(
1354 dir.path().join("narrow.py"),
1355 r#"import urllib.request
1356import urllib.error
1357
1358API = "https://api.tavily.com/search"
1359
1360def lookup():
1361 try:
1362 return urllib.request.urlopen(API)
1363 except (urllib.error.URLError, ValueError):
1364 return []
1365"#,
1366 )
1367 .unwrap();
1368 let s = scan(dir.path());
1369 assert!(
1370 s.findings.simplifications.is_empty(),
1371 "{:?}",
1372 s.findings.simplifications
1373 );
1374 }
1375
1376 #[test]
1377 fn bare_from_import_sleep_is_still_detected() {
1378 if !python3_present() {
1385 eprintln!("skip: python3 not available");
1386 return;
1387 }
1388 let dir = TempDir::new().unwrap();
1389 fs::write(
1390 dir.path().join("retry.py"),
1391 r#"import httpx
1392from time import sleep
1393
1394API = "https://api.tavily.com/search"
1395
1396def retryer():
1397 attempts = 0
1398 while True:
1399 try:
1400 return httpx.get(API)
1401 except Exception:
1402 attempts += 1
1403 sleep(1)
1404"#,
1405 )
1406 .unwrap();
1407 let s = scan(dir.path());
1408 let got: Vec<(&str, &str)> = s
1409 .findings
1410 .simplifications
1411 .iter()
1412 .map(|x| (x.kind.as_str(), x.function.as_str()))
1413 .collect();
1414 assert_eq!(got, vec![("hand-rolled-retry", "retryer")]);
1415 }
1416
1417 #[test]
1418 fn for_and_async_for_loops_hand_roll_retry_and_poll_directly() {
1419 if !python3_present() {
1424 eprintln!("skip: python3 not available");
1425 return;
1426 }
1427 let dir = TempDir::new().unwrap();
1428 fs::write(
1432 dir.path().join("for_retry.py"),
1433 r#"import time
1434import httpx
1435
1436API = "https://api.tavily.com/search"
1437
1438def retryer():
1439 attempts = 0
1440 for _ in range(5):
1441 attempts += 1
1442 time.sleep(1)
1443 try:
1444 return httpx.get(API)
1445 except Exception:
1446 pass
1447"#,
1448 )
1449 .unwrap();
1450 fs::write(
1453 dir.path().join("async_for_poll.py"),
1454 r#"import asyncio
1455import httpx
1456
1457API = "https://api.tavily.com/search"
1458
1459async def poller():
1460 httpx.get(API)
1461 async for chunk in stream():
1462 if chunk == "done":
1463 return chunk
1464 await asyncio.sleep(1)
1465"#,
1466 )
1467 .unwrap();
1468 let s = scan(dir.path());
1469 let got: Vec<(&str, &str, &str)> = s
1470 .findings
1471 .simplifications
1472 .iter()
1473 .map(|x| (x.file.as_str(), x.kind.as_str(), x.function.as_str()))
1474 .collect();
1475 assert_eq!(
1476 got,
1477 vec![
1478 ("async_for_poll.py", "hand-rolled-poll", "poller"),
1479 ("for_retry.py", "hand-rolled-retry", "retryer"),
1480 ]
1481 );
1482 }
1483
1484 #[test]
1485 fn nested_hand_rolled_loops_are_each_sighted() {
1486 if !python3_present() {
1494 eprintln!("skip: python3 not available");
1495 return;
1496 }
1497 let dir = TempDir::new().unwrap();
1498 fs::write(
1499 dir.path().join("nested.py"),
1500 r#"import time
1501import httpx
1502
1503API = "https://api.tavily.com/search"
1504
1505def retryer():
1506 outer_attempts = 0
1507 while outer_attempts < 3:
1508 inner_attempts = 0
1509 while True:
1510 inner_attempts += 1
1511 try:
1512 return httpx.get(API)
1513 except Exception:
1514 time.sleep(1)
1515 outer_attempts += 1
1516"#,
1517 )
1518 .unwrap();
1519 let s = scan(dir.path());
1520 let got: Vec<(&str, &str)> = s
1521 .findings
1522 .simplifications
1523 .iter()
1524 .map(|x| (x.kind.as_str(), x.function.as_str()))
1525 .collect();
1526 assert_eq!(
1527 got,
1528 vec![
1529 ("hand-rolled-retry", "retryer"),
1530 ("hand-rolled-retry", "retryer"),
1531 ],
1532 "expected today's double-sighting (outer + inner loop both flagged): {got:?}"
1533 );
1534 }
1535
1536 #[test]
1537 fn dependency_averse_files_are_detected_and_markers_win() {
1538 if !python3_present() {
1539 eprintln!("skip: python3 not available");
1540 return;
1541 }
1542 let dir = TempDir::new().unwrap();
1543 fs::write(
1545 dir.path().join("risk_gate.py"),
1546 "\"\"\"Deterministic risk gate. stdlib only.\"\"\"\nimport json, urllib.request\n",
1547 )
1548 .unwrap();
1549 fs::write(
1551 dir.path().join("validate.py"),
1552 "# keel: include\nimport json\n",
1553 )
1554 .unwrap();
1555 fs::write(dir.path().join("app.py"), "# keel: exclude\nimport httpx\n").unwrap();
1557 fs::write(dir.path().join("util.py"), "import json\n").unwrap();
1559 let s = scan(dir.path());
1560 let files: Vec<&str> = s
1561 .findings
1562 .dependency_averse
1563 .iter()
1564 .map(|d| d.file.as_str())
1565 .collect();
1566 assert_eq!(files, vec!["app.py", "risk_gate.py"]);
1567 let app = &s.findings.dependency_averse[0];
1568 assert_eq!(app.reason, "marker");
1569 let gate = &s.findings.dependency_averse[1];
1570 assert!(
1571 gate.reason.contains("risk") || gate.reason.contains("gate"),
1572 "{}",
1573 gate.reason
1574 );
1575 }
1576
1577 #[test]
1578 fn syntax_error_file_is_skipped_not_fatal() {
1579 if !python3_present() {
1580 eprintln!("skip: python3 not available");
1581 return;
1582 }
1583 let dir = TempDir::new().unwrap();
1584 fs::write(dir.path().join("broken.py"), "def (:\n").unwrap();
1585 fs::write(dir.path().join("ok.py"), "import requests\n").unwrap();
1586 let scan = scan(dir.path());
1587 assert_eq!(scan.files_scanned, 1, "only the parseable file counts");
1588 assert!(scan.findings.http_in_use);
1589 }
1590}