Skip to content

Add a language backend (Go)

This guide shows how to add first-class Go support to CLDK. You build the codeanalyzer-go backend, then connect it to the Python SDK. The guide covers the tooling, the schema, and the architecture decisions.

The two-layer design of CLDK separates the backend, which emits analysis.json, from the SDK frontend, which is a Python query API:

graph LR
    A["Go Project<br/>(project_path)"]
    B["codeanalyzer-go<br/>(native binary)"]
    C["analysis.json<br/>(canonical schema)"]
    D["Python SDK<br/>(CLDK.go factory)"]
    E["GoAnalysis<br/>(symbol_table, call_graph)"]
    
    A -->|"go/ast + go/types"| B
    B -->|"JSON stdout/file"| C
    C -->|"subprocess + parse"| D
    D -->|"query layer"| E

Before you write code, choose the runtime, parser, resolver, materialization format, and packaging. Go has a standard tool for each of these.

Record these choices in the README.md of your backend, under an Architecture and Tooling heading, so that they stay recoverable:

## Architecture & Tooling
- **depth**: rapid (level 1): symbol table + resolver call graph; level 2 (CodeQL) stubbed
- **runtime**: Go 1.21+
- **structural**: go/ast
- **resolution**: go/types (via go/packages), loaded after `go mod download`
- **build/deps**: `go mod download` materializes the module graph
- **packaging**: `go build` → single static binary, version 0.1.0
- **extra nodes**: `GoStruct`, `GoInterface`, embedded structs/interface satisfaction in `base_classes`

Before you build, make sure that the Go toolchain is installed:

Terminal window
go version # must be 1.21+

If it is not present, install it from golang.org/dl before you continue.

Design the data types of the backend. Mirror the canonical schema, then add Go-specific nodes. Go differs from Java and Python in four ways. It has no classes, but it has structs and interfaces. Methods always carry a receiver type. Interfaces are structural. Packages are the module unit.

Read the Python and Java models side by side:

  • Java: cldk/models/java/models.py (JType, JCallable, JField)
  • Python: cldk/models/python/__init__.py, which re-exports PyClass and PyCallable from the codeanalyzer-python package (codeanalyzer.schema.py_schema)

Both models mirror the canonical spine (Module/Callable nesting, identity-only edges, signature as the primary key).

Decide how to model each of these language concepts:

ConceptJavaPythonGo: your decision
Top-level unitCompilation unit (file + package)Module (file)Package (all files in a package are siblings)
Struct/Class equivJTypePyClassGoStruct (add as a node kind. Methods are nested, fields explicit)
InterfaceJType with kind="interface"(none)GoInterface (structural. Methods are signatures, no body)
Method receiverImplicit (this)Implicit (self)Explicit (receiver type is part of the signature)
Callable signature formatpackage.ClassName.methodNamemodule.ClassName.method_namepackage.StructName.MethodName or package.FunctionName
Base classesextends + implementsBase class chainEmbedded structs + satisfied interfaces (explicit)
Packages/modulesMulti-file, flatPer-fileMulti-file module. GoPackage is the parent container

Define a GoModule, GoStruct, GoInterface, GoCallable, GoCallSite, and GoCallEdge that match the canonical invariants:

{
"symbol_table": {
"net/http/server.go": {
"file_path": "net/http/server.go",
"module_name": "net/http",
"imports": [
{ "path": "fmt", "is_local": false }
],
"functions": {
"net.http.ListenAndServe": {
"name": "ListenAndServe",
"signature": "net.http.ListenAndServe",
"parameters": [
{ "name": "addr", "type": "string" },
{ "name": "handler", "type": "Handler" }
],
"return_type": "error",
"call_sites": [
{
"method_name": "Log",
"receiver_type": "*log.Logger",
"callee_signature": null,
"start_line": 42
}
]
}
},
"structs": {
"net.http.Server": {
"name": "Server",
"signature": "net.http.Server",
"fields": [
{ "name": "Addr", "type": "string" },
{ "name": "Handler", "type": "Handler" }
],
"methods": {
"net.http.(*Server).ListenAndServe": {
"name": "ListenAndServe",
"signature": "net.http.(*Server).ListenAndServe",
"receiver_type": "*Server"
}
}
}
},
"interfaces": {
"net.http.Handler": {
"name": "Handler",
"signature": "net.http.Handler",
"methods": {
"net.http.Handler.ServeHTTP": {
"name": "ServeHTTP",
"signature": "net.http.Handler.ServeHTTP",
"parameters": [
{ "name": "w", "type": "ResponseWriter" },
{ "name": "r", "type": "*Request" }
]
}
}
}
}
}
},
"call_graph": [
{
"source": "net.http.ListenAndServe",
"target": "net.http.(*Server).ListenAndServe",
"type": "CALL_DEP",
"weight": 1,
"provenance": ["go/types"]
}
]
}

Key decisions to record in a SCHEMA_DECISIONS.md file in codeanalyzer-go:

  1. Is GoPackage a top-level container, or is GoModule (file) the unit?
  2. How do you distinguish *T.Method from T.Method in signatures?
  3. Do you materialize interface satisfaction (base_classes), or record it separately?
  4. Do struct tags get captured in a tags field?

Step 3: Scaffold the modular analyzer package

Section titled “Step 3: Scaffold the modular analyzer package”

Create the codeanalyzer-go project with a delegating architecture: a CLI entry, a core orchestrator, phase subpackages, and a pluggable pass layer. The pass layer can be empty at the start.

  • Directorycodeanalyzer-go/
    • Directorycmd/
      • Directorycodeanalyzer-go/
        • main.go (CLI entry, delegates to core)
    • Directoryinternal/
      • Directoryanalysis/
        • pass.go (AnalysisPass interface, registry)
      • Directoryframeworks/
        • finder.go (EntrypointFinder base)
      • Directorycore/
        • analyzer.go (orchestrator, delegates phases)
      • Directorysyntactic_analysis/
        • symbol_table_builder.go (per-file builder)
        • modules.go (package discovery)
      • Directorysemantic_analysis/
        • call_graph.go (go/types resolver, edges)
        • (framework_backend.go: stubbed)
    • go.mod
    • go.sum
    • README.md (Architecture & Tooling)

Checklist for modularity:

  • CLI (cmd/*/main.go) parses flags and calls core.Analyzer.
  • The core orchestrator delegates to phases. It never inlines analysis.
  • The symbol table builder is one focused module (or a class-like set of methods), not a set of flat functions.
  • The framework backend (CodeQL) has its own subpackage. Scaffold it even when it is a stub.
  • analysis/pass.go defines the AnalysisPass interface and a Registry for discovery and run_pipeline.
  • frameworks/finder.go defines EntrypointFinder base for extension.

Before you parse, materialize the Go module graph, so that go/packages can load types from dependencies.

internal/core/materialize.go
func MaterializeProject(projectDir string) error {
cmd := exec.Command("go", "mod", "download")
cmd.Dir = projectDir
if err := cmd.Run(); err != nil {
// Log and degrade gracefully; proceed with partial types if it fails
log.Printf("warning: go mod download failed, proceeding with partial types: %v", err)
return nil
}
return nil
}

Run this before you build the symbol table, so that go/packages.Load() can access transitive dependencies.

Degradation: If go.mod is absent or go mod download fails, log and continue. The symbol table then has unresolved callees. This is acceptable.

Walk the Go packages of the project and build symbol_table: Dict[file_path, GoModule]. Follow the pattern in the codeanalyzer-python backend.

Algorithm:

  1. Load packages with go/packages.Load("./...", cfg) (where cfg includes Mode: packages.NeedTypes | NeedSyntax | NeedImports)
  2. For each package, iterate over its .go files.
  3. Per file:
    • Compute a stable file_key (relative path from project root).
    • Search the cache for the file by hash or mtime. If the file is unchanged, reuse the cached GoModule and go to the next file.
    • Parse the file with go/ast → get the *ast.File.
    • Walk the AST:
      • For each top-level ast.FuncDecl, create a GoCallable (unresolved call sites with callee_signature = null).
      • For each top-level ast.GenDecl with Token == token.TYPE, extract ast.TypeSpec:
        • If it is a struct (*ast.StructType), create a GoStruct with fields and methods.
        • If it is an interface (*ast.InterfaceType), create a GoInterface with method signatures.
      • For each method (receiver and FuncDecl), create a GoCallable nested in the struct or the interface.
      • Collect imports and comments (with go/ast comment groups).
    • Assemble the GoModule and cache it.
  4. Return symbol_table[file_key] = module.

Per-file builder example (pseudocode):

func (b *SymbolTableBuilder) buildModuleFromFile(filePath string, pkg *packages.Package) (*GoModule, error) {
module := &GoModule{
FilePath: filePath,
ModuleName: pkg.PkgPath,
Imports: []GoImport{},
Functions: map[string]GoCallable{},
Structs: map[string]GoStruct{},
Interfaces: map[string]GoInterface{},
}
// Parse AST
file, err := parser.ParseFile(b.fset, filePath, nil, parser.ParseComments)
if err != nil {
return nil, err
}
// Walk declarations
for _, decl := range file.Decls {
switch d := decl.(type) {
case *ast.GenDecl:
if d.Tok == token.IMPORT {
for _, spec := range d.Specs {
importSpec := spec.(*ast.ImportSpec)
module.Imports = append(module.Imports, GoImport{
Path: importSpec.Path.Value,
})
}
} else if d.Tok == token.TYPE {
for _, spec := range d.Specs {
typeSpec := spec.(*ast.TypeSpec)
switch t := typeSpec.Type.(type) {
case *ast.StructType:
s := b.buildStruct(typeSpec.Name.Name, t, pkg)
module.Structs[s.Signature] = s
case *ast.InterfaceType:
iface := b.buildInterface(typeSpec.Name.Name, t, pkg)
module.Interfaces[iface.Signature] = iface
}
}
}
case *ast.FuncDecl:
fn := b.buildFunction(d, pkg)
module.Functions[fn.Signature] = fn
}
}
return module, nil
}

Canonicalize signatures (the primary identity mechanism):

Define a single signatureOf() function and use it everywhere:

func signatureOf(pkgPath, name string, isPointerReceiver bool, receiverType string) string {
if receiverType != "" {
if isPointerReceiver {
return fmt.Sprintf("%s.(*%s).%s", pkgPath, receiverType, name)
}
return fmt.Sprintf("%s.%s.%s", pkgPath, receiverType, name)
}
return fmt.Sprintf("%s.%s", pkgPath, name)
}

Call sites: When you encounter a function call within a function body, record it as unresolved:

type GoCallSite struct {
MethodName string
ReceiverType string
CalleeSignature *string // null until resolved
StartLine int
}

Make sure that this stage works:

  • Run it on a small fixture project, for example a module with 2 or 3 files.
  • The output validates against the GoApplication Pydantic model in the SDK. You create that model in Part 2.
  • symbol_table is non-empty, keyed by relative file paths.
  • The GoModule of a known file has the expected structs, functions, and call sites.
  • A second run reuses the cache for unchanged files, with no rebuild.

Step 6: Build the call graph with the resolver

Section titled “Step 6: Build the call graph with the resolver”

Resolve each unresolved call site with go/types. This step is level 2, the call-graph level, and it is cheap because the type resolver is already loaded.

For each recorded call site in every callable:

  1. Get the receiver type and the argument types of the enclosing callable, from the AST or the symbol table.
  2. Resolve the target of the call with go/types:
    • Use types.LookupFieldOrMethod() to resolve receiver-type dispatch (signature: LookupFieldOrMethod(T Type, addressable bool, pkg *Package, name string) (Object, bool)).
    • For unqualified names, search the package scope and imports.
  3. Backfill callee_signature in place.
  4. Emit an identity-only edge: source_sig → target_sig with provenance: ["go/types"].

Example (pseudocode):

func (r *CallGraphResolver) resolveCallSites(pkg *packages.Package, symbolTable map[string]*GoModule) []GoCallEdge {
var edges []GoCallEdge
for filePath, module := range symbolTable {
// Resolve all call sites in all callables
for _, fn := range module.Functions {
for i, site := range fn.CallSites {
resolved := r.resolveCallSite(site, pkg, fn)
if resolved != "" {
fn.CallSites[i].CalleeSignature = &resolved
edges = append(edges, GoCallEdge{
Source: fn.Signature,
Target: resolved,
Type: "CALL_DEP",
Weight: 1,
Provenance: []string{"go/types"},
})
}
}
}
// Repeat for structs/interfaces/methods
}
return edges
}
func (r *CallGraphResolver) resolveCallSite(site GoCallSite, pkg *packages.Package, caller *GoCallable) string {
// If receiver, use types.LookupFieldOrMethod with proper signature:
// LookupFieldOrMethod(T Type, addressable bool, pkg *Package, name string) (Object, bool)
if site.ReceiverType != "" {
// Resolve receiver type, then method on it
// Note: This is pseudocode; real implementation requires resolving receiverType to a types.Type first
method, _ := types.LookupFieldOrMethod(
resolveType(site.ReceiverType, pkg),
true,
pkg.Types,
site.MethodName,
)
if method != nil {
return signatureOf(pkg.PkgPath, method.Name(), isPointer, method.Type().String())
}
}
// Unqualified: search package scope
obj := pkg.Types.Scope().Lookup(site.MethodName)
if obj != nil {
return signatureOf(pkg.PkgPath, obj.Name(), false, "")
}
// Unresolved: return empty and skip the edge
return ""
}

Make sure that this stage works:

  • Every edge endpoint is a real signature in the symbol table (no dangling nodes).
  • Output still validates against the SDK model.
  • The call graph of a small fixture is non-empty and correct.

Give the backend a command-line interface that matches what the SDK expects:

cmd/codeanalyzer-go/main.go
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"github.com/codellm-devkit/codeanalyzer-go/internal/core"
)
func main() {
input := flag.String("i", "", "Project root directory")
output := flag.String("o", "", "Output directory for analysis.json")
analysisLevel := flag.String("a", "2", "Analysis level: 1=symbol table, 2=+call graph")
targetFiles := flag.String("t", "", "Comma-separated target files (optional)")
skipTests := flag.Bool("skip-tests", true, "Skip test files")
eager := flag.Bool("eager", false, "Force rebuild (ignore cache)")
cacheDir := flag.String("c", "", "Cache directory")
flagHelp := flag.Bool("h", false, "Show help")
flag.Parse()
if *flagHelp || *input == "" {
flag.PrintDefaults()
return
}
analyzer, err := core.NewAnalyzer(*input, *cacheDir, *skipTests, *eager)
if err != nil {
log.Fatalf("failed to init analyzer: %v", err)
}
result, err := analyzer.Analyze(*analysisLevel, *targetFiles)
if err != nil {
log.Fatalf("analysis failed: %v", err)
}
// Write JSON to output or stdout
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
log.Fatalf("json marshal failed: %v", err)
}
if *output == "" {
fmt.Println(string(jsonBytes))
} else {
outPath := filepath.Join(*output, "analysis.json")
if err := os.WriteFile(outPath, jsonBytes, 0644); err != nil {
log.Fatalf("write failed: %v", err)
}
}
}

Test it:

Terminal window
go build -o codeanalyzer-go ./cmd/codeanalyzer-go
./codeanalyzer-go -i /path/to/project -o /tmp/out -a 2
cat /tmp/out/analysis.json

Compile to a static binary:

Terminal window
go build -o codeanalyzer-go ./cmd/codeanalyzer-go
# Strip and compress
strip codeanalyzer-go

Give it a version. Step 13 describes how to distribute it. Add a VERSION file, or a tag in Git:

Terminal window
git tag v0.1.0

After codeanalyzer-go works, connect it to the Python SDK. Do this work on a branch, add-go-support in python-sdk.

In the SDK, add cldk/models/go/ with Pydantic models that mirror your schema and the canonical spine:

cldk/models/go/models.py
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class GoImport(BaseModel):
path: str
is_local: bool = False
class GoCallSite(BaseModel):
method_name: str
receiver_type: Optional[str] = None
argument_types: List[str] = Field(default_factory=list)
return_type: Optional[str] = None
callee_signature: Optional[str] = None
start_line: int = -1
start_column: int = -1
end_line: int = -1
end_column: int = -1
class GoCallEdge(BaseModel):
source: str
target: str
type: str = "CALL_DEP"
weight: int = 1
provenance: List[str] = Field(default_factory=list)
tags: Dict[str, Any] = Field(default_factory=dict)
class GoCallable(BaseModel):
name: str
path: Optional[str] = None
signature: str
parameters: List[Dict[str, Any]] = Field(default_factory=list)
return_type: Optional[str] = None
code: Optional[str] = None
call_sites: List[GoCallSite] = Field(default_factory=list)
receiver_type: Optional[str] = None
start_line: int = -1
end_line: int = -1
code_start_line: int = -1
class GoField(BaseModel):
name: str
type: str
is_exported: bool = False
class GoStruct(BaseModel):
name: str
signature: str
fields: List[GoField] = Field(default_factory=list)
methods: Dict[str, GoCallable] = Field(default_factory=dict)
base_classes: List[str] = Field(default_factory=list) # embedded structs + satisfied interfaces
code: Optional[str] = None
start_line: int = -1
end_line: int = -1
class GoInterface(BaseModel):
name: str
signature: str
methods: Dict[str, GoCallable] = Field(default_factory=dict)
code: Optional[str] = None
start_line: int = -1
end_line: int = -1
class GoModule(BaseModel):
file_path: str
module_name: str
imports: List[GoImport] = Field(default_factory=list)
functions: Dict[str, GoCallable] = Field(default_factory=dict)
structs: Dict[str, GoStruct] = Field(default_factory=dict)
interfaces: Dict[str, GoInterface] = Field(default_factory=dict)
content_hash: Optional[str] = None
last_modified: Optional[float] = None
file_size: int = 0
class GoApplication(BaseModel):
symbol_table: Dict[str, GoModule] = Field(default_factory=dict)
call_graph: List[GoCallEdge] = Field(default_factory=list)
entrypoints: List[Dict[str, Any]] = Field(default_factory=list)
# cldk/models/go/__init__.py
from cldk.models.go.models import (
GoApplication, GoModule, GoCallable, GoStruct, GoInterface,
GoField, GoCallSite, GoCallEdge, GoImport
)
__all__ = [
"GoApplication", "GoModule", "GoCallable", "GoStruct", "GoInterface",
"GoField", "GoCallSite", "GoCallEdge", "GoImport"
]

Add cldk/analysis/go/ with the GoAnalysis facade and the backend wrapper. The facade takes a backend= configuration object and dispatches on its type to choose a concrete backend. JavaAnalysis, PythonAnalysis, and TypeScriptAnalysis all work the same way. There is no analysis_backend_path, because the binary ships packaged. There is no analysis_json_path, because cache_dir on the configuration holds the cache:

cldk/analysis/go/go_analysis.py
from pathlib import Path
from typing import Dict, List, Optional, Union
import networkx as nx
from cldk.models.go import GoApplication, GoModule, GoCallable, GoStruct, GoInterface
from cldk.analysis.go.codeanalyzer import GoCodeanalyzer
from cldk.analysis import AnalysisLevel
# GoBackend / GoCodeAnalyzerConfig come from the shared backend-config module (see Step 11a).
from cldk.analysis.commons.backend_config import (
GoBackend,
GoCodeAnalyzerConfig,
Neo4jConnectionConfig,
)
class GoAnalysis:
"""Analysis API for Go code."""
def __init__(
self,
project_dir: Union[str, Path, None],
analysis_level: str,
target_files: Optional[List[str]],
eager_analysis: bool,
backend: Optional[GoBackend] = None,
) -> None:
self.project_dir = Path(project_dir) if project_dir else None
self.analysis_level = analysis_level
self.target_files = target_files
self.eager_analysis = eager_analysis
# Dispatch on the *type* of the backend config. Default to the codeanalyzer backend.
if backend is None:
backend = GoCodeAnalyzerConfig()
if isinstance(backend, Neo4jConnectionConfig):
# self.backend = GoNeo4jBackend(backend) # read-only Cypher client (optional)
raise NotImplementedError("Neo4j backend for Go is not implemented yet.")
elif isinstance(backend, GoCodeAnalyzerConfig):
self.backend = GoCodeanalyzer(
project_dir=self.project_dir,
cache_dir=backend.cache_dir,
analysis_level=analysis_level,
target_files=target_files,
eager=eager_analysis,
)
else:
raise TypeError(f"Unsupported backend config: {type(backend).__name__}")
self._application: Optional[GoApplication] = None
def _ensure_analysis(self) -> GoApplication:
if self._application is None:
self._application = self.backend.analyze()
return self._application
def get_application_view(self) -> GoApplication:
"""Return the full canonical Application."""
return self._ensure_analysis()
def get_symbol_table(self) -> Dict[str, GoModule]:
"""Return symbol_table: Dict[file_path, GoModule]."""
return self._ensure_analysis().symbol_table
def get_call_graph(self) -> nx.DiGraph:
"""Build and return a NetworkX DiGraph."""
app = self._ensure_analysis()
graph = nx.DiGraph()
for edge in app.call_graph:
graph.add_edge(edge.source, edge.target)
return graph
def get_call_graph_json(self) -> List[dict]:
"""Return call_graph edges as JSON-serializable dicts."""
app = self._ensure_analysis()
return [e.model_dump() for e in app.call_graph]
def get_classes(self) -> Dict[str, Union[GoStruct, GoInterface]]:
"""Return all structs and interfaces (the class-like entities)."""
app = self._ensure_analysis()
result = {}
for module in app.symbol_table.values():
result.update(module.structs)
result.update(module.interfaces)
return result
def get_struct(self, signature: str) -> Optional[GoStruct]:
"""Retrieve a struct by signature."""
classes = self.get_classes()
return classes.get(signature)
def get_methods(self, struct_signature: str) -> Dict[str, GoCallable]:
"""Return all methods in a struct."""
s = self.get_struct(struct_signature)
if s and isinstance(s, GoStruct):
return s.methods
return {}
def get_method(self, struct_signature: str, method_signature: str) -> Optional[GoCallable]:
"""Retrieve a method by struct and method signature."""
methods = self.get_methods(struct_signature)
return methods.get(method_signature)
def get_callers(self, target_signature: str) -> Dict[str, List[str]]:
"""Return all callers of a target."""
graph = self.get_call_graph()
callers = {}
for source in graph.predecessors(target_signature):
callers[source] = list(graph.successors(source))
return callers
def get_callees(self, source_signature: str) -> Dict[str, List[str]]:
"""Return all callees of a source."""
graph = self.get_call_graph()
return {source_signature: list(graph.successors(source_signature))}
def get_go_file(self, file_path: str) -> Optional[GoModule]:
"""Retrieve a Go module by file path."""
st = self.get_symbol_table()
return st.get(file_path)
def get_class_call_graph(self, struct_signature: str) -> nx.DiGraph:
"""Build a call graph restricted to a struct's methods."""
graph = self.get_call_graph()
subgraph = nx.DiGraph()
methods = self.get_methods(struct_signature)
for method_sig in methods.keys():
subgraph.add_node(method_sig)
for callee in graph.successors(method_sig):
if callee in methods:
subgraph.add_edge(method_sig, callee)
return subgraph
def get_class_hierarchy(self) -> nx.DiGraph:
"""Build a type hierarchy (base_classes → subclasses)."""
app = self._ensure_analysis()
hierarchy = nx.DiGraph()
for module in app.symbol_table.values():
for struct in module.structs.values():
hierarchy.add_node(struct.signature)
for base in struct.base_classes:
hierarchy.add_edge(struct.signature, base)
for iface in module.interfaces.values():
hierarchy.add_node(iface.signature)
return hierarchy

Backend wrapper (subprocess). Map the AnalysisLevel name to the integer that -a takes with analyzer_level() from cldk/analysis/commons/levels.py, as the other wrappers do:

cldk/analysis/go/codeanalyzer/codeanalyzer.py
import json
import subprocess
import tempfile
from pathlib import Path
from typing import Optional, Union
from cldk.models.go import GoApplication
class GoCodeanalyzer:
"""Wrapper for the codeanalyzer-go subprocess binary."""
def __init__(
self,
project_dir: Union[str, Path, None],
cache_dir: Union[str, Path, None],
analysis_level: str,
target_files: Optional[list],
eager: bool,
) -> None:
self.project_dir = Path(project_dir) if project_dir else None
# Resolve the language-keyed cache directory: <cache_dir or project/.codeanalyzer>/go
self.cache_dir = cache_subdir(cache_dir, self.project_dir, "go")
self.analysis_level = analysis_level
self.target_files = target_files
self.eager = eager
def analyze(self) -> GoApplication:
"""Run the analyzer and return GoApplication."""
out_dir = self.cache_dir # cached artifacts; reused unless eager
out_dir.mkdir(parents=True, exist_ok=True)
cmd = [
self._get_binary_path(),
"-i", str(self.project_dir),
"-o", str(out_dir),
"-a", self.analysis_level,
"-c", str(out_dir),
]
if self.eager:
cmd.append("--eager")
if self.target_files:
cmd.extend(["-t", ",".join(self.target_files)])
subprocess.run(cmd, capture_output=True, text=True, check=True)
# Read analysis.json
analysis_path = out_dir / "analysis.json"
with open(analysis_path) as f:
data = json.load(f)
return GoApplication(**data)
def _get_binary_path(self) -> str:
"""Locate the packaged codeanalyzer-go binary.
The binary ships with the SDK (downloaded/pinned at install time), so there is no
``analysis_backend_path`` override. (For comparison, the TypeScript backend allows a
``$CODEANALYZER_TS_BIN`` env override for local development.)
"""
# TODO: resolve the bundled, version-pinned binary; fall back to PATH for development.
return "codeanalyzer-go"

The wrapper above calls cache_subdir. Import it from cldk/analysis/commons/backend_config.py:

from cldk.analysis.commons.backend_config import cache_subdir

Export from cldk/analysis/go/__init__.py:

from cldk.analysis.go.go_analysis import GoAnalysis
__all__ = ["GoAnalysis"]

Step 11: Register the language (config + factory + shim)

Section titled “Step 11: Register the language (config + factory + shim)”

CLDK selects behavior along two independent axes. The factory method that you call selects the language, for example CLDK.go(...). The type of the backend= configuration object that you pass selects the backend. To register a language, you therefore make three small additions, not one branch.

In cldk/analysis/commons/backend_config.py, add a language-specific codeanalyzer configuration. It subclasses CodeAnalyzerConfig, so it inherits cache_dir. Then add a per-language GoBackend union that the facade matches on. If Go never gets a Neo4j backend, the union holds only the codeanalyzer configuration. If Go can have one, add Neo4jConnectionConfig, which is already defined there:

cldk/analysis/commons/backend_config.py
@dataclass
class GoCodeAnalyzerConfig(CodeAnalyzerConfig):
"""Select the in-process codeanalyzer backend for Go.
Inherits ``cache_dir`` from CodeAnalyzerConfig; add Go-only knobs here if needed.
"""
# for example use_ray: bool = False
# Per-language discriminated union the GoAnalysis facade dispatches on.
GoBackend = Union[GoCodeAnalyzerConfig, Neo4jConnectionConfig]
# Register the cache key so artifacts land under <cache_dir>/go/
_CACHE_KEYS["go"] = "go"

This is the same shape as the existing JavaBackend, PyBackend, and TSBackend unions. The facade in Step 10 dispatches on isinstance(backend, ...).

Step 11b: Add the CLDK.go(...) factory method

Section titled “Step 11b: Add the CLDK.go(...) factory method”

In cldk/core.py, add a @staticmethod factory beside CLDK.java, CLDK.python, and CLDK.typescript. It normalizes the project path and builds the GoAnalysis facade. It forwards the backend= configuration without a change, and the facade picks the concrete backend from its type:

# cldk/core.py (add to imports)
from cldk.analysis.go import GoAnalysis
from cldk.analysis.commons.backend_config import GoBackend
# In class CLDK, next to java()/python()/typescript():
@staticmethod
def go(
project_path: str | Path | None = None,
*,
analysis_level: str = AnalysisLevel.symbol_table,
target_files: List[str] | None = None,
eager: bool = False,
backend: GoBackend | None = None,
) -> GoAnalysis:
"""Create a Go analysis facade."""
return GoAnalysis(
project_dir=_normalize_project_path(project_path),
analysis_level=analysis_level,
target_files=target_files,
eager_analysis=eager,
backend=backend,
)

CLDK(language).analysis(...) exists only for backward compatibility, and it is deprecated. Add a branch that forwards to your new factory. Map the legacy keyword arguments onto a backend= configuration. Use the legacy analysis_json_path as the cache root when cache_dir is absent. Ignore the legacy analysis_backend_path:

# In CLDK.analysis(), alongside the other language branches:
elif self.language == "go":
backend = neo4j_config if neo4j_config is not None else GoCodeAnalyzerConfig(cache_dir=cache_root)
return CLDK.go(
project_path=project_path,
analysis_level=analysis_level,
target_files=target_files,
eager=eager,
backend=backend,
)

New code must call CLDK.go(...) directly. The shim branch exists for parity with the other languages, so that CLDK(language="go").analysis(...) forwards to the factory and does not raise NotImplementedError.

Create tests/analysis/go/ and mock the backend:

tests/analysis/go/test_go_analysis.py
import json
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from cldk.analysis.go import GoAnalysis
from cldk.analysis.go.codeanalyzer import GoCodeanalyzer
from cldk.models.go import GoApplication
@pytest.fixture
def fixture_analysis_json():
"""Load a fixture analysis.json for a small Go project."""
fixture_dir = Path(__file__).parent.parent.parent / "resources" / "go" / "analysis_json"
with open(fixture_dir / "analysis.json") as f:
return json.load(f)
def test_go_analysis_symbol_table(fixture_analysis_json):
"""Test get_symbol_table returns non-empty dict."""
with patch.object(GoCodeanalyzer, "analyze") as mock_analyze:
mock_analyze.return_value = GoApplication(**fixture_analysis_json)
analysis = GoAnalysis(
project_dir="/tmp/fixture",
analysis_level="2",
target_files=None,
eager_analysis=False,
) # backend=None -> default GoCodeAnalyzerConfig
st = analysis.get_symbol_table()
assert len(st) > 0
# Spot-check a module
first_module = next(iter(st.values()))
assert first_module.module_name
def test_go_analysis_call_graph(fixture_analysis_json):
"""Test get_call_graph builds a valid NetworkX graph."""
with patch.object(GoCodeanalyzer, "analyze") as mock_analyze:
mock_analyze.return_value = GoApplication(**fixture_analysis_json)
analysis = GoAnalysis(
project_dir="/tmp/fixture",
analysis_level="2",
target_files=None,
eager_analysis=False,
) # backend=None -> default GoCodeAnalyzerConfig
graph = analysis.get_call_graph()
# Verify no dangling edges
for source, target in graph.edges():
# Both endpoints should be callable signatures
# (This is a loose check; you'd validate harder in real tests)
assert isinstance(source, str)
assert isinstance(target, str)

Create fixture JSON under tests/resources/go/analysis_json/analysis.json.

In pyproject.toml, pin the backend version:

[tool.backend-versions]
codeanalyzer-go = "0.1.0"

Ship the binary as its own PyPI package, codeanalyzer-go, in the same way as codeanalyzer-java and codeanalyzer-typescript. Add it to [project.dependencies] or to an extra in pyproject.toml. Make _get_binary_path() import that package to find the binary. Compare codeanalyzer_typescript.bin_path() in cldk/analysis/typescript/codeanalyzer/codeanalyzer.py.

Before you merge, make sure that all of these are true:

  1. Backend works standalone:

    Terminal window
    codeanalyzer-go -i /path/to/go/project -o /tmp/out -a 2
    cat /tmp/out/analysis.json # valid JSON, no errors
  2. Output validates:

    from cldk.models.go import GoApplication
    with open("/tmp/out/analysis.json") as f:
    app = GoApplication(**json.load(f)) # no pydantic errors
  3. Symbol table is non-empty:

    • app.symbol_table has at least one module.
    • Each module has expected structs/functions/imports.
  4. Call graph has no dangling nodes:

    • The source and target of every edge are real callable signatures.
    • nx.DiGraph builds without an error.
  5. SDK API works:

    from cldk import CLDK
    analysis = CLDK.go(project_path="/path/to/fixture")
    st = analysis.get_symbol_table()
    assert len(st) > 0
    graph = analysis.get_call_graph()
    assert graph.number_of_nodes() > 0
  6. Tests pass:

    Terminal window
    cd python-sdk
    uv run pytest tests/analysis/go/ -v
  7. Modularity checklist:

    • The CLI (cmd/*/main.go) parses flags and calls the core.
    • The core orchestrator delegates to phases. It never inlines analysis.
    • The symbol-table builder is one cohesive module, not a set of flat functions.
    • The framework backend has its own subpackage.
    • analysis/pass.go and frameworks/finder.go exist and are wired.
  8. Schema integrity:

    • All fields from the canonical schema are present in the Go models.
    • Language-specific nodes (GoStruct, GoInterface) are first-class.
    • signature is the identity key. Every callable and type has one.
    • The backend captures receiver types, so that *T.Method differs from T.Method.

After you merge, consider this work:

  • CodeQL framework backend: The stub is ready. Implement the CodeQL Go pack integration for deeper dataflow analysis (AnalysisLevel.program_dependency_graph and above).
  • Entrypoint detection: Add framework detection, for example for gRPC services and REST handlers, and return the results through the get_entry_point_* methods.
  • Comments and docstrings: Extract comments from the AST and attach them to callables.
  • Testing utilities: If you ship a Tree-sitter parser, add is_parsable and get_raw_ast.