Every unverifiable reachability call has a bill attached, and someone always pays it. When a triage system asserts "this vulnerable function is reachable" with no evidence you can inspect, a human has to re-derive the call graph by hand before trusting the verdict — real review hours burned reconstructing what a language server already knew. And when the system leans on a large language model to answer the same question by reading source as a string, you pay the other way: tokens spent re-deriving, on every single query, a call graph that gopls had indexed for free. Either way you are paying to reproduce work that a deterministic tool does instantly and exactly. That is the cost we set out to eliminate.
There is a lazy version of "AI reachability" going around, and it deserves to be called out. It looks like this: hand a model a repository and a CVE, tell it to figure out whether the vulnerable function is reachable, and let it re-derive the call graph from raw text on every query. It is expensive, it is slow, and it is wrong more often than anyone selling it admits — because a model reading source as a string has no type information, no cross-file symbol resolution, and no way to tell your app's merge apart from lodash.merge apart from some third dependency's merge. The model guesses. Sometimes the guess is confident and wrong, which is the worst possible outcome for a triage system.
We think that is the wrong division of labor. The question "is this vulnerable function actually reachable from app code?" is mostly a static-analysis question with a hard AI-shaped kernel at its center. So we built it that way. Nullify drives real Language Server Protocol (LSP) navigation — the same deterministic call-hierarchy and go-to-definition machinery that powers your IDE — to establish ground truth, and reserves LLM judgment for the genuinely ambiguous cases that no deterministic tool can resolve. Precise, type-aware static analysis does the heavy lifting. The AI reasons only about what is actually ambiguous. Neither is trying to replace the other, and that split is exactly what lets reachability triage scale with your codebase instead of with your review team.
Reachability is not one question. It is two, and they cost wildly different amounts to answer.
import or require statement references the package at all.Our SCA design doc states the split in plain terms: a deterministic walker can answer "is the package imported" cheaply — and we do, via tree-sitter — but answering "is pkg.Foo actually called" requires context-sensitive reasoning that an LLM with code tools does well. What that framing gets exactly right, and what most implementations get wrong, is that the middle of that second question is not AI-shaped at all. Resolving "does this call site actually bind to the vulnerable definition, or to a same-named function somewhere else" is a semantic resolution problem that language servers already solve, precisely, every time you hit go-to-definition in your editor. Making an LLM re-derive it from text is not clever. It is redundant work at a premium price.
Underneath the AI layer sits a shared Go package in Hyperdrive — the library nearly every Nullify service depends on — that wraps real language servers behind a uniform tool interface, so an agent can call them like any other tool. These are not homegrown AST heuristics. They are the actual production language servers you already trust in your editor: gopls for Go, Pyright for Python, typescript-language-server, and JDTLS (Eclipse JDT) for Java, with a broader fleet — rust-analyzer, sourcekit-lsp for Swift, kotlin-language-server, ruby-lsp for Ruby, and more — behind the navigation tools. Because we run a real server per language rather than one generic parser across all of them, we get language-correct semantics — real type resolution and real cross-file linking — instead of one lowest-common-denominator approximation.
On top of those clients sit LLM-facing tools: find_definition, find_references, contextual variants that return the surrounding code so an agent can tell whether a call is gated, dead, or test-only, and — the key reachability primitive — call_hierarchy, which exposes real incoming and outgoing call-graph edges resolved by the language server's semantic model.
A point of honesty that matters here: call-hierarchy support is not uniform across languages, and we do not pretend it is. The reachability primitive rests on the language server's ability to answer "who calls this symbol," and that capability is only reliable where the underlying server implements it well — today that means Go (gopls), Python (Pyright), JavaScript/TypeScript, and Java (JDTLS). Some servers in the broader fleet are excellent for go-to-definition and reference navigation but cannot back call-hierarchy reachability: the Kotlin language server returns UnsupportedOperationException for call hierarchy — it simply has none — and Ruby's ruby-lsp is unreliable for this purpose, with call-hierarchy queries prone to timeouts and connection drops. So a language having an LSP in our fleet does not mean it gets a high-confidence call-hierarchy verdict. Where the primitive is unavailable, the system knows it and degrades to a weaker signal rather than fabricating a call graph — which is the whole point of the confidence ladder below.
The purpose-built reachability tool, get_callers_in_app, composes these into a single answer where the primitive is available. It resolves the vulnerable symbol via a workspace-symbol lookup, runs prepareCallHierarchy, and queries incoming calls against the real language server. Then it does the filtering that separates a trustworthy verdict from a text-search guess:
lodash.merge gets told apart from your app's merge and from a third dependency's merge — something a textual or tree-sitter grep cannot do reliably, because grep has no idea which definition a call binds to.file:// results — the JDK standard library surfacing as jdt:// or jar:// URIs — are stripped, so a vulnerable function calling itself inside a dependency never produces a false "reached" verdict against your application.The tool returns one of four statuses: reached, unreached, symbol_not_found, or unknown. That fourth status is load-bearing, and we will come back to it.
Here is the caveat we insist on stating up front, because a reachability tool that oversells its determinism is dangerous. LSP resolution is not perfectly deterministic in production. Language servers have async indexing races, they time out, and — as the language-by-language reality above makes plain — some languages have far weaker LSP support than others for the specific operation we need. Our own code says so directly — symbol resolution is flagged in comments as non-deterministic, subject to indexing races, timeouts, and unsupported languages.
The correct response to that reality is not to pretend it does not exist. It is to degrade gracefully and to be explicit about how confident any given verdict is. That is what the confidence ladder does. Every verdict records the method that produced it, ranked by evidentiary strength:
symbol_lsp — a definite reference resolved through the language server's call hierarchy. Highest confidence.package_only — the package is imported but symbol resolution failed. Lower confidence.ai_judgment — LLM reasoning with no concrete reference to point at. Lowest.The method enum reserves one more rung between symbol_lsp and package_only — symbol_grep, a textual reference for the case where a symbol resolves by name but no LSP call hierarchy is available. It is honored on the read side, but no code path emits it today: the current decision path only ever produces symbol_lsp, package_only, or ai_judgment, so the live ladder runs those three. We keep the slot defined rather than pretend the ladder is finished.
The decision algorithm walks that ladder in order. It derives candidate vulnerable symbol names from the real upstream fix commit diff, calls get_callers_in_app per candidate, and maps a clean reached/unreached to method=symbol_lsp, confidence=high. Only when LSP resolution genuinely fails — returning symbol_not_found or unknown, whether because of an indexing race or because the language simply has no call-hierarchy support — does it fall back to the package-level signal, and only then to web search for the dynamic-dispatch and framework-mediated cases, emitting unknown if it is still ambiguous.
The single most important design principle here is encoded in a test comment: an LSP being unavailable must never be reported as unreached. The two states are kept deliberately distinct so that a language-server failure can never masquerade to the downstream LLM as "we looked and found no callers." A missing answer and a negative answer are not the same thing, and conflating them is exactly how a flaky tool run — or a language with no call-hierarchy support — silently buries a real vulnerability. This is the function-level companion to the finding-level guardrails described in our writeup on per-CVE reachability — where the same unreached-versus-unknown distinction decides whether a whole finding's severity is allowed to drop.
The reason to lead with LSP rather than the model is not purity. It is cost — of both human time and AI tokens.
An LLM asked to establish reachability from raw source burns tokens re-deriving a call graph the language server already has indexed, and it does it again on the next query, and the next. A get_callers_in_app call returns a resolved, filtered answer in one shot. The model does not have to read half the repository to reconstruct what gopls computed for free. In the per-CVE pipeline, this collapses a decision that could take fifteen to twenty exploratory tool calls down to a handful that fit inside a tight tool budget. The budget is concrete, not aspirational: the per-CVE reachability subagent caps get_callers_in_app at three caller-checks per CVE, and a single classify_dependency_usage call covers every CVE in the finding at once — so even a 32-CVE Go-stdlib bump resolves inside one bounded pass. And the whole subagent runs on the cheapest, fastest model tier (Haiku), precisely because the language server, not the model, is doing the semantic resolution.
The layering compounds the savings. The cheap deterministic layer runs first and settles the clear-cut cases at essentially zero LLM cost: if tree-sitter proves the package is not imported anywhere, every CVE under that finding gets an unreached verdict without the AI ever waking up. When the AI does run, the LSP result is handed to it as ground truth rather than a research assignment. The model spends its budget only on the residue — the genuinely ambiguous symbols where dynamic dispatch or a framework indirection defeats static resolution and judgment is actually required. That is the whole thesis in operational terms: deterministic tooling settles what it can settle, and the expensive reasoning is spent only where it is the only thing that works. The outcome — a shorter, cleaner triage queue — scales with the codebase, not with the number of analysts you can hire.
Two things make this auditable rather than a black box.
First, every verdict carries its method. Because the confidence ladder is recorded on the verdict itself — not lost inside transient agent state — a security engineer can see at a glance whether a "not reachable" call came from a definite LSP call-hierarchy resolution or from the model's best guess with no reference to point at. That is the optional human-in-the-loop control for the power user: the ladder is a transparency surface. You do not have to trust every verdict equally. You can filter to the low-confidence ones — including the ones a language's missing call-hierarchy support forced down the ladder — and intervene exactly where intervention is worth your time, and leave the high-confidence symbol_lsp verdicts alone.
Second, the concrete evidence is copied into the graph verbatim. When a verdict resolves to reached via get_callers_in_app, the subagent copies each entry from the tool's callers array — the actual in-app call sites — straight into the verdict's structured fields. The deterministic LSP output flows directly into the evidence graph, not merely into LLM prose that asserts a conclusion you cannot check. When you ask why a function was judged reachable, you get the file-and-line call sites the language server found, not a paragraph of model reasoning you have to take on faith.
Being specific about mechanism means being specific about limits, so a few honest boundaries.
get_callers_in_app answers "who calls this symbol directly." If the vulnerable function is invoked through an internal wrapper, tracing the transitive path takes an additional find_references step on the wrapper. The tool documents this precision boundary to the very model consuming it, rather than implying unlimited call-depth resolution.get_vulnerable_symbols_for_cve reads a GHSA advisory's affected_functions field directly to name the vulnerable symbols; as that path finishes landing, the reliable method we run today is parsing the upstream fix commit diff, which tells us exactly which symbols the patch touched. Both routes converge on the same input to the reachability tool.None of these are fatal to the approach. They are the reason the confidence ladder exists. A system that told you it had perfect call-depth resolution across every language would be lying, and a triage tool that lies is worse than no tool at all.
Static analysis is precise but rigid: it gives you ground truth about the call graph, and it goes quiet exactly where dynamic dispatch and framework magic begin — or where a given language server simply has no call hierarchy to offer. AI is flexible but ungrounded: it reasons about the ambiguous cases well, and it hallucinates confidently when you make it do work a deterministic tool should have done. The mistake is treating these as competitors and picking one.
We picked both, in the right order. Real language servers establish what is knowable — resolved, type-aware, filtered to your first-party code, with the actual call sites recorded as evidence — and say plainly when a language is not one where they can. The LLM adjudicates only what is genuinely unknowable by deterministic means, and every verdict says which of the two produced it and how confident it is. That is how you get the security outcome — a triage queue that is not clogged with unreachable findings — for the least human effort and the least token spend, while keeping every decision on the record for the humans who want to check the work.
The result is reachability triage whose throughput is set by your codebase, not your headcount: controlled because every verdict is labeled and auditable, measurable because the confidence ladder is data and not vibes, and cheap because the deterministic layer answers everything it can before a single token is spent.