Skip to content

Commit 2ee845b

Browse files
committed
docs: plain-language tour of the Jedi + defuse-linker call graph
How the two layers build the graph, the resolution-tier ladder, the Joern/Fraunhofer validation story with the audited exception classes, and the determinism guarantees — written for readers with light PL background.
1 parent 914c6ee commit 2ee845b

1 file changed

Lines changed: 150 additions & 0 deletions

File tree

docs/defuse-linker-explained.md

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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

Comments
 (0)