Ripwire: A Deterministic Call-Graph Primer for Coding Agents
What ripwire actually does under the hood: zero dependencies, full repo parse, ranked subgraph
ripwire is a single C++23 binary that delivers something most of us assumed required a small infrastructure project: repository-level context for coding agents, computed deterministically, on demand, with no daemon, no vector store, no embeddings, and no external indexer running in the background. The "no" list is the headline here. There's no API key to rotate, no GPU to provision, no index to rebuild when you pull new code, no process watching your filesystem. You invoke it, it does its work, it exits.
The mechanics are clean. ripwire vendors tree-sitter grammars directly into the binary, covering 21+ languages, and on each invocation it parses the repository's files, builds a symbol table (functions, methods, classes, with their file and line locations), and constructs a call graph from call sites to definitions. It then applies Personalized PageRank, seeded with keywords extracted from your --for prompt, and outputs the top-K nodes and edges that fit within a token budget, serialized as an XML snippet. The agent receives that snippet as structured context, not a dump of raw matching lines.
It runs on demand via CLI or through MCP, so whatever agent you're using — Claude Code, Cursor, Aider, or a custom harness — can invoke it as a tool. That's the "ripgrep" positioning: fast, single-purpose, composable, with no state to maintain. Unlike ripgrep, though, it's not just matching strings; it's reasoning about structure. Caching is keyed to the commit tree, so repeated invocations against the same commit reuse the parse, but dirty working-tree changes or uncommitted edits will trigger a fresh parse.
The output format is deliberately constrained. Token-budgeted XML means the agent gets the most relevant subgraph, not the whole graph, and the relevance is computed deterministically — same repo, same prompt, same ranking. No probabilistic similarity thresholds, no non-determinism from embedding models. That reproducibility matters in an agent context: when you're debugging why an agent made a change, you want to know exactly what context it saw, and with ripwire that context is reproducible by re-running the command.
The tradeoff embedded in this design is worth naming upfront. It sacrifices the fuzzy semantic comprehension that vector embeddings provide for precise structural knowledge. It knows exact callers and callees, file boundaries, and symbol relationships — things that chunked vector retrieval often gets wrong because chunk boundaries slice through AST hierarchy. It doesn't know what "rate limiter" means conceptually if the code calls it TrafficShaper. But for the common case — "what does this function affect, and what tests cover it" — structural precision beats semantic approximation.
```mermaid
graph TD
U[User prompt: --for 'incremental cache invalidation'] --> W[ripwire CLI invoked]
W --> P[Parse repo files with vendored tree-sitter grammars]
P --> S[Build symbol table: functions, classes, file/line]
S --> C[Build call graph: caller → callee edges]
C --> R[Personalized PageRank: seed nodes from prompt keywords]
R --> F[Filter top-K nodes under token budget]
F --> X[Serialize XML subgraph]
X --> A[Output to agent via CLI]
X --> M[Output to MCP client]
## Why call-graph context beats embeddings for code changes, and when it falls short
When an agent is tasked with modifying code, the critical question isn't "which chunks of text are semantically similar to my prompt?" — it's "what code will break if I change this function, and which tests cover it?" ripwire answers that with a call graph. A call graph tells you the actual callers of a function, its callees, and the file boundaries. That's impact analysis, not similarity search. For "find every caller of `process_order` and understand what a signature change would break," a call graph is precise. Embeddings will return chunks that *contain* `process_order` or chunks that are *semantically related* to order processing, but they won't guarantee you've found every caller, especially if a caller uses a variable named `handle_po` that the embedding model doesn't associate strongly enough with "order processing."
There's also a determinism advantage. Embedding retrieval is probabilistic; the same query against the same index can return different results depending on model version, chunking strategy, or similarity threshold. For agents, that means non-reproducible behavior. If an agent makes a change you need to audit, you want to know what context it saw. With ripwire, you re-run the command and you get the same graph.
But here's where I'd push back on the "embeddings are obsolete" framing. Vague conceptual queries are genuinely a sweet spot for vector retrieval. If you're asking "where is authentication handled?" and the codebase calls it `SessionManager`, `AuthService`, or `TokenValidator` across different modules, embeddings will often surface those files because the semantic space connects "authentication" to those concepts. ripwire's lexical keyword matching might seed on "authentication," find `auth.js`, miss `SessionManager` entirely if the prompt doesn't contain those words, and produce a weak graph.
The token budget truncation is another failure mode. In a large monolith, Personalized PageRank concentrates probability mass on highly connected utilities — `logger`, `string_utils`, `base_handler`, `config`. Those nodes get ranked high because they're referenced everywhere. The algorithm doesn't discriminate between "common utility" and "domain-critical for this change." If your budget is tight, the true downstream domain logic that you actually need to see might get pruned because it's less connected globally, even though it's directly relevant to the prompt. I'd watch for that in monorepos especially.
And there are structural blind spots. Dynamic dispatch, reflection, metaprogramming — languages with `getattr()`, `eval()`, dependency injection containers, or macro expansion will produce call graphs with false negatives. A call edge the parser couldn't see statically simply won't exist. Cross-language FFI boundaries break the graph into disconnected components.
```mermaid
sequenceDiagram
participant User as Engineer/Agent
participant C as Coding Agent (Claude Code, Cursor, etc.)
participant R as ripwire CLI
participant M as MCP Client (if applicable)
User->>C: "Find all callers of process_order and assess impact"
C->>R: Invoke (CLI or MCP): --for 'process_order'
R->>R: Parse repo with tree-sitter
R->>R: Build symbol table & call graph
R->>R: Run Personalized PageRank (seeded on 'process_order')
R->>R: Fit top-K nodes under token budget
R-->>C: XML subgraph (callers, callees, files)
C->>C: Reason about impact, plan change
C-->>User: Proposed code modification
Note over R,M: MCP client receives same XML, pushes to agent
| Criterion | ripwire (AST/call-graph) | Vector RAG (embedding similarity) | LSP/SCIP (compiler-accurate) |
|---|---|---|---|
| Dependencies | Zero runtime deps; single C++23 binary, vendored grammars | Embedding model, vector DB, API keys or local daemon | Full language toolchain + runtime (JDK, Rust, etc.) |
| Setup overhead | Instant; CLI/MCP on demand | High; background re-indexing, chunking design | Very high; long index pipelines, memory-heavy |
| Determinism | 100% deterministic; reproducible | Probabilistic; depends on model & thresholds | Deterministic |
| Call graph quality | High; ranked callers, reachable tests | Poor; chunks lack relational structure | Very high; compiler-accurate references |
| Fuzzy/concept matching | Moderate; lexical keyword anchoring | High; handles synonyms, vague queries | Low; requires symbol awareness |
| Token efficiency | High; structured token-budgeted XML | Moderate to low; returns chunked text | Variable |
| Use case sweet spot | Local-first, CI, privacy-conscious, structural impact analysis | Concept discovery when names are unknown | Compiler-grade refactoring, polyglot type resolution |
How ripwire constructs the graph and ranks nodes: tree-sitter, symbol resolution, Personalized PageRank
The graph construction pipeline is straightforward and entirely deterministic. For each file in the repo, ripwire invokes the vendored tree-sitter grammar for that language, producing a concrete syntax tree (CST). It then walks the CST, extracting symbols — functions, methods, classes, interfaces — with their scope and location. Each symbol becomes a node in the graph, carrying its file path, line number, and type. This is a syntactic pass, not semantic. It's not calling a compiler type checker; it's reading the parse tree.
Edition of edges is the more interesting piece. For each call site identified in the CST (a function invocation, a method call, a constructor), ripwire looks up the callee's symbol name across the global symbol table. If multiple symbols share the same name — two process() methods in different classes — it makes a best-effort resolution, which may be wrong because there's no type information to disambiguate. The edge is then created from the caller node to the callee node. This is where dynamic dispatch, reflection, macros, and FFI produce gaps. The call graph is a syntactic approximation of the true program structure, which is exactly what you'd expect from a tool that needs zero runtime dependencies and no compiler.
Once the graph is built, ranking happens via Personalized PageRank. The --for prompt is tokenized, and keywords that match symbol names in the graph become seed nodes. A deterministic subset of that probability mass is distributed to those seeds. Then the standard PageRank iteration runs: each node distributes its probability mass to its neighbors — both callers and callees — with a damping factor (typically ~0.85) that allows mass to escape cycles and propagate through the graph. Utilities like logger or string_utils accumulate high probability because they're connected to many nodes, so after the iteration they'll rank high globally.
The final step is serialization. The top-K nodes and edges that fit within the token budget are emitted as XML, structured so the agent can parse it cleanly: nodes with symbol names, files, line numbers; edges representing caller→callee relationships. The XML format is deliberate: it's machine-parseable, compact, and doesn't invite the agent to treat it as a code block.
A concrete example makes this tangible. Suppose the prompt is --for='process_order'. If the symbol exists, it becomes a seed node. PageRank spreads probability from process_order to its direct callers (e.g., checkout_handler, api_controller) and its callees (e.g., validate_payment, update_inventory, generate_receipt). Those, in turn, spread to their own connections. The result is a weighted subgraph centered on order processing logic. If token budget is tight, the ranking might favor validate_payment because it's a direct callee with high connectivity, but prune a downstream module like inventory_reverter if it's a caller-of-a-caller with lower PageRank score. That's the tradeoff: importance is computed structurally, not semantically, and truncation can lose far-reaching impact.
graph TD
A[For each file in repo] --> B[Tree-sitter parses CST]
B --> C[Extract symbols: functions, classes, with file/line]
C --> D[Symbol table built]
D --> E[For each call site in CST]
E --> F[Look up callee name in global symbol table]
F --> G[Create edge: caller → callee]
G --> H[Call graph complete]
H --> I[Personalized PageRank]
I --> J[Query keywords match symbol names]
J --> K[Seed nodes get probability mass]
K --> L[Mass propagates through callers & callees]
L --> M[Damping factor applied per iteration]
M --> N[Top nodes under token budget]
N --> O[Serialize to XML: nodes, edges, files, lines]
Ripwire in practice: repo cache, dirty tree, and submodule gotchas
The caching design is where ripwire's operational realities start to bite. It keys its parse cache to the git commit tree, which is smart — it means repeated invocations against the same commit reuse the AST parse and graph build almost instantly. But that's a commit-tree key, not a file-hash key. If you're working with a dirty working tree — uncommitted edits, new files not yet staged, deletions — ripwire sees a mismatch between the actual filesystem state and the cached commit state. The safe behavior is a fresh parse; the risk is stale cache if the invalidation logic is conservative and doesn't catch every dirty-file case. My rule: if you're running ripwire in an agent loop, treat the cache as trustworthy only from a clean tree.
Submodules and generated files are the bigger practical concern. Vendored code is poison for the PageRank graph. If node_modules, vendor/, build/, dist/, or .proto generated bindings get parsed and their symbols enter the graph, they become high-centrality nodes almost by definition — node_modules contains thousands of interconnected functions that will soak up probability mass and push your actual domain logic out of the top-K. Configure exclusion patterns aggressively. I'd also watch .gitignore itself: ripwire may respect it, but if you have build outputs that are committed or tracked, they need explicit rules.
Monorepo scale is the other operational reality. The first parse on a repository with tens of thousands of files takes seconds — not minutes, but not instant either. Subsequent runs against the same commit are fast, but any dirty-tree state or a new commit means a full reparse. In an agent loop where ripwire is invoked repeatedly during a single session, that's fine. In a CI pipeline where each run starts from a fresh checkout, you pay the parse cost every time. There's no daemon holding a warm cache, so plan for that latency budget.
Three projects you can build with ripwire right now
The most immediately useful thing you can build is a monorepo change impact analyzer. A CLI that takes a module name or function symbol, calls ripwire with it, and parses the XML output to extract nodes and edges. From there, you construct a reverse dependency map—walking edges backward from the target node to find every direct and indirect caller—and render it as a simple text UI: a tree of affected files with the blast radius clearly visible. The value is speed: you get the answer in seconds without grepping the entire repo or paging through a giant codebase mentally. The watch item is token budget. In a large monorepo, PageRank's connectivity bias can prune deep call chains that are actually critical, leaving you with a blast radius that's technically correct but practically incomplete. If you're using this for release risk assessment, raise the budget or filter to a subgraph and cross-check dynamic dispatch targets against compiler output when the stakes are high.
Second, a CI test selection bot is a natural fit. A GitHub Action that detects changed files in a PR, extracts the changed symbols, and calls ripwire with those symbols. The graph's reachable test files—tests that reference the changed functions directly or transitively—become the candidate test set, and the bot invokes only those via your test runner, skipping the full suite. The savings compound on large repos. The failure mode: if your changed code uses reflection, dynamic dispatch, or macros, ripwire will miss the test files that exercise those behaviors, and you'll ship a PR that skipped critical tests. Add a fallback—if uncertainty is high, run the full suite. And always run ripwire against a clean checkout in CI; a dirty tree with uncommitted changes will invalidate the cache and produce a fresh parse, which is fine, but a stale cache from a previous commit is a subtle failure I'd rather avoid entirely.
Third, an MCP server wrapping ripwire turns it into a first-class agent tool. The server accepts natural language prompts, calls ripwire with --for, parses the XML, and returns the ranked subgraph as structured context to the agent. The tricky design decision: keyword mismatch. If the agent asks about "rate limiting" and the code calls it TokenBucketThrottler, ripwire's lexical seeding will produce a weak graph. Design the server to accept exact symbol queries as an explicit tool parameter, not just free text, and add a ripgrep fallback when ripwire returns low-confidence results. Large repo latency is the other consideration—the first parse on a big monorepo takes seconds. Consider a persistent process that keeps the parsed state warm between invocations, rather than spawning a fresh binary each time and paying the parse cost repeatedly.
Ripwire's place in the agent toolchain: when to go beyond it
The right mental model is layers, not replacements. Ripwire is the fast, deterministic first pass: it gives you the map—callers, callees, test reachability, file boundaries—cheaply and reproducibly. That map tells you where to look, and it's often enough for a simple change. But it's a map, not the territory. The moment you need to actually modify code, you have to verify against the ground truth of the compiler or type checker.
I'd reach for ripwire, then layer LSP or compiler diagnostics on top for anything that touches type resolution. If you're changing a function signature, ripwire tells you which files might be affected, but it can't tell you whether a caller is actually passing a compatible type. That's a compiler question. Similarly, if your codebase relies heavily on interfaces, dependency injection, or runtime dispatch, ripwire's syntactic approximation will miss edges that a compiler-accurate reference graph would catch.
The other layer is semantic, for vague discovery. When you don't know what a system is called, or you're exploring unfamiliar territory, embeddings genuinely excel. A question like "where is billing logic handled?" might map to InvoiceService, PaymentController, or LedgerManager across different modules. Lexical keyword matching will miss most of those. I'd use vector retrieval for that kind of open-ended discovery, then hand off to ripwire once you know a concrete symbol to anchor on.
The final layer is tests. The agent's natural instinct is to reason about code. The hard reality is that logic can be wrong in ways that are structurally sound but semantically broken. Running tests catches that. So my workflow: ripwire for the map, compiler for type verification, embeddings for when you're lost, and always the test suite. Ripwire reduces the token budget for context ingestion—that's its real contribution. It doesn't substitute for understanding.
Resources
Updated 2026-09-05 by Mehran Mozaffari.
Related posts
8 September 2026
diagram-design: What Actually Happens When Your Agent Draws Instead of Compiles
3 September 2026
FFmpeg Skill: The Deterministic Control Plane for Media-Specific AI Agents
27 August 2026
Herdr Keeps Coding Agents Running Across Lids, Reboots, and SSH Hops: How the Client-Server Split Actually Works
27 August 2026
Stringing Skills Together: A Field Guide to Jeffrey's Skills.md (jsm)
27 August 2026
The Standard of Completion: How Factory's Three-Role Agent System Rebuilt gdal to 90 Percent Parity
21 August 2026
The Boss Sign-Off Gate: What agency-agents-zh's Orchestrator Actually Gets Right and Wrong About Human Checkpoints