Skip to content
ALL WRITING

// build log

What breaks if I change this?

Grep answers a question nobody asked. Building graphyn meant deciding what a relationship between two symbols actually is — and living with that definition across seven languages.

10 min read1,904 words

You are about to rename a field on a type used across a repository you did not write all of. So you do what everyone does:

code
rg "UserPayload"

And you get a list of lines. Some are call sites. Some are comments. One is a string in a log message. One is a different UserPayload in a vendored SDK. And the ones that matter most — the file that did import { UserPayload as ResponseModel } four months ago — are not in the list at all, because that file never spells the name you searched for.

Text search finds mentions. The question was about consequences. Those are not the same question, and the gap between them is where I have spent the last several months.

graphyn is my attempt to close it: a Rust engine that turns a repository into a deterministic symbol graph, so that "what breaks if I change this?" has an answer you can compute instead of estimate.

This is what I learned building it. Most of it is not about parsing.

Parsing is the easy part

I want to get this out of the way, because it is the part everyone assumes is hard.

Parsing is solved. tree-sitter gives you a concrete syntax tree for any language with a grammar, it is error-tolerant, and it is fast enough that scanning a large repository is bounded by disk rather than by the parser. Standing up a new language adapter takes an afternoon.

Standing up a correct one takes considerably longer, and the reason has nothing to do with grammar. Every adapter in graphyn has the same four files:

code
crates/graphyn-adapter-ts/src/
├── parser.rs           tree-sitter → syntax tree
├── extractor.rs        syntax tree → symbols and relationships
├── scope_analyzer.rs   which binding does this name refer to?
└── import_resolver.rs  which file does this module specifier mean?

parser.rs is the shortest file in every one of them. import_resolver.rs is usually the longest, because "which file does @/lib/user mean" is a question about tsconfig.json, barrel re-export chains, ESM extension rules, and the particular lie your bundler tells — none of which appear in the syntax tree at all.

The real work of code intelligence is not reading source. It is deciding what the source means, and then committing to that meaning consistently enough that an answer is worth something.

A symbol needs a name that survives

Before you can relate two symbols you need to be able to say which two. That sounds trivial. It is the single decision the rest of the system is built on.

code
/// Format: "relative/file/path.ts::SymbolName::kind"
/// Example: "src/models/user.ts::UserPayload::class"
pub type SymbolId = String;

File path, symbol name, kind. Three components, one separator, and every one of those choices was made twice.

The path is repo-relative so that the same repository produces the same ids on my machine, in CI, and inside your editor. The kind is in there because a function and a class can share a name in most of these languages, and a graph where they collide silently reports the wrong blast radius.

The separator is where it got interesting. :: reads naturally — right up until you index a Rust or C++ codebase, where :: occurs inside paths and names. Parsing an id back apart stops being unambiguous:

code
/// File paths may contain `::` on no platform we support, but symbol names can
/// (`Trait::method` in a Rust signature), so the name is taken as everything
/// between the first and last separator.
pub fn parse_symbol_id(id: &str) -> Option<(&str, &str, &str)> {
    let first = id.find("::")?;
    let last = id.rfind("::")?;
    if last <= first {
        return None;
    }
    Some((&id[..first], &id[first + 2..last], &id[last + 2..]))
}

First separator and last separator, everything between is the name. It handles Trait::method correctly and it is not elegant.

There is a second class of id that never survives to the graph. Extractors run per file, in parallel, with no knowledge of any other file — so when the TypeScript extractor sees import { UserPayload } from "./models", it cannot know what that resolves to. It emits a placeholder and moves on:

code
/// - `unresolved_import|<module>|<symbol>` — an import awaiting resolution
/// - `unresolved_local_type|<type>` — a type reference awaiting local lookup

Note the separator changed. Placeholders use |, because | cannot occur in an identifier or a module path in any language graphyn parses, and placeholders must round-trip exactly — the resolver reads them back apart to do its job. The resolved form tolerates ambiguity because a human reads it; the intermediate form does not, because a machine does.

That distinction was originally not made at all. Each adapter minted its own ids, the TypeScript one used | for placeholders while the newer adapters used ::, and the spellings drifted apart until a Rust import resolved to a symbol that did not exist. Now there is exactly one module that knows how an id is spelled, and every adapter goes through it.

Centralize identity before you centralize anything else. If two components disagree about what a thing is called, nothing downstream can be right, and nothing downstream will tell you why.

What is a relationship?

Here is the question I underestimated most. Two symbols are related — fine. How?

code
pub enum RelationshipKind {
    Imports,
    Calls,
    Extends,
    Implements,
    UsesType,
    AccessesProperty,
    ReExports,
    Instantiates,
}

Eight kinds, and each one is a claim about the world that has to hold in seven languages. Extends means single inheritance in TypeScript, base classes in C++, struct embedding in Go, and nothing at all in C. Either the model absorbs that variation or every query has to know which language it is looking at.

The edge itself carries more than a direction:

code
pub struct Relationship {
    pub from: SymbolId,
    pub to: SymbolId,
    pub kind: RelationshipKind,
    pub alias: Option<String>,
    pub properties_accessed: Vec<String>,
    pub context: String,
    pub file: String,
    pub line: u32,
}

alias and properties_accessed are the two fields that make the difference between a graph and a search index.

Alias. import { UserPayload as ResponseModel } creates an edge to UserPayload whose local name is ResponseModel. Both facts have to survive, because the target is what makes the edge correct and the local name is what makes it findable by a human reading the output.

Properties accessed. An edge that records ["user_id", "email"] is a much stronger statement than "this file uses that type." Renaming created_at does not affect a consumer that only ever reads email. Without member-level attribution every field change has the blast radius of a type change, the tool cries wolf, and people stop reading its output — which is a worse failure than being wrong occasionally, because it is permanent.

Every adapter attributes member access to the type a value was declared as, so payload.user_id is recorded against UserPayload however the local variable was named.

The bug that taught me about noise

Graphyn labels part of its output HIGH RISK: the references that reach a symbol under a different name, which are precisely the ones text search cannot find. The check looked obviously right.

code
if edge.alias.is_some() {
    // renamed — flag it
}

Except adapters record the local name on type-reference edges whether or not it differs from the target's name. So an ordinary same-name reference carries alias: Some("UserPayload"), and every single reference in the repository got flagged HIGH RISK. The genuine renames — the three edges the feature exists to surface — were buried in a list of four hundred.

code
/// True when `edge` refers to its target under a name other than the target's.
pub fn is_renamed(graph: &GraphynGraph, edge: &QueryEdge) -> bool {
    let Some(alias) = edge.alias.as_deref() else {
        return false;
    };
    match graph.symbols.get(&edge.to) {
        // A qualified reference such as `models.UserPayload` names the symbol
        // directly; only the final segment is compared.
        Some(symbol) => alias.rsplit(['.', ':']).next().unwrap_or(alias) != symbol.name,
        // Unknown target: an alias is the only name we have, so treat it as one.
        None => true,
    }
}

Three cases, and the middle one is the whole fix: a qualified reference like models.UserPayload or crate::models::UserPayload names the symbol directly, so only the segment after the last separator is compared.

The lesson is not about aliases. It is that a tool whose job is to reduce uncertainty fails completely the moment its output requires triage. The implementation was doing what it was told. What it was told described a different question than the one users had.

The query is the small part

After all of that, blast radius is a breadth-first traversal:

code
const DEFAULT_DEPTH: usize = 3;
const MAX_DEPTH: usize = 10;
 
pub fn blast_radius(
    graph: &GraphynGraph,
    symbol: &str,
    file: Option<&str>,
    depth: Option<usize>,
) -> Result<Vec<QueryEdge>, GraphynError> {
    let root = find_symbol_id(graph, symbol, file)?;
    traverse(graph, &root, depth.unwrap_or(DEFAULT_DEPTH), Direction::Incoming)
}
 
pub fn dependencies(/* ... */) -> Result<Vec<QueryEdge>, GraphynError> {
    let root = find_symbol_id(graph, symbol, file)?;
    traverse(graph, &root, depth.unwrap_or(DEFAULT_DEPTH), Direction::Outgoing)
}

Blast radius and dependencies are the same traversal with the edge direction flipped. Incoming edges are the things that would break. Outgoing edges are the things that could break you. That symmetry is not a clever trick — it is what you get for free once the graph is built correctly, and it is the payoff for every decision above.

The depth cap is a product decision wearing a constant's clothing. Depth 3 covers the honest answer to "what breaks"; by depth 6 you have transitively reached most of the repository and the result stops carrying information. 10 is a hard ceiling because an unbounded traversal on a dense graph is a hang, and a tool that hangs gets uninstalled.

Determinism is a feature, not a property

No LLM participates in graph construction. The same repository produces the same graph, every time, on every machine.

This is not purism. If a blast radius is probabilistic, you cannot act on it. "These 14 call sites will break" is a thing you can work through. "These 14 call sites will probably break, and there may be others" is a thing you have to verify manually — at which point you have done the work anyway and the tool saved you nothing.

Determinism is also what makes the graph cacheable. It persists to RocksDB under .graphyn/db, graphyn watch updates it incrementally as files change, and the incremental result is required to be identical to a clean rebuild. That invariant is the reason incremental analysis is trustworthy rather than merely fast.

Being wrong out loud

Every adapter has limits, and I put them in the README rather than in an issue tracker nobody reads:

  • Imports resolve within one language. A Python module importing a TypeScript file through a build step is not linked.
  • Chained access is attributed to the first receiver only. In a.b.c, the c is not attributed to the type of a.b — that needs field-type resolution.
  • C++ templates are parsed but not instantiated. vector<Foo> records a reference to Foo; it does not model what instantiation generates.
  • Go structural matching is per-package. Matching every method set against every interface repository-wide produces far more noise than signal.
  • Rust macro bodies are token trees. Field access inside format! is recovered by scanning tokens; elaborate macro-generated code is not expanded.

The diagnostics are structured for the same reason — an unresolved import is a Warning in the Resolution category, a tree-sitter failure is an Error in Parse, a skipped minified file is Info in Skip. You can ask the tool what it could not see.

An analysis tool that hides its blind spots is worse than one with more of them, because you cannot calibrate against it. If graphyn says a symbol has no consumers, you need to know whether that means "nothing uses it" or "it is used from a language I do not link."

Why agents changed the priority

I started graphyn as a refactoring aid for myself. The thing that moved it up my list was watching coding agents work.

An agent changes code quickly and confidently. What it lacks is not competence at editing — it is context about consequences. It reads the file it was pointed at, makes a locally correct change, and cannot know that a service three directories away depends on the field it renamed under a different name.

That is exactly the gap. So the same queries are exposed over MCP:

code
graphyn serve --stdio

blast_radius, symbol_usages, dependencies, refresh_graph — the identical functions the CLI calls, no second implementation to drift. An agent can check impact before editing rather than discovering it in CI.

There is something fitting about a deterministic tool being most useful as a grounding layer for a probabilistic one. The agent is good at deciding what to change. The graph is good at knowing what that touches. Neither is good at the other's job, and the interesting systems are increasingly the ones that admit that.

Try it

code
curl -fsSL https://raw.githubusercontent.com/JeelGajera/graphyn/master/install.sh | bash
 
graphyn analyze ./my-repo
graphyn query blast-radius UserPayload
graphyn query usages UserPayload
graphyn watch ./my-repo

TypeScript, JavaScript, Python, Rust, Go, C, and C++ today, plus Vue, Svelte, and Astro script blocks. Java and Kotlin next.

It is Apache-2.0 and an active experiment — the scanning and graph construction are stable enough to build on, and the query surface is still moving. If you run it on something large and it gets an answer wrong, that is the bug report I want most.