From 5abe90adc7e42bd8ae1fd6b0a1a7701ab473d127 Mon Sep 17 00:00:00 2001 From: Saurabh Sinha Date: Wed, 17 Jun 2026 10:06:33 -0400 Subject: [PATCH 1/7] feat: add Go language support via codeanalyzer-go subprocess backend Wires CLDK(language="go") end to end: Models (cldk/models/go/): - 9 Pydantic models mirroring codeanalyzer-go schema: GoApplication, GoFile, GoType, GoCallable, GoCallsite, GoCallEdge, GoField, GoParameter, GoImport, GoComment, GoSymbol, GoVariableDeclaration, GoEntrypoint. - _NullSafeBase coerces JSON null to empty list/dict for nil-slice serialization from Go. - GoFile.types / .package_name aliases for spine compatibility. Analysis facade (cldk/analysis/go/): - GoCodeanalyzer subprocess wrapper: shells out to codeanalyzer-go binary discovered via shutil.which(); passes --analysis-level as integer (1/2), not enum string. - GoAnalysis facade: get_application_view, get_symbol_table, get_file, get_all_types, get_types_in_file, get_type, get_all_callables, get_callables_in_file, get_callable, get_call_graph (nx.DiGraph), get_callers, get_callees, get_call_graph_edges. Core dispatch (cldk/core.py): - elif language == "go" branch; rejects source_code mode. pyproject.toml: codeanalyzer-go = "0.1.0" pinned under [tool.backend-versions]. Tests: - 17 mocked SDK tests (backend patched, no binary required). - 25 E2E tests against a multi-package Go fixture; skipif when codeanalyzer-go is absent from PATH. - Fixture: calc (Calculator, Operator interface, embedded field, pointer/value receivers, multi-return, cross-file method) + pipeline (goroutine, variadic, cyclomatic complexity) + main.go. Signed-off-by: Saurabh Sinha --- cldk/analysis/go/__init__.py | 21 + cldk/analysis/go/codeanalyzer/__init__.py | 21 + cldk/analysis/go/codeanalyzer/codeanalyzer.py | 213 ++++++++++ cldk/analysis/go/go_analysis.py | 229 ++++++++++ cldk/core.py | 16 +- cldk/models/go/__init__.py | 49 +++ cldk/models/go/models.py | 212 ++++++++++ pyproject.toml | 1 + tests/analysis/go/__init__.py | 0 tests/analysis/go/test_go_analysis.py | 317 ++++++++++++++ tests/analysis/go/test_go_e2e.py | 397 ++++++++++++++++++ tests/resources/go/application/calc/calc.go | 51 +++ .../go/application/calc/formatter.go | 19 + tests/resources/go/application/go.mod | 3 + tests/resources/go/application/main.go | 31 ++ .../go/application/pipeline/pipeline.go | 69 +++ uv.lock | 2 +- 17 files changed, 1649 insertions(+), 2 deletions(-) create mode 100644 cldk/analysis/go/__init__.py create mode 100644 cldk/analysis/go/codeanalyzer/__init__.py create mode 100644 cldk/analysis/go/codeanalyzer/codeanalyzer.py create mode 100644 cldk/analysis/go/go_analysis.py create mode 100644 cldk/models/go/__init__.py create mode 100644 cldk/models/go/models.py create mode 100644 tests/analysis/go/__init__.py create mode 100644 tests/analysis/go/test_go_analysis.py create mode 100644 tests/analysis/go/test_go_e2e.py create mode 100644 tests/resources/go/application/calc/calc.go create mode 100644 tests/resources/go/application/calc/formatter.go create mode 100644 tests/resources/go/application/go.mod create mode 100644 tests/resources/go/application/main.go create mode 100644 tests/resources/go/application/pipeline/pipeline.go diff --git a/cldk/analysis/go/__init__.py b/cldk/analysis/go/__init__.py new file mode 100644 index 0000000..e806951 --- /dev/null +++ b/cldk/analysis/go/__init__.py @@ -0,0 +1,21 @@ +################################################################################ +# Copyright IBM Corporation 2024 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +"""Go analysis package.""" + +from cldk.analysis.go.go_analysis import GoAnalysis + +__all__ = ["GoAnalysis"] diff --git a/cldk/analysis/go/codeanalyzer/__init__.py b/cldk/analysis/go/codeanalyzer/__init__.py new file mode 100644 index 0000000..717711a --- /dev/null +++ b/cldk/analysis/go/codeanalyzer/__init__.py @@ -0,0 +1,21 @@ +################################################################################ +# Copyright IBM Corporation 2024 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +"""Go analysis backend package.""" + +from cldk.analysis.go.codeanalyzer.codeanalyzer import GoCodeanalyzer + +__all__ = ["GoCodeanalyzer"] diff --git a/cldk/analysis/go/codeanalyzer/codeanalyzer.py b/cldk/analysis/go/codeanalyzer/codeanalyzer.py new file mode 100644 index 0000000..25c81be --- /dev/null +++ b/cldk/analysis/go/codeanalyzer/codeanalyzer.py @@ -0,0 +1,213 @@ +################################################################################ +# Copyright IBM Corporation 2024 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +"""Go analysis backend that wraps the ``codeanalyzer-go`` binary. + +This module provides :class:`GoCodeanalyzer`, the subprocess driver for Go static +analysis. It shells out to the ``codeanalyzer-go`` native binary (analogous to +Java shelling out to the ``codeanalyzer-*.jar``), reads the produced +``analysis.json``, and deserializes it into a :class:`~cldk.models.go.GoApplication`. + +The binary must be on ``PATH`` or provided via ``analysis_backend_path``. +""" + +import json +import logging +import shlex +import subprocess +from importlib import resources +from pathlib import Path +from subprocess import CompletedProcess +from typing import Dict, List, Optional, Union + +from cldk.analysis import AnalysisLevel +from cldk.models.go.models import GoApplication, GoCallable, GoFile, GoType +from cldk.utils.exceptions.exceptions import CodeanalyzerExecutionException + +logger = logging.getLogger(__name__) + +_BINARY_NAME = "codeanalyzer-go" + + +class GoCodeanalyzer: + """Subprocess driver for the ``codeanalyzer-go`` native binary. + + Args: + project_dir: Path to the root of the Go project (must contain ``go.mod``). + analysis_backend_path: Directory containing the ``codeanalyzer-go`` binary. + When ``None``, the binary must be on ``PATH``. + analysis_json_path: Directory where ``analysis.json`` should be written. + When ``None``, the binary is invoked with ``--output`` pointing at a + temporary directory and results are read from there. + analysis_level: ``"symbol_table"`` (level 1) or ``"call_graph"`` (level 2). + eager_analysis: When ``True``, always re-run the binary even if a cached + ``analysis.json`` already exists. + cache_dir: Optional path passed to the binary's ``--cache`` flag. + """ + + def __init__( + self, + project_dir: Union[str, Path], + analysis_backend_path: Union[str, Path, None], + analysis_json_path: Union[str, Path, None], + analysis_level: str, + eager_analysis: bool, + cache_dir: Union[str, Path, None] = None, + ) -> None: + self.project_dir = Path(project_dir) + self.analysis_backend_path = Path(analysis_backend_path) if analysis_backend_path else None + self.analysis_json_path = Path(analysis_json_path) if analysis_json_path else None + self.analysis_level = analysis_level + self.eager_analysis = eager_analysis + self.cache_dir = Path(cache_dir) if cache_dir else None + self.application: GoApplication = self._init_codeanalyzer() + + # ── Binary resolution ────────────────────────────────────────────────────── + + def _get_codeanalyzer_exec(self) -> List[str]: + """Return the shell argv list for the codeanalyzer-go binary.""" + if self.analysis_backend_path: + binary = self.analysis_backend_path / _BINARY_NAME + if not binary.exists(): + # Try without extension and with .exe on Windows. + binary_exe = self.analysis_backend_path / f"{_BINARY_NAME}.exe" + if binary_exe.exists(): + binary = binary_exe + else: + raise CodeanalyzerExecutionException( + f"codeanalyzer-go binary not found in {self.analysis_backend_path}" + ) + return [str(binary)] + + # Fall back to PATH. + import shutil + found = shutil.which(_BINARY_NAME) + if found is None: + raise CodeanalyzerExecutionException( + f"'{_BINARY_NAME}' not found on PATH and no analysis_backend_path provided. " + "Install codeanalyzer-go or pass analysis_backend_path." + ) + return [found] + + # ── Init / cache check ───────────────────────────────────────────────────── + + @staticmethod + def _level_flag(analysis_level: str) -> str: + # Binary --analysis-level expects an integer: 1=symbol_table, 2=call_graph. + if analysis_level == AnalysisLevel.call_graph: + return "2" + return "1" + + @staticmethod + def _check_existing_analysis(analysis_json_path_file: Path, analysis_level: str) -> bool: + if not analysis_json_path_file.exists(): + return False + try: + with open(analysis_json_path_file) as f: + data = json.load(f) + if analysis_level == AnalysisLevel.call_graph and not data.get("call_graph"): + return False + if "symbol_table" not in data: + return False + return True + except (json.JSONDecodeError, OSError): + return False + + def _init_codeanalyzer(self) -> GoApplication: + exec_cmd = self._get_codeanalyzer_exec() + level_flag = self._level_flag(self.analysis_level) + + if self.analysis_json_path is None: + # Pipe mode: write to a temp location the caller manages via analysis_json_path. + # In practice, callers always pass analysis_json_path; this branch is a safety net. + import tempfile + tmp_dir = tempfile.mkdtemp(prefix="cldk-go-") + return self._run_and_parse(exec_cmd, level_flag, Path(tmp_dir)) + + analysis_file = self.analysis_json_path / "analysis.json" + needs_run = self.eager_analysis or not self._check_existing_analysis(analysis_file, self.analysis_level) + + if needs_run: + self._run_and_parse(exec_cmd, level_flag, self.analysis_json_path) + + with open(analysis_file) as f: + return GoApplication(**json.load(f)) + + def _run_and_parse(self, exec_cmd: List[str], level_flag: str, output_dir: Path) -> GoApplication: + output_dir.mkdir(parents=True, exist_ok=True) + args = exec_cmd + [ + "--input", str(self.project_dir), + "--output", str(output_dir), + "--analysis-level", level_flag, + ] + if self.cache_dir: + args += ["--cache", str(self.cache_dir)] + + logger.info("Running codeanalyzer-go: %s", " ".join(args)) + try: + result: CompletedProcess = subprocess.run( + args, + capture_output=True, + text=True, + check=True, + ) + except subprocess.CalledProcessError as e: + raise CodeanalyzerExecutionException( + f"codeanalyzer-go failed (exit {e.returncode}):\n{e.stderr}" + ) from e + except Exception as e: + raise CodeanalyzerExecutionException(str(e)) from e + + analysis_file = output_dir / "analysis.json" + if not analysis_file.exists(): + raise CodeanalyzerExecutionException( + "codeanalyzer-go did not produce analysis.json" + ) + with open(analysis_file) as f: + return GoApplication(**json.load(f)) + + # ── Public accessors ─────────────────────────────────────────────────────── + + def get_application(self) -> GoApplication: + return self.application + + def get_symbol_table(self) -> Dict[str, GoFile]: + return self.application.symbol_table + + def get_all_files(self) -> Dict[str, GoFile]: + return self.application.symbol_table + + def get_file(self, file_path: str) -> Optional[GoFile]: + return self.application.symbol_table.get(file_path) + + def get_all_types(self) -> Dict[str, GoType]: + """Return all types across all files, keyed by qualified name.""" + result: Dict[str, GoType] = {} + for file_path, go_file in self.application.symbol_table.items(): + for type_name, go_type in go_file.classes.items(): + result[f"{go_file.module_name}.{type_name}"] = go_type + return result + + def get_all_callables(self) -> Dict[str, GoCallable]: + """Return all top-level functions and type methods across all files.""" + result: Dict[str, GoCallable] = {} + for _, go_file in self.application.symbol_table.items(): + for sig, fn in go_file.functions.items(): + result[sig] = fn + for _, go_type in go_file.classes.items(): + for sig, method in go_type.methods.items(): + result[sig] = method + return result diff --git a/cldk/analysis/go/go_analysis.py b/cldk/analysis/go/go_analysis.py new file mode 100644 index 0000000..da78bff --- /dev/null +++ b/cldk/analysis/go/go_analysis.py @@ -0,0 +1,229 @@ +################################################################################ +# Copyright IBM Corporation 2024 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +"""Go analysis facade. + +This module provides :class:`GoAnalysis`, the primary interface for performing +static analysis on Go projects. It mirrors the method surface of +:class:`~cldk.analysis.java.JavaAnalysis` and +:class:`~cldk.analysis.python.PythonAnalysis` to provide a consistent +cross-language experience. + +The facade delegates to :class:`~cldk.analysis.go.codeanalyzer.GoCodeanalyzer`, +which shells out to the ``codeanalyzer-go`` native binary. + +Key capabilities: + - Symbol table access (files, types, functions/methods) + - Call graph construction as a NetworkX directed graph + - Caller/callee relationship queries + - Type and method lookup + +See Also: + - :class:`~cldk.analysis.java.JavaAnalysis`: Java equivalent. + - :class:`~cldk.analysis.python.PythonAnalysis`: Python equivalent. + - :class:`~cldk.analysis.go.codeanalyzer.GoCodeanalyzer`: Backend. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Dict, List, Optional, Union + +import networkx as nx + +from cldk.analysis.go.codeanalyzer import GoCodeanalyzer +from cldk.models.go.models import ( + GoApplication, + GoCallEdge, + GoCallable, + GoFile, + GoType, +) + + +class GoAnalysis: + """Analysis facade for Go projects. + + Provides a unified interface for inspecting the symbol table and call graph + produced by the ``codeanalyzer-go`` backend. + + Args: + project_dir: Path to the root of the Go project (must contain ``go.mod``). + analysis_backend_path: Directory containing the ``codeanalyzer-go`` binary. + When ``None``, the binary must be on ``PATH``. + analysis_json_path: Directory where ``analysis.json`` should be written. + analysis_level: ``"symbol_table"`` (default) or ``"call_graph"``. + eager_analysis: When ``True``, always re-run the binary. + cache_dir: Optional cache directory passed to the binary. + """ + + def __init__( + self, + project_dir: Union[str, Path, None], + analysis_backend_path: Union[str, None], + analysis_json_path: Union[str, Path, None], + analysis_level: str, + eager_analysis: bool, + cache_dir: Union[str, Path, None] = None, + ) -> None: + self.project_dir = project_dir + self.analysis_level = analysis_level + self.analysis_json_path = analysis_json_path + self.analysis_backend_path = analysis_backend_path + self.eager_analysis = eager_analysis + self.cache_dir = cache_dir + self.backend: GoCodeanalyzer = GoCodeanalyzer( + project_dir=self.project_dir, + analysis_backend_path=self.analysis_backend_path, + analysis_json_path=self.analysis_json_path, + analysis_level=self.analysis_level, + eager_analysis=self.eager_analysis, + cache_dir=self.cache_dir, + ) + self._call_graph: Optional[nx.DiGraph] = None + + # ── Application / symbol table ───────────────────────────────────────────── + + def get_application_view(self) -> GoApplication: + """Return the complete analyzed application model.""" + return self.backend.get_application() + + def get_symbol_table(self) -> Dict[str, GoFile]: + """Return the symbol table mapping relative file paths to :class:`GoFile` objects.""" + return self.backend.get_symbol_table() + + def get_file(self, file_path: str) -> Optional[GoFile]: + """Return the :class:`GoFile` for a given relative file path, or ``None``.""" + return self.backend.get_file(file_path) + + # ── Types ────────────────────────────────────────────────────────────────── + + def get_all_types(self) -> Dict[str, GoType]: + """Return all named types (structs and interfaces) keyed by qualified name.""" + return self.backend.get_all_types() + + def get_types_in_file(self, file_path: str) -> Dict[str, GoType]: + """Return all named types defined in a specific file. + + Args: + file_path: Relative file path as it appears in the symbol table. + + Returns: + A ``{type_name: GoType}`` dict, or an empty dict if the file is not found. + """ + go_file = self.backend.get_file(file_path) + if go_file is None: + return {} + return go_file.classes + + def get_type(self, file_path: str, type_name: str) -> Optional[GoType]: + """Return a specific type by file path and type name. + + Args: + file_path: Relative file path as it appears in the symbol table. + type_name: Unqualified type name (e.g. ``"Server"``). + + Returns: + The :class:`GoType`, or ``None`` if not found. + """ + types = self.get_types_in_file(file_path) + return types.get(type_name) + + # ── Callables ────────────────────────────────────────────────────────────── + + def get_all_callables(self) -> Dict[str, GoCallable]: + """Return all functions and methods across all files, keyed by signature.""" + return self.backend.get_all_callables() + + def get_callables_in_file(self, file_path: str) -> Dict[str, GoCallable]: + """Return all top-level functions and type methods defined in a specific file. + + Args: + file_path: Relative file path. + + Returns: + A ``{signature: GoCallable}`` dict. + """ + go_file = self.backend.get_file(file_path) + if go_file is None: + return {} + callables: Dict[str, GoCallable] = dict(go_file.functions) + for go_type in go_file.classes.values(): + callables.update(go_type.methods) + return callables + + def get_callable(self, signature: str) -> Optional[GoCallable]: + """Return a callable by its fully-qualified signature, or ``None``.""" + return self.backend.get_all_callables().get(signature) + + # ── Call graph ───────────────────────────────────────────────────────────── + + def get_call_graph(self) -> nx.DiGraph: + """Return the project call graph as a NetworkX directed graph. + + Nodes are callable signatures (strings). Edges carry the attributes + from :class:`GoCallEdge` (``type``, ``weight``, ``provenance``). + + Returns: + A ``networkx.DiGraph`` of method call relationships. + """ + if self._call_graph is None: + self._call_graph = self._build_call_graph() + return self._call_graph + + def _build_call_graph(self) -> nx.DiGraph: + cg = nx.DiGraph() + for edge in self.backend.get_application().call_graph: + cg.add_edge( + edge.source, + edge.target, + type=edge.type, + weight=edge.weight, + provenance=edge.provenance, + ) + return cg + + def get_callers(self, target_signature: str) -> List[str]: + """Return signatures of all callables that call ``target_signature``. + + Args: + target_signature: Fully-qualified signature of the callee. + + Returns: + List of caller signatures. + """ + cg = self.get_call_graph() + if target_signature not in cg: + return [] + return list(cg.predecessors(target_signature)) + + def get_callees(self, source_signature: str) -> List[str]: + """Return signatures of all callables called by ``source_signature``. + + Args: + source_signature: Fully-qualified signature of the caller. + + Returns: + List of callee signatures. + """ + cg = self.get_call_graph() + if source_signature not in cg: + return [] + return list(cg.successors(source_signature)) + + def get_call_graph_edges(self) -> List[GoCallEdge]: + """Return the raw call graph edge list from the analyzed application.""" + return self.backend.get_application().call_graph diff --git a/cldk/core.py b/cldk/core.py index 2dc5677..a17987e 100644 --- a/cldk/core.py +++ b/cldk/core.py @@ -46,6 +46,7 @@ from cldk.analysis import AnalysisLevel from cldk.analysis.c import CAnalysis +from cldk.analysis.go import GoAnalysis from cldk.analysis.java import JavaAnalysis from cldk.analysis.commons.treesitter import TreesitterJava from cldk.analysis.python.python_analysis import PythonAnalysis @@ -108,7 +109,7 @@ def analysis( cache_dir: str | Path | None = None, use_codeql: bool = True, use_ray: bool = False, - ) -> JavaAnalysis | PythonAnalysis | CAnalysis: + ) -> JavaAnalysis | PythonAnalysis | CAnalysis | GoAnalysis: """Initialize and return a language-specific analysis facade. This factory method creates an appropriate analysis object based on the @@ -234,6 +235,19 @@ def analysis( ) elif self.language == "c": return CAnalysis(project_dir=project_path) + elif self.language == "go": + if source_code is not None: + raise CldkInitializationException( + "source_code mode is not supported for Go; please pass project_path." + ) + return GoAnalysis( + project_dir=project_path, + analysis_backend_path=analysis_backend_path, + analysis_json_path=analysis_json_path, + analysis_level=analysis_level, + eager_analysis=eager, + cache_dir=cache_dir, + ) else: raise NotImplementedError(f"Analysis support for {self.language} is not implemented yet.") diff --git a/cldk/models/go/__init__.py b/cldk/models/go/__init__.py new file mode 100644 index 0000000..306107a --- /dev/null +++ b/cldk/models/go/__init__.py @@ -0,0 +1,49 @@ +################################################################################ +# Copyright IBM Corporation 2024 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +"""Go schema models for CLDK.""" + +from cldk.models.go.models import ( + GoApplication, + GoCallEdge, + GoCallable, + GoCallsite, + GoComment, + GoEntrypoint, + GoField, + GoFile, + GoImport, + GoParameter, + GoSymbol, + GoType, + GoVariableDeclaration, +) + +__all__ = [ + "GoApplication", + "GoCallEdge", + "GoCallable", + "GoCallsite", + "GoComment", + "GoEntrypoint", + "GoField", + "GoFile", + "GoImport", + "GoParameter", + "GoSymbol", + "GoType", + "GoVariableDeclaration", +] diff --git a/cldk/models/go/models.py b/cldk/models/go/models.py new file mode 100644 index 0000000..794dd40 --- /dev/null +++ b/cldk/models/go/models.py @@ -0,0 +1,212 @@ +################################################################################ +# Copyright IBM Corporation 2024 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +"""Go data models. + +Pydantic models mirroring the codeanalyzer-go schema.go types. +All JSON keys match the snake_case names produced by the Go analyzer so +deserialization is zero-transform. + +Go serializes nil slices as JSON ``null`` (the zero value for a slice type). +All models inherit from ``_NullSafeBase``, which coerces ``null`` list/dict +fields to their empty-collection defaults before Pydantic validates them. +""" + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field, model_validator + + +class _NullSafeBase(BaseModel): + """Coerce JSON null to an empty list/dict for any field whose default is a collection.""" + + @model_validator(mode="before") + @classmethod + def _coerce_null_collections(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + for field_name, field_info in cls.model_fields.items(): + if data.get(field_name) is None and field_info.default_factory is not None: + try: + sentinel = field_info.default_factory() + if isinstance(sentinel, (list, dict)): + data[field_name] = sentinel + except Exception: + pass + return data + + +class GoImport(_NullSafeBase): + module: str + alias: str = "" + start_line: int = -1 + end_line: int = -1 + + +class GoComment(_NullSafeBase): + content: str + start_line: int = -1 + end_line: int = -1 + start_column: int = -1 + end_column: int = -1 + is_doc_comment: bool = False + + +class GoParameter(_NullSafeBase): + name: str + type: str + is_variadic: bool = False + start_line: int = -1 + end_line: int = -1 + + +class GoVariableDeclaration(_NullSafeBase): + name: str + type: str = "" + initializer: str = "" + scope: str = "function" + start_line: int = -1 + end_line: int = -1 + start_column: int = -1 + end_column: int = -1 + + +class GoField(_NullSafeBase): + name: str + type: str + comments: List[GoComment] = Field(default_factory=list) + tags: Dict[str, str] = Field(default_factory=dict) + is_exported: bool = False + is_embedded: bool = False + start_line: int = -1 + end_line: int = -1 + + +class GoSymbol(_NullSafeBase): + name: str + scope: str = "local" + kind: str = "variable" + type: str = "" + qualified_name: str = "" + is_builtin: bool = False + lineno: int = -1 + col_offset: int = -1 + + +class GoCallsite(_NullSafeBase): + method_name: str + receiver_expr: str = "" + receiver_type: str = "" + argument_types: List[str] = Field(default_factory=list) + return_type: str = "" + callee_signature: Optional[str] = None + is_constructor_call: bool = False + is_goroutine: bool = False + start_line: int = -1 + start_column: int = -1 + end_line: int = -1 + end_column: int = -1 + + +class GoCallable(_NullSafeBase): + name: str + path: str = "" + signature: str = "" + comments: List[GoComment] = Field(default_factory=list) + parameters: List[GoParameter] = Field(default_factory=list) + return_type: str = "" + return_types: List[str] = Field(default_factory=list) + code: str = "" + start_line: int = -1 + end_line: int = -1 + code_start_line: int = -1 + accessed_symbols: List[GoSymbol] = Field(default_factory=list) + call_sites: List[GoCallsite] = Field(default_factory=list) + inner_callables: Dict[str, "GoCallable"] = Field(default_factory=dict) + local_variables: List[GoVariableDeclaration] = Field(default_factory=list) + cyclomatic_complexity: int = 1 + is_entrypoint: bool = False + entrypoint_framework: str = "" + receiver_type: str = "" + receiver_name: str = "" + is_exported: bool = False + + +class GoType(_NullSafeBase): + name: str + signature: str = "" + comments: List[GoComment] = Field(default_factory=list) + code: str = "" + is_interface: bool = False + is_exported: bool = False + fields: List[GoField] = Field(default_factory=list) + methods: Dict[str, GoCallable] = Field(default_factory=dict) + base_types: List[str] = Field(default_factory=list) + inner_types: Dict[str, "GoType"] = Field(default_factory=dict) + start_line: int = -1 + end_line: int = -1 + + +class GoFile(_NullSafeBase): + file_path: str = "" + # JSON key is module_name for spine compatibility with other language models. + module_name: str = "" + imports: List[GoImport] = Field(default_factory=list) + comments: List[GoComment] = Field(default_factory=list) + # JSON key is classes for spine compatibility. + classes: Dict[str, GoType] = Field(default_factory=dict) + functions: Dict[str, GoCallable] = Field(default_factory=dict) + variables: List[GoVariableDeclaration] = Field(default_factory=list) + content_hash: Optional[str] = None + last_modified: Optional[float] = None + file_size: Optional[int] = None + + @property + def types(self) -> Dict[str, GoType]: + """Alias for classes — the Go schema stores types under 'classes' for spine compat.""" + return self.classes + + @property + def package_name(self) -> str: + """Alias for module_name — the Go schema stores package name under 'module_name'.""" + return self.module_name + + +class GoCallEdge(_NullSafeBase): + source: str + target: str + type: str = "CALL_DEP" + weight: int = 1 + provenance: List[str] = Field(default_factory=list) + tags: Dict[str, str] = Field(default_factory=dict) + + +class GoEntrypoint(_NullSafeBase): + signature: str + framework: str = "" + detection_source: str = "" + tags: Dict[str, str] = Field(default_factory=dict) + + +class GoApplication(_NullSafeBase): + symbol_table: Dict[str, GoFile] = Field(default_factory=dict) + call_graph: List[GoCallEdge] = Field(default_factory=list) + entrypoints: Dict[str, List[GoEntrypoint]] = Field(default_factory=dict) + + +# Required for forward references in recursive models. +GoCallable.model_rebuild() +GoType.model_rebuild() diff --git a/pyproject.toml b/pyproject.toml index a1babea..8c66a75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,7 @@ include = [ [tool.backend-versions] codeanalyzer-java = "2.3.8" codeanalyzer-python = "0.1.14" +codeanalyzer-go = "0.1.0" ######################################## # Tool configurations diff --git a/tests/analysis/go/__init__.py b/tests/analysis/go/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/analysis/go/test_go_analysis.py b/tests/analysis/go/test_go_analysis.py new file mode 100644 index 0000000..c62c3e4 --- /dev/null +++ b/tests/analysis/go/test_go_analysis.py @@ -0,0 +1,317 @@ +################################################################################ +# Copyright IBM Corporation 2024 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +"""Mocked tests for the Go analysis facade. + +End-to-end tests require ``codeanalyzer-go`` to be installed; these tests +verify the SDK public API contract and data-model round-trip without invoking +the real binary. +""" + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from cldk import CLDK +from cldk.analysis.go import GoAnalysis +from cldk.models.go.models import ( + GoApplication, + GoCallEdge, + GoCallable, + GoFile, + GoType, + GoParameter, +) +from cldk.utils.exceptions import CldkInitializationException + + +# ── Fixtures ─────────────────────────────────────────────────────────────────── + +def _make_minimal_app() -> GoApplication: + """Build a minimal GoApplication that exercises the main schema paths.""" + param = GoParameter(name="x", type="int", is_variadic=False) + fn = GoCallable( + name="Add", + signature="example.com/demo.Add", + parameters=[param], + return_types=["int"], + return_type="int", + is_exported=True, + ) + method = GoCallable( + name="String", + signature="example.com/demo.MyStruct.String", + receiver_type="*MyStruct", + receiver_name="s", + return_types=["string"], + return_type="string", + is_exported=True, + ) + go_type = GoType( + name="MyStruct", + is_interface=False, + is_exported=True, + methods={"example.com/demo.MyStruct.String": method}, + ) + go_file = GoFile( + file_path="pkg/demo.go", + module_name="demo", + classes={"MyStruct": go_type}, + functions={"example.com/demo.Add": fn}, + ) + edge = GoCallEdge( + source="example.com/demo.main", + target="example.com/demo.Add", + type="CALL_DEP", + weight=1, + provenance=["go/types"], + ) + return GoApplication( + symbol_table={"pkg/demo.go": go_file}, + call_graph=[edge], + ) + + +@pytest.fixture +def mock_backend(monkeypatch): + """Monkeypatch GoCodeanalyzer to return a minimal application.""" + app = _make_minimal_app() + + class FakeCodeanalyzer: + def __init__(self, **kwargs): + self._app = app + + def get_application(self): + return self._app + + def get_symbol_table(self): + return self._app.symbol_table + + def get_file(self, path): + return self._app.symbol_table.get(path) + + def get_all_types(self): + result = {} + for f in self._app.symbol_table.values(): + for name, t in f.classes.items(): + result[f"{f.module_name}.{name}"] = t + return result + + def get_all_callables(self): + result = {} + for f in self._app.symbol_table.values(): + result.update(f.functions) + for t in f.classes.values(): + result.update(t.methods) + return result + + monkeypatch.setattr("cldk.analysis.go.go_analysis.GoCodeanalyzer", FakeCodeanalyzer) + return app + + +# ── CLDK factory ────────────────────────────────────────────────────────────── + +def test_go_analysis_rejects_source_code_mode(): + with pytest.raises(CldkInitializationException): + CLDK(language="go").analysis(source_code="package main") + + +def test_go_analysis_requires_inputs(): + with pytest.raises(CldkInitializationException): + CLDK(language="go").analysis() + + +# ── Symbol table ────────────────────────────────────────────────────────────── + +def test_get_symbol_table_non_empty(mock_backend, tmp_path): + analysis = GoAnalysis( + project_dir=tmp_path, + analysis_backend_path=None, + analysis_json_path=None, + analysis_level="symbol_table", + eager_analysis=False, + ) + symtab = analysis.get_symbol_table() + assert len(symtab) > 0 + + +def test_get_file_returns_correct_file(mock_backend, tmp_path): + analysis = GoAnalysis( + project_dir=tmp_path, + analysis_backend_path=None, + analysis_json_path=None, + analysis_level="symbol_table", + eager_analysis=False, + ) + go_file = analysis.get_file("pkg/demo.go") + assert go_file is not None + assert go_file.module_name == "demo" + + +def test_get_file_missing_returns_none(mock_backend, tmp_path): + analysis = GoAnalysis( + project_dir=tmp_path, + analysis_backend_path=None, + analysis_json_path=None, + analysis_level="symbol_table", + eager_analysis=False, + ) + assert analysis.get_file("nonexistent.go") is None + + +# ── Types ────────────────────────────────────────────────────────────────────── + +def test_get_types_in_file(mock_backend, tmp_path): + analysis = GoAnalysis( + project_dir=tmp_path, + analysis_backend_path=None, + analysis_json_path=None, + analysis_level="symbol_table", + eager_analysis=False, + ) + types = analysis.get_types_in_file("pkg/demo.go") + assert "MyStruct" in types + assert not types["MyStruct"].is_interface + + +def test_get_type_by_name(mock_backend, tmp_path): + analysis = GoAnalysis( + project_dir=tmp_path, + analysis_backend_path=None, + analysis_json_path=None, + analysis_level="symbol_table", + eager_analysis=False, + ) + t = analysis.get_type("pkg/demo.go", "MyStruct") + assert t is not None + assert t.is_exported + + +# ── Callables ────────────────────────────────────────────────────────────────── + +def test_get_callables_in_file(mock_backend, tmp_path): + analysis = GoAnalysis( + project_dir=tmp_path, + analysis_backend_path=None, + analysis_json_path=None, + analysis_level="symbol_table", + eager_analysis=False, + ) + callables = analysis.get_callables_in_file("pkg/demo.go") + assert "example.com/demo.Add" in callables + assert "example.com/demo.MyStruct.String" in callables + + +def test_get_callable_by_signature(mock_backend, tmp_path): + analysis = GoAnalysis( + project_dir=tmp_path, + analysis_backend_path=None, + analysis_json_path=None, + analysis_level="symbol_table", + eager_analysis=False, + ) + fn = analysis.get_callable("example.com/demo.Add") + assert fn is not None + assert fn.is_exported + assert len(fn.parameters) == 1 + + +# ── Call graph ────────────────────────────────────────────────────────────────── + +def test_get_call_graph_has_edges(mock_backend, tmp_path): + analysis = GoAnalysis( + project_dir=tmp_path, + analysis_backend_path=None, + analysis_json_path=None, + analysis_level="call_graph", + eager_analysis=False, + ) + cg = analysis.get_call_graph() + assert cg.number_of_edges() > 0 + + +def test_get_callees(mock_backend, tmp_path): + analysis = GoAnalysis( + project_dir=tmp_path, + analysis_backend_path=None, + analysis_json_path=None, + analysis_level="call_graph", + eager_analysis=False, + ) + callees = analysis.get_callees("example.com/demo.main") + assert "example.com/demo.Add" in callees + + +def test_get_callers(mock_backend, tmp_path): + analysis = GoAnalysis( + project_dir=tmp_path, + analysis_backend_path=None, + analysis_json_path=None, + analysis_level="call_graph", + eager_analysis=False, + ) + callers = analysis.get_callers("example.com/demo.Add") + assert "example.com/demo.main" in callers + + +def test_get_callers_unknown_node(mock_backend, tmp_path): + analysis = GoAnalysis( + project_dir=tmp_path, + analysis_backend_path=None, + analysis_json_path=None, + analysis_level="call_graph", + eager_analysis=False, + ) + assert analysis.get_callers("no.such.sig") == [] + + +# ── Pydantic model round-trip ────────────────────────────────────────────────── + +def test_go_application_round_trip(): + """GoApplication must deserialize cleanly from the JSON the Go binary emits.""" + app = _make_minimal_app() + raw = app.model_dump_json() + restored = GoApplication.model_validate_json(raw) + assert "pkg/demo.go" in restored.symbol_table + assert len(restored.call_graph) == 1 + + +def test_go_file_type_alias(): + """GoFile.types property must alias GoFile.classes.""" + go_file = GoFile( + file_path="x.go", + module_name="x", + classes={"MyType": GoType(name="MyType")}, + ) + assert go_file.types is go_file.classes + + +def test_go_file_package_name_alias(): + """GoFile.package_name property must alias GoFile.module_name.""" + go_file = GoFile(file_path="x.go", module_name="mypkg") + assert go_file.package_name == "mypkg" + + +def test_go_callable_receiver_fields(): + method = GoCallable( + name="Do", + receiver_type="*MyStruct", + receiver_name="s", + ) + assert method.receiver_type == "*MyStruct" + assert method.receiver_name == "s" diff --git a/tests/analysis/go/test_go_e2e.py b/tests/analysis/go/test_go_e2e.py new file mode 100644 index 0000000..79aab53 --- /dev/null +++ b/tests/analysis/go/test_go_e2e.py @@ -0,0 +1,397 @@ +################################################################################ +# Copyright IBM Corporation 2024 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +"""End-to-end tests for GoAnalysis against the cldk-e2e Go fixture. + +Requires ``codeanalyzer-go`` on PATH (built from codeanalyzer-go repo and +installed to e.g. ~/.local/bin). Tests are skipped automatically when the +binary is absent, so CI without Go toolchain does not break. + +Fixture: tests/resources/go/application/ + calc/calc.go — Calculator struct, Operator interface, exported/unexported methods, + pointer receiver, multiple return types, embedded field + calc/formatter.go — FormatResult (variadic), Describe (cross-file value-receiver method) + pipeline/pipeline.go — Pipeline struct, Runner interface, goroutine callsite, + cyclomatic complexity > 1, variadic RunAll + main.go — entry point, cross-package calls + +All assertions reference exact values from the JSON the binary emits. +""" + +import shutil +from pathlib import Path + +import pytest + +from cldk.analysis.go import GoAnalysis +from cldk.analysis import AnalysisLevel +from cldk.models.go.models import GoApplication + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +FIXTURE_DIR = Path(__file__).parent.parent.parent / "resources" / "go" / "application" + +pytestmark = pytest.mark.skipif( + shutil.which("codeanalyzer-go") is None, + reason="codeanalyzer-go not found on PATH — install from codeanalyzer-go repo", +) + + +def _analysis(tmp_path: Path, level: str = AnalysisLevel.symbol_table) -> GoAnalysis: + return GoAnalysis( + project_dir=FIXTURE_DIR, + analysis_backend_path=None, + analysis_json_path=tmp_path, + analysis_level=level, + eager_analysis=True, + ) + + +# ── Symbol table structure ───────────────────────────────────────────────────── + +def test_e2e_symbol_table_files(tmp_path): + """All four source files must appear in the symbol table.""" + analysis = _analysis(tmp_path) + st = analysis.get_symbol_table() + for expected in ("calc/calc.go", "calc/formatter.go", "pipeline/pipeline.go", "main.go"): + assert expected in st, f"missing file {expected!r} in symbol_table" + + +def test_e2e_symbol_table_keys_are_relative(tmp_path): + """No key should be an absolute or CWD-relative path.""" + analysis = _analysis(tmp_path) + for key in analysis.get_symbol_table(): + assert not key.startswith("/"), f"absolute path key: {key!r}" + assert not key.startswith(".."), f"CWD-relative key: {key!r}" + + +def test_e2e_application_round_trips_pydantic(tmp_path): + """GoApplication.model_validate must accept the raw analysis.json without errors.""" + import json + _analysis(tmp_path) + with open(tmp_path / "analysis.json") as f: + raw = json.load(f) + app = GoApplication(**raw) + assert len(app.symbol_table) == 4 + + +# ── Multi-file package ───────────────────────────────────────────────────────── + +def test_e2e_multi_file_package_both_files_present(tmp_path): + """calc/calc.go and calc/formatter.go must both be in the symbol table.""" + analysis = _analysis(tmp_path) + st = analysis.get_symbol_table() + assert "calc/calc.go" in st + assert "calc/formatter.go" in st + + +def test_e2e_format_result_lives_in_formatter_file(tmp_path): + """FormatResult must be keyed under calc/formatter.go, not calc/calc.go.""" + analysis = _analysis(tmp_path) + fmtr = analysis.get_file("calc/formatter.go") + assert fmtr is not None + sigs = list(fmtr.functions.keys()) + assert any("FormatResult" in s for s in sigs), ( + f"FormatResult not in calc/formatter.go functions: {sigs}" + ) + + +# ── Types ────────────────────────────────────────────────────────────────────── + +def test_e2e_operator_is_interface(tmp_path): + """Operator must be detected as an interface.""" + analysis = _analysis(tmp_path) + calc_file = analysis.get_file("calc/calc.go") + assert calc_file is not None + operator = calc_file.classes.get("Operator") + assert operator is not None, "GoType 'Operator' not found in calc/calc.go" + assert operator.is_interface, "Operator.is_interface should be True" + + +def test_e2e_runner_is_interface(tmp_path): + """Runner in pipeline package must be detected as an interface.""" + analysis = _analysis(tmp_path) + pipe_file = analysis.get_file("pipeline/pipeline.go") + assert pipe_file is not None + runner = pipe_file.classes.get("Runner") + assert runner is not None, "GoType 'Runner' not found in pipeline/pipeline.go" + assert runner.is_interface + + +def test_e2e_calculator_is_not_interface(tmp_path): + analysis = _analysis(tmp_path) + calc_file = analysis.get_file("calc/calc.go") + calculator = calc_file.classes.get("Calculator") + assert calculator is not None + assert not calculator.is_interface + assert calculator.is_exported + + +def test_e2e_base_is_unexported_type(tmp_path): + analysis = _analysis(tmp_path) + calc_file = analysis.get_file("calc/calc.go") + base = calc_file.classes.get("base") + assert base is not None, "GoType 'base' not found" + assert not base.is_exported, "base.is_exported should be False" + + +# ── Embedded field ───────────────────────────────────────────────────────────── + +def test_e2e_calculator_has_embedded_field(tmp_path): + """Calculator embeds base — at least one field must have is_embedded=True.""" + analysis = _analysis(tmp_path) + calc_file = analysis.get_file("calc/calc.go") + calculator = calc_file.classes["Calculator"] + embedded = [f for f in calculator.fields if f.is_embedded] + assert embedded, f"Calculator has no embedded field; fields: {calculator.fields}" + + +# ── Multiple return types ────────────────────────────────────────────────────── + +def test_e2e_add_has_two_return_types(tmp_path): + """Calculator.Add returns (int, error) — must have exactly two return types.""" + analysis = _analysis(tmp_path) + calc_file = analysis.get_file("calc/calc.go") + calculator = calc_file.classes["Calculator"] + add_method = next( + (m for m in calculator.methods.values() if m.name == "Add"), + None, + ) + assert add_method is not None, "Calculator.Add method not found" + assert len(add_method.return_types) == 2, ( + f"Add.return_types should be ['int', 'error']; got {add_method.return_types}" + ) + assert "error" in add_method.return_types + + +# ── Exported / unexported callables ─────────────────────────────────────────── + +def test_e2e_precision_value_is_unexported(tmp_path): + """precisionValue must have is_exported=False.""" + analysis = _analysis(tmp_path) + calc_file = analysis.get_file("calc/calc.go") + calculator = calc_file.classes["Calculator"] + precision = next( + (m for m in calculator.methods.values() if m.name == "precisionValue"), + None, + ) + assert precision is not None, "method 'precisionValue' not found in Calculator" + assert not precision.is_exported, "precisionValue.is_exported should be False" + + +def test_e2e_execute_is_unexported(tmp_path): + """Pipeline.execute must have is_exported=False.""" + analysis = _analysis(tmp_path) + pipe_file = analysis.get_file("pipeline/pipeline.go") + pipeline_type = pipe_file.classes.get("Pipeline") + assert pipeline_type is not None + execute = next( + (m for m in pipeline_type.methods.values() if m.name == "execute"), + None, + ) + assert execute is not None, "method 'execute' not found in Pipeline" + assert not execute.is_exported, "execute.is_exported should be False" + + +# ── Receiver type / name ─────────────────────────────────────────────────────── + +def test_e2e_add_pointer_receiver(tmp_path): + """Calculator.Add has a pointer receiver — receiver_type must contain '*'.""" + analysis = _analysis(tmp_path) + calc_file = analysis.get_file("calc/calc.go") + add = next( + (m for m in calc_file.classes["Calculator"].methods.values() if m.name == "Add"), + None, + ) + assert add is not None + assert add.receiver_type != "", "Add.receiver_type should be non-empty" + assert "*" in add.receiver_type, ( + f"Add.receiver_type {add.receiver_type!r} should be a pointer receiver" + ) + assert add.receiver_name != "", "Add.receiver_name should be non-empty" + + +def test_e2e_describe_value_receiver_cross_file(tmp_path): + """Describe is a value receiver on Calculator, defined in formatter.go. + + The reconcileCrossFileMethods pass must attach it to Calculator in calc.go, + while Describe.path must still point to formatter.go. + """ + analysis = _analysis(tmp_path) + calc_file = analysis.get_file("calc/calc.go") + describe = next( + (m for m in calc_file.classes["Calculator"].methods.values() if m.name == "Describe"), + None, + ) + assert describe is not None, ( + "Describe not found attached to Calculator in calc/calc.go" + ) + assert "*" not in describe.receiver_type, ( + f"Describe.receiver_type {describe.receiver_type!r} should be a value receiver (no '*')" + ) + assert "formatter.go" in describe.path, ( + f"Describe.path {describe.path!r} should point to formatter.go" + ) + + +# ── Variadic parameters ──────────────────────────────────────────────────────── + +def test_e2e_format_result_variadic(tmp_path): + """FormatResult(value int, tags ...string) must have a variadic parameter.""" + analysis = _analysis(tmp_path) + fmtr = analysis.get_file("calc/formatter.go") + format_result = next( + (fn for fn in fmtr.functions.values() if fn.name == "FormatResult"), + None, + ) + assert format_result is not None, "FormatResult not found in calc/formatter.go" + variadic = [p for p in format_result.parameters if p.is_variadic] + assert variadic, f"FormatResult has no variadic parameter; params: {format_result.parameters}" + + +def test_e2e_run_all_variadic(tmp_path): + """Pipeline.RunAll(steps ...Step) must have a variadic parameter.""" + analysis = _analysis(tmp_path) + pipe_file = analysis.get_file("pipeline/pipeline.go") + run_all = next( + (m for m in pipe_file.classes["Pipeline"].methods.values() if m.name == "RunAll"), + None, + ) + assert run_all is not None + variadic = [p for p in run_all.parameters if p.is_variadic] + assert variadic, f"RunAll has no variadic parameter; params: {run_all.parameters}" + + +# ── Goroutine call site ──────────────────────────────────────────────────────── + +def test_e2e_run_all_has_goroutine_callsite(tmp_path): + """RunAll launches `go p.execute(...)` — must have a call site with is_goroutine=True.""" + analysis = _analysis(tmp_path) + pipe_file = analysis.get_file("pipeline/pipeline.go") + run_all = next( + (m for m in pipe_file.classes["Pipeline"].methods.values() if m.name == "RunAll"), + None, + ) + assert run_all is not None + goroutine_sites = [cs for cs in run_all.call_sites if cs.is_goroutine] + assert goroutine_sites, f"RunAll has no goroutine call site; sites: {run_all.call_sites}" + + +# ── Cyclomatic complexity ────────────────────────────────────────────────────── + +def test_e2e_execute_cyclomatic_complexity(tmp_path): + """execute() has an `if err != nil` branch — cyclomatic_complexity must be >= 2.""" + analysis = _analysis(tmp_path) + pipe_file = analysis.get_file("pipeline/pipeline.go") + execute = next( + (m for m in pipe_file.classes["Pipeline"].methods.values() if m.name == "execute"), + None, + ) + assert execute is not None + assert execute.cyclomatic_complexity >= 2, ( + f"execute.cyclomatic_complexity should be >= 2; got {execute.cyclomatic_complexity}" + ) + + +# ── Call graph (level 2) ─────────────────────────────────────────────────────── + +def test_e2e_call_graph_edges_present(tmp_path): + analysis = _analysis(tmp_path, level=AnalysisLevel.call_graph) + edges = analysis.get_call_graph_edges() + assert len(edges) > 0, "call graph must not be empty" + + +def test_e2e_specific_edge_main_to_calc_new(tmp_path): + """main() calls calc.New — this named edge must exist in the call graph.""" + analysis = _analysis(tmp_path, level=AnalysisLevel.call_graph) + targets = {e.target for e in analysis.get_call_graph_edges()} + assert "example.com/cldk-e2e/calc.New" in targets, ( + f"call graph missing edge to calc.New; targets: {sorted(targets)}" + ) + + +def test_e2e_specific_edge_run_all_to_execute(tmp_path): + """RunAll spawns a goroutine calling execute — that edge must exist.""" + analysis = _analysis(tmp_path, level=AnalysisLevel.call_graph) + edges = {(e.source, e.target) for e in analysis.get_call_graph_edges()} + assert ( + "example.com/cldk-e2e/pipeline.Pipeline.RunAll", + "example.com/cldk-e2e/pipeline.Pipeline.execute", + ) in edges, f"RunAll->execute edge missing; edges: {sorted(edges)}" + + +def test_e2e_cross_package_edges(tmp_path): + """At least one edge must cross main→calc and one main→pipeline.""" + analysis = _analysis(tmp_path, level=AnalysisLevel.call_graph) + sources = {e.source for e in analysis.get_call_graph_edges()} + targets = {e.target for e in analysis.get_call_graph_edges()} + calc_target = any("cldk-e2e/calc." in t for t in targets) + pipeline_target = any("cldk-e2e/pipeline." in t for t in targets) + assert calc_target, "no call-graph edge into the calc package" + assert pipeline_target, "no call-graph edge into the pipeline package" + + +def test_e2e_no_dangling_call_graph_nodes(tmp_path): + """Every edge endpoint must correspond to a signature in the symbol table.""" + analysis = _analysis(tmp_path, level=AnalysisLevel.call_graph) + all_sigs = set() + for go_file in analysis.get_symbol_table().values(): + for sig in go_file.functions: + all_sigs.add(sig) + for go_type in go_file.classes.values(): + for sig in go_type.methods: + all_sigs.add(sig) + + dangling = [] + for edge in analysis.get_call_graph_edges(): + if edge.source not in all_sigs: + dangling.append(f"source {edge.source!r}") + if edge.target not in all_sigs: + dangling.append(f"target {edge.target!r}") + assert not dangling, f"dangling call-graph nodes: {dangling}" + + +# ── Caching (idempotency) ────────────────────────────────────────────────────── + +def test_e2e_second_run_reuses_cache(tmp_path): + """Running analysis twice with eager=False must not re-invoke the binary.""" + import time + # First run (eager=True to seed the cache). + GoAnalysis( + project_dir=FIXTURE_DIR, + analysis_backend_path=None, + analysis_json_path=tmp_path, + analysis_level=AnalysisLevel.symbol_table, + eager_analysis=True, + ) + mtime_after_first = (tmp_path / "analysis.json").stat().st_mtime + + time.sleep(0.05) + + # Second run (eager=False) — must reuse the cached file. + GoAnalysis( + project_dir=FIXTURE_DIR, + analysis_backend_path=None, + analysis_json_path=tmp_path, + analysis_level=AnalysisLevel.symbol_table, + eager_analysis=False, + ) + mtime_after_second = (tmp_path / "analysis.json").stat().st_mtime + + assert mtime_after_first == mtime_after_second, ( + "analysis.json was rewritten on the second run despite eager=False" + ) diff --git a/tests/resources/go/application/calc/calc.go b/tests/resources/go/application/calc/calc.go new file mode 100644 index 0000000..709ae32 --- /dev/null +++ b/tests/resources/go/application/calc/calc.go @@ -0,0 +1,51 @@ +// Package calc provides arithmetic operations. +// Exercises: struct with exported/unexported fields, exported/unexported methods, +// pointer receiver, multiple return types (T, error), interface, is_embedded. +package calc + +import "fmt" + +// Operator is the arithmetic interface. +// Exercises: is_interface=true. +type Operator interface { + Add(a, b int) (int, error) +} + +// base holds common state shared by embedding. +// Exercises: embedded struct field (is_embedded=true in Calculator). +type base struct { + label string +} + +// Calculator implements Operator and embeds base. +type Calculator struct { + base // embedded — exercises is_embedded=true + precision int // unexported field +} + +// New constructs a Calculator. +// Exercises: constructor-style function, single return. +func New(label string) *Calculator { + return &Calculator{base: base{label: label}, precision: 2} +} + +// Add performs integer addition. +// Exercises: pointer receiver, multiple return types (int, error). +func (c *Calculator) Add(a, b int) (int, error) { + if c == nil { + return 0, fmt.Errorf("nil calculator") + } + return a + b, nil +} + +// Label returns the calculator's label. +// Exercises: pointer receiver, single return type. +func (c *Calculator) Label() string { + return c.label +} + +// precision returns the internal precision value. +// Exercises: unexported method (is_exported=false). +func (c *Calculator) precisionValue() int { + return c.precision +} diff --git a/tests/resources/go/application/calc/formatter.go b/tests/resources/go/application/calc/formatter.go new file mode 100644 index 0000000..5c46dc2 --- /dev/null +++ b/tests/resources/go/application/calc/formatter.go @@ -0,0 +1,19 @@ +// Second file in the calc package. +// Exercises: multi-file package, cross-file method (FormatResult is a free function, +// Describe is a value-receiver method on Calculator defined here while Calculator +// is declared in calc.go — exercises reconcileCrossFileMethods). +package calc + +import "strings" + +// Describe returns a human-readable description. +// Exercises: value receiver on a type from a sibling file (cross-file method). +func (c Calculator) Describe() string { + return "Calculator(" + c.label + ")" +} + +// FormatResult formats a numeric result with a tag. +// Exercises: variadic — tags ...string — is_variadic=true. +func FormatResult(value int, tags ...string) string { + return strings.Join(tags, ",") + ":" + strings.TrimSpace(strings.Repeat(" ", value)) +} diff --git a/tests/resources/go/application/go.mod b/tests/resources/go/application/go.mod new file mode 100644 index 0000000..f4948cf --- /dev/null +++ b/tests/resources/go/application/go.mod @@ -0,0 +1,3 @@ +module example.com/cldk-e2e + +go 1.21 diff --git a/tests/resources/go/application/main.go b/tests/resources/go/application/main.go new file mode 100644 index 0000000..aedaf85 --- /dev/null +++ b/tests/resources/go/application/main.go @@ -0,0 +1,31 @@ +// Package main is the entry point for the cldk-e2e fixture. +// It exercises cross-package calls into calc and pipeline. +package main + +import ( + "fmt" + "log" + + "example.com/cldk-e2e/calc" + "example.com/cldk-e2e/pipeline" +) + +func main() { + c := calc.New("demo") + result, err := c.Add(10, 32) + if err != nil { + log.Fatal(err) + } + fmt.Println(result) + fmt.Println(c.Label()) + fmt.Println(calc.FormatResult(result, "sum")) + + p := pipeline.New(c) + out := p.RunAll( + pipeline.Step{Name: "add", A: 1, B: 2}, + pipeline.Step{Name: "add", A: 3, B: 4}, + ) + for _, r := range out { + fmt.Println(r) + } +} diff --git a/tests/resources/go/application/pipeline/pipeline.go b/tests/resources/go/application/pipeline/pipeline.go new file mode 100644 index 0000000..cdb52cb --- /dev/null +++ b/tests/resources/go/application/pipeline/pipeline.go @@ -0,0 +1,69 @@ +// Package pipeline runs a sequence of calculation steps concurrently. +// Exercises: goroutine launch (is_goroutine=true), variadic function call, +// struct with unexported fields, interface usage, cyclomatic complexity > 1. +package pipeline + +import ( + "sync" + + "example.com/cldk-e2e/calc" +) + +// Step describes a single arithmetic operation. +type Step struct { + Name string `json:"name"` + A int `json:"a"` + B int `json:"b"` +} + +// Result holds the outcome of a step. +type Result struct { + Step Step `json:"step"` + Value int `json:"value"` + Err error +} + +// Runner executes steps. +// Exercises: interface with a method. +type Runner interface { + RunAll(steps ...Step) []Result +} + +// Pipeline holds an Operator and runs Steps. +type Pipeline struct { + op calc.Operator // unexported field + mu sync.Mutex +} + +// New creates a Pipeline backed by the given Operator. +func New(op calc.Operator) *Pipeline { + return &Pipeline{op: op} +} + +// RunAll executes every Step concurrently and collects Results. +// Exercises: variadic parameter (steps ...Step), goroutine launch (go p.execute), +// cyclomatic_complexity >= 2 (the if err != nil branch in execute). +func (p *Pipeline) RunAll(steps ...Step) []Result { + results := make([]Result, len(steps)) + var wg sync.WaitGroup + for i, s := range steps { + wg.Add(1) + go p.execute(s, i, results, &wg) // goroutine — is_goroutine=true + } + wg.Wait() + return results +} + +// execute processes one step under the mutex. +// Exercises: unexported method (is_exported=false), cyclomatic_complexity >= 2. +func (p *Pipeline) execute(s Step, idx int, results []Result, wg *sync.WaitGroup) { + defer wg.Done() + p.mu.Lock() + defer p.mu.Unlock() + v, err := p.op.Add(s.A, s.B) + if err != nil { + results[idx] = Result{Step: s, Err: err} + return + } + results[idx] = Result{Step: s, Value: v} +} diff --git a/uv.lock b/uv.lock index b7eec14..4cd0116 100644 --- a/uv.lock +++ b/uv.lock @@ -300,7 +300,7 @@ wheels = [ [[package]] name = "cldk" -version = "1.0.7" +version = "1.1.3" source = { editable = "." } dependencies = [ { name = "clang" }, From aac1203f214c1f816134fcf3d69934dae9aa862c Mon Sep 17 00:00:00 2001 From: Saurabh Sinha Date: Wed, 17 Jun 2026 10:12:45 -0400 Subject: [PATCH 2/7] docs: add Go to README language support section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TOC: add Go under Analysis Backends - Mermaid diagram: add cldk.analysis.go → codeanalyzer-go branch and Go models node - Update "full support for Java, Python, C" → include Go - Update cldk.models example list to include cldk.models.go - Add Go backend section: tools, capabilities, prerequisites, usage snippet Signed-off-by: Saurabh Sinha --- README.md | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 937a0e2..11dfaea 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ For any questions, feedback, or suggestions, please contact the authors: - [Java](#java) - [Python](#python) - [C](#c) + - [Go](#go) - [3. **Utilities and Extensions**](#3-utilities-and-extensions) - [Contributing](#contributing) - [Publication (papers and blogs related to CLDK)](#publication-papers-and-blogs-related-to-cldk) @@ -158,6 +159,10 @@ User <--> A[CLDK] A6 --> A7[treesitter_python] A7 --> PA[Analysis] + A1 --> AG[cldk.analysis.go] + AG --> AGG[codeanalyzer-go → go/types] + AGG --> GA[Analysis] + A1 --> A8[cldk.analysis.commons] A8 --> LSP[LSP] A8 --> TS[treesitter base] @@ -167,6 +172,7 @@ User <--> A[CLDK] M --> MJ[Java models] M --> MP[Python models] M --> MC[C models] + M --> MG[Go models] M --> MT[treesitter models] A --> U[cldk.utils] @@ -179,12 +185,12 @@ User <--> A[CLDK] The user interacts with the CLDK API via the top-level `CLDK` interface exposed in `core.py`. This interface is responsible for configuring the analysis session, initializing language-specific pipelines, and exposing a high-level, language-agnostic API for interacting with program structure and semantics. -CLDK is currently implemented with full support for **Java**, **Python**, and **C**. Each language module is structured around two core components: **data models** and **analysis backends**. +CLDK is currently implemented with full support for **Java**, **Python**, **C**, and **Go**. Each language module is structured around two core components: **data models** and **analysis backends**. ### 1. **Data Models** -Each supported language has its own set of Pydantic-based data models, located in the `cldk.models` module (e.g., `cldk.models.java`, `cldk.models.python`, `cldk.models.c`). These models provide: +Each supported language has its own set of Pydantic-based data models, located in the `cldk.models` module (e.g., `cldk.models.java`, `cldk.models.python`, `cldk.models.c`, `cldk.models.go`). These models provide: - **Structured representations** of language elements such as classes, methods, annotations, fields, and statements. - **Typed access** using dot notation (e.g., `method.return_type` or `klass.methods`), promoting developer productivity. @@ -224,6 +230,23 @@ Each language has a dedicated analysis backend implemented under `cldk.analysis. - **Tools:** Clang frontend - **Capabilities:** Structural symbol resolution and method/function layout using Clang AST +#### Go +- **Backend:** `cldk.analysis.go` +- **Tools:** `codeanalyzer-go` (native binary, `go/packages` + `go/types`) +- **Capabilities:** Symbol table, resolver-based call graph, struct/interface types, exported/unexported members, pointer and value receivers, multi-return types, goroutine call sites, embedded fields, cross-file method attachment, cyclomatic complexity +- **Prerequisites:** Build `codeanalyzer-go` from the [codeanalyzer-go repo](https://github.com/codellm-devkit/codeanalyzer-go) and place the binary on `PATH` (e.g. `~/.local/bin/`). + +```python +from cldk import CLDK + +analysis = CLDK(language="go").analysis(project_path="/path/to/your/go/project") +for file_path, go_file in analysis.get_symbol_table().items(): + print(file_path, go_file.package_name) + for type_name, go_type in go_file.types.items(): + for sig, method in go_type.methods.items(): + print(f" {sig} receiver={method.receiver_type}") +``` + All analysis backends share common infrastructure defined in `cldk.analysis.commons`, including: - **Tree-sitter utilities** (`treesitter_java`, `treesitter_python`) - **LSP integration hooks** From f9b52cea40dfd9ccb2254d22e75ca6407c6bdece Mon Sep 17 00:00:00 2001 From: Saurabh Sinha Date: Fri, 3 Jul 2026 12:53:44 -0400 Subject: [PATCH 3/7] fix(go): pass --eager flag to codeanalyzer-go binary when eager_analysis=True Previously GoCodeanalyzer bypassed its own analysis.json cache when eager_analysis=True, but never forwarded --eager to the binary. The binary's own internal cache (~/.cldk/go-cache) was therefore still used, so repeated eager calls could return stale results. Pass --eager to the subprocess args unconditionally when eager_analysis is set; the binary will force a clean rebuild and ignore its cache. Tests: add test_eager_flag_passed_to_binary and test_eager_flag_absent_when_not_eager in test_go_analysis.py, patching subprocess.run at the GoCodeanalyzer layer to assert on the argv. Signed-off-by: Saurabh Sinha --- cldk/analysis/go/codeanalyzer/codeanalyzer.py | 2 + tests/analysis/go/test_go_analysis.py | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/cldk/analysis/go/codeanalyzer/codeanalyzer.py b/cldk/analysis/go/codeanalyzer/codeanalyzer.py index cd6e630..550eb76 100644 --- a/cldk/analysis/go/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/go/codeanalyzer/codeanalyzer.py @@ -133,6 +133,8 @@ def _run_and_parse(self, exec_cmd: List[str], level_flag: str, output_dir: Path) "--output", str(output_dir), "--analysis-level", level_flag, ] + if self.eager_analysis: + args.append("--eager") logger.info("Running codeanalyzer-go: %s", " ".join(args)) try: diff --git a/tests/analysis/go/test_go_analysis.py b/tests/analysis/go/test_go_analysis.py index d2b0e82..92cbeb2 100644 --- a/tests/analysis/go/test_go_analysis.py +++ b/tests/analysis/go/test_go_analysis.py @@ -29,6 +29,7 @@ from cldk import CLDK from cldk.analysis.go import GoAnalysis +from cldk.analysis.go.codeanalyzer import GoCodeanalyzer from cldk.models.go.models import ( GoApplication, GoCallEdge, @@ -293,3 +294,47 @@ def test_go_callable_receiver_fields(): ) assert method.receiver_type == "*MyStruct" assert method.receiver_name == "s" + + +# ── Binary invocation flags ──────────────────────────────────────────────────── + +def _make_subprocess_stub(output_dir_arg_index: int = None): + """Return a fake subprocess.run that writes analysis.json to the --output dir.""" + minimal = '{"symbol_table": {}, "call_graph": [], "entrypoints": {}}' + + def fake_run(args, **kwargs): + out_idx = args.index("--output") + 1 + out = Path(args[out_idx]) + out.mkdir(parents=True, exist_ok=True) + (out / "analysis.json").write_text(minimal) + return MagicMock(returncode=0) + + return fake_run + + +def test_eager_flag_passed_to_binary(tmp_path): + """GoCodeanalyzer must append --eager to the subprocess args when eager_analysis=True.""" + with patch("cldk.analysis.go.codeanalyzer.codeanalyzer.shutil.which", return_value="/bin/codeanalyzer-go"): + with patch("cldk.analysis.go.codeanalyzer.codeanalyzer.subprocess.run", side_effect=_make_subprocess_stub()) as mock_run: + GoCodeanalyzer( + project_dir=tmp_path, + analysis_json_path=None, + analysis_level="symbol_table", + eager_analysis=True, + ) + invoked_args = mock_run.call_args[0][0] + assert "--eager" in invoked_args + + +def test_eager_flag_absent_when_not_eager(tmp_path): + """GoCodeanalyzer must NOT pass --eager when eager_analysis=False.""" + with patch("cldk.analysis.go.codeanalyzer.codeanalyzer.shutil.which", return_value="/bin/codeanalyzer-go"): + with patch("cldk.analysis.go.codeanalyzer.codeanalyzer.subprocess.run", side_effect=_make_subprocess_stub()) as mock_run: + GoCodeanalyzer( + project_dir=tmp_path, + analysis_json_path=None, + analysis_level="symbol_table", + eager_analysis=False, + ) + invoked_args = mock_run.call_args[0][0] + assert "--eager" not in invoked_args From d1961996cd270d443e82e5d6ffa3a65c756186a6 Mon Sep 17 00:00:00 2001 From: Saurabh Sinha Date: Fri, 3 Jul 2026 13:06:16 -0400 Subject: [PATCH 4/7] =?UTF-8?q?fix(go):=20wire=20target=5Ffiles=20end-to-e?= =?UTF-8?q?nd=20through=20CLDK.go()=20=E2=86=92=20GoAnalysis=20=E2=86=92?= =?UTF-8?q?=20binary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLDK.go() accepted target_files but neither passed it to GoAnalysis nor did GoAnalysis or GoCodeanalyzer forward it to the codeanalyzer-go binary. The --target-files flag therefore had no effect, silently ignoring incremental mode. Changes: - GoCodeanalyzer.__init__: add target_files: Optional[List[str]] = None; append one --target-files per entry in _run_and_parse - GoAnalysis.__init__: add target_files param; forward to GoCodeanalyzer - CLDK.go(): add target_files param; forward to GoAnalysis - analysis() shim: forward target_files to CLDK.go() Tests: add test_target_files_passed_to_binary and test_target_files_absent_when_none in test_go_analysis.py. Signed-off-by: Saurabh Sinha --- cldk/analysis/go/codeanalyzer/codeanalyzer.py | 5 +++ cldk/analysis/go/go_analysis.py | 3 ++ cldk/core.py | 4 +++ tests/analysis/go/test_go_analysis.py | 34 +++++++++++++++++++ 4 files changed, 46 insertions(+) diff --git a/cldk/analysis/go/codeanalyzer/codeanalyzer.py b/cldk/analysis/go/codeanalyzer/codeanalyzer.py index 550eb76..5daa847 100644 --- a/cldk/analysis/go/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/go/codeanalyzer/codeanalyzer.py @@ -63,11 +63,13 @@ def __init__( analysis_json_path: Union[str, Path, None], analysis_level: str, eager_analysis: bool, + target_files: Optional[List[str]] = None, ) -> None: self.project_dir = Path(project_dir) self.analysis_json_path = Path(analysis_json_path) if analysis_json_path else None self.analysis_level = analysis_level self.eager_analysis = eager_analysis + self.target_files = target_files self.application: GoApplication = self._init_codeanalyzer() # ── Binary resolution ────────────────────────────────────────────────────── @@ -135,6 +137,9 @@ def _run_and_parse(self, exec_cmd: List[str], level_flag: str, output_dir: Path) ] if self.eager_analysis: args.append("--eager") + if self.target_files: + for f in self.target_files: + args += ["--target-files", f] logger.info("Running codeanalyzer-go: %s", " ".join(args)) try: diff --git a/cldk/analysis/go/go_analysis.py b/cldk/analysis/go/go_analysis.py index 022db09..784951e 100644 --- a/cldk/analysis/go/go_analysis.py +++ b/cldk/analysis/go/go_analysis.py @@ -75,10 +75,12 @@ def __init__( analysis_level: str = AnalysisLevel.symbol_table, eager_analysis: bool = False, backend: GoBackend | None = None, + target_files: List[str] | None = None, ) -> None: self.project_dir = project_dir self.analysis_level = analysis_level self.eager_analysis = eager_analysis + self.target_files = target_files cache_dir = backend.cache_dir if backend is not None else None analysis_json_path = cache_subdir(cache_dir, project_dir, "go") self._codeanalyzer: GoCodeanalyzer = GoCodeanalyzer( @@ -86,6 +88,7 @@ def __init__( analysis_json_path=analysis_json_path, analysis_level=self.analysis_level, eager_analysis=self.eager_analysis, + target_files=self.target_files, ) self._call_graph: Optional[nx.DiGraph] = None diff --git a/cldk/core.py b/cldk/core.py index e715a48..b740f89 100644 --- a/cldk/core.py +++ b/cldk/core.py @@ -250,6 +250,7 @@ def go( project_path: str | Path, *, analysis_level: str = AnalysisLevel.symbol_table, + target_files: List[str] | None = None, eager: bool = False, backend: GoBackend | None = None, ) -> GoAnalysis: @@ -258,6 +259,7 @@ def go( Args: project_path: Path to the Go project directory (must contain ``go.mod``). analysis_level: Analysis depth (see :class:`~cldk.analysis.AnalysisLevel`). + target_files: Restrict analysis to these files (incremental mode). eager: Force regeneration of cached analysis. backend: Backend configuration. Defaults to :class:`GoCodeAnalyzerConfig`. """ @@ -266,6 +268,7 @@ def go( analysis_level=analysis_level, eager_analysis=eager, backend=backend, + target_files=target_files, ) def analysis( @@ -352,6 +355,7 @@ def analysis( return CLDK.go( project_path=project_path, analysis_level=analysis_level, + target_files=target_files, eager=eager, backend=GoCodeAnalyzerConfig(cache_dir=cache_root), ) diff --git a/tests/analysis/go/test_go_analysis.py b/tests/analysis/go/test_go_analysis.py index 92cbeb2..a395297 100644 --- a/tests/analysis/go/test_go_analysis.py +++ b/tests/analysis/go/test_go_analysis.py @@ -338,3 +338,37 @@ def test_eager_flag_absent_when_not_eager(tmp_path): ) invoked_args = mock_run.call_args[0][0] assert "--eager" not in invoked_args + + +def test_target_files_passed_to_binary(tmp_path): + """Each entry in target_files must appear as a separate --target-files arg.""" + targets = ["pkg/server.go", "pkg/handler.go"] + with patch("cldk.analysis.go.codeanalyzer.codeanalyzer.shutil.which", return_value="/bin/codeanalyzer-go"): + with patch("cldk.analysis.go.codeanalyzer.codeanalyzer.subprocess.run", side_effect=_make_subprocess_stub()) as mock_run: + GoCodeanalyzer( + project_dir=tmp_path, + analysis_json_path=None, + analysis_level="symbol_table", + eager_analysis=False, + target_files=targets, + ) + invoked_args = mock_run.call_args[0][0] + assert "--target-files" in invoked_args + # Both files must be present as individual flag values. + tf_values = [invoked_args[i + 1] for i, a in enumerate(invoked_args) if a == "--target-files"] + assert set(tf_values) == set(targets) + + +def test_target_files_absent_when_none(tmp_path): + """GoCodeanalyzer must not add --target-files when target_files is None.""" + with patch("cldk.analysis.go.codeanalyzer.codeanalyzer.shutil.which", return_value="/bin/codeanalyzer-go"): + with patch("cldk.analysis.go.codeanalyzer.codeanalyzer.subprocess.run", side_effect=_make_subprocess_stub()) as mock_run: + GoCodeanalyzer( + project_dir=tmp_path, + analysis_json_path=None, + analysis_level="symbol_table", + eager_analysis=False, + target_files=None, + ) + invoked_args = mock_run.call_args[0][0] + assert "--target-files" not in invoked_args From 31e497f41b2cd0f6f92347b3e119a692c9549d81 Mon Sep 17 00:00:00 2001 From: Saurabh Sinha Date: Fri, 3 Jul 2026 13:48:00 -0400 Subject: [PATCH 5/7] feat(go): add GoAnalysisBackend ABC and fix e2e cache path Java, Python, and TypeScript all define an abstract base class that every analysis backend implements, letting GoAnalysis hold its backend via the interface rather than the concrete type. This commit brings Go to parity: - Add cldk/analysis/go/backend.py with GoAnalysisBackend(ABC) exposing six abstract methods: get_application, get_symbol_table, get_all_files, get_file, get_all_types, get_all_callables. - Make GoCodeanalyzer subclass GoAnalysisBackend (satisfies ABC at class definition time; TypeError at startup if any abstract method is missing). - Type GoAnalysis._codeanalyzer as GoAnalysisBackend so static type checkers enforce the interface boundary. - Export GoAnalysisBackend from cldk/analysis/go/__init__.py. - Add two unit tests: GoCodeanalyzer is a subclass of GoAnalysisBackend (issubclass), and GoAnalysis._codeanalyzer is a GoAnalysisBackend instance at runtime (subprocess-stub pattern, no mock needed). - Fix test_e2e_application_round_trips_pydantic: cache_subdir writes analysis.json under /go/, not directly under cache_dir. Signed-off-by: Saurabh Sinha --- cldk/analysis/go/__init__.py | 3 +- cldk/analysis/go/backend.py | 77 +++++++++++++++++++ cldk/analysis/go/codeanalyzer/codeanalyzer.py | 3 +- cldk/analysis/go/go_analysis.py | 3 +- tests/analysis/go/test_go_analysis.py | 17 +++- tests/analysis/go/test_go_e2e.py | 2 +- 6 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 cldk/analysis/go/backend.py diff --git a/cldk/analysis/go/__init__.py b/cldk/analysis/go/__init__.py index e806951..e6ad7a2 100644 --- a/cldk/analysis/go/__init__.py +++ b/cldk/analysis/go/__init__.py @@ -16,6 +16,7 @@ """Go analysis package.""" +from cldk.analysis.go.backend import GoAnalysisBackend from cldk.analysis.go.go_analysis import GoAnalysis -__all__ = ["GoAnalysis"] +__all__ = ["GoAnalysis", "GoAnalysisBackend"] diff --git a/cldk/analysis/go/backend.py b/cldk/analysis/go/backend.py new file mode 100644 index 0000000..f845f3b --- /dev/null +++ b/cldk/analysis/go/backend.py @@ -0,0 +1,77 @@ +################################################################################ +# Copyright IBM Corporation 2026 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +"""The Go analysis backend contract. + +:class:`GoAnalysis` is a thin façade that delegates every query to a *backend*. +Today the only backend is :class:`~cldk.analysis.go.codeanalyzer.GoCodeanalyzer` +(in-process subprocess driver over ``analysis.json``); this ABC formalizes the +surface the façade depends on so an alternative backend (e.g. a Neo4j/Cypher +backend, mirroring :class:`~cldk.analysis.python.backend.PythonAnalysisBackend`) +can be dropped in and selected via the ``backend=`` configuration object without +touching the façade. + +The contract is enforced by the type system and at instantiation time. +Backend-specific lifecycle (caches, subprocess management) is intentionally not +part of it. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Dict, List, Optional + +from cldk.models.go.models import GoApplication, GoCallable, GoFile, GoType + + +class GoAnalysisBackend(ABC): + """Abstract base every Go analysis backend implements. + + A backend owns all indexing and query logic for a Go application; the + :class:`~cldk.analysis.go.GoAnalysis` façade is a one-line-delegation shim + over it. Implementations must return the canonical ``cldk.models.go`` Pydantic + objects so backends are behaviorally interchangeable. + """ + + # ── application / whole-program ─────────────────────────────────────────── + + @abstractmethod + def get_application(self) -> GoApplication: + """The complete application model (symbol table + call graph).""" + + @abstractmethod + def get_symbol_table(self) -> Dict[str, GoFile]: + """The per-file symbol table, keyed by file path relative to the project root.""" + + @abstractmethod + def get_all_files(self) -> Dict[str, GoFile]: + """All analyzed files (alias for :meth:`get_symbol_table`).""" + + @abstractmethod + def get_file(self, file_path: str) -> Optional[GoFile]: + """The :class:`~cldk.models.go.GoFile` for a given relative file path, or ``None``.""" + + # ── types ───────────────────────────────────────────────────────────────── + + @abstractmethod + def get_all_types(self) -> Dict[str, GoType]: + """All named types (structs and interfaces) across all files, keyed by qualified name.""" + + # ── callables ───────────────────────────────────────────────────────────── + + @abstractmethod + def get_all_callables(self) -> Dict[str, GoCallable]: + """All top-level functions and type methods across all files, keyed by signature.""" diff --git a/cldk/analysis/go/codeanalyzer/codeanalyzer.py b/cldk/analysis/go/codeanalyzer/codeanalyzer.py index 5daa847..9249f61 100644 --- a/cldk/analysis/go/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/go/codeanalyzer/codeanalyzer.py @@ -33,6 +33,7 @@ from typing import Dict, List, Optional, Union from cldk.analysis import AnalysisLevel +from cldk.analysis.go.backend import GoAnalysisBackend from cldk.models.go.models import GoApplication, GoCallable, GoFile, GoType from cldk.utils.exceptions.exceptions import CodeanalyzerExecutionException @@ -41,7 +42,7 @@ _BINARY_NAME = "codeanalyzer-go" -class GoCodeanalyzer: +class GoCodeanalyzer(GoAnalysisBackend): """Subprocess driver for the ``codeanalyzer-go`` native binary. Args: diff --git a/cldk/analysis/go/go_analysis.py b/cldk/analysis/go/go_analysis.py index 784951e..bd009ee 100644 --- a/cldk/analysis/go/go_analysis.py +++ b/cldk/analysis/go/go_analysis.py @@ -46,6 +46,7 @@ from cldk.analysis import AnalysisLevel from cldk.analysis.commons.backend_config import GoBackend, cache_subdir +from cldk.analysis.go.backend import GoAnalysisBackend from cldk.analysis.go.codeanalyzer import GoCodeanalyzer from cldk.models.go.models import ( GoApplication, @@ -83,7 +84,7 @@ def __init__( self.target_files = target_files cache_dir = backend.cache_dir if backend is not None else None analysis_json_path = cache_subdir(cache_dir, project_dir, "go") - self._codeanalyzer: GoCodeanalyzer = GoCodeanalyzer( + self._codeanalyzer: GoAnalysisBackend = GoCodeanalyzer( project_dir=self.project_dir, analysis_json_path=analysis_json_path, analysis_level=self.analysis_level, diff --git a/tests/analysis/go/test_go_analysis.py b/tests/analysis/go/test_go_analysis.py index a395297..85e79a7 100644 --- a/tests/analysis/go/test_go_analysis.py +++ b/tests/analysis/go/test_go_analysis.py @@ -28,7 +28,7 @@ import pytest from cldk import CLDK -from cldk.analysis.go import GoAnalysis +from cldk.analysis.go import GoAnalysis, GoAnalysisBackend from cldk.analysis.go.codeanalyzer import GoCodeanalyzer from cldk.models.go.models import ( GoApplication, @@ -372,3 +372,18 @@ def test_target_files_absent_when_none(tmp_path): ) invoked_args = mock_run.call_args[0][0] assert "--target-files" not in invoked_args + + +# ── Backend ABC ──────────────────────────────────────────────────────────────── + +def test_go_codeanalyzer_implements_backend_abc(): + """GoCodeanalyzer must be a concrete subclass of GoAnalysisBackend.""" + assert issubclass(GoCodeanalyzer, GoAnalysisBackend) + + +def test_go_analysis_backend_field_is_abc_type(tmp_path): + """GoAnalysis._codeanalyzer must be a GoAnalysisBackend instance at runtime.""" + with patch("cldk.analysis.go.codeanalyzer.codeanalyzer.shutil.which", return_value="/bin/codeanalyzer-go"): + with patch("cldk.analysis.go.codeanalyzer.codeanalyzer.subprocess.run", side_effect=_make_subprocess_stub()): + analysis = GoAnalysis(project_dir=tmp_path, analysis_level="symbol_table", eager_analysis=False) + assert isinstance(analysis._codeanalyzer, GoAnalysisBackend) diff --git a/tests/analysis/go/test_go_e2e.py b/tests/analysis/go/test_go_e2e.py index a771b5d..ce839a7 100644 --- a/tests/analysis/go/test_go_e2e.py +++ b/tests/analysis/go/test_go_e2e.py @@ -83,7 +83,7 @@ def test_e2e_application_round_trips_pydantic(tmp_path): """GoApplication.model_validate must accept the raw analysis.json without errors.""" import json _analysis(tmp_path) - with open(tmp_path / "analysis.json") as f: + with open(tmp_path / "go" / "analysis.json") as f: raw = json.load(f) app = GoApplication(**raw) assert len(app.symbol_table) == 4 From 486c5c87778545e78969b0912e2944f3896b76a0 Mon Sep 17 00:00:00 2001 From: Saurabh Sinha Date: Sat, 4 Jul 2026 12:49:59 -0400 Subject: [PATCH 6/7] feat(go): resolve analyzer via codeanalyzer-go PyPI package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the Python/TypeScript backends: GoCodeanalyzer now resolves the binary in order — $CODEANALYZER_GO_BIN override, then the prebuilt binary shipped in the codeanalyzer-go PyPI package (codeanalyzer_go.bin_path()), then codeanalyzer-go/cango on PATH. So `pip install codeanalyzer-go` alone is sufficient; the PATH fallback keeps Homebrew/installer/go-build setups working. Also update the e2e skip guard to mirror that resolution order, so the suite runs when the package is installed (not only when a binary is on PATH). Signed-off-by: Saurabh Sinha --- cldk/analysis/go/codeanalyzer/codeanalyzer.py | 50 +++++++++++++++---- tests/analysis/go/test_go_e2e.py | 27 ++++++++-- 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/cldk/analysis/go/codeanalyzer/codeanalyzer.py b/cldk/analysis/go/codeanalyzer/codeanalyzer.py index 9249f61..5928505 100644 --- a/cldk/analysis/go/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/go/codeanalyzer/codeanalyzer.py @@ -21,11 +21,20 @@ Java shelling out to the ``codeanalyzer-*.jar``), reads the produced ``analysis.json``, and deserializes it into a :class:`~cldk.models.go.GoApplication`. -The binary must be on ``PATH`` or provided via ``analysis_backend_path``. +The binary is resolved in this order (see :meth:`GoCodeanalyzer._get_codeanalyzer_exec`): + +1. ``$CODEANALYZER_GO_BIN`` — an explicit override (e.g. a locally built binary). +2. The ``codeanalyzer-go`` PyPI package, which ships the prebuilt, platform-specific + binary and exposes ``codeanalyzer_go.bin_path()`` — mirroring how the Python and + TypeScript backends depend on ``codeanalyzer-python`` / ``codeanalyzer-typescript``. +3. The ``codeanalyzer-go`` (or ``cango``) command on ``PATH`` — e.g. a Homebrew, + shell-installer, or ``go build`` install. """ import json import logging +import os +import shlex import shutil import subprocess from pathlib import Path @@ -76,14 +85,37 @@ def __init__( # ── Binary resolution ────────────────────────────────────────────────────── def _get_codeanalyzer_exec(self) -> List[str]: - """Return the shell argv list for the codeanalyzer-go binary.""" - found = shutil.which(_BINARY_NAME) - if found is None: - raise CodeanalyzerExecutionException( - f"'{_BINARY_NAME}' not found on PATH. " - "Build codeanalyzer-go and place the binary on PATH." - ) - return [found] + """Resolve the argv list for the codeanalyzer-go binary. + + Resolution order: ``$CODEANALYZER_GO_BIN`` override, then the prebuilt binary + shipped in the ``codeanalyzer-go`` PyPI package, then the ``codeanalyzer-go`` / + ``cango`` command on ``PATH``. + """ + # 1. Explicit override (e.g. a locally built binary). + env_bin = os.environ.get("CODEANALYZER_GO_BIN") + if env_bin: + return shlex.split(env_bin) + + # 2. Prebuilt binary shipped inside the `codeanalyzer-go` PyPI package (platform + # wheel), mirroring how the Python/TypeScript backends depend on their analyzers. + try: + import codeanalyzer_go + + return [str(codeanalyzer_go.bin_path())] + except (ModuleNotFoundError, FileNotFoundError): + pass + + # 3. `codeanalyzer-go` (canonical) or `cango` on PATH — Homebrew, shell installer, + # or a `go build` install. `codeanalyzer-go` is an alias for `cango`. + for name in (_BINARY_NAME, "cango"): + found = shutil.which(name) + if found is not None: + return [found] + + raise CodeanalyzerExecutionException( + "codeanalyzer-go binary not found. Install it with `pip install codeanalyzer-go`, " + "place `codeanalyzer-go` or `cango` on PATH, or set $CODEANALYZER_GO_BIN." + ) # ── Init / cache check ───────────────────────────────────────────────────── diff --git a/tests/analysis/go/test_go_e2e.py b/tests/analysis/go/test_go_e2e.py index ce839a7..92b2419 100644 --- a/tests/analysis/go/test_go_e2e.py +++ b/tests/analysis/go/test_go_e2e.py @@ -16,9 +16,10 @@ """End-to-end tests for GoAnalysis against the cldk-e2e Go fixture. -Requires ``codeanalyzer-go`` on PATH (built from codeanalyzer-go repo and -installed to e.g. ~/.local/bin). Tests are skipped automatically when the -binary is absent, so CI without Go toolchain does not break. +Requires the ``codeanalyzer-go`` analyzer to be resolvable — via ``pip install +codeanalyzer-go``, the ``codeanalyzer-go``/``cango`` command on PATH, or +``$CODEANALYZER_GO_BIN``. Tests are skipped automatically when it is absent, so +CI without the analyzer does not break. Fixture: tests/resources/go/application/ calc/calc.go — Calculator struct, Operator interface, exported/unexported methods, @@ -31,6 +32,7 @@ All assertions reference exact values from the JSON the binary emits. """ +import os import shutil from pathlib import Path @@ -46,9 +48,24 @@ FIXTURE_DIR = Path(__file__).parent.parent.parent / "resources" / "go" / "application" + +def _codeanalyzer_available() -> bool: + """Mirror GoCodeanalyzer's resolution order: env override, PyPI package, PATH.""" + if os.environ.get("CODEANALYZER_GO_BIN"): + return True + try: + import codeanalyzer_go + + codeanalyzer_go.bin_path() + return True + except Exception: + pass + return shutil.which("codeanalyzer-go") is not None or shutil.which("cango") is not None + + pytestmark = pytest.mark.skipif( - shutil.which("codeanalyzer-go") is None, - reason="codeanalyzer-go not found on PATH — install from codeanalyzer-go repo", + not _codeanalyzer_available(), + reason="codeanalyzer-go not resolvable — pip install codeanalyzer-go, put codeanalyzer-go/cango on PATH, or set $CODEANALYZER_GO_BIN", ) From d9a31ac67c4a0b7b6e3d5215a4190dd772eeaee2 Mon Sep 17 00:00:00 2001 From: Saurabh Sinha Date: Sun, 5 Jul 2026 08:07:58 -0400 Subject: [PATCH 7/7] docs(go): record Go support in README, CHANGELOG, and agent guidance The Go feature PR added the language but left it out of several docs. This closes those gaps: - CLAUDE.md (and its AGENTS.md/GEMINI.md symlinks): add the Go row to the supported-languages table, per the table's own rule that adding a language must update it in the same change. - README.md: include go/packages+go/types in the engine list and add Go to the "same ergonomic interface" sentence, the deprecation-note factory-method list, and the data-models list. Fold in C at those same three prose spots, which were also missing it. - CHANGELOG.md: add an [Unreleased] > Added entry describing Go support. Signed-off-by: Saurabh Sinha --- CHANGELOG.md | 8 ++++++++ CLAUDE.md | 1 + README.md | 6 +++--- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c16ae5..96f1303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Go language support** via the `codeanalyzer-go` subprocess backend. New `CLDK.go()` factory + method returns a `GoAnalysis` facade backed by the `GoAnalysisBackend` ABC (parity with + Java/Python/TypeScript), with a `GoCodeanalyzer` local backend that shells out to the + `codeanalyzer-go` binary on `PATH`. Exposes symbol table, call graph, type, and caller/callee + queries over `cldk.models.go` Pydantic models. Supports `analysis_level`, `eager`, and + `target_files`. The binary must be installed and on `PATH`. + ## [v1.4.0] - 2026-06-27 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index ea15a2e..23ddabe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ an optional read-only Neo4j backend — selected by the *type* of the `backend=` | Python | `CLDK.python(...)` | `PyCodeanalyzer` (in-process `codeanalyzer-python`) | `PyNeo4jBackend` | re-exported from `codeanalyzer-python` | | TypeScript | `CLDK.typescript(...)` | `TSCodeanalyzer` (`codeanalyzer-typescript` binary, subprocess) | `TSNeo4jBackend` | `cldk/models/typescript/` | | C | `CLDK.c(...)` | libclang (in-process, syntactic only) | — | `cldk/models/c/` | +| Go | `CLDK.go(...)` | `GoCodeanalyzer` (`codeanalyzer-go` binary, subprocess) | — | `cldk/models/go/` | The legacy `CLDK(language="").analysis(...)` entry still works as a compat shim. Adding a language means a new factory method + facade + backend ABC/impl(s) + models + tests — **update this diff --git a/README.md b/README.md index bfc81ab..7a4b555 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ **A unified, multilingual program-analysis SDK for Code LLMs.** CLDK turns raw source code into structured, LLM-ready program facts — symbol tables, call graphs, type hierarchies, and more — behind a single Python API, so you can build analysis-augmented LLM pipelines without wrangling a different static-analysis tool for every language. -Under the hood, CLDK orchestrates mature analysis engines (WALA, Tree-sitter, Jedi, PyCG, ts-morph) and normalizes their output into consistent, typed [Pydantic](https://docs.pydantic.dev/) models. You get the same ergonomic interface whether you are analyzing Java, Python, or TypeScript. +Under the hood, CLDK orchestrates mature analysis engines (WALA, Tree-sitter, Jedi, PyCG, ts-morph, `go/packages` + `go/types`) and normalizes their output into consistent, typed [Pydantic](https://docs.pydantic.dev/) models. You get the same ergonomic interface whether you are analyzing Java, Python, TypeScript, C, or Go. CLDK is: @@ -117,7 +117,7 @@ classes = analysis.get_all_classes() > **`project_path` with the Neo4j backend:** it's **optional** — the graph is read over Bolt, so you can omit it as shown above. CLDK validates `project_path` only when you actually pass one (it must exist and be a directory, on every backend); passing `None` skips that check. Supply a real path only if you also need on-disk source access (e.g. file content/snippets) alongside the graph. -> **Deprecation:** the old `CLDK(language="java").analysis(...)` entry point still works as a thin compatibility shim (it emits a `DeprecationWarning`). Prefer the `CLDK.java()` / `CLDK.python()` / `CLDK.typescript()` factory methods. +> **Deprecation:** the old `CLDK(language="java").analysis(...)` entry point still works as a thin compatibility shim (it emits a `DeprecationWarning`). Prefer the `CLDK.java()` / `CLDK.python()` / `CLDK.typescript()` / `CLDK.c()` / `CLDK.go()` factory methods. ## Supported Languages & Backends @@ -159,7 +159,7 @@ graph TD T -. read-only .-> N ``` -**Data models** — each language has its own set of Pydantic models under `cldk.models` (`cldk.models.java`, `cldk.models.python`, `cldk.models.typescript`). They give you structured, typed, dot-accessible representations of classes, methods, fields, and statements, with JSON serialization and shared conventions across languages. +**Data models** — each language has its own set of Pydantic models under `cldk.models` (`cldk.models.java`, `cldk.models.python`, `cldk.models.typescript`, `cldk.models.c`, `cldk.models.go`). They give you structured, typed, dot-accessible representations of classes, methods, fields, and statements, with JSON serialization and shared conventions across languages. **Analysis backends** — each language has a backend under `cldk.analysis.` that coordinates its engine (see the table above) and maps the result onto the data models. The read-only Neo4j backends (`cldk.analysis..neo4j`) reconstruct the *same* models from a Cypher graph, so they are drop-in interchangeable with the in-process analyzers. Backends are orchestrated internally; you only call high-level methods such as `get_symbol_table()`, `get_method_body(...)`, and `get_call_graph(...)`, and CLDK handles tool coordination, parsing, and marshalling under the hood.