M03 Β· Parsing Code into Graphs with tree-sitter
Learning objectives Intermediate
- Explain what an abstract syntax tree (AST) is and why graph extractors parse structure instead of reading text.
- Describe the three properties that made tree-sitter the industry-standard parser for code graphs: incremental parsing, fault tolerance, and speed.
- Write a working extractor that pulls function definitions and call edges out of a real Python file β in both Python and Node.js.
- Assign the provenance tags EXTRACTED / INFERRED / AMBIGUOUS correctly, and explain the resolution ladder (heuristics β LSP β honest ambiguity).
- Predict where structural parsing gets harder: dynamic languages, dynamic dispatch, and per-language visibility rules.
In M01 you built a graph by hand and queried it. In M02 you watched text-similarity retrieval fail on questions about structure. This module answers the obvious next question: where do real code graphs come from? The answer is not an LLM reading your files. It is a parser β and understanding that parser is what separates people who trust their graph from people who should not.
What an AST is β and why extractors start there
Before: imagine handing a friend a printed novel and asking, "list every character who speaks to every other character." Your friend has to read all 400 pages, guess who "he" refers to in each line of dialogue, and keep a growing list on a napkin. Slow, error-prone, and they have to redo it for every new question.
The pain: the information was always in the book, but it was buried in prose. Prose is optimized for reading front-to-back, not for answering structural questions like "who talks to whom."
The mapping: a screenplay of the same story solves this instantly β every line is labeled CHARACTER: dialogue, scenes are numbered, and stage directions are marked. An abstract syntax tree is the screenplay version of your source code: the same content, reorganized into an explicit, labeled structure where "find every function call" is a lookup, not a reading comprehension exercise.
An abstract syntax tree (AST) is a tree data structure that represents the grammatical structure of source code. Each node in the tree is a labeled construct β a module at the root, then function_definition, class_definition, call, identifier, string, and so on. "Abstract" means the tree drops details that don't affect meaning, like exact whitespace and most punctuation β it keeps the grammar, not the typography.
A parser is the program that turns flat text into that tree. When this course says a tool "parses code structurally," it means: the tool asks the tree "give me every node labeled call inside every node labeled function_definition" β it never pattern-matches raw text the way grep does.
Here is why this matters for knowledge graphs specifically. In M01 you learned that a code graph is nodes (functions, classes, modules) plus typed edges (calls, imports, inherits). Every one of those node and edge types corresponds exactly to an AST node type. A function_definition node becomes a graph node. A call node inside it becomes an outgoing edge. An import_statement becomes an imports edge. The graph is not "derived" from the AST in some fuzzy way β it is a straightforward relabeling of things the parser already found.
Contrast that with the two alternatives you already know. grep "verify_token" finds the string verify_token β in calls, in comments, in docstrings, in a variable named verify_token_backup, with no idea which is which. An embedding search (M02) finds text that talks like verify_token. Only the AST knows the difference between defining a function, calling it, and mentioning it in a comment β because those are three different node types in the tree.
Static description: the four lines of verify_token source become a tree β a module root, a function_definition child with its name, a block, and two labeled call nodes (AuthError, decode_jwt). Call sites are explicit nodes in the tree, so extraction is a lookup rather than a text search.
The orderflow repo from the labs has 14 modules and 22 call edges. grep -rn "execute" across it returns matches in 5 files including comments and docstrings; you'd read all five to learn who really calls DatabasePool.execute. The AST route returns exactly 3 caller nodes, labeled, with line numbers β and on a 10,000-file monorepo the arithmetic is the difference between an agent reading 2.8 million tokens of files and querying a pre-built graph (that gap, measured on VS Code's repo, is 78% of all tokens β you'll see the full benchmark table in M05).
Meet tree-sitter: the parser every graph tool chose
tree-sitter is an open-source parsing library originally developed at GitHub for the Atom editorGitHub's extensible text editor (2014β2022). tree-sitter was built to give it fast, accurate syntax highlighting; the editor is gone, the parser became an industry standard., and now serving as NeovimA modern fork of the Vim editor. Its syntax highlighting and code navigation are powered by tree-sitter grammars.'s core syntax engine. Graphify runs it across 36 language grammars. CodeGraph uses it for every language it indexes. okf-rs embeds it in a native Rust core. When independently-built tools all converge on one dependency, the dependency is doing something hard well. Three things, specifically.
1. It parses incrementally
tree-sitter was designed to re-parse a file as you type, reusing the untouched parts of the previous tree. Edit one function in a 2,000-line file and it rebuilds only the subtree that changed. For an editor this means keystroke-speed highlighting. For a knowledge graph it means something better: watch mode is cheap. When CodeGraph's file watcher (M05) sees you save one file, re-indexing is proportional to the edit, not the codebase. This property is what makes "the graph stays current automatically" architecturally plausible at all.
2. It is fault-tolerant
This is the property that matters most, and the one traditional compiler frontends lack. A compiler parser meets one syntax error and gives up β that's fine for a compiler, whose job is to refuse bad programs. tree-sitter instead produces a partial AST: the broken region becomes an ERROR node island, and everything around it parses normally.
Why does a graph tool care? Because real development never happens in a permanently compilable state. At any moment in a working repository, someone's branch has a half-written function, a missing bracket, a merge-conflict marker. A parser that refused those files would leave holes in the graph exactly where the most active development is happening β the code your agent is most likely to be asked about. Fault tolerance means one broken function costs you one ERROR node, not a whole file's worth of nodes and edges.
3. It is fast
tree-sitter grammars compile to C, and parsing runs at millions of lines per minute. That matters because initial indexing is the one unavoidable full-corpus pass: CodeGraph's codegraph init -i on a 100,000-file repository takes about ten minutes β bounded by parsing speed and disk I/O β and that's a one-time cost precisely because everything after it is incremental.
"The parser understands what my code means." β No. tree-sitter knows grammar, not semantics. It knows decode_jwt(token) is a call expression with one argument; it has no idea what a JWT is, whether the call is safe, or even β in the general case β which decode_jwt in a large project this name refers to. Meaning stays with the LLM; structure comes from the parser.
"Parsing needs the code to compile." β The opposite is the point. Fault tolerance means the tree is produced even when the code cannot compile; broken regions degrade to ERROR nodes locally instead of failing the file.
"An LLM could just extract this structure instead." β It could, at a price: tokens for every file on every pass, plus a nonzero hallucination rate on the one layer of your stack that can be exact. The next section makes this the central design argument.
Extraction queries: from tree to graph
A parse tree is not yet a graph. The second half of every extractor is a set of extraction queries β language-specific patterns that walk the tree and pull out two kinds of things:
- Symbols (graph nodes): functions, classes, methods, interfaces β anything with a name and a location worth pointing at.
- Edges (relationships): calls, imports, inheritance, interface implementations.
"Language-specific" is doing real work in that sentence. A Python function is a function_definition node; in Go it's a function_declaration; a Ruby method call can omit parentheses entirely. Every language needs its own grammar and its own extraction queries β which is why tools advertise language support as a headline number (Graphify: 36 grammars; okf-rs: 11 languages; CodeGraph: deepest coverage in TypeScript, Python, Rust, and Go). The grammar gives you the tree; the queries decide what in the tree becomes knowledge.
Static description: an extraction query cursor sweeps down four tree rows (function_definition, its name, a call node, an import). As it passes, two graph nodes appear on the right (verify_token, decode_jwt) and a gold calls edge connects them.
Here is what one extracted record actually looks like β the concrete artifact behind all this vocabulary. This is a single edge from the lab extractor you'll run later, in the exact shape Graphify's graph.json uses:
{
"source": "function:shared.auth.verify_token",
"target": "function:shared.auth.decode_jwt",
"type": "calls",
"provenance": "EXTRACTED"
}Four fields. The first three you know from M01. The fourth β provenance β is the subject of the resolution ladder below, and it is the most honest field in the whole file.
Determinism: the structural pass cannot hallucinate
Before: think about two ways to inventory a warehouse. Way one: walk the aisles with a barcode scanner β every scan is a fact, and scanning the same warehouse twice produces the same list. Way two: ask a very well-read consultant to describe from memory what's probably in the warehouse.
The pain: the consultant is genuinely useful for questions like "what should we stock next quarter?" β but if you ask for the current inventory, you'll get a plausible list with a few confident inventions in it, and you won't know which entries those are.
The mapping: AST extraction is the barcode scanner. An LLM reading your code is the consultant. The entire design bet of Graphify, CodeGraph, and okf-rs is: use the scanner for the inventory (structure), save the consultant for judgment (meaning) β and never let the consultant write inventory rows.
Three consequences follow, and each one shows up as a concrete, checkable property of these tools:
First: zero API cost for the structural pass. Parsing needs no LLM calls β no API key, no tokens, no rate limits. Graphify's own logs say it plainly: Re-extracting code files in . (no LLM needed).... On a pure-code repository, building the entire graph is free. Only the optional semantic pass β docs, PDFs, images β costs tokens, and only that pass can hallucinate.
Second: zero hallucination in extracted edges. An EXTRACTED edge exists because a call node existed in a parse tree. There is no probability attached, nothing to "verify." This is the zero-hallucination structural extraction that Graphify's benchmark analysis identifies as one of its two real advantages (the other being ingest cost β M04 covers both honestly).
Third: reproducibility. okf-rs takes this furthest as an explicit guarantee: identical source produces byte-identical output β no timestamps, no unordered-map noise leaking into the result. That sounds like pedantry until you put a graph in CI: byte-identical output means a graph diff in a pull request shows exactly and only what the code change changed. A tool that shuffles its output every run makes graph diffs useless.
Put M02 and M03 side by side. A vector-RAG answer to "who calls decode_jwt?" is a similarity guess that might surface an old docstring. The structural answer is a parse-tree fact. When Graphify's own benchmarks show its retrieval accuracy tying dense vector RAG (76% vs 76% on LongMemEval-S β full numbers in M04), determinism is precisely what it's buying you instead: not better recall, but answers that are facts with provenance, produced for $0 in API calls.
The resolution ladder: EXTRACTED β INFERRED β AMBIGUOUS
The parse tree tells you, with certainty, that some function named publish is called on line 27 of webhooks.py. It does not tell you which publish. Your project might define one in shared/events.py, another in a vendored SDK, and a third on a class. Connecting a call site to the right definition is name resolution, and it is where structural parsing stops being trivially exact.
Name resolution is the step that binds a name at a use site (a call like publish(...)) to the declaration it refers to (the def publish in a specific module). Compilers do this with full type information. Structural extractors work with less β so honest ones grade their own confidence, tagging every edge:
- EXTRACTED β the binding is explicit in source (the definition is in scope, unambiguous β e.g., defined in the same module). Parse-tree fact; 100% confidence.
- INFERRED β resolved by heuristic (exactly one definition with that name exists project-wide, so it's probably the target). Correct most of the time; a guess by construction.
- AMBIGUOUS β multiple candidate definitions match; the honest move is to emit all candidates, labeled, and let the consumer decide.
Three forces push edges down the ladder, and you should be able to name all three:
Dynamic languages. In Python or JavaScript, "types are merely suggestions" β nothing in the tree says what type self.gateway is, so self.gateway.charge() could resolve to any class with a charge method. Extractors fall back to name matching, which is exactly the heuristic INFERRED describes.
Dynamic dispatch. A call through an interface, a callback passed as an argument, a handler registered in a table (subscribe("payment.settled", send_receipt) from orderflow is a perfect example) β the real call happens at runtime through a variable. Static extraction sees the registration but not the eventual invocation. This is a known blind spot: dynamic dispatch stays invisible, and no static tool escapes it.
Name collisions. Two modules both define execute. Bare-name matching now has two candidates. Down to AMBIGUOUS.
The middle rung nobody should skip: asking a language server
Between "cheap heuristic" and "give up" there is a third option: ask the program that already solves resolution for your editor. A language serverA background process implementing the Language Server Protocol (LSP) β the same thing that powers go-to-definition in VS Code. rust-analyzer (Rust) and pyright (Python) are the reference examples. performs full semantic analysis; okf-rs exposes this as okf-rs generate --lsp, which resolves project-wide ambiguous names by asking the real language server (rust-analyzer, pyright) via the LSP request textDocument/definition β verified end-to-end against real servers, with timeout handling and correct behavior on paths containing spaces or non-ASCII characters. The tradeoff is the ladder's whole shape: each rung up buys accuracy with startup time and complexity. Heuristics are instant; an LSP takes seconds to warm up and needs project configuration; and anything still unresolved gets the honest label instead of a confident wrong edge.
Static description: a call site execute(...) tries rung 1 (bare-name heuristic β two candidates, inconclusive), climbs to rung 2 (ask the pyright language server via textDocument/definition β resolves to shared.db.execute), and rung 3 (emit AMBIGUOUS with all candidates) is shown as the honest fallback if the LSP also fails.
"INFERRED means broken." β No; it means graded. In the lab extractor's run over orderflow, 13 of 22 edges are INFERRED and every one happens to be correct. The tag exists so a consumer (or an agent) can choose its confidence floor β recall from M01 that blast-radius queries can run EXTRACTED-only for high-stakes questions.
"The LSP rung makes everything EXTRACTED." β It resolves what static heuristics can't, but dynamic dispatch is still invisible: a handler invoked through an event table is a runtime fact, and no amount of static analysis sees it.
"More edges = better graph." β A tool that guesses aggressively produces more edges and more wrong edges, unlabeled. Prefer the tool that tells you which answers are facts and which are guesses; it's the only one whose graph you can audit.
Walk it, step by step
One file, all the way from source text to call edges. The last step is the one that matters most: what the parser refuses to guess, and why an AMBIGUOUS edge is worth more than a confident wrong one.
Languages and visibility: where "public API" comes from
One more extraction detail matters for the narrative layers you'll build in Track 3: deciding which symbols are a module's public API. That sounds universal; it's actually one of the most language-specific judgments an extractor makes, because every language community encodes visibility differently:
| Convention | Languages | What the extractor checks |
|---|---|---|
| Explicit opt-in | Rust (pub), Java (public) | a keyword on the declaration node |
| Opt-out by default | PHP, Kotlin | everything public unless marked otherwise |
| Capitalization-based | Go | Execute exported, execute package-private β the first letter is the modifier |
| Section-based | C++ | position under a public:/private: label |
okf-rs implements exactly this per-language table across its 11 languages (Rust, Python, TypeScript, JavaScript, Go, Java, C#, PHP, Kotlin, C/C++, Swift). Graphify's 36 grammars and CodeGraph's coverage (deepest in TypeScript, Python, Rust, Go; Objective-C explicitly partial) each embody the same kind of judgment. The takeaway when evaluating any tool: "supports language X" is a spectrum β grammar-level parsing is the easy part; correct visibility rules, call conventions, and module-system semantics are where support is deep or shallow. Projects on less common languages should test before adopting.
Code walkthrough: a real extractor in ~60 lines
Time to hold the whole pipeline in your hands: parse shared/auth.py from orderflow with tree-sitter, walk the tree, and emit graph nodes and provenance-tagged edges. Pick your language β the two tabs implement the same logic with the official bindings.
Chunk 1 β parse the file. What: load the Python grammar and produce a tree. Why: everything downstream is tree lookups. Gotcha: tree-sitter wants bytes, not str β its node offsets are byte offsets, which matters the moment a file contains a non-ASCII character.
import tree_sitter_python
from tree_sitter import Language, Parser
parser = Parser(Language(tree_sitter_python.language()))
try:
source = open("sample-project/shared/auth.py", "rb").read()
except OSError as exc:
raise SystemExit(f"cannot read source: {exc}")
tree = parser.parse(source)
print(tree.root_node) # the AST as an S-expressionChunk 2 β collect the definitions. What: walk the tree once, recording every function_definition by name. Why: this is the candidate list that name resolution matches against β you can't grade a call's provenance until you know what's defined where. Notice there is no regex anywhere: we ask nodes for their type, and slice names out of the source by the byte offsets the parser hands us.
def node_text(node) -> str:
return source[node.start_byte:node.end_byte].decode("utf-8", "replace")
defs = {} # name -> line number
stack = [tree.root_node]
while stack:
node = stack.pop()
if node.type == "function_definition":
name_node = node.child_by_field_name("name")
if name_node is not None:
defs[node_text(name_node)] = name_node.start_point[0] + 1
stack.extend(node.children)
print(f"definitions: {defs}")
# definitions: {'decode_jwt': 16, 'verify_token': 40}Chunk 3 β extract calls and grade them. What: a second walk finds every call node inside each function and applies the resolution ladder's bottom rung. Why: a call whose target is in our own defs table resolves locally (EXTRACTED here, since this is a single module); a known-name-elsewhere would be INFERRED; unknown names are external and skipped. Gotcha: a call's function child can be a bare identifier (decode_jwt(...)) or an attribute (hmac.new(...)) β handle both or you silently drop every method call in the file.
def call_name(call_node):
fn = call_node.child_by_field_name("function")
if fn is None: return None
if fn.type == "identifier": return node_text(fn)
if fn.type == "attribute": # obj.method(...) -> "method"
attr = fn.child_by_field_name("attribute")
return node_text(attr) if attr else None
return None
edges = []
stack = [tree.root_node]
current_fn = None
def walk(node, owner):
if node.type == "function_definition":
name_node = node.child_by_field_name("name")
owner = node_text(name_node) if name_node else owner
if node.type == "call" and owner:
target = call_name(node)
if target in defs: # resolves inside this module
edges.append((owner, target, "EXTRACTED"))
# unknown names are stdlib/third-party: skipped, not guessed
for child in node.children:
walk(child, owner)
walk(tree.root_node, None)
for src, dst, prov in edges:
print(f"{src} -> {dst} [{prov}]")
# verify_token -> decode_jwt [EXTRACTED]Chunk 1 β parse the file. Same shape as the Python tab: load the grammar, read bytes, get a tree. The npm packages are tree-sitter and tree-sitter-python (we're parsing Python source from Node β the grammar and the host language are independent choices).
import Parser from "tree-sitter";
import Python from "tree-sitter-python";
import { readFileSync } from "node:fs";
const parser = new Parser();
parser.setLanguage(Python);
let source;
try {
source = readFileSync("sample-project/shared/auth.py", "utf8");
} catch (err) {
console.error(`cannot read source: ${err.message}`);
process.exit(1);
}
const tree = parser.parse(source);
console.log(tree.rootNode.toString()); // S-expression ASTChunk 2 β collect definitions. Identical walk, JavaScript idiom: an explicit stack, checking node.type, reading names via the field accessor.
const defs = new Map();
const stack = [tree.rootNode];
while (stack.length) {
const node = stack.pop();
if (node.type === "function_definition") {
const nameNode = node.childForFieldName("name");
if (nameNode) defs.set(nameNode.text, nameNode.startPosition.row + 1);
}
stack.push(...node.children);
}
console.log("definitions:", defs);
// definitions: Map { 'decode_jwt' => 16, 'verify_token' => 40 }Chunk 3 β extract and grade calls. Same ladder-bottom logic; note the same gotcha β call nodes hold either an identifier or an attribute in their function field.
function callName(callNode) {
const fn = callNode.childForFieldName("function");
if (!fn) return null;
if (fn.type === "identifier") return fn.text;
if (fn.type === "attribute")
return fn.childForFieldName("attribute")?.text ?? null;
return null;
}
const edges = [];
function walk(node, owner) {
if (node.type === "function_definition")
owner = node.childForFieldName("name")?.text ?? owner;
if (node.type === "call" && owner) {
const target = callName(node);
if (target && defs.has(target))
edges.push([owner, target, "EXTRACTED"]);
}
for (const child of node.children) walk(child, owner);
}
walk(tree.rootNode, null);
edges.forEach(([s, d, p]) => console.log(`${s} -> ${d} [${p}]`));
// verify_token -> decode_jwt [EXTRACTED]You built the front half of Graphify. One pass collected the definition table (the graph's nodes); a second pass found every call site and resolved it against that table, emitting a provenance-tagged edge when it matched and skipping external names instead of guessing. No LLM was involved, no API key exists in this file, and running it twice produces identical output. Scale this to 36 grammars, add import/inheritance queries, persist to graph.json β that's the whole structural layer, and you now know exactly which parts of it are facts.
Hands-on lab
π Get the files: labs/M03-tree-sitter-extraction on GitHub β or clone the whole course once: git clone https://github.com/varasrinivas/knowledge-graph-course.git
The full exercise lives in labs/M03-tree-sitter-extraction/ β do it now while the walkthrough is fresh. It runs in four steps:
- Parse one file β
shared/auth.py, print the S-expression, find thefunction_definitionnodes. (Expected: a(module ...)expression.) - Prove fault tolerance β delete a closing parenthesis from a copy, re-parse, and observe the
ERRORnode island while every other function still parses. This is the step that should permanently change how you think about "the code doesn't compile." - Extract defs and calls β the walkthrough code, extended with the full grading rules: same-module unique match β EXTRACTED; unique match elsewhere β INFERRED; multiple β AMBIGUOUS (emit all); zero β external, skip.
- Compare with the fallback β run
python ../shared_tools/kg_extract.py ../sample-project(the course's stdlib-astextractor) and diff itsshared.authedges against yours. Both must agree onverify_token β decode_jwt (EXTRACTED).
Stretch goal: time both parsers on 1,000 synthetic copies of auth.py. The gap you measure is why Graphify and CodeGraph can index tens of thousands of files in minutes.
This entire lab costs $0 in API calls β by design, and the design is the lesson. If a "code understanding" tool asks for an API key before it can tell you who calls a function, you now know to ask what, exactly, it needs an LLM for.
Knowledge check
1. Why is tree-sitter's fault tolerance essential for knowledge-graph extraction specifically?
2. A call edge is tagged INFERRED. What does that tell you?
3. Why can the structural pass not hallucinate?
4. What does okf-rs generate --lsp add over bare tree-sitter extraction?
5. orderflow's worker.py registers subscribe("payment.settled", send_receipt). What will static extraction see?
subscribe itself β but the eventual runtime invocation of send_receipt through the event table stays invisible; dynamic dispatch is a known blind spot of all static toolspublish to send_receipt6. In Go, how does an extractor decide a function is part of the public API?
public keyword on the declarationExecute) means exported; lowercase means package-private β visibility rules are language-specific judgments the extractor must encodepublic: above the functionModule summary
AST = the screenplay
Source code reorganized into labeled structure; graph nodes/edges are relabeled tree nodes. Structure is a lookup, not a search.
tree-sitter won for 3 reasons
Incremental (cheap watch mode), fault-tolerant (partial ASTs on broken code), fast (C-compiled grammars; one-time init cost).
Determinism is the product
Zero LLM calls, zero hallucination in EXTRACTED edges, byte-identical reruns (okf-rs) β the structural pass is free and auditable.
The resolution ladder
EXTRACTED (fact) β INFERRED (heuristic) β AMBIGUOUS (all candidates, labeled). LSP is the middle rung; dynamic dispatch stays invisible.
What we built on orderflow: a working tree-sitter extractor that finds decode_jwt and verify_token, grades the edge between them EXTRACTED, and agrees with the course's stdlib fallback extractor.
Next module preview: you've built a toy extractor; M04 hands you the production one. Graphify wraps everything from this module β 36 grammars, Leiden community detection, god-node identification β behind one command, and produces three artifacts you'll learn to read critically. Bring skepticism: M04 is also where we put the famous "71.5Γ" benchmark next to its replications.
References
- tree-sitter documentation β tree-sitter.github.io (grammars, incremental parsing, query language)
- py-tree-sitter and
tree-sitter-pythonpackages (PyPI);tree-sitter+tree-sitter-python(npm) - Graphify repository β 36-grammar AST extraction, EXTRACTED/INFERRED tagging
- okf-rs repository β github.com/jyjeanne/okf-rs (deterministic output, LSP-backed disambiguation, per-language visibility rules)
- Course lab:
labs/M03-tree-sitter-extraction/and fallback extractorlabs/shared_tools/kg_extract.py