parse in the project matches. One of them is yours.Which definition does a name bind to — across files, following imports and scope? That is the question a text search cannot answer, and it is why a text search over-reports. Four questions, and every answer says how much to trust it.
references(root, {path="src/lib.ts", lnum=1, name="target"})
two passes
Two files called the same thing are two files. Ripgrep cannot tell them apart, and neither can anything built on ripgrep — which is most of what an editor offers you. So the work is done in two passes, and the split is the honesty.
$ rg --word-regexp --fixed-strings target src/decoy.ts:1:10:function target(x) { src/notes.md:8:3: -- target is the one that src/page.ts:2:11:const x = target("a"); src/decoy.ts:2:11:const y = target(2); src/util.ts:44:6: "target" 5 positions. Fast, over-broad, and never reported as more than it is.
A language server answers this in ONE request — it searches the project itself. Stack graphs is asked of each candidate whether it binds to this definition. src/page.ts:2:11 ← kept Four dropped. Two of them are a DIFFERENT function that merely shares a name; one is a comment; one is a string. Without an engine the first pass still runs and the answer is labeled text, because a name match is evidence of a mention and nothing more.
A consumer rendering an answer may never use a stronger word than the one it was handed. That is the entire contract, and it is what makes a wrong answer into a weak answer instead of a silent one.
An engine decided this. A binding, not a coincidence.
Ripgrep matched a name. A mention is not a reference; two files called the same thing are two files.
Nothing could be determined. The list is empty because nothing was answerable — not because the answer is empty.
text and none are different words
The two directions degrade differently, on purpose. With no resolver installed,
references() still has ripgrep's answer — which files mention the name —
so it returns text. There is no textual approximation of what does this
file reach, so depends() returns none rather than a zero it
cannot stand behind.
An entry point nobody calls has no references and a large footprint. That is what makes it an entry point. Conflating the two is how a tool reports zero about the most important file in a project.
What the name at this position binds to. A batch, because each call is a subprocess and a footprint asks about hundreds.
What binds to this definition. Who actually uses it — as against who happens to type the same word.
What this file reaches, one hop. Every identifier in it goes to the engine at once; each definition that lands in another file is a dependency.
Outbound, transitive. Depth-bounded, because a footprint that reaches the whole repository tells you nothing — and because each hop is a subprocess.
local sg = require("stackgraphs") sg.prepare(root, function(langs) -- index. the only slow step sg.references(root, { path = "src/lib.ts", lnum = 1, name = "target" }, function(a) a.hits -- { { path = "src/page.ts", lnum = 2, col = 11 } } a.n, a.files -- 1 reference, in 1 file a.fidelity -- "resolved" | "text" | "none" — never nil a.reason -- why, when it is not resolved end) sg.depends(root, "src/entry.ts", function(a) a.files -- one hop out end) sg.closure(root, { "src/entry.ts" }, { depth = 3 }, function(a) end) sg.definitions(root, positions, function(a) a.at -- keyed "path:lnum:col" end) end) sg.languages() -- what this machine can resolve RIGHT NOW sg.status() -- a line per crate, for :checkhealth
Both are asserted in the specs rather than documented and hoped for, because both have already produced a silent zero in real code. A silent zero is indistinguishable from a symbol nobody uses.
The engine prints absolute paths even when the query was relative. On macOS
it prints the symlink-resolved absolute path, so a root under /var
comes back under /private/var. A caller comparing those against its own
relative paths gets no matches and no error.
Matching the engine's own convention and Neovim's line numbers. An off-by-one here does not throw; it simply asks about the character before the name and gets back nothing, which reads exactly like a symbol that binds to nothing.
The plugin was built on the tree-sitter-stack-graphs CLI. That engine is
archived upstream — repository archived 2025‑09‑09, crates last published
2024‑12‑13. A language server is the primary backend now, because servers are
maintained by the ecosystems whose languages they resolve, and for TypeScript the
server is the compiler: ../lib/db.js resolving to db.js is not an
approximation of module resolution, it is module resolution.
stackgraphs/init.lua definitions · references depends · closure languages · status · prepare fidelity, always repo-relative paths 1-based positions No consumer changed. scry's fourteen specs passed untouched against a resolver that shares no code with the one they were written for.
stackgraphs/engine.lua binaries, index, db stackgraphs/query.lua run, parse, filter stackgraphs/lsp.lua new Two files touched, one added. And the conventions earned their keep on the first run: the new backend had the SAME silent zero as the old one — an unresolved root — in code sharing none of its lines.
No setup() call. It is a library — nothing runs until something asks it a question.
{ "vim-pro/stackgraphs.nvim" }Anything on $PATH from the list below is picked up with no configuration. Install only what you need — the rest answer honestly rather than wrongly.
typescript · javascript · tsx vtsls, typescript-language-server
python basedpyright-langserver, pyright-langserver, pylsp
lua lua-language-server
go gopls
rust rust-analyzer
c · cpp clangd
java jdtls
ruby ruby-lsp, solargraph
# and setup{} names one the list does not know:
# servers = { elixir = { cmd = { "elixir-ls" } } }Archived upstream, and still the only backend that answers with no server running and no project toolchain set up. Binaries are looked for in this order — the second so an existing scry install is not made to redo it.
cargo install tree-sitter-stack-graphs-typescript --features cli \ --root ~/.local/share/nvim/stackgraphs stdpath("data")/stackgraphs/bin/<crate> stdpath("data")/scry/bin/<crate> $PATH
It changes fidelity, which is what fidelity is for. :checkhealth reports both backends, and languages() only ever lists what is actually installed — mapped to the backend that will answer.
> require("stackgraphs").languages() { typescript = "lsp", javascript = "lsp", python = "stackgraphs" }
Each of these was learned by being wrong about it. A drawing that does not mark its own tolerances is not a drawing.
text fidelity is a count of mentions. It includes the definition itself, comments, strings, and same-named things in unrelated files.resolved means an engine put a definition at the target's location. It says nothing about whether the code runs, or runs on any real input.typescript-language-server, with the server reporting initialized throughout: the first project-wide references came back with 0 hits, the very next identical call with 3, and every call after with the correct 2. tsserver answers a query that arrives before the project has loaded by returning nothing, immediately. prepare() now spends that first throwaway query itself, and references asks a second time when an answer comes back empty — a symbol nobody uses pays about a quarter-second for that. The alternative is reporting “nothing uses this” about a symbol with users, which is the failure this whole thing exists to prevent.typescript-language-server needs a classic TypeScript 5 tsserver.js, in the workspace or beside itself; TypeScript 7's native package ships none, so npm i typescript typescript-language-server today installs a pair that cannot run together. The wait is bounded at eight seconds and then reported honestly.none from depends() where a resolver did run means no identifier in that file resolved. It is a weak test: a file whose identifiers resolve only to itself passes it and still reaches nothing outside. That is observable on a real project, where an imported name resolves to its own import binding and the chain stops. A minimal fixture of the same shape continues. The difference is not explained here, and what is not explained is not asserted.closure() is depth-bounded. A file outside the bound is absent from a resolved answer, and the answer does not say so.github/stack-graphs was archived 2025‑09‑09, its crates last published 2024‑12‑13. That backend still works and is still supported here; it is simply not where new capability will come from. That is the reason the seam exists, not a footnote to it — and the seam has now been used.