Read and Understand Code (Part 1) — Deeply Analyzing and Understanding Code with ZAgent's code-analyzer Tool
This article (split into two parts for easier reading) gives a detailed walkthrough of how to use the code-analyzer-cli tool.
code-analyzer-cli is a code analysis tool designed specifically for AI coding agents. Its purpose is to let an AI coding agent quickly and accurately understand the code of a given project.
code-analyzer-cli is an open-source project. It was spun out by the author from ZAgent — an AI agent project — into a standalone tool. It can also be used by other AI coding agents simply by wiring it up through a SKILL.
Project URL: https://github.com/briancai/code-analyzer-cli.git
The project is implemented in Rust. It uses tree-sitter to parse code and expresses the relationships between code elements as a directed graph. The advantages: it's fast, and AI agents can simply call it through the bash tool — no need for a tedious MCP server.
Of course, you can also just download ZAgent and use the built-in code-analyzer tool directly.
0. Three Real-World Dilemmas
We frequently run into situations like the following:
Scenario 1: It's 9 a.m. Monday morning. You've just arrived at the office and you're already pulled into a brand-new project.
Your boss gives you the link to the repo and says, "Get familiar with the code first, then start fixing bugs." After git clone and opening the IDE, the directory tree on the left shows hundreds of files and dozens of modules. You don't know where to start.
Scenario 2: You want to change what looks like a basic utility function — format_time().
This function lives inside a 3,000-line utils.rs. But of course you need to look at how many places call it. You're not sure whether it's 5 places or 50. You're also unsure which features may break afterwards. You hesitate whether to change it at all.
Scenario 3: You're reviewing a PR with a diff across 17 files.
After the first five files, you're already tired. But deep down, you know that some unremarkable change in this PR may sit on a critical call chain. You wish somebody could tell you: "In this change, which files should you focus on? Which are trivial? How risky is it?"
The essence of these three scenarios is the same: the human brain's reading speed simply can't keep up with how fast code grows. We need a capability that lets us "query code as data" — one that, without running the code, can tell us what is in this project, how things relate to each other, what's central and what's peripheral, and how far a small change will ripple.
This capability is called Code Analysis.
You might say: today's IDEs can already help us solve those three scenarios.
True. But in the AI coding era, we want an AI agent to solve them for us. That requires a tool that can feed the relevant code information to the LLM.
That's why I deliberately spun out the code analysis tool used inside ZAgent into a standalone CLI tool, and open-sourced it as the code-analyzer-cli project.
The goal of this article is to walk you through how to use this tool in depth, so you can either build a correct SKILL on top of it for your own coding agent, or just use ZAgent directly — where it ships as a built-in tool the LLM can call.
By the time you finish reading this article, you should have built the following mental model:
Code Analysis = parsing source code into structured data (AST / symbols / call graph / dependency graph) + running queries and graph traversals over those structures + outputting results in a way that's friendly to humans or Agents.
Let's start at the lowest layer: static analysis.
1. What Is Code Analysis? Why "Without Running" the Code?
1.1 Static Analysis vs Dynamic Analysis
Dynamic Analysis: run the program and observe its actual behavior. gdb stepping, strace tracing system calls, perf flame graphs, code coverage from unit tests — these are all dynamic analysis. Its characteristic: precise, but it only sees the paths you actually ran.
Static Analysis: read only the source code, don't run it, and infer the program's properties from text and syntactic structure. code-analyzer-cli is firmly in this camp — it is a static analysis tool.
It has the following characteristics:
- Zero side effects. You don't need an environment, a database, or a mocked third-party API to scan the code.
- Fast. Even thousands of files take only seconds to tens of seconds.
- Usable at any time. Pre-commit hook, CI pipeline, IDE startup, AI Agent picking up a task — as long as there's code, it can scan.
But static analysis can never see what's only determined at runtime — Java reflection, Python's getattr, Go's interface{}, Rust's trait dispatch, the object graph assembled at runtime by a DI framework. Those things you only know if you actually run them.
This gives us the first engineering principle: the output of code analysis is always a "structural signal," not "absolute truth." The three-state design of code-analyzer-cli's Resolver is the concrete embodiment of this principle.
1.2 Doesn't My IDE Already Do This? Why a CLI?
That's a very reasonable question. IntelliJ, VS Code, and Cursor can already "go to definition," "find usages," and draw call graphs. So why bother with a standalone CLI?
The answer: the IDE locks this capability behind a GUI, a single machine, and a single language. As soon as your use case crosses any of those boundaries, the IDE can't help:
- No display on the server / CI: You want to run a PR risk scan in GitHub Actions — the IDE can't even launch.
- AI Agents don't use mice: An LLM can only read JSON; it can't take screenshots and click menus.
- Cross-language / cross-repo batch jobs: You want a one-shot "entry-function list" across thirty microservices — opening them one by one in the IDE is far too slow.
- Scriptable composition: CLI output (JSON / Mermaid / SARIF) can be piped to the next tool. An IDE cannot.
In other words, I built this tool for Agents, so that a coding agent can understand a project's code efficiently and accurately.
1.3 What Questions Can a Code Analysis Tool Answer?
Static analysis typically answers questions like:
- What languages are in this project? Which directories and files?
- Which functions / classes / structs are defined where?
- What does function A call? What functions call A?
- If I change one file, which other files are affected?
- Which files are "hubs"? Which are forgotten islands?
- Which critical paths did this PR touch? How risky is it?
code-analyzer-cli packs these questions into 13 subcommands: analyze / symbols / definitions / references / call-graph / graph / impact / map / resolve / review / context / report / help.
We'll walk through them one by one.
1.4 Quick Start: the analyze Command
The fastest way to experience code-analyzer-cli is the analyze command:
code-analyzer analyze . --full
Its result is a JSON, structured roughly as:
{
"schema_version": "...",
...
}
Note the first line: schema_version. That's for programs to read — meaning the output was designed to be machine-readable from day one. You'll see the value of this when we get to AI Agent integration.
2. From Text to Structure — AST and Tree-sitter
2.1 Why grep Isn't Enough
In many AI Agents, we see them use grep and glob a lot to understand code.
grep is the most primitive form of "code analysis." For example, to find all definitions of function foo:
grep -rn "function foo" .
This approach has three unavoidable problems:
- False positives:
function fooinside a comment also matches. - False negatives: JavaScript also lets you write
const foo = () => {}orclass { foo() {} }. None of those sayfunction foo. - No structural understanding: It doesn't know which parent function a
foo()call sits inside, so it can't answer "who calls whom."
Regex can mitigate the first two, but never the third. We need a tool that understands "this is a function declaration, that is a call expression, and they are parent and child." That tool is the AST.
2.2 AST: Your Entry Ticket to Compiler Thinking
The AST (Abstract Syntax Tree) is the tree representation of source code. For example, this Rust snippet:
fn run() {
helper();
}
Its AST (simplified) looks like:
function_item (name=run)
block
call_expression
identifier: helper
Every node has an explicit kind: function_item, call_expression, identifier. Once you have this tree, the following questions stop being fuzzy string matching and become precise tree traversal:
- Find all function definitions → find all
function_itemnodes. - Find all calls → find all
call_expressionnodes. - "Who calls
helper?" → find allcall_expressionnodes whose identifier ishelper, then walk up to find the enclosingfunction_item.
2.3 Tree-sitter: the De Facto Standard for Modern Code Analysis
All language parsing in code-analyzer-cli is built on top of Tree-sitter.
Tree-sitter is a "universal parser framework" open-sourced by GitHub in 2018. Its design goals, straight from the official docs:
Tree-sitter aims to be:
- General enough to parse any programming language
- Fast enough to parse on every keystroke in a text editor
- Robust enough to provide useful results even in the presence of syntax errors
- Dependency-free so that the runtime library (written in pure C11) can be embedded in any application
Why has Tree-sitter become a near-industry standard?
- GLR algorithm: can handle the complex grammars of most modern languages (C++, TypeScript included).
- Incremental parsing: editing one line in an editor doesn't require reparsing the whole file — only the affected subtree is updated. That's why Atom, GitHub's code highlighting, certain VS Code extensions, and Neovim's default highlighting all adopted it at scale.
- Error recovery: even if the source is mid-edit and syntactically incomplete, it still hands you a "best-effort" tree instead of bailing out with an error. Crucial for analysis tools — you can't refuse to analyze the whole repo just because one file is malformed.
- Language-agnostic runtime: the core is C11; supporting a new language just means writing a grammar. The whole ecosystem shares one runtime.
The code-analyzer-cli project pulls in 7 tree-sitter language packs at the same time:
tree-sitter = "0.24"
tree-sitter-rust = "..."
tree-sitter-python = "..."
tree-sitter-java = "..."
tree-sitter-go = "..."
tree-sitter-c = "..."
tree-sitter-javascript = "..."
tree-sitter-typescript = "..."
In the project tree, these map to src/code_analyzer/ts/{rust,python,java,go,c,javascript,typescript}.rs — seven files, each implementing an AST traverser for that language.
One thing worth noting: the "recognized file extensions" registered in src/code_analyzer/detect.rs cover far more than 7 languages (Ruby / PHP / Kotlin / Scala / Swift / C# / Lua / Zig…), but only the 7 above can actually produce structural information (functions, classes, calls, imports). Other languages will be recognized as "this is some code" but won't make it into the definition/call graph.
2.4 A Real Example: How ts/rust.rs Extracts Call Relationships
Let's use the Rust extractor as an example to see what production-grade AST handling looks like. It implements a TreeSitterExtractor trait with four core methods:
parse -> parse a file into a tree
extract_definitions -> collect all definitions
extract_calls -> collect all calls
extract_imports -> collect all imports
For call extraction, it doesn't use tree-sitter's query DSL — it directly matches against node.kind(), because that's lighter and more controllable:
call_expression→ a direct call likehelper()method_call_expression→ a method call likeobj.helper()macro_invocation→ a macro call likeprintln!()
For method calls, it even attempts a "lightweight type inference": figure out what type the receiver obj is in the current scope (look at parameter declarations, the right-hand side of let bindings, etc.), and if it can be resolved, produce a target name in Type::method form.
This is a classic engineering tradeoff: don't do full type inference, but capture the most common patterns.
2.5 Two Engines: AST as the Primary, Regex as the Fallback
You'll notice code-analyzer-cli ships two directories:
src/code_analyzer/ts/ # tree-sitter extractors (preferred, 7 languages)
src/code_analyzer/regex/ # regex extractors (fallback)
This is a common engineering decision. Tree-sitter is powerful but does occasionally fail at edge cases (certain extension syntax, certain language versions, embedded DSLs). When the primary engine breaks, the regex extractor provides a "lower-fidelity but robust" fallback — you still get a roughly usable result instead of an exception and an exit.
This design philosophy has a name in compiler land: graceful degradation. Better an imperfect result than total failure.
2.6 Transition: From Trees to Graphs — Only One Step Away
By now you might be wondering: "An AST is a tree, but the call graph we're about to discuss is a graph — how do they connect?"
Easy. The extractor walks the AST of each file, turns every function_item into a "node," and turns every call_expression into an "edge." Each file produces a set of nodes and edges. Stitch the results from many files together and the cross-file edges (a call from file A to a function in file B) naturally weave the scattered small graphs into one big graph.
This stitching happens in src/code_analyzer/output.rs at Analyzer::analyze — first walk each file's extraction, turn functions/classes into graph nodes, then turn calls into edges. With this mapping in mind, Section 3 will read much more smoothly.
3. Symbols — the "Dictionary" of Code Analysis
3.1 What Is a Symbol, and Why Invent the Word?
Someone might ask: why not just say "function name" or "class name"? Why insist on calling it a "symbol"?
Because the same name doesn't mean the same thing. A project might simultaneously contain utils.parse, json.parse, and config.parse — all named parse, three entirely different entities. Names alone can't disambiguate; the name must be bound to its origin. That bound thing is the symbol:
Symbol = name + origin. It is the smallest uniquely-referenceable unit in code.
In code-analyzer-cli, the most important symbols are functions, methods, classes, structs, enums, and interfaces (trait/interface). Every symbol has two key properties:
- name: a human-readable label like
runorUserService. - location: a unique origin tag like
src/main.rs:42.
Combine them into a unified format:
src/main.rs::run
A colon (here a double colon) separates the file path and the symbol name. That's the symbol ID. All graph nodes, call edges, and analysis results reference each other through this ID.
Two notes:
- Inside the tool there are actually two ID flavors — the underlying graph builder (
src/code_analyzer/graph/build.rs) uses single-colonfile:name; the public API (symbol_idinsrc/lib.rs) uses double-colonfile::name. Don't be alarmed by this when reading source. For the external protocol, just rememberfile::name. - The three-state Resolver classification in §5 is built directly on the "symbol = name + origin" definition: when a call only has a name but no origin can be found, it can only be ambiguous or unresolved.
3.2 Definition and Reference
Many people mix up "definition" and "reference," but in code analysis they must be strictly separated:
- Definition: the place where the symbol is declared.
fn helper() {}is a definition ofhelper. - Reference: the place where the symbol is used.
helper();is a reference tohelper.
The tool provides two corresponding commands:
code-analyzer definitions . helper # Where is helper defined?
code-analyzer references . helper # Where is helper used?
This distinction sounds basic, but it maps exactly to the two high-frequency IDE shortcuts: Go to Definition and Find Usages. In plain terms: the IDE's jump and usage features are essentially queries over a prebuilt symbol index.
code-analyzer-cli's symbols / definitions / references turn that IDE capability into a CLI — you can use it on a server with no IDE, in a CI pipeline, or inside an AI Agent's conversation.
3.3 Why Paginate and Cap Output?
You'll notice every list command in code-analyzer-cli supports --limit / --offset, and the JSON output has:
"pagination": {
"limit": 50,
"offset": 0,
"total": 1234
}
Pagination has two complementary benefits:
- For humans: a terminal dumping a thousand lines at once is unreadable. 50 per page with manual flipping is much more comfortable.
- For AI Agents: LLMs have context limits. Stuffing all 12,000 symbols of a repo into one prompt either overflows the context window or squeezes out other key information. Pagination lets the Agent fetch data on demand — look at some, flip if needed — behaving very much like a human browsing.
The top of src/lib.rs defines a set of default caps:
const DEFAULT_MAX_FILES: usize = 30;
// ... and similar caps for symbols, edges, etc.
By default, output is truncated to these numbers to avoid accidentally dumping 200k lines of JSON into a terminal or prompt. Add --full when you need it all; use --limit / --offset when you want to paginate. We'll come back to this in the AI integration section.
4. From Symbols to Relationships — Call Graphs and Dependency Graphs
If we stop at symbols, code analysis is still just "fancy grep." What truly transforms it is modeling code as a graph.
4.1 Why a Graph
The two ingredients of a graph — node and edge — map naturally to code:
| Graph world | Code world |
|---|---|
| Node | Function, class, module, file |
| Directed edge | "A calls B" / "File X imports file Y" |
Once you accept the "code is a graph" view, your entire data structures and algorithms course suddenly becomes useful: BFS, DFS, connected components, shortest path, centrality, community detection — what used to be interview trivia now maps directly to engineering problems:
- Which code will a function change ripple to? → reverse BFS on the call graph.
- Which modules tend to change together? → community detection on the dependency graph.
- Which function is a hub? → sort nodes by degree.
4.2 Call Graph
The call graph is a directed graph where nodes are functions/methods and edges represent "calls." If run() calls helper(), there's an edge:
run -> helper
Edges are directional. That means you can ask two opposite questions:
- Outgoing edges: who does
runcall? — "What do I depend on?" - Incoming edges: who calls
run? — "Who depends on me?"
The corresponding code-analyzer-cli command:
code-analyzer call-graph . run --direction outgoing --depth 2
--depth N lets you walk N hops. Depth=1 is "direct relationship," Depth>1 is "indirect relationship."
4.3 Import Graph
The call graph focuses on function-level relationships. The import graph operates at the file/module level:
// In main.ts
import { helper } from "./util";
This import is a directed edge main.ts -> util.ts tagged with the symbol helper.
The import graph is typically much sparser than the call graph (a file may call a thousand functions but only import twenty modules), so it's well-suited to architecture-level visualization. For example, want to know if your project has cyclic dependencies? Find cycles in the import graph.
By default the tool only emits call edges; pass --include-imports to include import edges as well:
code-analyzer graph . --include-imports --format mermaid -f arch.mmd
Output formats: json / dot / mermaid. DOT goes to Graphviz, Mermaid embeds in Markdown, JSON is consumed by programs.
4.4 Under the Hood: petgraph
code-analyzer-cli stores graphs using petgraph, the most widely used graph crate in the Rust ecosystem. In src/code_analyzer/graph/build.rs:
pub struct GraphBuilder {
graph: DiGraph<NodeData, EdgeData>,
node_map: HashMap<String, NodeIndex>,
...
}
DiGraph is a directed graph; node_map is a lookup from string ID to internal index. When you add_edge, if an endpoint doesn't yet exist, an "unknown-type" node is added automatically:
fn add_node_if_missing(&mut self, id: &str) -> NodeIndex {
// create with node_type = "unknown" if needed
}
That's why the call graph occasionally contains node_type: "unknown" nodes — these are symbols that were "called but have no findable definition," like standard library functions or third-party functions. It's a very practical design: keep the edge information but honestly mark "I don't know this node."
(Recall that IDs here use the single-colon file:name form — the graph builder's internal convention. The public API uses double-colon file::name. We mentioned this in §3.1.)
4.5 Degree Centrality: Finding "Hub Symbols"
One of the simplest and most useful analyses you can run on a graph is degree centrality — sort nodes by their degree:
pub fn god_nodes(&self, limit: usize) -> Vec<(String, usize)> {
// sort by in_degree + out_degree, take top N
}
The code calls them god_nodes ("god-tier nodes") — an evocative name. A function with an unusually high degree is often:
- A coordinator: the main logic stringing many sub-flows together (e.g.
run,handle_request). - A common utility: a helper called from dozens of places.
- A "God Object / God Function" anti-pattern: a bloated, do-everything function — a hotbed of technical debt.
High degree ≠ bad. It's a signal telling you "this thing matters; worth a look." That's one of the central design philosophies running throughout this article: the tool offers signals; humans make judgments.
5. The Resolver — Admit What You Don't Know
This is the climax of the article. The "crashes" of many code analysis tools happen at this layer.
5.1 The Fundamental Dilemma of Call Resolution
Look at this perfectly ordinary line of code:
helper()
The extractor easily tells you "there's a call here, target name is helper." But — which helper?
- A local function defined in this file?
- A same-named function imported from another module?
- Some global function elsewhere in the project?
- A function from a third-party library?
- A standard library built-in?
- A local variable that happens to hold a function?
If the tool blindly "picks the most likely one," it makes mistakes. Wrong call relationships warp the call graph, lead to bogus impact analyses, and let AI Agents reason on top of hallucinated data — errors compound at every layer.
This echoes the definition from §3.1: Symbol = name + origin. The extractor only got the "name"; the "origin" must be supplied by the resolver. When the resolver can't supply it, it should honestly say so.
5.2 Three-State Classification: resolved / ambiguous / unresolved
code-analyzer-cli classifies every call edge's resolution result into three buckets:
- resolved_edges: a unique and safe in-project definition was found.
- ambiguous_edges: multiple candidate definitions were found; can't disambiguate.
- unresolved_edges: no safe in-project definition was found.
Note: "unresolved" doesn't mean "wrong." It means "I don't currently have enough confidence to give an answer."
resolve_calls (in src/lib.rs) goes through SymbolIndex::resolve in an ordered cascade match:
1. exact_id exact match on normalized ID confidence=high
2. same_file same-file local function confidence=high
3. js_ts_relative_import JS/TS relative import resolution confidence=high
4. python_module_import Python module import confidence=high
5. rust_use_path Rust use path confidence=medium
6. go_package_dot Go pkg.Func confidence=medium
7. global_unique_name globally unique by short name confidence=low
...
Every sub-resolver returns Some only when there's a single match. As soon as there are multiple candidates, the result is demoted to Ambiguous. This "would rather not answer than guess" policy makes resolver output highly trustworthy.
5.3 Language-Specific Resolution
The semantics of import differ wildly across languages. The tool writes per-language logic for each mainstream one:
- JS/TS relative paths:
import { x } from "./util"→ resolve./utilrelative to the caller's directory into candidates likeutil.ts/util.js/util/index.ts. - Python:
from a.b import c→ translatea.bintoa/b.pyora/b/__init__.py. - Rust:
use foo::bar→ walk the module hierarchy looking atfoo.rs/foo/mod.rs/foo/bar.rs. - Go:
pkg.Func()→ find files in the workspace ending withpkg.go/pkg/mod.go, or paths containing/pkg/.
Each rule follows the same conservative principle: only return on a unique match.
5.4 Analogy: the Resolver vs LLM "Hallucinations"
If you've built LLM applications, this tradeoff should look very familiar. Faced with uncertainty, LLMs tend to fabricate "plausible-looking" answers rather than say "I don't know." That's the famous hallucination.
The Resolver's three-state classification essentially turns "rather blank than wrong" into a first-class citizen of code analysis. A structured unresolved is much safer for downstream consumers than a resolved that looks right but isn't.
Those consumers may be humans (eyeball the low-confidence parts and judge for themselves) or AI Agents (see ambiguous / unresolved and decide whether to open the source and verify further).
5.5 The Real-World Command
code-analyzer resolve . --full
The output looks roughly like:
{
"resolved_edges": [...],
"ambiguous_edges": [...],
"unresolved_edges": [...],
"summary": {
"resolved": 1234,
"ambiguous": 56,
"unresolved": 78
}
}
This summary also feeds back into the later Review Risk score — if a PR touches a region with an unusually high unresolved ratio, the tool's knowledge of that region is shallow, so the reviewer (or Agent) should be extra cautious.
5.6 Recap: Concepts → Commands
By the end of the first five sections, you've covered all the core concepts of code analysis. But concepts are concepts; when you actually type a command, the cheat sheet below is what you should remember.
| Concept you just learned | Direct command | Typical use moment |
|---|---|---|
| Project structure / AST extraction | analyze | First time scanning a project; want one big JSON. |
| Symbols (functions/classes) | symbols | Only remember part of a name; want candidates. |
| Definition | definitions | Where exactly is this name defined? |
| Reference | references | Where is this name used? |
| Call graph (in/out edges) | call-graph | Direct/indirect relationships of function A. |
| Import graph + visualization | graph | Want an architecture diagram (DOT / Mermaid). |
| Resolver three-state | resolve | Suspect analysis isn't accurate; need confidence. |
| Blast radius (impact) | impact | Estimate the impact before changing code. |
| Overview / hubs / entries / islands | map | First command on an unfamiliar project. |
| PR risk score | review | Before opening a PR / running CI. |
| Natural-language entry finding | context | "I want to learn the payment retry logic." |
| Report generation | report | Produce an HTML / Markdown document. |
A simple mantra for this table:
Don't know where to start →
map; know the name but unsure where →definitions/references;
want relationships →call-graph/graph; want impact →impact; want risk →review.
In later parts we'll dive into the implementation details of each of these commands. But even if you only remember this table, you can already handle 80% of everyday problems.
(To be continued.)
How to Download code-analyzer-cli
Project URL: https://github.com/briancai/code-analyzer-cli.git
How to Download ZAgent
- Download from the loadskill.net website:
https://www.loadskill.net/download.html - Download from GitHub:
https://github.com/briancai/zagent-tauri/releases/tag/v1.0.1