Skip to content
ALL WRITING

// build log

What breaks if I change this?

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

min read2,184 wordsupdated 18 September 2026

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.

OpenInvar 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.

A note on the name. This shipped as graphyn and is now OpenInvar. The rename came with a change of purpose: answering questions about code turned out to be the smaller half of the problem, and the useful half was refusing to let a change through. The commands and paths below are the current ones. What the post argues is unchanged — the graph described here is the thing the gates now stand on, and the case study picks up where this leaves off.

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 has the same four files:

code
crates/openinvar-lang/src/lang/typescript/
├── 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 OpenInvar 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

OpenInvar 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: &InvarGraph, 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: &InvarGraph,
    symbol: &str,
    file: Option<&str>,
    depth: Option<usize>,
) -> Result<Vec<QueryEdge>, InvarError> {
    let root = find_symbol_id(graph, symbol, file)?;
    traverse(graph, &root, depth.unwrap_or(DEFAULT_DEPTH), Direction::Incoming)
}
 
pub fn dependencies(/* ... */) -> Result<Vec<QueryEdge>, InvarError> {
    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 SQLite at .openinvar/graph.db, openinvar 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.

It is also what let the gates exist later. A snapshot recorded for one revision can be compared against another without a checkout, which is the whole mechanism behind openinvar audit --base HEAD --head worktree — and comparing two revisions is only meaningful if analysing the same revision twice cannot disagree with itself.

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 OpenInvar 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 this 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
openinvar serve --stdio

get_blast_radius, get_symbol_usages, get_dependencies, graph_diff, check_rules, refresh_graph_index — the identical functions the CLI calls, no second implementation to drift. An agent can check impact before editing rather than discovering it in CI.

MCP is pull, though: the agent has to decide to ask, and the ones that most need the answer are the ones least likely to. That is why the same queries now also run as editor and git hooks, which are push — the blast radius arrives in context at the moment of the edit, and a change that breaks a committed rule does not reach a commit at all.

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
cargo install openinvar-cli
 
openinvar analyze ./my-repo
openinvar query blast-radius UserPayload
openinvar query usages UserPayload
openinvar watch ./my-repo

Full resolution — imports, aliases, declared types — for TypeScript and JavaScript, Python, Rust, Go, C, C++, Java and C#, plus Vue, Svelte and Astro script blocks. Ruby, PHP, Swift and Lua are carried at a lower tier: symbols and references within a single file, nothing across files, and reported as such everywhere they appear so that nothing draws a conclusion from their silence.

Java and C# both arrived at that lower tier and were promoted later — same grammars, same binary, with resolution built behind them. Kotlin is not there yet, which is worth saying plainly given the previous version of this paragraph promised it.

It is Apache-2.0 — if you run it on something large and it gets an answer wrong, that is the bug report I want most.