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. 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/// One import finding from the walker.
550#[derive(Debug, Deserialize)]
551struct Import {
552    lib: String,
553    file: String,
554    line: u32,
555}
556
557/// One URL-literal finding from the walker.
558#[derive(Debug, Deserialize)]
559struct Url {
560    host: String,
561    file: String,
562    line: u32,
563    /// `"tracked"` / `"untracked-known"` / absent-or-anything-else = unknown.
564    /// The walker's `via` (which stdlib/tracked lib classified it) is not
565    /// consumed on the Rust side yet — only the class matters for
566    /// `host_transports` — so it is left for serde to ignore rather than
567    /// carried as an unread field.
568    #[serde(default)]
569    transport: Option<String>,
570}
571
572/// One module-level function's facts from the walker.
573#[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/// One subprocess/external-process launch from the walker.
588#[derive(Debug, Deserialize)]
589struct WalkerSubprocess {
590    file: String,
591    line: u32,
592    launcher: String,
593    command: String,
594    /// The literal argv as a positional vector, or `None` when the call is
595    /// not the "list/tuple of string literals, no `shell=True`" shape
596    /// `subprocess_pack.py`'s runtime interceptor requires (issue #41) —
597    /// `argv_list`'s doc explains why this is a stricter, DIFFERENT
598    /// condition than `command`'s "statically extractable at all".
599    #[serde(default)]
600    argv: Option<Vec<String>>,
601}
602
603/// One simplification sighting from the walker (see
604/// [`SimplificationSighting`]).
605#[derive(Debug, Deserialize)]
606struct WalkerSimplification {
607    file: String,
608    function: String,
609    kind: String,
610    line: u32,
611    targets: Vec<String>,
612}
613
614/// One dependency-averse file from the walker (see [`DepAverseFile`]).
615#[derive(Debug, Deserialize)]
616struct WalkerDepAverse {
617    file: String,
618    reason: String,
619}
620
621/// The walker's JSON output, typed.
622#[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/// The Python pass result.
640#[derive(Debug, Clone, Default)]
641pub struct PyScan {
642    /// Whether `python3` ran the walker.
643    pub available: bool,
644    /// Files the walker parsed.
645    pub files_scanned: usize,
646    /// Findings, ready to merge.
647    pub findings: LangFindings,
648    /// Per-function attribution (module-level defs), for `keel flows suggest`.
649    pub functions: Vec<FunctionFacts>,
650}
651
652const HTTP_LIBS: &[&str] = &["httpx", "requests", "aiohttp", "urllib3", "urllib.request"];
653
654/// Normalize a walker-reported import key to the name `keel doctor`'s
655/// `REGISTRY` (see `crate::doctor`) keys its adapters by. The walker emits
656/// raw Python identifiers (`google.adk`, `pydantic_ai`, `agents`); doctor's
657/// registry — and the docs/findings that reference it — use the packs'
658/// PyPI-ish public names (`google-adk`, `pydantic-ai`, `openai-agents`).
659/// Everything else passes through unchanged (`crewai`, `langgraph`, `mcp`,
660/// `httpx`, `openai`, …).
661fn 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
671/// Run the walker over `project`. A missing `python3`, or a walker that fails,
672/// yields an empty unavailable result — never a panic.
673pub 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            // psycopg/boto3/agent-pack libs: recorded as effect libraries via
691            // their DSN/URL literals (if any) or the doctor adapter registry;
692            // no synthetic host target from the import alone.
693            _ => {}
694        }
695    }
696    // A DSN literal (postgres://…) is itself evidence of an outbound call even
697    // without one of the HTTP libraries imported.
698    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        // The walker already returned a bare hostname (urlsplit.hostname), so it
730        // is lowercased and port-stripped; normalize defensively.
731        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
773/// Spawn `python3 - <root>`, feed the walker on stdin, parse its stdout.
774fn 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    /// Issue #29: the tracked-transport set is enumerated in three places —
807    /// this file's Rust [`HTTP_LIBS`], the embedded walker's `HTTP_LIBS` set,
808    /// and `doctor.rs`'s `REGISTRY`. That shape is inherent, not a bug: the
809    /// walker runs out-of-process so its copy must be spelled in real Python,
810    /// the Rust copy classifies the walker's JSON back in-process, and REGISTRY
811    /// is a richer per-adapter table (its `host`-targeted python rows include
812    /// `psycopg`, so it isn't even the same 5-set). None can derive from
813    /// another across the process/semantic boundary — so per the issue we guard
814    /// the pair that lives in THIS file with a consistency test rather than
815    /// collapse it. Pure string-parse, so it runs without `python3`.
816    #[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        // tracked: httpx imported in the same file.
877        fs::write(
878            dir.path().join("a.py"),
879            "import httpx\nU = \"https://api.tracked.com/v1\"\n",
880        )
881        .unwrap();
882        // tracked since WS4: urllib.request is an adapted transport.
883        fs::write(
884            dir.path().join("b.py"),
885            "import urllib.request\nU = \"https://api.stdlib.com/v1\"\n",
886        )
887        .unwrap();
888        // unknown: bare URL, no transport at all.
889        fs::write(
890            dir.path().join("c.py"),
891            "U = \"https://api.mystery.com/v1\"\n",
892        )
893        .unwrap();
894        // untracked-known: http.client is still unadapted.
895        fs::write(
896            dir.path().join("d.py"),
897            "import http.client\nU = \"https://api.lowlevel.com/v1\"\n",
898        )
899        .unwrap();
900        // untracked-known: bare urllib (parse only) is not the adapted
901        // submodule — still just "stdlib urllib in reach".
902        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    /// WS4: every import form that puts `urllib.request` in reach resolves to
917    /// the dotted lib key `urllib.request` (the google.adk precedent) — it
918    /// lands in `libs` (so doctor's REGISTRY row reports detected) and makes
919    /// same-file URL sightings tracked.
920    #[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        // The six agent-framework packs + the two google submodules, all
983        // normalized to doctor's REGISTRY names.
984        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        // google.genai joins the llm findings, alongside openai/anthropic.
996        assert!(scan.findings.llm.iter().any(|(p, _)| p == "google-genai"));
997        // tenacity is resilience, not a coverage-relevant lib.
998        assert_eq!(
999            scan.findings.resilience_libs,
1000            ["tenacity".to_owned()].into_iter().collect()
1001        );
1002        // Bare `google` (the namespace package) must record NOTHING — it
1003        // also hosts unrelated distributions (google-protobuf and friends).
1004        // Neither must `import google.protobuf` nor `from google import
1005        // protobuf`: `protobuf` isn't one of Keel's two adapted submodules
1006        // (`GOOGLE_SUBMODULES = {"adk", "genai"}`), so both forms must fall
1007        // through to the same "namespace package, not evidence" handling —
1008        // never surfacing as `google`, `google.protobuf`, or `protobuf`.
1009        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        // Resilience libs never pollute `libs` (which feeds doctor's
1037        // "invisible/unadapted effect library" coverage classification —
1038        // tenacity/backoff were never adapter candidates).
1039        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        // get + post + create — the chained .json() must NOT double-count.
1083        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    /// Issue #29: `aliases_of` builds one flat, file-wide binding map, so a
1100    /// generic handle name (`client`, `session`, `resp`) bound to a tracked
1101    /// library in one function and then REUSED for an unrelated purpose in
1102    /// another used to keep its stale mapping — misattributing the second
1103    /// function's calls to the first library. A non-known reassignment now
1104    /// invalidates the stale entry.
1105    #[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        // The bug: `unrelated` reuses `client` for a non-openai handle and must
1141        // NOT inherit build()'s openai attribution (before the fix it did:
1142        // effects == 1, targets == ["llm:openai"]).
1143        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        // Import bindings themselves are never invalidated — a direct
1151        // `openai.<call>` in a third function is still attributed.
1152        let direct = f("direct");
1153        assert_eq!(direct.effects, 1);
1154        assert!(direct.targets.contains("llm:openai"));
1155        // Documented flat-dict tradeoff (characterization): invalidating the
1156        // shared name also clears build()'s own handle, so build loses its
1157        // attribution rather than risk the collision — conservative by design.
1158        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        // Poll: the fetch_short_metrics.py:79-95 shape — while + sleep +
1248        // status-string comparison. The `while` is on line 7 of this file.
1249        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        // Retry: loop + manually incremented attempt counter + sleep (the sleep
1268        // sits in the except handler INSIDE the loop — must yield ONE sighting
1269        // anchored at the loop, not a second one at the handler).
1270        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        // Swallow: the screen.py:376-398 shape — broad except, single default
1289        // return, urlopen in the try body.
1290        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        // Anchor line: the poll sighting points at the `while` (line 7), the
1321        // construct to delete — not the sleep inside it (line 12).
1322        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        // Identical retry shape, but the function reaches NO target — the
1335        // conservative gate must suppress it (a sleep loop around local work is
1336        // none of Keel's business).
1337        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        // Narrow except (specific exception tuple, the web_search.py shape) with
1352        // a target in reach — NOT a silent swallow.
1353        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        // `from time import sleep` binds `sleep` -> "time" in `aliases`
1379        // (import_entries tracks module -> binding); a direct `sleep(1)`
1380        // call (an `ast.Name`, not `ast.Attribute`) resolves `name =
1381        // call_root(node.func) = "sleep"`, matching `is_sleep` without the
1382        // `time.` prefix. Issue #28 item 4: "verified correct by
1383        // inspection, untested" — this pins it.
1384        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        // Coverage beyond `ast.While`: the primary loop-classification pass
1420        // (not the except-handler fallback) must also fire for `for` and
1421        // `async for` loops when the sleep/counter (or sleep/status-compare)
1422        // sit directly in the loop body.
1423        if !python3_present() {
1424            eprintln!("skip: python3 not available");
1425            return;
1426        }
1427        let dir = TempDir::new().unwrap();
1428        // `for`: counter + sleep directly in the loop body (not nested in
1429        // an except handler) — exercises the `ast.For` arm of the primary
1430        // classification match.
1431        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        // `async for`: a status-string compare governing a `return` (the
1451        // poll shape) — exercises the `ast.AsyncFor` arm.
1452        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        // Characterization test (issue #28 item 4): a hand-rolled loop
1487        // nested inside another loop is currently "double-sighted" — the
1488        // outer loop's own subtree walk also contains the inner loop's
1489        // sleep/counter, so BOTH the outer and inner `while` independently
1490        // satisfy the detection predicate and each produce a finding for
1491        // what a human would call one construct. This pins today's actual
1492        // behavior; it is not an assertion that double-sighting is ideal.
1493        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        // stdlib-only + docstring signal -> detected.
1544        fs::write(
1545            dir.path().join("risk_gate.py"),
1546            "\"\"\"Deterministic risk gate. stdlib only.\"\"\"\nimport json, urllib.request\n",
1547        )
1548        .unwrap();
1549        // stdlib-only + name signal, but explicit include marker -> NOT detected.
1550        fs::write(
1551            dir.path().join("validate.py"),
1552            "# keel: include\nimport json\n",
1553        )
1554        .unwrap();
1555        // third-party import + no signal, but explicit exclude marker -> detected.
1556        fs::write(dir.path().join("app.py"), "# keel: exclude\nimport httpx\n").unwrap();
1557        // plain file -> not detected.
1558        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}