Every tool in this space leads with a language count. basemind's README says 300+. Serena says 40+.
aider says 130+. codebase-memory-mcp says 158.
Those numbers are not comparable, because they are not measuring the same thing. Most of them are
counting grammars - how many languages the tool can parse into a syntax tree. Parsing is the
cheap part. It tells you where the functions are. It does not tell you which get this get is.
So here is the honest version of basemind's number, split by what you actually get.
| Tier | Languages | Engine | What you get |
|---|---|---|---|
| Full resolution | JavaScript, TypeScript | oxc | Scope + import/export resolution, intra- and cross-file |
| Full resolution | Python, Java | stack-graphs .tsg rules |
Name binding over the parse tree, cross-file via specifier resolution |
| Scope only | everything else | tree-sitter locals query |
Intra-file lexical binding, no imports |
| Symbols + calls | ~100 grammars | tree-sitter tags.scm
|
Symbol and call extraction |
| Parse only | 371 | tree-sitter-language-pack | A file map. JSON, YAML and TOML produce no symbols at all |
Three languages get real resolution. About a hundred get symbols. The rest parse. That is a much
less impressive sentence than "300+ languages," and it is the one that survives contact with a user.
Why not just run a language server
The obvious objection: language servers already solve this, correctly, with a real type checker.
rust-analyzer knows things about your code that no amount of tree-sitter will ever recover.
That is true, and I want to concede it up front rather than have it raised in a comment thread.
For generics, macros, dynamic dispatch, dependency injection and monkey-patching, an LSP-backed tool
like Serena - or an SCIP-backed one like Sourcegraph - will be right where basemind is wrong.
What you pay for that is a build. A language server needs your project to compile, or close to it: a
resolved dependency tree, a populated node_modules, a cargo build that has actually run. It needs
a server process per language, each with its own startup cost and its own memory. On a polyglot
monorepo that means several daemons, several indexes, several cold starts, and a hard failure the
moment the build is broken - which, if an agent is halfway through editing, it usually is.
basemind is optimizing for a different constraint: an agent asking a hundred questions per session
about a repo that may not compile, in a language mix nobody planned for. So the design bet is no
build step, no language server, no per-language daemon - and the honest cost of that bet is that
resolution is an approximation.
The part nobody else shipped
For JavaScript and TypeScript, the answer is easy and good: oxc. oxc_semantic
builds a real scope tree, symbol table and reference bindings; oxc_resolver does Node and tsconfig
module resolution, including solution-style monorepo path aliases. It is fast, actively developed,
and funded. A monorepo rarely imports by relative path - it writes @app/hooks/useSettings - and
oxc_resolver handles that natively.
For Python and Java there was no equivalent, so basemind uses stack-graphs.
Stack graphs come out of GitHub research: Douglas Creager's
Name resolution at scale. The idea is that name binding can be
expressed as a graph-reachability problem, with per-language rules written declaratively in a DSL
(.tsg) that runs against a tree-sitter parse tree. No type checker, no build, and - critically -
the per-file work is independent, so it caches.
It is a genuinely good idea, and it is also abandoned. GitHub archived the repository on
2025-09-09 with the note that it is no longer supported and that you should fork it if you want to
keep going. The tree-sitter-stack-graphs crate has not had a release since v0.10.0 in December 2024.
I am not the first person to look at this. aider evaluated stack-graphs and went with tree-sitter
tags plus PageRank instead (#534). So did
OpenHands (#742) and
SWE-agent (#38). Three serious teams reached the
same conclusion: too much work, unmaintained upstream, not worth it.
They were probably right about the cost. Here is what taking it on actually involved.
Four rule bugs that silently deleted a file's entire resolution
The upstream Python rules abort the whole file's stack-graph build on several perfectly ordinary
constructs. Not "resolve that one line wrong" - the file produces no resolution at all, quietly. A
sweep over a real Python codebase surfaced four:
-
typed_parametermatched too broadly. A typed splat parameter -**kwargs: T,*args: T- bound the splat pattern as if it were a plain name. Fixed by restricting the rule to identifier-named params. -
Keyword arguments in a class header were treated as superclasses.
class X(TypedDict, total=False)and anything withmetaclass=…broke. Fixed by restricting the superclass list to identifiers, attributes and subscripts. -
Parameter-less lambdas got no call node. The combined function/lambda stanza required a
parametersfield, solambda: xwas skipped entirely. -
Chained assignment referenced an undefined output.
a = b = cnests, and the inner assignment had no.outputto flow from. Fixed by having every assignment carry one.
With those four fixed, that codebase goes to 100% of files building.
Two panics on the stitching hot path
A panic while resolving one file must never take down a scan of an 82k-file repository, and two
upstream ones could:
-
Database::get_incoming_path_degreeindexed a lazily-grown supplemental arena directly, which panics for any node that is not already the end node of some partial path in the database. It now reportsDegree::Zero. -
ForwardPartialPathStitcher::extendcalled.expect()on the cycle detector. The cycle test replays a suffix of the path against freshly minted stack variables, which can legitimately fail to unify. An undecidable cycle test now discontinues the path - the same conservative outcome as a detected cycle - instead of panicking.
Both have regression tests. Alongside that, the scanner persists the code map before running the
optional lanes and wraps each lane in catch_unwind, so a per-file panic in the resolve pass costs
that file's edges and nothing else. It used to cost the entire map, which meant gigabytes of blobs
sitting behind a file_count of 0 and a full re-scan on every launch.
The fork is four workspace crates - stack-graphs, tree-sitter-graph, tree-sitter-stack-graphs,
lsp-positions - carrying upstream copyright, ported to tree-sitter 0.26 and edition 2024, with the
C FFI, serde, visualization and storage modules stripped because none of that surface is reachable
when you build the graph in memory and stitch it in-process.
Cross-file is where the interesting caching lives
Intra-file resolution links a use to its definition inside one file. That result is deterministic in
the file's bytes, so it is cached in a content-addressed blob next to the code map: unchanged file,
no re-analysis.
Cross-file resolution is the opposite. It resolves an import's module specifier - Node/tsconfig for
JS/TS, dotted and relative path arithmetic for Python, fully-qualified names over Maven and Gradle
source roots for Java - then joins the imported name against the target file's exports, following
re-export chains through package __init__.py files and TypeScript barrels up to eight hops.
That result is not a function of one file's bytes. So it is deliberately not cached in the
per-file blob. If a file is unchanged but its dependency moved, the edges still get re-stitched. The
incremental path restages only the changed files' intra-file facts and re-stitches only the affected
importers - the changed files plus everything that imports one.
Caching the thing that is safe to cache, and refusing to cache the thing that looks similar but
isn't, is most of what makes this fast enough to sit under an agent.
The contract that matters more than the resolution
Here is the design decision I would defend hardest, and it is a decision to do less.
callers never narrows to the resolved subset.
It returns every call site a plain name scan would return - the complete, no-scope floor - and
annotates each hit with whether resolution proved it binds to this definition, plus a
resolved_total count. It does not filter down to the proven ones.
That looks wrong. Filtering to proven hits is more precise, and precision is the whole point of
building a resolver.
It is wrong because resolution cannot see through a module-object import - from pkg import mod
then mod.f() - or an unresolvable path alias. Those are real callers that resolution will not
prove. Filter to the proven subset and you return a smaller set with no indication anything is
missing.
We know exactly how that fails, because it did. Resolution saw 2 of 172 real call sites and the
tool reported total: 2, with no truncation flag. A confident, complete-looking, wrong answer. A
Python case returned 7 of roughly 4,000. An agent acting on either would conclude a function had
almost no callers and refactor accordingly.
resolved: false is not evidence a hit is not a real caller. It is evidence resolution could not
prove it either way. So the sound floor is the answer, and precision is an annotation on top. Filter
on resolved when you want confidence; trust total when you need completeness.
The same principle runs through the provenance model on the code graph: every edge carries a
confidence - extracted, inferred, or ambiguous - derived on read, and it degrades downward. A
binding whose proof is unreachable is reported as inferred, never as falsely extracted. Import and
inheritance edges are name-resolved by construction, so they can never claim to be extracted at all.
What this still doesn't do
Java qualified member calls on an imported type - Foo.greet() - are unresolved, and
import static is not distinguished from a type import. Both are misses, never wrong answers, which
is the failure mode the contract above is built to guarantee.
basemind is read-only. It will tell you every caller of a symbol; it will not rename it for you.
Serena does symbol-level editing and refactoring, and its users lean on that heavily.
And the PageRank-over-a-tree-sitter-symbol-graph idea for ranking repo architecture is not mine -
aider published that in October 2023. basemind blends
git churn into the weighting, which is an increment on their work, not a replacement for it.
The number on the box is 371 languages. The number that describes what the tool understands is
three, plus about a hundred that give up symbols, plus a long tail that parses. Both are true. Only
one of them is useful when you are deciding whether to install something.
basemind is MIT, pure Rust, and lives at github.com/Goldziher/basemind.
Top comments (0)