alden

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

  1. 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).

  2. 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.

  3. 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.

  4. Find callers. Alden follows imports to the changed symbol:

    • TypeScript and JavaScript: relative imports (including .js imports of .ts files), index files, tsconfig path 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, so product_sync.upsert() reaches services/product_sync.py.
    • Go: import paths under the repo's modules, read from every go.mod (nested modules, and local replace directives such as Kubernetes' staging/ packages). An import reaches every file in the package, under the name its package clause declares. Files in one package see each other's names without imports. Methods are also reached through interfaces and embedded structs; see Go.
    • PHP: use imports (plain, aliased and grouped) and fully qualified names, mapped to files through the PSR-4 prefixes in every composer.json (autoload and autoload-dev), or failing that the one file whose path ends in the class's namespace path. Classes in the same namespace see each other without use. Type hints count as uses, since frameworks mostly use a class by injecting it.
  5. 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 (./pricing now finding pricing/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.

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:

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

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:

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:

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:

  1. 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 in clones.folders (see Configuration). Any remote may point at the PR's repo, so a fork with an upstream remote works.
  2. Alden fetches GitHub's merge of the PR onto its base (refs/pull/<number>/merge, what CI tests) into refs/alden/pr-<number>-merge, and the PR's head into refs/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.

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.