The problem
This project began as graphyn, and the question it set out to answer was
"what breaks if I change this symbol?" Text search finds mentions; the question
was about consequences. Building a deterministic symbol graph closed that gap,
and the graph worked.
Then the way code gets written changed underneath it.
An agent opens a pull request. It compiles. The suite is green. And one of the
tests no longer asserts what it used to — the assert_eq! became a call with
its result dropped, so the test still runs, still references the symbol, and
checks nothing. Coverage stayed green because the edge is still there. Review
sees a diff that looks like a cleanup.
Answering questions about that repository was no longer the useful thing. The useful thing was refusing to let the change through. So the project was renamed and repointed: the graph is now the substrate, and the product is the gate on top of it.
What it does
Two commands gate, and both exit non-zero, which is the entire integration story — no plugin, no wiring.
openinvar audit compares two revisions and reports changes that look like they
were made to pass a check rather than to work:
| Detector | Signal | Severity |
|---|---|---|
test-tampering | A test stopped covering a symbol that changed in the same diff | error |
assertion-removal | A test kept covering a changed symbol, but lost assertions | error |
contract-erosion | A symbol was removed while a surviving caller still referred to it | error |
dead-on-arrival | A new symbol only tests refer to | warn |
openinvar check enforces the constraints the repository wrote down in a
committed openinvar.toml — layering, forbidden dependencies, field stability,
import cycles, feature independence, fan-in ceilings, and requires-test:
[[rule]]
name = "crate-layering"
kind = "layers"
layers = ["crates/openinvar-cli/**", "crates/openinvar-core/**"]
[[rule]]
name = "new-api-is-covered"
kind = "requires-test"
symbols = "src/api/**"
new_only = true # judge only what this change addednew_only is the flag that makes the rule adoptable. Every symbol you added is
covered is enforceable on a repository that could never pass every symbol is
covered — the second is a coverage project, not a gate.
Why determinism came first
No model participates in graph construction or in any gating decision. Identical input produces identical output, byte for byte.
This is not purism, and it is not a performance argument. You cannot gate CI on
an opinion. A tool that answers "what breaks if I change this" probabilistically
can advise, but it cannot block a merge, because a check that returns a different
answer on a re-run is a check nobody can act on — the first time it disagrees
with itself, somebody adds --no-verify to the pipeline and it is over.
Everything else in the design is downstream of that one commitment.
The shape
Five crates, and the boundary between them is enforced by openinvar check
running against openinvar.toml in its own CI:
| Crate | Responsibility |
|---|---|
openinvar-core | scan, IR, symbol identity, resolution, graph, queries, rules, audit |
openinvar-lang | every language: parser, extractor, scope, import resolver, dispatch |
openinvar-store | SQLite persistence, revision snapshots, caching under .openinvar/ |
openinvar-mcp | the same queries, exposed as tools to agents |
openinvar-cli | analyze, audit, check, diff, tests, report, status, watch, serve |
The per-language adapters used to be one crate each plus a dispatch crate. They
are now modules inside openinvar-lang, which was the right correction: the
crate split bought no isolation that the module system did not already give, and
cost a Cargo.toml and a version bump every time a language was added.
Publishing the blind spots
An enforcement tool that reports what it could not resolve is worth more than
one implying completeness, so openinvar status publishes coverage on every run.
Two numbers, because one of them flatters:
Resolved 99.8% (5980 of 5992 edge(s))
References bound 69.6% (5980 of 8597 reference(s))
2617 reference(s) named in source bound to nothing in the graph.The first answers "can a gate act on what is in the graph". The second answers "how much of the source is in the graph", and it is the honest one — an edge only exists once something bound it, so failing to bind more references makes the first percentage go up. Measured across four repositories nobody here wrote, the second number lands between 50% and 66%.
Publishing it is the point. A tool that gates CI should not be the only party who knows where its blind spots are.
Tiers, and gates that fail open
Languages are Tier 1 or Tier 2, and the tier is reported wherever a symbol from it appears.
Tier 1 — full import resolution, alias tracking, and declared-type binding: TypeScript and JavaScript (plus Vue, Svelte and Astro script blocks), Python, Rust, Go, C, C++, Java and C#. Tier 2 — symbols and references within a single file, nothing across files: Ruby, PHP, Swift and Lua.
Gates do not fire on Tier 2 regions. Not "are discouraged from" — they do
not. check returns an undecided verdict rather than a pass, tests refuses to
call its selection complete, and every audit detector is restricted to files a
Tier 1 adapter resolved. A structural region cannot support a conclusion drawn
from the absence of a reference, because that reference would never have
reached the graph in the first place.
Java and C# both shipped as Tier 2 and are now Tier 1 — same grammars, same
binary, with resolution built behind them. The tier says what has been built for
a language, not how much it is valued. Ruby is the honest caveat: autoloading,
monkey-patching and method_missing make a large share of its references
undecidable statically, so it may stay where it is rather than be promoted on a
claim the analysis cannot support.
Reaching the agent
MCP is pull — the agent has to decide to ask. Six tools, deliberately few,
because a large tool surface degrades a model's ability to pick the right one:
get_blast_radius, get_dependencies, get_symbol_usages, graph_diff,
check_rules, refresh_graph_index.
Hooks are push. Before an agent edits a file, its blast radius goes into context whether or not the agent asked:
OpenInvar blast radius for src/models/user_payload.ts: 1 file(s) and 3
reference(s) depend on symbols defined here.And a change that breaks a rule does not reach a commit:
$ git commit -m "drop unused email field"
payload-is-stable no-field-removal [error]
src/models/user_payload.ts:5
field 'email' removed from 'UserPayload'
openinvar: commit blocked by a rule in openinvar.toml
Override once with: git commit --no-verifyOnly a rule violated on resolved evidence blocks. A missing binary, a missing graph, a stale snapshot or a timeout all let the commit through and say on stderr that nothing was checked — a gate that silently stops working is worse than one that is plainly off.
In CI the same thing is one uses: line:
- uses: JeelGajera/OpenInvar@v0It analyzes both sides, evaluates the rules, and edits one PR comment rather than adding another on every push.
Using it
cargo install openinvar-cli
openinvar analyze . --snapshot HEAD # record the graph before the change
openinvar audit . --base HEAD --head worktree
openinvar check . --diff-onlyWhat I learned
The first lesson survived the rename: the hard part of code intelligence is not parsing, it is deciding what a relationship is and committing to that definition consistently. I wrote the long version up in What breaks if I change this?, including the alias bug that flagged every reference in a repository as high risk and buried the three that mattered.
The second lesson is the one that produced the rename. A correct answer nobody acts on is worth very little. The graph was right for months while the thing people actually needed — stop this change — sat one decision away, and the decision was not technical. It was admitting that an advisory tool and a gate are different products, and that I had built the first while describing the second.
Determinism turned out to be the hinge. It was a design preference when the tool answered questions, and it became the load-bearing requirement the moment the tool was allowed to say no.
Current state
v0.3.0, Apache-2.0, published to crates.io. audit, check and the rule engine
are what the work is on now; the graph beneath them has been stable for a while.
It runs on its own repository in CI, which is the only endorsement that costs
anything.