The code graph
A diff shows what changed. It doesn't show who depends on it. For that, Alden builds a code graph of the repo and lists the callers outside the diff of every public function, class and method the change touches (in Go, package-internal ones too; see Go). It then flags removed or re-signed code that's still called, and gives the model those call sites as context.
Languages
| Language | Status |
|---|---|
| TypeScript, TSX, JavaScript | Supported |
| Python | Supported |
| Go | Supported |
| PHP, with Laravel and Symfony patterns | Supported |
Changed files in other languages are listed under "Not checked". Blade templates aren't parsed, but are searched for fully qualified calls (see Laravel and Symfony).
How it works
Parse. Alden parses every tracked file with tree-sitter, compiled to WebAssembly, so nothing is compiled at install time on any OS. For each file it records definitions (functions, classes, methods, exported values), imports and references (calls,
new, JSX elements, base classes, and functions passed as arguments).Cache. Results are cached in
~/.alden/index, keyed by each file's git blob hash, so later reviews only re-parse changed files. As a rough guide, a 3,000-file repo takes a few seconds the first time and under a second after that.Find what changed. The diff's changed lines are mapped to the definitions that contain them, so a changed function body counts, not only a changed signature.
Find callers. Alden follows imports to the changed symbol:
- TypeScript and JavaScript: relative imports (including
.jsimports of.tsfiles),indexfiles,tsconfigpath aliases, workspace packages, and re-exports through barrel files. - Python: module paths from the repo's source roots, relative imports, and names re-exported through
__init__.py. A submodule imported from its package (from .services import product_sync,from . import product_sync) counts as the module, soproduct_sync.upsert()reachesservices/product_sync.py. - Go: import paths under the repo's modules, read from every
go.mod(nested modules, and localreplacedirectives such as Kubernetes'staging/packages). An import reaches every file in the package, under the name itspackageclause declares. Files in one package see each other's names without imports. Methods are also reached through interfaces and embedded structs; see Go. - PHP:
useimports (plain, aliased and grouped) and fully qualified names, mapped to files through the PSR-4 prefixes in everycomposer.json(autoloadandautoload-dev), or failing that the one file whose path ends in the class's namespace path. Classes in the same namespace see each other withoutuse. Type hints count as uses, since frameworks mostly use a class by injecting it.
- TypeScript and JavaScript: relative imports (including
Moved and deleted files. A file the change moves or deletes is treated like a removed symbol: files that still import it by its old path (and that the PR didn't change) are listed under "Callers outside the diff" as
imports src/pricing.ts (moved to src/lib/pricing.ts). An import that still resolves after the move (./pricingnow findingpricing/index.ts) isn't counted. In Go, an import breaks only when no package is left in the old directory. A move with no edits isn't untested code, and the briefing says where each moved file came from.
Indexing stops after 60 seconds. When that happens, the briefing says the results may be incomplete. As a guide,
Kubernetes (13,500 Go files outside vendor/) indexes in about 18 seconds the first time and 1.5 seconds after that,
and symfony/symfony (12,000 PHP files) in about 12 seconds and 1 second.
vendor/, node_modules/ and build output are skipped.
Related files
Each place in "Needs your eyes" also suggests files to look at beside it ("Look at these too" in the terminal, the
web UI's guided review and alden_review), worked out from the graph the briefing already built, so they cost no
extra parsing:
- caller: code calling the changed symbols at that place (the definition around its line, or all of the file's changed ones), outside the diff. For a place that is itself a caller, the symbol's other callers. Up to 4, sure matches before name-only ones.
- test: tests calling those symbols or importing the changed file (test files by name, as the checks tell them apart). Up to 2; a test's call comes before its import line.
- importer: files importing the changed file (in Go, its package). For a file still importing a moved or deleted one, the other files importing the old path. Up to 2.
One entry per file, never the place's own. Without the graph (no clone, --no-graph, a language it doesn't read) a
place has none, and model findings never have any.
Signatures
For Go and PHP, "Changed public signatures" uses the graph: it compares each function's and method's whole
declaration before and after the change, keyed by type, so multi-line parameter lists compare properly and two types'
Close methods don't get mixed up. A Go interface's signature is its method set; a struct's is its name, since adding
fields breaks nobody using keyed literals. Moving a declaration to another file in the same Go package isn't reported.
TypeScript and Python use a heuristic that reads declarations from the diff's changed lines.
A change that only appends parameters existing calls don't need to pass (a default value, an optional b?: T, a
rest parameter, Python's *args or keyword-only defaults, Go's variadic ...T) counts as compatible: it's listed,
but callers are flagged as reached by a behaviour change rather than broken.
How sure it is
- Import match. The call resolves to the changed symbol through the file's imports, or is in the same file.
- Name match only. A bare call with the same name (4 characters or more) and no import that resolves elsewhere.
These are marked
name match only. - Methods.
obj.method()counts in files that import the class (in Go, the type's package). For very common names (get,save,filter,updateand so on) only calls onself,this,clsor the class itself count, becauserequest.data.get()is almost never your view'sget. Go has its own list (String,Error,Close,Get,Save, logging methods…), where the method's receiver (sinfunc (s *Server)) plays the part ofthis.
Alden doesn't infer types, so a method call on a variable of another type with the same method name, in a file that imports the package, is counted too. In Go, a bare name in another package is always that package's own, so Go's name-only matches are only the uncertain cases below: calls through interfaces, and methods named without a call.
Go
Go code calls a lot through types the call site doesn't name. The graph follows:
- Package-internal code. A changed unexported function, method, type or function variable is looked up too: a Go
package is often dozens of files sharing helpers, so
isStaleSerieschanging inhead.goreaches its callers inhead_wal.go. Only its own package can call it. Helpers in_test.gofiles aren't followed. - Interfaces. A changed method is reached through the repo's interfaces its type implements, judged by method
names (an interface's own and those of interfaces it embeds; Alden doesn't compare parameter types, and methods of
interfaces from outside the repo, such as
io.Reader, aren't required). Calls in files that use the interface's package count, markedname match onlyand with the interface named:calls Client.PushDefault (signature changed) through interface GitConfigClient, name match only. This is how dependency injection reaches an implementation:team.Servicein one package,teamimpl.Servicein another. Names that dozens of unrelated types implement (Go's common list above, andList,Patch,Watch,Statusand the like, which every generated API client has) aren't followed through interfaces. - Embedded structs. A method promoted through embedding (
type Server struct{ *store.Base }) is reached by calls in files that use the embedding type's package, up to three levels deep, unless that type declares its own method of the same name. These are sure matches:calls Base.Sync (changed) through Server (embeds Base). - Functions as values. A function or method named without being called is a use: struct fields
(
FetchOptions: shared.FetchOptions), assignments (opts.Run = runEdit,h := s.handle), returns, option lists ([]Option{WithRetry}) and package-level values.pkg.Funcand bare names are sure;x.Methodis a name match unlessxis the method's receiver, since it may as well read a field of another type. - Function variables. A package-level
var now = func() time.Time {…}, often swapped out in tests, is a definition its callers reach, exported or not.
Struct fields aren't followed: x.Name reads are everywhere, and a type check would be needed to tell whose field it
is.
Laravel and Symfony
PHP frameworks call a lot of code without naming it. The graph follows the patterns it can see:
- Interfaces and parents. A method is also reached through the interfaces and parent classes its class extends,
which is how container bindings and injected dependencies call it (
$this->gateway->charge()with aPaymentGatewaytype hint), and through subclasses and classes that use it as a trait. - Facades. An app's own facade reaches its target:
getFacadeAccessor()returningFoo::class, or a container key with the target named in the class's@seedocblock, as Laravel's own facades do. - Eloquent.
scopePublishedis called aspublished(). On a model, relationships and accessors are read as properties ($post->author,getFullTitleAttributeas$post->full_title). Property reads are markedname match only, since another model may have a property of the same name. - Constructors. A changed
__constructis called by everynew. - Common names.
save,find,where,handle,toArrayand others count only on$this,self,static,parentor the class itself. - Blade templates. Fully qualified static calls such as
{{ \Illuminate\Mail\Markdown::parse($slot) }}. - Service configuration. When a class's constructor changes, or the class is removed, the YAML and XML config that
names it (Symfony's
services.yaml, for example).
Container bindings by string key, app('name'), event listeners registered by name and routes to controller actions
aren't traced.
Dynamic code (reflection, string-based dispatch, dependency injection) can't be traced statically. Treat "no callers" as "none found", not "none exist".
Reviewing PRs
Local reviews use your working tree. For a PR, Alden needs the PR's code on disk:
- Alden finds a clone of the PR's repo: the one you run
alden review <pr>in, or else one it saw you run Alden in before, or one under the folders inclones.folders(see Configuration). Any remote may point at the PR's repo, so a fork with anupstreamremote works. - Alden fetches GitHub's merge of the PR onto its base (
refs/pull/<number>/merge, what CI tests) intorefs/alden/pr-<number>-merge, and the PR's head intorefs/alden/pr-<number>, and checks the merge out in its own worktree under~/.alden/worktrees(one per repo, reused). Your branch, working tree and stash are never touched.
With no clone, the review still runs, and "Callers outside the diff" says why it was skipped. The first checkout of a large repo can take a while; later ones reuse the worktree.
Skip the graph with --no-graph.
Keeping up with the base
Others keep merging while a PR waits. A PR's branch holds the rest of the repo as it was when the PR branched, so a caller added on main since, or a file moved there, wouldn't be in it. That's why callers are found in GitHub's merge: the PR on top of the latest base.
- What changed is still the PR's own diff: the PR against where it branched, as GitHub shows it. In the merge, the PR's changed files are put back to the PR's version, so the diff's line numbers match them even where main has edited the same file since. (A caller main added inside one of those files is the one thing this misses.)
- No merge. When the PR conflicts with its base, or GitHub hasn't made the merge for its latest push yet, callers come from the PR's branch, and "Callers outside the diff" says so: "Callers from the PR's branch: it conflicts with main, so changes merged since aren't included."
- No fetch when nothing moved. Alden asks GitHub for the PR's head and merge commits along with the PR, and skips the fetch when it already has both.
- What it was briefed against. Each PR briefing records the head commit it saw (
headSha), the base commit its graph saw (baseSha, the merge's first parent), where callers came from (graphFrom:mergeorhead) and when it was made.alden reviewends with "Callers from main at abc1234 + this PR". - Stale briefings. Comparing those with the PR now takes one GitHub request: a new push makes a briefing stale, and so do new commits on the base for a graph from the merge. The web UI says "main has 4 new commits since · Re-brief", and the desktop app prepares a fresh briefing instead of showing a stale one.
The index cache is keyed by each file's content (its blob hash), so the merge and the PR's branch share it safely, and only files that differ between them are parsed again.