Skip to content

Add a language backend (Go)

This guide describes how to add first-class Go support to CLDK by building the codeanalyzer-go backend and wiring it into the Python SDK. It covers the tooling, schema, and architecture decisions involved.

CLDK’s two-layer design separates the backend analyzer (emits analysis.json) from the SDK frontend (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 writing code, choose the runtime, parser, resolver, materialization format, and packaging. Go provides a standard tool for each of these.

Make this explicit in your analyzer’s README.md under an Architecture & Tooling heading so the locked choices are 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 building, check that the Go toolchain is installed:

Terminal window
go version # must be 1.21+

If not present, stop and instruct the user to install it (golang.org/dl).

Design the analyzer’s data types by mirroring the canonical schema and adding Go-specific nodes. Go’s structure differs from Java and Python: it has no classes but uses structs and interfaces, methods always carry a receiver type, interfaces are structural, and packages are the module unit.

Read the Python and Java models side by side:

  • Java: cldk/models/java/models.py (JType, JCallable, JField)
  • Python: python-sdk/cldk/models/python/__init__.py (PyClass, PyCallable)

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

For each of these language concepts, decide how to model them:

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 as the parent container

Define a GoModule, GoStruct, GoInterface, GoCallable, GoCallSite, and GoCallEdge matching 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 lock in your SCHEMA_DECISIONS.md:

  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 (even if empty at 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 invokes core.Analyzer.
  • Core orchestrator delegates to phases; never inlines analysis.
  • Symbol table builder is one focused module (or a class-like set of methods), not flat functions.
  • Framework backend (CodeQL) isolated in its own subpackage, scaffolded even when stubbed.
  • analysis/pass.go defines AnalysisPass interface and a Registry for discovery and run_pipeline.
  • frameworks/finder.go defines EntrypointFinder base for extension.

Before parsing, materialize the Go module graph so 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 symbol-table construction so go/packages.Load() can access transitive dependencies.

Degradation: If go.mod is absent or go mod download fails, log and continue. The symbol table will have unresolved callees, which is acceptable.

Walk the project’s Go packages and build symbol_table: Dict[file_path, GoModule]. Follow the pattern in the Python analyzer.

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).
    • Check cache (by file hash / mtime). If unchanged, reuse the cached GoModule; skip to 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’s a struct (*ast.StructType), create a GoStruct with fields and methods.
        • If it’s an interface (*ast.InterfaceType), create a GoInterface with method signatures.
      • For each method (receiver + FuncDecl), create a GoCallable nested in the struct/interface.
      • Collect imports, comments (via 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
}

Signature canonicalization (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
}

Verify this stage:

  • Run on a small fixture project (e.g., a module with 2-3 files).
  • Output validates against the SDK GoApplication Pydantic model (you’ll create that in Part 2).
  • symbol_table is non-empty, keyed by relative file paths.
  • A known file’s GoModule has the expected structs/functions/call sites.
  • Re-running reuses cache for unchanged files (no rebuild).

Step 6: Call graph construction (resolver-based)

Section titled “Step 6: Call graph construction (resolver-based)”

Resolve each unresolved call site using go/types. This is level 1 and inexpensive because the type resolver is already loaded.

For each recorded call site in every callable:

  1. Get the enclosing callable’s receiver type and argument types (from the AST or the symbol table).
  2. Resolve the call’s target using 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 ""
}

Verify this stage:

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

Expose the analyzer via a command-line interface matching the SDK’s expectations:

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

Version it and distribute (bundled in the SDK or downloaded on first use). Add a VERSION file or tag in Git:

Terminal window
git tag v0.1.0

Once codeanalyzer-go is working, wire it into the Python SDK. Do this 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 backend wrapper. The facade takes a backend= config object and dispatches on its type to choose a concrete backend — exactly how JavaAnalysis, PythonAnalysis, and TypeScriptAnalysis work. There is no analysis_backend_path (the binary ships packaged) and no analysis_json_path (caching is folded into the config’s cache_dir):

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):

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"

Import cache_subdir from the shared backend-config module:

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)”

Dispatch no longer lives in a single if self.language == ... ladder inside CLDK.analysis(). CLDK now selects behavior along two orthogonal axes: the language is chosen by which factory method you call (CLDK.go(...)), and the backend is chosen by the type of the backend= config object you pass. Registering a language therefore means three small additions, not one branch.

In cldk/analysis/commons/backend_config.py, add a language-specific codeanalyzer config (subclassing CodeAnalyzerConfig so it inherits cache_dir) plus a per-language GoBackend union the facade matches on. If Go will never have a Neo4j backend, the union is just the codeanalyzer config; if it might, union in Neo4jConnectionConfig (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.
"""
# e.g. use_codeql: 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 alongside CLDK.java, CLDK.python, and CLDK.typescript. It normalizes the project path and constructs the GoAnalysis facade, forwarding the backend= config untouched (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(...) is retained only for backward compatibility and is deprecated. Add a branch that forwards to your new factory, mapping the legacy keyword args onto a backend= config (folding the removed analysis_json_path into cache_dir, and ignoring the removed 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 should call CLDK.go(...) directly; the shim exists so existing CLDK(language="go").analysis(...) callers keep working.

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 analyzer version:

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

If distributing as a bundled binary, add it to cldk/analysis/go/codeanalyzer/bin/ or arrange download-on-first-use.

Before merging, verify:

  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:

    • Every edge’s source and target are real callable signatures.
    • nx.DiGraph builds successfully.
  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:

    • CLI (cmd/*/main.go) parses flags, calls core.
    • Core orchestrator delegates to phases; never inlines.
    • Symbol-table builder is one cohesive module, not flat functions.
    • Framework backend isolated in its own subpackage.
    • analysis/pass.go and frameworks/finder.go exist and are wired.
  8. Schema integrity:

    • All fields from canonical schema are present in Go models.
    • Language-specific nodes (GoStruct, GoInterface) are first-class.
    • signature is the identity key; every callable and type has one.
    • Receiver types are captured (for *T.Method vs T.Method).

After merging, consider:

  • Level-2 (CodeQL) backend: Stub is ready; implement CodeQL Go pack integration for deeper dataflow analysis.
  • Entrypoint detection: Add framework detection (e.g., gRPC services, REST handlers) and surface via get_entry_point_* methods.
  • Comments/docstrings: Extract comments from the AST and attach to callables.
  • Testing utilities: Add is_parsable and get_raw_ast if you ship a tree-sitter parser.