|
| 1 | +# How we build the call graph — a plain-language tour |
| 2 | + |
| 3 | +A **call graph** answers one question about a codebase: *who calls whom?* |
| 4 | +Every arrow `A → B` means "somewhere inside function A, function B gets |
| 5 | +called." It is the backbone for everything downstream — impact analysis |
| 6 | +("what breaks if I change this?"), security tracing, dead-code hunting. |
| 7 | + |
| 8 | +The hard part in Python is that the language fights you. Functions get |
| 9 | +passed around like values, wrapped in decorators, stored in dicts, glued |
| 10 | +onto classes at runtime. A naive reader misses most of it. |
| 11 | + |
| 12 | +Our analyzer builds the graph in two layers. |
| 13 | + |
| 14 | +## Layer 1: Jedi, the IDE brain |
| 15 | + |
| 16 | +[Jedi](https://github.com/davidhalter/jedi) is the library that powers |
| 17 | +autocomplete in many Python editors. When your editor knows that typing |
| 18 | +`session.` should offer `get`, that is Jedi resolving what `session` is |
| 19 | +and what methods it has. |
| 20 | + |
| 21 | +We run Jedi over every file and ask, for every call it can see: *what |
| 22 | +exactly is being called?* When it knows, we get a precise arrow — |
| 23 | +`Session.request → PreparedRequest.prepare`. This is the **base graph**: |
| 24 | +fast (a fraction of a second once files are parsed) and rarely wrong. |
| 25 | + |
| 26 | +But Jedi is cautious by design. It shrugs at anything dynamic: |
| 27 | + |
| 28 | +```python |
| 29 | +f = handler |
| 30 | +f(x) # Jedi: "no idea what f is" |
| 31 | + |
| 32 | +@lru_cache |
| 33 | +def lookup(...): # Jedi: "that's a... functools._lru_cache_wrapper?" |
| 34 | +``` |
| 35 | + |
| 36 | +Every shrug is a missing arrow. On real code that is a lot of arrows. |
| 37 | + |
| 38 | +## Layer 2: the defuse linker, the detective |
| 39 | + |
| 40 | +For every call Jedi could not resolve, a second pass reads the code the |
| 41 | +way a person would. "Defuse" = *definitions and uses*: find where the |
| 42 | +name being called was **defined**, by walking backwards from where it is |
| 43 | +**used**. It climbs a ladder of tricks, cheapest first: |
| 44 | + |
| 45 | +1. **Follow the name.** `f = handler; f(x)` — walk the assignment chain |
| 46 | + back to `handler`. This respects Python's real scoping rules: |
| 47 | + parameters shadow outer names, class bodies are invisible to methods, |
| 48 | + inner functions see enclosing ones. |
| 49 | +2. **Follow the import.** `from tools import parse` — jump into |
| 50 | + `tools.py` (even through relative imports) and point the arrow at the |
| 51 | + real `parse`. |
| 52 | +3. **Climb the class tree.** `self.save()` — look on the class, then its |
| 53 | + parents, then mixin-style siblings (if a mixin calls `self.send()` |
| 54 | + and exactly one subclass defines `send`, that's the target), then |
| 55 | + imported base classes in other libraries. |
| 56 | +4. **Know the builtins.** `super()`, `len()`, `sorted()` — if nothing in |
| 57 | + scope claims the name and it is a Python builtin, say so. |
| 58 | +5. **Catch import-time work.** Code at the top of a module *runs* when |
| 59 | + the module loads — `logging.getLogger(__name__)`, decorators being |
| 60 | + applied. Those are real calls; we emit them, attributed to the module. |
| 61 | +6. **Catch the calls Python makes for you.** An f-string `f"{x!r}"` |
| 62 | + secretly calls `repr`. A `for` loop secretly calls `__iter__`. |
| 63 | + `TypeError(msg).with_traceback(tb)` calls a method on a temporary. |
| 64 | + We emit those too, because runtime does. |
| 65 | +7. **Do light type detective work.** One bounded round, no guessing |
| 66 | + loops: |
| 67 | + - `w = Widget()` → later `w.tick()` is `Widget.tick`; |
| 68 | + - if every caller passes a `CookieJar` as the second argument, the |
| 69 | + second parameter *is* a `CookieJar` (call sites "vote"); |
| 70 | + - if a function only ever `return Widget()`, whatever holds its |
| 71 | + result is a `Widget`; |
| 72 | + - `self.jar = CookieJar()` in `__init__` types `self.jar` everywhere. |
| 73 | +8. **Last resort: the phone book.** If the receiver is truly unknowable |
| 74 | + (`thing.write(...)` where nothing reveals `thing`), list every |
| 75 | + internal method named `write` as a *possible* target. Over-broad, but |
| 76 | + honest — "one of these" — and it only fires after every smarter tier |
| 77 | + has failed. (This is what Joern does for *all* untyped calls; we do |
| 78 | + it only for the leftovers.) |
| 79 | + |
| 80 | +Every arrow carries a tag (`prov`) saying which layer produced it — |
| 81 | +`jedi`, `defuse`, or both — so a consumer can always tell precise |
| 82 | +resolution from careful reading from the phone book. |
| 83 | + |
| 84 | +One deliberate rule: the linker never writes its answers back into the |
| 85 | +cached analysis. Answers are returned separately, so a cached rerun can |
| 86 | +never mislabel a linker arrow as a Jedi arrow. |
| 87 | + |
| 88 | +## The showdown: Joern and Fraunhofer |
| 89 | + |
| 90 | +Two respected open-source code-analysis platforms build Python call |
| 91 | +graphs the same "fast base + CPG backfill" way: **Joern** and |
| 92 | +**Fraunhofer AISEC's CPG**. We used them as the measuring stick — not by |
| 93 | +reading their docs, but by *running them on the same code and diffing |
| 94 | +the actual edges*, then chasing **every single edge they had and we |
| 95 | +lacked** to its root cause. Each chase ended one of two ways: we fixed |
| 96 | +something, or we proved their edge doesn't correspond to anything real. |
| 97 | + |
| 98 | +That loop ran until we were a superset of everything real: |
| 99 | + |
| 100 | +| corpus | Joern (their real edges) | Fraunhofer | us | |
| 101 | +| --- | --- | --- | --- | |
| 102 | +| requests (~30 files) | 211/212 covered | all real edges covered | 873 edges | |
| 103 | +| flask (~80 files) | 182/190 covered | all real edges covered | ~1,200 edges | |
| 104 | +| odoo (2,364 files) | 99.0% of 28,486 covered | **crashed — out of memory at 44 GB** | **7½ min, 760k edges** | |
| 105 | + |
| 106 | +(For scale: our own previous engine, PyCG, ran **3 hours 19 minutes** on |
| 107 | +that odoo corpus without finishing and produced zero edges. That is why |
| 108 | +it was removed.) |
| 109 | + |
| 110 | +### What about the last 1%? |
| 111 | + |
| 112 | +Every uncovered edge was audited by hand. None survived scrutiny: |
| 113 | + |
| 114 | +- **Edges from calls that don't exist.** Joern claims a function calls |
| 115 | + `append`; the function's source contains no `append`. We checked. |
| 116 | +- **Edges to targets that don't exist.** Fraunhofer emits `None.read` |
| 117 | + and `object.object`, and invents methods on classes that never declare |
| 118 | + them (a method actually inherited from an external library — we point |
| 119 | + at the real one instead). |
| 120 | +- **Different names for the same thing.** odoo does |
| 121 | + `guess_mimetype = _odoo_guess_mimetype`; they point at the alias, we |
| 122 | + point at the actual function. Their `RLock` is our |
| 123 | + `_dummy_threading._RLock` — the class it truly is. |
| 124 | +- **Their internal bookkeeping nodes** — synthetic `<lambda>0`, |
| 125 | + `<metaClass...>`, `<redefined>` entries that are artifacts of their |
| 126 | + graph format, not calls. |
| 127 | + |
| 128 | +### The best part: the chase fixed real bugs |
| 129 | + |
| 130 | +Diffing against two independent tools is a brutal test, and it kept |
| 131 | +catching *our* defects, not just theirs: |
| 132 | + |
| 133 | +- Our symbol table silently **skipped any function defined inside an |
| 134 | + `if` or `try`** — on odoo, whole families of functions simply didn't |
| 135 | + exist in our output. |
| 136 | +- Jedi sometimes "resolves" a call to nonsense — `typing.Callable` for a |
| 137 | + decorated function, `classmethod.__get__` for `cls.helper()`, the |
| 138 | + stdlib's `_warnings.warn` for odoo's own `self._warn`. Those junk |
| 139 | + answers used to block the detective from even trying. |
| 140 | +- Every module-level call to a library function was being dropped by an |
| 141 | + over-eager filter. |
| 142 | + |
| 143 | +## And it's repeatable |
| 144 | + |
| 145 | +Run the analyzer twice on the same code and you get **byte-identical** |
| 146 | +output on the test fixtures, and 99.91% identical on the 2,364-file odoo |
| 147 | +corpus — the tiny remainder traces to a known probabilistic quirk inside |
| 148 | +Jedi's inference (tracked as issue #146), not to anything we built. Same |
| 149 | +input, same graph. That is what makes the output diffable, cacheable, |
| 150 | +and trustworthy in CI. |
0 commit comments