Static Analysis with codespine
This guide shows how to use codespine as a static analysis tool —
answering questions about a TypeScript codebase without running it. The
optimization agent is one consumer of the graph; this document is about the
layer underneath it, the one you drive by hand or from a script.
Static analysis here means reasoning over a semantic model of the code: a
graph whose nodes are declarations (modules, classes, functions, types…) and
whose edges are the relationships the TypeScript compiler resolves between them
(CALLS, USES_TYPE, EXTENDS, READS…). Because the graph is built on
ts-morph (the TypeScript Compiler API), a call site is linked to the exact
declaration it resolves to — across files and through import aliases — not to
every symbol that merely shares its name.
The questions this is good at are the ones that require following those relationships transitively:
| Question | Command |
|---|---|
| What exported code is unused? | dead-exports |
| If I change this, what breaks? | blast-radius / who-calls |
| Is it safe to rename or delete this? | references |
| What does this symbol depend on? | calls / neighbors |
| How is this symbol wired into the rest? | neighbors |
| What types ripple out from this type? | references |
Setup: build the graph once
Every analysis below reads from a loaded Kùzu database. Build it with two
commands, and always extract with --semantic — the type and behavioral
edges that make static analysis useful (CALLS, USES_TYPE, READS, heritage)
only exist in a semantic extraction. Without it you get the structural skeleton
(files, declarations, imports, containment) and little to analyse.
# 1. parse the project into a JSONL graph (point it at any tsconfig project)
npx codespine extract . --semantic
# 2. load the JSONL into the embedded query database
npx codespine loadThat writes ./.codespine/graph.kuzu, the default every query command reads from.
See The pipeline for a fuller walk-through and
extract / load for all options.
Re-extract after editing code. The loader merges by node id and does not remove stale nodes, so a renamed or deleted symbol lingers until you rebuild. For a trustworthy reading start clean:
rm -rf .codespine/graph.kuzu .codespine/graph && npx codespine extract . --semantic && npx codespine load
How to read the graph
Two rules cover almost every command:
- Node ids always come from a query, never your keyboard. An id encodes the
declaration line (
kind:relPath#name@line) and shifts whenever the code moves. Get one fromfind --json, or copy it out of another query’s output. The line numbers in the examples below will differ in your tree — that is expected. - A “reference” is a use, not a mention. Ten edge kinds count as a symbol
being used:
CALLS,IMPLEMENTS,EXTENDS,USES_TYPE,RETURNS,PARAM_TYPE,INSTANTIATES,READS,OVERRIDES,HANDLES. Structural, mutation, and system-level config/HTTP edges (CONTAINS,IMPORTS,EXPORTS,WRITES,READS_CONFIG,CALLS_EXTERNAL) do not — being imported or exported is not a use. This set is whatdead-exportsandreferenceswalk.
Every query command accepts --json for machine-readable output and -o, --output-folder <dir> (default ./.codespine) to point at an output folder other
than the default.
Recipes
1. Find dead code
Question: which exported symbols does nothing reference, so I can delete them?
npx codespine dead-exportsClass Cli src/cli.ts:15
TypeAlias EdgeKind src/schema/edge.ts:20
TypeAlias Range src/schema/node.ts:26
3 result(s)dead-exports scans the whole graph for exported nodes with zero inbound
reference edges. It is member-aware — a class or interface stays live if any
of its methods or properties is referenced, even when the container name is
never used directly — and it counts the READS edge, so exported consts used
only as values (Zod schemas, lookup tables) are not false positives. See
dead-exports for the exact query.
Treat the output as candidates, not a kill list. In the run above, Cli is
exported but reachable only through Cli.run(process.argv) at module scope —
the kind of entry point a static reference walk cannot see (more on that under
Limitations). Confirm each candidate with
references before removing it.
2. Analyse change impact (blast radius)
Question: if I rewrite this function, what is the full set of code that could be affected?
First resolve the symbol to an id, then walk callers transitively:
npx codespine find run --json # copy the id of the symbol you mean
npx codespine blast-radius 'MethodDeclaration:src/store/kuzu_store.ts#run@52' --depth 10Method run src/cli.ts:16
Method register src/commands/blast_radius_command.ts:9
Method register src/commands/webview_command.ts:42
Method run src/commands/webview_command.ts:53
...
Method whoCalls src/query/graph_query.ts:29
Method find src/query/graph_query.ts:107
18 result(s)blast-radius walks CALLS edges backwards from the target up to --depth
hops and returns the deduplicated set of everything that can reach it. For just
the direct callers — the first hop — use who-calls,
which is blast-radius --depth 1:
npx codespine who-calls 'MethodDeclaration:src/store/kuzu_store.ts#run@52'Method buildDataScript src/commands/webview_command.ts:76
Method whoCalls src/query/graph_query.ts:29
Method calls src/query/graph_query.ts:40
...
8 result(s)This is the safety check before a refactor: the smaller and more local the blast
radius, the safer the edit. Note it follows CALLS only — for type-level ripple
see recipe 6.
3. Find every reference (rename / delete safety)
Question: before I rename or remove this symbol, what touches it — not just callers, but every kind of use?
npx codespine references 'TypeAliasDeclaration:src/schema/node.ts#GraphNode@37'<- PARAM_TYPE printBreakdown src/commands/extract_command.ts:46
<- PARAM_TYPE extractImports src/extract/structural_extractor.ts:50
<- PARAM_TYPE write src/store/jsonl_store.ts:7
<- PARAM_TYPE load src/store/kuzu_store.ts:29
<- RETURNS getNodes src/extract/graph_builder.ts:30
<- USES_TYPE Extraction src/extract/structural_extractor.ts:12
<- USES_TYPE GraphData src/store/jsonl_reader.ts:7
10 edge(s)Where who-calls sees only CALLS, references
reports all ten reference edge kinds and labels each one — so for a type like
GraphNode you see the parameters, return positions, and type aliases that
depend on it, which calls alone would miss entirely. An empty result is the
graph’s strongest signal that a symbol is safe to delete; pair it with
dead-exports to confirm a candidate. See
references.
4. Trace forward dependencies
Question: what does this function call — what would I need to understand to read it?
npx codespine calls 'MethodDeclaration:src/query/graph_query.ts#whoCalls@29'Variable RETURN_REF src/query/graph_query.ts:19
Method toRefs src/query/graph_query.ts:119
Method run src/store/kuzu_store.ts:52
3 result(s)calls is the forward direction of who-calls: the
symbols this one invokes directly. Following it outward sketches a top-down call
tree from any entry point. See calls.
5. Inspect a symbol’s neighbourhood
Question: how is this class wired into the rest of the code — everything one hop away, in both directions and across all edge kinds?
npx codespine neighbors 'ClassDeclaration:src/store/kuzu_store.ts#KuzuStore@13'-> CONTAINS initSchema src/store/kuzu_store.ts:23
-> CONTAINS run src/store/kuzu_store.ts:52
-> CONTAINS close src/store/kuzu_store.ts:63
<- INSTANTIATES withQuery src/commands/command_helpers.ts:36
<- INSTANTIATES run src/commands/load_command.ts:20
<- USES_TYPE store src/query/graph_query.ts:23
<- READS run src/store/kuzu_store.ts:52
16 edge(s)neighbors is the most general single-step view: outgoing edges (->) show
what the node owns and uses; incoming edges (<-) show who depends on it,
labelled by relationship. Here it reads as a quick structural summary of
KuzuStore — its members (CONTAINS), the three call sites that construct it
(INSTANTIATES), and the field that holds it (USES_TYPE). See
neighbors.
6. Trace type-level impact
Question: if I change this type, which signatures and other types are affected?
blast-radius follows CALLS and so
says nothing about types. Type impact lives in three reference edges —
USES_TYPE, RETURNS, PARAM_TYPE — which references
reports. Re-reading the GraphNode output in recipe 3: every PARAM_TYPE row is
a function whose parameter is typed GraphNode, every RETURNS row a function
that returns it, and every USES_TYPE row a type alias built on it. That is the
set of declarations a breaking change to GraphNode’s shape would force you to
revisit.
Scripting and CI integration
Add --json to any query for a stable shape you can pipe into jq or feed to
another tool. The structure is an array of { id, kind, name, filePath, startLine } (with edgeKind / direction added for references and
neighbors).
Gate a build on dead code — exit non-zero when any exported symbol is unused:
npx codespine dead-exports --json | jq -e 'length == 0' > /dev/null \
|| { echo 'Dead exports found:'; npx codespine dead-exports; exit 1; }List just the file paths impacted by a change, for a reviewer checklist:
npx codespine blast-radius "$ID" --depth 10 --json | jq -r '.[].filePath' | sort -uCount direct callers of every match for a name (a cheap fan-in metric):
npx codespine find handleRequest --json \
| jq -r '.[].id' \
| while read -r id; do
n=$(npx codespine who-calls "$id" --json | jq 'length')
echo "$n $id"
done | sort -rnA clean rebuild and these checks fit naturally into a pre-merge job; just
remember to wipe .codespine/graph.kuzu first so the reading is not stale.
Custom analyses
The two built-in traversal shapes — backward CALLS (blast-radius) and the
reference edge set (references) — do not cover every question. For anything
else you have two options.
Write Cypher against the database directly. .codespine/graph.kuzu is a
standard embedded Kùzu database with a two-table schema:
// Node table: GraphNode (id, kind, name, filePath, exported, startLine, endLine)
// Rel table: Edge (FROM GraphNode TO GraphNode, kind)
// Example: the ten most-called symbols (highest fan-in)
MATCH (caller:GraphNode)-[e:Edge]->(callee:GraphNode)
WHERE e.kind = 'CALLS'
RETURN callee.name, callee.filePath, count(caller) AS callers
ORDER BY callers DESC
LIMIT 10Open it with the Kùzu CLI or any Kùzu client. Every relationship carries its
type in the kind property; the
NODE_KINDS
and EDGE_KINDS
enums are the full vocabulary.
Add a method to GraphQuery. Each method in
src/query/graph_query.ts
is a small Cypher query that returns SymbolRefs, and the existing ones are the
best templates. A new method becomes a new analysis you can call from code (and,
by the same pattern, expose to the agent as a tool).
What the analysis cannot see
The graph is a static model. Knowing its blind spots is what separates a trustworthy reading from a misleading one.
- Dynamic dispatch and reflection are invisible. A method reached only
through a computed property (
obj[name]()), a string-keyed lookup table, or a framework that wires handlers by name has noCALLSedge. Such a symbol can look dead or show an artificially small blast radius while being heavily used at runtime. - Module-scope entry points look unused. Code invoked at the top level of a
module —
Cli.run(process.argv)at the bottom ofcli.ts, a CLI’smain(), test bootstrap — is the program’s real entry, yetdead-exportsmay flag it because the reference does not originate from another declaration node. Always sanity-checkdead-exportscandidates against how the program actually starts. blast-radiusisCALLS-only. It deliberately ignores type edges, so the runtime impact of a change is captured but the type-checking impact is not. Combine it withreferencesfor the full picture.- A stale graph lies confidently. Because
loadmerges rather than replaces, an out-of-date database reports symbols and ids that no longer exist (and the duplicate-looking entries that come from a file rename). Re-extract before trusting any result you intend to act on. - Node ids are line-bound. An id like
…#run@52pins the declaration line. Reusing an id across extractions, or after an edit, silently queries the wrong thing or returns nothing. Always re-find. - Structural-only graphs cannot answer these questions at all. If you skipped
--semantic, there are noCALLS, type, orREADSedges, sodead-exportsflags nearly everything and the traversals return empty. Re-extract with--semantic.
See also
- The pipeline — the end-to-end pipeline from clone to agent.
- CLI command reference — every command’s arguments, options, and underlying query in depth.
dead-exports·blast-radius·references·who-calls·calls·neighbors·find- Graph model — the node and edge vocabulary.