A Go static-analysis toolkit — the CLDK backend that emits a canonical symbol table and call graph as analysis.json.
cango is a static analyzer for Go built on golang.org/x/tools/go/packages
(AST + full type resolution). It produces the canonical CodeLLM-DevKit (CLDK) analysis.json — a
symbol table plus a resolver-based call graph — consumable by the Python SDK via
CLDK(language="go").analysis(project_path=...). It is the Go backend behind
CLDK, mirroring its
Python,
TypeScript, and
Java siblings.
The binary is fully self-contained: a single static executable with no runtime dependencies.
- Features
- Installation
- Usage
- Analysis levels
- Output schema
- Neo4j projection (
--emit neo4j) - Python SDK (CLDK) integration
- Upgrading
- Architecture & Tooling
- Development
- License
- Symbol table — packages, structs, interfaces, fields, methods, package-level functions, imports, struct tags, and generic type parameters, with precise source spans.
- Call graph — a resolver-based call graph via
go/types: each call site is resolved to its full import-path signature. Level 2 emits edges to project callees and to library callees (homed inapplication.external_symbolsundercan://<app>/@external/<import-path>/<sig>). Level 3 (-a 3) adds per-callablecfg,cdgandddg. Level 4 (-a 4) adds the interprocedural system dependence graph (SDG): parameter vertices,param_in/param_out, call-sitesummaryedges, VTA dispatch targets and library stand-ins — what the SDK's slicing andtaint()walk. - Generics-aware — Go 1.18+ generics (
Set[T], union-constraint interfaces, multi-type-param functions) are modeled in the unified type/callable schema. - Self-contained binary — a single static executable (
go build,CGO_ENABLED=0); no runtime dependencies for SDK users. - CLDK canonical schema — output is spine-compatible with the Java/Python/TypeScript analyzers, loadable directly by the Python SDK.
- Neo4j projection — the same v2 envelope projected into a property graph (
--emit neo4j), either as a replayablegraph.cyphersnapshot or pushed live over Bolt, keyed on the identicalcan://ids so JSON and graph join on one string.
Running a prebuilt cango binary requires nothing — it is fully self-contained. To analyze a
project, that project should be a normal Go module (contain a go.mod) so the type checker can
resolve imports. Building cango from source requires Go 1.26+, and the
latest Go release is recommended — see Toolchain.
Download and install the prebuilt binary for your platform from the latest release:
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/codellm-devkit/codeanalyzer-go/releases/latest/download/cango-installer.sh | shThe installer drops cango into ~/.local/bin (override with CANGO_INSTALL_DIR) and can pin a
version with CANGO_VERSION=vX.Y.Z. It also creates a codeanalyzer-go alias symlink. Supports
macOS (arm64/x86_64) and Linux (x86_64/aarch64).
brew install codellm-devkit/homebrew-tap/codeanalyzer-goThe wheel bundles the prebuilt, self-contained binary for your platform (no Go toolchain required):
pip install codeanalyzer-go
cango --helpThe wheel installs both a cango launcher and a codeanalyzer-go alias on PATH. This is also the
package CLDK's Python SDK depends on to locate the analyzer backend; it exposes
codeanalyzer_go.bin_path().
git clone https://github.com/codellm-devkit/codeanalyzer-go
cd codeanalyzer-go
go build -o cango ./cmd/codeanalyzerThis produces a single static binary cango with no runtime dependencies. You can also run the
analyzer directly from source without compiling:
go run ./cmd/codeanalyzer -i /path/to/project -a 2cango type-checks the projects it analyzes with the go/types package compiled into the
binary, so the Go that builds cango must be at least as new as the newest go line of the
projects it analyzes. Build with the latest Go release (the release binaries are). go.mod's
go 1.26.0 is only the minimum that compiles the analyzer (set by golang.org/x/tools, whose
recent releases require Go 1.26).
With a binary built by an older Go, every package that needs a newer Go type-checks only
partially, yet the run still exits 0: callees go unresolved, call edges and L3 defs are lost,
and nothing else looks wrong. cango prints one warning when this happens, naming the Go it was
built with, the highest version required and the number of affected packages:
[codeanalyzer-go WARN] 12 packages require a newer Go version (highest go1.27) than this analyzer was built with (go1.26.4); …
Rebuild cango with that Go version or newer. (-vv lists the affected packages.)
cango -i /path/to/go/projectcodeanalyzer-go produces analysis.json (symbol table + call graph) for Go projects.
Usage:
cango [flags]
Aliases:
cango, codeanalyzer-go
Flags:
-a, --analysis-level int Analysis level: 1=symbol table, 2=+call graph, 3=+intraprocedural cfg/cdg/ddg, 4=+interprocedural SDG (default 1)
--app-name string Application anchor name for can:// ids and Neo4j :Application (default: input dir name)
-c, --cache-dir string Cache directory (default: ~/.cldk/go-cache)
--eager Force clean rebuild (ignore cache)
--emit string Output projection: json|neo4j|schema (default "json")
-f, --format string Output format: json|msgpack (default "json")
--graph-field-depth int Field-path depth bound for L3 dataflow nodes (k limit; applies at -a 3 and -a 4) (default 3)
-h, --help help for cango
-i, --input string Project root to analyze (required)
-j, --jobs int Worker parallelism (default: CPU cores)
--library-models string JSON file of library stand-in flows overriding the all-to-all default (-a 4 and --emit neo4j only)
--neo4j-database string Neo4j database (env NEO4J_DATABASE, optional)
--neo4j-password string Neo4j password (env NEO4J_PASSWORD, default neo4j)
--neo4j-uri string Live Bolt push target (env NEO4J_URI); omit to write graph.cypher
--neo4j-user string Neo4j username (env NEO4J_USERNAME, default neo4j)
-o, --output string Output directory for analysis.json (default: stdout)
--skip-tests Skip *_test.go files (default true)
--strict Fail non-zero on any L4 degradation (default: degrade and record it)
-t, --target-files strings Restrict analysis to specific files (incremental mode)
-v, --verbose count Verbosity (repeat for more detail)
--version Print version and exit
Symbol table only (Level 1, default):
cango -i ./my-go-projectPrints analysis.json to stdout.
Symbol table + call graph (Level 2):
cango -i ./my-go-project -a 2Write output to a directory:
cango -i ./my-go-project -a 2 -o /path/to/output/
# Writes: /path/to/output/analysis.jsonIncremental analysis (specific files only):
cango -i ./my-go-project -t pkg/server/server.go -t pkg/server/handler.goForce rebuild, ignore cache:
cango -i ./my-go-project --eagerVerbose output:
cango -i ./my-go-project -a 2 -vvThe end-to-end steps to produce an analysis.json file for any Go project.
1. Make sure you have cango. Either install a released binary (see
Installation) or build from source:
git clone https://github.com/codellm-devkit/codeanalyzer-go
cd codeanalyzer-go
go build -o cango ./cmd/codeanalyzer2. Point it at the project root (the directory containing the app's go.mod).
cango resolves imports with the Go type checker, so the target must be a normal Go
module. Write the output to a directory with -o; cango names the file
analysis.json inside it.
# Level 2 (symbol table + call graph):
cango -i /path/to/go/app -a 2 -o /path/to/output/
# → writes /path/to/output/analysis.jsonSchema.
cangoemits the canonical CLDK v2 tree (the shape the Python SDK's v2 loader and the sections below describe). Omit-oto stream the JSON to stdout instead of writing a file.
3. (Optional) name the application anchor. --app-name sets the <app> in every
can://<app>/… id and defaults to the input directory's base name. Set it when the
directory name isn't the identity you want:
cango -i /path/to/go/app -a 2 --app-name myapp -o ./out/4. Verify. A successful run exits 0 and produces a JSON document whose envelope
carries "schema_version": "2.0.0", "language": "go", "max_level": 2, and an
application tree under application.symbol_table:
cango -i /path/to/go/app -a 2 -o ./out/ && echo "exit=$?"
python3 -c "import json; d=json.load(open('out/analysis.json')); print(d['schema_version'], d['max_level'], len(d['application']['symbol_table']), 'modules')"Notes for larger apps. Analysis parallelism defaults to your CPU count (tune with
-j); a warm cache (--cache-dir, default ~/.cldk/go-cache) makes re-runs fast, and
--eager forces a clean rebuild. Projects that use cgo (import "C") are supported:
the toolchain-synthesized wrapper functions are correctly excluded from the output (they
are build artifacts, not project source), so only your own declarations are emitted.
| Level | Flag | What runs | Status |
|---|---|---|---|
| 1 | -a 1 (default) |
Symbol table only — types, functions, call sites | Implemented |
| 2 | -a 2 |
Level 1 + resolver-based call graph via go/types |
Implemented |
| 3 | -a 3 |
Level 2 + per-callable statement nodes and cfg / cdg / ddg |
Implemented |
| 4 | -a 4 |
Level 3 + interprocedural SDG: VTA dispatch, parameter vertices, param_in / param_out, summary, port and points-to ddg, library stand-ins |
Implemented |
Level 1 loads each package with packages.NeedSyntax | NeedTypes | NeedTypesInfo and walks the AST file by file. Call sites are recorded with callee_signature = null at this stage.
Level 3 completes every callable's body with its statement, control (branch, loop,
switch, return) and synthetic (@entry, @exit) nodes around the L1 call nodes, and adds
three intra-callable edge lists: cfg (control flow, including the defer chain and panic
exception edges), cdg (control dependence from post-dominators) and ddg (syntactic
reaching definitions over k-limited access paths, prov: ["ssa"]). --graph-field-depth <k>
(default 3, minimum 1) bounds the access paths (x.f.g.h → x.f.g.* at k = 3) and is
recorded in the envelope as k_limit. It is a flag error below -a 3 and with
--emit neo4j. Callables are analyzed in parallel (-j), and the output is byte-identical
for every -j. L3 only adds to the L2 output: every L2 fact stays as it is.
Level 4 is whole-program. It builds go/ssa for the project packages over the same load and
runs VTA (golang.org/x/tools/go/callgraph/vta) over the project's functions, so interface and
function-value calls get their concrete targets: call_graph gains those edges with
prov: ["vta"] (an L2 edge is never changed). Every callable gets formal_in / formal_out
vertices (parameters, receiver, closure captures, the package variables it reads or writes,
results), and every resolved call site gets actual_in / actual_out vertices, wired into the
body's ddg (prov: ["reaching-defs"]). param_in / param_out (application scope) join each
call site to every target, including the interface method itself for an interface call, and a
bottom-up pass over the call graph's SCCs puts summary edges (actual_in → actual_out) at
each call site. Library callees get a stand-in in external_symbols (body holds only its
formal vertices; summary all inputs → all outputs unless --library-models overrides it). A
type-based may-alias oracle adds ddg edges with prov: ["points-to"]. -a 3 output is a
subset of -a 4 output, and -j 1 and -j N output are byte-identical.
-
Degradation. When L4 cannot build all it needs, it still emits and records why in the envelope's
degraded[]. Each entry has acode, an optionaldetail, and optionalpackages[]andcallables[]. The codes are:no_ssa/partial_ssa: SSA failed, in whole or for some packages;no_vta: VTA failed;no_cha: the CHA complement for library-originated values failed (its targets are dropped);partial_types: some packages have type errors;unmapped_ssa: SSA functions or calls that map to no callable;l3_skipped: callables without an L3 result, which get a pass-through summary;range_func: range-over-func, which is not modelled at L4.
It also logs one warning per entry.
--strictmakes any degradation a non-zero exit. -
--library-models <file.json>overrides stand-in summaries:{"strconv/Atoi": [], "strings/TrimSpace": [["s", "<return>"]]}. A key is the external id's<import-path>/<sig>tail, and a value is a list of[in, out]pairs ofofnames ([]= no summary edges). A model changes summaries, not statement-level reachability: inn, err := strconv.Atoi(id)the value assigned at the call site keeps its reaching-definition edge fromid, which is correct data flow. A model is therefore not a sanitizer. Cutting such a flow is the SDK taint analysis's job (codellm-devkit/.github#103). A variable cut onnis also bypassed, by implicit flow througherrand by the coarse host wiring. An entry naming anofthe external lacks falls back to all-to-all, with a warning. A key no call site targets is ignored, with a warning. The flag is valid only at-a 4and with--emit neo4j. -
-t/--target-filesis not supported at-a 4(whole-program): it is an error. Use-a 3 -t, or run without-t. -
Known limits (see
docs/design/notes/l3-dataflow-metrics.md§ L4):- library code calling back into the project (
sort.Slice,http.HandleFunc) is not an edge; - an interface call on a value that may have come out of library code (
ctx.Value(k).(Store),sync.Map.Load, a library container), directly or through project code (results, parameters, captures, fields, globals, container elements), binds every project type implementing the interface (a CHA complement). Storage is tracked by container type (every instance of a struct field), which is sound but coarser than VTA; - range-over-func is not modelled (
range_func); - summaries are close to all-to-all, by design;
summaryedges whose two ends are both<global>:vertices are omitted (codeanalyzer-go#30). Reachability is unchanged, but a depth-bounded slice can stop earlier than it would through that shortcut.
- library code calling back into the project (
Level 2 adds a resolver pass: for each call site, go/types resolves the callee to its full import-path signature (pkgImportPath.TypeName.MethodName). A project callee becomes an edge to its callable; a library callee (stdlib, a dependency, or a package vendored under vendor/) becomes an edge to an @external symbol, recorded once in application.external_symbols. A callee that is neither (an interface method of a project type, a conversion, a builtin, a call through a function value) gets no edge. Every resolved call node's callee is backfilled.
cango emits the canonical CLDK v2 shape — the shape the v2 Python SDK loader consumes
and the step-by-step section above produces.
The document is a manifest envelope wrapping one application containment tree. Every
node carries a can://<app>/… id, a kind, and a span:
{
"schema_version": "2.0.0",
"language": "go",
"max_level": 2,
"analyzer": { "name": "codeanalyzer-go", "version": "0.1.0" },
"application": {
"id": "can://myapp",
"kind": "application",
"symbol_table": {
"pkg/greeter/greeter.go": {
"id": "can://myapp/go/pkg/greeter/greeter.go",
"kind": "module",
"package": "greeter",
"span": { "start": [1, 1], "end": [40, 2], "bytes": [0, 812] },
"source": "package greeter\n\n...",
"content_hash": "…",
"imports": [ { "name": "fmt", "path": "fmt", "span": {…} } ],
"types": {
"Greeter": {
"id": "can://myapp/go/pkg/greeter/greeter.go/example.com/pkg/greeter.Greeter",
"kind": "struct",
"span": { "start": [5, 1], "end": [7, 2], "bytes": [17, 63] },
"fields": {
"Prefix": { "id": "…/Prefix", "kind": "field", "type": "string", "span": {…} }
},
"callables": {
"example.com/pkg/greeter.Greeter.Greet": {
"id": "can://myapp/go/pkg/greeter/greeter.go/example.com/pkg/greeter.Greeter/example.com/pkg/greeter.Greeter.Greet",
"kind": "method",
"signature": "example.com/pkg/greeter.Greeter.Greet",
"span": { "start": [9, 1], "end": [11, 2], "bytes": [65, 140] },
"parameters": [ { "name": "name", "type": "string", "span": {…} } ],
"return_type": "string",
"error_channel": ["error"],
"metrics": { "cyclomatic": 1 },
"body": {
"10:2": {
"kind": "call",
"id": "can://myapp/go/pkg/greeter/greeter.go/…/example.com/pkg/greeter.Greeter.Greet@10:2",
"span": { "start": [10, 2], "end": [10, 30], "bytes": [90, 118] },
"callee": "can://myapp/go/pkg/greeter/greeter.go/…/example.com/pkg/greeter.format"
}
}
}
}
}
},
"functions": {
"example.com/main.main": { "id": "…", "kind": "function", "signature": "…", "body": {…} }
}
}
},
"call_graph": [
{
"src": "can://myapp/go/main.go/…/example.com/main.main",
"dst": "can://myapp/go/pkg/greeter/greeter.go/…/example.com/pkg/greeter.Greeter.Greet",
"prov": ["go/types"],
"weight": 1
}
]
}
}Key v2 schema properties:
- Envelope —
schema_version("2.0.0"),language("go"),max_level(1–4),k_limit(L3+: the--graph-field-depthvalue),degraded[](L4, only when something degraded) andanalyzer{name,version}wrap a singleapplicationnode. - One containment tree —
application → module → type → callable → body, keyed assymbol_table(modules, by project-relative file path),types,callables,body. id— every node carries a durablecan://<app>/go/<file>/<type>/<signature>id;<app>is the--app-nameanchor. A body node is keyed byline:colin its callable'sbodymap and carriesid=<callable-id>@<key>at every level, from-a 1on.kind—module·typekindsstruct | interface | alias | defined(collapses v1'sis_interface) ·callablekindsfunction | method | lambda· bodycall.base_types: [](type, optional) — embedded type ids (struct/interface embedding — the explicit spine), as opposed to interfaces the type is computed to satisfy.span—{start:[line,col], end:[line,col], bytes:[from,to]};bytesare UTF-8 byte offsets into the owningmodule.source(source is stored once per module, every node slices it).source_file(callable, optional) — set when a method is declared in a different file than its receiver type, and on every closure (nested ones too) inside such a method; the callable stays nested under the type, and itsspan.bytes(and its body nodes' spans) indexsymbol_table[source_file].sourceinstead of the nesting module's.error_channel: []— populated fromerror-typed returns (the returns also stay inreturn_type). Closures nest ascallables{}on the enclosing callable.bodycall nodes —calleeis the sanctionednull → idslot:nullat L1; at L2 a project callable'scan://id, or for a library (stdlib or dependency) callee itscan://<app>/@external/<import-path>/<sig>id, a key ofexternal_symbols. It staysnullfor conversions, builtins, calls through function values and unresolved sites.is_goroutine/is_deferredare boolean flags emitted only when true (a plain call omits them), forgo f()/defer f().call_graphedges —{src, dst, prov, weight};srcis a callable id in the tree anddsta callable id or anexternal_symbolskey (never a raw signature);provis resolver provenance, e.g.["go/types"].external_symbols(application, L2, optional) — one entry per library callee, keyed by its@externalid:{id, kind: "external", name, module, signature, parameters[], return_type}(parameters carry no span). Absent when no library call was resolved.- L3 (
-a 3) —bodygainsstatement/branch/loop/switch/returnnodes and the synthetic@entry/@exit(which have nospan). Every body node carriesid(<callable-id>@<key>). A call nested in a statement carriesparent, the statement's key. The callable gainscfg[] {src, dst, kind},cdg[] {src, dst}andddg[] {src, dst, var, prov}, whose endpoints arebodykeys. Seedocs/design/specs/v2-schema-structure.md. - L4 (
-a 4) —bodygains span-less vertices@formal_in:<i>,@formal_out[:<i>],<call-key>/actual_in:<i>,<call-key>/actual_out[:<i>], each withof(the bound name: a parameter or receiver name,_<i>,_recv,<return>,<return>:<i>,<capture>:<name>,<global>:<pkgpath>::<Name>); actuals carryparent= the call key.ddggainsprov: ["reaching-defs"](port wiring) and["points-to"]edges; a callable gainssummary[] {src, dst}(local keys); the application gainsparam_in[]/param_out[]{src, dst, var}(global body-node ids,var= the callee formal'sof); an external gainsreceiver {name, type}and a stand-inbodyof its formal vertices (ids<external-id>@formal_in:<i>).
--emit neo4j projects the same v2 envelope into a Neo4j property graph. It is a second
projection of the identical analysis — JSON nodes and graph nodes carry the same can://<app>/…
ids, so the two join on one string and are never recomposed. The graph vocabulary is Go-prefixed
labels (:GoModule, :GoType, :GoCallable, :GoField, :GoBodyNode, :GoExternal, plus the
shared merge label :GoSymbol and marker :GoCanNode) and GO_-prefixed relationships
(GO_HAS_MODULE, GO_DECLARES, GO_HAS_METHOD, GO_HAS_FIELD, GO_CALLS, GO_RESOLVES_TO,
GO_IMPORTS, GO_EMBEDS, GO_SATISFIES, …).
There are two output paths, chosen by whether --neo4j-uri is set:
--neo4j-uri unset |
--neo4j-uri set |
|
|---|---|---|
| What happens | Writes a replayable graph.cypher snapshot |
Pushes the graph live over Bolt into a running Neo4j |
| Where | <-o dir>/graph.cypher (or stdout) |
The target database |
| Use for | Review, diffing, loading on your own schedule, CI | A DB you already have running |
Both paths are scoped to one application: every destructive statement is anchored on
:GoCanNode and the can://<app>/ id prefix, so it never touches another app (or a
sibling-language graph) in the same database. The projection is deterministic — a second emit
is byte-identical.
What a re-emit replaces depends on the path:
graph.cyphersnapshot and--eagerBolt push: the app's subgraph is purged and rebuilt, so re-emitting replaces exactly that app's subgraph.- Default (lazy) Bolt push: purely additive. Changed modules are MERGE-upserted and nothing is
deleted. After source edits, nodes and edges that the new analysis no longer emits stay in the
database. For L3 this includes
:GoBodyNodes and theirGO_CFG_NEXT/GO_CDG/GO_DDGedges, whose ids are keyed online:col: once lines shift, the old body nodes and their edges remain beside the new ones. Pass--eagerwhen you push an L3 graph after editing source. The purge needs a full run: with--target-filesit is skipped, because the push carries only the targeted packages. - L4 rows on a lazy push (D5). L4 facts are not local to a module: editing a callee changes
its callers' summaries and vertices. So a lazy push upserts every L4 row (vertices,
GO_PARAM_IN/GO_PARAM_OUT,GO_SUMMARY, portGO_DDG) on every push, not only the changed modules'. It still adds and never removes: stale L4 rows stay until an--eagerfull run. - Level changes. A lazy push whose level is above the stored
:GoApplication.max_level(a graph last pushed at L3, or with-t), or whose stored level is unreadable, upserts every module, so no L4 row is dropped for a missing L3 endpoint. A push at a lower level onto a higher graph diffs as usual and leaves the old L4 rows in place (additive). An interrupted push can leave:GoApplicationat the newmax_levelwhile some modules still hold older rows; the next lazy push then trusts the diff. Recover with an--eagerfull run. --target-files(D9). L4 is whole-program, so--emit neo4j -tprojects the targeted modules at L3 and writes no L4 rows; they keep the last full run's facts until the next full run. It warns once on stderr:--target-files: projecting the graph at level 3 (level 4 is whole-program); L4 rows keep the last full run's facts until a full run(plus; --library-models is ignored at level 3when that flag is set).
--emit neo4jis always full depth. The graph is projected at the max implemented level (L4 — symbol table, call graph, every callable's body nodes withGO_CFG_NEXT/GO_CDG/GO_DDG, and the SDG: vertex:GoBodyNodes withvar,GO_PARAM_IN/GO_PARAM_OUT {var},GO_SUMMARY, stand-in vertices under their:GoExternal, anddegraded_jsonon:GoApplication) and canonical schema v2. Do not pass-a/--analysis-levelwith it — an explicit level is a flag error (--analysis-level does not apply to --emit neo4j), because there is no shallower graph to ask for.
cango -i /path/to/go/app --emit neo4j -o ./out/
# → writes ./out/graph.cypherThe snapshot is a self-contained script: constraints + indexes, a scoped wipe of this app's subgraph,
then UNWIND … MERGE batches for nodes and relationships. Replay it into any Neo4j with
cypher-shell (see below). Omit -o to stream the Cypher to stdout.
Set --neo4j-uri (or the NEO4J_URI env var) to push directly over Bolt instead of writing a file:
./cango -i /path/to/go/app --emit neo4j \
--neo4j-uri bolt://127.0.0.1:7687 \
--neo4j-user neo4j --neo4j-password <pass>Credentials and target also read from the environment (NEO4J_URI, NEO4J_USERNAME,
NEO4J_PASSWORD, NEO4J_DATABASE), so you can keep them out of your shell history:
export NEO4J_URI=bolt://127.0.0.1:7687
export NEO4J_PASSWORD=<pass>
cango -i /path/to/go/app --emit neo4jUse
127.0.0.1rather thanlocalhostif anything else (an SSH tunnel, another DB) might also be listening on7687—localhostcan resolve to the wrong listener.
If you emitted a graph.cypher snapshot, load it into a running Neo4j with cypher-shell:
cypher-shell -a bolt://127.0.0.1:7687 -u neo4j -p <pass> < ./out/graph.cypherA convenience harness, scripts/load_and_query.sh, loads the snapshot
and runs a set of sample queries — it auto-detects cypher-shell from a Neo4j Desktop or Homebrew
install. It reads graph.cypher from its own directory, so emit the snapshot into scripts/ first
(-o scripts):
# brew install neo4j && neo4j start # (or Neo4j Desktop) — then set a password
cango -i /path/to/go/app --emit neo4j -o scripts # writes scripts/graph.cypher
NEO4J_PASSWORD=<pass> ./scripts/load_and_query.sh # load + query
NEO4J_PASSWORD=<pass> ./scripts/load_and_query.sh --query # queries only
NEO4J_PASSWORD=<pass> ./scripts/load_and_query.sh --check # just test the connectionOr explore visually in Neo4j Browser at http://127.0.0.1:7474 (connect to bolt://127.0.0.1:7687),
paste a query, and swap RETURN p for RETURN … to draw the graph.
Always scope by the can:// id prefix (an index-backed seek via the gocannode_id range index),
never by a bare label scan — replace <app> with your --app-name:
// Node counts by label.
MATCH (n:GoCanNode) RETURN labels(n)[0] AS label, count(*) AS n ORDER BY n DESC;
// Relationship counts by type.
MATCH ()-[r]->() RETURN type(r) AS rel, count(*) AS n ORDER BY n DESC;
// Everything owned by one application (prefix seek).
MATCH (n:GoCanNode) WHERE n.id STARTS WITH 'can://<app>/' RETURN count(n) AS app_nodes;
// Types ranked by method count (the "god struct" finder).
MATCH (t:GoType) OPTIONAL MATCH (t)-[:GO_HAS_METHOD]->(m)
RETURN t.kind AS kind, t.signature AS type, count(m) AS methods ORDER BY methods DESC;
// Most-called callables (in-degree) — the hot utilities.
MATCH (:GoCallable)-[:GO_CALLS]->(b:GoCallable)
RETURN b.signature AS callee, count(*) AS called_by ORDER BY called_by DESC LIMIT 15;
// Everything Command.Execute reaches, transitively (any depth).
MATCH (:GoCallable {signature:'github.com/spf13/cobra.Command.Execute'})-[:GO_CALLS*]->(b)
RETURN DISTINCT b.signature AS reachable ORDER BY reachable;
// External / stdlib packages reached via imports.
MATCH (:GoCanNode)-[:GO_IMPORTS]->(x:GoExternal) RETURN DISTINCT x.path AS import ORDER BY import;The full, commented catalog — overview/sanity, containment tree, call graph (callers, callees,
reachability, shortest path, entry points, leaves), call-site resolution, imports, inheritance,
metrics, and scoped maintenance — lives in
Eval/Neo4j_eval/queries.cypher, validated against the
spf13/cobra graph.
from cldk import CLDK
analysis = CLDK(language="go").analysis(project_path="/path/to/go/project")
for file_path, go_file in analysis.get_symbol_table().items():
print(file_path, go_file.module_name)The SDK locates the analyzer via the codeanalyzer-go command on PATH (installed by any of the
methods above, including pip install codeanalyzer-go). See
python-sdk for full API documentation.
This release changes the output contract. If you consume analysis.json or the Neo4j graph,
check the following.
- Ids are app-outermost.
can://go/<app>/<file>/…is nowcan://<app>/go/<file>/…. Library callees arecan://<app>/@external/<import-path>/<sig>. - Body nodes carry
idat every level. Each body node hasid=<callable-id>@<local-key>, from-a 1on, not only at-a 3. - L2 includes library calls. The call graph now has edges to library (stdlib and dependency)
callees, and
application.external_symbolsdescribes them. A library call'scalleeis its external id instead ofnull. A type conversion is still a call node, but itscalleestaysnull; it is no longer resolved to a<Type>.__new__pseudo-callee. - L1 records more.
- A type's
kindcan now bealiasordefined, not onlystructorinterface. - Deferred calls carry
is_deferred: true. - Chained calls that start at the same position, such as
s.A().B(), are now separate call nodes keyedl:candl:c/2. Previously the inner call was dropped. Do not parse body keys as plainline:col. - Calls inside
gostatement arguments, such asg()ingo f(g()), are now call sites. - Consumers that count call nodes will see more of them.
- A type's
- L4 (
-a 4) is new. It is additive: an-a 3consumer sees the same document plus new keys (vertices,of,summary,param_in/param_out,points-to/reaching-defsddg, VTAcall_graphedges, externalreceiver/body,degraded). Code that assumes every body node has aspan, or that everyddgedge hasprov: ["ssa"], must handle the L4 kinds.-a 4 -tis an error (whole-program; D9).--library-modelsis new and valid only at L4. Measured cost (2026-10-28, analyzer built with go1.27.3, eight real apps). On 7 of 8 apps, the peak RSS of-a 4is 1.0–1.5× that of-a 3; Kubernetes takes 38 s at 7.9 GB. nats-server is the outlier at 3.8×, because its globals fan out through one recursive 822-callable SCC. The JSON output is 1.9–23× that of-a 3. Per-app figures are indocs/design/notes/l3-dataflow-metrics.md§ L4. - Neo4j:
--emit neo4jprojects L4 (D4), addingGO_CFG_NEXT,GO_CDGandGO_DDG, and the SDG rowsGO_PARAM_IN,GO_PARAM_OUT,GO_SUMMARY, vertex:GoBodyNodes withvar, anddegraded_json.-awith it is still an error.- A lazy push upserts every L4 row on every push and removes none (D5); use
--eagerto drop stale ones.--emit neo4j -tprojects at L3 with a warning (D9). A push at a higher level than the stored graph upserts everything; a lower-level push leaves the old L4 rows. See Neo4j projection. :GoBodyNode.call_nodeis the enclosing statement's local key (the JSONparent), not a global id.--eagernow works on Neo4j 5.x.
- Build. Building from source needs Go 1.26 or newer. Release binaries are built with the latest Go (see Toolchain).
| Slot | Choice | Rationale |
|---|---|---|
| Runtime | Go binary | Self-contained; no runtime dep for SDK users |
| Structural parser | go/ast (stdlib) |
Part of the standard toolchain; no external dep |
| Type resolver | golang.org/x/tools/go/packages |
Single API for both AST + full type resolution; handles modules natively |
| Build/dep materialization | go mod download |
Required before packages.Load so the module cache is warm; result cached by go.sum hash |
| Packaging | Native binary (go build) |
Zero-runtime-dep distribution; matches Rust/C++ analyzers |
| Analysis depth | Levels 1–4 | Symbol table, resolver call graph, intraprocedural cfg/cdg/ddg, interprocedural SDG |
| Call-graph dispatch | Declared-type resolution via go/types.Selections |
CHA-equivalent; sufficient for cross-package reachability at Level 1 |
| CFG (L3) | Hand-built from go/ast (internal/dataflow) |
Adds what go/cfg does not model as edges: panic exception edges and the defer chain (defer_resume). A differential test checks reachability against golang.org/x/tools/go/cfg on every fixture body |
| Def-use / DDG (L3) | Syntactic reaching definitions over k-limited access paths | go/types objects as bases (shadowing-safe); weak defs through reference arguments; prov: ["ssa"] for parity with the Java and Python L3 |
| SSA (L4) | golang.org/x/tools/go/ssa via ssautil.Packages over the same load, project packages only, built per package under recover |
Bodies only for the project (dependencies from types); a panic degrades instead of aborting |
| Dispatch oracle (L4) | VTA (go/callgraph/vta) over the project's functions, library targets included |
Concrete targets for interface and function-value calls; an interface call also binds the interface method (pass-through) |
| Alias oracle (L4) | Type-based may-alias (structural pointee compatibility, interface pairs, address-taken values, one-level field offset) | Additive points-to ddg edges; an interface so Andersen points-to can replace it without a schema change |
codeanalyzer-go/
├── cmd/codeanalyzer/ # CLI entry point (cobra) — builds the `cango` binary
├── internal/
│ ├── core/ # Orchestrator — delegates only, no inlined analysis
│ ├── schema/ # GoApplication, GoFile, GoType, GoCallable, … (schema.go)
│ ├── options/ # AnalysisOptions + AnalysisLevel constants
│ ├── syntactic_analysis/ # SymbolTableBuilder (packages.Load → AST walk)
│ ├── semantic_analysis/ # CallGraphBuilder (go/types resolver)
│ ├── dataflow/ # L3: body nodes, CFG, CDG, def-use, DDG; Run fans out per callable
│ ├── sdg/ # L4: SSA, VTA dispatch, footprints, vertices, port wiring, summaries, stand-ins, may-alias
│ ├── analysis/ # Pluggable pass interface + registry (topo-ordered pipeline)
│ ├── frameworks/ # BaseEntrypointFinder — extension seam for framework passes
│ └── utils/ # DiscoverGoFiles, IsVendored, IsTestFile, logging
├── packaging/
│ ├── python/ # PyPI wheel wrapper (bundles the prebuilt binary per platform)
│ ├── homebrew/ # generate_formula.sh — Homebrew tap formula generator
│ └── install/ # cango-installer.sh — curl | sh installer
├── testdata/
│ ├── greeter/ # Minimal two-package fixture (basic struct/interface/call sites)
│ ├── multipackage/ # Richer fixture covering embedded fields, variadic params, goroutines, …
│ ├── generics/ # Go 1.18+ generics fixture (Set[T], union-constraint interfaces, Map[T,U])
│ └── chi/ # External-dep fixture (chi v5, vendored) for HTTP handler patterns
The core package is a pure orchestrator: it calls syntactic_analysis → semantic_analysis → analysis.RunPipeline in sequence, with no inlined parsing logic. Framework-specific analysis extends through the analysis/ + frameworks/ layer without touching core.
go test ./...Tests run against four fixtures: testdata/greeter/ (basic), testdata/multipackage/ (multi-file packages, goroutines, variadic params), testdata/generics/ (Go 1.18+ generics — Set[T], union constraints, multi-type-param functions), and testdata/chi/ (external dependency via vendored chi v5, HTTP handler patterns). All 105 tests cover symbol table correctness, generic receiver attribution, call graph edges, JSON round-trip, output format validation, caching behaviour, and error paths.
go test caches passing results by source hash. To force a full re-run:
go clean -testcache && go test ./...The analyzer's own CacheDir (used inside tests for analysis_cache.json and go_mod_hash) is written to OS temp directories that are wiped automatically when the test binary exits — there is no persistent on-disk state between test runs. The chi fixture is fully vendored, so tests never require network access.
By default the CLI writes its cache to ~/.cldk/go-cache. To bypass it for a single run:
cango -i ./my-project --eagerTo delete it entirely:
rm -rf ~/.cldk/go-cacheIf you pass a custom --cache-dir, remove that directory instead.
Releases are cut by pushing a vX.Y.Z tag. The release workflow
cross-compiles the cango binary for all supported platforms and publishes:
- the raw binaries +
cango-installer.shas GitHub Release assets, - platform-tagged wheels to PyPI as
codeanalyzer-go(via Trusted Publishing/OIDC), and - a Homebrew formula pushed to
codellm-devkit/homebrew-tap.
The version is injected into the binary at build time via -ldflags "-X main.version=<tag>", so
cango --version, the wheel version, and the git tag always stay in lockstep. See
packaging/ for the build scripts.
Apache 2.0 — see LICENSE.